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