The rule PLAN.md describes and the rationale it gives for that rule disagree on one shape that turns out to be common. 235 checkboxes in the corpus sit under a plain bullet used as a group label, and by the letter of the rule those have no checkbox ancestor, so a finished one would be dropped on its own. But section 4 already argues the opposite case for checkbox parents: a finished item under an unfinished group is context, not cruft, and a label like `- Trellis "Ergonomics" Fixes` is a group in every sense that matters. Resolved in favour of the rationale, which collapses both cases into one rule. A top-level bullet is any bullet with no bullet ancestor in its section. It is dropped, with its subtree, exactly when that subtree holds at least one checkbox and all of them are [x]. Prose bullets hold none and so are never eligible, which is the property section 4 wanted. Validated across all 228 notes with zero invariant failures: no heading, no unfinished item and no checkbox-free subtree is ever removed, and no group label is left with its children gone. 270 lines would be dropped across 118 files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PU5ZFfQFtDTFqqhvMTWdGH
128 lines
3.5 KiB
Python
Executable File
128 lines
3.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Prune completed work out of a daily note.
|
|
|
|
A top-level bullet is one with no bullet ancestor in its section; headings reset
|
|
scope. Such a bullet is dropped, with its whole subtree, exactly when the
|
|
subtree contains at least one checkbox and every one of them is [x].
|
|
|
|
That single rule covers both shapes that occur in practice. `- [x] Climb` is a
|
|
subtree of one and goes. A group, whether its label is `- [ ] Add OpenID` or a
|
|
plain `- Trellis "Ergonomics" Fixes`, survives entire until its last child is
|
|
checked, because a finished item under an unfinished group is context rather
|
|
than cruft. Prose bullets hold no checkboxes and so are never eligible at all.
|
|
"""
|
|
|
|
import argparse
|
|
import re
|
|
import sys
|
|
|
|
BULLET = re.compile(r"^(\s*)([-*+])\s+(?:\[(.)\]\s+)?(.*)$")
|
|
HEADING = re.compile(r"^#{1,6}\s")
|
|
FENCE = re.compile(r"^\s*```")
|
|
|
|
DONE = "x"
|
|
|
|
|
|
class Line:
|
|
__slots__ = ("text", "indent", "is_bullet", "state", "is_heading", "in_fence")
|
|
|
|
def __init__(self, text):
|
|
self.text = text
|
|
self.is_heading = bool(HEADING.match(text))
|
|
self.in_fence = False
|
|
m = BULLET.match(text) if text.strip() else None
|
|
if m:
|
|
self.is_bullet = True
|
|
self.indent = len(m.group(1))
|
|
self.state = (m.group(3) or "").lower() or None
|
|
else:
|
|
self.is_bullet = False
|
|
self.indent = len(text) - len(text.lstrip())
|
|
self.state = None
|
|
|
|
|
|
def parse(text_lines):
|
|
out = []
|
|
fence = False
|
|
for t in text_lines:
|
|
ln = Line(t)
|
|
if FENCE.match(t):
|
|
fence = not fence
|
|
ln.in_fence = True
|
|
elif fence:
|
|
ln.in_fence = True
|
|
if ln.in_fence:
|
|
ln.is_bullet = False
|
|
ln.is_heading = False
|
|
out.append(ln)
|
|
return out
|
|
|
|
|
|
def subtree_end(lines, i):
|
|
"""Last index of the subtree rooted at bullet i, wrapped continuation lines included."""
|
|
base = lines[i].indent
|
|
last = i
|
|
j = i + 1
|
|
while j < len(lines):
|
|
ln = lines[j]
|
|
if ln.in_fence or ln.is_heading:
|
|
break
|
|
if not ln.text.strip():
|
|
j += 1
|
|
continue
|
|
if ln.indent <= base:
|
|
break
|
|
last = j
|
|
j += 1
|
|
return last
|
|
|
|
|
|
def top_level_bullets(lines):
|
|
"""Indices of bullets that have no bullet ancestor within their section."""
|
|
stack = []
|
|
for i, ln in enumerate(lines):
|
|
if ln.in_fence:
|
|
continue
|
|
if ln.is_heading:
|
|
stack = []
|
|
continue
|
|
if not ln.is_bullet:
|
|
continue
|
|
while stack and stack[-1] >= ln.indent:
|
|
stack.pop()
|
|
if not stack:
|
|
yield i
|
|
stack.append(ln.indent)
|
|
|
|
|
|
def dropped_indices(lines):
|
|
drop = set()
|
|
for i in top_level_bullets(lines):
|
|
end = subtree_end(lines, i)
|
|
states = [
|
|
lines[j].state
|
|
for j in range(i, end + 1)
|
|
if lines[j].is_bullet and lines[j].state is not None
|
|
]
|
|
if states and all(s == DONE for s in states):
|
|
drop.update(range(i, end + 1))
|
|
return drop
|
|
|
|
|
|
def prune(text):
|
|
lines = parse(text.split("\n"))
|
|
drop = dropped_indices(lines)
|
|
return "\n".join(l.text for k, l in enumerate(lines) if k not in drop)
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
ap.add_argument("file", nargs="?", help="note to prune, default stdin")
|
|
a = ap.parse_args()
|
|
src = open(a.file).read() if a.file else sys.stdin.read()
|
|
sys.stdout.write(prune(src))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|