UNPKG

omnifocus-mcp

Version:

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

501 lines (492 loc) 19.7 kB
import { runOsascriptFile } from '../../utils/scriptExecution.js'; import { writeFileSync, unlinkSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; import { generateDateAssignmentV2 } from '../../utils/dateFormatting.js'; import { escapeAppleScriptString, escapeForJsonInAppleScript, generateFolderLookupScript, generateProjectLookupScript, generateOccurrenceGuardScript, JSON_ESCAPE_HANDLER, } from '../../utils/appleScriptHelpers.js'; import { repetitionRuleRecord } from '../../utils/repetitionRule.js'; /** * Generate pure AppleScript for item editing with dates constructed outside tell blocks */ export function generateAppleScript(params) { // Sanitize and prepare parameters for AppleScript const id = params.id ? escapeAppleScriptString(params.id) : ''; const name = params.name ? escapeAppleScriptString(params.name) : ''; const itemType = params.itemType; // Verify we have at least one identifier if (!id && !name) { return `return "{\\\"success\\\":false,\\\"error\\\":\\\"Either id or name must be provided\\\"}"`; } // Collect all date constructions that need to happen outside tell blocks const datePreScripts = []; const dateAssignments = {}; // Process due date if provided const dueDateParts = generateDateAssignmentV2('foundItem', 'due date', params.newDueDate); if (dueDateParts) { if (dueDateParts.preScript) { datePreScripts.push(dueDateParts.preScript); } dateAssignments['due date'] = dueDateParts.assignmentScript; } // Process defer date if provided const deferDateParts = generateDateAssignmentV2('foundItem', 'defer date', params.newDeferDate); if (deferDateParts) { if (deferDateParts.preScript) { datePreScripts.push(deferDateParts.preScript); } dateAssignments['defer date'] = deferDateParts.assignmentScript; } // Process planned date if provided (tasks only) const plannedDateParts = generateDateAssignmentV2('foundItem', 'planned date', params.newPlannedDate); if (plannedDateParts) { if (plannedDateParts.preScript) { datePreScripts.push(plannedDateParts.preScript); } dateAssignments['planned date'] = plannedDateParts.assignmentScript; } // Build the complete script let script = JSON_ESCAPE_HANDLER; // Add date constructions outside tell blocks if (datePreScripts.length > 0) { script += datePreScripts.join('\n') + '\n\n'; } // Start the main script script += `try tell application "OmniFocus" tell front document -- Find the item to edit set foundItem to missing value `; // Add ID search if provided if (id) { if (itemType === 'task') { script += ` -- Find task by ID (flattened tasks includes inbox tasks and returns direct references) try set foundItem to first flattened task whose id is "${id}" end try `; } else { script += ` -- Find project by ID try set foundItem to first flattened project whose id is "${id}" end try `; } } // Add name search if provided (and no ID or as fallback) if (!id && name) { if (itemType === 'task') { script += ` -- Find task by name (flattened tasks includes inbox tasks) try set foundItem to first flattened task whose name is "${name}" end try `; } else { script += ` -- Find project by name try set foundItem to first flattened project whose name is "${name}" end try `; } } else if (id && name) { if (itemType === 'task') { script += ` -- If ID search failed, try to find by name as fallback if foundItem is missing value then try set foundItem to first flattened task whose name is "${name}" end try end if `; } else { script += ` -- If ID search failed, try to find project by name as fallback if foundItem is missing value then try set foundItem to first flattened project whose name is "${name}" end try end if `; } } // Add the item editing logic script += ` -- If we found the item, edit it if foundItem is not missing value then set itemName to name of foundItem set itemId to id of foundItem as string set changedProperties to {} ${params.allowPastOccurrence ? '' : generateOccurrenceGuardScript('foundItem', `{\\"success\\":false,\\"error\\":\\"This is a completed occurrence of a repeating item, not a duplicate. Mutating it can cascade through the live repeat chain. Query without includeCompleted to get the live occurrence, or pass allowPastOccurrence: true if you really mean this one.\\"}`)} `; // Common property updates for both tasks and projects if (params.newName !== undefined) { script += ` -- Update name set name of foundItem to "${escapeAppleScriptString(params.newName)}" set end of changedProperties to "name" `; } if (params.newNote !== undefined) { script += ` -- Update note set note of foundItem to "${escapeAppleScriptString(params.newNote, { preserveNewlines: true })}" set end of changedProperties to "note" `; } // Add date assignments (using pre-constructed dates) if (dateAssignments['due date']) { script += ` -- Update due date ${dateAssignments['due date']} set end of changedProperties to "due date" `; } if (dateAssignments['defer date']) { script += ` -- Update defer date ${dateAssignments['defer date']} set end of changedProperties to "defer date" `; } if (dateAssignments['planned date']) { script += ` -- Update planned date ${dateAssignments['planned date']} set end of changedProperties to "planned date" `; } if (params.newFlagged !== undefined) { script += ` -- Update flagged status set flagged of foundItem to ${params.newFlagged} set end of changedProperties to "flagged" `; } if (params.newEstimatedMinutes !== undefined) { script += ` -- Update estimated minutes set estimated minutes of foundItem to ${params.newEstimatedMinutes} set end of changedProperties to "estimated minutes" `; } // Repetition rule (#116). `null` clears — chosen over the dates' empty-string // convention because the value is an object, so there is no empty form of it. // Applies to tasks and projects alike; both can repeat. if (params.newRepeat !== undefined) { if (params.newRepeat === null) { script += ` -- Clear the repetition rule set repetition rule of foundItem to missing value set end of changedProperties to "repetition (cleared)" `; } else { script += ` -- Update the repetition rule. Whole-record assignment only: setting -- \`recurrence\` or \`repetition method\` as sub-properties fails with -- "Can't make … into type specifier". set repetition rule of foundItem to ${repetitionRuleRecord(params.newRepeat)} set end of changedProperties to "repetition" `; } } // Tag operations apply to both tasks and projects (OmniFocus supports tags on // projects too). Kept in the common section so project edits aren't a no-op. if (params.replaceTags && params.replaceTags.length > 0) { const tagsList = params.replaceTags.map(tag => `"${escapeAppleScriptString(tag)}"`).join(", "); script += ` -- Replace all tags set tagNames to {${tagsList}} set existingTags to tags of foundItem -- First clear all existing tags repeat with existingTag in existingTags remove existingTag from tags of foundItem end repeat -- Then add new tags repeat with tagName in tagNames set tagObj to missing value try set tagObj to first flattened tag where name = (tagName as string) on error -- Tag doesn't exist, create it set tagObj to make new tag with properties {name:(tagName as string)} end try if tagObj is not missing value then add tagObj to tags of foundItem end if end repeat set end of changedProperties to "tags (replaced)" `; } else { // Add tags if specified if (params.addTags && params.addTags.length > 0) { const tagsList = params.addTags.map(tag => `"${escapeAppleScriptString(tag)}"`).join(", "); script += ` -- Add tags set tagNames to {${tagsList}} repeat with tagName in tagNames set tagObj to missing value try set tagObj to first flattened tag where name = (tagName as string) on error -- Tag doesn't exist, create it set tagObj to make new tag with properties {name:(tagName as string)} end try if tagObj is not missing value then add tagObj to tags of foundItem end if end repeat set end of changedProperties to "tags (added)" `; } // Remove tags if specified if (params.removeTags && params.removeTags.length > 0) { const tagsList = params.removeTags.map(tag => `"${escapeAppleScriptString(tag)}"`).join(", "); script += ` -- Remove tags set tagNames to {${tagsList}} repeat with tagName in tagNames try set tagObj to first flattened tag where name = (tagName as string) remove tagObj from tags of foundItem end try end repeat set end of changedProperties to "tags (removed)" `; } } // Task-specific updates if (itemType === 'task') { // Update task status if (params.newStatus !== undefined) { if (params.newStatus === 'completed') { script += ` -- Mark task as completed (works reliably for all task types including inbox tasks) mark complete foundItem set end of changedProperties to "status (completed)" `; } else if (params.newStatus === 'dropped') { script += ` -- Mark task as dropped mark dropped foundItem set end of changedProperties to "status (dropped)" `; } else if (params.newStatus === 'skipped') { script += ` -- Skip repeating task: complete it to fire the next repeat, then drop the completed instance if repetition rule of foundItem is missing value then return "{\\\"success\\\":false,\\\"error\\\":\\\"Cannot skip a non-repeating task. The task must have a repetition rule.\\\"}" end if -- Store the ID of the current instance before completing set skippedTaskId to id of foundItem as string -- Complete the task to trigger the next repetition mark complete foundItem -- Now find and drop the completed instance by its original ID try set completedTask to first flattened task whose id is skippedTaskId set dropped of completedTask to true set end of changedProperties to "status (skipped)" on error -- The completed instance may have moved; still report success since repeat was triggered set end of changedProperties to "status (skipped - completed instance not found to drop)" end try `; } else if (params.newStatus === 'incomplete') { script += ` -- Mark task as incomplete mark incomplete foundItem set end of changedProperties to "status (incomplete)" `; } } // Move task to a different project if (params.newProjectName !== undefined) { if (params.newProjectName === '' || params.newProjectName.toLowerCase() === 'inbox') { script += ` -- Move task to inbox by clearing its assigned container set assigned container of foundItem to missing value set end of changedProperties to "project (moved to inbox)" `; } else { // escapedProjectPath is for AppleScript string contexts (the // changedProperties note below); the error JSON needs // escapeForJsonInAppleScript instead — an AppleScript-escaped quote // would re-materialize raw inside the JSON and corrupt it (#103). const escapedProjectPath = escapeAppleScriptString(params.newProjectName); const errorJson = `{\\\"success\\\":false,\\\"error\\\":\\\"Project not found: ${escapeForJsonInAppleScript(params.newProjectName)}\\\"}`; const projectLookup = generateProjectLookupScript(params.newProjectName, 'destProject', errorJson); script += ` -- Find the destination project (supports folder paths like "Work/My Project") ${projectLookup} move foundItem to end of tasks of destProject set end of changedProperties to "project (moved to ${escapedProjectPath})" `; } } } // Project-specific updates if (itemType === 'project') { // Update sequential status if (params.newSequential !== undefined) { script += ` -- Update sequential status set sequential of foundItem to ${params.newSequential} set end of changedProperties to "sequential" `; } // Update project status. OmniFocus rejects setting status to done/dropped // directly — those transitions require the mark complete/mark dropped verbs. if (params.newProjectStatus !== undefined) { if (params.newProjectStatus === 'dropped') { script += ` -- Mark project as dropped (must use verb, not set status) mark dropped foundItem set end of changedProperties to "status (dropped)" `; } else if (params.newProjectStatus === 'completed') { script += ` -- Mark project as completed (must use verb, not set status) mark complete foundItem set end of changedProperties to "status (completed)" `; } else { const statusValue = params.newProjectStatus === 'active' ? 'active status' : 'on hold status'; script += ` -- Update project status set status of foundItem to ${statusValue} set end of changedProperties to "status (${params.newProjectStatus})" `; } } // Mark project as reviewed if (params.markReviewed === true) { script += ` -- Let OmniFocus compute the next review date from the review interval set last review date of foundItem to current date set next review date of foundItem to missing value set end of changedProperties to "marked reviewed" `; } // Move to a new folder (supports nested paths like "Work/Engineering") if (params.newFolderName !== undefined && params.newFolderName !== '') { const errorJson = `{\\\"success\\\":false,\\\"error\\\":\\\"Folder not found: ${escapeForJsonInAppleScript(params.newFolderName)}\\\"}`; const folderLookup = generateFolderLookupScript(params.newFolderName, 'destFolder', errorJson); script += ` -- Find the destination folder ${folderLookup} -- Move project to the folder move {foundItem} to end of projects of destFolder set end of changedProperties to "folder" `; } } script += ` -- Prepare the changed properties as a string set changedPropsText to "" repeat with i from 1 to count of changedProperties set changedPropsText to changedPropsText & item i of changedProperties if i < count of changedProperties then set changedPropsText to changedPropsText & ", " end if end repeat -- Return success with details. itemName and changedPropsText only exist -- at script runtime, so they go through the jsonEscape handler — quotes -- in an item name would otherwise corrupt the payload (#103). return "{\\\"success\\\":true,\\\"id\\\":\\"" & itemId & "\\",\\\"name\\\":\\"" & my jsonEscape(itemName) & "\\",\\\"changedProperties\\\":\\"" & my jsonEscape(changedPropsText) & "\\"}" else -- Item not found return "{\\\"success\\\":false,\\\"error\\\":\\\"Item not found\\"}" end if end tell end tell on error errorMessage return "{\\\"success\\\":false,\\\"error\\\":\\"" & my jsonEscape(errorMessage) & "\\"}" end try `; return script; } /** * Edit a task or project in OmniFocus */ export async function editItem(params) { let tempFile; try { // Generate AppleScript const script = generateAppleScript(params); console.error("Executing AppleScript for editing (V2)..."); console.error(`Item type: ${params.itemType}, ID: ${params.id || 'not provided'}, Name: ${params.name || 'not provided'}`); // Log a preview of the script for debugging (first few lines) const scriptPreview = script.split('\n').slice(0, 10).join('\n') + '\n...'; console.error("AppleScript preview:\n", scriptPreview); // Write script to temporary file to avoid shell escaping issues tempFile = join(tmpdir(), `edit_omnifocus_${crypto.randomUUID()}.applescript`); writeFileSync(tempFile, script); // Execute AppleScript from file const { stdout, stderr } = await runOsascriptFile(tempFile); // Clean up temp file try { unlinkSync(tempFile); } catch (cleanupError) { console.error("Failed to clean up temp file:", cleanupError); } if (stderr) { console.error("AppleScript stderr:", stderr); } console.error("AppleScript stdout:", stdout); // Parse the result try { const result = JSON.parse(stdout); // Return the result return { success: result.success, id: result.id, name: result.name, changedProperties: result.changedProperties, error: result.error }; } catch (parseError) { console.error("Error parsing AppleScript result:", parseError); return { success: false, error: `Failed to parse result: ${stdout}` }; } } catch (error) { // Clean up temp file if it exists if (tempFile) { try { unlinkSync(tempFile); } catch (cleanupError) { // Ignore cleanup errors } } console.error("Error in editItem execution:", error); // Include more detailed error information if (error.message && error.message.includes('syntax error')) { console.error("This appears to be an AppleScript syntax error. Review the script generation logic."); } return { success: false, error: error?.message || "Unknown error in editItem" }; } }