omnifocus-mcp-enhanced 1.7.0 → 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +68 -1
  2. package/dist/server.js +21 -1
  3. package/dist/tools/definitions/addFolder.js +40 -0
  4. package/dist/tools/definitions/appendToNote.js +41 -0
  5. package/dist/tools/definitions/countTasks.js +41 -0
  6. package/dist/tools/definitions/duplicateTask.js +42 -0
  7. package/dist/tools/definitions/editFolder.js +39 -0
  8. package/dist/tools/definitions/getFolder.js +53 -0
  9. package/dist/tools/definitions/listFolders.js +18 -0
  10. package/dist/tools/definitions/removeFolder.js +56 -0
  11. package/dist/tools/primitives/addFolder.js +87 -0
  12. package/dist/tools/primitives/addFolder.test.js +27 -0
  13. package/dist/tools/primitives/appendToNote.js +122 -0
  14. package/dist/tools/primitives/appendToNote.test.js +45 -0
  15. package/dist/tools/primitives/countTasks.js +34 -0
  16. package/dist/tools/primitives/duplicateTask.js +27 -0
  17. package/dist/tools/primitives/duplicateTask.test.js +85 -0
  18. package/dist/tools/primitives/editFolder.js +196 -0
  19. package/dist/tools/primitives/getFolder.js +27 -0
  20. package/dist/tools/primitives/listFolders.js +32 -0
  21. package/dist/tools/primitives/listFolders.test.js +59 -0
  22. package/dist/tools/primitives/removeFolder.js +117 -0
  23. package/dist/utils/omnifocusScripts/duplicateTask.js +76 -0
  24. package/dist/utils/omnifocusScripts/getFolder.js +107 -0
  25. package/dist/utils/omnifocusScripts/listFolders.js +62 -0
  26. package/package.json +1 -1
  27. package/src/server.ts +69 -1
  28. package/src/tools/definitions/addFolder.ts +45 -0
  29. package/src/tools/definitions/appendToNote.ts +46 -0
  30. package/src/tools/definitions/countTasks.ts +45 -0
  31. package/src/tools/definitions/duplicateTask.ts +47 -0
  32. package/src/tools/definitions/editFolder.ts +44 -0
  33. package/src/tools/definitions/getFolder.ts +59 -0
  34. package/src/tools/definitions/listFolders.ts +21 -0
  35. package/src/tools/definitions/removeFolder.ts +60 -0
  36. package/src/tools/primitives/addFolder.test.ts +35 -0
  37. package/src/tools/primitives/addFolder.ts +101 -0
  38. package/src/tools/primitives/appendToNote.test.ts +54 -0
  39. package/src/tools/primitives/appendToNote.ts +150 -0
  40. package/src/tools/primitives/countTasks.ts +53 -0
  41. package/src/tools/primitives/duplicateTask.test.ts +104 -0
  42. package/src/tools/primitives/duplicateTask.ts +51 -0
  43. package/src/tools/primitives/editFolder.ts +228 -0
  44. package/src/tools/primitives/getFolder.ts +60 -0
  45. package/src/tools/primitives/listFolders.test.ts +82 -0
  46. package/src/tools/primitives/listFolders.ts +55 -0
  47. package/src/tools/primitives/removeFolder.ts +142 -0
  48. package/src/utils/omnifocusScripts/duplicateTask.js +76 -0
  49. package/src/utils/omnifocusScripts/getFolder.js +107 -0
  50. package/src/utils/omnifocusScripts/listFolders.js +62 -0
package/README.md CHANGED
@@ -44,6 +44,8 @@ Want to see where the project is heading next? See the [roadmap](docs/roadmap/20
44
44
 
45
45
  ## 🆕 Latest Release
46
46
 
47
+ - **v1.9.0** - Added 3 productivity tools: `append_to_note` (append text to a task/project note without overwriting), `count_tasks` (fast "how many" aggregate queries with a status breakdown, using the same filters as `filter_tasks`), and `duplicate_task` (clone a task with or without its subtasks, optionally renamed).
48
+ - **v1.8.0** - Added full **Folder Management** support: `add_folder`, `edit_folder`, `remove_folder`, `list_folders`, and `get_folder`. Create nested folder hierarchies, rename/move folders (with cycle protection), and inspect folder contents (child projects + subfolders). Note: removing a folder permanently deletes all projects and tasks it contains.
47
49
  - **v1.7.0** - Added OmniFocus 4.7+ repeat rule support via `set_repetition_rule` (ICS rule strings, schedule type, anchor date, catch-up, end date, repetition count), mutually exclusive tag support via `exclusiveTags` on add/edit tools, and improved planned-date editing tests.
48
50
  - **v1.6.10** - Fixed Inbox task completion via `edit_item`, fixed AppleScript special-character handling for apostrophes/backslashes, fixed JSON result escaping for special characters, and clarified `batch_add_items` / `mcporter` usage with working examples.
49
51
  - **v1.6.9** - Added task attachment support: `get_task_by_id` now lists attachment metadata, `dump_database` exports attachment/link metadata, and new `read_task_attachment` returns image attachments as MCP image content when possible.
@@ -65,6 +67,7 @@ Want to see where the project is heading next? See the [roadmap](docs/roadmap/20
65
67
  - **🎯 Batch Operations** - Add/remove multiple tasks efficiently
66
68
  - **📊 Smart Querying** - Find tasks by ID, name, or complex criteria
67
69
  - **🔄 Full CRUD Operations** - Create, read, update, delete tasks and projects
70
+ - **📁 Folder Management** - Full CRUD for folders with nested hierarchy, move/rename, and content inspection
68
71
  - **📅 Time Management** - Due, defer, planned dates, estimates, and scheduling
69
72
  - **🏷️ Advanced Tagging** - Tag-based filtering with exact/partial matching
70
73
  - **🚫 Mutually Exclusive Tags** - Automatically respects exclusive tag groups when applying tags
@@ -512,8 +515,20 @@ read_task_attachment {
512
515
  16. **list_custom_perspectives** - 🌟 **NEW**: List all custom perspectives with details
513
516
  17. **get_custom_perspective_tasks** - 🌟 **NEW**: Access custom perspective with hierarchical display
514
517
 
518
+ ### 📁 Folder Management Tools (NEW)
519
+ 18. **add_folder** - 🆕 **NEW**: Create a folder, optionally nested under a parent folder
520
+ 19. **edit_folder** - 🆕 **NEW**: Rename a folder or move it under a different parent (empty string moves to root)
521
+ 20. **remove_folder** - 🆕 **NEW**: Delete a folder (⚠️ also deletes all contained projects and tasks)
522
+ 21. **list_folders** - 🆕 **NEW**: List all folders with IDs, parents, status, and project counts
523
+ 22. **get_folder** - 🆕 **NEW**: Get a single folder with its child projects and subfolders
524
+
525
+ ### ⚡ Productivity Tools (NEW)
526
+ 23. **append_to_note** - 🆕 **NEW**: Append text to a task/project note without overwriting
527
+ 24. **count_tasks** - 🆕 **NEW**: Fast "how many" queries with a status breakdown (same filters as filter_tasks)
528
+ 25. **duplicate_task** - 🆕 **NEW**: Clone a task with/without subtasks, optionally renamed
529
+
515
530
  ### 📊 Analytics & Tracking
516
- 18. **get_today_completed_tasks** - View today's completed tasks
531
+ 26. **get_today_completed_tasks** - View today's completed tasks
517
532
 
518
533
  Batch move feature roadmap (future): [docs/roadmap/2026-02-25-batch-move-tasks-plan.md](docs/roadmap/2026-02-25-batch-move-tasks-plan.md)
519
534
 
@@ -623,6 +638,58 @@ get_custom_perspective_tasks {
623
638
  }
624
639
  ```
625
640
 
641
+ ### 📁 Folder Management
642
+
643
+ ```bash
644
+ # List all folders with project counts
645
+ list_folders {"includeDropped": false}
646
+
647
+ # Create a top-level folder
648
+ add_folder {"name": "Work"}
649
+
650
+ # Create a nested folder
651
+ add_folder {"name": "Clients", "parentFolderName": "Work"}
652
+
653
+ # Inspect a folder's projects and subfolders
654
+ get_folder {"name": "Work"}
655
+
656
+ # Rename or move a folder (empty string moves to root)
657
+ edit_folder {"name": "Clients", "newName": "Key Clients"}
658
+ edit_folder {"name": "Key Clients", "newParentFolderName": ""}
659
+
660
+ # Delete a folder (⚠️ also deletes all contained projects and tasks)
661
+ remove_folder {"name": "Old Archive"}
662
+ ```
663
+
664
+ ### ⚡ Productivity Tools
665
+
666
+ ```bash
667
+ # Append a progress note without overwriting the existing note
668
+ append_to_note {
669
+ "itemType": "task",
670
+ "name": "Write report",
671
+ "text": "Drafted section 1 today"
672
+ }
673
+
674
+ # Fast count: how many flagged tasks are still actionable?
675
+ count_tasks {
676
+ "flagged": true,
677
+ "taskStatus": ["Available", "Next", "DueSoon", "Overdue"]
678
+ }
679
+
680
+ # How many tasks remain in a project (by status breakdown)
681
+ count_tasks {"projectFilter": "Website Redesign"}
682
+
683
+ # Duplicate a task template with its subtasks
684
+ duplicate_task {
685
+ "name": "Weekly Review Checklist",
686
+ "newName": "Weekly Review - 2026-03-02"
687
+ }
688
+
689
+ # Duplicate without subtasks
690
+ duplicate_task {"taskId": "abc123", "includeSubtasks": false}
691
+ ```
692
+
626
693
  ## 🔧 Configuration
627
694
 
628
695
  ### Claude Code
package/dist/server.js CHANGED
@@ -25,10 +25,20 @@ import * as filterTasksTool from './tools/definitions/filterTasks.js';
25
25
  // Import custom perspective tools
26
26
  import * as listCustomPerspectivesTool from './tools/definitions/listCustomPerspectives.js';
27
27
  import * as getCustomPerspectiveTasksTool from './tools/definitions/getCustomPerspectiveTasks.js';
28
+ // Import folder management tools
29
+ import * as addFolderTool from './tools/definitions/addFolder.js';
30
+ import * as editFolderTool from './tools/definitions/editFolder.js';
31
+ import * as removeFolderTool from './tools/definitions/removeFolder.js';
32
+ import * as listFoldersTool from './tools/definitions/listFolders.js';
33
+ import * as getFolderTool from './tools/definitions/getFolder.js';
34
+ // Import productivity tools
35
+ import * as appendToNoteTool from './tools/definitions/appendToNote.js';
36
+ import * as countTasksTool from './tools/definitions/countTasks.js';
37
+ import * as duplicateTaskTool from './tools/definitions/duplicateTask.js';
28
38
  // Create an MCP server
29
39
  const server = new McpServer({
30
40
  name: "OmniFocus MCP",
31
- version: "1.6.9"
41
+ version: "1.9.0"
32
42
  });
33
43
  // Register tools
34
44
  server.tool("dump_database", "Gets the current state of your OmniFocus database", dumpDatabaseTool.schema.shape, dumpDatabaseTool.handler);
@@ -54,6 +64,16 @@ server.tool("filter_tasks", "Advanced task filtering with unlimited perspective
54
64
  // Custom perspective tools
55
65
  server.tool("list_custom_perspectives", "List all custom perspectives defined in OmniFocus", listCustomPerspectivesTool.schema.shape, listCustomPerspectivesTool.handler);
56
66
  server.tool("get_custom_perspective_tasks", "Get tasks from a specific OmniFocus custom perspective by name. Use this when user refers to perspective names like '今日工作安排', '今日复盘', '本周项目' etc. - these are custom views created in OmniFocus, NOT tags. Supports hierarchical tree display of task relationships.", getCustomPerspectiveTasksTool.schema.shape, getCustomPerspectiveTasksTool.handler);
67
+ // Folder management tools
68
+ server.tool("add_folder", "Create a new folder in OmniFocus, optionally nested under a parent folder. Folders organize projects into a hierarchy.", addFolderTool.schema.shape, addFolderTool.handler);
69
+ server.tool("edit_folder", "Rename a folder or move it under a different parent folder (use an empty string for newParentFolderName to move it to the root level).", editFolderTool.schema.shape, editFolderTool.handler);
70
+ server.tool("remove_folder", "Remove a folder from OmniFocus. WARNING: this also permanently deletes all projects and tasks contained in the folder.", removeFolderTool.schema.shape, removeFolderTool.handler);
71
+ server.tool("list_folders", "List all OmniFocus folders with IDs, parent relationships, status, and project counts without loading tasks.", listFoldersTool.schema.shape, listFoldersTool.handler);
72
+ server.tool("get_folder", "Get a single OmniFocus folder by ID or name, including its child projects and subfolders.", getFolderTool.schema.shape, getFolderTool.handler);
73
+ // Productivity tools
74
+ server.tool("append_to_note", "Append text to a task or project note without overwriting the existing note. Useful for logging progress or adding context.", appendToNoteTool.schema.shape, appendToNoteTool.handler);
75
+ server.tool("count_tasks", "Count tasks matching filters without returning the full list. Fast 'how many' queries that return a total plus a breakdown by status. Uses the same filters as filter_tasks.", countTasksTool.schema.shape, countTasksTool.handler);
76
+ server.tool("duplicate_task", "Duplicate an existing task, optionally with its subtasks, and optionally with a new name. Useful for template-based workflows.", duplicateTaskTool.schema.shape, duplicateTaskTool.handler);
57
77
  // Start the MCP server
58
78
  const transport = new StdioServerTransport();
59
79
  // Use await with server.connect to ensure proper connection
@@ -0,0 +1,40 @@
1
+ import { z } from 'zod';
2
+ import { addFolder } from '../primitives/addFolder.js';
3
+ export const schema = z.object({
4
+ name: z.string().describe('The name of the folder'),
5
+ parentFolderName: z.string().optional().describe('The name of the parent folder to nest this folder under (created at the root level if not specified)')
6
+ });
7
+ export async function handler(args, _extra) {
8
+ try {
9
+ const result = await addFolder(args);
10
+ if (result.success) {
11
+ const locationText = args.parentFolderName
12
+ ? `inside folder "${args.parentFolderName}"`
13
+ : 'at the root level';
14
+ return {
15
+ content: [{
16
+ type: 'text',
17
+ text: `✅ Folder "${args.name}" created successfully ${locationText}.\n\nid: ${result.folderId}`
18
+ }]
19
+ };
20
+ }
21
+ return {
22
+ content: [{
23
+ type: 'text',
24
+ text: `Failed to create folder: ${result.error}`
25
+ }],
26
+ isError: true
27
+ };
28
+ }
29
+ catch (err) {
30
+ const error = err;
31
+ console.error(`Tool execution error: ${error.message}`);
32
+ return {
33
+ content: [{
34
+ type: 'text',
35
+ text: `Error creating folder: ${error.message}`
36
+ }],
37
+ isError: true
38
+ };
39
+ }
40
+ }
@@ -0,0 +1,41 @@
1
+ import { z } from 'zod';
2
+ import { appendToNote } from '../primitives/appendToNote.js';
3
+ export const schema = z.object({
4
+ id: z.string().optional().describe('The ID of the task or project'),
5
+ name: z.string().optional().describe('The name of the task or project (as fallback if ID not provided)'),
6
+ itemType: z.enum(['task', 'project']).describe("Type of item whose note to append to ('task' or 'project')"),
7
+ text: z.string().describe('The text to append to the existing note'),
8
+ separator: z.string().optional().describe('Separator inserted between the existing note and the new text (default: a newline). Pass an empty string to append with no separator.')
9
+ });
10
+ export async function handler(args, _extra) {
11
+ try {
12
+ if (!args.id && !args.name) {
13
+ return {
14
+ content: [{ type: 'text', text: 'Either id or name must be provided to append to a note.' }],
15
+ isError: true
16
+ };
17
+ }
18
+ const result = await appendToNote(args);
19
+ if (result.success) {
20
+ const itemTypeLabel = args.itemType === 'task' ? 'Task' : 'Project';
21
+ return {
22
+ content: [{
23
+ type: 'text',
24
+ text: `✅ Appended text to ${itemTypeLabel} "${result.name}" note.\n\nid: ${result.id}`
25
+ }]
26
+ };
27
+ }
28
+ return {
29
+ content: [{ type: 'text', text: `Failed to append to note: ${result.error}` }],
30
+ isError: true
31
+ };
32
+ }
33
+ catch (err) {
34
+ const error = err;
35
+ console.error(`Tool execution error: ${error.message}`);
36
+ return {
37
+ content: [{ type: 'text', text: `Error appending to note: ${error.message}` }],
38
+ isError: true
39
+ };
40
+ }
41
+ }
@@ -0,0 +1,41 @@
1
+ import { z } from 'zod';
2
+ import { countTasks } from '../primitives/countTasks.js';
3
+ export const schema = z.object({
4
+ taskStatus: z.array(z.string()).optional().describe('Filter by task status: Available, Next, Blocked, DueSoon, Overdue, Completed, Dropped'),
5
+ perspective: z.enum(['inbox', 'flagged', 'all']).optional().describe("Scope: 'inbox', 'flagged', or 'all' (default: all)"),
6
+ projectFilter: z.string().optional().describe('Only count tasks in projects whose name contains this text'),
7
+ tagFilter: z.union([z.string(), z.array(z.string())]).optional().describe('Only count tasks with these tags'),
8
+ exactTagMatch: z.boolean().optional().describe('Require exact tag name match (default: false)'),
9
+ flagged: z.boolean().optional().describe('Only count flagged (true) or unflagged (false) tasks'),
10
+ searchText: z.string().optional().describe('Only count tasks whose name or note contains this text'),
11
+ dueToday: z.boolean().optional().describe('Only count tasks due today'),
12
+ dueThisWeek: z.boolean().optional().describe('Only count tasks due this week'),
13
+ overdue: z.boolean().optional().describe('Only count overdue tasks'),
14
+ completedToday: z.boolean().optional().describe('Only count tasks completed today'),
15
+ completedThisWeek: z.boolean().optional().describe('Only count tasks completed this week'),
16
+ plannedToday: z.boolean().optional().describe('Only count tasks planned for today'),
17
+ plannedThisWeek: z.boolean().optional().describe('Only count tasks planned for this week')
18
+ });
19
+ export async function handler(args, _extra) {
20
+ try {
21
+ const result = await countTasks(args);
22
+ const statusEntries = Object.entries(result.byStatus).sort((a, b) => b[1] - a[1]);
23
+ const breakdown = statusEntries.length > 0
24
+ ? statusEntries.map(([status, count]) => `- ${status}: ${count}`).join('\n')
25
+ : '- (no matching tasks)';
26
+ return {
27
+ content: [{
28
+ type: 'text',
29
+ text: `# Task Count\n\n**Total: ${result.total}**\n\nBy status:\n${breakdown}`
30
+ }]
31
+ };
32
+ }
33
+ catch (err) {
34
+ const error = err;
35
+ console.error(`Tool execution error: ${error.message}`);
36
+ return {
37
+ content: [{ type: 'text', text: `Error counting tasks: ${error.message}` }],
38
+ isError: true
39
+ };
40
+ }
41
+ }
@@ -0,0 +1,42 @@
1
+ import { z } from 'zod';
2
+ import { duplicateTask } from '../primitives/duplicateTask.js';
3
+ export const schema = z.object({
4
+ taskId: z.string().optional().describe('The ID of the task to duplicate'),
5
+ taskName: z.string().optional().describe('The name of the task to duplicate (as fallback if ID not provided)'),
6
+ newName: z.string().optional().describe('Optional new name for the duplicated task (keeps the original name if omitted)'),
7
+ includeSubtasks: z.boolean().optional().describe('Whether to include the task\'s subtasks in the copy (default: true)')
8
+ });
9
+ export async function handler(args, _extra) {
10
+ try {
11
+ if (!args.taskId && !args.taskName) {
12
+ return {
13
+ content: [{ type: 'text', text: 'Either taskId or taskName must be provided to duplicate a task.' }],
14
+ isError: true
15
+ };
16
+ }
17
+ const result = await duplicateTask(args);
18
+ if (result.success) {
19
+ const subtaskText = (result.childrenCount && result.childrenCount > 0)
20
+ ? ` with ${result.childrenCount} subtask(s)`
21
+ : '';
22
+ return {
23
+ content: [{
24
+ type: 'text',
25
+ text: `✅ Duplicated task as "${result.name}"${subtaskText}.\n\nid: ${result.newTaskId}`
26
+ }]
27
+ };
28
+ }
29
+ return {
30
+ content: [{ type: 'text', text: `Failed to duplicate task: ${result.error}` }],
31
+ isError: true
32
+ };
33
+ }
34
+ catch (err) {
35
+ const error = err;
36
+ console.error(`Tool execution error: ${error.message}`);
37
+ return {
38
+ content: [{ type: 'text', text: `Error duplicating task: ${error.message}` }],
39
+ isError: true
40
+ };
41
+ }
42
+ }
@@ -0,0 +1,39 @@
1
+ import { z } from 'zod';
2
+ import { editFolder } from '../primitives/editFolder.js';
3
+ export const schema = z.object({
4
+ id: z.string().optional().describe('The ID of the folder to edit'),
5
+ name: z.string().optional().describe('The name of the folder to edit (as fallback if ID not provided)'),
6
+ newName: z.string().optional().describe('New name for the folder'),
7
+ newParentFolderName: z.string().optional().describe('Move the folder under this parent folder. Use an empty string "" to move it to the root level.')
8
+ });
9
+ export async function handler(args, _extra) {
10
+ try {
11
+ if (!args.id && !args.name) {
12
+ return {
13
+ content: [{ type: 'text', text: 'Either id or name must be provided to edit a folder.' }],
14
+ isError: true
15
+ };
16
+ }
17
+ const result = await editFolder(args);
18
+ if (result.success) {
19
+ return {
20
+ content: [{
21
+ type: 'text',
22
+ text: `✅ Folder "${result.name}" updated successfully.\nChanged: ${result.changedProperties || 'nothing'}\n\nid: ${result.id}`
23
+ }]
24
+ };
25
+ }
26
+ return {
27
+ content: [{ type: 'text', text: `Failed to edit folder: ${result.error}` }],
28
+ isError: true
29
+ };
30
+ }
31
+ catch (err) {
32
+ const error = err;
33
+ console.error(`Tool execution error: ${error.message}`);
34
+ return {
35
+ content: [{ type: 'text', text: `Error editing folder: ${error.message}` }],
36
+ isError: true
37
+ };
38
+ }
39
+ }
@@ -0,0 +1,53 @@
1
+ import { z } from 'zod';
2
+ import { getFolder } from '../primitives/getFolder.js';
3
+ export const schema = z.object({
4
+ id: z.string().optional().describe('The ID of the folder to get'),
5
+ name: z.string().optional().describe('The name of the folder to get (as fallback if ID not provided)')
6
+ });
7
+ export async function handler(args, _extra) {
8
+ try {
9
+ if (!args.id && !args.name) {
10
+ return {
11
+ content: [{ type: 'text', text: 'Either id or name must be provided to get a folder.' }],
12
+ isError: true
13
+ };
14
+ }
15
+ const folder = await getFolder({ id: args.id, name: args.name });
16
+ const lines = [];
17
+ lines.push(`# Folder: ${folder.name}`);
18
+ lines.push('');
19
+ lines.push(`- id: ${folder.id}`);
20
+ lines.push(`- status: ${folder.status}`);
21
+ if (folder.parentFolderID) {
22
+ lines.push(`- parent folder id: ${folder.parentFolderID}`);
23
+ }
24
+ lines.push('');
25
+ lines.push(`## Subfolders (${folder.subfolders.length})`);
26
+ if (folder.subfolders.length === 0) {
27
+ lines.push('None');
28
+ }
29
+ else {
30
+ for (const sub of folder.subfolders) {
31
+ lines.push(`- ${sub.name} [${sub.status}] (id:${sub.id})`);
32
+ }
33
+ }
34
+ lines.push('');
35
+ lines.push(`## Projects (${folder.projects.length})`);
36
+ if (folder.projects.length === 0) {
37
+ lines.push('None');
38
+ }
39
+ else {
40
+ for (const project of folder.projects) {
41
+ lines.push(`- ${project.name} [${project.status}] (id:${project.id}, remaining:${project.remainingTaskCount})`);
42
+ }
43
+ }
44
+ return { content: [{ type: 'text', text: lines.join('\n') }] };
45
+ }
46
+ catch (error) {
47
+ const message = error instanceof Error ? error.message : 'Unknown error occurred';
48
+ return {
49
+ content: [{ type: 'text', text: `Error getting folder: ${message}` }],
50
+ isError: true
51
+ };
52
+ }
53
+ }
@@ -0,0 +1,18 @@
1
+ import { z } from 'zod';
2
+ import { listFolders } from '../primitives/listFolders.js';
3
+ export const schema = z.object({
4
+ includeDropped: z.boolean().optional().describe('Include dropped folders (default: true)')
5
+ });
6
+ export async function handler(args, _extra) {
7
+ try {
8
+ const result = await listFolders(args.includeDropped !== false);
9
+ return { content: [{ type: 'text', text: result }] };
10
+ }
11
+ catch (error) {
12
+ const message = error instanceof Error ? error.message : 'Unknown error occurred';
13
+ return {
14
+ content: [{ type: 'text', text: `Error listing folders: ${message}` }],
15
+ isError: true
16
+ };
17
+ }
18
+ }
@@ -0,0 +1,56 @@
1
+ import { z } from 'zod';
2
+ import { removeFolder } from '../primitives/removeFolder.js';
3
+ export const schema = z.object({
4
+ id: z.string().optional().describe('The ID of the folder to remove'),
5
+ name: z.string().optional().describe('The name of the folder to remove (as fallback if ID not provided)')
6
+ });
7
+ export async function handler(args, _extra) {
8
+ try {
9
+ if (!args.id && !args.name) {
10
+ return {
11
+ content: [{ type: 'text', text: 'Either id or name must be provided to remove a folder.' }],
12
+ isError: true
13
+ };
14
+ }
15
+ const result = await removeFolder(args);
16
+ if (result.success) {
17
+ const projectCount = result.deletedProjectCount ?? 0;
18
+ const taskCount = result.deletedTaskCount ?? 0;
19
+ const cascadeWarning = (projectCount > 0 || taskCount > 0)
20
+ ? `\n⚠️ This also permanently deleted ${projectCount} contained project(s) and ${taskCount} task(s).`
21
+ : '';
22
+ return {
23
+ content: [{
24
+ type: 'text',
25
+ text: `✅ Folder "${result.name}" removed successfully.${cascadeWarning}`
26
+ }]
27
+ };
28
+ }
29
+ let errorMsg = 'Failed to remove folder';
30
+ if (result.error) {
31
+ if (result.error.includes('Folder not found')) {
32
+ errorMsg = 'Folder not found';
33
+ if (args.id)
34
+ errorMsg += ` with ID "${args.id}"`;
35
+ if (args.name)
36
+ errorMsg += `${args.id ? ' or' : ' with'} name "${args.name}"`;
37
+ errorMsg += '.';
38
+ }
39
+ else {
40
+ errorMsg += `: ${result.error}`;
41
+ }
42
+ }
43
+ return {
44
+ content: [{ type: 'text', text: errorMsg }],
45
+ isError: true
46
+ };
47
+ }
48
+ catch (err) {
49
+ const error = err;
50
+ console.error(`Tool execution error: ${error.message}`);
51
+ return {
52
+ content: [{ type: 'text', text: `Error removing folder: ${error.message}` }],
53
+ isError: true
54
+ };
55
+ }
56
+ }
@@ -0,0 +1,87 @@
1
+ import { executeAppleScript } from '../../utils/scriptExecution.js';
2
+ import { buildAppleScriptJsonHelpers } from '../../utils/appleScriptJson.js';
3
+ import { escapeAppleScriptString } from '../../utils/appleScriptString.js';
4
+ /**
5
+ * Generate pure AppleScript for folder creation
6
+ */
7
+ export function generateAppleScript(params) {
8
+ const name = escapeAppleScriptString(params.name);
9
+ const parentFolderName = params.parentFolderName ? escapeAppleScriptString(params.parentFolderName) : '';
10
+ const jsonHelpers = buildAppleScriptJsonHelpers();
11
+ const script = `
12
+ ${jsonHelpers}
13
+ try
14
+ tell application "OmniFocus"
15
+ tell front document
16
+ -- Determine the container (root or parent folder)
17
+ if "${parentFolderName}" is "" then
18
+ -- Create folder at the root level
19
+ set newFolder to make new folder with properties {name:"${name}"}
20
+ else
21
+ -- Resolve parent folder with duplicate protection
22
+ set parentMatches to (flattened folders where name = "${parentFolderName}")
23
+ set parentMatchCount to count of parentMatches
24
+
25
+ if parentMatchCount = 0 then
26
+ return "{\\\"success\\\":false,\\\"error\\\":\\"" & my jsonEscape("Parent folder not found: ${parentFolderName}") & "\\\"}"
27
+ else if parentMatchCount > 1 then
28
+ return "{\\\"success\\\":false,\\\"error\\\":\\"" & my jsonEscape("Ambiguous parent folder name: ${parentFolderName}. Multiple matches found; please rename or use a unique folder.") & "\\\"}"
29
+ end if
30
+
31
+ set parentFolder to item 1 of parentMatches
32
+ set newFolder to make new folder with properties {name:"${name}"} at end of folders of parentFolder
33
+ end if
34
+
35
+ -- Get the folder ID and name
36
+ set folderId to id of newFolder as string
37
+ set folderNameValue to name of newFolder
38
+
39
+ -- Return success with folder ID
40
+ return "{\\\"success\\\":true,\\\"folderId\\\":\\"" & my jsonEscape(folderId) & "\\",\\\"name\\\":\\"" & my jsonEscape(folderNameValue) & "\\\"}"
41
+ end tell
42
+ end tell
43
+ on error errorMessage
44
+ return "{\\\"success\\\":false,\\\"error\\\":\\"" & my jsonEscape(errorMessage) & "\\\"}"
45
+ end try
46
+ `;
47
+ return script;
48
+ }
49
+ /**
50
+ * Add a folder to OmniFocus
51
+ */
52
+ export async function addFolder(params) {
53
+ try {
54
+ const script = generateAppleScript(params);
55
+ console.error('Executing AppleScript for folder creation...');
56
+ const stdout = await executeAppleScript(script);
57
+ console.error('AppleScript stdout:', stdout);
58
+ try {
59
+ const result = JSON.parse(stdout);
60
+ if (!result.success) {
61
+ return {
62
+ success: false,
63
+ error: result.error
64
+ };
65
+ }
66
+ return {
67
+ success: true,
68
+ folderId: result.folderId,
69
+ name: result.name
70
+ };
71
+ }
72
+ catch (parseError) {
73
+ console.error('Error parsing AppleScript result:', parseError);
74
+ return {
75
+ success: false,
76
+ error: `Failed to parse result: ${stdout}`
77
+ };
78
+ }
79
+ }
80
+ catch (error) {
81
+ console.error('Error in addFolder:', error);
82
+ return {
83
+ success: false,
84
+ error: error?.message || 'Unknown error in addFolder'
85
+ };
86
+ }
87
+ }
@@ -0,0 +1,27 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+ import { generateAppleScript } from './addFolder.js';
4
+ test('addFolder creates folder at root level when no parent specified', () => {
5
+ const script = generateAppleScript({ name: 'Work' });
6
+ // Root branch is selected at AppleScript runtime via the empty parent name check.
7
+ assert.match(script, /if "" is "" then/);
8
+ assert.match(script, /make new folder with properties \{name:"Work"\}/);
9
+ assert.match(script, /my jsonEscape\(folderId\)/);
10
+ });
11
+ test('addFolder nests under a parent folder with duplicate protection', () => {
12
+ const script = generateAppleScript({ name: 'Sub', parentFolderName: 'Parent' });
13
+ assert.match(script, /flattened folders where name = "Parent"/);
14
+ assert.match(script, /Ambiguous parent folder name: Parent/);
15
+ assert.match(script, /make new folder with properties \{name:"Sub"\} at end of folders of parentFolder/);
16
+ });
17
+ test('addFolder keeps apostrophes and doubles backslashes in the name', () => {
18
+ const script = generateAppleScript({ name: "Client's \\ Files" });
19
+ assert.match(script, /name:"Client's \\\\ Files"/);
20
+ assert.doesNotMatch(script, /\\'/);
21
+ });
22
+ test('addFolder escapes JSON response values through AppleScript helper', () => {
23
+ const script = generateAppleScript({ name: 'Work' });
24
+ assert.match(script, /on jsonEscape\(inputText\)/);
25
+ assert.match(script, /my jsonEscape\(folderId\)/);
26
+ assert.match(script, /my jsonEscape\(folderNameValue\)/);
27
+ });