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)