refactor(cli): split into summary and csv subcommands
- Add subparsers for 'summary' and 'csv' replacing the --summary flag - Extract _add_shared_args(), _resolve_date(), _resolve_rows(), and _resolve_project_map() as shared helpers used by both subcommands - Both subcommands support -o/--output to write to a file instead of stdout - Update AGENTS.md with new subcommand usage examples
This commit is contained in:
parent
615bfe30e0
commit
6915d8d764
2 changed files with 145 additions and 82 deletions
50
AGENTS.md
50
AGENTS.md
|
|
@ -53,45 +53,41 @@ Do **not** use `pip` or `python` directly.
|
||||||
|
|
||||||
## CLI usage
|
## CLI usage
|
||||||
|
|
||||||
|
The CLI has two subcommands: `summary` and `csv`. Both accept the same arguments.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
# Print CSV to stdout (date defaults to today)
|
# Human-readable summary for today (from Joplin)
|
||||||
uv run timesheets --input input.md
|
uv run timesheets summary --joplin
|
||||||
|
|
||||||
# Specify a day (positional, accepts YYYY-MM-DD, MM-DD, or DD-MM; - or / separator)
|
# Human-readable summary for a specific day
|
||||||
uv run timesheets 2026-05-22 --input input.md
|
uv run timesheets summary 2026-05-22 --joplin
|
||||||
uv run timesheets 05-22 --input input.md
|
uv run timesheets summary yesterday --joplin
|
||||||
|
uv run timesheets summary 3 days ago --joplin
|
||||||
|
|
||||||
# Write CSV to a file
|
# Export today's entries as CSV to stdout
|
||||||
uv run timesheets --input input.md -o output.csv
|
uv run timesheets csv --joplin
|
||||||
|
|
||||||
# Override the day (accepts YYYY-MM-DD, MM-DD, or DD-MM; - or / separator)
|
# Export to a file
|
||||||
uv run timesheets --input input.md --day 2026-05-22
|
uv run timesheets csv --joplin -o output.csv
|
||||||
uv run timesheets --input input.md --day 05-22
|
|
||||||
|
|
||||||
# Use a specific project map file
|
# Use a local markdown file instead of Joplin
|
||||||
uv run timesheets --input input.md --map /path/to/project_map.json
|
uv run timesheets summary --input timesheet.md
|
||||||
|
uv run timesheets csv --input timesheet.md -o output.csv
|
||||||
# Print a human-readable summary instead of CSV
|
|
||||||
uv run timesheets --input input.md --summary
|
|
||||||
|
|
||||||
# Read from stdin
|
# Read from stdin
|
||||||
cat input.md | uv run timesheets --input -
|
cat timesheet.md | uv run timesheets csv --input -
|
||||||
|
|
||||||
# Fetch today's entries from Joplin (token via env var)
|
# Specify a day (positional, accepts YYYY-MM-DD, MM-DD, or DD-MM; - or / separator)
|
||||||
JOPLIN_TOKEN=your_token uv run timesheets --joplin
|
uv run timesheets csv 2026-05-22 --input input.md
|
||||||
|
uv run timesheets csv 05-22 --input input.md
|
||||||
|
|
||||||
|
# Use a specific project map file
|
||||||
|
uv run timesheets csv --input input.md --map /path/to/project_map.json
|
||||||
|
|
||||||
# Fetch entries for a specific day from Joplin
|
# Fetch entries for a specific day from Joplin
|
||||||
uv run timesheets 2026-05-22 --joplin --token your_token
|
uv run timesheets csv 2026-05-22 --joplin --token your_token
|
||||||
```
|
```
|
||||||
|
|
||||||
The `--joplin` flag and the file `input` argument are mutually exclusive.
|
|
||||||
When `--joplin` is used, only entries matching the target day (positional arg,
|
|
||||||
or today) are returned, filtered by the `# ... YYYY-MM-DD` day heading in the note.
|
|
||||||
|
|
||||||
The API token can be provided via:
|
|
||||||
- `--token <token>` CLI flag
|
|
||||||
- `JOPLIN_TOKEN` environment variable
|
|
||||||
|
|
||||||
`project_map.json` is auto-discovered in the current working directory if
|
`project_map.json` is auto-discovered in the current working directory if
|
||||||
`--map` is not provided.
|
`--map` is not provided.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,17 +9,18 @@ from .parser import aggregate_rows, filter_rows_by_date, parse_document
|
||||||
from .projects import load_project_map
|
from .projects import load_project_map
|
||||||
from .utils import AmbiguousDateError, format_date, parse_date_arg
|
from .utils import AmbiguousDateError, format_date, parse_date_arg
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shared argument helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def build_parser() -> argparse.ArgumentParser:
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description="Parse a markdown timesheet table and output a CSV file."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
def _add_shared_args(parser: argparse.ArgumentParser) -> None:
|
||||||
|
"""Add arguments common to both subcommands."""
|
||||||
source = parser.add_mutually_exclusive_group(required=True)
|
source = parser.add_mutually_exclusive_group(required=True)
|
||||||
source.add_argument(
|
source.add_argument(
|
||||||
"--input",
|
"--input",
|
||||||
"-i",
|
"-i",
|
||||||
help="Path to the markdown file containing the timesheet table, or '-' to read from stdin.",
|
help="Path to a markdown file, or '-' to read from stdin.",
|
||||||
default=None,
|
default=None,
|
||||||
)
|
)
|
||||||
source.add_argument(
|
source.add_argument(
|
||||||
|
|
@ -27,28 +28,10 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
action="store_true",
|
action="store_true",
|
||||||
default=False,
|
default=False,
|
||||||
help=(
|
help=(
|
||||||
"Fetch the weekly timesheet note from Joplin instead of reading a file. "
|
"Fetch the weekly timesheet note from Joplin. "
|
||||||
"Only entries for today (or --date) are included. "
|
"Requires a Joplin API token via --token or JOPLIN_TOKEN."
|
||||||
"Requires a Joplin API token via --token or the JOPLIN_TOKEN environment variable."
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
|
||||||
"--config",
|
|
||||||
help="Path to a TOML config file. Defaults to timesheets.toml in the current working directory if it exists.",
|
|
||||||
default=None,
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"--token",
|
|
||||||
help="Joplin API token. Falls back to the JOPLIN_TOKEN environment variable.",
|
|
||||||
default=None,
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
|
||||||
"-o",
|
|
||||||
"--output",
|
|
||||||
help="Path to the output CSV file. Defaults to stdout.",
|
|
||||||
default=None,
|
|
||||||
)
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"day",
|
"day",
|
||||||
nargs="*",
|
nargs="*",
|
||||||
|
|
@ -58,22 +41,65 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
"Defaults to today."
|
"Defaults to today."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--config",
|
||||||
|
help="Path to a TOML config file. Defaults to timesheets.toml in the cwd if it exists.",
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--token",
|
||||||
|
help="Joplin API token. Falls back to the JOPLIN_TOKEN environment variable.",
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--map",
|
"--map",
|
||||||
help=(
|
help=(
|
||||||
"Path to a JSON file mapping project keys to Project+Task pairs. "
|
"Path to a JSON file mapping project keys to Project+Task pairs. "
|
||||||
"Defaults to project_map.json in the current working directory if it exists."
|
"Defaults to project_map.json in the cwd if it exists."
|
||||||
),
|
),
|
||||||
default=None,
|
default=None,
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--summary",
|
"-o",
|
||||||
action="store_true",
|
"--output",
|
||||||
help="Print a human-readable summary instead of writing CSV.",
|
help="Write output to this file instead of stdout.",
|
||||||
|
default=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Parser construction
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Parse markdown timesheet tables and export them.",
|
||||||
|
prog="timesheets",
|
||||||
|
)
|
||||||
|
subparsers = parser.add_subparsers(dest="command", metavar="command")
|
||||||
|
subparsers.required = True
|
||||||
|
|
||||||
|
summary_parser = subparsers.add_parser(
|
||||||
|
"summary",
|
||||||
|
help="Print a human-readable summary of time spent per project.",
|
||||||
|
)
|
||||||
|
_add_shared_args(summary_parser)
|
||||||
|
|
||||||
|
csv_parser = subparsers.add_parser(
|
||||||
|
"csv",
|
||||||
|
help="Export timesheet entries as CSV.",
|
||||||
|
)
|
||||||
|
_add_shared_args(csv_parser)
|
||||||
|
|
||||||
return parser
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shared resolution helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _resolve_token(args: argparse.Namespace, config: dict) -> str:
|
def _resolve_token(args: argparse.Namespace, config: dict) -> str:
|
||||||
token = args.token or get_token(config) or os.environ.get("JOPLIN_TOKEN")
|
token = args.token or get_token(config) or os.environ.get("JOPLIN_TOKEN")
|
||||||
if not token:
|
if not token:
|
||||||
|
|
@ -86,15 +112,9 @@ def _resolve_token(args: argparse.Namespace, config: dict) -> str:
|
||||||
return token
|
return token
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def _resolve_date(args: argparse.Namespace) -> tuple[date, str]:
|
||||||
args = build_parser().parse_args()
|
"""Parse the day positional argument and return (date, formatted string)."""
|
||||||
|
|
||||||
# Load config file: explicit --config flag, else auto-discover timesheets.toml
|
|
||||||
config_path = args.config if args.config is not None else find_default_config()
|
|
||||||
config = load_config(config_path)
|
|
||||||
|
|
||||||
day_input = " ".join(args.day) if args.day else None
|
day_input = " ".join(args.day) if args.day else None
|
||||||
|
|
||||||
if day_input is None:
|
if day_input is None:
|
||||||
target_date = date.today()
|
target_date = date.today()
|
||||||
else:
|
else:
|
||||||
|
|
@ -106,11 +126,14 @@ def main() -> None:
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
print(f"Error: {e}", file=sys.stderr)
|
print(f"Error: {e}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
return target_date, format_date(target_date)
|
||||||
|
|
||||||
date_str = format_date(target_date)
|
|
||||||
|
|
||||||
|
def _resolve_rows(
|
||||||
|
args: argparse.Namespace, config: dict, target_date: date
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Fetch and parse rows from the configured source."""
|
||||||
if args.joplin:
|
if args.joplin:
|
||||||
# Late import so joppy is only required when --joplin is used
|
|
||||||
from .joplin import fetch_week_note
|
from .joplin import fetch_week_note
|
||||||
|
|
||||||
token = _resolve_token(args, config)
|
token = _resolve_token(args, config)
|
||||||
|
|
@ -119,9 +142,8 @@ def main() -> None:
|
||||||
except RuntimeError as e:
|
except RuntimeError as e:
|
||||||
print(f"Error: {e}", file=sys.stderr)
|
print(f"Error: {e}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
lines = content.splitlines()
|
lines = content.splitlines()
|
||||||
rows = filter_rows_by_date(lines, target_date)
|
return filter_rows_by_date(lines, target_date)
|
||||||
else:
|
else:
|
||||||
if args.input == "-":
|
if args.input == "-":
|
||||||
content = sys.stdin.read()
|
content = sys.stdin.read()
|
||||||
|
|
@ -132,29 +154,74 @@ def main() -> None:
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
print(f"Error: file not found: {args.input}", file=sys.stderr)
|
print(f"Error: file not found: {args.input}", file=sys.stderr)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
return parse_document(content.splitlines())
|
||||||
|
|
||||||
lines = content.splitlines()
|
|
||||||
rows = parse_document(lines)
|
|
||||||
|
|
||||||
if not rows:
|
def _resolve_project_map(args: argparse.Namespace, config: dict) -> dict:
|
||||||
print("Warning: no timesheet rows found in input.", file=sys.stderr)
|
"""Resolve the project map from CLI flag, config, or cwd default."""
|
||||||
|
|
||||||
aggregated = aggregate_rows(rows)
|
|
||||||
|
|
||||||
# Resolve project map: CLI flag > config file > project_map.json in cwd
|
|
||||||
map_path = args.map or get_map_path(config)
|
map_path = args.map or get_map_path(config)
|
||||||
if map_path is None:
|
if map_path is None:
|
||||||
default_map = os.path.join(os.getcwd(), "project_map.json")
|
default = os.path.join(os.getcwd(), "project_map.json")
|
||||||
if os.path.exists(default_map):
|
if os.path.exists(default):
|
||||||
map_path = default_map
|
map_path = default
|
||||||
|
return load_project_map(map_path)
|
||||||
|
|
||||||
project_map = load_project_map(map_path)
|
|
||||||
|
|
||||||
if args.summary:
|
# ---------------------------------------------------------------------------
|
||||||
|
# Subcommand handlers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_summary(args: argparse.Namespace, config: dict) -> None:
|
||||||
|
target_date, _ = _resolve_date(args)
|
||||||
|
rows = _resolve_rows(args, config, target_date)
|
||||||
|
if not rows:
|
||||||
|
print("Warning: no timesheet rows found in input.", file=sys.stderr)
|
||||||
|
aggregated = aggregate_rows(rows)
|
||||||
|
project_map = _resolve_project_map(args, config)
|
||||||
|
|
||||||
|
if args.output:
|
||||||
|
with open(args.output, "w", encoding="utf-8") as f:
|
||||||
|
# Redirect stdout temporarily so print_summary writes to the file
|
||||||
|
old_stdout = sys.stdout
|
||||||
|
sys.stdout = f
|
||||||
|
try:
|
||||||
print_summary(aggregated, project_map)
|
print_summary(aggregated, project_map)
|
||||||
elif args.output:
|
finally:
|
||||||
|
sys.stdout = old_stdout
|
||||||
|
print(f"Written to {args.output}", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print_summary(aggregated, project_map)
|
||||||
|
|
||||||
|
|
||||||
|
def _cmd_csv(args: argparse.Namespace, config: dict) -> None:
|
||||||
|
target_date, date_str = _resolve_date(args)
|
||||||
|
rows = _resolve_rows(args, config, target_date)
|
||||||
|
if not rows:
|
||||||
|
print("Warning: no timesheet rows found in input.", file=sys.stderr)
|
||||||
|
aggregated = aggregate_rows(rows)
|
||||||
|
project_map = _resolve_project_map(args, config)
|
||||||
|
|
||||||
|
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(aggregated, f, date_str, project_map)
|
write_csv(aggregated, f, date_str, project_map)
|
||||||
print(f"Written to {args.output}", file=sys.stderr)
|
print(f"Written to {args.output}", file=sys.stderr)
|
||||||
else:
|
else:
|
||||||
write_csv(aggregated, sys.stdout, date_str, project_map)
|
write_csv(aggregated, sys.stdout, date_str, project_map)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Entry point
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
args = build_parser().parse_args()
|
||||||
|
|
||||||
|
config_path = args.config if args.config is not None else find_default_config()
|
||||||
|
config = load_config(config_path)
|
||||||
|
|
||||||
|
if args.command == "summary":
|
||||||
|
_cmd_summary(args, config)
|
||||||
|
elif args.command == "csv":
|
||||||
|
_cmd_csv(args, config)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue