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()
This commit is contained in:
parent
8b6f0b24e2
commit
de46399010
6 changed files with 209 additions and 25 deletions
24
README.md
24
README.md
|
|
@ -85,6 +85,30 @@ uv run timesheets stories -w --joplin -o stories.md # write to file
|
||||||
uv run timesheets status --joplin
|
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
|
## Config file
|
||||||
|
|
||||||
Copy `timesheets.example.toml` to `timesheets.toml` in the working directory
|
Copy `timesheets.example.toml` to `timesheets.toml` in the working directory
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ from .output import (
|
||||||
from .parser import (
|
from .parser import (
|
||||||
aggregate_rows,
|
aggregate_rows,
|
||||||
filter_rows_by_date,
|
filter_rows_by_date,
|
||||||
|
filter_skip_csv,
|
||||||
filter_week_sections,
|
filter_week_sections,
|
||||||
parse_document,
|
parse_document,
|
||||||
resolve_overlaps,
|
resolve_overlaps,
|
||||||
|
|
@ -436,7 +437,9 @@ def _cmd_csv(args: argparse.Namespace, config: dict) -> None:
|
||||||
day_sections = [
|
day_sections = [
|
||||||
(
|
(
|
||||||
_fmt(day),
|
_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
|
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)
|
rows = _resolve_rows(args, config, target_date)
|
||||||
if not rows:
|
if not rows:
|
||||||
print("Warning: no timesheet rows found in input.", file=sys.stderr)
|
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:
|
if args.output:
|
||||||
with open(args.output, "w", newline="", encoding="utf-8") as f:
|
with open(args.output, "w", newline="", encoding="utf-8") as f:
|
||||||
write_csv(entries, f, _fmt(target_date), project_map)
|
write_csv(entries, f, _fmt(target_date), project_map)
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ def to_csv_entries(rows: list[dict]) -> list[dict]:
|
||||||
"quantity": row["duration_hours"],
|
"quantity": row["duration_hours"],
|
||||||
}
|
}
|
||||||
for row in rows
|
for row in rows
|
||||||
if row["duration_hours"] is not None
|
if row["duration_hours"] is not None and not row.get("skip_csv")
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,19 @@ def parse_table(lines: list[str], has_duration_col: bool = True) -> list[dict]:
|
||||||
note = strip_markdown_link(cells[4])
|
note = strip_markdown_link(cells[4])
|
||||||
duration = None
|
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":
|
if start.lower() == "start":
|
||||||
continue
|
continue
|
||||||
if not re.match(r"^\d+:\d{2}$", start):
|
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 not re.match(r"^\d+:\d{2}$", end):
|
||||||
if duration is None:
|
if duration is None:
|
||||||
# No end and no duration — preserve as an open entry
|
# No end and no duration — preserve as an open entry
|
||||||
rows.append(
|
row = {
|
||||||
{
|
"start": start,
|
||||||
"start": start,
|
"end": None,
|
||||||
"end": None,
|
"duration_hours": None,
|
||||||
"duration_hours": None,
|
"project": project,
|
||||||
"project": project,
|
"story": story,
|
||||||
"story": story,
|
"story_raw": story_raw,
|
||||||
"story_raw": story_raw,
|
"note": note,
|
||||||
"note": note,
|
}
|
||||||
}
|
if skip_csv:
|
||||||
)
|
row["skip_csv"] = True
|
||||||
|
rows.append(row)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if duration is not None:
|
if duration is not None:
|
||||||
|
|
@ -135,17 +149,18 @@ def parse_table(lines: list[str], has_duration_col: bool = True) -> list[dict]:
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
rows.append(
|
row = {
|
||||||
{
|
"start": start,
|
||||||
"start": start,
|
"end": end,
|
||||||
"end": end,
|
"duration_hours": duration_hours,
|
||||||
"duration_hours": duration_hours,
|
"project": project,
|
||||||
"project": project,
|
"story": story,
|
||||||
"story": story,
|
"story_raw": story_raw,
|
||||||
"story_raw": story_raw,
|
"note": note,
|
||||||
"note": note,
|
}
|
||||||
}
|
if skip_csv:
|
||||||
)
|
row["skip_csv"] = True
|
||||||
|
rows.append(row)
|
||||||
|
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
@ -354,6 +369,11 @@ def resolve_overlaps(rows: list[dict]) -> list[dict]:
|
||||||
return open_entries + closed
|
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:
|
def build_description(story: str, note: str) -> str:
|
||||||
"""Combine story and note into a single description string."""
|
"""Combine story and note into a single description string."""
|
||||||
parts = [p.strip() for p in [story, note] if p.strip()]
|
parts = [p.strip() for p in [story, note] if p.strip()]
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,19 @@ class TestToCsvEntries:
|
||||||
rows = [_raw_row(" bugs ", "", "dsu", 0.25)]
|
rows = [_raw_row(" bugs ", "", "dsu", 0.25)]
|
||||||
assert to_csv_entries(rows)[0]["project"] == "bugs"
|
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):
|
def test_mixed_open_and_closed(self):
|
||||||
rows = [
|
rows = [
|
||||||
_raw_row("bugs", "ticket 1", "", 1.0),
|
_raw_row("bugs", "ticket 1", "", 1.0),
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ from timesheets.parser import (
|
||||||
detect_has_duration_column,
|
detect_has_duration_column,
|
||||||
extract_table_blocks,
|
extract_table_blocks,
|
||||||
filter_rows_by_date,
|
filter_rows_by_date,
|
||||||
|
filter_skip_csv,
|
||||||
parse_document,
|
parse_document,
|
||||||
parse_table,
|
parse_table,
|
||||||
resolve_overlaps,
|
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:
|
class TestAggregateRows:
|
||||||
def test_same_project_story_summed(self):
|
def test_same_project_story_summed(self):
|
||||||
rows = parse_table(WITH_DURATION)
|
rows = parse_table(WITH_DURATION)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue