UNPKG

omnifocus-mcp

Version:

Model Context Protocol (MCP) server that integrates with OmniFocus for AI assistant interaction

351 lines (341 loc) 15.3 kB
import { runOsascriptFile } from '../../utils/scriptExecution.js'; import { writeFileSync, unlinkSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { createDateOutsideTellBlock } from '../../utils/dateFormatting.js'; import { escapeAppleScriptString, escapeForJsonInAppleScript, generateProjectLookupScript, JSON_ESCAPE_HANDLER, } from '../../utils/appleScriptHelpers.js'; import { repetitionRuleRecord } from '../../utils/repetitionRule.js'; /** * Generate pure AppleScript for task creation */ export function generateAppleScript(params) { // Sanitize and prepare parameters for AppleScript const name = escapeAppleScriptString(params.name); const note = params.note ? escapeAppleScriptString(params.note, { preserveNewlines: true }) : ''; const dueDate = params.dueDate || ''; const deferDate = params.deferDate || ''; const plannedDate = params.plannedDate || ''; const flagged = params.flagged === true; const estimatedMinutes = params.estimatedMinutes?.toString() || ''; const tags = params.tags || []; const projectId = params.projectId ? escapeAppleScriptString(params.projectId) : ''; const projectName = params.projectName ? escapeAppleScriptString(params.projectName) : ''; const parentTaskId = params.parentTaskId ? escapeAppleScriptString(params.parentTaskId) : ''; const parentTaskName = params.parentTaskName ? escapeAppleScriptString(params.parentTaskName) : ''; // Generate date constructions outside tell blocks let datePreScript = ''; let dueDateVar = ''; let deferDateVar = ''; let plannedDateVar = ''; if (dueDate) { dueDateVar = `dueDate${Math.random().toString(36).substr(2, 9)}`; datePreScript += createDateOutsideTellBlock(dueDate, dueDateVar) + '\n\n'; } if (deferDate) { deferDateVar = `deferDate${Math.random().toString(36).substr(2, 9)}`; datePreScript += createDateOutsideTellBlock(deferDate, deferDateVar) + '\n\n'; } if (plannedDate) { plannedDateVar = `plannedDate${Math.random().toString(36).substr(2, 9)}`; datePreScript += createDateOutsideTellBlock(plannedDate, plannedDateVar) + '\n\n'; } // Build the projectName lookup fragment via generateProjectLookupScript — // this gives folder-path support (e.g. "Work/My Project") for free, matching // edit_item.newProjectName. Only emitted when projectName is supplied. const projectNameLookup = params.projectName ? generateProjectLookupScript(params.projectName, 'targetProject', // escapeForJsonInAppleScript, not escapeAppleScriptString: this name lands // inside a JSON payload, where an AppleScript-escaped quote re-materializes // raw and corrupts the result (#103). `{\\\"success\\\":false,\\\"error\\\":\\\"Project not found: ${escapeForJsonInAppleScript(params.projectName)}\\\"}`) : ''; // Construct AppleScript with error handling let script = JSON_ESCAPE_HANDLER + datePreScript + ` try tell application "OmniFocus" tell front document -- Resolve target project up front: by id if provided, else by name, else missing set targetProject to missing value if "${projectId}" is not "" then try set targetProject to first flattened project where id = "${projectId}" end try if targetProject is missing value then return "{\\\"success\\\":false,\\\"error\\\":\\\"Project not found: id ${params.projectId ? escapeForJsonInAppleScript(params.projectId) : ''}\\\"}" end if else if "${projectName}" is not "" then ${projectNameLookup} end if -- Resolve parent task if provided set newTask to missing value set parentTask to missing value set placement to "" if "${parentTaskId}" is not "" then try set parentTask to first flattened task where id = "${parentTaskId}" end try if parentTask is missing value then try set parentTask to first inbox task where id = "${parentTaskId}" end try end if -- If target project resolved, ensure parent is within that project if parentTask is not missing value and targetProject is not missing value then try set pproj to containing project of parentTask if pproj is missing value or (id of pproj as string) is not equal to (id of targetProject as string) then set parentTask to missing value end try end if end if if parentTask is missing value and "${parentTaskName}" is not "" then if targetProject is not missing value then -- Find by name but constrain to the target project try set parentTask to first flattened task where name = "${parentTaskName}" end try if parentTask is not missing value then try set pproj to containing project of parentTask if pproj is missing value or (id of pproj as string) is not equal to (id of targetProject as string) then set parentTask to missing value end try end if else -- No project specified; allow global or inbox match by name try set parentTask to first flattened task where name = "${parentTaskName}" end try if parentTask is missing value then try set parentTask to first inbox task where name = "${parentTaskName}" end try end if end if end if if parentTask is not missing value then -- Create task under parent task set newTask to make new task with properties {name:"${name}"} at end of tasks of parentTask else if targetProject is not missing value then -- Create under the resolved target project set newTask to make new task with properties {name:"${name}"} at end of tasks of targetProject else -- Fallback to inbox set newTask to make new inbox task with properties {name:"${name}"} end if -- Set task properties ${note ? `set note of newTask to "${note}"` : ''} ${dueDate ? ` -- Set due date set due date of newTask to ` + dueDateVar : ''} ${deferDate ? ` -- Set defer date set defer date of newTask to ` + deferDateVar : ''} ${plannedDate ? ` -- Set planned date set planned date of newTask to ` + plannedDateVar : ''} ${flagged ? `set flagged of newTask to true` : ''} ${estimatedMinutes ? `set estimated minutes of newTask to ${estimatedMinutes}` : ''} ${params.repeat ? ` -- Set the repetition rule (#116). Assigned as a whole record: setting -- \`recurrence\` or \`repetition method\` as sub-properties fails with -- "Can't make … into type specifier". set repetition rule of newTask to ${repetitionRuleRecord(params.repeat)}` : ''} -- Derive placement from container; distinguish real parent vs project root task try set placement to "inbox" set ctr to container of newTask set cclass to class of ctr as string set ctrId to id of ctr as string if cclass is "project" then set placement to "project" else if cclass is "task" then if parentTask is not missing value then set parentId to id of parentTask as string if ctrId is equal to parentId then set placement to "parent" else -- Likely the project's root task; treat as project set placement to "project" end if else -- No explicit parent requested; container is root task -> treat as project set placement to "project" end if else set placement to "inbox" end if on error -- If container access fails (e.g., inbox), default based on whether a project was targeted if "${projectId}" is not "" or "${projectName}" is not "" then set placement to "project" else set placement to "inbox" end if end try -- Get the task ID set taskId to id of newTask as string -- Add tags if provided ${tags.length > 0 ? tags.map(tag => { const sanitizedTag = escapeAppleScriptString(tag); return ` try set theTag to first flattened tag where name = "${sanitizedTag}" add theTag to tags of newTask on error -- Tag might not exist, try to create it try set theTag to make new tag with properties {name:"${sanitizedTag}"} add theTag to tags of newTask on error -- Could not create or add tag end try end try`; }).join('\n') : ''} -- Return success with task ID and placement. The name is deliberately NOT -- echoed here: the caller already knows it, and splicing it into -- hand-built JSON is how quotes in a task name corrupted the payload (#103). return "{\\\"success\\\":true,\\\"taskId\\\":\\"" & taskId & "\\",\\\"placement\\\":\\"" & placement & "\\"}" end tell end tell on error errorMessage return "{\\\"success\\\":false,\\\"error\\\":\\"" & my jsonEscape(errorMessage) & "\\"}" end try `; return script; } // Backoff schedule (ms) for confirming a freshly-created task is resolvable. // First probe is immediate; on a warm database it hits and adds one cheap read. // The tail (~1.5s total) only elapses in the rare cold/racy case. const VERIFY_DELAYS_MS = [0, 50, 100, 200, 400, 800]; const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); /** * Retry a "is this task resolvable yet?" probe on a backoff schedule, returning * true as soon as the probe succeeds and false if it never does. * * Extracted (with injectable probe/sleep) so the retry behavior can be unit * tested without driving osascript. See verifyTaskResolvable for why this exists. */ export async function verifyPersistedWithRetries(probe, opts = {}) { const delays = opts.delaysMs ?? VERIFY_DELAYS_MS; const doSleep = opts.sleepFn ?? sleep; for (const delay of delays) { if (delay > 0) await doSleep(delay); if (await probe()) return true; } return false; } /** * Confirm a newly-created task is resolvable by its id in a SEPARATE osascript * invocation before we report success (issue #57). * * OmniFocus commits new objects on its own run loop. Immediately after * `make new task`, the record can be briefly invisible to a fresh `whose id` * lookup from another process, so an immediate follow-up edit_item (by the id we * returned) finds nothing — yet add reported success. That silent false-success * is the worst failure class: it makes the audit trail lie. * * The re-fetch MUST be a separate process. An in-script re-fetch (inside the * create tell block) resolves the in-memory object even when it isn't yet visible * to other processes, so it cannot distinguish committed from uncommitted — * verified empirically. A bounded, separate-process retry waits the commit out — * the same effect as the reporter's manual query-between-calls workaround — and * lets us return an honest failure if the task truly never lands. */ async function verifyTaskResolvable(taskId) { const escapedId = escapeAppleScriptString(taskId); const script = ` tell application "OmniFocus" tell front document try set t to first flattened task whose id is "${escapedId}" return "found" on error return "missing" end try end tell end tell`; const probe = async () => { const tempFile = join(tmpdir(), `omnifocus_verify_${crypto.randomUUID()}.applescript`); try { writeFileSync(tempFile, script, { encoding: 'utf8' }); const { stdout } = await runOsascriptFile(tempFile); return stdout.trim() === 'found'; } catch { // Treat a transient osascript error as not-yet-resolvable and keep retrying. return false; } finally { try { unlinkSync(tempFile); } catch { } } }; return verifyPersistedWithRetries(probe); } /** * Add a task to OmniFocus */ export async function addOmniFocusTask(params) { try { // Generate AppleScript const script = generateAppleScript(params); console.error("Executing AppleScript via temp file..."); // Write to a temporary AppleScript file to avoid shell escaping issues const tempFile = join(tmpdir(), `omnifocus_add_${crypto.randomUUID()}.applescript`); writeFileSync(tempFile, script, { encoding: 'utf8' }); // Execute AppleScript from file const { stdout, stderr } = await runOsascriptFile(tempFile); if (stderr) { console.error("AppleScript stderr:", stderr); } console.error("AppleScript stdout:", stdout); // Cleanup temp file try { unlinkSync(tempFile); } catch { } // Parse the result try { const result = JSON.parse(stdout); // Before reporting success, confirm the task actually persisted and is // resolvable by the id we're about to hand back (issue #57). Without this, // an immediate follow-up edit_item can operate on a not-yet-committed task // and the "success" we returned would be a lie. if (result.success && result.taskId) { const persisted = await verifyTaskResolvable(result.taskId); if (!persisted) { return { success: false, taskId: result.taskId, placement: result.placement, error: `Task "${params.name}" was created but could not be confirmed in OmniFocus after retries; it may not have persisted. Please retry.` }; } } // Return the result return { success: result.success, taskId: result.taskId, error: result.error, placement: result.placement }; } catch (parseError) { console.error("Error parsing AppleScript result:", parseError); return { success: false, error: `Failed to parse result: ${stdout}` }; } } catch (error) { console.error("Error in addOmniFocusTask:", error); return { success: false, error: error?.message || "Unknown error in addOmniFocusTask" }; } }