import re import streamlit as st from google.oauth2 import service_account from googleapiclient.discovery import build import db def get_secret(key): import os # Try environment variables first env_val = os.environ.get(key.upper()) or os.environ.get(key) if env_val: if key == "gcp_service_account" or (isinstance(env_val, str) and env_val.strip().startswith("{")): import json try: return json.loads(env_val) except Exception: pass return env_val try: # Try Streamlit secrets first if hasattr(st, "secrets") and st.secrets and key in st.secrets: return st.secrets[key] except Exception: pass # Fallback to loading from toml directly import toml try: secrets_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".streamlit", "secrets.toml") if os.path.exists(secrets_path): data = toml.load(secrets_path) if key in data: return data[key] except Exception: pass return None def get_credentials(): creds_dict = get_secret("gcp_service_account") if not creds_dict: raise KeyError("gcp_service_account not found in secrets") return service_account.Credentials.from_service_account_info( dict(creds_dict), scopes=[ 'https://www.googleapis.com/auth/drive.readonly', 'https://www.googleapis.com/auth/documents.readonly' ] ) def extract_text_from_elements(elements): text = "" for value in elements: if 'paragraph' in value: for elem in value.get('paragraph', {}).get('elements', []): if 'textRun' in elem: text += elem.get('textRun', {}).get('content', '') return text def parse_problem_body(problem_name, body): # Match headers like "Method 1:" or "Method 2 (Two Pointers):" method_matches = list(re.finditer(r'Method\s*(\d+)(?:\s*\(([^)]+)\))?:', body, re.IGNORECASE)) difficulty = "Medium" diff_match = re.search(r'^\s*(Easy|Medium|Hard)\b', body, re.IGNORECASE | re.MULTILINE) if diff_match: difficulty = diff_match.group(1).capitalize() methods = [] # Extract description text: everything before the first Method match or Time Complexity line, excluding the difficulty word. desc_raw = "" if method_matches: desc_raw = body[:method_matches[0].start()] else: time_match = re.search(r'Time\s+Complexity:\s*([^\n\r]+)', body, re.IGNORECASE) if time_match: desc_raw = body[:time_match.start()] else: desc_raw = body description = re.sub(r'^\s*(Easy|Medium|Hard)\b', '', desc_raw, flags=re.IGNORECASE | re.MULTILINE).strip() if not method_matches: # Fallback for when there are no "Method X" headers (treat the whole body as one method) time_match = re.search(r'Time\s+Complexity:\s*([^\n\r]+)', body, re.IGNORECASE) space_match = re.search(r'Space\s+Complexity:\s*([^\n\r]+)', body, re.IGNORECASE) optimal_time = time_match.group(1).strip() if time_match else "O(N)" optimal_space = space_match.group(1).strip() if space_match else "O(N)" code = "" if space_match: start_pos = space_match.end() raw_code = body[start_pos:].strip() # Improved regex to handle leading indentation/spaces/tabs code_start_match = re.search(r'(?:^|\n)[ \t]*(?:def\s+|class\s+|import\s+|from\s+\S+\s+import|```[a-zA-Z]*)', raw_code) if code_start_match: raw_code = raw_code[code_start_match.start():].strip() # Clean up: remove "Code:" or "Reference Code:" if explicitly present raw_code = re.sub(r'^(Code|Reference Code):\s*', '', raw_code, flags=re.IGNORECASE) # Clean up: strip markdown backticks if present raw_code = re.sub(r'^```[a-zA-Z]*\s*', '', raw_code) raw_code = re.sub(r'\s*```$', '', raw_code) code = raw_code.strip() methods.append({ "name": f"{problem_name} - Method 1 (Standard)", "pattern": "Standard", "difficulty": difficulty, "optimal_time": optimal_time, "optimal_space": optimal_space, "code": code, "description": description }) else: for j, m_match in enumerate(method_matches): m_start = m_match.end() m_end = method_matches[j+1].start() if j + 1 < len(method_matches) else len(body) method_num = m_match.group(1).strip() method_name = m_match.group(2).strip() if m_match.group(2) else f"Method {method_num}" method_body = body[m_start:m_end] # Complexities time_match = re.search(r'Time\s+Complexity:\s*([^\n\r]+)', method_body, re.IGNORECASE) space_match = re.search(r'Space\s+Complexity:\s*([^\n\r]+)', method_body, re.IGNORECASE) optimal_time = time_match.group(1).strip() if time_match else "O(N)" optimal_space = space_match.group(1).strip() if space_match else "O(N)" # Extract code block: grab everything following the "Space Complexity" line code = "" if space_match: start_pos = space_match.end() raw_code = method_body[start_pos:].strip() # Improved regex to handle leading indentation/spaces/tabs code_start_match = re.search(r'(?:^|\n)[ \t]*(?:def\s+|class\s+|import\s+|from\s+\S+\s+import|```[a-zA-Z]*)', raw_code) if code_start_match: raw_code = raw_code[code_start_match.start():].strip() # Clean up: remove "Code:" or "Reference Code:" if explicitly present raw_code = re.sub(r'^(Code|Reference Code):\s*', '', raw_code, flags=re.IGNORECASE) # Clean up: strip markdown backticks if present raw_code = re.sub(r'^```[a-zA-Z]*\s*', '', raw_code) raw_code = re.sub(r'\s*```$', '', raw_code) code = raw_code.strip() methods.append({ "name": f"{problem_name} - Method {method_num} ({method_name})", "pattern": method_name, "difficulty": difficulty, "optimal_time": optimal_time, "optimal_space": optimal_space, "code": code, "description": description }) return methods def parse_multi_problem_doc(text): # Find all problem headers: e.g. "1. Two Sum" matches = list(re.finditer(r'^\s*(\d+)\.\s*([^\n\r]+)', text, re.MULTILINE)) problems = [] for i, match in enumerate(matches): start_idx = match.end() end_idx = matches[i+1].start() if i + 1 < len(matches) else len(text) problem_number = int(match.group(1).strip()) problem_name = match.group(2).strip() problem_body = text[start_idx:end_idx] parsed_methods = parse_problem_body(problem_name, problem_body) for m in parsed_methods: m['problem_number'] = problem_number problems.extend(parsed_methods) return problems def sync_gdrive(client=None): if client is None: client = db.get_supabase_client() try: creds = get_credentials() except KeyError: return "GCP credentials not found in secrets.toml" drive_service = build('drive', 'v3', credentials=creds) docs_service = build('docs', 'v1', credentials=creds) folder_id = get_secret("GDRIVE_FOLDER_ID") if not folder_id: return "GDRIVE_FOLDER_ID not found in secrets.toml" query = f"'{folder_id}' in parents and mimeType='application/vnd.google-apps.document' and trashed=false" results = drive_service.files().list(q=query, fields="nextPageToken, files(id, name)").execute() items = results.get('files', []) inserted_count = 0 updated_count = 0 for item in items: doc_id = item['id'] doc_name = item['name'] db.log_trace(f"Sync: Processing Doc '{doc_name}' ({doc_id})") # Fetch doc content doc = docs_service.documents().get(documentId=doc_id).execute() doc_content = doc.get('body').get('content') full_text = extract_text_from_elements(doc_content) parsed_problems = parse_multi_problem_doc(full_text) db.log_trace(f"Sync: Parsed {len(parsed_problems)} methods from '{doc_name}'") for p in parsed_problems: # Check if this specific problem/method name exists in DB for this user existing = db.get_problem_by_name(p['name'], client) problem_data = { "name": p['name'], "problem_number": p.get('problem_number'), "pattern": p['pattern'], "difficulty": p['difficulty'], "google_doc_url": f"https://docs.google.com/document/d/{doc_id}/edit", "google_doc_id": doc_id, "optimal_time": p['optimal_time'], "optimal_space": p['optimal_space'], "reference_code": p['code'], "description": p.get('description', '') } db.log_trace(f"Sync: Method '{p['name']}' -> code length={len(p['code'])}") if not existing: # Insert problem db.log_trace(f"Sync: Inserting new problem: '{p['name']}'") prob_res = db.insert_problem(problem_data, client) db.log_trace(f"Sync: Insert result data: {prob_res.data if prob_res else 'None'}") # Insert review queue starting at Box 1 if prob_res and prob_res.data: prob_id = prob_res.data[0]['id'] db.insert_review({ "problem_id": prob_id, "box_level": 1 }, client) inserted_count += 1 else: # Update problem in case anything changed db.log_trace(f"Sync: Updating existing problem: '{p['name']}' (ID={existing['id']})") update_res = db.update_problem(existing['id'], { "problem_number": p.get('problem_number'), "pattern": p['pattern'], "difficulty": p['difficulty'], "optimal_time": p['optimal_time'], "optimal_space": p['optimal_space'], "reference_code": p['code'], "description": p.get('description', '') }, client) db.log_trace(f"Sync: Update result data: {update_res.data if update_res else 'None'}") updated_count += 1 if p.get('problem_number'): db.insert_journey_progress(p['problem_number'], client) return f"Sync complete. Created {inserted_count} new methods, updated {updated_count} existing methods."