omnifocus-mcp-enhanced 1.7.0 → 1.8.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 (32) hide show
  1. package/README.md +33 -1
  2. package/dist/server.js +13 -1
  3. package/dist/tools/definitions/addFolder.js +40 -0
  4. package/dist/tools/definitions/editFolder.js +39 -0
  5. package/dist/tools/definitions/getFolder.js +53 -0
  6. package/dist/tools/definitions/listFolders.js +18 -0
  7. package/dist/tools/definitions/removeFolder.js +56 -0
  8. package/dist/tools/primitives/addFolder.js +87 -0
  9. package/dist/tools/primitives/addFolder.test.js +27 -0
  10. package/dist/tools/primitives/editFolder.js +196 -0
  11. package/dist/tools/primitives/getFolder.js +27 -0
  12. package/dist/tools/primitives/listFolders.js +32 -0
  13. package/dist/tools/primitives/listFolders.test.js +59 -0
  14. package/dist/tools/primitives/removeFolder.js +117 -0
  15. package/dist/utils/omnifocusScripts/getFolder.js +107 -0
  16. package/dist/utils/omnifocusScripts/listFolders.js +62 -0
  17. package/package.json +1 -1
  18. package/src/server.ts +43 -1
  19. package/src/tools/definitions/addFolder.ts +45 -0
  20. package/src/tools/definitions/editFolder.ts +44 -0
  21. package/src/tools/definitions/getFolder.ts +59 -0
  22. package/src/tools/definitions/listFolders.ts +21 -0
  23. package/src/tools/definitions/removeFolder.ts +60 -0
  24. package/src/tools/primitives/addFolder.test.ts +35 -0
  25. package/src/tools/primitives/addFolder.ts +101 -0
  26. package/src/tools/primitives/editFolder.ts +228 -0
  27. package/src/tools/primitives/getFolder.ts +60 -0
  28. package/src/tools/primitives/listFolders.test.ts +82 -0
  29. package/src/tools/primitives/listFolders.ts +55 -0
  30. package/src/tools/primitives/removeFolder.ts +142 -0
  31. package/src/utils/omnifocusScripts/getFolder.js +107 -0
  32. package/src/utils/omnifocusScripts/listFolders.js +62 -0
package/README.md CHANGED
@@ -44,6 +44,7 @@ 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.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
48
  - **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
49
  - **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
50
  - **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 +66,7 @@ Want to see where the project is heading next? See the [roadmap](docs/roadmap/20
65
66
  - **🎯 Batch Operations** - Add/remove multiple tasks efficiently
66
67
  - **📊 Smart Querying** - Find tasks by ID, name, or complex criteria
67
68
  - **🔄 Full CRUD Operations** - Create, read, update, delete tasks and projects
69
+ - **📁 Folder Management** - Full CRUD for folders with nested hierarchy, move/rename, and content inspection
68
70
  - **📅 Time Management** - Due, defer, planned dates, estimates, and scheduling
69
71
  - **🏷️ Advanced Tagging** - Tag-based filtering with exact/partial matching
70
72
  - **🚫 Mutually Exclusive Tags** - Automatically respects exclusive tag groups when applying tags
@@ -512,8 +514,15 @@ read_task_attachment {
512
514
  16. **list_custom_perspectives** - 🌟 **NEW**: List all custom perspectives with details
513
515
  17. **get_custom_perspective_tasks** - 🌟 **NEW**: Access custom perspective with hierarchical display
514
516
 
517
+ ### 📁 Folder Management Tools (NEW)
518
+ 18. **add_folder** - 🆕 **NEW**: Create a folder, optionally nested under a parent folder
519
+ 19. **edit_folder** - 🆕 **NEW**: Rename a folder or move it under a different parent (empty string moves to root)
520
+ 20. **remove_folder** - 🆕 **NEW**: Delete a folder (⚠️ also deletes all contained projects and tasks)
521
+ 21. **list_folders** - 🆕 **NEW**: List all folders with IDs, parents, status, and project counts
522
+ 22. **get_folder** - 🆕 **NEW**: Get a single folder with its child projects and subfolders
523
+
515
524
  ### 📊 Analytics & Tracking
516
- 18. **get_today_completed_tasks** - View today's completed tasks
525
+ 23. **get_today_completed_tasks** - View today's completed tasks
517
526
 
518
527
  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
528
 
@@ -623,6 +632,29 @@ get_custom_perspective_tasks {
623
632
  }
624
633
  ```
625
634
 
635
+ ### 📁 Folder Management
636
+
637
+ ```bash
638
+ # List all folders with project counts
639
+ list_folders {"includeDropped": false}
640
+
641
+ # Create a top-level folder
642
+ add_folder {"name": "Work"}
643
+
644
+ # Create a nested folder
645
+ add_folder {"name": "Clients", "parentFolderName": "Work"}
646
+
647
+ # Inspect a folder's projects and subfolders
648
+ get_folder {"name": "Work"}
649
+
650
+ # Rename or move a folder (empty string moves to root)
651
+ edit_folder {"name": "Clients", "newName": "Key Clients"}
652
+ edit_folder {"name": "Key Clients", "newParentFolderName": ""}
653
+
654
+ # Delete a folder (⚠️ also deletes all contained projects and tasks)
655
+ remove_folder {"name": "Old Archive"}
656
+ ```
657
+
626
658
  ## 🔧 Configuration
627
659
 
628
660
  ### Claude Code
package/dist/server.js CHANGED
@@ -25,10 +25,16 @@ 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';
28
34
  // Create an MCP server
29
35
  const server = new McpServer({
30
36
  name: "OmniFocus MCP",
31
- version: "1.6.9"
37
+ version: "1.8.0"
32
38
  });
33
39
  // Register tools
34
40
  server.tool("dump_database", "Gets the current state of your OmniFocus database", dumpDatabaseTool.schema.shape, dumpDatabaseTool.handler);
@@ -54,6 +60,12 @@ server.tool("filter_tasks", "Advanced task filtering with unlimited perspective
54
60
  // Custom perspective tools
55
61
  server.tool("list_custom_perspectives", "List all custom perspectives defined in OmniFocus", listCustomPerspectivesTool.schema.shape, listCustomPerspectivesTool.handler);
56
62
  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);
63
+ // Folder management tools
64
+ 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);
65
+ 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);
66
+ 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);
67
+ server.tool("list_folders", "List all OmniFocus folders with IDs, parent relationships, status, and project counts without loading tasks.", listFoldersTool.schema.shape, listFoldersTool.handler);
68
+ server.tool("get_folder", "Get a single OmniFocus folder by ID or name, including its child projects and subfolders.", getFolderTool.schema.shape, getFolderTool.handler);
57
69
  // Start the MCP server
58
70
  const transport = new StdioServerTransport();
59
71
  // 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,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
+ });
@@ -0,0 +1,196 @@
1
+ import { executeAppleScript } from '../../utils/scriptExecution.js';
2
+ import { buildAppleScriptJsonHelpers } from '../../utils/appleScriptJson.js';
3
+ import { escapeAppleScriptString } from '../../utils/appleScriptString.js';
4
+ /**
5
+ * Validate edit parameters before script generation.
6
+ */
7
+ export function validateEditFolderParams(params) {
8
+ if (!params.id && !params.name) {
9
+ return {
10
+ valid: false,
11
+ error: 'Either id or name must be provided'
12
+ };
13
+ }
14
+ if (params.newName === undefined && params.newParentFolderName === undefined) {
15
+ return {
16
+ valid: false,
17
+ error: 'Nothing to update: provide newName and/or newParentFolderName.'
18
+ };
19
+ }
20
+ return { valid: true };
21
+ }
22
+ /**
23
+ * Generate pure AppleScript for folder editing
24
+ */
25
+ export function generateAppleScript(params) {
26
+ const id = params.id ? escapeAppleScriptString(params.id) : '';
27
+ const name = params.name ? escapeAppleScriptString(params.name) : '';
28
+ const jsonHelpers = buildAppleScriptJsonHelpers();
29
+ if (!id && !name) {
30
+ return `return "{\\\"success\\\":false,\\\"error\\\":\\\"Either id or name must be provided\\\"}"`;
31
+ }
32
+ let script = `
33
+ ${jsonHelpers}
34
+ try
35
+ tell application "OmniFocus"
36
+ tell front document
37
+ -- Find the folder to edit
38
+ set foundFolder to missing value
39
+ `;
40
+ if (id) {
41
+ script += `
42
+ -- Try to find by ID first
43
+ try
44
+ set foundFolder to first flattened folder where id = "${id}"
45
+ end try
46
+ `;
47
+ }
48
+ if (name) {
49
+ script += `
50
+ -- Resolve by name with duplicate protection
51
+ if foundFolder is missing value then
52
+ set nameMatches to (flattened folders where name = "${name}")
53
+ set nameMatchCount to count of nameMatches
54
+
55
+ if nameMatchCount = 1 then
56
+ set foundFolder to item 1 of nameMatches
57
+ else if nameMatchCount > 1 then
58
+ return "{\\\"success\\\":false,\\\"error\\\":\\"" & my jsonEscape("Ambiguous folder name: ${name}. Multiple matches found; please use id.") & "\\\"}"
59
+ end if
60
+ end if
61
+ `;
62
+ }
63
+ script += `
64
+ -- If we found the folder, edit it
65
+ if foundFolder is not missing value then
66
+ set folderName to name of foundFolder
67
+ set folderId to id of foundFolder as string
68
+ set changedProperties to {}
69
+ `;
70
+ // Move to a new parent folder (or root when empty string)
71
+ if (params.newParentFolderName !== undefined) {
72
+ if (params.newParentFolderName === '') {
73
+ script += `
74
+ -- Move folder to the root level
75
+ move foundFolder to end of folders
76
+ set end of changedProperties to "parent (root)"
77
+ `;
78
+ }
79
+ else {
80
+ const destName = escapeAppleScriptString(params.newParentFolderName);
81
+ script += `
82
+ -- Resolve destination parent folder with duplicate protection
83
+ set destMatches to (flattened folders where name = "${destName}")
84
+ set destMatchCount to count of destMatches
85
+
86
+ if destMatchCount = 0 then
87
+ return "{\\\"success\\\":false,\\\"error\\\":\\"" & my jsonEscape("Destination parent folder not found: ${destName}") & "\\\"}"
88
+ else if destMatchCount > 1 then
89
+ return "{\\\"success\\\":false,\\\"error\\\":\\"" & my jsonEscape("Ambiguous destination parent folder name: ${destName}. Multiple matches found; please rename or use a unique folder.") & "\\\"}"
90
+ end if
91
+
92
+ set destFolder to item 1 of destMatches
93
+
94
+ -- Prevent cycles: destination cannot be this folder or any of its descendants
95
+ set cursorFolder to destFolder
96
+ repeat while cursorFolder is not missing value
97
+ if (id of cursorFolder as string) is folderId then
98
+ return "{\\\"success\\\":false,\\\"error\\\":\\\"Invalid move target: cannot move a folder into itself or its descendants.\\\"}"
99
+ end if
100
+
101
+ set nextCursorFolder to missing value
102
+ try
103
+ set cursorContainer to container of cursorFolder
104
+ if class of cursorContainer is folder then
105
+ set nextCursorFolder to cursorContainer
106
+ end if
107
+ end try
108
+ set cursorFolder to nextCursorFolder
109
+ end repeat
110
+
111
+ -- Move folder under destination parent
112
+ move foundFolder to end of folders of destFolder
113
+ set end of changedProperties to "parent"
114
+ `;
115
+ }
116
+ }
117
+ if (params.newName !== undefined) {
118
+ script += `
119
+ -- Update name
120
+ set name of foundFolder to "${escapeAppleScriptString(params.newName)}"
121
+ set end of changedProperties to "name"
122
+ `;
123
+ }
124
+ script += `
125
+ -- Prepare the changed properties as a string
126
+ set changedPropsText to ""
127
+ repeat with i from 1 to count of changedProperties
128
+ set changedPropsText to changedPropsText & item i of changedProperties
129
+ if i < count of changedProperties then
130
+ set changedPropsText to changedPropsText & ", "
131
+ end if
132
+ end repeat
133
+
134
+ -- Re-read the (possibly updated) name and id
135
+ set folderName to name of foundFolder
136
+ set folderId to id of foundFolder as string
137
+
138
+ return "{\\\"success\\\":true,\\\"id\\\":\\"" & my jsonEscape(folderId) & "\\",\\\"name\\\":\\"" & my jsonEscape(folderName) & "\\",\\\"changedProperties\\\":\\"" & my jsonEscape(changedPropsText) & "\\\"}"
139
+ else
140
+ return "{\\\"success\\\":false,\\\"error\\\":\\\"Folder not found\\\"}"
141
+ end if
142
+ end tell
143
+ end tell
144
+ on error errorMessage
145
+ return "{\\\"success\\\":false,\\\"error\\\":\\"" & my jsonEscape(errorMessage) & "\\\"}"
146
+ end try
147
+ `;
148
+ return script;
149
+ }
150
+ /**
151
+ * Edit a folder in OmniFocus
152
+ */
153
+ export async function editFolder(params) {
154
+ try {
155
+ const validation = validateEditFolderParams(params);
156
+ if (!validation.valid) {
157
+ return {
158
+ success: false,
159
+ error: validation.error
160
+ };
161
+ }
162
+ const script = generateAppleScript(params);
163
+ console.error('Executing AppleScript for folder editing...');
164
+ const stdout = await executeAppleScript(script);
165
+ console.error('AppleScript stdout:', stdout);
166
+ try {
167
+ const result = JSON.parse(stdout);
168
+ if (!result.success) {
169
+ return {
170
+ success: false,
171
+ error: result.error
172
+ };
173
+ }
174
+ return {
175
+ success: true,
176
+ id: result.id,
177
+ name: result.name,
178
+ changedProperties: result.changedProperties
179
+ };
180
+ }
181
+ catch (parseError) {
182
+ console.error('Error parsing AppleScript result:', parseError);
183
+ return {
184
+ success: false,
185
+ error: `Failed to parse result: ${stdout}`
186
+ };
187
+ }
188
+ }
189
+ catch (error) {
190
+ console.error('Error in editFolder execution:', error);
191
+ return {
192
+ success: false,
193
+ error: error?.message || 'Unknown error in editFolder'
194
+ };
195
+ }
196
+ }
@@ -0,0 +1,27 @@
1
+ import { executeOmniFocusScript } from '../../utils/scriptExecution.js';
2
+ function parseGetFolderResult(result) {
3
+ const data = typeof result === 'string' ? JSON.parse(result) : result;
4
+ if (!data || typeof data !== 'object') {
5
+ throw new Error('Invalid get_folder response');
6
+ }
7
+ const response = data;
8
+ if (response.success !== true) {
9
+ throw new Error(response.error || 'Unable to get OmniFocus folder');
10
+ }
11
+ if (!response.folder || typeof response.folder !== 'object') {
12
+ throw new Error('Invalid get_folder response: folder is missing');
13
+ }
14
+ return {
15
+ success: true,
16
+ folder: response.folder
17
+ };
18
+ }
19
+ export async function getFolder(params) {
20
+ const result = await executeOmniFocusScript('@getFolder.js', {
21
+ folderId: params.id || null,
22
+ folderName: params.name || null
23
+ });
24
+ const data = parseGetFolderResult(result);
25
+ return data.folder;
26
+ }
27
+ export { parseGetFolderResult };