Implement storage layer for MVP-1 Personal Tracker CLI
Add storage layer with FileStorage, MarkdownReader, and MarkdownWriter classes. Add data models (Project, Session, Note, Change).
This commit is contained in:
1
tracker/__init__.py
Normal file
1
tracker/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Personal Tracker CLI - A Markdown-based project tracking system."""
|
||||
7
tracker/models/__init__.py
Normal file
7
tracker/models/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
"""Data models for the tracker."""
|
||||
from .project import Project
|
||||
from .session import Session
|
||||
from .note import Note, NoteType
|
||||
from .change import Change
|
||||
|
||||
__all__ = ["Project", "Session", "Note", "NoteType", "Change"]
|
||||
13
tracker/models/change.py
Normal file
13
tracker/models/change.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Change model definition."""
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import date
|
||||
|
||||
|
||||
class Change(BaseModel):
|
||||
"""Represents a notable change in a project."""
|
||||
|
||||
date: date
|
||||
type: str # code, infra, config, docs, automation, decision
|
||||
title: str
|
||||
impact: str = ""
|
||||
references: list[str] = Field(default_factory=list)
|
||||
22
tracker/models/note.py
Normal file
22
tracker/models/note.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""Note model definition."""
|
||||
from enum import Enum
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class NoteType(Enum):
|
||||
"""Types of notes that can be recorded during a session."""
|
||||
WORK = "work"
|
||||
CHANGE = "change"
|
||||
BLOCKER = "blocker"
|
||||
DECISION = "decision"
|
||||
IDEA = "idea"
|
||||
REFERENCE = "reference"
|
||||
|
||||
|
||||
class Note(BaseModel):
|
||||
"""Represents a note recorded during a session."""
|
||||
|
||||
type: NoteType
|
||||
text: str
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
21
tracker/models/project.py
Normal file
21
tracker/models/project.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Project model definition."""
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Project(BaseModel):
|
||||
"""Represents a tracked project."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
slug: str
|
||||
description: str = ""
|
||||
type: str = "misc" # code, homelab, automation, agent, research, misc
|
||||
status: str = "inbox" # inbox, next, active, blocked, waiting, done, archived
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
root_path: str = ""
|
||||
repo_path: Optional[str] = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
last_session_at: Optional[datetime] = None
|
||||
23
tracker/models/session.py
Normal file
23
tracker/models/session.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Session model definition."""
|
||||
from pydantic import BaseModel, Field
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Session(BaseModel):
|
||||
"""Represents a work session on a project."""
|
||||
|
||||
id: str
|
||||
project_slug: str
|
||||
started_at: datetime
|
||||
ended_at: Optional[datetime] = None
|
||||
duration_minutes: Optional[int] = None
|
||||
objective: str = ""
|
||||
summary: str = ""
|
||||
work_done: list[str] = Field(default_factory=list)
|
||||
changes: list[str] = Field(default_factory=list)
|
||||
decisions: list[str] = Field(default_factory=list)
|
||||
blockers: list[str] = Field(default_factory=list)
|
||||
next_steps: list[str] = Field(default_factory=list)
|
||||
references: list[str] = Field(default_factory=list)
|
||||
raw_notes: list[dict] = Field(default_factory=list) # [{"type": "work", "text": "...", "timestamp": "..."}]
|
||||
45
tracker/services/__init__.py
Normal file
45
tracker/services/__init__.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Services layer for business logic."""
|
||||
|
||||
from .session_service import (
|
||||
get_active_session,
|
||||
set_active_session,
|
||||
clear_active_session,
|
||||
get_active_session_path,
|
||||
validate_no_other_active_session,
|
||||
)
|
||||
from .project_service import (
|
||||
create_project,
|
||||
get_project,
|
||||
update_project,
|
||||
list_projects,
|
||||
get_projects_root,
|
||||
ensure_project_structure,
|
||||
)
|
||||
from .note_service import (
|
||||
add_note,
|
||||
consolidate_notes,
|
||||
)
|
||||
from .heuristics_service import (
|
||||
suggest_next_steps,
|
||||
)
|
||||
from .summary_service import (
|
||||
generate_summary,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"get_active_session",
|
||||
"set_active_session",
|
||||
"clear_active_session",
|
||||
"get_active_session_path",
|
||||
"validate_no_other_active_session",
|
||||
"create_project",
|
||||
"get_project",
|
||||
"update_project",
|
||||
"list_projects",
|
||||
"get_projects_root",
|
||||
"ensure_project_structure",
|
||||
"add_note",
|
||||
"consolidate_notes",
|
||||
"suggest_next_steps",
|
||||
"generate_summary",
|
||||
]
|
||||
47
tracker/services/heuristics_service.py
Normal file
47
tracker/services/heuristics_service.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""Heuristics service for suggestions based on rules."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from ..models import Session, Project
|
||||
|
||||
|
||||
def suggest_next_steps(session: Session, project: Project) -> list[str]:
|
||||
"""
|
||||
Generate suggestions based on session state and project context.
|
||||
Rules:
|
||||
- si hay blockers abiertos, sugerir "Destrabar: [bloqueos]"
|
||||
- si hay changes sin references, sugerir "Validar cambios recientes"
|
||||
- si work_done está vacío y session > 30 min, sugerir "Revisar progreso del objetivo"
|
||||
- si no hay next_steps definidos, sugerir "Definir próximos pasos"
|
||||
"""
|
||||
suggestions = []
|
||||
|
||||
# Rule: blockers open
|
||||
if session.blockers:
|
||||
for blocker in session.blockers:
|
||||
suggestions.append(f"Destrabar: {blocker}")
|
||||
|
||||
# Rule: changes without references
|
||||
changes_without_refs = []
|
||||
for change in session.changes:
|
||||
# Simple heuristic: if change doesn't reference anything specific
|
||||
if change and not any(ref in change.lower() for ref in ["#", "commit", "pr", "issue"]):
|
||||
changes_without_refs.append(change)
|
||||
|
||||
if changes_without_refs:
|
||||
suggestions.append("Validar cambios recientes")
|
||||
|
||||
# Rule: work_done empty and session > 30 minutes
|
||||
if not session.work_done:
|
||||
duration = session.duration_minutes
|
||||
if duration == 0 and session.ended_at and session.started_at:
|
||||
duration = int((session.ended_at - session.started_at).total_seconds() / 60)
|
||||
|
||||
if duration > 30:
|
||||
suggestions.append("Revisar progreso del objetivo")
|
||||
|
||||
# Rule: no next_steps defined
|
||||
if not session.next_steps:
|
||||
suggestions.append("Definir próximos pasos")
|
||||
|
||||
return suggestions
|
||||
65
tracker/services/note_service.py
Normal file
65
tracker/services/note_service.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Note service for note management."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from ..models import Session, NoteType, Note
|
||||
|
||||
|
||||
def add_note(session: Session, note_type: str, text: str) -> dict:
|
||||
"""
|
||||
Add a note to the session and return the note dict.
|
||||
Valid note types: work, change, blocker, decision, idea, reference
|
||||
"""
|
||||
try:
|
||||
note_type_enum = NoteType(note_type)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid note type: {note_type}. Valid types are: {[t.value for t in NoteType]}")
|
||||
|
||||
note = Note(type=note_type_enum, text=text)
|
||||
session.raw_notes.append(note.model_dump(mode="json"))
|
||||
|
||||
return {
|
||||
"type": note.type.value,
|
||||
"text": note.text,
|
||||
"created_at": note.created_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def consolidate_notes(raw_notes: list[dict]) -> dict:
|
||||
"""
|
||||
Consolidate raw notes into categorized sections.
|
||||
Returns dict with keys: work_done, changes, decisions, blockers, references
|
||||
"""
|
||||
result = {
|
||||
"work_done": [],
|
||||
"changes": [],
|
||||
"decisions": [],
|
||||
"blockers": [],
|
||||
"references": [],
|
||||
}
|
||||
|
||||
for note in raw_notes:
|
||||
if isinstance(note, dict):
|
||||
note_type = note.get("type", "")
|
||||
text = note.get("text", "")
|
||||
else:
|
||||
# Handle string format like "[type] text"
|
||||
parts = note.split("]", 1)
|
||||
if len(parts) == 2:
|
||||
note_type = parts[0][1:]
|
||||
text = parts[1].strip()
|
||||
else:
|
||||
continue
|
||||
|
||||
if note_type == NoteType.WORK.value:
|
||||
result["work_done"].append(text)
|
||||
elif note_type == NoteType.CHANGE.value:
|
||||
result["changes"].append(text)
|
||||
elif note_type == NoteType.DECISION.value:
|
||||
result["decisions"].append(text)
|
||||
elif note_type == NoteType.BLOCKER.value:
|
||||
result["blockers"].append(text)
|
||||
elif note_type == NoteType.REFERENCE.value:
|
||||
result["references"].append(text)
|
||||
|
||||
return result
|
||||
118
tracker/services/project_service.py
Normal file
118
tracker/services/project_service.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Project service for project management."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from ..models import Project
|
||||
|
||||
|
||||
_PROJECTS_ROOT = Path("projects")
|
||||
|
||||
|
||||
def get_projects_root() -> Path:
|
||||
"""Return the root directory for all projects."""
|
||||
return _PROJECTS_ROOT
|
||||
|
||||
|
||||
def _get_project_meta_path(slug: str) -> Path:
|
||||
"""Return the path to the project's meta/project.yaml file."""
|
||||
return _PROJECTS_ROOT / slug / "meta" / "project.yaml"
|
||||
|
||||
|
||||
def _get_project_readme_path(slug: str) -> Path:
|
||||
"""Return the path to the project's README.md file."""
|
||||
return _PROJECTS_ROOT / slug / "README.md"
|
||||
|
||||
|
||||
def create_project(
|
||||
name: str,
|
||||
slug: str,
|
||||
description: str = "",
|
||||
type: str = "misc",
|
||||
tags: Optional[list[str]] = None,
|
||||
repo_path: Optional[Path] = None,
|
||||
) -> Project:
|
||||
"""
|
||||
Create a new project and return the Project instance.
|
||||
Note: This does not write any files - that is handled by storage.
|
||||
"""
|
||||
if tags is None:
|
||||
tags = []
|
||||
|
||||
project = Project(
|
||||
id=str(uuid.uuid4()),
|
||||
name=name,
|
||||
slug=slug,
|
||||
description=description,
|
||||
type=type,
|
||||
status="inbox",
|
||||
tags=tags,
|
||||
root_path=_PROJECTS_ROOT / slug,
|
||||
repo_path=repo_path,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
)
|
||||
return project
|
||||
|
||||
|
||||
def get_project(slug: str) -> Optional[Project]:
|
||||
"""
|
||||
Get a project by slug.
|
||||
Note: This reads from file system - placeholder for storage integration.
|
||||
"""
|
||||
meta_path = _get_project_meta_path(slug)
|
||||
if not meta_path.exists():
|
||||
return None
|
||||
# TODO: Load from storage (YAML)
|
||||
return None
|
||||
|
||||
|
||||
def update_project(slug: str, **kwargs) -> Optional[Project]:
|
||||
"""
|
||||
Update a project's attributes.
|
||||
Note: This does not persist - that is handled by storage.
|
||||
"""
|
||||
project = get_project(slug)
|
||||
if project is None:
|
||||
return None
|
||||
|
||||
for key, value in kwargs.items():
|
||||
if hasattr(project, key):
|
||||
setattr(project, key, value)
|
||||
|
||||
project.updated_at = datetime.now()
|
||||
return project
|
||||
|
||||
|
||||
def list_projects() -> list[Project]:
|
||||
"""
|
||||
List all projects.
|
||||
Note: This reads from file system - placeholder for storage integration.
|
||||
"""
|
||||
projects_root = get_projects_root()
|
||||
if not projects_root.exists():
|
||||
return []
|
||||
|
||||
projects = []
|
||||
for item in projects_root.iterdir():
|
||||
if item.is_dir() and not item.name.startswith("."):
|
||||
project = get_project(item.name)
|
||||
if project is not None:
|
||||
projects.append(project)
|
||||
|
||||
return projects
|
||||
|
||||
|
||||
def ensure_project_structure(slug: str) -> None:
|
||||
"""
|
||||
Ensure the project directory structure exists.
|
||||
Creates: sessions/, docs/, assets/, meta/
|
||||
Note: This creates directories only - actual file writing is storage's job.
|
||||
"""
|
||||
project_root = _PROJECTS_ROOT / slug
|
||||
directories = ["sessions", "docs", "assets", "meta"]
|
||||
|
||||
for directory in directories:
|
||||
(project_root / directory).mkdir(parents=True, exist_ok=True)
|
||||
67
tracker/services/session_service.py
Normal file
67
tracker/services/session_service.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Session service for active session management."""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from ..models import Session
|
||||
|
||||
|
||||
_ACTIVE_SESSION_FILE = ".active_session.json"
|
||||
|
||||
|
||||
def get_active_session_path() -> Path:
|
||||
"""Return the path to the active session file in projects/ directory."""
|
||||
return Path("projects") / _ACTIVE_SESSION_FILE
|
||||
|
||||
|
||||
def get_active_session() -> Optional[Session]:
|
||||
"""Load and return the currently active session, or None if none exists."""
|
||||
path = get_active_session_path()
|
||||
if not path.exists():
|
||||
return None
|
||||
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Convert started_at string back to datetime
|
||||
data["started_at"] = datetime.fromisoformat(data["started_at"])
|
||||
if data.get("ended_at"):
|
||||
data["ended_at"] = datetime.fromisoformat(data["ended_at"])
|
||||
|
||||
return Session(**data)
|
||||
|
||||
|
||||
def set_active_session(session: Session) -> None:
|
||||
"""Save the given session as the active session."""
|
||||
path = get_active_session_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
data = session.model_dump(mode="json")
|
||||
# Serialize datetime objects to ISO format
|
||||
data["started_at"] = session.started_at.isoformat()
|
||||
if session.ended_at:
|
||||
data["ended_at"] = session.ended_at.isoformat()
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def clear_active_session() -> None:
|
||||
"""Remove the active session file."""
|
||||
path = get_active_session_path()
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
|
||||
|
||||
def validate_no_other_active_session(project_slug: str) -> bool:
|
||||
"""
|
||||
Check if there is an active session for a different project.
|
||||
Returns True if no conflict exists (i.e., either no active session
|
||||
or the active session belongs to the same project).
|
||||
"""
|
||||
active = get_active_session()
|
||||
if active is None:
|
||||
return True
|
||||
return active.project_slug == project_slug
|
||||
42
tracker/services/summary_service.py
Normal file
42
tracker/services/summary_service.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Summary service for heuristic summary generation."""
|
||||
|
||||
from ..models import Session
|
||||
from .note_service import consolidate_notes
|
||||
|
||||
|
||||
def generate_summary(session: Session) -> str:
|
||||
"""
|
||||
Generate a heuristic summary from the session.
|
||||
Uses consolidate_notes to extract work_done, decisions, blockers.
|
||||
"""
|
||||
# Consolidate raw notes into categorized sections
|
||||
consolidated = consolidate_notes(session.raw_notes)
|
||||
|
||||
lines = []
|
||||
|
||||
# Work done section
|
||||
if consolidated["work_done"]:
|
||||
lines.append("Trabajo realizado:")
|
||||
for item in consolidated["work_done"]:
|
||||
lines.append(f" - {item}")
|
||||
lines.append("")
|
||||
|
||||
# Decisions section
|
||||
if consolidated["decisions"]:
|
||||
lines.append("Decisiones:")
|
||||
for item in consolidated["decisions"]:
|
||||
lines.append(f" - {item}")
|
||||
lines.append("")
|
||||
|
||||
# Blockers section
|
||||
if consolidated["blockers"]:
|
||||
lines.append("Bloqueos:")
|
||||
for item in consolidated["blockers"]:
|
||||
lines.append(f" - {item}")
|
||||
lines.append("")
|
||||
|
||||
# If no content, provide a minimal summary
|
||||
if not lines:
|
||||
return f"Session de {session.duration_minutes} minutos sin progreso registrado."
|
||||
|
||||
return "\n".join(lines)
|
||||
5
tracker/storage/__init__.py
Normal file
5
tracker/storage/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from tracker.storage.file_storage import FileStorage
|
||||
from tracker.storage.markdown_reader import MarkdownReader
|
||||
from tracker.storage.markdown_writer import MarkdownWriter
|
||||
|
||||
__all__ = ["FileStorage", "MarkdownReader", "MarkdownWriter"]
|
||||
158
tracker/storage/file_storage.py
Normal file
158
tracker/storage/file_storage.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""Storage layer for file-based persistence."""
|
||||
|
||||
import json
|
||||
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"""
|
||||
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
|
||||
filename = started.strftime("%Y-%m-%d_%H%M.md")
|
||||
session_path = sessions_path / filename
|
||||
|
||||
writer = MarkdownWriter()
|
||||
content = writer.format_session_file(session)
|
||||
|
||||
with open(session_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
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()
|
||||
138
tracker/storage/markdown_reader.py
Normal file
138
tracker/storage/markdown_reader.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""Markdown reader utility."""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class MarkdownReader:
|
||||
"""Lectura de archivos Markdown del proyecto."""
|
||||
|
||||
def parse_log_entry(self, content: str) -> dict:
|
||||
"""Parse una entrada de LOG.md.
|
||||
|
||||
Formato esperado:
|
||||
## 2026-03-23 10:00–11:20
|
||||
**Objetivo**
|
||||
...
|
||||
**Trabajo realizado**
|
||||
- ...
|
||||
**Cambios relevantes**
|
||||
- ...
|
||||
**Bloqueos**
|
||||
- ...
|
||||
**Decisiones**
|
||||
- ...
|
||||
**Próximos pasos**
|
||||
- ...
|
||||
**Resumen**
|
||||
...
|
||||
|
||||
Returns dict con:
|
||||
- date_range: str
|
||||
- objective: str
|
||||
- work_done: list[str]
|
||||
- changes: list[str]
|
||||
- blockers: list[str]
|
||||
- decisions: list[str]
|
||||
- next_steps: list[str]
|
||||
- summary: str
|
||||
"""
|
||||
result = {
|
||||
"date_range": "",
|
||||
"objective": "",
|
||||
"work_done": [],
|
||||
"changes": [],
|
||||
"blockers": [],
|
||||
"decisions": [],
|
||||
"next_steps": [],
|
||||
"summary": "",
|
||||
}
|
||||
|
||||
# Extraer fecha/rango
|
||||
date_match = re.search(r"##\s+(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}[–-]\d{2}:\d{2})", content)
|
||||
if date_match:
|
||||
result["date_range"] = date_match.group(1)
|
||||
|
||||
# Extraer secciones
|
||||
sections = {
|
||||
"objective": r"\*\*Objetivo\*\*\s*\n(.*?)(?=\n\*\*|\n##|\Z)",
|
||||
"work_done": r"\*\*Trabajo realizado\*\*\s*\n(.*?)(?=\n\*\*|\n##|\Z)",
|
||||
"changes": r"\*\*Cambios relevantes\*\*\s*\n(.*?)(?=\n\*\*|\n##|\Z)",
|
||||
"blockers": r"\*\*Bloqueos\*\*\s*\n(.*?)(?=\n\*\*|\n##|\Z)",
|
||||
"decisions": r"\*\*Decisiones\*\*\s*\n(.*?)(?=\n\*\*|\n##|\Z)",
|
||||
"next_steps": r"\*\*Próximos pasos\*\*\s*\n(.*?)(?=\n\*\*|\n##|\Z)",
|
||||
"summary": r"\*\*Resumen\*\*\s*\n(.*?)(?=\n##|\Z)",
|
||||
}
|
||||
|
||||
for key, pattern in sections.items():
|
||||
match = re.search(pattern, content, re.DOTALL)
|
||||
if match:
|
||||
text = match.group(1).strip()
|
||||
if key in ("work_done", "changes", "blockers", "decisions", "next_steps"):
|
||||
# Extraer listas con bullet points
|
||||
items = re.findall(r"^\s*-\s+(.+)$", text, re.MULTILINE)
|
||||
result[key] = items
|
||||
else:
|
||||
result[key] = text
|
||||
|
||||
return result
|
||||
|
||||
def extract_autogen_section(self, content: str, section: str) -> str:
|
||||
"""Extrae contenido de una seccion AUTOGEN.
|
||||
|
||||
Busca <!-- AUTOGEN:{section}_START --> ... <!-- AUTOGEN:{section}_END -->
|
||||
Returns el contenido entre esos marcadores, o string vacio si no existe.
|
||||
"""
|
||||
pattern = rf"<!--\s*AUTOGEN:{section}_START\s*-->(.*?)<!--\s*AUTOGEN:{section}_END\s*-->"
|
||||
match = re.search(pattern, content, re.DOTALL)
|
||||
if match:
|
||||
return match.group(1).strip()
|
||||
return ""
|
||||
|
||||
def parse_tasks(self, content: str) -> dict:
|
||||
"""Parse TASKS.md por secciones.
|
||||
|
||||
Secciones esperadas:
|
||||
- Inbox
|
||||
- Próximo
|
||||
- En curso
|
||||
- Bloqueado
|
||||
- En espera
|
||||
- Hecho
|
||||
|
||||
Returns dict con nombre de seccion -> lista de tareas
|
||||
"""
|
||||
result = {}
|
||||
current_section = None
|
||||
current_tasks = []
|
||||
|
||||
lines = content.split("\n")
|
||||
|
||||
for line in lines:
|
||||
# Detectar headers de seccion (## )
|
||||
section_match = re.match(r"^##\s+(.+)$", line)
|
||||
if section_match:
|
||||
# Guardar seccion anterior
|
||||
if current_section is not None:
|
||||
result[current_section] = current_tasks
|
||||
|
||||
current_section = section_match.group(1).strip()
|
||||
current_tasks = []
|
||||
elif current_section is not None:
|
||||
# Parsear bullet points
|
||||
task_match = re.match(r"^\s*-\s+\[([ x])\]\s*(.+)$", line)
|
||||
if task_match:
|
||||
checked = task_match.group(1) == "x"
|
||||
task_text = task_match.group(2).strip()
|
||||
current_tasks.append({"text": task_text, "done": checked})
|
||||
elif line.strip():
|
||||
# Lineas que no son bullet ni header, agregar a la ultima tarea
|
||||
if current_tasks:
|
||||
current_tasks[-1]["text"] += " " + line.strip()
|
||||
|
||||
# Guardar ultima seccion
|
||||
if current_section is not None:
|
||||
result[current_section] = current_tasks
|
||||
|
||||
return result
|
||||
241
tracker/storage/markdown_writer.py
Normal file
241
tracker/storage/markdown_writer.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""Markdown writer utility."""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from tracker.models.session import Session
|
||||
|
||||
|
||||
class MarkdownWriter:
|
||||
"""Escritura de archivos Markdown del proyecto."""
|
||||
|
||||
def format_log_entry(self, session: Session, summary: str) -> str:
|
||||
"""Formatea una entrada para LOG.md.
|
||||
|
||||
Formato:
|
||||
## 2026-03-23 10:00–11:20
|
||||
**Objetivo**
|
||||
...
|
||||
|
||||
**Trabajo realizado**
|
||||
- ...
|
||||
|
||||
**Cambios relevantes**
|
||||
- ...
|
||||
|
||||
**Bloqueos**
|
||||
- ...
|
||||
|
||||
**Decisiones**
|
||||
- ...
|
||||
|
||||
**Próximos pasos**
|
||||
- ...
|
||||
|
||||
**Resumen**
|
||||
...
|
||||
|
||||
Returns string formateado.
|
||||
"""
|
||||
started = session.started_at.strftime("%Y-%m-%d %H:%M")
|
||||
ended = session.ended_at.strftime("%H:%M") if session.ended_at else "En Curso"
|
||||
date_range = f"{started}–{ended}"
|
||||
|
||||
lines = [
|
||||
f"## {date_range}",
|
||||
"",
|
||||
"**Objetivo**",
|
||||
f"{session.objective or 'No especificado'}",
|
||||
"",
|
||||
"**Trabajo realizado**",
|
||||
]
|
||||
|
||||
if session.work_done:
|
||||
for item in session.work_done:
|
||||
lines.append(f"- {item}")
|
||||
else:
|
||||
lines.append("- Sin trabajo registrado")
|
||||
|
||||
lines.extend(["", "**Cambios relevantes**"])
|
||||
if session.changes:
|
||||
for item in session.changes:
|
||||
lines.append(f"- {item}")
|
||||
else:
|
||||
lines.append("- Sin cambios")
|
||||
|
||||
lines.extend(["", "**Bloqueos**"])
|
||||
if session.blockers:
|
||||
for item in session.blockers:
|
||||
lines.append(f"- {item}")
|
||||
else:
|
||||
lines.append("- Sin bloqueos")
|
||||
|
||||
lines.extend(["", "**Decisiones**"])
|
||||
if session.decisions:
|
||||
for item in session.decisions:
|
||||
lines.append(f"- {item}")
|
||||
else:
|
||||
lines.append("- Sin decisiones")
|
||||
|
||||
lines.extend(["", "**Próximos pasos**"])
|
||||
if session.next_steps:
|
||||
for item in session.next_steps:
|
||||
lines.append(f"- {item}")
|
||||
else:
|
||||
lines.append("- Sin pasos definidos")
|
||||
|
||||
lines.extend(["", "**Resumen**", summary])
|
||||
|
||||
return "\n".join(lines) + "\n\n"
|
||||
|
||||
def format_session_file(self, session: Session) -> str:
|
||||
"""Formatea archivo de sesion detalle en sessions/YYYY-MM-DD_HHMM.md.
|
||||
|
||||
Formato:
|
||||
# Sesion: 2026-03-23 10:00–11:20
|
||||
|
||||
## Objetivo
|
||||
...
|
||||
|
||||
## Notas
|
||||
...
|
||||
|
||||
## Trabajo realizado
|
||||
...
|
||||
|
||||
## Cambios
|
||||
...
|
||||
|
||||
## Decisiones
|
||||
...
|
||||
|
||||
## Bloqueos
|
||||
...
|
||||
|
||||
## Proximos pasos
|
||||
...
|
||||
|
||||
## Referencias
|
||||
...
|
||||
|
||||
## Duracion
|
||||
X minutos
|
||||
"""
|
||||
started = session.started_at.strftime("%Y-%m-%d %H:%M")
|
||||
ended = session.ended_at.strftime("%H:%M") if session.ended_at else "En Curso"
|
||||
|
||||
lines = [
|
||||
f"# Sesion: {started}–{ended}",
|
||||
"",
|
||||
"## Objetivo",
|
||||
f"{session.objective or 'No especificado'}",
|
||||
"",
|
||||
"## Notas",
|
||||
]
|
||||
|
||||
if session.raw_notes:
|
||||
for note in session.raw_notes:
|
||||
note_type = note.get("type", "work")
|
||||
note_text = note.get("text", "")
|
||||
lines.append(f"- [{note_type}] {note_text}")
|
||||
else:
|
||||
lines.append("- Sin notas")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"## Trabajo realizado",
|
||||
])
|
||||
if session.work_done:
|
||||
for item in session.work_done:
|
||||
lines.append(f"- {item}")
|
||||
else:
|
||||
lines.append("- Sin trabajo realizado")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"## Cambios",
|
||||
])
|
||||
if session.changes:
|
||||
for item in session.changes:
|
||||
lines.append(f"- {item}")
|
||||
else:
|
||||
lines.append("- Sin cambios")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"## Decisiones",
|
||||
])
|
||||
if session.decisions:
|
||||
for item in session.decisions:
|
||||
lines.append(f"- {item}")
|
||||
else:
|
||||
lines.append("- Sin decisiones")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"## Bloqueos",
|
||||
])
|
||||
if session.blockers:
|
||||
for item in session.blockers:
|
||||
lines.append(f"- {item}")
|
||||
else:
|
||||
lines.append("- Sin bloqueos")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"## Proximos pasos",
|
||||
])
|
||||
if session.next_steps:
|
||||
for item in session.next_steps:
|
||||
lines.append(f"- {item}")
|
||||
else:
|
||||
lines.append("- Sin pasos definidos")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"## Referencias",
|
||||
])
|
||||
if session.references:
|
||||
for item in session.references:
|
||||
lines.append(f"- {item}")
|
||||
else:
|
||||
lines.append("- Sin referencias")
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"## Duracion",
|
||||
f"{session.duration_minutes} minutos",
|
||||
])
|
||||
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
def format_autogen_section(self, content: str, section: str, new_content: str) -> str:
|
||||
"""Reemplaza o inserta una seccion AUTOGEN en contenido Markdown.
|
||||
|
||||
Busca <!-- AUTOGEN:{section}_START --> ... <!-- AUTOGEN:{section}_END -->
|
||||
Si existe, reemplaza el contenido entre los marcadores.
|
||||
Si no existe, inserta la seccion al final.
|
||||
|
||||
Returns el contenido modificado.
|
||||
"""
|
||||
start_marker = f"<!-- AUTOGEN:{section}_START -->"
|
||||
end_marker = f"<!-- AUTOGEN:{section}_END -->"
|
||||
|
||||
full_marker = f"{start_marker}\n{new_content}\n{end_marker}"
|
||||
|
||||
# Buscar si existe la seccion
|
||||
pattern = rf"{re.escape(start_marker)}.*?{re.escape(end_marker)}"
|
||||
if re.search(pattern, content, re.DOTALL):
|
||||
# Reemplazar seccion existente
|
||||
return re.sub(pattern, full_marker, content, flags=re.DOTALL)
|
||||
else:
|
||||
# Insertar al final
|
||||
return content + "\n" + full_marker + "\n"
|
||||
|
||||
def format_readme_section(self, section: str, content: str) -> str:
|
||||
"""Formatea una seccion de README.md.
|
||||
|
||||
Para usar con format_autogen_section.
|
||||
"""
|
||||
return content
|
||||
Reference in New Issue
Block a user