feat(joplin): add --joplin flag to fetch weekly timesheet note from Joplin

- Add joplin.py with fetch_week_note() that walks Work > Timesheets > YYYY
  and returns the body of the matching YYYY-WNN note via joppy ClientApi
- Add filter_rows_by_date() to parser.py to extract only rows belonging
  to a specific day based on '# ... YYYY-MM-DD' headings in the document
- Update cli.py: input and --joplin are now a mutually exclusive required
  group; add --token flag with JOPLIN_TOKEN env var fallback; --date is
  parsed into a real date object used for both output and day filtering
- Add joppy as a runtime dependency (lazy-imported in cli.py)
- Add tests for filter_rows_by_date and full mocked coverage of joplin.py
- Update AGENTS.md with Joplin usage, notebook structure, and test rules

The actual Joplin structure has notes directly inside the year notebook
(Work > Timesheets > YYYY), not in per-week sub-notebooks as initially
assumed. fetch_week_note() reflects this flat structure.
This commit is contained in:
Jef Roosens 2026-05-22 10:33:00 +02:00
parent d6689a6c83
commit ecdd28e8a3
Signed by: Jef Roosens
GPG key ID: 119385BCAA005C21
8 changed files with 513 additions and 17 deletions

68
src/timesheets/joplin.py Normal file
View file

@ -0,0 +1,68 @@
"""
Joplin integration via the joppy ClientApi.
Actual notebook structure:
Work > Timesheets > YYYY
Notes live directly inside the year notebook and are titled 'YYYY - WNN'.
"""
from datetime import date
from typing import Optional
from joppy.client_api import ClientApi
def _iso_week_label(d: date) -> str:
"""Return the note title for the week containing the given date, e.g. '2026 - W21'."""
year, week, _ = d.isocalendar()
return f"{year} - W{week:02d}"
def _find_notebook(
api: ClientApi, title: str, parent_id: Optional[str] = None
) -> Optional[str]:
"""Return the ID of a notebook matching title (and optionally parent_id), or None."""
for nb in api.get_all_notebooks():
if nb.title == title:
if parent_id is None or nb.parent_id == parent_id:
return nb.id
return None
def fetch_week_note(token: str, target_date: date) -> str:
"""
Fetch the body of the weekly timesheet note from Joplin for the week
containing target_date.
Notebook path: Work > Timesheets > YYYY
Note title: YYYY - WNN
Raises RuntimeError if any notebook or the note cannot be found.
"""
api = ClientApi(token=token)
week_label = _iso_week_label(target_date)
year_str = str(target_date.year)
work_id = _find_notebook(api, "Work")
if work_id is None:
raise RuntimeError("Joplin notebook 'Work' not found")
timesheets_id = _find_notebook(api, "Timesheets", parent_id=work_id)
if timesheets_id is None:
raise RuntimeError("Joplin notebook 'Work > Timesheets' not found")
year_id = _find_notebook(api, year_str, parent_id=timesheets_id)
if year_id is None:
raise RuntimeError(
f"Joplin notebook 'Work > Timesheets > {year_str}' not found"
)
notes = api.get_all_notes(notebook_id=year_id, fields="id,title,body")
for note in notes:
if note.title == week_label:
return note.body
raise RuntimeError(
f"Joplin note '{week_label}' not found in 'Work > Timesheets > {year_str}'"
)