UNPKG

omnifocus-mcp

Version:

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

235 lines (234 loc) 10.5 kB
/** * Shared AppleScript generation helpers for OmniFocus MCP primitives. */ /** * Escape a value for interpolation inside a double-quoted AppleScript string * literal. Quotes and backslashes are escaped first, then newlines are either * flattened to spaces (default) or preserved via `" & linefeed & "` splices * (for multi-line content like notes). The escape order matters: escaping * quotes/backslashes after splicing in linefeed would corrupt the splice. */ export function escapeAppleScriptString(value, options) { const escaped = value.replace(/["\\]/g, '\\$&'); return options?.preserveNewlines ? escaped.replace(/\r\n|\r|\n/g, '" & linefeed & "') : escaped.replace(/[\r\n]/g, ' '); } /** * Escape a TypeScript-known string for embedding as a JSON string *value* inside * a double-quoted AppleScript string literal (issue #103). * * `escapeAppleScriptString` alone is not enough for these sites: it protects the * AppleScript *source*, but at runtime the escaped quote re-materializes as a raw * `"` inside the JSON the script returns, and `JSON.parse` on our side then fails * — reporting an error for a write that succeeded. Two layers, applied in order: * JSON-escape the value (what the parser will see), then AppleScript-escape the * result (so the source stays well-formed). */ export function escapeForJsonInAppleScript(value) { // slice(1, -1) drops JSON.stringify's surrounding quotes, leaving just the // escaped body. JSON escaping also removes raw newlines, so the AppleScript // layer never sees one. return escapeAppleScriptString(JSON.stringify(value).slice(1, -1)); } /** * AppleScript source for a `jsonEscape` handler, for values that only exist at * script runtime (an item's current name, AppleScript error text). Prepend this * to a script and wrap interpolations with `my jsonEscape(...)` wherever a * runtime string is spliced into a JSON return payload (issue #103). * * Escapes backslash first (order matters), then quote, then the control * characters JSON.parse rejects raw. AppleScript has no replace; text item * delimiters are the idiom. */ export const JSON_ESCAPE_HANDLER = ` on jsonEscape(theText) set theText to theText as string set oldDelims to AppleScript's text item delimiters set AppleScript's text item delimiters to "\\\\" set theParts to text items of theText set AppleScript's text item delimiters to "\\\\\\\\" set theText to theParts as string set AppleScript's text item delimiters to "\\"" set theParts to text items of theText set AppleScript's text item delimiters to "\\\\\\"" set theText to theParts as string set AppleScript's text item delimiters to linefeed set theParts to text items of theText set AppleScript's text item delimiters to "\\\\n" set theText to theParts as string set AppleScript's text item delimiters to return set theParts to text items of theText set AppleScript's text item delimiters to "\\\\r" set theText to theParts as string set AppleScript's text item delimiters to tab set theParts to text items of theText set AppleScript's text item delimiters to "\\\\t" set theText to theParts as string set AppleScript's text item delimiters to oldDelims return theText end jsonEscape `; /** * Generate AppleScript that resolves a folder by path (e.g. "Work/Engineering") * or by simple name (e.g. "Work"). Sets `varName` to the found folder object, * or returns `errorReturnJson` if not found. * * The generated code must be placed inside a `tell front document` block. */ export function generateFolderLookupScript(rawFolderPath, varName, errorReturnJson) { const components = rawFolderPath.split('/').filter(c => c.length > 0); if (components.length === 0) { // A path with no usable components (e.g. "/" or "//") names no folder. Fail // loudly: leaving `varName` as `missing value` with no return lets the caller // fall through to a default container and report success for a placement // nobody asked for — the silent-success class of bug fixed in #57. return `set ${varName} to missing value return "${errorReturnJson}"`; } const escaped = components.map(c => escapeAppleScriptString(c)); const leafName = escaped[escaped.length - 1]; if (components.length === 1) { // Simple name lookup — same behavior as the original code return `set ${varName} to missing value try set ${varName} to first flattened folder where name = "${leafName}" end try if ${varName} is missing value then return "${errorReturnJson}" end if`; } // Path-based lookup: find the leaf folder, then verify ancestor chain const listItems = escaped.map(c => `"${c}"`).join(', '); return `set ${varName} to missing value set pathComponents to {${listItems}} repeat with aFolder in (flattened folders) if name of aFolder = "${leafName}" then -- Verify ancestor chain matches path set ancestorOk to true set currentItem to aFolder repeat with i from ((count of pathComponents) - 1) to 1 by -1 try set currentItem to container of currentItem if class of currentItem is not folder or name of currentItem is not equal to (item i of pathComponents) then set ancestorOk to false exit repeat end if on error set ancestorOk to false exit repeat end try end repeat if ancestorOk then set ${varName} to aFolder exit repeat end if end if end repeat if ${varName} is missing value then return "${errorReturnJson}" end if`; } /** * Generate AppleScript that resolves a project by name or folder-qualified path * (e.g. "Community Outreach" or "Work/Community Outreach"). * Sets `varName` to the found project object, or returns `errorReturnJson` if not found. * * When a path is provided, all components except the last are treated as the folder * ancestry (parent, grandparent, etc.) and the last component is the project name. * * The generated code must be placed inside a `tell front document` block. */ export function generateProjectLookupScript(rawProjectPath, varName, errorReturnJson) { const components = rawProjectPath.split('/').filter(c => c.length > 0); if (components.length === 0) { // See the note in generateFolderLookupScript: a component-less path must not // resolve to "no project" silently, or creation falls through to the inbox. return `set ${varName} to missing value return "${errorReturnJson}"`; } const escaped = components.map(c => escapeAppleScriptString(c)); const projectName = escaped[escaped.length - 1]; if (components.length === 1) { // Simple name lookup — whose gives a direct reference return `set ${varName} to missing value try set ${varName} to first flattened project whose name is "${projectName}" end try if ${varName} is missing value then return "${errorReturnJson}" end if`; } // Path-based lookup: last component is project name, preceding are folder ancestry const folderComponents = escaped.slice(0, -1); const folderItems = folderComponents.map(c => `"${c}"`).join(', '); return `set ${varName} to missing value set folderPath to {${folderItems}} repeat with aProject in (flattened projects) if (name of aProject as string) = "${projectName}" then -- Verify folder ancestry matches path set ancestorOk to true set currentItem to container of aProject repeat with i from (count of folderPath) to 1 by -1 try if class of currentItem is not folder or name of currentItem is not equal to (item i of folderPath) then set ancestorOk to false exit repeat end if set currentItem to container of currentItem on error set ancestorOk to false exit repeat end try end repeat if ancestorOk then set ${varName} to aProject exit repeat end if end if end repeat if ${varName} is missing value then return "${errorReturnJson}" end if`; } /** * AppleScript guard against mutating a completed occurrence of a repeating item * (issue #124). * * OmniFocus 4 keeps each completed occurrence of a repeating item as its own row, * carrying the same name and the same repetition rule as the live one. A query * that includes completed items therefore returns rows that look like duplicates, * and mutating one cascades through the live repeat chain — this produced real * data loss (8 targeted drops became ~15 dropped rows, including a future * occurrence of a daily task). * * The check is deliberately NOT on id shape. A repeating project's *live* id is * itself dotted (`bY_WHmMzWfC.116` is Active; `.116.43` is history), so refusing * dotted ids would make repeating projects uneditable. What actually separates * them is: belongs to a repeating series, and has already reached a terminal * status. Completing or editing the live occurrence — the common, correct * operation — is unaffected, because the live one is neither completed nor * dropped. * * Emit inside `tell front document`, after `foundItem` resolves. `varName` is the * resolved item; `errorReturnJson` is returned when the guard trips. */ export function generateOccurrenceGuardScript(varName, errorReturnJson) { return `try if (repetition rule of ${varName}) is not missing value then set _isDone to false try if completed of ${varName} then set _isDone to true end try try if (status of ${varName}) is done status or (status of ${varName}) is dropped status then set _isDone to true end try try if dropped of ${varName} then set _isDone to true end try if _isDone then return "${errorReturnJson}" end if end if end try`; }