Commit f2a571b7b851
Changed files (4)
dots
config
emacs
site-lisp
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);
+ });
+ });
}