omnifocus_mcp 1.0.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.
- checksums.yaml +7 -0
- data/AGENTS.md +15 -0
- data/CHANGELOG.md +7 -0
- data/CODE_OF_CONDUCT.md +16 -0
- data/LICENSE.txt +21 -0
- data/README.md +147 -0
- data/Rakefile +12 -0
- data/bin/omnifocus-mcp +7 -0
- data/lib/omnifocus_mcp/config.rb +18 -0
- data/lib/omnifocus_mcp/infrastructure/.keep +1 -0
- data/lib/omnifocus_mcp/infrastructure/apple_script.rb +263 -0
- data/lib/omnifocus_mcp/infrastructure/apple_script_date_builder.rb +65 -0
- data/lib/omnifocus_mcp/infrastructure/js_embed.rb +39 -0
- data/lib/omnifocus_mcp/infrastructure/script_runner.rb +254 -0
- data/lib/omnifocus_mcp/infrastructure.rb +6 -0
- data/lib/omnifocus_mcp/json_rpc_compat.rb +75 -0
- data/lib/omnifocus_mcp/logger.rb +34 -0
- data/lib/omnifocus_mcp/mcp.rb +74 -0
- data/lib/omnifocus_mcp/parsers/.keep +1 -0
- data/lib/omnifocus_mcp/parsers/apple_script_envelope.rb +44 -0
- data/lib/omnifocus_mcp/parsers.rb +6 -0
- data/lib/omnifocus_mcp/resources/base.rb +87 -0
- data/lib/omnifocus_mcp/resources/flagged_resource.rb +31 -0
- data/lib/omnifocus_mcp/resources/inbox_resource.rb +31 -0
- data/lib/omnifocus_mcp/resources/perspective_resource.rb +28 -0
- data/lib/omnifocus_mcp/resources/project_resource.rb +37 -0
- data/lib/omnifocus_mcp/resources/stats_resource.rb +22 -0
- data/lib/omnifocus_mcp/resources/today_resource.rb +37 -0
- data/lib/omnifocus_mcp/result.rb +108 -0
- data/lib/omnifocus_mcp/tools/batch_report.rb +9 -0
- data/lib/omnifocus_mcp/tools/database_stats.rb +184 -0
- data/lib/omnifocus_mcp/tools/definitions/add_omnifocus_task_tool.rb +61 -0
- data/lib/omnifocus_mcp/tools/definitions/add_project_tool.rb +54 -0
- data/lib/omnifocus_mcp/tools/definitions/batch_add_items_tool.rb +105 -0
- data/lib/omnifocus_mcp/tools/definitions/batch_remove_items_tool.rb +68 -0
- data/lib/omnifocus_mcp/tools/definitions/date_formatter.rb +45 -0
- data/lib/omnifocus_mcp/tools/definitions/edit_item_tool.rb +87 -0
- data/lib/omnifocus_mcp/tools/definitions/get_perspective_view_tool.rb +57 -0
- data/lib/omnifocus_mcp/tools/definitions/key_normalizer.rb +30 -0
- data/lib/omnifocus_mcp/tools/definitions/list_perspectives_tool.rb +47 -0
- data/lib/omnifocus_mcp/tools/definitions/list_tags_tool.rb +42 -0
- data/lib/omnifocus_mcp/tools/definitions/mcp_envelope.rb +31 -0
- data/lib/omnifocus_mcp/tools/definitions/operation_factory.rb +33 -0
- data/lib/omnifocus_mcp/tools/definitions/query_omnifocus_tool.rb +187 -0
- data/lib/omnifocus_mcp/tools/definitions/remove_item_tool.rb +55 -0
- data/lib/omnifocus_mcp/tools/generators/.keep +1 -0
- data/lib/omnifocus_mcp/tools/generators/add_omnifocus_task.rb +348 -0
- data/lib/omnifocus_mcp/tools/generators/add_project.rb +141 -0
- data/lib/omnifocus_mcp/tools/generators/database_stats.rb +16 -0
- data/lib/omnifocus_mcp/tools/generators/edit_item.rb +455 -0
- data/lib/omnifocus_mcp/tools/generators/list_perspectives.rb +13 -0
- data/lib/omnifocus_mcp/tools/generators/list_tags.rb +13 -0
- data/lib/omnifocus_mcp/tools/generators/perspective_view.rb +17 -0
- data/lib/omnifocus_mcp/tools/generators/query_omnifocus.rb +571 -0
- data/lib/omnifocus_mcp/tools/generators/query_omnifocus_debug.rb +169 -0
- data/lib/omnifocus_mcp/tools/generators/remove_item.rb +61 -0
- data/lib/omnifocus_mcp/tools/generators.rb +8 -0
- data/lib/omnifocus_mcp/tools/messages/add_omnifocus_task.rb +53 -0
- data/lib/omnifocus_mcp/tools/messages/add_project.rb +28 -0
- data/lib/omnifocus_mcp/tools/messages/batch_remove_items.rb +13 -0
- data/lib/omnifocus_mcp/tools/messages/edit_item.rb +39 -0
- data/lib/omnifocus_mcp/tools/messages/list_tools.rb +15 -0
- data/lib/omnifocus_mcp/tools/messages/remove_item.rb +42 -0
- data/lib/omnifocus_mcp/tools/messages.rb +8 -0
- data/lib/omnifocus_mcp/tools/operations/add_omnifocus_task.rb +74 -0
- data/lib/omnifocus_mcp/tools/operations/add_project.rb +75 -0
- data/lib/omnifocus_mcp/tools/operations/batch_add_items/batch_item.rb +38 -0
- data/lib/omnifocus_mcp/tools/operations/batch_add_items/bulk_executor.rb +94 -0
- data/lib/omnifocus_mcp/tools/operations/batch_add_items/cycle_detector.rb +74 -0
- data/lib/omnifocus_mcp/tools/operations/batch_add_items/param_builder.rb +47 -0
- data/lib/omnifocus_mcp/tools/operations/batch_add_items/planner.rb +111 -0
- data/lib/omnifocus_mcp/tools/operations/batch_add_items.rb +149 -0
- data/lib/omnifocus_mcp/tools/operations/batch_remove_items.rb +49 -0
- data/lib/omnifocus_mcp/tools/operations/database_stats.rb +52 -0
- data/lib/omnifocus_mcp/tools/operations/edit_item.rb +79 -0
- data/lib/omnifocus_mcp/tools/operations/get_perspective_view.rb +112 -0
- data/lib/omnifocus_mcp/tools/operations/list_perspectives.rb +85 -0
- data/lib/omnifocus_mcp/tools/operations/list_tags.rb +80 -0
- data/lib/omnifocus_mcp/tools/operations/query_omnifocus.rb +74 -0
- data/lib/omnifocus_mcp/tools/operations/query_omnifocus_debug.rb +63 -0
- data/lib/omnifocus_mcp/tools/operations/remove_item.rb +75 -0
- data/lib/omnifocus_mcp/tools/operations.rb +8 -0
- data/lib/omnifocus_mcp/tools/params/mcp_boundary.rb +41 -0
- data/lib/omnifocus_mcp/tools/params.rb +106 -0
- data/lib/omnifocus_mcp/tools/presenters/batch_report.rb +55 -0
- data/lib/omnifocus_mcp/tools/presenters/list_perspectives.rb +33 -0
- data/lib/omnifocus_mcp/tools/presenters/list_tags.rb +49 -0
- data/lib/omnifocus_mcp/tools/presenters/perspective_view.rb +81 -0
- data/lib/omnifocus_mcp/tools/presenters/query_reply.rb +52 -0
- data/lib/omnifocus_mcp/tools/presenters/query_results.rb +183 -0
- data/lib/omnifocus_mcp/tools/presenters.rb +8 -0
- data/lib/omnifocus_mcp/tools/query_omnifocus_formatter.rb +9 -0
- data/lib/omnifocus_mcp/tools/query_statuses.rb +22 -0
- data/lib/omnifocus_mcp/utils/apple_script.rb +9 -0
- data/lib/omnifocus_mcp/utils/apple_script_envelope.rb +9 -0
- data/lib/omnifocus_mcp/utils/apple_script_helpers.rb +9 -0
- data/lib/omnifocus_mcp/utils/blank.rb +26 -0
- data/lib/omnifocus_mcp/utils/date_filter.rb +76 -0
- data/lib/omnifocus_mcp/utils/date_formatting.rb +9 -0
- data/lib/omnifocus_mcp/utils/iso_date.rb +27 -0
- data/lib/omnifocus_mcp/utils/omnifocus_scripts/getPerspectiveView.js +472 -0
- data/lib/omnifocus_mcp/utils/omnifocus_scripts/listPerspectives.js +59 -0
- data/lib/omnifocus_mcp/utils/omnifocus_scripts/listTags.js +58 -0
- data/lib/omnifocus_mcp/utils/omnifocus_scripts/omnifocusDump.js +223 -0
- data/lib/omnifocus_mcp/utils/script_execution.rb +9 -0
- data/lib/omnifocus_mcp/version.rb +5 -0
- data/lib/omnifocus_mcp.rb +102 -0
- metadata +166 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 4eb86aa8048f5b98edf61273ab7936b859663199e2ad6c73bb11ec55605550d8
|
|
4
|
+
data.tar.gz: 7dc7b4f11e986aa96d8b9d746d75a16e7222f13627577ca3e3c420ee4df21bb1
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d7886bb913d144e269a8d9605a7e09ac18a9cab49742a62b9bd4f8b835fd034e6f4984ed33dbc38df2709f6ea748b0902b69b6e823da5b7cd58548c645e195ef
|
|
7
|
+
data.tar.gz: 033366a6ead47fccfb29bdb0e0df36a1817a124ecea767977bd7c397d26252884fc4e57a3f3e4d6e8b41dc397ad9e6bd2265bf532f3f55ee127d796a300ab00c
|
data/AGENTS.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
## Testing (RSpec)
|
|
2
|
+
|
|
3
|
+
We use RSpec with:
|
|
4
|
+
|
|
5
|
+
- **`context`** — group examples by scenario or setup.
|
|
6
|
+
- **`let`** — define lazy, memoized values used by examples.
|
|
7
|
+
- **`subject`** — name the object under test when it improves clarity.
|
|
8
|
+
- Declare `subject` above any other `let` declarations.
|
|
9
|
+
- Declare `subject` above any other `before` declarations.
|
|
10
|
+
|
|
11
|
+
We aim for **one `expect` per example** when that keeps failures easy to
|
|
12
|
+
interpret; use multiple expectations in the same example when splitting would be
|
|
13
|
+
artificial or would hide a single behavioural assertion.
|
|
14
|
+
|
|
15
|
+
After Ruby changes, run **`bundle exec rspec`**. Add specs for new methods and behaviour.
|
data/CHANGELOG.md
ADDED
data/CODE_OF_CONDUCT.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Code of Conduct
|
|
2
|
+
|
|
3
|
+
"omnifocus_mcp" follows
|
|
4
|
+
[The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in
|
|
5
|
+
all "collaborative space", which is defined as community communications channels
|
|
6
|
+
(such as mailing lists, submitted patches, commit comments, etc.):
|
|
7
|
+
|
|
8
|
+
* Participants will be tolerant of opposing views.
|
|
9
|
+
* Participants must ensure that their language and actions are free of personal
|
|
10
|
+
attacks and disparaging personal remarks.
|
|
11
|
+
* When interpreting the words and actions of others, participants should always
|
|
12
|
+
assume good intentions.
|
|
13
|
+
* Behaviour which can be reasonably considered harassment will not be tolerated.
|
|
14
|
+
|
|
15
|
+
If you have any concerns about behaviour within this project, please contact us
|
|
16
|
+
at ["hmaddocks@me.com"](mailto:"hmaddocks@me.com").
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Henry Maddocks
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# omnifocus-mcp (Ruby)
|
|
2
|
+
|
|
3
|
+
`omnifocus-mcp` is a Ruby MCP server that lets LLM clients (Claude Desktop, MCP
|
|
4
|
+
Inspector, etc.) work with OmniFocus on macOS over stdio. It exposes tools and
|
|
5
|
+
resources for creating, editing, removing, querying, and reporting on OmniFocus
|
|
6
|
+
tasks, projects, perspectives, and tags.
|
|
7
|
+
|
|
8
|
+
This tool was heavily inspired by [OmniFocus MCP Server](https://github.com/themotionmachine/OmniFocus-MCP).
|
|
9
|
+
|
|
10
|
+
## Features
|
|
11
|
+
|
|
12
|
+
- Create, edit, remove, and query OmniFocus tasks.
|
|
13
|
+
- Add and manage projects.
|
|
14
|
+
- List tags and perspectives.
|
|
15
|
+
- Read common OmniFocus views such as inbox, today, flagged, stats, projects,
|
|
16
|
+
and perspectives.
|
|
17
|
+
|
|
18
|
+
## Tools and Resources
|
|
19
|
+
|
|
20
|
+
### Tools
|
|
21
|
+
|
|
22
|
+
- `add_omnifocus_task` - Adds a new task to OmniFocus, with optional notes,
|
|
23
|
+
dates, tags, project placement, and parent task placement.
|
|
24
|
+
- `add_project` - Adds a new project to OmniFocus, with optional notes, dates,
|
|
25
|
+
tags, folder placement, and sequential task ordering.
|
|
26
|
+
- `remove_item` - Removes a task or project by ID, or by name when an ID is not
|
|
27
|
+
available.
|
|
28
|
+
- `edit_item` - Updates a task or project, including names, notes, dates, flags,
|
|
29
|
+
estimates, statuses, tags, and location.
|
|
30
|
+
- `batch_add_items` - Adds multiple tasks or projects in one operation,
|
|
31
|
+
including support for task hierarchy within the batch.
|
|
32
|
+
- `batch_remove_items` - Removes multiple tasks or projects in one operation.
|
|
33
|
+
- `query_omnifocus` - Queries tasks, projects, or folders with filters for
|
|
34
|
+
project, tag, status, dates, flags, notes, and more.
|
|
35
|
+
- `list_perspectives` - Lists built-in and custom OmniFocus perspectives.
|
|
36
|
+
- `get_perspective_view` - Returns the tasks and projects visible in a named
|
|
37
|
+
OmniFocus perspective.
|
|
38
|
+
- `list_tags` - Lists OmniFocus tags and their hierarchy, with optional inactive
|
|
39
|
+
tags.
|
|
40
|
+
|
|
41
|
+
### Resources
|
|
42
|
+
|
|
43
|
+
- `omnifocus://inbox` - Returns current OmniFocus inbox tasks.
|
|
44
|
+
- `omnifocus://today` - Returns today's agenda, including tasks due today,
|
|
45
|
+
planned for today, and overdue tasks.
|
|
46
|
+
- `omnifocus://flagged` - Returns flagged OmniFocus tasks.
|
|
47
|
+
- `omnifocus://stats` - Returns a quick overview of OmniFocus database statistics.
|
|
48
|
+
- `omnifocus://project/{name}` - Returns tasks in the named OmniFocus project.
|
|
49
|
+
- `omnifocus://perspective/{name}` - Returns items visible in the named
|
|
50
|
+
OmniFocus perspective.
|
|
51
|
+
|
|
52
|
+
## Requirements
|
|
53
|
+
|
|
54
|
+
- Ruby 3.4 or later
|
|
55
|
+
- macOS with OmniFocus 4 installed
|
|
56
|
+
|
|
57
|
+
## Install
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
gem install omnifocus-mcp
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Run
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
omnifocus-mcp
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The server speaks MCP over stdio. Test it with the MCP inspector:
|
|
70
|
+
|
|
71
|
+
```sh
|
|
72
|
+
npx @modelcontextprotocol/inspector omnifocus-mcp
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Configure an MCP Client
|
|
76
|
+
|
|
77
|
+
After installing the executable, add this server to any MCP client that supports
|
|
78
|
+
stdio servers:
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
{
|
|
82
|
+
"mcpServers": {
|
|
83
|
+
"omnifocus-mcp": {
|
|
84
|
+
"command": "omnifocus-mcp",
|
|
85
|
+
"args": []
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Client Instructions
|
|
92
|
+
|
|
93
|
+
This server uses [fast-mcp](https://github.com/yjacquin/fast-mcp) 1.6, which
|
|
94
|
+
does not currently expose MCP server instructions during client initialization.
|
|
95
|
+
To give an MCP client better guidance, copy the instructions below into a skill,
|
|
96
|
+
rule, your project's `AGENTS.md`, or another client-specific instruction file.
|
|
97
|
+
|
|
98
|
+
```text
|
|
99
|
+
OmniFocus MCP server for macOS task management.
|
|
100
|
+
|
|
101
|
+
TOOL GUIDANCE:
|
|
102
|
+
- Use query_omnifocus for targeted lookups; the "fields" parameter requests only
|
|
103
|
+
needed fields
|
|
104
|
+
- Use "summary: true" for quick counts without full data
|
|
105
|
+
- For batch operations, prefer batch_add_items/batch_remove_items over repeated
|
|
106
|
+
single calls
|
|
107
|
+
|
|
108
|
+
RESOURCES:
|
|
109
|
+
- omnifocus://inbox - current inbox items
|
|
110
|
+
- omnifocus://today - today's agenda (due, planned, overdue)
|
|
111
|
+
- omnifocus://flagged - all flagged items
|
|
112
|
+
- omnifocus://stats - quick database statistics
|
|
113
|
+
- omnifocus://project/{name} - tasks in a specific project
|
|
114
|
+
- omnifocus://perspective/{name} - items in a named perspective
|
|
115
|
+
|
|
116
|
+
QUERY FILTER TIPS:
|
|
117
|
+
- Tags filter is case-sensitive and exact match
|
|
118
|
+
- projectName filter is case-insensitive partial match
|
|
119
|
+
- Status values for tasks: Next, Available, Blocked, DueSoon, Overdue
|
|
120
|
+
- Status values for projects: Active, OnHold, Done, Dropped
|
|
121
|
+
- Combine filters with AND logic; within arrays, OR logic applies
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Tests
|
|
125
|
+
|
|
126
|
+
```sh
|
|
127
|
+
bundle exec rspec
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Integration specs that hit the real OmniFocus app are tagged
|
|
131
|
+
`:requires_omnifocus` and skipped by default. Run them with:
|
|
132
|
+
|
|
133
|
+
```sh
|
|
134
|
+
INTEGRATION=1 bundle exec rspec
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
OmniFocus must be running and macOS must allow the terminal app to send Apple
|
|
138
|
+
Events to OmniFocus. If macOS reports
|
|
139
|
+
`Not authorised to send Apple events to OmniFocus (-1743)`, grant the terminal
|
|
140
|
+
permission in System Settings > Privacy & Security > Automation.
|
|
141
|
+
|
|
142
|
+
Integration tests create items prefixed `TEST:` and clean them up at teardown.
|
|
143
|
+
If a run is killed mid-flight, you can sweep leftover items with:
|
|
144
|
+
|
|
145
|
+
```sh
|
|
146
|
+
bundle exec ruby spec/integration/cleanup.rb
|
|
147
|
+
```
|
data/Rakefile
ADDED
data/bin/omnifocus-mcp
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmnifocusMcp
|
|
4
|
+
# Runtime configuration read from the environment.
|
|
5
|
+
module Config
|
|
6
|
+
DEFAULT_SCRIPT_TIMEOUT_SEC = 180
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
# Seconds to wait for an `osascript` invocation before terminating it.
|
|
10
|
+
# Set to 0 to disable (wait indefinitely). Default: 180.
|
|
11
|
+
def script_timeout_sec
|
|
12
|
+
raw = ENV.fetch("OMNIFOCUS_MCP_SCRIPT_TIMEOUT_SEC", DEFAULT_SCRIPT_TIMEOUT_SEC.to_s)
|
|
13
|
+
sec = Float(raw, exception: false)
|
|
14
|
+
sec&.positive? ? sec : nil
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmnifocusMcp
|
|
4
|
+
module Infrastructure
|
|
5
|
+
# Composable AppleScript fragments for write-side primitives.
|
|
6
|
+
# rubocop:disable Metrics/ModuleLength
|
|
7
|
+
module AppleScript
|
|
8
|
+
ITEM_TYPES = %w[task project].freeze
|
|
9
|
+
LOOKUP_KINDS = %i[folder project].freeze
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
# Prefix every non-empty line of `text` with `prefix`. Lines that are
|
|
13
|
+
# blank or only-whitespace are passed through unchanged so the result
|
|
14
|
+
# diffs cleanly.
|
|
15
|
+
def indent(text:, prefix:)
|
|
16
|
+
text.each_line
|
|
17
|
+
.map { |line| line.strip.empty? ? line : "#{prefix}#{line}" }
|
|
18
|
+
.join
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Escape `"` and `\` so `value` is safe inside an AppleScript
|
|
22
|
+
# double-quoted string. CR/LF collapse to a single space.
|
|
23
|
+
def escape(value)
|
|
24
|
+
value.to_s
|
|
25
|
+
.gsub(/["\\]/) { |m| "\\#{m}" }
|
|
26
|
+
.gsub(/[\r\n]/, " ")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Wrap a script `body` in the standard OmniFocus front document
|
|
30
|
+
# envelope used by write-side primitives.
|
|
31
|
+
def tell_document(body)
|
|
32
|
+
<<~APPLESCRIPT
|
|
33
|
+
try
|
|
34
|
+
tell application "OmniFocus"
|
|
35
|
+
tell front document
|
|
36
|
+
#{indent(text: body.chomp, prefix: " ")}
|
|
37
|
+
end tell
|
|
38
|
+
end tell
|
|
39
|
+
on error errorMessage
|
|
40
|
+
return "{\\"success\\":false,\\"error\\":\\"" & errorMessage & "\\"}"
|
|
41
|
+
end try
|
|
42
|
+
APPLESCRIPT
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Find a task or project, setting `var` to the located object or
|
|
46
|
+
# `missing value` when not found.
|
|
47
|
+
def find_item(var:, item_type:, id:, name:)
|
|
48
|
+
unless ITEM_TYPES.include?(item_type)
|
|
49
|
+
raise ArgumentError, "item_type must be one of #{ITEM_TYPES.inspect}, got #{item_type.inspect}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
collection = "flattened #{item_type}"
|
|
53
|
+
sections = [
|
|
54
|
+
["set #{var} to missing value"],
|
|
55
|
+
id_lookup_lines(var: var, collection: collection, item_type: item_type, id: id),
|
|
56
|
+
name_lookup_lines(var: var, collection: collection, item_type: item_type, name: name, fallback: !id.empty?)
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
"#{sections.flatten.join("\n")}\n"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Add an existing tag to `item_var`, creating it if it does not exist.
|
|
63
|
+
def tag_assignment(item_var:, tag_name:)
|
|
64
|
+
<<~APPLESCRIPT.chomp
|
|
65
|
+
try
|
|
66
|
+
set theTag to first flattened tag where name = "#{tag_name}"
|
|
67
|
+
add theTag to tags of #{item_var}
|
|
68
|
+
on error
|
|
69
|
+
-- Tag might not exist, try to create it
|
|
70
|
+
try
|
|
71
|
+
set theTag to make new tag with properties {name:"#{tag_name}"}
|
|
72
|
+
add theTag to tags of #{item_var}
|
|
73
|
+
on error
|
|
74
|
+
-- Could not create or add tag
|
|
75
|
+
end try
|
|
76
|
+
end try
|
|
77
|
+
APPLESCRIPT
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Generate AppleScript that resolves a folder by path or simple name.
|
|
81
|
+
def generate_folder_lookup_script(raw_folder_path:, var_name:, error_return_json:)
|
|
82
|
+
generate_lookup_script(
|
|
83
|
+
kind: :folder,
|
|
84
|
+
raw_path: raw_folder_path,
|
|
85
|
+
var_name: var_name,
|
|
86
|
+
error_return_json: error_return_json
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Generate AppleScript that resolves a project by path or simple name.
|
|
91
|
+
def generate_project_lookup_script(raw_project_path:, var_name:, error_return_json:)
|
|
92
|
+
generate_lookup_script(
|
|
93
|
+
kind: :project,
|
|
94
|
+
raw_path: raw_project_path,
|
|
95
|
+
var_name: var_name,
|
|
96
|
+
error_return_json: error_return_json
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Unified entry point for folder/project lookup scripts.
|
|
101
|
+
def generate_lookup_script(kind:, raw_path:, var_name:, error_return_json:)
|
|
102
|
+
unless LOOKUP_KINDS.include?(kind)
|
|
103
|
+
raise ArgumentError, "kind must be one of #{LOOKUP_KINDS.inspect}, got #{kind.inspect}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
components = raw_path.split("/")
|
|
107
|
+
.reject(&:empty?)
|
|
108
|
+
return "set #{var_name} to missing value" if components.empty?
|
|
109
|
+
|
|
110
|
+
escaped_components = components.map { |c| escape(c) }
|
|
111
|
+
builder = components.length == 1 ? :simple : :nested
|
|
112
|
+
send(:"#{builder}_#{kind}_lookup", var_name:, escaped_components:, error_return_json:)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
private
|
|
116
|
+
|
|
117
|
+
def id_lookup_lines(var:, collection:, item_type:, id:)
|
|
118
|
+
return [] if id.empty?
|
|
119
|
+
|
|
120
|
+
[
|
|
121
|
+
"",
|
|
122
|
+
"-- Find #{item_type} by ID",
|
|
123
|
+
"try",
|
|
124
|
+
%( set #{var} to first #{collection} whose id is "#{id}"),
|
|
125
|
+
"end try"
|
|
126
|
+
]
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def name_lookup_lines(var:, collection:, item_type:, name:, fallback:)
|
|
130
|
+
return [] if name.empty?
|
|
131
|
+
|
|
132
|
+
if fallback
|
|
133
|
+
[
|
|
134
|
+
"",
|
|
135
|
+
"-- Fall back to name search if id missed",
|
|
136
|
+
"if #{var} is missing value then",
|
|
137
|
+
" try",
|
|
138
|
+
%( set #{var} to first #{collection} whose name is "#{name}"),
|
|
139
|
+
" end try",
|
|
140
|
+
"end if"
|
|
141
|
+
]
|
|
142
|
+
else
|
|
143
|
+
[
|
|
144
|
+
"",
|
|
145
|
+
"-- Find #{item_type} by name",
|
|
146
|
+
"try",
|
|
147
|
+
%( set #{var} to first #{collection} whose name is "#{name}"),
|
|
148
|
+
"end try"
|
|
149
|
+
]
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def applescript_string_list(escaped_strings)
|
|
154
|
+
escaped_strings.map { |s| %("#{s}") }.join(", ")
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def simple_folder_lookup(var_name:, escaped_components:, error_return_json:)
|
|
158
|
+
name = escaped_components.first
|
|
159
|
+
<<~APPLESCRIPT.chomp
|
|
160
|
+
set #{var_name} to missing value
|
|
161
|
+
try
|
|
162
|
+
set #{var_name} to first flattened folder where name = "#{name}"
|
|
163
|
+
end try
|
|
164
|
+
if #{var_name} is missing value then
|
|
165
|
+
return "#{error_return_json}"
|
|
166
|
+
end if
|
|
167
|
+
APPLESCRIPT
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# rubocop:disable Metrics/MethodLength
|
|
171
|
+
def nested_folder_lookup(var_name:, escaped_components:, error_return_json:)
|
|
172
|
+
leaf_name = escaped_components.last
|
|
173
|
+
list_items = applescript_string_list(escaped_components)
|
|
174
|
+
|
|
175
|
+
<<~APPLESCRIPT.chomp
|
|
176
|
+
set #{var_name} to missing value
|
|
177
|
+
set pathComponents to {#{list_items}}
|
|
178
|
+
repeat with aFolder in (flattened folders)
|
|
179
|
+
if name of aFolder = "#{leaf_name}" then
|
|
180
|
+
-- Verify ancestor chain matches path
|
|
181
|
+
set ancestorOk to true
|
|
182
|
+
set currentItem to aFolder
|
|
183
|
+
repeat with i from ((count of pathComponents) - 1) to 1 by -1
|
|
184
|
+
try
|
|
185
|
+
set currentItem to container of currentItem
|
|
186
|
+
if class of currentItem is not folder or name of currentItem is not equal to (item i of pathComponents) then
|
|
187
|
+
set ancestorOk to false
|
|
188
|
+
exit repeat
|
|
189
|
+
end if
|
|
190
|
+
on error
|
|
191
|
+
set ancestorOk to false
|
|
192
|
+
exit repeat
|
|
193
|
+
end try
|
|
194
|
+
end repeat
|
|
195
|
+
if ancestorOk then
|
|
196
|
+
set #{var_name} to aFolder
|
|
197
|
+
exit repeat
|
|
198
|
+
end if
|
|
199
|
+
end if
|
|
200
|
+
end repeat
|
|
201
|
+
if #{var_name} is missing value then
|
|
202
|
+
return "#{error_return_json}"
|
|
203
|
+
end if
|
|
204
|
+
APPLESCRIPT
|
|
205
|
+
end
|
|
206
|
+
# rubocop:enable Metrics/MethodLength
|
|
207
|
+
|
|
208
|
+
def simple_project_lookup(var_name:, escaped_components:, error_return_json:)
|
|
209
|
+
name = escaped_components.first
|
|
210
|
+
<<~APPLESCRIPT.chomp
|
|
211
|
+
set #{var_name} to missing value
|
|
212
|
+
try
|
|
213
|
+
set #{var_name} to first flattened project whose name is "#{name}"
|
|
214
|
+
end try
|
|
215
|
+
if #{var_name} is missing value then
|
|
216
|
+
return "#{error_return_json}"
|
|
217
|
+
end if
|
|
218
|
+
APPLESCRIPT
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# rubocop:disable Metrics/MethodLength
|
|
222
|
+
def nested_project_lookup(var_name:, escaped_components:, error_return_json:)
|
|
223
|
+
project_name = escaped_components.last
|
|
224
|
+
folder_components = escaped_components[0...-1]
|
|
225
|
+
folder_items = applescript_string_list(folder_components)
|
|
226
|
+
|
|
227
|
+
<<~APPLESCRIPT.chomp
|
|
228
|
+
set #{var_name} to missing value
|
|
229
|
+
set folderPath to {#{folder_items}}
|
|
230
|
+
repeat with aProject in (flattened projects)
|
|
231
|
+
if (name of aProject as string) = "#{project_name}" then
|
|
232
|
+
-- Verify folder ancestry matches path
|
|
233
|
+
set ancestorOk to true
|
|
234
|
+
set currentItem to container of aProject
|
|
235
|
+
repeat with i from (count of folderPath) to 1 by -1
|
|
236
|
+
try
|
|
237
|
+
if class of currentItem is not folder or name of currentItem is not equal to (item i of folderPath) then
|
|
238
|
+
set ancestorOk to false
|
|
239
|
+
exit repeat
|
|
240
|
+
end if
|
|
241
|
+
set currentItem to container of currentItem
|
|
242
|
+
on error
|
|
243
|
+
set ancestorOk to false
|
|
244
|
+
exit repeat
|
|
245
|
+
end try
|
|
246
|
+
end repeat
|
|
247
|
+
if ancestorOk then
|
|
248
|
+
set #{var_name} to aProject
|
|
249
|
+
exit repeat
|
|
250
|
+
end if
|
|
251
|
+
end if
|
|
252
|
+
end repeat
|
|
253
|
+
if #{var_name} is missing value then
|
|
254
|
+
return "#{error_return_json}"
|
|
255
|
+
end if
|
|
256
|
+
APPLESCRIPT
|
|
257
|
+
end
|
|
258
|
+
# rubocop:enable Metrics/MethodLength
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
# rubocop:enable Metrics/ModuleLength
|
|
262
|
+
end
|
|
263
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "time"
|
|
5
|
+
|
|
6
|
+
module OmnifocusMcp
|
|
7
|
+
module Infrastructure
|
|
8
|
+
# Builds AppleScript date fragments outside tell blocks so date variables can
|
|
9
|
+
# be safely referenced inside OmniFocus tell blocks.
|
|
10
|
+
class AppleScriptDateBuilder
|
|
11
|
+
ISO_DATE_ONLY_RE = /\A\d{4}-\d{2}-\d{2}\z/
|
|
12
|
+
|
|
13
|
+
DateAssignmentParts = Data.define(:pre_script, :assignment_script)
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
# Generate AppleScript to construct a date variable outside `tell` blocks.
|
|
17
|
+
def create_date_outside_tell_block(iso_date_string, var_name)
|
|
18
|
+
emit_date_assignment(parse_iso(iso_date_string), var_name)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Return the scripts needed to assign or clear a date property.
|
|
22
|
+
def generate_date_assignment(object_name, property_name, iso_date_string)
|
|
23
|
+
return nil if iso_date_string.nil?
|
|
24
|
+
|
|
25
|
+
if iso_date_string == ""
|
|
26
|
+
return DateAssignmentParts.new(
|
|
27
|
+
pre_script: "",
|
|
28
|
+
assignment_script: "set #{property_name} of #{object_name} to missing value"
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
var_name = "dateVar#{SecureRandom.hex(5)}"
|
|
33
|
+
|
|
34
|
+
DateAssignmentParts.new(
|
|
35
|
+
pre_script: create_date_outside_tell_block(iso_date_string, var_name),
|
|
36
|
+
assignment_script: "set #{property_name} of #{object_name} to #{var_name}"
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def parse_iso(iso_date_string)
|
|
43
|
+
# Date-only strings are normalized to local midnight, avoiding timezone
|
|
44
|
+
# shifts that can happen when JS interprets YYYY-MM-DD as UTC.
|
|
45
|
+
normalized = ISO_DATE_ONLY_RE.match?(iso_date_string) ? "#{iso_date_string}T00:00:00" : iso_date_string
|
|
46
|
+
Time.parse(normalized)
|
|
47
|
+
rescue ArgumentError, TypeError
|
|
48
|
+
raise ArgumentError, "Invalid date string: #{iso_date_string}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def emit_date_assignment(time, var_name)
|
|
52
|
+
<<~APPLESCRIPT.chomp
|
|
53
|
+
copy current date to #{var_name}
|
|
54
|
+
set year of #{var_name} to #{time.year}
|
|
55
|
+
set month of #{var_name} to #{time.month}
|
|
56
|
+
set day of #{var_name} to #{time.day}
|
|
57
|
+
set hours of #{var_name} to #{time.hour}
|
|
58
|
+
set minutes of #{var_name} to #{time.min}
|
|
59
|
+
set seconds of #{var_name} to #{time.sec}
|
|
60
|
+
APPLESCRIPT
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmnifocusMcp
|
|
4
|
+
module Infrastructure
|
|
5
|
+
# Escapes Ruby strings before embedding them into generated JavaScript/JXA.
|
|
6
|
+
module JsEmbed
|
|
7
|
+
DOUBLE_QUOTED_STRING_ESCAPES = {
|
|
8
|
+
"\\" => "\\\\",
|
|
9
|
+
'"' => '\\"',
|
|
10
|
+
"\n" => "\\n",
|
|
11
|
+
"\r" => "\\r"
|
|
12
|
+
}.freeze
|
|
13
|
+
private_constant :DOUBLE_QUOTED_STRING_ESCAPES
|
|
14
|
+
|
|
15
|
+
DOUBLE_QUOTED_STRING_ESCAPE_REGEX = /[\\"\n\r]/
|
|
16
|
+
private_constant :DOUBLE_QUOTED_STRING_ESCAPE_REGEX
|
|
17
|
+
|
|
18
|
+
TEMPLATE_LITERAL_ESCAPES = {
|
|
19
|
+
"\\" => "\\\\",
|
|
20
|
+
"`" => "\\`",
|
|
21
|
+
"$" => "\\$"
|
|
22
|
+
}.freeze
|
|
23
|
+
private_constant :TEMPLATE_LITERAL_ESCAPES
|
|
24
|
+
|
|
25
|
+
TEMPLATE_LITERAL_ESCAPE_REGEX = /[\\`$]/
|
|
26
|
+
private_constant :TEMPLATE_LITERAL_ESCAPE_REGEX
|
|
27
|
+
|
|
28
|
+
class << self
|
|
29
|
+
def double_quoted_string(value)
|
|
30
|
+
value.to_s.gsub(DOUBLE_QUOTED_STRING_ESCAPE_REGEX, DOUBLE_QUOTED_STRING_ESCAPES)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def template_literal(value)
|
|
34
|
+
value.to_s.gsub(TEMPLATE_LITERAL_ESCAPE_REGEX, TEMPLATE_LITERAL_ESCAPES)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|