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.
- package/README.md +33 -1
- package/dist/server.js +13 -1
- package/dist/tools/definitions/addFolder.js +40 -0
- package/dist/tools/definitions/editFolder.js +39 -0
- package/dist/tools/definitions/getFolder.js +53 -0
- package/dist/tools/definitions/listFolders.js +18 -0
- package/dist/tools/definitions/removeFolder.js +56 -0
- package/dist/tools/primitives/addFolder.js +87 -0
- package/dist/tools/primitives/addFolder.test.js +27 -0
- package/dist/tools/primitives/editFolder.js +196 -0
- package/dist/tools/primitives/getFolder.js +27 -0
- package/dist/tools/primitives/listFolders.js +32 -0
- package/dist/tools/primitives/listFolders.test.js +59 -0
- package/dist/tools/primitives/removeFolder.js +117 -0
- package/dist/utils/omnifocusScripts/getFolder.js +107 -0
- package/dist/utils/omnifocusScripts/listFolders.js +62 -0
- package/package.json +1 -1
- package/src/server.ts +43 -1
- package/src/tools/definitions/addFolder.ts +45 -0
- package/src/tools/definitions/editFolder.ts +44 -0
- package/src/tools/definitions/getFolder.ts +59 -0
- package/src/tools/definitions/listFolders.ts +21 -0
- package/src/tools/definitions/removeFolder.ts +60 -0
- package/src/tools/primitives/addFolder.test.ts +35 -0
- package/src/tools/primitives/addFolder.ts +101 -0
- package/src/tools/primitives/editFolder.ts +228 -0
- package/src/tools/primitives/getFolder.ts +60 -0
- package/src/tools/primitives/listFolders.test.ts +82 -0
- package/src/tools/primitives/listFolders.ts +55 -0
- package/src/tools/primitives/removeFolder.ts +142 -0
- package/src/utils/omnifocusScripts/getFolder.js +107 -0
- 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
|
-
|
|
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.
|
|
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 };
|