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
116
tests/test_joplin.py
Normal file
116
tests/test_joplin.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
"""
|
||||
Tests for the Joplin integration module.
|
||||
|
||||
All tests mock the joppy ClientApi so no live Joplin instance is required.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from timesheets.joplin import _iso_week_label, fetch_week_note
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _iso_week_label
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIsoWeekLabel:
|
||||
@pytest.mark.parametrize(
|
||||
"d, expected",
|
||||
[
|
||||
(date(2026, 5, 22), "2026 - W21"),
|
||||
(date(2026, 5, 18), "2026 - W21"), # Monday of same week
|
||||
(date(2026, 1, 1), "2026 - W01"),
|
||||
(date(2026, 12, 28), "2026 - W53"),
|
||||
],
|
||||
)
|
||||
def test_label(self, d, expected):
|
||||
assert _iso_week_label(d) == expected
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fetch_week_note — mocked API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_notebook(id_, title, parent_id=""):
|
||||
nb = MagicMock()
|
||||
nb.id = id_
|
||||
nb.title = title
|
||||
nb.parent_id = parent_id
|
||||
return nb
|
||||
|
||||
|
||||
def _make_note(id_, title, body):
|
||||
note = MagicMock()
|
||||
note.id = id_
|
||||
note.title = title
|
||||
note.body = body
|
||||
return note
|
||||
|
||||
|
||||
def _build_api(note_body: str, week_label: str = "2026 - W21"):
|
||||
"""Return a mocked ClientApi that serves a minimal notebook tree."""
|
||||
api = MagicMock()
|
||||
api.get_all_notebooks.return_value = [
|
||||
_make_notebook("work_id", "Work", parent_id=""),
|
||||
_make_notebook("ts_id", "Timesheets", parent_id="work_id"),
|
||||
_make_notebook("yr_id", "2026", parent_id="ts_id"),
|
||||
]
|
||||
api.get_all_notes.return_value = [
|
||||
_make_note("note_id", week_label, note_body),
|
||||
]
|
||||
return api
|
||||
|
||||
|
||||
class TestFetchWeekNote:
|
||||
def _patch(self, api):
|
||||
return patch("timesheets.joplin.ClientApi", return_value=api)
|
||||
|
||||
def test_returns_note_body(self):
|
||||
api = _build_api("# content")
|
||||
with self._patch(api):
|
||||
result = fetch_week_note("dummy_token", date(2026, 5, 22))
|
||||
assert result == "# content"
|
||||
|
||||
def test_constructs_client_with_token(self):
|
||||
api = _build_api("body")
|
||||
with patch("timesheets.joplin.ClientApi", return_value=api) as mock_cls:
|
||||
fetch_week_note("my_secret_token", date(2026, 5, 22))
|
||||
mock_cls.assert_called_once_with(token="my_secret_token")
|
||||
|
||||
def test_missing_work_notebook_raises(self):
|
||||
api = MagicMock()
|
||||
api.get_all_notebooks.return_value = []
|
||||
with self._patch(api):
|
||||
with pytest.raises(RuntimeError, match="'Work' not found"):
|
||||
fetch_week_note("token", date(2026, 5, 22))
|
||||
|
||||
def test_missing_timesheets_notebook_raises(self):
|
||||
api = MagicMock()
|
||||
api.get_all_notebooks.return_value = [
|
||||
_make_notebook("work_id", "Work", parent_id=""),
|
||||
]
|
||||
with self._patch(api):
|
||||
with pytest.raises(RuntimeError, match="Timesheets"):
|
||||
fetch_week_note("token", date(2026, 5, 22))
|
||||
|
||||
def test_missing_year_notebook_raises(self):
|
||||
api = MagicMock()
|
||||
api.get_all_notebooks.return_value = [
|
||||
_make_notebook("work_id", "Work", parent_id=""),
|
||||
_make_notebook("ts_id", "Timesheets", parent_id="work_id"),
|
||||
]
|
||||
with self._patch(api):
|
||||
with pytest.raises(RuntimeError, match="2026"):
|
||||
fetch_week_note("token", date(2026, 5, 22))
|
||||
|
||||
def test_missing_note_raises(self):
|
||||
api = _build_api("body", week_label="2026 - W21")
|
||||
# Return a note with the wrong title
|
||||
api.get_all_notes.return_value = [_make_note("x", "wrong title", "body")]
|
||||
with self._patch(api):
|
||||
with pytest.raises(RuntimeError, match="note '2026 - W21' not found"):
|
||||
fetch_week_note("token", date(2026, 5, 22))
|
||||
|
|
@ -7,6 +7,7 @@ from timesheets.parser import (
|
|||
build_description,
|
||||
detect_has_duration_column,
|
||||
extract_table_blocks,
|
||||
filter_rows_by_date,
|
||||
parse_document,
|
||||
parse_table,
|
||||
)
|
||||
|
|
@ -215,6 +216,67 @@ class TestParseDocument:
|
|||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# filter_rows_by_date
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestFilterRowsByDate:
|
||||
# Reuse the W21 file which has one table per day-heading
|
||||
with open(WEEK_FILE, encoding="utf-8") as _f:
|
||||
_WEEK_LINES = _f.read().splitlines()
|
||||
|
||||
def test_returns_only_matching_day(self):
|
||||
from datetime import date
|
||||
|
||||
rows = filter_rows_by_date(self._WEEK_LINES, date(2026, 5, 22))
|
||||
assert len(rows) > 0
|
||||
# Friday has these projects in the sample file
|
||||
projects = {r["project"] for r in rows}
|
||||
assert "scrum" in projects
|
||||
|
||||
def test_different_day_returns_different_rows(self):
|
||||
from datetime import date
|
||||
|
||||
rows_fri = filter_rows_by_date(self._WEEK_LINES, date(2026, 5, 22))
|
||||
rows_mon = filter_rows_by_date(self._WEEK_LINES, date(2026, 5, 18))
|
||||
assert rows_fri != rows_mon
|
||||
assert len(rows_mon) > 0
|
||||
|
||||
def test_no_match_returns_empty(self):
|
||||
from datetime import date
|
||||
|
||||
rows = filter_rows_by_date(self._WEEK_LINES, date(2026, 1, 1))
|
||||
assert rows == []
|
||||
|
||||
def test_inline_document(self):
|
||||
from datetime import date
|
||||
|
||||
lines = [
|
||||
"# Maandag - 2026-05-18",
|
||||
"| Start | End | Project | Story | Note |",
|
||||
"|-------|-------|---------|-------|------||",
|
||||
"| 08:00 | 08:30 | bugs | | fix |",
|
||||
"",
|
||||
"# Dinsdag - 2026-05-19",
|
||||
"| Start | End | Project | Story | Note |",
|
||||
"|-------|-------|---------|-------|------||",
|
||||
"| 09:00 | 09:30 | scrum | | dsu |",
|
||||
]
|
||||
rows = filter_rows_by_date(lines, date(2026, 5, 18))
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["project"] == "bugs"
|
||||
|
||||
rows = filter_rows_by_date(lines, date(2026, 5, 19))
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["project"] == "scrum"
|
||||
|
||||
def test_empty_input(self):
|
||||
from datetime import date
|
||||
|
||||
assert filter_rows_by_date([], date(2026, 5, 22)) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# build_description
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue