;;; todo-cleanup.el --- Auto-fix and audit for todo.org hygiene ;; ;; Usage: ;; emacs --batch -q -l todo-cleanup.el todo.org # apply fixes in place ;; emacs --batch -q -l todo-cleanup.el --check todo.org # report-only ;; ;; What it does: ;; ;; 1. Auto-deletes "bogus state-log" lines of the form ;; - State "X" from "X" [date] ;; where the state didn't actually change. Org sometimes logs these when ;; `org-log-into-drawer' is unset and a state-change toggle lands on the ;; same state. They carry no information and they break org's planning-line ;; parser by sitting between the heading and DEADLINE/SCHEDULED. ;; ;; 2. Detects "orphan planning lines" — entries whose body contains ;; `^DEADLINE:' or `^SCHEDULED:' that org-entry-get can't read because the ;; line isn't in canonical position. Reports these for manual fix; doesn't ;; auto-rewrite (preserving real state-log history is judgement work). ;; ;; Designed for the wrap-it-up workflow: cheap (~0.4s on a 3700-line todo.org), ;; idempotent, and safe to run every session. Any fixes show up in the ;; wrap-up commit's diff for review. (require 'org) (require 'cl-lib) (setq org-todo-keywords '((sequence "TODO" "DOING" "WAITING" "NEXT" "|" "DONE" "CANCELLED"))) (defvar tc-fixes 0) (defvar tc-issues nil) (defvar tc-check-only nil) (defvar tc-current-file nil) (defun tc-fix-bogus-state-log-in-entry () "Delete bogus state-log lines within the entry at point. A bogus log line matches `- State \"X\" from \"X\" [date]' where the two states are identical." (save-excursion (let ((end (save-excursion (or (outline-next-heading) (goto-char (point-max))) (point)))) (while (re-search-forward "^[[:space:]]*- State \"\\([^\"]+\\)\"[[:space:]]+from \"\\1\"[[:space:]]+\\[[^]]+\\][[:space:]]*\n" end t) (let ((line (line-number-at-pos (match-beginning 0)))) (if tc-check-only (push (list :kind 'bogus-log :file tc-current-file :line line :detail (string-trim (match-string 0))) tc-issues) (delete-region (match-beginning 0) (match-end 0)) (cl-incf tc-fixes) (push (list :kind 'bogus-log-fixed :file tc-current-file :line line :detail (string-trim (match-string 0))) tc-issues))))))) (defun tc-detect-orphan-planning-in-entry () "Flag entries where DEADLINE/SCHEDULED is in the body but org-entry-get returns nil. This means the planning line isn't in canonical position, so org-mode's agenda + scheduling machinery won't see it." (let* ((line (line-number-at-pos)) (heading (org-get-heading t t t t)) (dl-canonical (org-entry-get (point) "DEADLINE")) (sc-canonical (org-entry-get (point) "SCHEDULED")) (start (save-excursion (org-end-of-meta-data t) (point))) (end (save-excursion (or (outline-next-heading) (goto-char (point-max))) (point))) (body (buffer-substring-no-properties start end))) (when (and (not dl-canonical) (string-match "^[[:space:]]*DEADLINE:[[:space:]]*\\(<[^>]+>\\)" body)) (push (list :kind 'orphan-deadline :file tc-current-file :line line :heading heading :detail (match-string 1 body)) tc-issues)) (when (and (not sc-canonical) (string-match "^[[:space:]]*SCHEDULED:[[:space:]]*\\(<[^>]+>\\)" body)) (push (list :kind 'orphan-scheduled :file tc-current-file :line line :heading heading :detail (match-string 1 body)) tc-issues)))) (defun tc-process-file (file) (setq tc-current-file (file-name-nondirectory file)) (with-current-buffer (find-file-noselect file) (org-mode) ;; Pass 1: auto-fix bogus state logs (or report under --check). (org-map-entries #'tc-fix-bogus-state-log-in-entry nil 'file) ;; Pass 2: detect orphan planning lines (always report-only). (org-map-entries #'tc-detect-orphan-planning-in-entry nil 'file) (when (and (not tc-check-only) (buffer-modified-p)) (save-buffer)))) (defun tc-emit-report () (princ (format "todo-cleanup: %d fix(es) applied%s\n" tc-fixes (if tc-check-only " — CHECK MODE (no writes)" ""))) (let ((orphans (cl-remove-if-not (lambda (i) (memq (plist-get i :kind) '(orphan-deadline orphan-scheduled))) tc-issues)) (logs (cl-remove-if-not (lambda (i) (memq (plist-get i :kind) '(bogus-log bogus-log-fixed))) tc-issues))) (when logs (princ (format " Bogus state-log lines (%s):\n" (if tc-check-only "would delete" "deleted"))) (dolist (i (nreverse logs)) (princ (format " %s:%d: %s\n" (plist-get i :file) (plist-get i :line) (plist-get i :detail))))) (when orphans (princ (format " Orphan planning lines needing manual fix (%d):\n" (length orphans))) (dolist (i (nreverse orphans)) (princ (format " %s:%d: %s — %s in body\n" (plist-get i :file) (plist-get i :line) (plist-get i :heading) (plist-get i :detail))))))) (when noninteractive ;; Mutate `command-line-args-left' so emacs's own arg parser doesn't see ;; --check after our script returns. (when (member "--check" command-line-args-left) (setq tc-check-only t) (setq command-line-args-left (delete "--check" command-line-args-left))) (if (null command-line-args-left) (progn (princ "Usage: emacs --batch -q -l todo-cleanup.el [--check] FILE...\n") (kill-emacs 1)) (let ((files command-line-args-left)) (setq command-line-args-left nil) (dolist (file files) (when (file-readable-p file) (tc-process-file file))) (tc-emit-report)))) (provide 'todo-cleanup) ;;; todo-cleanup.el ends here