dotfiles/home/bin/executable_todo_archive

182 lines
5.6 KiB
Text

#!/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())