Commit 3827b07d79b1

Vincent Demeester <vincent@sbr.pm>
2026-02-13 10:36:29
feat(org-todos): deep refile targets and position-based refile
Refile targets now include sub-sections (level 2) with outline path display, enabling refiling into sub-projects. Both source and target positions are passed to org-refile instead of searching by heading text, which fixes refile failures caused by special characters (emoji, org links) and stale buffer content.
1 parent 281bbf6
Changed files (3)
dots
config
pi
agent
extensions
org-todos
dots/config/emacs/site-lisp/org-batch-functions.el
@@ -352,24 +352,27 @@ NEW in v2.0."
   "Get all headings from FILE, including non-TODO entries.
 LEVEL: if provided, only get headings at that level (default: all top-level).
 Includes both TODO items and plain entries (like links, notes)."
-  (org-ql-select file
-    (if level
-        `(level ,level)
-      '(level 1))  ; Default to top-level only
-    :action (lambda ()
-              (let* ((element (org-element-at-point))
-                     (priority-char (org-element-property :priority element))
-                     (priority-num (when priority-char (- priority-char 48)))
-                     (heading (org-get-heading t t t t))
-                     (todo (org-get-todo-state)))
-                `((heading . ,heading)
-                  (todo . ,todo)  ; Will be nil for non-TODO entries
-                  (priority . ,priority-num)
-                  (tags . ,(org-get-tags nil t))
-                  (level . ,(org-current-level))
-                  (scheduled . ,(org-entry-get nil "SCHEDULED"))
-                  (deadline . ,(org-entry-get nil "DEADLINE"))
-                  (is-link . ,(string-match-p "^\\[\\[http" heading)))))))
+  (with-current-buffer (find-file-noselect file)
+    (revert-buffer t t)
+    (org-ql-select (current-buffer)
+      (if level
+          `(level ,level)
+        '(level 1))  ; Default to top-level only
+      :action (lambda ()
+                (let* ((element (org-element-at-point))
+                       (priority-char (org-element-property :priority element))
+                       (priority-num (when priority-char (- priority-char 48)))
+                       (heading (org-get-heading t t t t))
+                       (todo (org-get-todo-state)))
+                  `((heading . ,heading)
+                    (todo . ,todo)  ; Will be nil for non-TODO entries
+                    (priority . ,priority-num)
+                    (tags . ,(org-get-tags nil t))
+                    (level . ,(org-current-level))
+                    (scheduled . ,(org-entry-get nil "SCHEDULED"))
+                    (deadline . ,(org-entry-get nil "DEADLINE"))
+                    (is-link . ,(string-match-p "^\\[\\[http" heading))
+                    (position . ,(point))))))))
 
 ;;; Write Operations (unchanged - org-ql is read-only)
 
@@ -1085,16 +1088,25 @@ ERROR: error message if any"
 
 ;;; Refile Operations
 
-(defun org-batch-get-refile-targets (file)
-  "Get all valid refile targets (top-level sections) from FILE.
-Returns list of sections with their positions for refiling."
-  (with-current-buffer (find-file-noselect file)
-    (org-ql-select (current-buffer)
-      '(level 1)  ; Top-level sections only
-      :action (lambda ()
-                `((section . ,(org-get-heading t t t t))
-                  (file . ,file)
-                  (position . ,(point)))))))
+(defun org-batch-get-refile-targets (file &optional max-level)
+  "Get valid refile targets from FILE up to MAX-LEVEL depth.
+MAX-LEVEL defaults to 2 (sections and sub-sections).
+Returns list of targets with their outline path and positions."
+  (let ((depth (or max-level 2)))
+    (with-current-buffer (find-file-noselect file)
+      (revert-buffer t t)
+      (org-ql-select (current-buffer)
+        `(and (level <= ,depth)
+              (not (todo "DONE" "CANX")))
+        :action (lambda ()
+                  (let* ((heading (org-get-heading t t t t))
+                         (level (org-current-level))
+                         (path (org-get-outline-path t)))
+                    `((section . ,heading)
+                      (path . ,(mapconcat #'identity path "/"))
+                      (level . ,level)
+                      (file . ,file)
+                      (position . ,(point)))))))))
 
 (defun org-batch-refile-entry (source-file source-heading target-file target-section)
   "Refile entry with SOURCE-HEADING from SOURCE-FILE to TARGET-SECTION in TARGET-FILE.
@@ -1105,8 +1117,8 @@ Returns t on success, signals error on failure."
     ;; Find the source heading (match with or without TODO keyword)
     (goto-char (point-min))
     (unless (re-search-forward
-             (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\)?\\s-*"
-                     (regexp-quote source-heading))
+             (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\)?[ \t]*"
+                     (regexp-quote source-heading) "\\s-*$")
              nil t)
       (error "Heading not found in %s: %s" source-file source-heading))
 
@@ -1136,5 +1148,41 @@ Returns t on success, signals error on failure."
 
       t)))
 
+(defun org-batch-refile-entry-at-pos (source-file source-heading target-file target-section target-position &optional source-position)
+  "Refile entry with SOURCE-HEADING from SOURCE-FILE to TARGET-POSITION in TARGET-FILE.
+TARGET-SECTION is used as the label for org-refile.
+TARGET-POSITION must be a buffer position in TARGET-FILE.
+SOURCE-POSITION, if provided, is used directly instead of searching.
+Returns t on success, signals error on failure."
+  (with-current-buffer (find-file-noselect source-file)
+    (revert-buffer t t)
+    (if source-position
+        ;; Use exact position
+        (goto-char source-position)
+      ;; Find the source heading (match with or without TODO keyword)
+      (goto-char (point-min))
+      (unless (re-search-forward
+               (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\)?[ \t]*"
+                       (regexp-quote source-heading) "\\s-*$")
+               nil t)
+        (error "Heading not found in %s: %s" source-file source-heading))
+      (goto-char (line-beginning-position)))
+
+    ;; Ensure target buffer is up-to-date
+    (let ((target-buf (find-file-noselect target-file)))
+      (with-current-buffer target-buf
+        (revert-buffer t t))
+
+      ;; Perform the refile using org-refile with the exact position
+      (org-refile nil nil
+                  (list target-section target-file nil target-position))
+
+      ;; Save both buffers
+      (save-buffer)
+      (with-current-buffer target-buf
+        (save-buffer))
+
+      t)))
+
 (provide 'org-batch-functions)
 ;;; org-batch-functions.el ends here
dots/config/emacs/site-lisp/pi-org-todos.el
@@ -324,16 +324,20 @@ Returns JSON string with available sections."
   (let ((f (pi/org-todo--get-file file)))
     (pi/org-todo--safe-call #'org-batch-get-refile-targets f)))
 
-(defun pi/org-todo-refile (source-heading target-section &optional source-file target-file)
+(defun pi/org-todo-refile (source-heading target-section &optional source-file target-file target-position source-position)
   "Refile SOURCE-HEADING from source to TARGET-SECTION in target file.
 SOURCE-FILE defaults to inbox.org, TARGET-FILE defaults to todos.org.
+TARGET-POSITION, if provided, is used directly instead of searching for TARGET-SECTION.
+SOURCE-POSITION, if provided, is used directly instead of searching for SOURCE-HEADING.
 Returns JSON string."
   (let ((src (or source-file 
                  (expand-file-name "~/desktop/org/inbox.org")))
         (tgt (pi/org-todo--get-file target-file)))
     (condition-case err
         (progn
-          (org-batch-refile-entry src source-heading tgt target-section)
+          (if target-position
+              (org-batch-refile-entry-at-pos src source-heading tgt target-section target-position source-position)
+            (org-batch-refile-entry src source-heading tgt target-section))
           (pi/org-todo--json-response t 
             `((heading . ,source-heading)
               (target . ,target-section)
dots/pi/agent/extensions/org-todos/index.ts
@@ -1167,26 +1167,29 @@ export default function (pi: ExtensionAPI) {
       
       // Step 2: Select inbox item (skip if heading provided as argument)
       let heading = (args || "").trim();
+      let sourcePosition: number | null = null;
       
       if (!heading) {
-        const inboxItems: SelectItem[] = inboxResult.data.map((item: any) => {
+        const inboxItems: SelectItem[] = inboxResult.data.map((item: any, i: number) => {
           const prefix = item.todo ? `[${item.todo}] ` : "";
           const label = `${prefix}${item.heading}`;
           // Strip org link markup for cleaner display
           const cleanLabel = label.replace(/\[\[([^\]]*)\]\[([^\]]*)\]\]/g, "$2");
           return {
-            value: item.heading,
+            value: String(i), // index into inboxResult.data
             label: cleanLabel,
             description: item.todo ? undefined : "link/note",
           };
         });
         
-        const selected = await showSelectMenu(ctx, "Select inbox item to refile", inboxItems);
-        if (!selected) {
+        const selectedIdx = await showSelectMenu(ctx, "Select inbox item to refile", inboxItems);
+        if (selectedIdx === null) {
           ctx.ui.notify("Refile cancelled", "info");
           return;
         }
-        heading = selected;
+        const sourceItem = inboxResult.data[parseInt(selectedIdx, 10)];
+        heading = sourceItem.heading;
+        sourcePosition = sourceItem.position;
       }
       
       // Step 3: Get refile targets and select section
@@ -1197,30 +1200,39 @@ export default function (pi: ExtensionAPI) {
         return;
       }
       
-      const sectionItems: SelectItem[] = targetsResult.data.map((t: any) => ({
-        value: t.section,
-        label: t.section,
-      }));
+      // Build flat list with path for display, use index to identify target
+      const sectionItems: SelectItem[] = targetsResult.data.map((t: any, i: number) => {
+        const indent = t.level > 1 ? "  ".repeat(t.level - 1) : "";
+        const cleanSection = t.section.replace(/\[\[([^\]]*)\]\[([^\]]*)\]\]/g, "$2");
+        return {
+          value: String(i), // index into targetsResult.data
+          label: `${indent}${cleanSection}`,
+          description: t.level > 1 ? t.path : undefined,
+        };
+      });
       
       // Clean heading for display
       const displayHeading = heading.replace(/\[\[([^\]]*)\]\[([^\]]*)\]\]/g, "$2").slice(0, 60);
-      const section = await showSelectMenu(ctx, `Refile "${displayHeading}" to:`, sectionItems);
-      if (!section) {
+      const targetIdx = await showSelectMenu(ctx, `Refile "${displayHeading}" to:`, sectionItems);
+      if (targetIdx === null) {
         ctx.ui.notify("Refile cancelled", "info");
         return;
       }
       
-      // Step 4: Perform refile
-      const refileResult = execEmacs(`(pi/org-todo-refile "${heading.replace(/"/g, '\\"')}" "${section.replace(/"/g, '\\"')}")`);
+      // Step 4: Perform refile using positions for accuracy (avoids encoding/regex issues)
+      const target = targetsResult.data[parseInt(targetIdx, 10)];
+      const srcPosArg = sourcePosition ? ` ${sourcePosition}` : "";
+      const refileResult = execEmacs(`(pi/org-todo-refile "${heading.replace(/"/g, '\\"')}" "${target.section.replace(/"/g, '\\"')}" nil nil ${target.position}${srcPosArg})`);
       
       if (!refileResult.success) {
         ctx.ui.notify(`Refile failed: ${refileResult.error}`, "error");
         return;
       }
       
+      const displayTarget = target.path || target.section;
       pi.sendMessage({
         customType: "org-todos",
-        content: `## ✅ Refiled\n\n**${displayHeading}** → *${section}*`,
+        content: `## ✅ Refiled\n\n**${displayHeading}** → *${displayTarget}*`,
         display: true,
       });