From de46399010768bb22bbfc34263b066c4933ca604 Mon Sep 17 00:00:00 2001 From: Jef Roosens Date: Thu, 28 May 2026 13:37:44 +0200 Subject: [PATCH] Add ~ marker to exclude entries from CSV export Prefix the project name or note column with ~ to mark an entry as count-but-don't-export. Marked entries are included in summary and status totals but omitted from all csv output (both --raw and aggregated, single-day and weekly). | 09:00 | 17:00 | 8:00 | ~Leave | | Day off | | 09:00 | 17:00 | 8:00 | Leave | | ~Day off | The ~ is stripped from whichever field carries it before any downstream processing, so project map resolution is unaffected. Implementation: - parse_table sets skip_csv=True on marked rows and strips the ~ - new filter_skip_csv() helper in parser.py - to_csv_entries() skips skip_csv rows - _cmd_csv calls filter_skip_csv() before aggregate_rows() --- README.md | 24 ++++++++ src/timesheets/cli.py | 9 ++- src/timesheets/output.py | 2 +- src/timesheets/parser.py | 64 +++++++++++++------- tests/test_output.py | 13 +++++ tests/test_parser.py | 122 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 209 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 34da466..8dd7ae4 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,30 @@ uv run timesheets stories -w --joplin -o stories.md # write to file uv run timesheets status --joplin ``` +## Timesheet table format + +Each day's entries live in a markdown pipe table with the following columns: + +```markdown +| Start | End | Duration | Project | Story | Note | +|-------|-------|----------|---------|-----------|------------| +| 09:00 | 10:00 | 1:00 | acme | PROJ-123 | Fix bug | +| 10:00 | 10:15 | 0:15 | scrum | | dsu | +``` + +### Excluding entries from CSV export + +Prefix the project name with `~` to mark an entry as **count-but-don't-export**. +The entry is included in `summary` and `status` totals, but omitted from `csv` output. +This is useful for time that is already pre-entered in Odoo (e.g. holidays, days off). + +```markdown +| 09:00 | 17:00 | 8:00 | ~Leave | | Public holiday | +``` + +The `~` is stripped before project name resolution, so `~Leave` maps to the same +project as `Leave` in your `project_map.json`. + ## Config file Copy `timesheets.example.toml` to `timesheets.toml` in the working directory diff --git a/src/timesheets/cli.py b/src/timesheets/cli.py index 8de634f..98dcb95 100644 --- a/src/timesheets/cli.py +++ b/src/timesheets/cli.py @@ -27,6 +27,7 @@ from .output import ( from .parser import ( aggregate_rows, filter_rows_by_date, + filter_skip_csv, filter_week_sections, parse_document, resolve_overlaps, @@ -436,7 +437,9 @@ def _cmd_csv(args: argparse.Namespace, config: dict) -> None: day_sections = [ ( _fmt(day), - to_csv_entries(rows) if args.raw else aggregate_rows(rows), + to_csv_entries(rows) + if args.raw + else aggregate_rows(filter_skip_csv(rows)), ) for day, rows in day_sections_raw ] @@ -450,7 +453,9 @@ def _cmd_csv(args: argparse.Namespace, config: dict) -> None: rows = _resolve_rows(args, config, target_date) if not rows: print("Warning: no timesheet rows found in input.", file=sys.stderr) - entries = to_csv_entries(rows) if args.raw else aggregate_rows(rows) + entries = ( + to_csv_entries(rows) if args.raw else aggregate_rows(filter_skip_csv(rows)) + ) if args.output: with open(args.output, "w", newline="", encoding="utf-8") as f: write_csv(entries, f, _fmt(target_date), project_map) diff --git a/src/timesheets/output.py b/src/timesheets/output.py index 6c70113..d56100e 100644 --- a/src/timesheets/output.py +++ b/src/timesheets/output.py @@ -27,7 +27,7 @@ def to_csv_entries(rows: list[dict]) -> list[dict]: "quantity": row["duration_hours"], } for row in rows - if row["duration_hours"] is not None + if row["duration_hours"] is not None and not row.get("skip_csv") ] diff --git a/src/timesheets/parser.py b/src/timesheets/parser.py index 35c472d..907dbd8 100644 --- a/src/timesheets/parser.py +++ b/src/timesheets/parser.py @@ -103,6 +103,19 @@ def parse_table(lines: list[str], has_duration_col: bool = True) -> list[dict]: note = strip_markdown_link(cells[4]) duration = None + project_stripped = project.strip() + if project_stripped.startswith("~"): + skip_csv = True + project = project_stripped[1:].strip() + else: + skip_csv = False + project = project_stripped + + note_stripped = note.strip() + if note_stripped.startswith("~"): + skip_csv = True + note = note_stripped[1:].strip() + if start.lower() == "start": continue if not re.match(r"^\d+:\d{2}$", start): @@ -112,17 +125,18 @@ def parse_table(lines: list[str], has_duration_col: bool = True) -> list[dict]: if not re.match(r"^\d+:\d{2}$", end): if duration is None: # No end and no duration — preserve as an open entry - rows.append( - { - "start": start, - "end": None, - "duration_hours": None, - "project": project, - "story": story, - "story_raw": story_raw, - "note": note, - } - ) + row = { + "start": start, + "end": None, + "duration_hours": None, + "project": project, + "story": story, + "story_raw": story_raw, + "note": note, + } + if skip_csv: + row["skip_csv"] = True + rows.append(row) continue if duration is not None: @@ -135,17 +149,18 @@ def parse_table(lines: list[str], has_duration_col: bool = True) -> list[dict]: except ValueError: continue - rows.append( - { - "start": start, - "end": end, - "duration_hours": duration_hours, - "project": project, - "story": story, - "story_raw": story_raw, - "note": note, - } - ) + row = { + "start": start, + "end": end, + "duration_hours": duration_hours, + "project": project, + "story": story, + "story_raw": story_raw, + "note": note, + } + if skip_csv: + row["skip_csv"] = True + rows.append(row) return rows @@ -354,6 +369,11 @@ def resolve_overlaps(rows: list[dict]) -> list[dict]: return open_entries + closed +def filter_skip_csv(rows: list[dict]) -> list[dict]: + """Return a copy of rows with any skip_csv-marked entries removed.""" + return [r for r in rows if not r.get("skip_csv")] + + 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()] diff --git a/tests/test_output.py b/tests/test_output.py index b10531c..0f130da 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -72,6 +72,19 @@ class TestToCsvEntries: rows = [_raw_row(" bugs ", "", "dsu", 0.25)] assert to_csv_entries(rows)[0]["project"] == "bugs" + def test_skips_skip_csv_rows(self): + row = {**_raw_row("Leave", "", "Day off", 8.0), "skip_csv": True} + assert to_csv_entries([row]) == [] + + def test_skip_csv_row_mixed_with_normal(self): + rows = [ + {**_raw_row("Leave", "", "Day off", 8.0), "skip_csv": True}, + _raw_row("bugs", "ticket 1", "", 1.0), + ] + entries = to_csv_entries(rows) + assert len(entries) == 1 + assert entries[0]["project"] == "bugs" + def test_mixed_open_and_closed(self): rows = [ _raw_row("bugs", "ticket 1", "", 1.0), diff --git a/tests/test_parser.py b/tests/test_parser.py index d3fa738..a9a1d8e 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -8,6 +8,7 @@ from timesheets.parser import ( detect_has_duration_column, extract_table_blocks, filter_rows_by_date, + filter_skip_csv, parse_document, parse_table, resolve_overlaps, @@ -351,6 +352,127 @@ class TestBuildDescription: # --------------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# skip_csv marker (~project) +# --------------------------------------------------------------------------- + + +class TestParseTableSkipCsv: + _LINES = [ + "| Start | End | Duration | Project | Story | Note |", + "|-------|-------|----------|---------|-------|----------|", + "| 09:00 | 17:00 | 8:00 | ~Leave | | Day off |", + "| 09:00 | 10:00 | 1:00 | work | st | |", + ] + + def test_tilde_sets_skip_csv(self): + rows = parse_table(self._LINES) + leave = next(r for r in rows if "Leave" in r["project"]) + assert leave["skip_csv"] is True + + def test_tilde_stripped_from_project(self): + rows = parse_table(self._LINES) + leave = next(r for r in rows if r.get("skip_csv")) + assert leave["project"] == "Leave" + + def test_tilde_with_space_stripped(self): + lines = [ + "| Start | End | Duration | Project | Story | Note |", + "|-------|-------|----------|----------|-------|------|", + "| 09:00 | 17:00 | 8:00 | ~ Leave | | |", + ] + rows = parse_table(lines) + assert rows[0]["skip_csv"] is True + assert rows[0]["project"] == "Leave" + + def test_normal_row_has_no_skip_csv(self): + rows = parse_table(WITH_DURATION) + assert all("skip_csv" not in r for r in rows) + + def test_skip_csv_on_open_entry(self): + lines = [ + "| Start | End | Project | Story | Note |", + "|-------|-----|---------|-------|------|", + "| 09:00 | | ~Leave | | |", + ] + rows = parse_table(lines, has_duration_col=False) + assert len(rows) == 1 + assert rows[0]["skip_csv"] is True + assert rows[0]["project"] == "Leave" + + def test_note_tilde_sets_skip_csv(self): + lines = [ + "| Start | End | Duration | Project | Story | Note |", + "|-------|-------|----------|---------|-------|----------|", + "| 09:00 | 17:00 | 8:00 | Leave | | ~Day off |", + ] + rows = parse_table(lines) + assert rows[0]["skip_csv"] is True + + def test_note_tilde_stripped_from_note(self): + lines = [ + "| Start | End | Duration | Project | Story | Note |", + "|-------|-------|----------|---------|-------|----------|", + "| 09:00 | 17:00 | 8:00 | Leave | | ~Day off |", + ] + rows = parse_table(lines) + assert rows[0]["note"] == "Day off" + + def test_note_tilde_with_space_stripped(self): + lines = [ + "| Start | End | Duration | Project | Story | Note |", + "|-------|-------|----------|---------|-------|-------|", + "| 09:00 | 17:00 | 8:00 | Leave | | ~ Day off |", + ] + rows = parse_table(lines) + assert rows[0]["skip_csv"] is True + assert rows[0]["note"] == "Day off" + + def test_note_tilde_without_duration_col(self): + lines = [ + "| Start | End | Project | Story | Note |", + "|-------|-------|---------|-------|----------|", + "| 09:00 | 17:00 | Leave | | ~Day off |", + ] + rows = parse_table(lines, has_duration_col=False) + assert rows[0]["skip_csv"] is True + assert rows[0]["note"] == "Day off" + + def test_note_tilde_does_not_affect_normal_row(self): + rows = parse_table(WITH_DURATION) + assert all("skip_csv" not in r for r in rows) + + +class TestFilterSkipCsv: + def test_removes_skip_csv_rows(self): + rows = [ + {"project": "Leave", "skip_csv": True, "duration_hours": 8.0}, + {"project": "work", "duration_hours": 1.0}, + ] + result = filter_skip_csv(rows) + assert len(result) == 1 + assert result[0]["project"] == "work" + + def test_keeps_all_normal_rows(self): + rows = [ + {"project": "work", "duration_hours": 1.0}, + {"project": "scrum", "duration_hours": 0.5}, + ] + assert filter_skip_csv(rows) == rows + + def test_empty_input(self): + assert filter_skip_csv([]) == [] + + def test_all_skip_csv_returns_empty(self): + rows = [{"project": "Leave", "skip_csv": True, "duration_hours": 8.0}] + assert filter_skip_csv(rows) == [] + + def test_rows_without_key_treated_as_normal(self): + """Rows that never had the key at all should pass through.""" + rows = [{"project": "work", "duration_hours": 1.0}] + assert filter_skip_csv(rows) == rows + + class TestAggregateRows: def test_same_project_story_summed(self): rows = parse_table(WITH_DURATION)