182 lines
5.6 KiB
Text
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())
|