# -*- coding: utf-8 -*- import streamlit as st import json # import os # Not needed import graphviz from datetime import datetime import re import uuid # For generating unique IDs import random # For quiz options shuffling import google.generativeai as genai # Gemini API from collections import defaultdict # For timeline and sibling grouping import firebase_admin from firebase_admin import credentials, db from copy import deepcopy # For comparing data states from dictdiffer import diff # Using dictdiffer for easier comparison # --- Configuration & Constants --- DEFAULT_PROFILE = { "profile": {"name": "", "dob": "", "gender": "Unknown", "phone": ""}, "family_members": [], "relationships": [], "settings": {"theme": "Default", "privacy": "Private"}, # Removed collaborators from here "metadata": {"owner_phone": "", "tree_name": "My Family Tree", "created_at": ""} } DEFAULT_MEMBER_STRUCTURE = { "id": "", "name": "", "dob": "", "dod": "", "gender": "Unknown", "phone": "", "stories": [], "totem": "", "created_at": "", "created_by": "", "last_edited_at": "", "last_edited_by": "" } DEFAULT_STORY_STRUCTURE = { "timestamp": "", "text": "", "added_by": "" } # DO NOT CHANGE THIS TITLE/NAME AS PER USER INSTRUCTION GEMINI_MODEL_NAME = "gemini-2.0-flash" # --- Firebase Initialization --- Firebase_DB_URL = st.secrets.get("Firebase_DB") Firebase_Credentials_JSON = st.secrets.get("FIREBASE") firebase_app = None firebase_db_ref = None firebase_error = None try: if Firebase_Credentials_JSON and Firebase_DB_URL: credentials_json = json.loads(Firebase_Credentials_JSON) if not firebase_admin._apps: cred = credentials.Certificate(credentials_json) firebase_app = firebase_admin.initialize_app(cred, {'databaseURL': Firebase_DB_URL}) firebase_db_ref = db.reference('/') # Root reference print("Firebase Admin SDK initialized successfully.") else: firebase_app = firebase_admin.get_app() firebase_db_ref = db.reference('/') print("Firebase Admin SDK already initialized.") else: firebase_error = "Firebase secrets (Firebase_DB, FIREBASE) missing." print(firebase_error) except Exception as e: firebase_error = f"Error initializing Firebase: {e}" print(firebase_error) # --- Gemini API Client Initialization --- genai_client = None api_key_error = False try: api_key = st.secrets.get("GOOGLE_API_KEY") if not api_key: api_key_error = True else: genai.configure(api_key=api_key); genai_client = genai except Exception as e: api_key_error = True # --- Helper Functions --- def normalize_phone(phone): """Normalizes phone to E.164-like format (e.g., +12223334444). Returns None if invalid.""" if not phone: return None phone_str = str(phone).strip() # Basic check: starts with +, followed by digits if not re.match(r"^\+\d{5,}$", phone_str): return None digits = "".join(filter(str.isdigit, phone_str)) return f"+{digits}" def get_user_db_path(owner_phone): """Gets the Firebase path for a user's primary tree data.""" normalized = normalize_phone(owner_phone) if not normalized: return None # Replace invalid Firebase path characters (like '+') if necessary, # but E.164 is generally safe. Let's assume '+' is okay for now. # If issues arise, replace '+' with something else or use a different key structure. return f'users/{normalized}' def get_phone_index_path(member_phone): """Gets the Firebase path for the phone index entry.""" normalized = normalize_phone(member_phone) if not normalized: return None return f'phone_index/{normalized}' def get_pending_changes_path(owner_phone, proposer_phone=None): """Gets the Firebase path for pending changes.""" norm_owner = normalize_phone(owner_phone) if not norm_owner: return None base_path = f'pending_changes/{norm_owner}' if proposer_phone: norm_proposer = normalize_phone(proposer_phone) if not norm_proposer: return None return f'{base_path}/{norm_proposer}' return base_path # --- Data Loading --- def load_tree_data(owner_phone): """Loads a specific tree's data from Firebase.""" if firebase_error or not firebase_db_ref: return None user_path = get_user_db_path(owner_phone) if not user_path: return None try: data = firebase_db_ref.child(user_path).get() if data and isinstance(data, dict): merged_data = deepcopy(DEFAULT_PROFILE) if "metadata" not in data or not isinstance(data["metadata"], dict): data["metadata"] = {} merged_data.update(data) merged_data["settings"] = {**DEFAULT_PROFILE["settings"], **data.get("settings", {})} merged_data["metadata"] = {**DEFAULT_PROFILE["metadata"], **data.get("metadata", {})} merged_data["metadata"]["owner_phone"] = owner_phone updated_members = [] for member_data in merged_data.get("family_members", []): if isinstance(member_data, dict): complete_member = deepcopy(DEFAULT_MEMBER_STRUCTURE) member_data.pop('photo_path', None) # Remove legacy field if present complete_member.update(member_data) updated_members.append(complete_member) merged_data["family_members"] = updated_members if not isinstance(merged_data.get("relationships"), list): merged_data["relationships"] = [] for member in merged_data["family_members"]: if "stories" not in member or not isinstance(member["stories"], list): member["stories"] = [] cleaned_stories = [] for story in member.get("stories", []): if isinstance(story, dict): complete_story = deepcopy(DEFAULT_STORY_STRUCTURE) complete_story.update(story) cleaned_stories.append(complete_story) member["stories"] = cleaned_stories return merged_data elif data is None: new_tree = deepcopy(DEFAULT_PROFILE) new_tree["metadata"]["owner_phone"] = owner_phone new_tree["metadata"]["created_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") owner_me_node = deepcopy(DEFAULT_MEMBER_STRUCTURE) owner_me_node.update({ "id": "Me", "name": "Me", "phone": owner_phone, "created_at": new_tree["metadata"]["created_at"], "created_by": owner_phone }) new_tree["family_members"].append(owner_me_node) new_tree["profile"]["phone"] = owner_phone return new_tree else: st.error(f"Invalid data format found for tree owner {owner_phone}.") return None except Exception as e: st.error(f"Error loading tree data for {owner_phone}: {e}") return None def find_linked_trees(member_phone): """Queries the phone_index to find trees containing the member_phone.""" if firebase_error or not firebase_db_ref: return {} index_path = get_phone_index_path(member_phone) if not index_path: return {} try: owner_phones_dict = firebase_db_ref.child(index_path).child("trees").get() return owner_phones_dict if owner_phones_dict and isinstance(owner_phones_dict, dict) else {} except Exception as e: st.error(f"Error querying phone index for {member_phone}: {e}") return {} # --- Index Update Logic --- def update_phone_index(owner_phone, previous_members, current_members): """Updates the phone_index based on member changes.""" if firebase_error or not firebase_db_ref: return False norm_owner_phone = normalize_phone(owner_phone) if not norm_owner_phone: return False prev_phones = {normalize_phone(p.get('phone')) for p in previous_members if isinstance(p, dict) and normalize_phone(p.get('phone'))} curr_phones = {normalize_phone(p.get('phone')) for p in current_members if isinstance(p, dict) and normalize_phone(p.get('phone'))} phones_added = curr_phones - prev_phones phones_removed = prev_phones - curr_phones updates = {} for phone in phones_added: if phone: index_entry_path = f"{get_phone_index_path(phone)}/trees/{norm_owner_phone}" updates[index_entry_path] = True for phone in phones_removed: if phone: index_entry_path = f"{get_phone_index_path(phone)}/trees/{norm_owner_phone}" updates[index_entry_path] = None if not updates: return True try: firebase_db_ref.update(updates) return True except Exception as e: st.error(f"Error updating phone index: {e}") st.error(f"Failed index updates: {json.dumps(updates)}") return False # --- Data Saving (Owner) --- def save_tree_data(owner_phone, current_data, previous_data): """Saves owner's tree data and updates the phone index.""" if firebase_error or not firebase_db_ref: st.error(f"Firebase error: {firebase_error or 'Not initialized'}. Cannot save.") return False user_path = get_user_db_path(owner_phone) if not user_path: st.error("Invalid owner phone format for saving.") return False # Prepare tree data update_totems(current_data) current_data["metadata"]["owner_phone"] = owner_phone if "family_members" in current_data: for member in current_data["family_members"]: if isinstance(member, dict): member.pop('photo_path', None) # Save main tree data try: firebase_db_ref.child(user_path).set(current_data) except Exception as e: st.error(f"Error saving tree data to {user_path}: {e}") return False # Update phone index prev_members = previous_data.get("family_members", []) if previous_data else [] curr_members = current_data.get("family_members", []) if not update_phone_index(owner_phone, prev_members, curr_members): st.warning("Tree data saved, but failed to update phone index. Index may be inconsistent.") return False # Indicate partial failure return True # --- Collaboration Functions --- def propose_changes(owner_phone, proposer_phone, proposed_data): """Saves the collaborator's proposed tree state.""" if firebase_error or not firebase_db_ref: st.error(f"Firebase error: {firebase_error or 'Not initialized'}. Cannot propose changes.") return False pending_path = get_pending_changes_path(owner_phone, proposer_phone) if not pending_path: st.error("Invalid owner or proposer phone format for proposing changes.") return False # Add metadata to the proposal proposal_payload = { "proposed_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "proposer_phone": proposer_phone, "tree_data": proposed_data # Store the entire proposed tree } try: firebase_db_ref.child(pending_path).set(proposal_payload) st.success(f"Changes proposed successfully to owner (...{owner_phone[-4:]}).") return True except Exception as e: st.error(f"Error proposing changes to {pending_path}: {e}") return False def load_pending_changes(owner_phone): """Loads all pending change proposals for a given owner.""" if firebase_error or not firebase_db_ref: return {} pending_base_path = get_pending_changes_path(owner_phone) if not pending_base_path: return {} try: data = firebase_db_ref.child(pending_base_path).get() return data if data and isinstance(data, dict) else {} except Exception as e: st.error(f"Error loading pending changes for {owner_phone}: {e}") return {} def accept_changes(owner_phone, proposer_phone): """Accepts changes: loads proposal, saves it as main tree, updates index, deletes proposal.""" if firebase_error or not firebase_db_ref: return False pending_path = get_pending_changes_path(owner_phone, proposer_phone) if not pending_path: return False try: # 1. Load the proposal proposal_payload = firebase_db_ref.child(pending_path).get() if not proposal_payload or "tree_data" not in proposal_payload: st.error(f"Proposal from {proposer_phone} not found or invalid.") return False accepted_data = proposal_payload["tree_data"] # 2. Load the owner's current data (for index comparison) previous_data = load_tree_data(owner_phone) if previous_data is None: st.error("Failed to load current owner data before accepting changes.") return False # Avoid overwriting if current state is unknown # 3. Save the accepted data as the main tree data (using save_tree_data for index update) if save_tree_data(owner_phone, accepted_data, previous_data): # 4. Delete the pending proposal firebase_db_ref.child(pending_path).delete() st.success(f"Changes from {proposer_phone} accepted and merged.") return True else: # save_tree_data already showed an error st.error("Failed to save accepted changes or update index. Proposal not deleted.") return False except Exception as e: st.error(f"Error accepting changes from {proposer_phone}: {e}") return False def reject_changes(owner_phone, proposer_phone): """Rejects and deletes a pending change proposal.""" if firebase_error or not firebase_db_ref: return False pending_path = get_pending_changes_path(owner_phone, proposer_phone) if not pending_path: return False try: firebase_db_ref.child(pending_path).delete() st.success(f"Changes from {proposer_phone} rejected.") return True except Exception as e: st.error(f"Error rejecting changes from {proposer_phone}: {e}") return False def generate_diff_summary(current_data, proposed_data): """Generates a human-readable summary of differences between two tree data dicts.""" summary = [] try: # Use dictdiffer to find changes result = list(diff(current_data, proposed_data, ignore={'last_edited_at', 'last_edited_by', 'created_at', 'created_by'})) # Ignore timestamps/editors for diff summary # Process members current_members = {m['id']: m for m in current_data.get('family_members', []) if isinstance(m, dict)} proposed_members = {m['id']: m for m in proposed_data.get('family_members', []) if isinstance(m, dict)} added_members = set(proposed_members.keys()) - set(current_members.keys()) deleted_members = set(current_members.keys()) - set(proposed_members.keys()) common_members = set(current_members.keys()) & set(proposed_members.keys()) if added_members: summary.append("**Members Added:**") for mid in added_members: summary.append(f"- {proposed_members[mid].get('name', 'Unnamed')} (ID: ...{mid[-6:]})") if deleted_members: summary.append("**Members Deleted:**") for mid in deleted_members: summary.append(f"- {current_members[mid].get('name', 'Unnamed')} (ID: ...{mid[-6:]})") modified_member_summary = [] for mid in common_members: member_diff = list(diff(current_members[mid], proposed_members[mid], ignore={'last_edited_at', 'last_edited_by', 'created_at', 'created_by', 'stories'})) # Ignore stories here, handle separately if member_diff: changes = [] for change_type, path, values in member_diff: field = path if isinstance(path, str) else path[0] # Get field name if change_type == 'change': changes.append(f"{field}: '{values[0]}' -> '{values[1]}'") elif change_type == 'add': changes.append(f"{field}: added '{values}'") # Should be rare at top level elif change_type == 'remove': changes.append(f"{field}: removed '{values}'") # Should be rare at top level if changes: modified_member_summary.append(f"- **{current_members[mid].get('name', 'Unnamed')}**: {'; '.join(changes)}") if modified_member_summary: summary.append("**Members Modified:**") summary.extend(modified_member_summary) # Process Relationships (Simplified comparison) current_rels = {(r.get('from_id'), r.get('to_id'), r.get('type')) for r in current_data.get('relationships', []) if isinstance(r, dict)} proposed_rels = {(r.get('from_id'), r.get('to_id'), r.get('type')) for r in proposed_data.get('relationships', []) if isinstance(r, dict)} added_rels = proposed_rels - current_rels deleted_rels = current_rels - proposed_rels id_to_name_current = {m['id']: m.get('name', 'Unknown') for m in current_data.get('family_members', [])} id_to_name_proposed = {m['id']: m.get('name', 'Unknown') for m in proposed_data.get('family_members', [])} def format_rel(rel_tuple, names): f_id, t_id, r_type = rel_tuple f_name = names.get(f_id, f"...{f_id[-6:]}") t_name = names.get(t_id, f"...{t_id[-6:]}") return f"{f_name} {r_type} {t_name}" if added_rels: summary.append("**Relationships Added:**") for rel in added_rels: summary.append(f"- {format_rel(rel, id_to_name_proposed)}") if deleted_rels: summary.append("**Relationships Deleted:**") for rel in deleted_rels: summary.append(f"- {format_rel(rel, id_to_name_current)}") # Process Stories (Simplified: check if stories list changed for any common member) story_changes = [] for mid in common_members: current_stories = current_members[mid].get('stories', []) proposed_stories = proposed_members[mid].get('stories', []) # Simple check: compare lengths or string representations (not perfect but gives an idea) if json.dumps(current_stories, sort_keys=True) != json.dumps(proposed_stories, sort_keys=True): story_changes.append(f"- Stories for **{current_members[mid].get('name', 'Unnamed')}**") if story_changes: summary.append("**Stories Modified:**") summary.extend(story_changes) # Check top-level profile/settings/metadata changes (optional) # ... add checks for profile, settings, metadata if needed ... except Exception as e: summary.append(f"Error generating diff: {e}") return "\n".join(summary) if summary else "No significant changes detected." # --- find_person_by_id, find_person_by_name, generate_unique_id (remain same) --- def find_person_by_id(data, person_id): for person in data.get("family_members", []): if isinstance(person, dict) and person.get("id") == person_id: return person return None def find_person_by_name(data, name): if not name: return None normalized_name = name.strip().lower() matching_members = [ person for person in data.get("family_members", []) if isinstance(person, dict) and person.get("name", "").strip().lower() == normalized_name ] # Return list of matches for name detection, or first match if only one expected return matching_members if matching_members else [] def generate_unique_id(data=None): return uuid.uuid4().hex # --- Graphviz, AI Functions (remain same logic) --- # (generate_graphviz, format_node_label, get_father_id, update_totems) # (safe_json_loads, call_gemini, generate_tree_from_description_gemini, generate_quiz_questions_gemini) # --- [Include the full code for these functions here - they are unchanged from the previous version] --- def format_node_label(person): label = f"{person.get('name', 'Unknown')}" details = [] if person.get('dob'): details.append(f"b. {person.get('dob')}") if person.get('dod'): details.append(f"d. {person.get('dod')}") if details: label += f"\n({' / '.join(details)})" if person.get('totem'): label += f"\nTotem: {person.get('totem')}" return label def get_father_id(person_id, relationships, members): id_to_person = {p['id']: p for p in members if isinstance(p, dict)} for rel in relationships: if isinstance(rel, dict) and rel.get('type') == 'parent' and rel.get('to_id') == person_id: parent_id = rel.get('from_id') parent = id_to_person.get(parent_id) if parent and parent.get('gender', '').lower() == 'male': return parent_id return None def update_totems(user_data): members = user_data.get("family_members", []) relationships = user_data.get("relationships", []) if not members or not relationships: return id_to_person = {p['id']: p for p in members if isinstance(p, dict)} visited = set(); processed_in_queue = set() all_child_ids = {r['to_id'] for r in relationships if isinstance(r, dict) and r.get('type') == 'parent'} # Start BFS from root nodes (those with no parents in this tree) queue = [p['id'] for p in members if isinstance(p, dict) and p['id'] not in all_child_ids] # Add remaining nodes in case of cycles or disconnected components queue.extend([p['id'] for p in members if isinstance(p, dict) and p['id'] not in queue]) processed_count = 0 # Safety break for potential infinite loops max_process = len(members) * 2 + 5 while queue and processed_count < max_process: person_id = queue.pop(0) processed_count += 1 if person_id in visited: continue # Already fully processed # processed_in_queue is not strictly needed with the visited check person = id_to_person.get(person_id) if not person: continue totem_updated = False current_totem = person.get('totem') # Only inherit if totem is explicitly empty or None if current_totem is None or current_totem == "": father_id = get_father_id(person_id, relationships, members) if father_id: father = id_to_person.get(father_id) # Check if father has a totem AND if father has been processed or is a root if father and father.get('totem') and (father_id in visited or father_id not in all_child_ids): person['totem'] = father.get('totem') totem_updated = True # print(f"DEBUG: Inherited totem '{person['totem']}' for {person['name']} from father {father['name']}") # Debug # Mark as visited *after* processing potential inheritance based on parents visited.add(person_id) # Add children to queue if not already visited for rel in relationships: if isinstance(rel, dict) and rel.get('type') == 'parent' and rel.get('from_id') == person_id: child_id = rel.get('to_id') if child_id and child_id not in visited and child_id not in queue: queue.append(child_id) # If totem was updated, re-add children to potentially update their totems later if totem_updated: for rel in relationships: if isinstance(rel, dict) and rel.get('type') == 'parent' and rel.get('from_id') == person_id: child_id = rel.get('to_id') if child_id and child_id in visited: # If child was already visited, needs re-check visited.remove(child_id) if child_id not in queue: queue.append(child_id) # Re-add to queue elif child_id and child_id not in queue: # If not visited and not in queue, add it queue.append(child_id) if processed_count >= max_process: print("WARN: Totem update BFS reached max iterations, potential loop?") def generate_graphviz(data): dot = graphviz.Digraph(comment='Family Tree', format='svg') dot.attr(rankdir='TB', splines='ortho', nodesep='0.6', ranksep='0.8') theme = data.get("settings", {}).get("theme", "Default") node_style = {'shape': 'box', 'style': 'filled', 'margin': '0.1'} edge_color, marriage_node_color = 'gray50', 'gray50' if theme == "Dark": dot.attr(bgcolor='black', fontcolor='white'); node_style.update(fillcolor='grey30', fontcolor='white', color='white'); edge_color, marriage_node_color = 'white', 'white' else: dot.attr(bgcolor='transparent'); node_style.update(fillcolor='lightblue', fontcolor='black', color='black') members = data.get("family_members", []); relationships = data.get("relationships", []) if not members: return dot member_ids = {m['id'] for m in members if isinstance(m, dict)}; id_to_person = {p['id']: p for p in members if isinstance(p, dict)} marriage_nodes = {}; parents_of_child = defaultdict(list); children_by_parent_pair = defaultdict(list) # Add nodes first for person in members: if isinstance(person, dict): dot.node(person['id'], label=format_node_label(person), **node_style) processed_spouses = set() # Process relationships to build structures for rel in relationships: if not isinstance(rel, dict): continue from_id, to_id, rel_type = rel.get("from_id"), rel.get("to_id"), rel.get("type") if from_id not in member_ids or to_id not in member_ids: continue # Skip dangling relationships if rel_type == 'parent': parents_of_child[to_id].append(from_id) elif rel_type == 'spouse': pair = frozenset([from_id, to_id]) if pair not in processed_spouses: m_id = f"m_{uuid.uuid4().hex[:8]}"; marriage_nodes[pair] = m_id # Use UUID for marriage node ID dot.node(m_id, shape='point', width='0.1', height='0.1', label='', color=marriage_node_color) # Link spouses to marriage node invisibly for ranking dot.edge(from_id, m_id, style='invis', dir='none', weight='10') dot.edge(to_id, m_id, style='invis', dir='none', weight='10') # Ensure spouses are ranked together with dot.subgraph(name=f"cluster_m_{m_id}") as sub: sub.attr(rank='same', style='invis') sub.node(from_id); sub.node(to_id) # Add spouses to subgraph processed_spouses.add(pair) # Group children by parent set for child_id, parent_ids_list in parents_of_child.items(): valid_p_ids = sorted([p_id for p_id in parent_ids_list if p_id in member_ids]); if not valid_p_ids: continue parent_key = frozenset(valid_p_ids); children_by_parent_pair[parent_key].append(child_id) # Add edges from parents/marriage node to children processed_child_links = set() for parent_key, children_list in children_by_parent_pair.items(): p_ids = list(parent_key); source_id = None if len(p_ids) == 1: source_id = p_ids[0] # Single parent elif len(p_ids) > 1: source_id = marriage_nodes.get(parent_key) # Find marriage node for couple if source_id: for child_id in children_list: if child_id in member_ids and (source_id, child_id) not in processed_child_links: dot.edge(source_id, child_id, arrowhead='none', color=edge_color); processed_child_links.add((source_id, child_id)) elif len(p_ids) > 1 and not source_id: # Fallback if marriage node wasn't created (shouldn't happen with spouse logic) print(f"WARN: Missing marriage node for parents {p_ids}, linking child {children_list} from first parent.") source_id = p_ids[0] for child_id in children_list: if child_id in member_ids and (source_id, child_id) not in processed_child_links: dot.edge(source_id, child_id, arrowhead='none', color=edge_color); processed_child_links.add((source_id, child_id)) # Rank siblings together for parent_key, siblings in children_by_parent_pair.items(): valid_sibs = [sid for sid in siblings if sid in member_ids] if len(valid_sibs) > 1: with dot.subgraph(name=f"cluster_s_{uuid.uuid4().hex[:8]}") as sub: # Unique cluster name sub.attr(rank='same', style='invis') for sid in sorted(valid_sibs): sub.node(sid) # Add nodes to subgraph return dot def safe_json_loads(text): # Attempts to extract and parse JSON from potentially messy AI responses # Find ```json ... ``` blocks match = re.search(r"```(?:json)?\s*(\{.*\}|\[.*\])\s*```", text, re.DOTALL | re.IGNORECASE) if match: json_text = match.group(1) else: # Fallback: Find first '{' or '[' and last '}' or ']' start_brace = text.find('{') start_bracket = text.find('[') end_brace = text.rfind('}') end_bracket = text.rfind(']') start = -1 if start_brace != -1 and start_bracket != -1: start = min(start_brace, start_bracket) elif start_brace != -1: start = start_brace else: start = start_bracket # Might be -1 if neither found end = -1 # Ensure end matches start type if possible if start == start_brace and end_brace != -1: end = end_brace elif start == start_bracket and end_bracket != -1: end = end_bracket # Fallback if start/end types mismatch or only one end type exists elif end_brace != -1 and end_bracket != -1: end = max(end_brace, end_bracket) elif end_brace != -1: end = end_brace elif end_bracket != -1: end = end_bracket if start != -1 and end != -1 and end > start: json_text = text[start:end+1] else: # Final fallback: assume the whole text is JSON (strip whitespace) json_text = text.strip() # Basic check if it looks like JSON if not ((json_text.startswith('{') and json_text.endswith('}')) or \ (json_text.startswith('[') and json_text.endswith(']'))): st.error(f"πŸ”΄ Could not find JSON structure. Response:\n```\n{text[:500]}...\n```") return None # Clean trailing commas before closing brackets/braces json_text = re.sub(r",\s*(\]|\})", r"\1", json_text) try: return json.loads(json_text) except json.JSONDecodeError as e: st.error(f"πŸ”΄ Error parsing JSON: {e}\nAttempted JSON Text:\n```\n{json_text[:500]}...\n```") return None except Exception as e: # Catch other potential errors st.error(f"πŸ”΄ Unexpected error during JSON parsing: {e}\nResponse Text:\n```\n{text[:500]}...\n```") return None def call_gemini(prompt_text, is_json_output_expected=True): if not genai_client or api_key_error: st.error("πŸ”΄ Gemini API not ready.") return None try: model = genai.GenerativeModel(GEMINI_MODEL_NAME) # Define safety settings to block harmful content safety_settings = [ {"category": "HARM_CATEGORY_HARASSMENT", "threshold": "BLOCK_MEDIUM_AND_ABOVE"}, {"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "BLOCK_MEDIUM_AND_ABOVE"}, {"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "BLOCK_MEDIUM_AND_ABOVE"}, {"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "BLOCK_MEDIUM_AND_ABOVE"}, ] # Configure for JSON output if expected gen_config = genai.types.GenerationConfig( # candidate_count=1, # Default is 1 response_mime_type="application/json" ) if is_json_output_expected else None response = model.generate_content( prompt_text, generation_config=gen_config, safety_settings=safety_settings ) # Check for empty or blocked response if not response.parts: reason = "Unknown" try: # Attempt to get block reason reason = response.prompt_feedback.block_reason.name if hasattr(response, 'prompt_feedback') and response.prompt_feedback.block_reason else None if not reason and response.candidates: reason = response.candidates[0].finish_reason.name # e.g., SAFETY, RECITATION except Exception: pass # Ignore errors trying to get the reason st.warning(f"⚠️ AI response empty or blocked. Reason: {reason}") # print(f"DEBUG: Blocked Response Details: {response}") # Optional detailed debug return None if not hasattr(response, 'text') or not response.text: st.warning("⚠️ AI response text empty.") return None # Parse if JSON expected, otherwise return text if is_json_output_expected: # Use safe_json_loads for robust parsing parsed_json = safe_json_loads(response.text) if parsed_json is None: st.error(f"πŸ”΄ AI response received, but failed to parse as JSON.\nRaw Response:\n```\n{response.text[:500]}...\n```") return parsed_json else: return response.text except Exception as e: st.error(f"πŸ”΄ Gemini API Call Error: {e} (Type: {type(e).__name__})") # print(f"DEBUG: Gemini Error Details: {e}") # Optional detailed debug return None def generate_tree_from_description_gemini(description): prompt = f"""Please analyze the following family description. Extract individuals mentioned, including their name, and if available, their date of birth (dob), date of death (dod), gender (Male/Female/Other/Unknown), and totem. Also, identify direct relationships like parent_of, spouse_of, or sibling_of between these individuals. Format the output as a single JSON object containing two keys: 1. "people": A list of objects, where each object represents an individual and has keys for "name", "dob", "dod", "gender", and "totem" (use null or omit if information is not present). Use the name "Me" if the description uses first-person references without providing a name. 2. "relationships": A list of objects, where each object represents a relationship. Each relationship object should have "person1_name", "person2_name", and "type" (e.g., "parent_of", "spouse_of", "sibling_of"). Ensure the names match exactly those extracted in the "people" list. Strictly output only the JSON object, without any introductory text, explanations, or markdown formatting like ```json. Description: "{description}" JSON Output: """ return call_gemini(prompt, is_json_output_expected=True) def generate_quiz_questions_gemini(members, relationships, num_questions=3): if not members or len(members) < 2: return None id_to_name = {p["id"]: p.get("name", "Unknown") for p in members if isinstance(p, dict)} rel_strings = [] processed_pairs = set() # To avoid duplicate spouse/sibling entries for rel in relationships: if not isinstance(rel, dict): continue p1_id, p2_id, rt = rel.get("from_id"), rel.get("to_id"), rel.get("type") p1n, p2n = id_to_name.get(p1_id), id_to_name.get(p2_id) # Ensure both names exist and are different if p1n and p2n and p1n != p2n: pair = frozenset([p1_id, p2_id]) if rt == "parent": rel_strings.append(f"'{p1n}' is a Parent of '{p2n}'") elif rt == "spouse" and pair not in processed_pairs: rel_strings.append(f"'{p1n}' is a Spouse of '{p2n}'") processed_pairs.add(pair) elif rt == "sibling" and pair not in processed_pairs: # Siblings might be implicitly defined via parents, but add explicit ones too rel_strings.append(f"'{p1n}' is a Sibling of '{p2n}'") processed_pairs.add(pair) if not rel_strings: st.warning("No relationships found to base quiz questions on.") return None unique_names = set(id_to_name.values()) min_required_names = 4 # Need at least 4 unique names for good multiple choice actual_num_questions = num_questions # Adjust question count or warn if not enough unique names for options if len(unique_names) < min_required_names: if len(unique_names) >= 2: actual_num_questions = max(1, len(unique_names) - 1) # Reduce questions if few names st.info(f"Note: Reduced quiz questions to {actual_num_questions} due to fewer than {min_required_names} unique names.") else: st.warning("Need at least 2 unique names in the tree to generate a quiz.") return None prompt = f"""Based *only* on the provided list of family relationships and names, generate {actual_num_questions} unique multiple-choice quiz questions. Each question should test knowledge about one specific relationship. The output format must be a single JSON list, where each element is an object representing a question: `{{"text": "Question text?", "options": ["Correct Answer", "Wrong Option 1", "Wrong Option 2", "Wrong Option 3"], "correct": "Correct Answer"}}` - The "options" list must contain the correct answer and 3 incorrect answers chosen from the provided 'Names' list. - All options within a single question must be unique names. - If fewer than 4 unique names are available in the 'Names' list, use all available names as options, ensuring the correct answer is included. - Do not invent relationships or information not present in the 'Relationships' list. - Ensure the 'correct' value exactly matches one of the strings in the 'options' list. - Strictly output only the JSON list, without any other text or markdown formatting. Names: {chr(10).join(f"- {name}" for name in sorted(list(unique_names)))} Relationships: {chr(10).join(f"- {rel}" for rel in rel_strings)} JSON Output: """ return call_gemini(prompt, is_json_output_expected=True) # --- End of unchanged functions --- # --- Streamlit App UI --- st.set_page_config(layout="wide", page_title="Family Tree Collab") # --- State Management --- # User/Login State if 'logged_in' not in st.session_state: st.session_state.logged_in = False if 'current_user_phone' not in st.session_state: st.session_state.current_user_phone = None # Tree Data State if 'primary_tree_data' not in st.session_state: st.session_state.primary_tree_data = None if 'linked_trees_data' not in st.session_state: st.session_state.linked_trees_data = {} # Dict: {owner_phone: tree_data} if 'active_tree_owner_phone' not in st.session_state: st.session_state.active_tree_owner_phone = None # Phone of the owner of the tree being viewed # Collaboration State if 'pending_changes_for_review' not in st.session_state: st.session_state.pending_changes_for_review = {} # Loaded pending changes for owner if 'selected_proposal_to_review' not in st.session_state: st.session_state.selected_proposal_to_review = None # Key (proposer_phone) of proposal being reviewed # UI/Feature State if 'ai_quiz_questions' not in st.session_state: st.session_state.ai_quiz_questions = None if 'current_quiz_question_index' not in st.session_state: st.session_state.current_quiz_question_index = -1 if 'quiz_score' not in st.session_state: st.session_state.quiz_score = 0 if 'quiz_answered' not in st.session_state: st.session_state.quiz_answered = False if 'ai_description_result' not in st.session_state: st.session_state.ai_description_result = None if 'ai_analyze_button_clicked' not in st.session_state: st.session_state.ai_analyze_button_clicked = False # Add state to hold temporary edits for collaborators before proposing if 'collaborator_edited_data' not in st.session_state: st.session_state.collaborator_edited_data = None # State for Name Conflict Detection if 'show_add_name_conflict_warning' not in st.session_state: st.session_state.show_add_name_conflict_warning = False if 'add_conflicting_members' not in st.session_state: st.session_state.add_conflicting_members = [] if 'add_new_member_data_staged' not in st.session_state: st.session_state.add_new_member_data_staged = None # Temp storage for member data during conflict resolution if 'confirm_add_different_person' not in st.session_state: st.session_state.confirm_add_different_person = False if 'show_edit_name_conflict_warning' not in st.session_state: st.session_state.show_edit_name_conflict_warning = False if 'edit_conflicting_members' not in st.session_state: st.session_state.edit_conflicting_members = [] if 'edit_member_data_staged' not in st.session_state: st.session_state.edit_member_data_staged = None # Temp storage for member data during conflict resolution if 'confirm_edit_name_change' not in st.session_state: st.session_state.confirm_edit_name_change = False if 'editing_member_id_conflict' not in st.session_state: st.session_state.editing_member_id_conflict = None # ID of member being edited during conflict # --- Login/Register --- if not st.session_state.logged_in: st.header("🌴Welcome to the Misheck's Linked Family Tree App!") st.markdown("*(Login with your phone number to see your tree and trees you are part of πŸ”₯)*") if firebase_error: st.error(f"πŸ”΄ Firebase Error: {firebase_error}") else: st.success("βœ… Firebase Database Connected") if api_key_error: st.warning("⚠️ Gemini API Key Missing/Invalid.") else: st.success("βœ… Gemini AI Is Ready") phone_input = st.text_input("Enter Phone Number (e.g., +263777777777):", key="login_phone") login_button = st.button("Login / Register") if login_button and phone_input: normalized_phone = normalize_phone(phone_input) if not normalized_phone: st.error("Invalid phone format. Use international format starting with '+'. (e.g., +263777777777)") else: st.session_state.current_user_phone = normalized_phone st.session_state.active_tree_owner_phone = normalized_phone # Default to viewing own tree # Load primary tree with st.spinner(f"Loading primary tree for {normalized_phone}..."): primary_tree = load_tree_data(normalized_phone) if primary_tree is None: st.error("Failed to load or initialize primary tree data. Cannot log in.") st.stop() st.session_state.primary_tree_data = primary_tree # If it was a new tree, save it immediately # Check if 'Me' node exists and has creation info - indicates it might be new me_node = find_person_by_id(primary_tree, "Me") is_potentially_new = not me_node or not me_node.get("created_at") if is_potentially_new and primary_tree["metadata"]["created_at"]: print(f"Attempting to save initial tree structure for {normalized_phone}") # Pass None as previous_data for initial save if not save_tree_data(normalized_phone, primary_tree, None): st.error("Failed to save initial tree structure. Cannot log in.") st.stop() else: print(f"Saved initial tree structure for {normalized_phone}") # Reload data after initial save to ensure consistency? Optional. # st.session_state.primary_tree_data = load_tree_data(normalized_phone) # Find and load linked trees st.session_state.linked_trees_data = {} with st.spinner(f"Checking for linked trees containing {normalized_phone}..."): linked_owner_phones = find_linked_trees(normalized_phone) if linked_owner_phones: st.info(f"You appear in {len(linked_owner_phones)} other tree(s). Loading them...") for owner_phone in linked_owner_phones.keys(): if owner_phone != normalized_phone: # Don't reload own tree print(f"Loading linked tree owned by: {owner_phone}") # Debug linked_tree = load_tree_data(owner_phone) if linked_tree: st.session_state.linked_trees_data[owner_phone] = linked_tree else: st.warning(f"Could not load linked tree owned by {owner_phone}.") else: print(f"No linked trees found for {normalized_phone}") # Debug st.session_state.logged_in = True # Reset other states st.session_state.ai_quiz_questions = None; st.session_state.current_quiz_question_index = -1; st.session_state.pending_changes_for_review = {}; st.session_state.selected_proposal_to_review = None; st.session_state.collaborator_edited_data = None; # Reset name conflict states st.session_state.show_add_name_conflict_warning = False; st.session_state.add_conflicting_members = []; st.session_state.add_new_member_data_staged = None; st.session_state.confirm_add_different_person = False st.session_state.show_edit_name_conflict_warning = False; st.session_state.edit_conflicting_members = []; st.session_state.edit_member_data_staged = None; st.session_state.confirm_edit_name_change = False; st.session_state.editing_member_id_conflict = None st.rerun() elif login_button: st.error("Phone number required.") # --- Main App Interface (After Login) --- else: current_user = st.session_state.current_user_phone primary_data = st.session_state.primary_tree_data linked_data = st.session_state.linked_trees_data # --- Sidebar --- st.sidebar.header(f"Logged in: {current_user}") profile_name_display = primary_data.get('profile', {}).get('name', '') if primary_data else '(No Profile)' st.sidebar.markdown(f"**My Name:** {profile_name_display or '(Set in Profile)'}") st.sidebar.divider() # --- Tree Selector --- tree_options = {} if primary_data: # Ensure primary data loaded before adding to options tree_options[current_user] = primary_data.get("metadata", {}).get("tree_name", "My Tree") + " (Yours)" for owner_phone, tree_data in linked_data.items(): tree_name = tree_data.get("metadata", {}).get("tree_name", f"Tree by ...{owner_phone[-4:]}") tree_options[owner_phone] = tree_name # Ensure active tree is valid, default to primary if not or if no options exist if not tree_options: st.error("Error: No trees available to view.") st.stop() if st.session_state.active_tree_owner_phone not in tree_options: st.session_state.active_tree_owner_phone = current_user # Default to own tree active_owner_phone = st.sidebar.selectbox( "Select Tree to View:", options=list(tree_options.keys()), format_func=lambda phone: tree_options[phone], key="active_tree_selector", index=list(tree_options.keys()).index(st.session_state.active_tree_owner_phone) # Set initial index ) # Update state if selection changes if active_owner_phone != st.session_state.active_tree_owner_phone: st.session_state.active_tree_owner_phone = active_owner_phone st.session_state.collaborator_edited_data = None # Clear collaborator edits when switching trees # Clear name conflict states when switching trees st.session_state.show_add_name_conflict_warning = False; st.session_state.add_conflicting_members = []; st.session_state.add_new_member_data_staged = None; st.session_state.confirm_add_different_person = False st.session_state.show_edit_name_conflict_warning = False; st.session_state.edit_conflicting_members = []; st.session_state.edit_member_data_staged = None; st.session_state.confirm_edit_name_change = False; st.session_state.editing_member_id_conflict = None st.rerun() # Rerun to load the correct tree's data into view st.sidebar.divider() # --- Get Active Tree Data --- is_viewing_own_tree = (active_owner_phone == current_user) active_tree_data_source = None # Where the data comes from (primary, linked, or collaborator edit cache) if is_viewing_own_tree: active_tree_data_source = primary_data active_tree_name = tree_options.get(current_user, "My Tree") elif active_owner_phone in linked_data: # If collaborator has started editing, use their cached version if st.session_state.collaborator_edited_data and st.session_state.collaborator_edited_data.get("owner_phone") == active_owner_phone: active_tree_data_source = st.session_state.collaborator_edited_data["data"] st.info("You have unsaved proposed changes for this tree.") else: # Otherwise, load the read-only version from linked_data active_tree_data_source = linked_data[active_owner_phone] active_tree_name = tree_options.get(active_owner_phone, f"Tree by ...{active_owner_phone[-4:]}") else: st.error("Error: Could not find data for the selected tree. Defaulting to 'My Tree'.") active_tree_data_source = primary_data active_tree_name = tree_options.get(current_user, "My Tree") st.session_state.active_tree_owner_phone = current_user # Reset state is_viewing_own_tree = True # Make a deep copy to work with, especially important for collaborators active_tree_data = deepcopy(active_tree_data_source) if active_tree_data_source else None if active_tree_data is None: st.error(f"Failed to load data for tree: {active_tree_name}. Please try reloading or contacting support.") st.stop() # Display whose tree is being viewed if not is_viewing_own_tree: st.info(f"Viewing: **{active_tree_name}** (Owned by ...{active_owner_phone[-4:]}). You can propose changes.") else: st.success(f"Viewing: **{active_tree_name}** (Your primary tree).") # Sidebar Status & Logout if firebase_error: st.sidebar.error(f"πŸ”΄ Firebase Error") else: st.sidebar.success("βœ… Firebase Connected") if api_key_error: st.sidebar.error("⚠️ Gemini API Key Error") else: st.sidebar.success("βœ… Gemini AI Ready") st.sidebar.divider() if st.sidebar.button("Logout"): # Clear session state keys_to_clear = list(st.session_state.keys()) # Get all keys for key in keys_to_clear: del st.session_state[key] st.rerun() # --- Navigation --- nav_options = ["🏠 My Profile", "🌳 View Family Tree"] # Editing/Proposing allowed for own tree OR linked trees nav_options.extend(["βž• Add/Edit Family", "πŸ’¬ Family Stories"]) # Review changes only for own tree if is_viewing_own_tree: nav_options.append("πŸ”„ Review Changes") # New page for owner nav_options.extend(["πŸ“œ Family Timeline", "🧩 Family Quiz (AI)"]) # Always available if is_viewing_own_tree: # AI builder and Settings only for own tree # DO NOT CHANGE THIS SUBHEADER TITLE AS PER USER INSTRUCTION nav_options.extend(["πŸ€– AI Tree Builder (Gemini)", "🎨 Settings"]) page = st.sidebar.radio("Navigate", nav_options, key="navigation") st.sidebar.divider() # --- Central Propose Button for Collaborators --- if not is_viewing_own_tree: st.sidebar.markdown("---") st.sidebar.markdown("**Collaboration**") if st.sidebar.button("Propose My Changes to Owner", key="propose_all_changes"): # Check if there are actually changes stored in the collaborator cache if st.session_state.collaborator_edited_data and st.session_state.collaborator_edited_data.get("owner_phone") == active_owner_phone: proposed_data = st.session_state.collaborator_edited_data["data"] if propose_changes(active_owner_phone, current_user, proposed_data): # Clear the cache after successful proposal st.session_state.collaborator_edited_data = None # Clear name conflict states as changes are proposed st.session_state.show_add_name_conflict_warning = False; st.session_state.add_conflicting_members = []; st.session_state.add_new_member_data_staged = None; st.session_state.confirm_add_different_person = False st.session_state.show_edit_name_conflict_warning = False; st.session_state.edit_conflicting_members = []; st.session_state.edit_member_data_staged = None; st.session_state.confirm_edit_name_change = False; st.session_state.editing_member_id_conflict = None st.rerun() # Rerun to show the read-only view again # Error handled within propose_changes else: st.sidebar.warning("No changes made to propose.") st.sidebar.markdown("*(Make edits in Add/Edit or Stories, then click above to submit)*") st.sidebar.markdown("---") st.header(f"{page} - {active_tree_name}") # Show page title and tree name # --- Function to handle saving/proposing --- def handle_save_or_propose(action_description="change"): """Saves data for owner or caches it for collaborator.""" if is_viewing_own_tree: # Owner saves directly previous_data = st.session_state.primary_tree_data # Get previous state for diff if save_tree_data(current_user, active_tree_data, previous_data): st.success(f"Data saved successfully: {action_description}") st.session_state.primary_tree_data = deepcopy(active_tree_data) # Update state return True else: st.error(f"Failed to save data: {action_description}") # Optionally reload to revert UI changes? # st.session_state.primary_tree_data = load_tree_data(current_user) return False else: # Collaborator caches the change locally in session state st.session_state.collaborator_edited_data = { "owner_phone": active_owner_phone, "data": deepcopy(active_tree_data) # Store the entire modified tree state } st.info(f"Change '{action_description}' staged. Click 'Propose My Changes to Owner' in the sidebar to submit.") return True # Indicate the change was staged # --- Page Implementations (Operate on `active_tree_data`) --- # --- My Profile (Only available & editable for own tree) --- if page == "🏠 My Profile": if not is_viewing_own_tree: st.warning("Profile editing is only available for your own tree ('My Tree').") else: # Ensure profile exists active_tree_data.setdefault("profile", deepcopy(DEFAULT_PROFILE["profile"])) profile = active_tree_data["profile"] st.subheader("Your Information") with st.form("profile_form"): name = st.text_input("Name", value=profile.get("name", "")) dob = st.text_input("DoB (YYYY-MM-DD)", value=profile.get("dob", "")) gender_options = ["Male", "Female", "Other", "Unknown"] try: gender_index = gender_options.index(profile.get("gender", "Unknown")) except ValueError: gender_index = 3 # Default to Unknown if value is invalid gender = st.selectbox("Gender", gender_options, index=gender_index) submitted = st.form_submit_button("Update Profile") if submitted: # Add validation if needed (e.g., date format) profile_updated = False if profile.get("name") != name: profile["name"] = name; profile_updated = True if profile.get("dob") != dob: profile["dob"] = dob; profile_updated = True if profile.get("gender") != gender: profile["gender"] = gender; profile_updated = True # Sync profile changes to the 'Me' node in family_members me_node = find_person_by_id(active_tree_data, "Me") me_node_updated = False if me_node: me_node_name_update = name if name else "Me" # Use entered name or default 'Me' if me_node.get("name") != me_node_name_update: me_node["name"] = me_node_name_update; me_node_updated = True if me_node.get("dob") != dob: me_node["dob"] = dob; me_node_updated = True if me_node.get("gender") != gender: me_node["gender"] = gender; me_node_updated = True if me_node_updated: me_node["last_edited_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") me_node["last_edited_by"] = current_user else: # This case should ideally not happen if the 'Me' node is created on registration st.warning("Could not find the 'Me' node in family members to sync profile information.") if profile_updated or me_node_updated: if handle_save_or_propose("Profile Update"): st.rerun() # Rerun to reflect changes immediately else: st.info("No changes made to profile.") # --- View Family Tree (Available for all trees) --- elif page == "🌳 View Family Tree": members = active_tree_data.get("family_members", []) if not members: st.info("This tree is empty. Go to 'Add/Edit Family' to add members.") else: st.subheader("Family Tree Visualization") # --- FIX: Ensure no other st.write or print before graphviz --- # Remove any potential print statements here that might have caused the NULL output try: # Generate the graph using the potentially modified active_tree_data graph_dot = generate_graphviz(active_tree_data) st.graphviz_chart(graph_dot.source) # Display the graph # Download Button try: # Use graphviz library to render SVG directly svg_data = graph_dot.pipe(format='svg') st.download_button( label="Download SVG", data=svg_data, file_name=f"family_tree_{active_owner_phone}.svg", mime="image/svg+xml" ) except Exception as e: st.warning(f"Could not generate SVG for download: {e}") except ImportError as ie: st.error(f"Graphviz library error: {ie}. Make sure Graphviz is installed and in the system PATH if needed.") except graphviz.backend.execute.ExecutableNotFound: st.error("Graphviz executable not found. Please install Graphviz (https://graphviz.org/download/) and ensure it's in your system's PATH.") except Exception as e: st.error(f"An error occurred while rendering the family tree: {e}") st.exception(e) # Show traceback for debugging st.divider() st.subheader("Family Members List") col_widths = [3, 2, 2, 1, 2, 2] # Added Provenance cols = st.columns(col_widths) cols[0].markdown("**Name**"); cols[1].markdown("**DoB**"); cols[2].markdown("**DoD**") cols[3].markdown("**Gender**"); cols[4].markdown("**Totem**"); cols[5].markdown("**Provenance**") st.divider() # Sort members alphabetically by name for consistent display sorted_members = sorted(members, key=lambda x: x.get('name', 'zzzzzz').lower() if isinstance(x, dict) else 'zzzzzz') for person in sorted_members: if not isinstance(person, dict): continue # Skip invalid entries cols = st.columns(col_widths) cols[0].write(person.get('name', 'N/A')) cols[1].write(person.get('dob', '-')) cols[2].write(person.get('dod', '-')) cols[3].write(person.get('gender', '-')) cols[4].write(person.get('totem', '-')) # Display provenance subtly prov_info = [] if person.get('created_by'): prov_info.append(f"C: ...{person['created_by'][-4:]} ({person.get('created_at','')[0:10]})") # Show date only if person.get('last_edited_by'): prov_info.append(f"E: ...{person['last_edited_by'][-4:]} ({person.get('last_edited_at','')[0:10]})") cols[5].caption(" / ".join(prov_info) if prov_info else "-") st.divider() # --- Add/Edit Family (Available for own tree and linked trees) --- elif page == "βž• Add/Edit Family": # This page modifies active_tree_data. Changes are saved/proposed via handle_save_or_propose. members = active_tree_data.setdefault("family_members", []) relationships = active_tree_data.setdefault("relationships", []) if not isinstance(relationships, list): relationships = [] # Ensure it's a list member_options = {f"{p.get('name', 'Unnamed')} (ID: ...{p.get('id', '')[-6:]})": p.get('id') for p in members if isinstance(p, dict)} id_to_name = {p['id']: p.get('name', 'Unknown') for p in members if isinstance(p, dict)} tab1, tab2, tab3 = st.tabs(["Add New Member", "Edit Existing Member", "Define Relationships"]) with tab1: # Add New Member st.subheader("Add a New Family Member") # Check if a name conflict warning is active if st.session_state.show_add_name_conflict_warning: st.warning(f"A member named '{st.session_state.add_new_member_data_staged.get('name')}' already exists.") st.write("Did you mean one of these existing members?") for member in st.session_state.add_conflicting_members: st.write(f"- **{member.get('name')}** (ID: ...{member.get('id')[-6:]})") st.session_state.confirm_add_different_person = st.checkbox( "This is a different person, add anyway.", value=st.session_state.confirm_add_different_person, key="confirm_add_different_person_checkbox" ) if st.button("Confirm Add New Person", disabled=not st.session_state.confirm_add_different_person, key="confirm_add_button"): # Proceed with adding the staged member data if st.session_state.add_new_member_data_staged: active_tree_data["family_members"].append(st.session_state.add_new_member_data_staged) if handle_save_or_propose(f"Add member {st.session_state.add_new_member_data_staged.get('name')} (Confirmed Duplicate Name)"): # Clear conflict state and staged data on success st.session_state.show_add_name_conflict_warning = False st.session_state.add_conflicting_members = [] st.session_state.add_new_member_data_staged = None st.session_state.confirm_add_different_person = False st.rerun() else: st.error("Error: Staged member data not found.") # Clear conflict state st.session_state.show_add_name_conflict_warning = False st.session_state.add_conflicting_members = [] st.session_state.add_new_member_data_staged = None st.session_state.confirm_add_different_person = False # Always show the form, but clear inputs if conflict is resolved/handled form_key = "add_member_form" if not st.session_state.show_add_name_conflict_warning: # Only clear on submit if no conflict was found, or if conflict was confirmed and handled clear_on_submit_flag = True else: # Don't clear if conflict is currently being displayed/resolved clear_on_submit_flag = False with st.form(form_key, clear_on_submit=clear_on_submit_flag): # Use session state values if conflict is active, otherwise use empty/default initial_name = st.session_state.add_new_member_data_staged.get('name', '') if st.session_state.show_add_name_conflict_warning else '' initial_dob = st.session_state.add_new_member_data_staged.get('dob', '') if st.session_state.show_add_name_conflict_warning else '' initial_dod = st.session_state.add_new_member_data_staged.get('dod', '') if st.session_state.show_add_name_conflict_warning else '' initial_gender = st.session_state.add_new_member_data_staged.get('gender', 'Unknown') if st.session_state.show_add_name_conflict_warning else 'Unknown' initial_totem = st.session_state.add_new_member_data_staged.get('totem', '') if st.session_state.show_add_name_conflict_warning else '' initial_phone = st.session_state.add_new_member_data_staged.get('phone', '') if st.session_state.show_add_name_conflict_warning else '' new_name = st.text_input("Full Name*", value=initial_name, key="add_name_input") new_dob = st.text_input("DoB (YYYY-MM-DD)", value=initial_dob, key="add_dob_input") new_dod = st.text_input("DoD (YYYY-MM-DD)", value=initial_dod, key="add_dod_input") gender_options = ["Male", "Female", "Other", "Unknown"] try: gender_index = gender_options.index(initial_gender) except ValueError: gender_index = 3 new_gender = st.selectbox("Gender", gender_options, index=gender_index, key="add_gender_select") new_totem = st.text_input("Totem (Optional)", value=initial_totem, key="add_totem_input") new_phone = st.text_input("Phone Number (Optional, e.g., +1...)", value=initial_phone, help="If added, this person might see this tree when they log in.", key="add_phone_input") st.markdown("*(Photo upload not implemented)*") submit_label = "Save Member" if is_viewing_own_tree else "Stage Member Addition" submitted = st.form_submit_button(submit_label, disabled=st.session_state.show_add_name_conflict_warning) # Disable if conflict is active if submitted: # Reset conflict state on new submission attempt st.session_state.show_add_name_conflict_warning = False st.session_state.add_conflicting_members = [] st.session_state.add_new_member_data_staged = None st.session_state.confirm_add_different_person = False norm_new_phone = normalize_phone(new_phone) if not new_name: st.error("Name required."); st.stop() # Stop execution on error if new_phone and not norm_new_phone: st.error("Invalid phone format. Use international format starting with '+'."); st.stop() # Stop execution on error # --- Name Conflict Detection --- conflicting_members = find_person_by_name(active_tree_data, new_name) if conflicting_members: # Conflict found, stage data and show warning st.session_state.show_add_name_conflict_warning = True st.session_state.add_conflicting_members = conflicting_members # Stage the data that was just entered staged_data = deepcopy(DEFAULT_MEMBER_STRUCTURE) staged_data.update({ "id": generate_unique_id(), # Generate ID now, but only use if confirmed "name": new_name.strip(), "dob": new_dob.strip(), "dod": new_dod.strip(), "gender": new_gender, "phone": norm_new_phone or "", "totem": new_totem.strip(), "created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "created_by": current_user, "last_edited_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "last_edited_by": current_user }) st.session_state.add_new_member_data_staged = staged_data st.rerun() # Rerun to show the warning UI else: # No conflict, proceed with adding new_member_id = generate_unique_id() current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") new_member_data = deepcopy(DEFAULT_MEMBER_STRUCTURE) new_member_data.update({ "id": new_member_id, "name": new_name.strip(), "dob": new_dob.strip(), "dod": new_dod.strip(), "gender": new_gender, "phone": norm_new_phone or "", # Store normalized or empty string "totem": new_totem.strip(), "created_at": current_time, "created_by": current_user, # Track who added it "last_edited_at": current_time, "last_edited_by": current_user }) # Modify the active_tree_data directly active_tree_data["family_members"].append(new_member_data) # Save (owner) or stage (collaborator) if handle_save_or_propose(f"Add member {new_name}"): # Don't rerun here, let the form clear and info message show pass # Success/Info message handled by the function with tab2: # Edit Existing Member st.subheader("Edit an Existing Member") current_members_edit = active_tree_data.get("family_members", []) if not current_members_edit: st.info("No members exist yet.") else: member_options_edit = {f"{p.get('name', 'Unnamed')} (ID: ...{p.get('id', '')[-6:]})": p.get('id') for p in current_members_edit if isinstance(p, dict)} # Sort options alphabetically for dropdown sorted_options = [""] + sorted(list(member_options_edit.keys())) selected_member_display = st.selectbox("Select Member to Edit", options=sorted_options, index=0, key="edit_select", placeholder="Choose a member...") if selected_member_display: member_id_to_edit = member_options_edit[selected_member_display] # Find the index in the *current* active_tree_data list member_index = next((i for i, p in enumerate(active_tree_data["family_members"]) if isinstance(p, dict) and p.get('id') == member_id_to_edit), -1) if member_index != -1: # Get a reference to the member data within active_tree_data member_data = active_tree_data["family_members"][member_index] # Check if a name conflict warning is active for THIS member is_editing_conflict_member = (st.session_state.show_edit_name_conflict_warning and st.session_state.editing_member_id_conflict == member_id_to_edit) if is_editing_conflict_member: st.warning(f"Changing name to '{st.session_state.edit_member_data_staged.get('name')}' would match an existing member.") st.write("Existing members with this name:") for member in st.session_state.edit_conflicting_members: st.write(f"- **{member.get('name')}** (ID: ...{member.get('id')[-6:]})") st.session_state.confirm_edit_name_change = st.checkbox( "Yes, change name anyway (these are different people).", value=st.session_state.confirm_edit_name_change, key=f"confirm_edit_name_change_checkbox_{member_id_to_edit}" ) if st.button("Confirm Name Change", disabled=not st.session_state.confirm_edit_name_change, key=f"confirm_edit_name_button_{member_id_to_edit}"): # Apply the staged name change and proceed with save/propose if st.session_state.edit_member_data_staged: # Find the member again in case list order changed (less likely in edit tab) current_member_ref = find_person_by_id(active_tree_data, member_id_to_edit) if current_member_ref: current_member_ref["name"] = st.session_state.edit_member_data_staged.get("name").strip() current_member_ref["last_edited_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") current_member_ref["last_edited_by"] = current_user if handle_save_or_propose(f"Update member {current_member_ref.get('name')} (Confirmed Name Change)"): # Clear conflict state and staged data on success st.session_state.show_edit_name_conflict_warning = False st.session_state.edit_conflicting_members = [] st.session_state.edit_member_data_staged = None st.session_state.confirm_edit_name_change = False st.session_state.editing_member_id_conflict = None st.rerun() # Error handled by handle_save_or_propose else: st.error("Error: Member not found during confirmation.") # Clear conflict state st.session_state.show_edit_name_conflict_warning = False st.session_state.edit_conflicting_members = [] st.session_state.edit_member_data_staged = None st.session_state.confirm_edit_name_change = False st.session_state.editing_member_id_conflict = None else: st.error("Error: Staged edit data not found.") # Clear conflict state st.session_state.show_edit_name_conflict_warning = False st.session_state.edit_conflicting_members = [] st.session_state.edit_member_data_staged = None st.session_state.confirm_edit_name_change = False st.session_state.editing_member_id_conflict = None with st.form(f"edit_member_form_{member_id_to_edit}", clear_on_submit=False): # Don't clear form on submit if conflict is active st.write(f"Editing: **{member_data.get('name')}** (ID: ...{member_id_to_edit[-6:]})") # Display provenance creator = member_data.get('created_by'); editor = member_data.get('last_edited_by') prov_text = [] if creator: prov_text.append(f"Created by ...{creator[-4:]} ({member_data.get('created_at','')})") if editor: prov_text.append(f"Last edit by ...{editor[-4:]} ({member_data.get('last_edited_at','')})") if prov_text: st.caption(" | ".join(prov_text)) is_me_node = (member_id_to_edit == "Me" and is_viewing_own_tree) # Can only be 'Me' if owner is editing own tree # Input fields - disable name/phone for 'Me' node # Use staged value if conflict is active for this member, otherwise use current data initial_edit_name = st.session_state.edit_member_data_staged.get('name', member_data.get("name", "")) if is_editing_conflict_member else member_data.get("name", "") edit_name = st.text_input("Name", value=initial_edit_name, disabled=is_me_node or is_editing_conflict_member, key=f"edit_name_{member_id_to_edit}") edit_dob = st.text_input("DoB", value=member_data.get("dob", ""), key=f"edit_dob_{member_id_to_edit}", disabled=is_editing_conflict_member) edit_dod = st.text_input("DoD", value=member_data.get("dod", ""), key=f"edit_dod_{member_id_to_edit}", disabled=is_editing_conflict_member) gender_options = ["Male", "Female", "Other", "Unknown"] try: gender_index = gender_options.index(member_data.get("gender", "Unknown")) except ValueError: gender_index = 3 edit_gender = st.selectbox("Gender", gender_options, index=gender_index, key=f"edit_gender_{member_id_to_edit}", disabled=is_editing_conflict_member) edit_totem = st.text_input("Totem", value=member_data.get("totem", ""), key=f"edit_totem_{member_id_to_edit}", disabled=is_editing_conflict_member) edit_phone = st.text_input("Phone", value=member_data.get("phone", ""), disabled=is_me_node or is_editing_conflict_member, help="Changing phone affects tree visibility for that user.", key=f"edit_phone_{member_id_to_edit}") st.markdown("*(Photo management not implemented)*"); st.divider() # --- Deletion --- # Allow deletion only if NOT the 'Me' node delete_member = False if not is_me_node: delete_member = st.checkbox(f"⚠️ Delete Member '{member_data.get('name')}'?", key=f"delete_{member_id_to_edit}", disabled=is_editing_conflict_member) else: st.markdown("*(Cannot delete the 'Me' user node)*") submit_label = "Save Changes" if is_viewing_own_tree else "Stage Changes" submitted = st.form_submit_button(submit_label, disabled=is_editing_conflict_member) # Disable if conflict is active if submitted: # Reset conflict state on new submission attempt for this member st.session_state.show_edit_name_conflict_warning = False st.session_state.edit_conflicting_members = [] st.session_state.edit_member_data_staged = None st.session_state.confirm_edit_name_change = False st.session_state.editing_member_id_conflict = None norm_edit_phone = normalize_phone(edit_phone) if edit_phone and not norm_edit_phone: st.error("Invalid phone format. Use international format starting with '+'."); st.stop() elif delete_member and not is_me_node: # --- Deletion Logic --- try: deleted_name = active_tree_data["family_members"][member_index].get('name') # Modify active_tree_data state del active_tree_data["family_members"][member_index] # Remove relationships involving the deleted member active_tree_data["relationships"] = [ r for r in active_tree_data.get("relationships", []) if isinstance(r,dict) and r.get("from_id") != member_id_to_edit and r.get("to_id") != member_id_to_edit ] if handle_save_or_propose(f"Delete member {deleted_name}"): st.rerun() # Rerun to refresh lists/view except Exception as e: st.error(f"Error during delete operation: {e}") # Consider reloading data to revert state if save/propose failed implicitly st.rerun() else: # --- Update Logic --- name_changed = (not is_me_node and member_data.get("name", "").strip().lower() != edit_name.strip().lower()) phone_changed = (not is_me_node and normalize_phone(member_data.get("phone","")) != norm_edit_phone) # --- Name Conflict Detection on Edit --- if name_changed and edit_name: # Check if the new name exists *among other members* conflicting_members = [ p for p in active_tree_data["family_members"] if isinstance(p,dict) and p['id'] != member_id_to_edit and p.get('name', '').strip().lower() == edit_name.strip().lower() ] if conflicting_members: # Conflict found, stage data and show warning st.session_state.show_edit_name_conflict_warning = True st.session_state.edit_conflicting_members = conflicting_members st.session_state.editing_member_id_conflict = member_id_to_edit # Stage the *potential* changes (only name is needed for conflict check) st.session_state.edit_member_data_staged = {"name": edit_name.strip()} st.rerun() # Rerun to show the warning UI st.stop() # Stop processing this submission # If no name conflict (or name didn't change), proceed with saving other fields if not is_me_node and not edit_name: st.error("Name cannot be empty."); st.stop() # Apply changes to the member_data reference within active_tree_data something_changed = False if name_changed: member_data["name"] = edit_name.strip(); something_changed = True if member_data.get("dob") != edit_dob.strip(): member_data["dob"] = edit_dob.strip(); something_changed = True if member_data.get("dod") != edit_dod.strip(): member_data["dod"] = edit_dod.strip(); something_changed = True if member_data.get("gender") != edit_gender: member_data["gender"] = edit_gender; something_changed = True if member_data.get("totem") != edit_totem.strip(): member_data["totem"] = edit_totem.strip(); something_changed = True if phone_changed: member_data["phone"] = norm_edit_phone or ""; something_changed = True if something_changed: member_data["last_edited_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") member_data["last_edited_by"] = current_user if handle_save_or_propose(f"Update member {member_data['name']}"): st.rerun() # Refresh view else: st.info("No changes detected.") elif selected_member_display: # This might happen if the list changed between render and selection st.error("Could not find the selected member data. The list might have refreshed. Please select again.") st.rerun() with tab3: # Define Relationships st.subheader("Define Family Relationships") current_members_rel = active_tree_data.get("family_members", []) relationships = active_tree_data.setdefault("relationships", []) # Ensure list exists member_options_rel = {f"{p.get('name', 'Unnamed')} (ID: ...{p.get('id', '')[-6:]})": p.get('id') for p in current_members_rel if isinstance(p, dict)} if len(member_options_rel) < 2: st.info("Need at least two members in the tree to define relationships.") else: id_to_name_rel = {p['id']: p.get('name', 'Unknown') for p in current_members_rel if isinstance(p, dict)} with st.form("add_relationship_form"): member_list = sorted(list(member_options_rel.keys())) col1, col2, col3 = st.columns([2, 1, 2]) with col1: p1_disp = st.selectbox("Person 1", member_list, key="rel_p1", index=None, placeholder="Choose...") with col2: rel_type = st.selectbox("Relationship", ["Parent Of", "Spouse Of", "Sibling Of"], key="rel_type", index=None, placeholder="Choose...") with col3: p2_disp = st.selectbox("Person 2", member_list, key="rel_p2", index=None, placeholder="Choose...") submit_label = "Save Relationship" if is_viewing_own_tree else "Stage Relationship Addition" submitted = st.form_submit_button(submit_label) if submitted: if not p1_disp or not p2_disp or not rel_type: st.error("Please select both people and the relationship type.") else: id1, id2 = member_options_rel.get(p1_disp), member_options_rel.get(p2_disp) if not id1 or not id2: st.error("Internal error: Could not map selected names to IDs.") elif id1 == id2: st.error("Cannot define a relationship between a person and themselves.") else: rel_map = {"Parent Of": 'parent', "Spouse Of": 'spouse', "Sibling Of": 'sibling'} rti = rel_map.get(rel_type) p1n, p2n = id_to_name_rel.get(id1, p1_disp), id_to_name_rel.get(id2, p2_disp) # Check for existing relationship exists = False for rel in relationships: if not isinstance(rel, dict): continue rf, rt, rtype = rel.get('from_id'), rel.get('to_id'), rel.get('type') # Parent check (directional) if rti == 'parent' and rtype == 'parent' and rf == id1 and rt == id2: exists = True; break # Spouse/Sibling check (undirectional) if rti in ['spouse', 'sibling'] and rtype == rti and frozenset([rf, rt]) == frozenset([id1, id2]): exists = True; break if exists: st.warning(f"This relationship ({p1n} {rel_type} {p2n}) already exists.") else: # Ensure correct order for spouse/sibling (optional, for consistency) # id1s, id2s = (id1, id2) if rti == 'parent' else tuple(sorted([id1, id2])) # Keep original direction for parent, use selected for others new_rel = {"from_id": id1, "to_id": id2, "type": rti} # Add to active_tree_data active_tree_data["relationships"].append(new_rel) if handle_save_or_propose(f"Add relationship {p1n}-{p2n}"): st.rerun() # Refresh view st.divider() st.write("**Existing Relationships**") # Refresh relationships from potentially modified active_tree_data relationships = active_tree_data.get("relationships", []) if not relationships: st.write("No relationships defined yet.") else: indices_to_delete = [] displayed_undirected_pairs = set() # Track spouse/sibling pairs to avoid duplicates for i, rel in enumerate(relationships): if not isinstance(rel, dict): continue from_id, to_id, rel_type = rel.get('from_id'), rel.get('to_id'), rel.get('type') # Check if IDs exist in the current member list (robustness) if from_id not in id_to_name_rel or to_id not in id_to_name_rel: st.caption(f"- Relationship with missing member(s): {rel}") continue from_name, to_name = id_to_name_rel.get(from_id), id_to_name_rel.get(to_id) rel_text, display_this = "", True if rel_type == 'parent': rel_text = f"'{from_name}' **Parent** of '{to_name}'" elif rel_type in ['spouse', 'sibling']: # Normalize pair for display check (undirected) pair_key = (frozenset([from_id, to_id]), rel_type) if pair_key in displayed_undirected_pairs: display_this = False # Don't show the reverse pair else: displayed_undirected_pairs.add(pair_key) # Display consistently (e.g., alphabetical) n1, n2 = sorted([from_name, to_name]) type_disp = "Spouse" if rel_type == 'spouse' else "Sibling" rel_text = f"'{n1}' **{type_disp}** of '{n2}'" else: rel_text = f"'{from_name}' **Unknown ({rel_type})** '{to_name}'" if display_this and rel_text: col1, col2 = st.columns([4, 1]) col1.markdown(f"- {rel_text}") delete_label = "Delete" if is_viewing_own_tree else "Stage Deletion" if col2.button(delete_label, key=f"del_rel_{i}_{from_id}_{to_id}"): indices_to_delete.append(i) if indices_to_delete: indices_to_delete.sort(reverse=True) # Delete from end to avoid index shifts deleted_rels_desc = [] for index in indices_to_delete: try: deleted_rel_info = relationships[index] deleted_rels_desc.append(f"{id_to_name_rel.get(deleted_rel_info['from_id'])} {deleted_rel_info['type']} {id_to_name_rel.get(deleted_rel_info['to_id'])}") del active_tree_data["relationships"][index] except IndexError: st.warning(f"Could not delete relationship at index {index} - list may have changed.") if deleted_rels_desc: if handle_save_or_propose(f"Delete relationships: {', '.join(deleted_rels_desc)}"): st.rerun() # Refresh view # --- Family Stories (Available for all trees, proposing changes for linked trees) --- elif page == "πŸ’¬ Family Stories": st.subheader("Record and View Family Stories") members = active_tree_data.get("family_members", []) if not members: st.info("No members in this tree.") else: member_options_story = {f"{p.get('name', 'Unnamed')} (ID: ...{p.get('id', '')[-6:]})": p.get('id') for p in members if isinstance(p, dict)} sorted_options = [""] + sorted(list(member_options_story.keys())) selected_member_display = st.selectbox("Select Family Member", options=sorted_options, index=0, key="story_select", placeholder="Choose...") if selected_member_display: member_id = member_options_story[selected_member_display] # Find index in the current active_tree_data member_index = next((i for i, p in enumerate(active_tree_data["family_members"]) if isinstance(p,dict) and p.get('id') == member_id), -1) if member_index != -1: # Get reference to member data in active_tree_data member_data = active_tree_data["family_members"][member_index] st.markdown(f"### Stories about **{member_data.get('name')}**") # Add Story Form with st.form(f"story_form_{member_id}", clear_on_submit=True): new_story_text = st.text_area("Add a new story:", key=f"story_text_{member_id}", height=100) submit_label = "Save Story" if is_viewing_own_tree else "Stage Story Addition" submitted = st.form_submit_button(submit_label) if submitted and new_story_text.strip(): # Ensure 'stories' list exists if "stories" not in member_data or not isinstance(member_data["stories"], list): member_data["stories"] = [] timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") new_story = deepcopy(DEFAULT_STORY_STRUCTURE) new_story.update({"timestamp": timestamp, "text": new_story_text.strip(), "added_by": current_user}) # Modify active_tree_data member_data["stories"].append(new_story) if handle_save_or_propose(f"Add story for {member_data['name']}"): # Don't rerun, let form clear pass elif submitted: st.warning("Story text cannot be empty.") st.divider(); st.markdown("**Saved Stories:**") # Read stories from potentially modified active_tree_data stories = member_data.get("stories", []) if not stories: st.write("No stories recorded yet.") else: indices_to_delete_story = [] # Display stories in reverse chronological order (newest first) for i, story in enumerate(reversed(stories)): if not isinstance(story, dict): continue original_index = len(stories) - 1 - i # Index in the original list added_info = f"Added: {story.get('timestamp', 'N/A')}" if story.get('added_by'): added_info += f" by ...{story['added_by'][-4:]}" st.markdown(f"**Story {len(stories)-i}** ({added_info})") st.markdown(f"> {story.get('text', '')}") # Delete button delete_label = "Delete Story" if is_viewing_own_tree else "Stage Story Deletion" if st.button(delete_label, key=f"del_story_{member_id}_{original_index}"): indices_to_delete_story.append(original_index) st.markdown("---") if indices_to_delete_story: indices_to_delete_story.sort(reverse=True) # Delete from end deleted_stories_desc = [] for index in indices_to_delete_story: try: deleted_story_text = member_data["stories"][index].get('text', 'story')[:30] + "..." deleted_stories_desc.append(f"'{deleted_story_text}'") del member_data["stories"][index] # Modify active_tree_data except IndexError: st.warning(f"Could not delete story at index {index} - list may have changed.") if deleted_stories_desc: if handle_save_or_propose(f"Delete stories for {member_data['name']}: {', '.join(deleted_stories_desc)}"): st.rerun() # Refresh view elif selected_member_display: st.error("Could not find member data. Refresh?") st.rerun() # --- Review Changes (Owner Only) --- elif page == "πŸ”„ Review Changes": if not is_viewing_own_tree: st.error("Access denied. This page is only for the tree owner.") st.stop() st.subheader("Review Proposed Changes from Collaborators") # Load pending changes if st.button("Refresh Pending Changes"): with st.spinner("Loading proposals..."): st.session_state.pending_changes_for_review = load_pending_changes(current_user) st.session_state.selected_proposal_to_review = None # Reset selection st.rerun() # Load initially if not already loaded if not st.session_state.pending_changes_for_review: st.session_state.pending_changes_for_review = load_pending_changes(current_user) pending_proposals = st.session_state.pending_changes_for_review if not pending_proposals: st.info("No pending changes to review at this time.") else: st.write(f"Found {len(pending_proposals)} proposal(s):") proposer_phones = list(pending_proposals.keys()) selected_proposer = st.selectbox( "Select proposal to review:", options=[""] + proposer_phones, format_func=lambda p: f"From ...{p[-4:]} ({pending_proposals[p].get('proposed_at', 'N/A')})" if p else "Choose...", key="review_selection" ) if selected_proposer: st.session_state.selected_proposal_to_review = selected_proposer proposal_data = pending_proposals[selected_proposer].get("tree_data") if not proposal_data: st.error("Selected proposal data is missing or invalid.") else: st.markdown("---") st.markdown(f"#### Changes Proposed by ...{selected_proposer[-4:]}") # Generate and display diff summary st.markdown("**Summary of Changes:**") current_owner_data = st.session_state.primary_tree_data # Use owner's current data diff_summary = generate_diff_summary(current_owner_data, proposal_data) st.text_area("Differences", value=diff_summary, height=250, disabled=True) # Accept / Reject Buttons col1, col2 = st.columns(2) with col1: if st.button("βœ… Accept Changes", key=f"accept_{selected_proposer}"): with st.spinner("Accepting and merging changes..."): if accept_changes(current_user, selected_proposer): # Reload primary data and clear pending state st.session_state.primary_tree_data = load_tree_data(current_user) st.session_state.pending_changes_for_review = load_pending_changes(current_user) # Refresh list st.session_state.selected_proposal_to_review = None st.rerun() # Error handled within accept_changes with col2: if st.button("❌ Reject Changes", key=f"reject_{selected_proposer}"): with st.spinner("Rejecting changes..."): if reject_changes(current_user, selected_proposer): # Clear pending state st.session_state.pending_changes_for_review = load_pending_changes(current_user) # Refresh list st.session_state.selected_proposal_to_review = None st.rerun() # Error handled within reject_changes st.markdown("---") # --- Family Timeline (Available for all trees) --- elif page == "πŸ“œ Family Timeline": # (Operates on active_tree_data - code remains same logic) st.subheader("Significant Family Events") members = active_tree_data.get("family_members", []) events = [] valid_date_format = "%Y-%m-%d" # Assumes this format for person in members: if not isinstance(person, dict): continue name = person.get("name", "Unknown") dob_str, dod_str = person.get("dob"), person.get("dod") try: # Attempt to parse dates, skip if invalid format if dob_str: dob_dt = datetime.strptime(dob_str, valid_date_format) events.append({"date": dob_dt, "type": "Birth", "desc": f"{name} born."}) if dod_str: dod_dt = datetime.strptime(dod_str, valid_date_format) events.append({"date": dod_dt, "type": "Death", "desc": f"{name} passed."}) except (ValueError, TypeError): # Silently ignore invalid date formats for the timeline pass try: # Sort events chronologically events.sort(key=lambda x: x.get("date", datetime.min)) except Exception as e: st.warning(f"Timeline sort error: {e}.") if not events: st.info("No valid Birth/Death dates found in YYYY-MM-DD format to display on the timeline.") else: st.write("Chronological Events:") last_year = None for event in events: event_date = event.get("date") if event_date: current_year = event_date.year # Group by year if current_year != last_year: if last_year is not None: st.divider() # Add divider between years st.markdown(f"#### {current_year}"); last_year = current_year # Display event st.markdown(f"- **{event_date.strftime(valid_date_format)}**: {event.get('desc','')}") # --- Family Quiz (AI) (Available for all trees) --- elif page == "🧩 Family Quiz (AI)": # (Operates on active_tree_data - code remains same logic) st.subheader("Test Your Family Knowledge!") if api_key_error: st.error("πŸ”΄ AI features disabled due to API key error."); st.stop() if firebase_error: st.warning(f"πŸ”΄ Firebase connection error: {firebase_error}.") members = active_tree_data.get("family_members", []) relationships = active_tree_data.get("relationships", []) # Quiz generation and display logic if st.button("Generate New Quiz Questions", key="generate_quiz"): # Reset quiz state st.session_state.ai_quiz_questions = None; st.session_state.current_quiz_question_index = -1; st.session_state.quiz_score = 0; st.session_state.quiz_answered = False if 'current_options_order' in st.session_state: del st.session_state['current_options_order'] # Clear previous answer choices for k in list(st.session_state.keys()): if k.startswith("quiz_q_") and k.endswith("_choice"): del st.session_state[k] if len(members) < 2 or not relationships: st.warning("Need at least 2 members and some defined relationships to generate a quiz.") else: with st.spinner("🧠 Generating quiz questions with AI..."): questions = generate_quiz_questions_gemini(members, relationships, num_questions=5) # Pass active data if questions and isinstance(questions, list) and len(questions) > 0: # Basic validation of question structure valid_qs = [q for q in questions if isinstance(q, dict) and all(k in q for k in ['text','options','correct']) and isinstance(q['options'],list) and len(q['options']) > 1 and q['correct'] in q['options']] if valid_qs: st.session_state.ai_quiz_questions = valid_qs st.session_state.current_quiz_question_index = 0 st.success(f"Generated {len(valid_qs)} quiz questions!") st.rerun() else: st.error("AI returned questions in an unexpected format. Please try again.") print(f"DEBUG: Invalid AI Quiz Response: {questions}") # Debug log elif questions is None: # Error likely displayed by call_gemini pass else: st.error("Failed to generate quiz questions or AI returned an empty list.") # Display Quiz questions = st.session_state.ai_quiz_questions q_index = st.session_state.current_quiz_question_index if questions and 0 <= q_index < len(questions): q_data = questions[q_index] st.info(f"Question {q_index + 1} of {len(questions)} (Current Score: {st.session_state.quiz_score})") st.markdown(f"**{q_data.get('text', 'Error: Question text missing')}**") options = q_data.get('options', []) correct = q_data.get('correct') if not options or not correct: st.error("Error: Invalid question data encountered.") else: # Shuffle options per question view opt_key = f'current_options_order_{q_index}' # Make key specific to question index if opt_key not in st.session_state: shuffled_options = list(options) random.shuffle(shuffled_options) st.session_state[opt_key] = shuffled_options display_opts = st.session_state[opt_key] # Radio button for answer selection choice_key = f"quiz_q_{q_index}_choice" radio_key = f"quiz_q_{q_index}_radio" # Function to update choice state on radio change def update_choice(): st.session_state[choice_key] = st.session_state[radio_key] # Get current selection or None current_selection = st.session_state.get(choice_key) try: current_index = display_opts.index(current_selection) if current_selection in display_opts else None except ValueError: current_index = None # Handle case where saved choice is no longer valid (shouldn't happen often) user_choice = st.radio( "Select your answer:", options=display_opts, key=radio_key, index=current_index, disabled=st.session_state.quiz_answered, on_change=update_choice # Update state variable when radio changes ) # Submit Button submit_button = st.button("Submit Answer", key=f"submit_{q_index}", disabled=st.session_state.quiz_answered or st.session_state.get(choice_key) is None) if submit_button and st.session_state.get(choice_key): st.session_state.quiz_answered = True if st.session_state[choice_key] == correct: st.session_state.quiz_score += 1 st.rerun() # Rerun to show feedback and Next button # Feedback and Next Button if st.session_state.quiz_answered: last_choice = st.session_state.get(choice_key) if last_choice == correct: st.success(f"Correct! πŸŽ‰ The answer is **{correct}**.") else: st.error(f"Incorrect. The correct answer was **{correct}**.") # Check if it's the last question if q_index < len(questions) - 1: if st.button("Next Question β†’", key=f"next_{q_index}"): st.session_state.current_quiz_question_index += 1 st.session_state.quiz_answered = False # Reset answered flag for next question # Don't need to clear choice state here as new keys are used per question st.rerun() else: # Last question answered st.balloons() st.success(f"Quiz Finished! Your final score: {st.session_state.quiz_score} out of {len(questions)}") if st.button("Start New Quiz", key="restart_quiz"): # Reset all quiz state variables st.session_state.ai_quiz_questions = None st.session_state.current_quiz_question_index = -1 st.session_state.quiz_score = 0 st.session_state.quiz_answered = False # Clear options order and choices from session state keys_to_delete = [k for k in st.session_state if k.startswith('current_options_order_') or (k.startswith('quiz_q_') and k.endswith('_choice'))] for key in keys_to_delete: del st.session_state[key] st.rerun() elif questions and q_index >= len(questions): # Should be handled by the logic above, but as a fallback st.success(f"Quiz Finished! Final Score: {st.session_state.quiz_score}/{len(questions)}") if st.button("Start New Quiz", key="restart_quiz_end"): # Reset all quiz state variables (same as above) st.session_state.ai_quiz_questions = None; st.session_state.current_quiz_question_index = -1; st.session_state.quiz_score = 0; st.session_state.quiz_answered = False keys_to_delete = [k for k in st.session_state if k.startswith('current_options_order_') or (k.startswith('quiz_q_') and k.endswith('_choice'))] for key in keys_to_delete: del st.session_state[key] st.rerun() # --- AI Tree Builder (Only available & editable for own tree) --- elif page == "πŸ€– AI Tree Builder (Gemini)": if not is_viewing_own_tree: st.warning("AI Tree Builder is only available for your own tree ('My Tree').") else: # (Operates on active_tree_data / primary_data - code remains same logic, uses new save function) # DO NOT CHANGE THIS SUBHEADER TITLE AS PER USER INSTRUCTION st.subheader("Build Tree from Text Description (Experimental)") st.markdown("Describe family members and their relationships (e.g., 'John Smith is my father. His wife is Jane Doe. They have two children, me and my sister Sarah.')") if api_key_error: st.error("πŸ”΄ AI features disabled due to API key error."); st.stop() if firebase_error: st.warning(f"πŸ”΄ Firebase connection error: {firebase_error}.") desc_input = st.text_area("Enter description:", height=150, key="ai_desc_input") if st.button("Analyze Description", key="ai_analyze"): st.session_state.ai_analyze_button_clicked = True; st.session_state['ai_description_result'] = None # Reset result if desc_input.strip(): with st.spinner("🧠 AI is analyzing the description..."): st.session_state['ai_description_result'] = generate_tree_from_description_gemini(desc_input) # Don't rerun here, let the result display below else: st.warning("Please enter a description first.") st.session_state.ai_analyze_button_clicked = False # Reset flag if input was empty ai_result = st.session_state.get('ai_description_result') analysis_attempted = st.session_state.get('ai_analyze_button_clicked', False) if ai_result: st.divider(); st.markdown("#### AI Analysis Result:") if isinstance(ai_result, dict) and "people" in ai_result and "relationships" in ai_result: ai_people = ai_result.get("people", []) ai_rels = ai_result.get("relationships", []) # Validate AI output structure before displaying/merging valid_people = [p for p in ai_people if isinstance(p, dict) and p.get("name")] valid_rel_structs = [r for r in ai_rels if isinstance(r, dict) and all(k in r for k in ["person1_name", "person2_name", "type"])] ai_people_names = {p.get('name') for p in valid_people} # Filter relationships to only include those between valid extracted people valid_rels_final = [r for r in valid_rel_structs if r.get('person1_name') in ai_people_names and r.get('person2_name') in ai_people_names] st.write("**People Extracted:**") if valid_people: for p in valid_people: details = ", ".join(f'{k}: {v}' for k,v in p.items() if k != 'name' and v) st.write(f"- **{p.get('name')}**" + (f" ({details})" if details else "")) else: st.write("None found.") st.write("**Relationships Extracted:**") if valid_rels_final: for r in valid_rels_final: st.write(f"- **{r.get('person1_name')}** {r.get('type','').replace('_',' ')} **{r.get('person2_name')}**") else: st.write("None found.") st.divider() if st.button("Merge AI Suggestions into Tree", key="ai_merge"): if not valid_people and not valid_rels_final: st.warning("No valid people or relationships extracted by AI to merge.") else: # Use handle_save_or_propose after modifying active_tree_data # Keep track of changes for summary added_p_count, added_r_count, updated_p_names, skipped_p_names, skipped_r_desc = 0, 0, [], [], [] id_map = {} # Map AI name to existing or new ID current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S") # --- Merge People --- for person_ai in valid_people: ai_name = person_ai.get("name") if not ai_name: continue # Should be filtered by valid_people, but double check # Use the updated find_person_by_name which returns a list existing_people_with_name = find_person_by_name(active_tree_data, ai_name) existing_person = existing_people_with_name[0] if existing_people_with_name else None # Take the first one if multiple exist (simplification for AI merge) person_updated = False if existing_person: # Person exists, map name to existing ID id_map[ai_name] = existing_person['id'] # Update existing person only if AI provides new info # Example: Update DoB if missing and AI provides it if not existing_person.get('dob') and person_ai.get('dob'): existing_person['dob'] = person_ai.get('dob'); person_updated = True if not existing_person.get('dod') and person_ai.get('dod'): existing_person['dod'] = person_ai.get('dod'); person_updated = True # Update gender only if current is Unknown and AI provides something else if existing_person.get('gender', 'Unknown') == 'Unknown' and person_ai.get('gender') and person_ai.get('gender') != 'Unknown': existing_person['gender'] = person_ai.get('gender'); person_updated = True if not existing_person.get('totem') and person_ai.get('totem'): existing_person['totem'] = person_ai.get('totem'); person_updated = True # Add more update conditions as needed if person_updated: existing_person["last_edited_at"] = current_time existing_person["last_edited_by"] = current_user # Mark AI merge as edit by current user updated_p_names.append(ai_name) else: skipped_p_names.append(ai_name + " (exists, no new info)") else: # Person doesn't exist, add them new_id = generate_unique_id() id_map[ai_name] = new_id new_data = deepcopy(DEFAULT_MEMBER_STRUCTURE) # Populate with AI data, ensuring keys are valid valid_keys = ['name', 'dob', 'dod', 'gender', 'totem'] new_data.update({k: v for k, v in person_ai.items() if k in valid_keys and v}) # Add mandatory fields and provenance new_data.update({ "id": new_id, "created_at": current_time, "created_by": current_user, # Mark AI merge as creation by current user "last_edited_at": current_time, "last_edited_by": current_user }) active_tree_data.setdefault("family_members", []).append(new_data) added_p_count += 1 # --- Merge Relationships --- current_rels_list = active_tree_data.setdefault("relationships", []) # Create a set for quick lookup of existing relationships existing_rel_set = set() for r in current_rels_list: if not isinstance(r, dict): continue from_id, to_id, r_type = r.get('from_id'), r.get('to_id'), r.get('type') if not from_id or not to_id or not r_type: continue if r_type == 'parent': existing_rel_set.add( (from_id, to_id, r_type) ) elif r_type in ['spouse', 'sibling']: # Store undirected relationships consistently (e.g., sorted IDs) existing_rel_set.add( (tuple(sorted((from_id, to_id))), r_type) ) rel_type_map = {"parent_of":'parent', "spouse_of":'spouse', "sibling_of":'sibling'} for rel_ai in valid_rels_final: p1n, p2n, rta = rel_ai.get("person1_name"), rel_ai.get("person2_name"), rel_ai.get("type") id1, id2 = id_map.get(p1n), id_map.get(p2n) # Get IDs mapped from names rti = rel_type_map.get(rta) # Map AI type to internal type if id1 and id2 and rti: # Check if relationship already exists exists = False rel_key = None if rti == 'parent': rel_key = (id1, id2, rti) elif rti in ['spouse', 'sibling']: rel_key = (tuple(sorted((id1, id2))), rti) if rel_key and rel_key in existing_rel_set: exists = True if exists: skipped_r_desc.append(f"{p1n} {rta.replace('_',' ')} {p2n} (exists)") else: # Add the new relationship new_rel = {"from_id": id1, "to_id": id2, "type": rti} current_rels_list.append(new_rel) added_r_count += 1 # Add to set to prevent duplicates within this merge batch if rel_key: existing_rel_set.add(rel_key) else: skipped_r_desc.append(f"{p1n} {rta.replace('_',' ')} {p2n} (missing person ID)") # --- Save or Propose the merged data --- if added_p_count > 0 or added_r_count > 0 or updated_p_names: merge_summary = f"AI Merge: Added {added_p_count} people, {added_r_count} relationships. Updated {len(updated_p_names)} people." if handle_save_or_propose(merge_summary): # Display skipped info after successful save/stage if skipped_p_names: st.info(f"Skipped {len(skipped_p_names)} people: {', '.join(skipped_p_names)}") if skipped_r_desc: st.info(f"Skipped {len(skipped_r_desc)} relationships: {', '.join(skipped_r_desc)}") # Clear AI results after merge st.session_state['ai_description_result'] = None st.session_state['ai_analyze_button_clicked'] = False st.rerun() else: st.info("No new information found by AI to merge into the tree.") if skipped_p_names: st.info(f"Skipped {len(skipped_p_names)} people: {', '.join(skipped_p_names)}") if skipped_r_desc: st.info(f"Skipped {len(skipped_r_desc)} relationships: {', '.join(skipped_r_desc)}") elif ai_result is None and analysis_attempted: # Error message was likely shown by call_gemini or safe_json_loads st.warning("AI analysis did not return a valid result. Please check the description or try again.") elif analysis_attempted: # Handle cases where AI returned something unexpected st.error("AI returned data in an unexpected format.") st.write("Raw AI Response:") st.code(str(ai_result)) # --- Settings (Only available & editable for own tree) --- elif page == "🎨 Settings": if not is_viewing_own_tree: st.warning("Settings are only available for your own tree ('My Tree').") else: # Operates on active_tree_data / primary_data st.subheader("Application Settings") # Ensure settings and metadata dicts exist settings = active_tree_data.setdefault("settings", deepcopy(DEFAULT_PROFILE["settings"])) metadata = active_tree_data.setdefault("metadata", deepcopy(DEFAULT_PROFILE["metadata"])) # Tree Name new_tree_name = st.text_input("Tree Name", value=metadata.get("tree_name", "My Family Tree")) # Theme current_theme = settings.get("theme", "Default"); theme_opts = ["Default", "Dark"] try: theme_idx = theme_opts.index(current_theme) except ValueError: theme_idx = 0 # Default to 'Default' if invalid value stored new_theme = st.selectbox("Display Theme", theme_opts, index=theme_idx) # Privacy (Conceptual - No backend enforcement implemented) st.markdown("---"); st.markdown("#### Privacy (Conceptual Only)") current_privacy = settings.get("privacy", "Private"); privacy_opts = ["Private", "Collaborators Only", "Public (Not Recommended)"] try: privacy_idx = privacy_opts.index(current_privacy) except ValueError: privacy_idx = 0 new_privacy = st.selectbox("Tree Visibility (Conceptual)", privacy_opts, index=privacy_idx) st.caption("Note: Privacy settings are currently for display only and are not enforced.") # Collaborators (Conceptual - Replaced by phone index linking) # st.markdown("#### Collaborators (Managed Automatically)") # st.caption("Collaborators are automatically determined by who is included in the tree via their phone number.") st.markdown("---") save_label = "Save Settings" # Only owner can save settings if st.button(save_label): changed = False if metadata.get("tree_name") != new_tree_name: metadata["tree_name"] = new_tree_name; changed = True if settings.get("theme") != new_theme: settings["theme"] = new_theme; changed = True if settings.get("privacy") != new_privacy: settings["privacy"] = new_privacy; changed = True if changed: # Add edit provenance to metadata? Optional for settings. # metadata["last_edited_at"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") # metadata["last_edited_by"] = current_user if handle_save_or_propose("Settings Update"): # Should always save for owner st.rerun() else: st.info("No changes made to settings.")