#!/usr/bin/env -S uv run --script # /// script # requires-python = ">=3.10" # dependencies = [ # "stage-left", # ] # /// """ Archive completed (DONE) and obsolete todos from a [x]it! file. Extracts all checked and obsolete items, outputs them (preserving groups), and removes them from the original file. """ import argparse import sys from pathlib import Path from stage_left import parse_file from stage_left.types import State, Group, Item def format_item(item: Item) -> str: """Format an item back to [x]it! syntax.""" return f"[{item.state.value}] {item.description}" def sort_groups(groups: list[Group]) -> list[Group]: """ Sort groups: untitled groups first, then alphabetically by title. """ untitled = [g for g in groups if not g.title] titled = [g for g in groups if g.title] titled.sort(key=lambda g: g.title.lower()) return untitled + titled def format_groups(groups: list[Group]) -> str: """Format a list of groups back to [x]it! syntax.""" lines = [] for i, group in enumerate(groups): if group.title: if i > 0: lines.append("") # Blank line before group title lines.append(group.title) for item in group.items: lines.append(format_item(item)) return "\n".join(lines) def extract_archivable_items(groups: list[Group]) -> tuple[list[Group], list[Group]]: """ Split groups into archivable (done/obsolete) and remaining items. Returns: (archivable_groups, remaining_groups) - Both preserve group structure. Empty groups are preserved in remaining_groups. """ archivable_states = {State.CHECKED, State.OBSOLETE} archivable_groups = [] remaining_groups = [] for group in groups: archivable_items = [item for item in group.items if item.state in archivable_states] remaining_items = [item for item in group.items if item.state not in archivable_states] if archivable_items: archivable_groups.append(Group(title=group.title, items=archivable_items)) # Always preserve the group in remaining, even if empty remaining_groups.append(Group(title=group.title, items=remaining_items)) return archivable_groups, remaining_groups def merge_groups(existing: list[Group], new: list[Group]) -> list[Group]: """ Merge new groups into existing groups. Groups with matching titles have their items combined. New groups without a matching title are appended. """ merged: dict[str | None, Group] = {} order: list[str | None] = [] for group in existing: if group.title in merged: # Extend existing group's items merged[group.title] = Group( title=group.title, items=merged[group.title].items + group.items, ) else: merged[group.title] = Group(title=group.title, items=list(group.items)) order.append(group.title) for group in new: if group.title in merged: # Merge into existing group merged[group.title] = Group( title=group.title, items=merged[group.title].items + group.items, ) else: # New group merged[group.title] = Group(title=group.title, items=list(group.items)) order.append(group.title) return [merged[title] for title in order] def main() -> int: parser = argparse.ArgumentParser( description="Archive completed and obsolete todos from a [x]it! file.", epilog="Done and obsolete items are extracted and removed from the input file.", ) parser.add_argument( "input", type=Path, help="Path to the [x]it! file to process", ) parser.add_argument( "output", type=Path, help="Output file for archived todos", ) args = parser.parse_args() if not args.input.exists(): print(f"Error: Input file not found: {args.input}", file=sys.stderr) return 1 with open(args.input, "r") as fp: groups = parse_file(fp) archivable_groups, remaining_groups = extract_archivable_items(groups) if archivable_groups: if args.output.exists(): with open(args.output, "r") as fp: existing_groups = parse_file(fp) merged_groups = merge_groups(existing_groups, archivable_groups) else: merged_groups = archivable_groups merged_output = format_groups(sort_groups(merged_groups)) with open(args.output, "w") as fp: fp.write(merged_output) fp.write("\n") remaining_output = format_groups(remaining_groups) with open(args.input, "w") as fp: if remaining_output: fp.write(remaining_output) fp.write("\n") elif args.output.exists(): # No new items to archive, but re-sort existing archive with open(args.output, "r") as fp: existing_groups = parse_file(fp) sorted_output = format_groups(sort_groups(existing_groups)) with open(args.output, "w") as fp: fp.write(sorted_output) fp.write("\n") archived_count = sum(len(g.items) for g in archivable_groups) remaining_count = sum(len(g.items) for g in remaining_groups) if archived_count: print( f"Archived {archived_count} todo(s). {remaining_count} remaining.", file=sys.stderr, ) else: print(f"Sorted {remaining_count} todo(s).", file=sys.stderr) return 0 if __name__ == "__main__": sys.exit(main())