omnifocus-mcp
Version:
Model Context Protocol (MCP) server that integrates with OmniFocus for AI assistant interaction
117 lines (116 loc) • 5.25 kB
JavaScript
/**
* Repetition rule support (issue #116).
*
* OmniFocus stores a repeat as an ICS recurrence string plus a method. Callers
* shouldn't have to hand-write RRULEs — a mistyped `INTERVAL` is silent and
* costly (a live task was found scheduled `FREQ=WEEKLY;INTERVAL=2;BYDAY=TU,TH`
* against a note reading "2x/week", i.e. half the intended rate, undetected for
* months). So the tool surface takes a typed shape and this module compiles it.
*
* Scope is set by a census of a real, heavily-used database (78 rules): every
* rule in the wild was a simple interval or a weekday set. Zero used BYSETPOS
* ("third Tuesday"), BYMONTHDAY, COUNT, or UNTIL — so those are deliberately
* unsupported rather than half-implemented.
*/
const FREQ_BY_UNIT = {
day: 'DAILY',
week: 'WEEKLY',
month: 'MONTHLY',
year: 'YEARLY',
};
/**
* AppleScript enum constants. These are the literal tokens the OmniFocus
* dictionary expects — verified by round-trip against OmniJS, which reports them
* back as Fixed / DeferUntilDate / DueDate respectively.
*/
const APPLESCRIPT_METHOD = {
fixed: 'fixed repetition',
'start-after-completion': 'start after completion',
'due-after-completion': 'due after completion',
};
/** How the method reads back from query_omnifocus (#115), for verification. */
export const OMNIJS_METHOD = {
fixed: 'Fixed',
'start-after-completion': 'DeferUntilDate',
'due-after-completion': 'DueDate',
};
const VALID_WEEKDAYS = ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'];
export class RepetitionSpecError extends Error {
}
/**
* Compile a spec into an ICS recurrence string.
*
* Throws RepetitionSpecError on anything malformed. Failing loudly matters here:
* a silently-dropped or silently-wrong repeat is exactly the failure this
* feature exists to remove, so an invalid spec must never degrade into "created
* without a repeat, reported success".
*/
export function compileRecurrence(spec) {
const freq = FREQ_BY_UNIT[spec.unit];
if (!freq) {
throw new RepetitionSpecError(`Invalid repeat unit "${spec.unit}". Expected one of: day, week, month, year.`);
}
if (!(spec.method in APPLESCRIPT_METHOD)) {
throw new RepetitionSpecError(`Invalid repeat method "${spec.method}". Expected one of: fixed, start-after-completion, due-after-completion.`);
}
const steps = spec.steps ?? 1;
if (!Number.isInteger(steps) || steps < 1) {
throw new RepetitionSpecError(`Invalid repeat steps "${spec.steps}". Expected a positive whole number.`);
}
const parts = [`FREQ=${freq}`];
// INTERVAL=1 is the ICS default; omitting it keeps rules byte-identical to
// what OmniFocus itself writes for a plain weekly repeat, which matters when
// comparing a written rule against one set by hand in the inspector.
if (steps > 1)
parts.push(`INTERVAL=${steps}`);
if (spec.weekdays && spec.weekdays.length > 0) {
if (spec.unit !== 'week') {
throw new RepetitionSpecError(`weekdays is only valid with unit "week" (got "${spec.unit}"). For monthly or yearly ` +
`positional rules ("third Tuesday"), set the repeat in OmniFocus directly — not yet supported here.`);
}
const seen = new Set();
for (const day of spec.weekdays) {
if (!VALID_WEEKDAYS.includes(day)) {
throw new RepetitionSpecError(`Invalid weekday "${day}". Expected two-letter codes: ${VALID_WEEKDAYS.join(', ')}.`);
}
if (seen.has(day)) {
throw new RepetitionSpecError(`Duplicate weekday "${day}" in repeat.`);
}
seen.add(day);
}
// Emit in calendar order rather than the caller's order, so the same set
// always produces the same rule string and comparisons are stable.
const ordered = VALID_WEEKDAYS.filter(d => seen.has(d));
parts.push(`BYDAY=${ordered.join(',')}`);
}
return parts.join(';');
}
/**
* The AppleScript record literal for a repetition rule, ready to interpolate
* into `set repetition rule of X to …` or a `make new …with properties` list.
*
* NOTE for anyone extending this: only assigning the WHOLE record works.
* Setting `recurrence` or `repetition method` as sub-properties fails with
* "Can't make … into type specifier". Verified empirically.
*/
export function repetitionRuleRecord(spec) {
const recurrence = compileRecurrence(spec);
return `{repetition method:${APPLESCRIPT_METHOD[spec.method]}, recurrence:"${recurrence}"}`;
}
/**
* Human-readable rendering for tool results, so a caller can confirm what was
* set without decoding an RRULE.
*/
export function describeRepetition(spec) {
const steps = spec.steps ?? 1;
const every = steps === 1 ? `every ${spec.unit}` : `every ${steps} ${spec.unit}s`;
const days = spec.weekdays && spec.weekdays.length > 0
? ` on ${VALID_WEEKDAYS.filter(d => spec.weekdays.includes(d)).join(', ')}`
: '';
const from = spec.method === 'fixed'
? 'on a fixed schedule'
: spec.method === 'start-after-completion'
? 'starting after completion'
: 'due after completion';
return `${every}${days}, ${from}`;
}