omnifocus-mcp
Version:
Model Context Protocol (MCP) server that integrates with OmniFocus for AI assistant interaction
795 lines (771 loc) • 32.5 kB
JavaScript
import { executeOmniFocusScript } from '../../utils/scriptExecution.js';
import { JXA_FORMAT_DATE_SOURCE } from '../../utils/dateSerialization.js';
// Escape user-provided strings before embedding in JXA script template literals.
// Prevents syntax errors and code injection from characters like " \ newlines.
function escapeJXA(str) {
return str
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r');
}
// OmniJS `Project` forwards most of its root task's properties (name, note,
// flagged, dueDate, deferDate, tags, completionDate, dropDate...) but NOT
// `added`/`modified` — reading those off a Project yields `undefined`, which
// silently matched nothing. Route them through the root task instead. Verified
// against the live OmniFocus 4.x database: 0/43 projects had `.added`, 43/43 had
// `.task.added`.
const PROJECT_ADDED_EXPR = 'item.task ? item.task.added : null';
const PROJECT_MODIFIED_EXPR = 'item.task ? item.task.modified : null';
// An empty note makes `item.note && ...` evaluate to "" rather than false, so the
// strict `hasNote !== false` comparison below rejected every item and
// `hasNote: false` returned nothing. Coerce before comparing.
const HAS_NOTE_EXPR = 'Boolean(item.note && item.note.trim().length > 0)';
// OmniJS has no `container` property at all — the containing folder is `parent`.
// Both the `path` field and `parentFolderID` read `container`, so `path` fell
// through to the bare name for every folder and `parentFolderID` (which had no
// branch of its own) defaulted to null. Neither errored; both just quietly
// described a flat database. Verified live against OmniFocus 4.x: 0/17 folders,
// 0/970 tasks and 0/81 projects had `.container`, while 9/17 folders had a
// `.parent` (issue #95).
//
// Walk the chain rather than reading one level up: nesting deeper than one folder
// is real (`PhD/Dissertation/Project 3` in the database this was verified
// against), and `container.name + "/" + item.name` could not have produced a
// depth-2 path even if `container` had existed. `parent` is null at the top
// level, which terminates the loop.
const FOLDER_PATH_EXPR = '(() => { const segments = []; let node = item; while (node) { segments.unshift(node.name); node = node.parent; } return segments.join("/"); })()';
// Matches omnifocusDump.js, which has read folder parentage correctly all along —
// dump_database reported the hierarchy while query_omnifocus flattened it.
const FOLDER_PARENT_ID_EXPR = 'item.parent ? item.parent.id.primaryKey : null';
/**
* Repetition rule readback (issue #115).
*
* `Task.RepetitionRule` has no useful `toString()` — it renders as the literal
* "[object Task.RepetitionRule]", which is what this field emitted before,
* telling the caller nothing about the rule. The real data is on two
* properties: the ICS recurrence string, and a method constant that
* stringifies as "[object Task.RepetitionMethod: DeferUntilDate]". Strip the
* wrapper so callers get a bare name they can compare and round-trip.
*/
/**
* Historical occurrence of a repeating series (issue #124).
*
* OmniFocus 4 keeps completed occurrences of a repeating item as separate rows
* with positional ids (`abc.5`, `abc.116.43`). They carry the same name AND the
* same repetitionRule as the live item, so a query that includes completed items
* returns rows that are indistinguishable from duplicates — which is exactly how
* one was read, leading to mutations that cascaded through a live repeat chain.
*
* Id shape is NOT the discriminator: a repeating *project's* live id is itself
* dotted (`bY_WHmMzWfC.116` is the Active project; `.116.43` is history), so
* refusing dotted ids would make repeating projects uneditable. The honest
* signal is "belongs to a repeating series AND has reached a terminal status".
*/
const IS_PAST_OCCURRENCE_TASK_EXPR = '(item.repetitionRule !== null && (item.taskStatus === Task.Status.Completed || item.taskStatus === Task.Status.Dropped))';
const IS_PAST_OCCURRENCE_PROJECT_EXPR = '(item.repetitionRule !== null && (item.status === Project.Status.Done || item.status === Project.Status.Dropped))';
const REPETITION_RULE_EXPR = 'item.repetitionRule ? item.repetitionRule.ruleString : null';
const REPETITION_METHOD_EXPR = 'item.repetitionRule ? String(item.repetitionRule.method).replace(/^\\[object Task\\.RepetitionMethod: |\\]$/g, "") : null';
export async function queryOmnifocus(params) {
try {
// Create JXA script for the query
const jxaScript = generateQueryScript(params);
// randomUUID, not Date.now(): parallel queries (e.g. the today resource)
// land in the same millisecond and would clobber each other's temp file
const tempFile = `/tmp/omnifocus_query_${crypto.randomUUID()}.js`;
const fs = await import('fs');
fs.writeFileSync(tempFile, jxaScript);
// Execute the script
const result = await executeOmniFocusScript(tempFile);
// Clean up temp file
fs.unlinkSync(tempFile);
if (result.error) {
return {
success: false,
error: result.error
};
}
return {
success: true,
items: params.summary ? undefined : result.items,
count: result.count
};
}
catch (error) {
console.error('Error querying OmniFocus:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error occurred'
};
}
}
function generateQueryScript(params) {
const { entity, filters = {}, fields, limit, sortBy, sortOrder, includeCompleted = false, summary = false } = params;
// Build the JXA script based on the entity type and filters
return `(() => {
try {
const startTime = new Date();
// Helper function to format dates — injected from src/utils/dateSerialization.ts
// so this and the human-readable formatter cannot disagree (#91).
${JXA_FORMAT_DATE_SOURCE}
// Helper to check date filters
function checkDateFilter(itemDate, daysFromNow) {
if (!itemDate) return false;
const futureDate = new Date();
futureDate.setDate(futureDate.getDate() + daysFromNow);
return itemDate <= futureDate;
}
// Helper to check if date is within last N days (backward-looking)
function checkDateWithinPast(itemDate, daysAgo) {
if (!itemDate) return false;
const pastDate = new Date();
pastDate.setDate(pastDate.getDate() - daysAgo);
pastDate.setHours(0, 0, 0, 0);
return itemDate >= pastDate;
}
// Helper to check exact day match
function checkSameDay(itemDate, daysFromNow) {
if (!itemDate) return false;
const target = new Date();
target.setDate(target.getDate() + daysFromNow);
return itemDate.getFullYear() === target.getFullYear() &&
itemDate.getMonth() === target.getMonth() &&
itemDate.getDate() === target.getDate();
}
function formatReviewInterval(ri) {
if (!ri) return null;
const parts = [];
if (ri.years && ri.years > 0) parts.push(ri.years === 1 ? "1 year" : ri.years + " years");
if (ri.months && ri.months > 0) parts.push(ri.months === 1 ? "1 month" : ri.months + " months");
if (ri.weeks && ri.weeks > 0) parts.push(ri.weeks === 1 ? "1 week" : ri.weeks + " weeks");
if (ri.days && ri.days > 0) parts.push(ri.days === 1 ? "1 day" : ri.days + " days");
return parts.length > 0 ? parts.join(", ") : null;
}
// Status mappings
const taskStatusMap = {
[Task.Status.Available]: "Available",
[Task.Status.Blocked]: "Blocked",
[Task.Status.Completed]: "Completed",
[Task.Status.Dropped]: "Dropped",
[Task.Status.DueSoon]: "DueSoon",
[Task.Status.Next]: "Next",
[Task.Status.Overdue]: "Overdue"
};
const projectStatusMap = {
[Project.Status.Active]: "Active",
[Project.Status.Done]: "Done",
[Project.Status.Dropped]: "Dropped",
[Project.Status.OnHold]: "OnHold"
};
// Helper to collect all descendant folder IDs by walking down from a folder.
// parentFolder is unreliable on flattenedFolders, so we walk children instead.
function collectDescendantFolderIds(folder, idSet) {
idSet.add(folder.id.primaryKey);
var children = folder.folders;
for (var i = 0; i < children.length; i++) {
collectDescendantFolderIds(children[i], idSet);
}
}
// Check if any ancestor folder is dropped.
// OmniJS doesn't expose effectivelyDropped on projects, so we walk up manually.
function isAncestorFolderDropped(project) {
var folder = project.parentFolder;
while (folder) {
if (folder.status === Folder.Status.Dropped) return true;
folder = folder.parentFolder;
}
return false;
}
// Get the appropriate collection based on entity type
let items = [];
const entityType = "${entity}";
if (entityType === "tasks") {
items = flattenedTasks;
} else if (entityType === "projects") {
items = flattenedProjects;
} else if (entityType === "folders") {
items = flattenedFolders;
}
${filters.folderId || filters.folderName ? `
// Pre-compute the set of folder IDs that are the target(s) or their
// descendants. _folderIdSet is referenced by the filter conditions emitted
// from generateFilterConditions(). folderId (exact) takes precedence over
// folderName (case-insensitive partial, may match several folders — #107).
const _folderIdSet = new Set();
${filters.folderId ? `
const _targetFolderId = "${escapeJXA(filters.folderId)}";
for (var _fi = 0; _fi < flattenedFolders.length; _fi++) {
if (flattenedFolders[_fi].id.primaryKey === _targetFolderId) {
collectDescendantFolderIds(flattenedFolders[_fi], _folderIdSet);
break;
}
}` : `
const _targetFolderName = "${escapeJXA(filters.folderName.toLowerCase())}";
for (var _fi = 0; _fi < flattenedFolders.length; _fi++) {
if (flattenedFolders[_fi].name.toLowerCase().includes(_targetFolderName)) {
collectDescendantFolderIds(flattenedFolders[_fi], _folderIdSet);
}
}`}
` : ''}
// Apply filters
let filtered = items.filter(item => {
// Skip completed/dropped unless explicitly requested
if (!${includeCompleted}) {
if (entityType === "tasks") {
if (item.taskStatus === Task.Status.Completed ||
item.taskStatus === Task.Status.Dropped) {
return false;
}
} else if (entityType === "projects") {
if (item.status === Project.Status.Done ||
item.status === Project.Status.Dropped ||
isAncestorFolderDropped(item)) {
return false;
}
}
}
// Apply specific filters
${generateFilterConditions(entity, filters)}
return true;
});
// Apply sorting if specified
${sortBy ? generateSortLogic(sortBy, sortOrder) : ''}
// Apply limit if specified
${limit ? `filtered = filtered.slice(0, ${limit});` : ''}
// If summary mode, just return count
if (${summary}) {
return JSON.stringify({
count: filtered.length,
error: null
});
}
// Transform items to return only requested fields
const results = filtered.map(item => {
${generateFieldMapping(entity, fields)}
});
return JSON.stringify({
items: results,
count: results.length,
error: null
});
} catch (error) {
return JSON.stringify({
error: "Script execution error: " + error.toString(),
items: [],
count: 0
});
}
})();`;
}
function generateFilterConditions(entity, filters) {
const conditions = [];
if (entity === 'tasks') {
if (filters.projectName) {
const safeName = escapeJXA(filters.projectName.toLowerCase());
conditions.push(`
if (item.containingProject) {
const projectName = item.containingProject.name.toLowerCase();
if (!projectName.includes("${safeName}")) return false;
} else if ("${safeName}" !== "inbox") {
return false;
}
`);
}
if (filters.taskName) {
const safeName = escapeJXA(filters.taskName.toLowerCase());
conditions.push(`
const taskName = (item.name || "").toLowerCase();
if (!taskName.includes("${safeName}")) return false;
`);
}
if (filters.projectId) {
const safeId = escapeJXA(filters.projectId);
// Match either the AppleScript-namespace id (project's root task id) or
// the OmniJS Project id — the two namespaces differ for projects.
conditions.push(`
if (!item.containingProject ||
(item.containingProject.task.id.primaryKey !== "${safeId}" &&
item.containingProject.id.primaryKey !== "${safeId}")) {
return false;
}
`);
}
if (filters.folderId || filters.folderName) {
// Both resolve to the pre-computed _folderIdSet; folderName differs only
// in how the set is seeded (name match, possibly several folders — #107).
conditions.push(`
{
let matchesFolder = false;
if (item.containingProject && item.containingProject.parentFolder) {
matchesFolder = _folderIdSet.has(item.containingProject.parentFolder.id.primaryKey);
}
if (!matchesFolder) return false;
}
`);
}
if (filters.tags && filters.tags.length > 0) {
const tagCondition = filters.tags.map((tag) => `item.tags.some(t => t.name === "${escapeJXA(tag)}")`).join(' || ');
conditions.push(`if (!(${tagCondition})) return false;`);
}
if (filters.status && filters.status.length > 0) {
const statusCondition = filters.status.map((status) => `taskStatusMap[item.taskStatus] === "${escapeJXA(status)}"`).join(' || ');
conditions.push(`if (!(${statusCondition})) return false;`);
}
if (filters.flagged !== undefined) {
conditions.push(`if (item.flagged !== ${filters.flagged}) return false;`);
}
if (filters.dueWithin !== undefined) {
conditions.push(`
if (!item.dueDate || !checkDateFilter(item.dueDate, ${filters.dueWithin})) {
return false;
}
`);
}
if (filters.plannedWithin !== undefined) {
conditions.push(`
if (!item.plannedDate || !checkDateFilter(item.plannedDate, ${filters.plannedWithin})) {
return false;
}
`);
}
if (filters.deferredUntil !== undefined) {
conditions.push(`
if (!item.deferDate || !checkDateFilter(item.deferDate, ${filters.deferredUntil})) {
return false;
}
`);
}
if (filters.dueOn !== undefined) {
conditions.push(`if (!checkSameDay(item.dueDate, ${filters.dueOn})) return false;`);
}
if (filters.deferOn !== undefined) {
conditions.push(`if (!checkSameDay(item.deferDate, ${filters.deferOn})) return false;`);
}
if (filters.plannedOn !== undefined) {
conditions.push(`if (!checkSameDay(item.plannedDate, ${filters.plannedOn})) return false;`);
}
if (filters.addedWithin !== undefined) {
conditions.push(`
if (!item.added || !checkDateWithinPast(item.added, ${filters.addedWithin})) {
return false;
}
`);
}
if (filters.addedOn !== undefined) {
conditions.push(`if (!checkSameDay(item.added, ${filters.addedOn})) return false;`);
}
if (filters.isRepeating !== undefined) {
if (filters.isRepeating) {
conditions.push(`if (item.repetitionRule === null) return false;`);
}
else {
conditions.push(`if (item.repetitionRule !== null) return false;`);
}
}
if (filters.completedWithin !== undefined) {
conditions.push(`
if (!item.completionDate || !checkDateWithinPast(item.completionDate, ${filters.completedWithin})) {
return false;
}
`);
}
if (filters.completedOn !== undefined) {
conditions.push(`if (!checkSameDay(item.completionDate, ${filters.completedOn})) return false;`);
}
if (filters.droppedWithin !== undefined) {
conditions.push(`
if (!item.dropDate || !checkDateWithinPast(item.dropDate, ${filters.droppedWithin})) {
return false;
}
`);
}
if (filters.droppedOn !== undefined) {
conditions.push(`if (!checkSameDay(item.dropDate, ${filters.droppedOn})) return false;`);
}
if (filters.hasNote !== undefined) {
conditions.push(`
const hasNote = ${HAS_NOTE_EXPR};
if (hasNote !== ${filters.hasNote}) return false;
`);
}
if (filters.inbox !== undefined) {
if (filters.inbox) {
conditions.push(`if (!item.inInbox) return false;`);
}
else {
conditions.push(`if (item.inInbox) return false;`);
}
}
}
if (entity === 'projects') {
if (filters.projectId) {
const safeId = escapeJXA(filters.projectId);
// Match either the AppleScript-namespace id (project's root task id) or
// the OmniJS Project id — the two namespaces differ for projects.
conditions.push(`
if (item.task.id.primaryKey !== "${safeId}" &&
item.id.primaryKey !== "${safeId}") {
return false;
}
`);
}
if (filters.projectName) {
const safeName = escapeJXA(filters.projectName.toLowerCase());
conditions.push(`
if (!item.name.toLowerCase().includes("${safeName}")) {
return false;
}
`);
}
if (filters.folderId || filters.folderName) {
conditions.push(`
{
let matchesFolder = false;
if (item.parentFolder) {
matchesFolder = _folderIdSet.has(item.parentFolder.id.primaryKey);
}
if (!matchesFolder) return false;
}
`);
}
if (filters.status && filters.status.length > 0) {
const statusCondition = filters.status.map((status) => `projectStatusMap[item.status] === "${escapeJXA(status)}"`).join(' || ');
conditions.push(`if (!(${statusCondition})) return false;`);
}
if (filters.tags && filters.tags.length > 0) {
const tagCondition = filters.tags.map((tag) => `_projectTags.some(t => t.name === "${escapeJXA(tag)}")`).join(' || ');
conditions.push(`
{
const _projectTags = item.tags || [];
if (!(${tagCondition})) return false;
}
`);
}
if (filters.flagged !== undefined) {
conditions.push(`if (item.flagged !== ${filters.flagged}) return false;`);
}
if (filters.dueWithin !== undefined) {
conditions.push(`
if (!item.dueDate || !checkDateFilter(item.dueDate, ${filters.dueWithin})) {
return false;
}
`);
}
if (filters.deferredUntil !== undefined) {
conditions.push(`
if (!item.deferDate || !checkDateFilter(item.deferDate, ${filters.deferredUntil})) {
return false;
}
`);
}
if (filters.dueOn !== undefined) {
conditions.push(`if (!checkSameDay(item.dueDate, ${filters.dueOn})) return false;`);
}
if (filters.deferOn !== undefined) {
conditions.push(`if (!checkSameDay(item.deferDate, ${filters.deferOn})) return false;`);
}
if (filters.hasNote !== undefined) {
conditions.push(`
{
const hasNote = ${HAS_NOTE_EXPR};
if (hasNote !== ${filters.hasNote}) return false;
}
`);
}
if (filters.addedWithin !== undefined) {
conditions.push(`
{
const addedDate = ${PROJECT_ADDED_EXPR};
if (!addedDate || !checkDateWithinPast(addedDate, ${filters.addedWithin})) {
return false;
}
}
`);
}
if (filters.addedOn !== undefined) {
conditions.push(`
{
const addedDate = ${PROJECT_ADDED_EXPR};
if (!checkSameDay(addedDate, ${filters.addedOn})) return false;
}
`);
}
if (filters.completedWithin !== undefined) {
conditions.push(`
if (!item.completionDate || !checkDateWithinPast(item.completionDate, ${filters.completedWithin})) {
return false;
}
`);
}
if (filters.completedOn !== undefined) {
conditions.push(`if (!checkSameDay(item.completionDate, ${filters.completedOn})) return false;`);
}
if (filters.droppedWithin !== undefined) {
conditions.push(`
if (!item.dropDate || !checkDateWithinPast(item.dropDate, ${filters.droppedWithin})) {
return false;
}
`);
}
if (filters.droppedOn !== undefined) {
conditions.push(`if (!checkSameDay(item.dropDate, ${filters.droppedOn})) return false;`);
}
if (filters.reviewDue !== undefined) {
if (filters.reviewDue) {
conditions.push(`
{
const reviewDate = item.nextReviewDate;
if (!reviewDate) return false;
const now = new Date();
now.setHours(23, 59, 59, 999);
if (reviewDate > now) return false;
}
`);
}
else {
conditions.push(`
{
const reviewDate = item.nextReviewDate;
if (reviewDate) {
const now = new Date();
now.setHours(23, 59, 59, 999);
if (reviewDate <= now) return false;
}
}
`);
}
}
}
return conditions.join('\n');
}
function generateSortLogic(sortBy, sortOrder) {
const order = sortOrder === 'desc' ? -1 : 1;
return `
filtered.sort((a, b) => {
let aVal = a.${sortBy};
let bVal = b.${sortBy};
// Handle null/undefined values
if (aVal == null && bVal == null) return 0;
if (aVal == null) return 1;
if (bVal == null) return -1;
// Compare based on type
if (typeof aVal === 'string') {
return aVal.localeCompare(bVal) * ${order};
} else if (aVal instanceof Date) {
return (aVal.getTime() - bVal.getTime()) * ${order};
} else {
return (aVal - bVal) * ${order};
}
});
`;
}
function generateFieldMapping(entity, fields) {
// If no specific fields requested, return common fields based on entity
if (!fields || fields.length === 0) {
if (entity === 'tasks') {
return `
const obj = {
id: item.id.primaryKey,
name: item.name || "",
flagged: item.flagged || false,
taskStatus: taskStatusMap[item.taskStatus] || "Unknown",
dueDate: formatDate(item.dueDate),
deferDate: formatDate(item.deferDate),
plannedDate: formatDate(item.plannedDate),
tagNames: item.tags ? item.tags.map(t => t.name) : [],
projectName: item.containingProject ? item.containingProject.name : (item.inInbox ? "Inbox" : null),
estimatedMinutes: item.estimatedMinutes || null,
note: item.note || "",
isPastOccurrence: ${IS_PAST_OCCURRENCE_TASK_EXPR}
};
return obj;
`;
}
else if (entity === 'projects') {
return `
const taskArray = item.tasks || [];
return {
id: item.task.id.primaryKey,
name: item.name || "",
status: projectStatusMap[item.status] || "Unknown",
folderName: item.parentFolder ? item.parentFolder.name : null,
taskCount: taskArray.length,
tagNames: item.tags ? item.tags.map(t => t.name) : [],
flagged: item.flagged || false,
dueDate: formatDate(item.dueDate),
deferDate: formatDate(item.deferDate),
note: item.note || "",
nextReviewDate: formatDate(item.nextReviewDate),
reviewInterval: formatReviewInterval(item.reviewInterval),
isPastOccurrence: ${IS_PAST_OCCURRENCE_PROJECT_EXPR}
};
`;
}
else if (entity === 'folders') {
return `
const projectArray = item.projects || [];
return {
id: item.id.primaryKey,
name: item.name || "",
projectCount: projectArray.length,
path: ${FOLDER_PATH_EXPR},
parentFolderID: ${FOLDER_PARENT_ID_EXPR}
};
`;
}
}
// Generate mapping for specific fields
const mappings = fields.map(field => {
// Handle special field mappings based on entity type
if (field === 'id') {
// For projects, emit the root task id: it matches the AppleScript project id,
// which edit_item/remove_item need (OmniJS Project ids live in a different
// namespace — issue #77).
return entity === 'projects'
? `id: item.task.id.primaryKey`
: `id: item.id.primaryKey`;
}
else if (field === 'taskStatus') {
return `taskStatus: taskStatusMap[item.taskStatus]`;
}
else if (field === 'status') {
return `status: projectStatusMap[item.status]`;
}
else if (field === 'modificationDate' || field === 'modified') {
return entity === 'projects'
? `modificationDate: formatDate(${PROJECT_MODIFIED_EXPR})`
: `modificationDate: formatDate(item.modified)`;
}
else if (field === 'creationDate' || field === 'added') {
return entity === 'projects'
? `creationDate: formatDate(${PROJECT_ADDED_EXPR})`
: `creationDate: formatDate(item.added)`;
}
else if (field === 'completionDate') {
return `completionDate: item.completionDate ? formatDate(item.completionDate) : null`;
}
else if (field === 'dropDate') {
return `dropDate: item.dropDate ? formatDate(item.dropDate) : null`;
}
else if (field === 'effectiveDropDate') {
return `effectiveDropDate: item.effectiveDropDate ? formatDate(item.effectiveDropDate) : null`;
}
else if (field === 'dueDate') {
return `dueDate: formatDate(item.dueDate)`;
}
else if (field === 'deferDate') {
return `deferDate: formatDate(item.deferDate)`;
}
else if (field === 'plannedDate') {
return `plannedDate: formatDate(item.plannedDate)`;
}
else if (field === 'effectiveDueDate') {
return `effectiveDueDate: formatDate(item.effectiveDueDate)`;
}
else if (field === 'effectiveDeferDate') {
return `effectiveDeferDate: formatDate(item.effectiveDeferDate)`;
}
else if (field === 'effectivePlannedDate') {
return `effectivePlannedDate: formatDate(item.effectivePlannedDate)`;
}
else if (field === 'tagNames') {
return `tagNames: item.tags ? item.tags.map(t => t.name) : []`;
}
else if (field === 'tags') {
return `tags: item.tags ? item.tags.map(t => t.id.primaryKey) : []`;
}
else if (field === 'projectName') {
return `projectName: item.containingProject ? item.containingProject.name : (item.inInbox ? "Inbox" : null)`;
}
else if (field === 'projectId') {
return `projectId: item.containingProject ? item.containingProject.task.id.primaryKey : null`;
}
else if (field === 'parentId') {
return `parentId: item.parent ? item.parent.id.primaryKey : null`;
}
else if (field === 'childIds') {
return `childIds: item.children ? item.children.map(c => c.id.primaryKey) : []`;
}
else if (field === 'hasChildren') {
return `hasChildren: item.children ? item.children.length > 0 : false`;
}
else if (field === 'folderName') {
return `folderName: item.parentFolder ? item.parentFolder.name : null`;
}
else if (field === 'folderID') {
return `folderID: item.parentFolder ? item.parentFolder.id.primaryKey : null`;
}
else if (field === 'taskCount') {
return `taskCount: item.tasks ? item.tasks.length : 0`;
}
else if (field === 'tasks') {
return `tasks: item.tasks ? item.tasks.map(t => t.id.primaryKey) : []`;
}
else if (field === 'projectCount') {
return `projectCount: item.projects ? item.projects.length : 0`;
}
else if (field === 'projects') {
return `projects: item.projects ? item.projects.map(p => p.task.id.primaryKey) : []`;
}
else if (field === 'subfolders') {
return `subfolders: item.folders ? item.folders.map(f => f.id.primaryKey) : []`;
}
else if (field === 'parentFolderID') {
return `parentFolderID: ${FOLDER_PARENT_ID_EXPR}`;
}
else if (field === 'path') {
// `path` is documented as a folder field. Tasks and projects have a
// `parent`/`parentFolder` chain too, but walking it would invent an
// undocumented meaning for them, so they keep returning the bare name —
// which is what the broken `container` expression already gave them.
return entity === 'folders'
? `path: ${FOLDER_PATH_EXPR}`
: `path: item.name`;
}
else if (field === 'isRepeating') {
return `isRepeating: item.repetitionRule !== null`;
}
else if (field === 'repetitionRule') {
return `repetitionRule: ${REPETITION_RULE_EXPR}`;
}
else if (field === 'repetitionMethod') {
return `repetitionMethod: ${REPETITION_METHOD_EXPR}`;
}
else if (field === 'isPastOccurrence') {
return `isPastOccurrence: ${entity === 'projects' ? IS_PAST_OCCURRENCE_PROJECT_EXPR : IS_PAST_OCCURRENCE_TASK_EXPR}`;
}
else if (field === 'sequential') {
// Both Task and Project expose a `sequential` Boolean in OmniJS. Coerce so an
// unexpected null/undefined surfaces as false rather than leaking through.
return `sequential: Boolean(item.sequential)`;
}
else if (field === 'estimatedMinutes') {
return `estimatedMinutes: item.estimatedMinutes || null`;
}
else if (field === 'nextReviewDate') {
return `nextReviewDate: formatDate(item.nextReviewDate)`;
}
else if (field === 'reviewInterval') {
return `reviewInterval: formatReviewInterval(item.reviewInterval)`;
}
else if (field === 'note') {
return `note: item.note || ""`;
}
else {
// Default: try to access the field directly
return `${field}: item.${field} !== undefined ? item.${field} : null`;
}
}).join(',\n ');
return `
return {
${mappings}
};
`;
}
// Exported for testing only - not part of the public API
export const _testExports = {
escapeJXA,
generateFilterConditions,
generateFieldMapping,
generateQueryScript,
};