omnifocus-mcp-enhanced
Version:
🚀 NEW: Native Custom Perspective Access! Enhanced MCP server with OmniFocus custom perspective support, hierarchical task display, AI-optimized tool selection, and comprehensive task management
250 lines (249 loc) • 9.62 kB
JavaScript
import { z } from 'zod';
import { dumpDatabase } from '../dumpDatabase.js';
export const schema = z.object({
hideCompleted: z.boolean().optional().describe("Set to false to show completed and dropped tasks (default: true)"),
hideRecurringDuplicates: z.boolean().optional().describe("Set to true to hide duplicate instances of recurring tasks (default: true)")
});
export async function handler(args, extra) {
try {
// Get raw database
const database = await dumpDatabase();
// Format as compact report
const formattedReport = formatCompactReport(database, {
hideCompleted: args.hideCompleted !== false, // Default to true
hideRecurringDuplicates: args.hideRecurringDuplicates !== false // Default to true
});
return {
content: [{
type: "text",
text: formattedReport
}]
};
}
catch (err) {
return {
content: [{
type: "text",
text: `Error generating report. Please ensure OmniFocus is running and try again.`
}],
isError: true
};
}
}
// Function to format date in compact format (M/D)
function formatCompactDate(isoDate) {
if (!isoDate)
return '';
const date = new Date(isoDate);
return `${date.getMonth() + 1}/${date.getDate()}`;
}
// Function to format the database in the compact report format
function formatCompactReport(database, options) {
const { hideCompleted, hideRecurringDuplicates } = options;
// Get current date for the header
const today = new Date();
const dateStr = today.toISOString().split('T')[0];
let output = `# OMNIFOCUS [${dateStr}]\n\n`;
// Add legend
output += `FORMAT LEGEND:
F: Folder | P: Project | •: Task | 🚩: Flagged
Dates: [M/D] | Duration: (30m) or (2h) | Tags: <tag1,tag2>
Status: #next #avail #block #due #over #compl #drop\n\n`;
// Map of folder IDs to folder objects for quick lookup
const folderMap = new Map();
Object.values(database.folders).forEach((folder) => {
folderMap.set(folder.id, folder);
});
// Get all tag names to compute minimum unique prefixes
const allTagNames = Object.values(database.tags).map((tag) => tag.name);
const tagPrefixMap = computeMinimumUniquePrefixes(allTagNames);
// Function to get folder hierarchy path
function getFolderPath(folderId) {
const path = [];
let currentId = folderId;
while (currentId) {
const folder = folderMap.get(currentId);
if (!folder)
break;
path.unshift(folder.name);
currentId = folder.parentFolderID;
}
return path;
}
// Get root folders (no parent)
const rootFolders = Object.values(database.folders).filter((folder) => !folder.parentFolderID);
// Process folders recursively
function processFolder(folder, level) {
const indent = ' '.repeat(level);
let folderOutput = `${indent}F: ${folder.name}\n`;
// Process subfolders
if (folder.subfolders && folder.subfolders.length > 0) {
for (const subfolderId of folder.subfolders) {
const subfolder = database.folders[subfolderId];
if (subfolder) {
folderOutput += `${processFolder(subfolder, level + 1)}`;
}
}
}
// Process projects in this folder
if (folder.projects && folder.projects.length > 0) {
for (const projectId of folder.projects) {
const project = database.projects[projectId];
if (project) {
folderOutput += processProject(project, level + 1);
}
}
}
return folderOutput;
}
// Process a project
function processProject(project, level) {
const indent = ' '.repeat(level);
// Skip if it's completed or dropped and we're hiding completed items
if (hideCompleted && (project.status === 'Done' || project.status === 'Dropped')) {
return '';
}
// Format project status info
let statusInfo = '';
if (project.status === 'OnHold') {
statusInfo = ' [OnHold]';
}
else if (project.status === 'Dropped') {
statusInfo = ' [Dropped]';
}
// Add due date if present
if (project.dueDate) {
const dueDateStr = formatCompactDate(project.dueDate);
statusInfo += statusInfo ? ` [DUE:${dueDateStr}]` : ` [DUE:${dueDateStr}]`;
}
// Add flag if present
const flaggedSymbol = project.flagged ? ' 🚩' : '';
let projectOutput = `${indent}P: ${project.name}${flaggedSymbol}${statusInfo}\n`;
// Process tasks in this project
const projectTasks = database.tasks.filter((task) => task.projectId === project.id && !task.parentId);
if (projectTasks.length > 0) {
for (const task of projectTasks) {
projectOutput += processTask(task, level + 1);
}
}
return projectOutput;
}
// Process a task
function processTask(task, level) {
const indent = ' '.repeat(level);
// Skip if it's completed or dropped and we're hiding completed items
if (hideCompleted && (task.completed || task.taskStatus === 'Completed' || task.taskStatus === 'Dropped')) {
return '';
}
// Flag symbol
const flagSymbol = task.flagged ? '🚩 ' : '';
// Format dates
let dateInfo = '';
if (task.dueDate) {
const dueDateStr = formatCompactDate(task.dueDate);
dateInfo += ` [DUE:${dueDateStr}]`;
}
if (task.deferDate) {
const deferDateStr = formatCompactDate(task.deferDate);
dateInfo += ` [defer:${deferDateStr}]`;
}
// Format duration
let durationStr = '';
if (task.estimatedMinutes) {
// Convert to hours if >= 60 minutes
if (task.estimatedMinutes >= 60) {
const hours = Math.floor(task.estimatedMinutes / 60);
durationStr = ` (${hours}h)`;
}
else {
durationStr = ` (${task.estimatedMinutes}m)`;
}
}
// Format tags
let tagsStr = '';
if (task.tagNames && task.tagNames.length > 0) {
// Use minimum unique prefixes for tag names
const abbreviatedTags = task.tagNames.map((tag) => {
return tagPrefixMap.get(tag) || tag;
});
tagsStr = ` <${abbreviatedTags.join(',')}>`;
}
// Format status
let statusStr = '';
switch (task.taskStatus) {
case 'Next':
statusStr = ' #next';
break;
case 'Available':
statusStr = ' #avail';
break;
case 'Blocked':
statusStr = ' #block';
break;
case 'DueSoon':
statusStr = ' #due';
break;
case 'Overdue':
statusStr = ' #over';
break;
case 'Completed':
statusStr = ' #compl';
break;
case 'Dropped':
statusStr = ' #drop';
break;
}
let taskOutput = `${indent}• ${flagSymbol}${task.name}${dateInfo}${durationStr}${tagsStr}${statusStr}\n`;
// Process subtasks
if (task.childIds && task.childIds.length > 0) {
const childTasks = database.tasks.filter((t) => task.childIds.includes(t.id));
for (const childTask of childTasks) {
taskOutput += processTask(childTask, level + 1);
}
}
return taskOutput;
}
// Process all root folders
for (const folder of rootFolders) {
output += processFolder(folder, 0);
}
// Process projects not in any folder (if any)
const rootProjects = Object.values(database.projects).filter((project) => !project.folderID);
for (const project of rootProjects) {
output += processProject(project, 0);
}
return output;
}
// Compute minimum unique prefixes for all tags (minimum 3 characters)
function computeMinimumUniquePrefixes(tagNames) {
const prefixMap = new Map();
// For each tag name
for (const tagName of tagNames) {
// Start with minimum length of 3
let prefixLength = 3;
let isUnique = false;
// Keep increasing prefix length until we find a unique prefix
while (!isUnique && prefixLength <= tagName.length) {
const prefix = tagName.substring(0, prefixLength);
// Check if this prefix uniquely identifies the tag
isUnique = tagNames.every(otherTag => {
// If it's the same tag, skip comparison
if (otherTag === tagName)
return true;
// If the other tag starts with the same prefix, it's not unique
return !otherTag.startsWith(prefix);
});
if (isUnique) {
prefixMap.set(tagName, prefix);
}
else {
prefixLength++;
}
}
// If we couldn't find a unique prefix, use the full tag name
if (!isUnique) {
prefixMap.set(tagName, tagName);
}
}
return prefixMap;
}