#!/usr/bin/env python3 import argparse import re from datetime import date, datetime, timedelta from pathlib import Path H1_RE = re.compile(r"^# (?!#)") CYCLE_RE = re.compile( r"^# Cycle (\d+) \((\d{2}/\d{2}/\d{4}) - (\d{2}/\d{2}/\d{4})\)$" ) def previous_sun_sat(today: date): # End on the most recent Saturday. days_since_saturday = (today.weekday() + 2) % 7 end = today - timedelta(days=days_since_saturday) start = end - timedelta(days=6) return start, end def fmt(d: date): return d.strftime("%m/%d/%Y") def parse_date(s: str): return datetime.strptime(s, "%m/%d/%Y").date() def main(): parser = argparse.ArgumentParser() parser.add_argument( "file", nargs="?", default="~/.local/share/os/tasks.md", help="Task markdown file (default: ~/.local/share/os/tasks.md)", ) parser.add_argument("--today", help="Override today, format YYYY-MM-DD") args = parser.parse_args() path = Path(args.file).expanduser() lines = path.read_text().splitlines(keepends=True) today = ( datetime.strptime(args.today, "%Y-%m-%d").date() if args.today else date.today() ) target_start, target_end = previous_sun_sat(today) cycles = [] for line in lines: m = CYCLE_RE.match(line.strip()) if m: cycles.append((int(m.group(1)), parse_date(m.group(2)), parse_date(m.group(3)))) max_cycle = max((cycle[0] for cycle in cycles), default=0) latest_end = max((cycle[2] for cycle in cycles), default=None) if latest_end is not None and latest_end >= target_end: print(f"Nothing to do; latest cycle already ends {fmt(latest_end)}") return missing_cycles = [] if latest_end is None: missing_cycles.append((max_cycle + 1, target_start, target_end)) else: cycle_start = latest_end + timedelta(days=1) cycle_num = max_cycle + 1 while cycle_start + timedelta(days=6) <= target_end: cycle_end = cycle_start + timedelta(days=6) missing_cycles.append((cycle_num, cycle_start, cycle_end)) cycle_start += timedelta(days=7) cycle_num += 1 if not missing_cycles: print("Nothing to do") return current_start = None for i, line in enumerate(lines): if line.strip() == "# Current": current_start = i break if current_start is None: raise SystemExit("Could not find '# Current' section") current_end = len(lines) for i in range(current_start + 1, len(lines)): if H1_RE.match(lines[i]): current_end = i break done_header = None for i in range(current_start + 1, current_end): if lines[i].strip() == "## Done": done_header = i break if done_header is None: raise SystemExit("Could not find '## Done' under '# Current'") done_start = done_header + 1 done_end = current_end for i in range(done_start, current_end): if lines[i].startswith("## "): done_end = i break done_lines = lines[done_start:done_end] while done_lines and not done_lines[0].strip(): done_lines.pop(0) while done_lines and not done_lines[-1].strip(): done_lines.pop() # Empty Current -> Done section. If there are no done items, still create # the new cycle block with an empty Done section. lines = lines[:done_start] + ["\n"] + lines[done_end:] # Re-find end of Current after edit. current_start = next(i for i, line in enumerate(lines) if line.strip() == "# Current") insert_at = len(lines) for i in range(current_start + 1, len(lines)): if H1_RE.match(lines[i]): insert_at = i break cycle_block = [] latest_cycle = missing_cycles[-1] for cycle_num, cycle_start, cycle_end in reversed(missing_cycles): cycle_block.extend([ f"# Cycle {cycle_num} ({fmt(cycle_start)} - {fmt(cycle_end)})\n", "\n", ]) if (cycle_num, cycle_start, cycle_end) == latest_cycle: cycle_block.extend(done_lines) cycle_block.append("\n") prefix = lines[:insert_at] suffix = lines[insert_at:] if prefix and prefix[-1].strip(): prefix.append("\n") if suffix and suffix[0].strip(): cycle_block.append("\n") path.write_text("".join(prefix + cycle_block + suffix)) if len(missing_cycles) == 1: cycle_num, cycle_start, cycle_end = missing_cycles[0] print(f"Created # Cycle {cycle_num} ({fmt(cycle_start)} - {fmt(cycle_end)})") else: first_num = missing_cycles[0][0] last_num = missing_cycles[-1][0] print(f"Created cycles {first_num}-{last_num}") if __name__ == "__main__": main()