diff --git a/AGENTS.md b/AGENTS.md index d7fd446..94c239c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,45 +53,41 @@ Do **not** use `pip` or `python` directly. ## CLI usage +The CLI has two subcommands: `summary` and `csv`. Both accept the same arguments. + ```sh -# Print CSV to stdout (date defaults to today) -uv run timesheets --input input.md +# Human-readable summary for today (from Joplin) +uv run timesheets summary --joplin -# Specify a day (positional, accepts YYYY-MM-DD, MM-DD, or DD-MM; - or / separator) -uv run timesheets 2026-05-22 --input input.md -uv run timesheets 05-22 --input input.md +# Human-readable summary for a specific day +uv run timesheets summary 2026-05-22 --joplin +uv run timesheets summary yesterday --joplin +uv run timesheets summary 3 days ago --joplin -# Write CSV to a file -uv run timesheets --input input.md -o output.csv +# Export today's entries as CSV to stdout +uv run timesheets csv --joplin -# Override the day (accepts YYYY-MM-DD, MM-DD, or DD-MM; - or / separator) -uv run timesheets --input input.md --day 2026-05-22 -uv run timesheets --input input.md --day 05-22 +# Export to a file +uv run timesheets csv --joplin -o output.csv -# Use a specific project map file -uv run timesheets --input input.md --map /path/to/project_map.json - -# Print a human-readable summary instead of CSV -uv run timesheets --input input.md --summary +# Use a local markdown file instead of Joplin +uv run timesheets summary --input timesheet.md +uv run timesheets csv --input timesheet.md -o output.csv # 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) -JOPLIN_TOKEN=your_token uv run timesheets --joplin +# Specify a day (positional, accepts YYYY-MM-DD, MM-DD, or DD-MM; - or / separator) +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 -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 ` CLI flag -- `JOPLIN_TOKEN` environment variable - `project_map.json` is auto-discovered in the current working directory if `--map` is not provided. diff --git a/src/timesheets/cli.py b/src/timesheets/cli.py index 2352a89..9830cd0 100644 --- a/src/timesheets/cli.py +++ b/src/timesheets/cli.py @@ -9,17 +9,18 @@ from .parser import aggregate_rows, filter_rows_by_date, parse_document from .projects import load_project_map 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.add_argument( "--input", "-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, ) source.add_argument( @@ -27,28 +28,10 @@ def build_parser() -> argparse.ArgumentParser: 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." + "Fetch the weekly timesheet note from Joplin. " + "Requires a Joplin API token via --token or JOPLIN_TOKEN." ), ) - - 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( "day", nargs="*", @@ -58,22 +41,65 @@ def build_parser() -> argparse.ArgumentParser: "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( "--map", help=( "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, ) parser.add_argument( - "--summary", - action="store_true", - help="Print a human-readable summary instead of writing CSV.", + "-o", + "--output", + 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 +# --------------------------------------------------------------------------- +# Shared resolution helpers +# --------------------------------------------------------------------------- + + def _resolve_token(args: argparse.Namespace, config: dict) -> str: token = args.token or get_token(config) or os.environ.get("JOPLIN_TOKEN") if not token: @@ -86,15 +112,9 @@ def _resolve_token(args: argparse.Namespace, config: dict) -> str: return token -def main() -> None: - args = build_parser().parse_args() - - # 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) - +def _resolve_date(args: argparse.Namespace) -> tuple[date, str]: + """Parse the day positional argument and return (date, formatted string).""" day_input = " ".join(args.day) if args.day else None - if day_input is None: target_date = date.today() else: @@ -106,11 +126,14 @@ def main() -> None: except ValueError as e: print(f"Error: {e}", file=sys.stderr) 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: - # Late import so joppy is only required when --joplin is used from .joplin import fetch_week_note token = _resolve_token(args, config) @@ -119,9 +142,8 @@ def main() -> None: except RuntimeError as e: print(f"Error: {e}", file=sys.stderr) sys.exit(1) - lines = content.splitlines() - rows = filter_rows_by_date(lines, target_date) + return filter_rows_by_date(lines, target_date) else: if args.input == "-": content = sys.stdin.read() @@ -132,29 +154,74 @@ def main() -> None: except FileNotFoundError: print(f"Error: file not found: {args.input}", file=sys.stderr) sys.exit(1) + return parse_document(content.splitlines()) - lines = content.splitlines() - rows = parse_document(lines) - if not rows: - print("Warning: no timesheet rows found in input.", file=sys.stderr) - - aggregated = aggregate_rows(rows) - - # Resolve project map: CLI flag > config file > project_map.json in cwd +def _resolve_project_map(args: argparse.Namespace, config: dict) -> dict: + """Resolve the project map from CLI flag, config, or cwd default.""" map_path = args.map or get_map_path(config) if map_path is None: - default_map = os.path.join(os.getcwd(), "project_map.json") - if os.path.exists(default_map): - map_path = default_map + default = os.path.join(os.getcwd(), "project_map.json") + if os.path.exists(default): + 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) + finally: + sys.stdout = old_stdout + print(f"Written to {args.output}", file=sys.stderr) + else: print_summary(aggregated, project_map) - elif args.output: + + +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: write_csv(aggregated, f, date_str, project_map) print(f"Written to {args.output}", file=sys.stderr) else: 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)