Files
tracker-cli/tracker/storage/file_storage.py
Daniel Arroyo b36b60353d Implement complete CLI commands for MVP-1 Personal Tracker
- Refactored CLI commands from nested Typer subapps to direct command functions
- Fixed main.py to use app.command() instead of app.add_typer_command()
- Fixed project_service.py to properly load projects from YAML
- Fixed file_storage.py to save session JSON files alongside markdown
- Added missing methods: write_file, read_file, extract_autogen_section, get_recent_sessions
- Fixed root_path and repo_path to use strings instead of Path objects
2026-03-23 09:02:21 -03:00

267 lines
9.4 KiB
Python

"""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/<slug>/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/<slug>/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/<slug>/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/<slug>/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/<slug>/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/<slug>/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/<slug>/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 <!-- AUTOGEN:{section}_START --> ... <!-- AUTOGEN:{section}_END -->
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/<slug>/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()