omnifocus-mcp
Version:
Model Context Protocol (MCP) server that integrates with OmniFocus for AI assistant interaction
235 lines (234 loc) • 10.5 kB
JavaScript
/**
* 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`;
}