Commit f2a571b7b851

Vincent Demeester <vincent@sbr.pm>
2026-02-13 10:05:07
feat(org-todos): add inbox, refile, status widgets
Added inbox support using separate inbox.org file with display for both TODO items and plain entries (links, notes). Added refile command to move inbox items to todos.org sections via org-refile. Added status bar widgets for inbox count and today's tasks (showing overdue and scheduled separately with periodic refresh). Fixed org-batch-get-overdue to include past-scheduled items matching org-agenda behavior. Ensured auto-save and backup directories are created on Emacs startup.
1 parent 992b2ec
Changed files (4)
dots
config
pi
agent
extensions
org-todos
dots/config/emacs/site-lisp/org-batch-functions.el
@@ -249,11 +249,13 @@ Returns nil if heading not found."
       "")))
 
 (defun org-batch-get-overdue (file)
-  "Get all tasks with DEADLINE before today from FILE.
-Uses org-ql's `deadline' predicate with :to -1 to get past deadlines."
+  "Get all tasks with DEADLINE or SCHEDULED before today from FILE.
+Uses org-ql's `deadline' and `scheduled' predicates with :to -1
+to get past deadlines and past scheduled items."
   (org-ql-select file
     '(and (todo "TODO" "NEXT" "STRT" "WAIT")
-          (deadline :to -1))
+          (or (deadline :to -1)
+              (scheduled :to -1)))
     :action #'org-batch--element-to-alist-at-point))
 
 (defun org-batch-get-upcoming (file &optional days)
@@ -346,6 +348,29 @@ NEW in v2.0."
             (regexp ,regexp))
       :action #'org-batch--element-to-alist-at-point)))
 
+(defun org-batch-get-all-entries (file &optional level)
+  "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)))))))
+
 ;;; Write Operations (unchanged - org-ql is read-only)
 
 (defun org-batch--adjust-heading-levels (content parent-level)
@@ -1058,5 +1083,59 @@ ERROR: error message if any"
   "Output error MESSAGE in JSON format."
   (org-batch-output-json nil nil message))
 
+;;; 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-refile-entry (source-file source-heading target-file target-section)
+  "Refile entry with SOURCE-HEADING from SOURCE-FILE to TARGET-SECTION in TARGET-FILE.
+Returns t on success, nil on failure."
+  (condition-case err
+      (with-current-buffer (find-file-noselect source-file)
+        ;; Find the source heading
+        (goto-char (point-min))
+        (unless (re-search-forward 
+                 (concat "^\\*+ \\(?:TODO\\|NEXT\\|STRT\\|WAIT\\|DONE\\|CANX\\)?\\s-*"
+                         (regexp-quote source-heading))
+                 nil t)
+          (error "Heading not found: %s" source-heading))
+        
+        (goto-char (line-beginning-position))
+        
+        ;; Find target location in target file
+        (let* ((target-buf (find-file-noselect target-file))
+               (target-pos (with-current-buffer target-buf
+                            (goto-char (point-min))
+                            (when (re-search-forward 
+                                   (concat "^\\* " (regexp-quote target-section))
+                                   nil t)
+                              (line-end-position)))))
+          
+          (unless target-pos
+            (error "Target section not found: %s" target-section))
+          
+          ;; Perform the refile using org-refile
+          (org-refile nil nil 
+                     (list target-section target-file nil target-pos))
+          
+          ;; Save both buffers
+          (save-buffer)
+          (with-current-buffer target-buf
+            (save-buffer))
+          
+          t))
+    (error
+     (message "Refile failed: %s" (error-message-string err))
+     nil)))
+
 (provide 'org-batch-functions)
 ;;; org-batch-functions.el ends here
dots/config/emacs/site-lisp/pi-org-todos.el
@@ -279,6 +279,16 @@ Returns JSON string."
   (let ((f (pi/org-todo--get-file file)))
     (pi/org-todo--safe-call #'org-batch-list-all-tags f)))
 
+;;; Inbox Operations
+
+(defun pi/org-todo-inbox-all (&optional file)
+  "Get all entries from inbox FILE (both TODOs and plain entries like links).
+Returns JSON string."
+  (let ((f (or file 
+               (getenv "ORG_INBOX_FILE") 
+               (expand-file-name "~/desktop/org/inbox.org"))))
+    (pi/org-todo--safe-call #'org-batch-get-all-entries f 1)))
+
 ;;; Property Operations
 
 (defun pi/org-todo-get-property (heading property &optional file)
@@ -306,5 +316,31 @@ Returns JSON string."
       (error
        (pi/org-todo--json-response nil nil (error-message-string err))))))
 
+;;; Refile Operations
+
+(defun pi/org-todo-get-refile-targets (&optional file)
+  "Get refile targets (top-level sections) from FILE.
+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)
+  "Refile SOURCE-HEADING from source to TARGET-SECTION in target file.
+SOURCE-FILE defaults to inbox.org, TARGET-FILE defaults to todos.org.
+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
+        (if (org-batch-refile-entry src source-heading tgt target-section)
+            (pi/org-todo--json-response t 
+              `((heading . ,source-heading)
+                (target . ,target-section)
+                (source-file . ,src)
+                (target-file . ,tgt)))
+          (pi/org-todo--json-response nil nil "Refile operation failed"))
+      (error
+       (pi/org-todo--json-response nil nil (error-message-string err))))))
+
 (provide 'pi-org-todos)
 ;;; pi-org-todos.el ends here
dots/config/emacs/init.el
@@ -8,6 +8,15 @@
 ;; This is the "mini" version for now, but aims to become the default one.
 ;;; Code:
 
+;;; Ensure critical directories exist
+;; Create auto-save and backup directories if they don't exist
+(let ((auto-save-dir (expand-file-name "~/.local/share/emacs/auto-saves/"))
+      (backup-dir (expand-file-name "~/.local/share/emacs/backups/")))
+  (unless (file-directory-p auto-save-dir)
+    (make-directory auto-save-dir t))
+  (unless (file-directory-p backup-dir)
+    (make-directory backup-dir t)))
+
 ;;; Some constants I am using across the configuration.
 (defconst org-directory "~/desktop/org/"
   "`org-mode' directory, where most of the org-mode file lives.")
@@ -2044,7 +2053,28 @@ Use this function via a hook."
 
 ;; org-batch-functions and pi-org-todos are site-lisp packages called
 ;; externally via emacsclient โ€” their require chains pull in org/org-ql.
-(use-package org-batch-functions)
+(use-package org-batch-functions
+  :commands (org-batch-list-todos
+             org-batch-scheduled-today
+             org-batch-by-section
+             org-batch-count-by-state
+             org-batch-search
+             org-batch-get-sections
+             org-batch-get-children
+             org-batch-get-todo-content
+             org-batch-get-overdue
+             org-batch-get-upcoming
+             org-batch-get-statistics
+             org-batch-append-content
+             org-batch-update-state
+             org-batch-add-todo
+             org-batch-schedule-task
+             org-batch-set-deadline
+             org-batch-set-priority
+             org-batch-archive-done
+             org-batch-get-all-entries
+             org-batch-get-refile-targets
+             org-batch-refile-entry))
 (use-package pi-org-todos
   :commands (pi/org-todo-list
              pi/org-todo-list-all
@@ -2068,7 +2098,10 @@ Use this function via a hook."
              pi/org-todo-remove-tags
              pi/org-todo-all-tags
              pi/org-todo-get-property
-             pi/org-todo-set-property))
+             pi/org-todo-set-property
+             pi/org-todo-inbox-all
+             pi/org-todo-get-refile-targets
+             pi/org-todo-refile))
 
 (use-package ox-tufte
   :after org
dots/pi/agent/extensions/org-todos/index.ts
@@ -40,6 +40,7 @@ import { Container, Text } from "@mariozechner/pi-tui";
 
 // Configuration
 const DEFAULT_ORG_FILE = join(homedir(), "desktop/org/todos.org");
+const INBOX_FILE = join(homedir(), "desktop/org/inbox.org");
 const DEFAULT_SECTION = "Inbox"; // Default section for quick adds
 
 interface OrgTodoResult {
@@ -306,7 +307,12 @@ export default function (pi: ExtensionAPI) {
 - deadline: Set deadline date
 - priority: Set priority (1-5)
 - add: Create new TODO
-- append: Append content to TODO`,
+- append: Append content to TODO
+- inbox-list: List all inbox items
+- inbox-count: Get count of inbox items
+- inbox-add: Add item to inbox
+- refile-targets: Get available refile target sections
+- refile: Refile item from inbox to a section`,
     parameters: {
       type: "object",
       properties: {
@@ -315,7 +321,9 @@ export default function (pi: ExtensionAPI) {
           enum: [
             "list", "scheduled", "upcoming", "overdue", "search", "get",
             "done", "state", "schedule", "deadline", "priority", "add", "append",
-            "sections", "statistics", "archive"
+            "sections", "statistics", "archive",
+            "inbox-list", "inbox-count", "inbox-add",
+            "refile-targets", "refile"
           ],
           description: "Action to perform",
         },
@@ -482,6 +490,39 @@ export default function (pi: ExtensionAPI) {
           elisp = "(pi/org-todo-archive-done)";
           break;
           
+        case "inbox-list":
+          elisp = `(pi/org-todo-list "${INBOX_FILE}" "TODO,NEXT,STRT,WAIT")`;
+          break;
+          
+        case "inbox-count":
+          elisp = `(pi/org-todo-inbox-all)`;
+          break;
+          
+        case "inbox-add":
+          if (!heading) {
+            return {
+              content: [{ type: "text", text: "Error: heading is required for inbox-add action" }],
+            };
+          }
+          const schedInbox = date ? `"${date}"` : "nil";
+          const prioInbox = priority !== undefined ? priority : "nil";
+          const tagsInbox = tags && tags.length > 0 ? `'(${tags.map(t => `"${t}"`).join(" ")})` : "nil";
+          elisp = `(pi/org-todo-add "${heading.replace(/"/g, '\\"')}" "Inbox" "${INBOX_FILE}" ${schedInbox} ${prioInbox} ${tagsInbox})`;
+          break;
+          
+        case "refile-targets":
+          elisp = "(pi/org-todo-get-refile-targets)";
+          break;
+          
+        case "refile":
+          if (!heading || !section) {
+            return {
+              content: [{ type: "text", text: "Error: heading and section are required for refile action" }],
+            };
+          }
+          elisp = `(pi/org-todo-refile "${heading.replace(/"/g, '\\"')}" "${section.replace(/"/g, '\\"')}")`;
+          break;
+          
         default:
           return {
             content: [{ type: "text", text: `Unknown action: ${action}` }],
@@ -718,6 +759,9 @@ export default function (pi: ExtensionAPI) {
         content: `## โœ… Done\n\n**${heading}** marked as DONE`,
         display: true,
       });
+      
+      // Update today status as completing a task affects the count
+      updateTodayStatus(ctx);
     },
   });
 
@@ -908,4 +952,231 @@ export default function (pi: ExtensionAPI) {
       });
     },
   });
+
+  // Register /inbox command - Quick view of inbox items (both TODOs and links)
+  pi.registerCommand("inbox", {
+    description: "View all inbox items (TODOs and links)",
+    handler: async (args, ctx) => {
+      const result = execEmacs(`(pi/org-todo-inbox-all)`);
+      
+      if (!result.success) {
+        ctx.ui.notify(`Failed to fetch inbox: ${result.error}`, "error");
+        return;
+      }
+      
+      const todos = result.data?.filter((item: any) => item.todo) || [];
+      const links = result.data?.filter((item: any) => !item.todo) || [];
+      
+      const lines: string[] = [];
+      lines.push("## ๐Ÿ“ฅ Inbox");
+      lines.push("");
+      
+      if (!result.data || result.data.length === 0) {
+        lines.push("*Inbox is empty* โœจ");
+      } else {
+        lines.push(`*${result.data.length} item(s)* (${todos.length} tasks, ${links.length} links/notes)`);
+        lines.push("");
+        
+        if (todos.length > 0) {
+          lines.push("### โœ… Tasks");
+          lines.push("");
+          for (const todo of todos) {
+            lines.push(`- ${formatTodoMarkdown(todo)}`);
+          }
+          lines.push("");
+        }
+        
+        if (links.length > 0) {
+          lines.push("### ๐Ÿ”— Links & Notes");
+          lines.push("");
+          for (const item of links) {
+            lines.push(`- ${item.heading}`);
+          }
+        }
+      }
+      
+      pi.sendMessage({
+        customType: "org-todos",
+        content: lines.join("\n"),
+        display: true,
+      });
+    },
+  });
+
+  // Register /inbox-add command - Quick capture to inbox
+  pi.registerCommand("inbox-add", {
+    description: "Quick capture to inbox. Usage: /inbox-add <title> [scheduled:date] [priority:N]",
+    handler: async (args, ctx) => {
+      if (!args?.trim()) {
+        ctx.ui.notify("Usage: /inbox-add <title> [scheduled:date] [priority:N]", "error");
+        return;
+      }
+      
+      const parsed = parseCommandArgs(args);
+      
+      if (!parsed.title) {
+        ctx.ui.notify("Error: TODO title is required", "error");
+        return;
+      }
+      
+      const schedArg = parsed.scheduled ? `"${parsed.scheduled}"` : "nil";
+      const prioArg = parsed.priority !== undefined ? parsed.priority : "nil";
+      const tagsArg = parsed.tags && parsed.tags.length > 0 ? `'(${parsed.tags.map(t => `"${t}"`).join(" ")})` : "nil";
+      
+      // Inbox.org has a simple structure - we just add at top level
+      const elisp = `(with-current-buffer (find-file-noselect "${INBOX_FILE}")
+        (goto-char (point-max))
+        (insert "\\n* TODO ${parsed.title.replace(/"/g, '\\"')}")
+        ${parsed.scheduled ? `(org-schedule nil "${parsed.scheduled}")` : ''}
+        ${parsed.priority !== undefined ? `(org-priority ${parsed.priority})` : ''}
+        (save-buffer)
+        (kill-buffer)
+        "Added")`;
+      
+      const result = execEmacs(elisp);
+      
+      if (!result.success) {
+        ctx.ui.notify(`Failed to add to inbox: ${result.error}`, "error");
+        return;
+      }
+      
+      const lines: string[] = [];
+      lines.push(`## ๐Ÿ“ฅ Added to Inbox`);
+      lines.push("");
+      lines.push(`**${parsed.title}**`);
+      if (parsed.scheduled) lines.push(`- ๐Ÿ“… Scheduled: ${parsed.scheduled}`);
+      if (parsed.priority) lines.push(`- Priority: #${parsed.priority}`);
+      
+      pi.sendMessage({
+        customType: "org-todos-add",
+        content: lines.join("\n"),
+        display: true,
+      });
+      
+      // Update status bars (inbox count and today if scheduled)
+      updateInboxStatus(ctx);
+      if (parsed.scheduled) {
+        updateTodayStatus(ctx);
+      }
+    },
+  });
+
+  // Register /inbox-refile command - Refile inbox item with interactive menu
+  pi.registerCommand("inbox-refile", {
+    description: "Refile inbox item to a section. Usage: /inbox-refile <heading>",
+    handler: async (args, ctx) => {
+      if (!args?.trim()) {
+        ctx.ui.notify("Usage: /inbox-refile <heading>", "error");
+        return;
+      }
+      
+      const heading = args.trim();
+      
+      // Get available refile targets
+      const targetsResult = execEmacs("(pi/org-todo-get-refile-targets)");
+      
+      if (!targetsResult.success || !targetsResult.data) {
+        ctx.ui.notify("Failed to get refile targets", "error");
+        return;
+      }
+      
+      const sections = targetsResult.data.map((t: any) => t.section);
+      
+      // Display menu for selection
+      const lines: string[] = [];
+      lines.push(`## ๐Ÿ“‹ Refile: "${heading}"`);
+      lines.push("");
+      lines.push("**Available sections:**");
+      lines.push("");
+      sections.forEach((section: string, i: number) => {
+        lines.push(`${i + 1}. **${section}**`);
+      });
+      lines.push("");
+      lines.push("To refile to a section, I'll call the org_todo tool with:");
+      lines.push("```json");
+      lines.push(`{`);
+      lines.push(`  "action": "refile",`);
+      lines.push(`  "heading": "${heading}",`);
+      lines.push(`  "section": "<choose from list above>"`);
+      lines.push(`}`);
+      lines.push("```");
+      lines.push("");
+      lines.push("*Which section would you like to refile to?*");
+      
+      pi.sendMessage({
+        customType: "org-todos",
+        content: lines.join("\n"),
+        display: true,
+      });
+    },
+  });
+
+  // Helper function to update inbox count in status bar
+  function updateInboxStatus(ctx: any) {
+    try {
+      const result = execEmacs(`(pi/org-todo-inbox-all)`);
+      
+      if (result.success && Array.isArray(result.data)) {
+        const count = result.data.length;
+        if (count > 0) {
+          ctx.ui.setStatus("inbox-count", ctx.ui.theme.fg("warning", `๐Ÿ“ฅ ${count}`));
+        } else {
+          ctx.ui.setStatus("inbox-count", undefined);
+        }
+      }
+    } catch (e) {
+      // Silently fail - inbox status is not critical
+    }
+  }
+
+  // Helper function to update today's tasks status in status bar
+  function updateTodayStatus(ctx: any) {
+    try {
+      // Get scheduled and overdue counts
+      const scheduledResult = execEmacs("(pi/org-todo-scheduled)");
+      const overdueResult = execEmacs("(pi/org-todo-overdue)");
+      
+      if (scheduledResult.success && overdueResult.success) {
+        const schedCount = Array.isArray(scheduledResult.data) ? scheduledResult.data.length : 0;
+        const overdueCount = Array.isArray(overdueResult.data) ? overdueResult.data.length : 0;
+        
+        if (schedCount === 0 && overdueCount === 0) {
+          // Nothing scheduled or overdue - show clear status
+          ctx.ui.setStatus("today-todos", ctx.ui.theme.fg("success", "โœ“"));
+        } else if (overdueCount > 0 && schedCount > 0) {
+          // Both overdue and scheduled - show both with overdue in red
+          ctx.ui.setStatus("today-todos", 
+            ctx.ui.theme.fg("error", `โš ๏ธ ${overdueCount}`) + " " + 
+            ctx.ui.theme.fg("accent", `๐Ÿ“… ${schedCount}`));
+        } else if (overdueCount > 0) {
+          // Only overdue - show in red
+          ctx.ui.setStatus("today-todos", 
+            ctx.ui.theme.fg("error", `โš ๏ธ ${overdueCount}`));
+        } else {
+          // Only scheduled - show in accent color
+          ctx.ui.setStatus("today-todos", 
+            ctx.ui.theme.fg("accent", `๐Ÿ“… ${schedCount}`));
+        }
+      }
+    } catch (e) {
+      // Silently fail - today status is not critical
+    }
+  }
+
+  // Update status bars on session start
+  pi.on("session_start", async (_event, ctx) => {
+    updateInboxStatus(ctx);
+    updateTodayStatus(ctx);
+    
+    // Set up periodic updates every 5 minutes
+    const updateInterval = setInterval(() => {
+      updateInboxStatus(ctx);
+      updateTodayStatus(ctx);
+    }, 5 * 60 * 1000); // 5 minutes in milliseconds
+    
+    // Clean up interval on session end
+    pi.on("session_end", async () => {
+      clearInterval(updateInterval);
+    });
+  });
 }