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

View file

@ -4,7 +4,7 @@ import sys
from datetime import date
from .output import print_summary, write_csv
from .parser import aggregate_rows, parse_document
from .parser import aggregate_rows, filter_rows_by_date, parse_document
from .projects import load_project_map
from .utils import format_date
@ -13,10 +13,29 @@ def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Parse a markdown timesheet table and output a CSV file."
)
parser.add_argument(
source = parser.add_mutually_exclusive_group(required=True)
source.add_argument(
"input",
nargs="?",
help="Path to the markdown file containing the timesheet table, or '-' to read from stdin.",
)
source.add_argument(
"--joplin",
action="store_true",
default=False,
help=(
"Fetch the weekly timesheet note from Joplin instead of reading a file. "
"Only entries for today (or --date) are included. "
"Requires a Joplin API token via --token or the JOPLIN_TOKEN environment variable."
),
)
parser.add_argument(
"--token",
help="Joplin API token. Falls back to the JOPLIN_TOKEN environment variable.",
default=None,
)
parser.add_argument(
"-o",
"--output",
@ -44,23 +63,66 @@ def build_parser() -> argparse.ArgumentParser:
return parser
def _resolve_token(args: argparse.Namespace) -> str:
token = args.token or os.environ.get("JOPLIN_TOKEN")
if not token:
print(
"Error: Joplin API token required. "
"Provide --token or set the JOPLIN_TOKEN environment variable.",
file=sys.stderr,
)
sys.exit(1)
return token
def _parse_date(date_str: str | None) -> date:
"""Parse DD/MM/YY date string, or return today."""
if date_str is None:
return date.today()
try:
from datetime import datetime
return datetime.strptime(date_str, "%d/%m/%y").date()
except ValueError:
print(
f"Error: invalid date format {date_str!r}, expected DD/MM/YY.",
file=sys.stderr,
)
sys.exit(1)
def main() -> None:
args = build_parser().parse_args()
date_str = args.date or format_date(date.today())
target_date = _parse_date(args.date)
date_str = format_date(target_date)
if args.input == "-":
content = sys.stdin.read()
else:
if args.joplin:
# Late import so joppy is only required when --joplin is used
from .joplin import fetch_week_note
token = _resolve_token(args)
try:
with open(args.input, "r", encoding="utf-8") as f:
content = f.read()
except FileNotFoundError:
print(f"Error: file not found: {args.input}", file=sys.stderr)
content = fetch_week_note(token, target_date)
except RuntimeError as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
lines = content.splitlines()
rows = parse_document(lines)
lines = content.splitlines()
rows = filter_rows_by_date(lines, target_date)
else:
if args.input == "-":
content = sys.stdin.read()
else:
try:
with open(args.input, "r", encoding="utf-8") as f:
content = f.read()
except FileNotFoundError:
print(f"Error: file not found: {args.input}", file=sys.stderr)
sys.exit(1)
lines = content.splitlines()
rows = parse_document(lines)
if not rows:
print("Warning: no timesheet rows found in input.", file=sys.stderr)

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}'"
)

View file

@ -1,8 +1,12 @@
import re
from collections import defaultdict
from datetime import date
from .utils import duration_from_start_end, parse_duration, strip_markdown_link
# Matches a date in YYYY-MM-DD format anywhere in a heading line
_DATE_HEADING_RE = re.compile(r"^#+.*?(\d{4}-\d{2}-\d{2})")
def _is_table_line(line: str) -> bool:
"""Return True if the line looks like part of a markdown table."""
@ -141,6 +145,37 @@ def parse_document(lines: list[str]) -> list[dict]:
return rows
def filter_rows_by_date(lines: list[str], target: date) -> list[dict]:
"""
Parse a document and return only rows that fall under the heading for
target date. Headings are detected by the pattern '# ... YYYY-MM-DD'.
Rows before the first dated heading are discarded.
"""
target_str = target.strftime("%Y-%m-%d")
sections: list[tuple[str | None, list[str]]] = []
current_date: str | None = None
current_lines: list[str] = []
for line in lines:
m = _DATE_HEADING_RE.match(line)
if m:
if current_lines:
sections.append((current_date, current_lines))
current_date = m.group(1)
current_lines = []
else:
current_lines.append(line)
if current_lines:
sections.append((current_date, current_lines))
rows: list[dict] = []
for section_date, section_lines in sections:
if section_date == target_str:
rows.extend(parse_document(section_lines))
return rows
def build_description(story: str, note: str) -> str:
"""Combine story and note into a single description string."""
parts = [p.strip() for p in [story, note] if p.strip()]