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:
parent
d6689a6c83
commit
ecdd28e8a3
8 changed files with 513 additions and 17 deletions
|
|
@ -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
68
src/timesheets/joplin.py
Normal 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}'"
|
||||
)
|
||||
|
|
@ -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()]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue