Commit 3827b07d79b1
Changed files (3)
dots
config
emacs
site-lisp
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,
});