"""Storage layer for file-based persistence.""" import json from datetime import datetime from pathlib import Path from typing import Optional import yaml from tracker.models.session import Session class FileStorage: """Maneja lectura/escritura de archivos del proyecto.""" def __init__(self, projects_root: Path): self.projects_root = projects_root def _project_path(self, slug: str) -> Path: return self.projects_root / slug def _meta_path(self, slug: str) -> Path: return self._project_path(slug) / "meta" / "project.yaml" def _log_path(self, slug: str) -> Path: return self._project_path(slug) / "LOG.md" def _changelog_path(self, slug: str) -> Path: return self._project_path(slug) / "CHANGELOG.md" def _tasks_path(self, slug: str) -> Path: return self._project_path(slug) / "TASKS.md" def _readme_path(self, slug: str) -> Path: return self._project_path(slug) / "README.md" def _sessions_path(self, slug: str) -> Path: return self._project_path(slug) / "sessions" def read_project_meta(self, slug: str) -> dict: """Lee projects//meta/project.yaml""" meta_path = self._meta_path(slug) with open(meta_path, "r", encoding="utf-8") as f: return yaml.safe_load(f) def write_project_meta(self, slug: str, data: dict) -> None: """Escribe projects//meta/project.yaml""" meta_path = self._meta_path(slug) meta_path.parent.mkdir(parents=True, exist_ok=True) with open(meta_path, "w", encoding="utf-8") as f: yaml.safe_dump(data, f, default_flow_style=False, allow_unicode=True) def read_log(self, slug: str) -> str: """Lee projects//LOG.md""" log_path = self._log_path(slug) if not log_path.exists(): return "" with open(log_path, "r", encoding="utf-8") as f: return f.read() def append_to_log(self, slug: str, entry: str) -> None: """Append a LOG.md entry.""" log_path = self._log_path(slug) with open(log_path, "a", encoding="utf-8") as f: f.write(entry) def read_changelog(self, slug: str) -> str: """Lee projects//CHANGELOG.md""" changelog_path = self._changelog_path(slug) if not changelog_path.exists(): return "" with open(changelog_path, "r", encoding="utf-8") as f: return f.read() def append_to_changelog(self, slug: str, change: str) -> None: """Append a CHANGELOG.md entry.""" changelog_path = self._changelog_path(slug) with open(changelog_path, "a", encoding="utf-8") as f: f.write(change) def read_tasks(self, slug: str) -> str: """Lee projects//TASKS.md""" tasks_path = self._tasks_path(slug) if not tasks_path.exists(): return "" with open(tasks_path, "r", encoding="utf-8") as f: return f.read() def write_tasks(self, slug: str, tasks_content: str) -> None: """Escribe projects//TASKS.md""" tasks_path = self._tasks_path(slug) with open(tasks_path, "w", encoding="utf-8") as f: f.write(tasks_content) def read_readme(self, slug: str) -> str: """Lee projects//README.md""" readme_path = self._readme_path(slug) if not readme_path.exists(): return "" with open(readme_path, "r", encoding="utf-8") as f: return f.read() def update_readme_autogen(self, slug: str, section: str, content: str) -> None: """Actualiza una seccion autogenerada en README.md. Busca ... y reemplaza el contenido entre esos marcadores. """ from tracker.storage.markdown_writer import MarkdownWriter readme_path = self._readme_path(slug) current_content = self.read_readme(slug) writer = MarkdownWriter() new_content = writer.format_autogen_section(current_content, section, content) with open(readme_path, "w", encoding="utf-8") as f: f.write(new_content) def write_session_file(self, session: Session) -> None: """Crea projects//sessions/YYYY-MM-DD_HHMM.md y el JSON correspondiente.""" from tracker.storage.markdown_writer import MarkdownWriter sessions_path = self._sessions_path(session.project_slug) sessions_path.mkdir(parents=True, exist_ok=True) started = session.started_at md_filename = started.strftime("%Y-%m-%d_%H%M.md") json_filename = f"{session.id}.json" # Write markdown file writer = MarkdownWriter() content = writer.format_session_file(session) md_path = sessions_path / md_filename with open(md_path, "w", encoding="utf-8") as f: f.write(content) # Write JSON file for tracking json_path = sessions_path / json_filename session_data = session.model_dump(mode="json") # Serialize datetime objects to ISO format if isinstance(session_data.get("started_at"), datetime): session_data["started_at"] = session_data["started_at"].isoformat() if isinstance(session_data.get("ended_at"), datetime): session_data["ended_at"] = session_data["ended_at"].isoformat() with open(json_path, "w", encoding="utf-8") as f: json.dump(session_data, f, indent=2, ensure_ascii=False, default=str) def active_session_path(self) -> Path: """Returns Path to projects/.active_session.json""" return self.projects_root / ".active_session.json" def read_active_session(self) -> Optional[dict]: """Lee la sesion activa desde .active_session.json""" path = self.active_session_path() if not path.exists(): return None with open(path, "r", encoding="utf-8") as f: return json.load(f) def write_active_session(self, session_data: dict) -> None: """Escribe la sesion activa a .active_session.json""" path = self.active_session_path() with open(path, "w", encoding="utf-8") as f: json.dump(session_data, f, indent=2, default=str) def delete_active_session(self) -> None: """Elimina .active_session.json""" path = self.active_session_path() if path.exists(): path.unlink() def write_file(self, slug: str, relative_path: str, content: str) -> None: """Escribe contenido a un archivo en el proyecto. Args: slug: Project slug. relative_path: Relative path within the project. content: Content to write. """ file_path = self._project_path(slug) / relative_path file_path.parent.mkdir(parents=True, exist_ok=True) with open(file_path, "w", encoding="utf-8") as f: f.write(content) def read_file(self, slug: str, relative_path: str) -> str: """Lee contenido de un archivo en el proyecto. Args: slug: Project slug. relative_path: Relative path within the project. Returns: File content or empty string if not found. """ file_path = self._project_path(slug) / relative_path if not file_path.exists(): return "" with open(file_path, "r", encoding="utf-8") as f: return f.read() def extract_autogen_section(self, slug: str, section: str) -> str: """Extrae contenido de una seccion AUTOGEN del README.md. Args: slug: Project slug. section: Section name (e.g., "SESSIONS", "NEXT_STEPS"). Returns: Content between AUTOGEN markers, or empty string if not found. """ from tracker.storage.markdown_reader import MarkdownReader reader = MarkdownReader() content = self.read_readme(slug) return reader.extract_autogen_section(content, section) def get_recent_sessions(self, slug: str, limit: int = 5) -> list: """Obtiene las sesiones mas recientes de un proyecto. Args: slug: Project slug. limit: Maximum number of sessions to return. Returns: List of Session objects sorted by date (most recent first). """ from tracker.models.session import Session sessions_path = self._sessions_path(slug) if not sessions_path.exists(): return [] sessions = [] for json_file in sessions_path.glob("*.json"): try: with open(json_file, "r", encoding="utf-8") as f: data = json.load(f) session = Session(**data) sessions.append(session) except (json.JSONDecodeError, TypeError): continue # Sort by started_at descending sessions.sort(key=lambda s: s.started_at, reverse=True) return sessions[:limit] def list_projects(self) -> list[str]: """Lista todos los slugs de proyectos. Returns: List of project slugs. """ if not self.projects_root.exists(): return [] return [d.name for d in self.projects_root.iterdir() if d.is_dir() and not d.name.startswith(".")] def project_exists(self, slug: str) -> bool: """Verifica si un proyecto existe. Args: slug: Project slug. Returns: True if project exists. """ return self._project_path(slug).exists()