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.
Files changed (108) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +15 -0
  3. data/CHANGELOG.md +7 -0
  4. data/CODE_OF_CONDUCT.md +16 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +147 -0
  7. data/Rakefile +12 -0
  8. data/bin/omnifocus-mcp +7 -0
  9. data/lib/omnifocus_mcp/config.rb +18 -0
  10. data/lib/omnifocus_mcp/infrastructure/.keep +1 -0
  11. data/lib/omnifocus_mcp/infrastructure/apple_script.rb +263 -0
  12. data/lib/omnifocus_mcp/infrastructure/apple_script_date_builder.rb +65 -0
  13. data/lib/omnifocus_mcp/infrastructure/js_embed.rb +39 -0
  14. data/lib/omnifocus_mcp/infrastructure/script_runner.rb +254 -0
  15. data/lib/omnifocus_mcp/infrastructure.rb +6 -0
  16. data/lib/omnifocus_mcp/json_rpc_compat.rb +75 -0
  17. data/lib/omnifocus_mcp/logger.rb +34 -0
  18. data/lib/omnifocus_mcp/mcp.rb +74 -0
  19. data/lib/omnifocus_mcp/parsers/.keep +1 -0
  20. data/lib/omnifocus_mcp/parsers/apple_script_envelope.rb +44 -0
  21. data/lib/omnifocus_mcp/parsers.rb +6 -0
  22. data/lib/omnifocus_mcp/resources/base.rb +87 -0
  23. data/lib/omnifocus_mcp/resources/flagged_resource.rb +31 -0
  24. data/lib/omnifocus_mcp/resources/inbox_resource.rb +31 -0
  25. data/lib/omnifocus_mcp/resources/perspective_resource.rb +28 -0
  26. data/lib/omnifocus_mcp/resources/project_resource.rb +37 -0
  27. data/lib/omnifocus_mcp/resources/stats_resource.rb +22 -0
  28. data/lib/omnifocus_mcp/resources/today_resource.rb +37 -0
  29. data/lib/omnifocus_mcp/result.rb +108 -0
  30. data/lib/omnifocus_mcp/tools/batch_report.rb +9 -0
  31. data/lib/omnifocus_mcp/tools/database_stats.rb +184 -0
  32. data/lib/omnifocus_mcp/tools/definitions/add_omnifocus_task_tool.rb +61 -0
  33. data/lib/omnifocus_mcp/tools/definitions/add_project_tool.rb +54 -0
  34. data/lib/omnifocus_mcp/tools/definitions/batch_add_items_tool.rb +105 -0
  35. data/lib/omnifocus_mcp/tools/definitions/batch_remove_items_tool.rb +68 -0
  36. data/lib/omnifocus_mcp/tools/definitions/date_formatter.rb +45 -0
  37. data/lib/omnifocus_mcp/tools/definitions/edit_item_tool.rb +87 -0
  38. data/lib/omnifocus_mcp/tools/definitions/get_perspective_view_tool.rb +57 -0
  39. data/lib/omnifocus_mcp/tools/definitions/key_normalizer.rb +30 -0
  40. data/lib/omnifocus_mcp/tools/definitions/list_perspectives_tool.rb +47 -0
  41. data/lib/omnifocus_mcp/tools/definitions/list_tags_tool.rb +42 -0
  42. data/lib/omnifocus_mcp/tools/definitions/mcp_envelope.rb +31 -0
  43. data/lib/omnifocus_mcp/tools/definitions/operation_factory.rb +33 -0
  44. data/lib/omnifocus_mcp/tools/definitions/query_omnifocus_tool.rb +187 -0
  45. data/lib/omnifocus_mcp/tools/definitions/remove_item_tool.rb +55 -0
  46. data/lib/omnifocus_mcp/tools/generators/.keep +1 -0
  47. data/lib/omnifocus_mcp/tools/generators/add_omnifocus_task.rb +348 -0
  48. data/lib/omnifocus_mcp/tools/generators/add_project.rb +141 -0
  49. data/lib/omnifocus_mcp/tools/generators/database_stats.rb +16 -0
  50. data/lib/omnifocus_mcp/tools/generators/edit_item.rb +455 -0
  51. data/lib/omnifocus_mcp/tools/generators/list_perspectives.rb +13 -0
  52. data/lib/omnifocus_mcp/tools/generators/list_tags.rb +13 -0
  53. data/lib/omnifocus_mcp/tools/generators/perspective_view.rb +17 -0
  54. data/lib/omnifocus_mcp/tools/generators/query_omnifocus.rb +571 -0
  55. data/lib/omnifocus_mcp/tools/generators/query_omnifocus_debug.rb +169 -0
  56. data/lib/omnifocus_mcp/tools/generators/remove_item.rb +61 -0
  57. data/lib/omnifocus_mcp/tools/generators.rb +8 -0
  58. data/lib/omnifocus_mcp/tools/messages/add_omnifocus_task.rb +53 -0
  59. data/lib/omnifocus_mcp/tools/messages/add_project.rb +28 -0
  60. data/lib/omnifocus_mcp/tools/messages/batch_remove_items.rb +13 -0
  61. data/lib/omnifocus_mcp/tools/messages/edit_item.rb +39 -0
  62. data/lib/omnifocus_mcp/tools/messages/list_tools.rb +15 -0
  63. data/lib/omnifocus_mcp/tools/messages/remove_item.rb +42 -0
  64. data/lib/omnifocus_mcp/tools/messages.rb +8 -0
  65. data/lib/omnifocus_mcp/tools/operations/add_omnifocus_task.rb +74 -0
  66. data/lib/omnifocus_mcp/tools/operations/add_project.rb +75 -0
  67. data/lib/omnifocus_mcp/tools/operations/batch_add_items/batch_item.rb +38 -0
  68. data/lib/omnifocus_mcp/tools/operations/batch_add_items/bulk_executor.rb +94 -0
  69. data/lib/omnifocus_mcp/tools/operations/batch_add_items/cycle_detector.rb +74 -0
  70. data/lib/omnifocus_mcp/tools/operations/batch_add_items/param_builder.rb +47 -0
  71. data/lib/omnifocus_mcp/tools/operations/batch_add_items/planner.rb +111 -0
  72. data/lib/omnifocus_mcp/tools/operations/batch_add_items.rb +149 -0
  73. data/lib/omnifocus_mcp/tools/operations/batch_remove_items.rb +49 -0
  74. data/lib/omnifocus_mcp/tools/operations/database_stats.rb +52 -0
  75. data/lib/omnifocus_mcp/tools/operations/edit_item.rb +79 -0
  76. data/lib/omnifocus_mcp/tools/operations/get_perspective_view.rb +112 -0
  77. data/lib/omnifocus_mcp/tools/operations/list_perspectives.rb +85 -0
  78. data/lib/omnifocus_mcp/tools/operations/list_tags.rb +80 -0
  79. data/lib/omnifocus_mcp/tools/operations/query_omnifocus.rb +74 -0
  80. data/lib/omnifocus_mcp/tools/operations/query_omnifocus_debug.rb +63 -0
  81. data/lib/omnifocus_mcp/tools/operations/remove_item.rb +75 -0
  82. data/lib/omnifocus_mcp/tools/operations.rb +8 -0
  83. data/lib/omnifocus_mcp/tools/params/mcp_boundary.rb +41 -0
  84. data/lib/omnifocus_mcp/tools/params.rb +106 -0
  85. data/lib/omnifocus_mcp/tools/presenters/batch_report.rb +55 -0
  86. data/lib/omnifocus_mcp/tools/presenters/list_perspectives.rb +33 -0
  87. data/lib/omnifocus_mcp/tools/presenters/list_tags.rb +49 -0
  88. data/lib/omnifocus_mcp/tools/presenters/perspective_view.rb +81 -0
  89. data/lib/omnifocus_mcp/tools/presenters/query_reply.rb +52 -0
  90. data/lib/omnifocus_mcp/tools/presenters/query_results.rb +183 -0
  91. data/lib/omnifocus_mcp/tools/presenters.rb +8 -0
  92. data/lib/omnifocus_mcp/tools/query_omnifocus_formatter.rb +9 -0
  93. data/lib/omnifocus_mcp/tools/query_statuses.rb +22 -0
  94. data/lib/omnifocus_mcp/utils/apple_script.rb +9 -0
  95. data/lib/omnifocus_mcp/utils/apple_script_envelope.rb +9 -0
  96. data/lib/omnifocus_mcp/utils/apple_script_helpers.rb +9 -0
  97. data/lib/omnifocus_mcp/utils/blank.rb +26 -0
  98. data/lib/omnifocus_mcp/utils/date_filter.rb +76 -0
  99. data/lib/omnifocus_mcp/utils/date_formatting.rb +9 -0
  100. data/lib/omnifocus_mcp/utils/iso_date.rb +27 -0
  101. data/lib/omnifocus_mcp/utils/omnifocus_scripts/getPerspectiveView.js +472 -0
  102. data/lib/omnifocus_mcp/utils/omnifocus_scripts/listPerspectives.js +59 -0
  103. data/lib/omnifocus_mcp/utils/omnifocus_scripts/listTags.js +58 -0
  104. data/lib/omnifocus_mcp/utils/omnifocus_scripts/omnifocusDump.js +223 -0
  105. data/lib/omnifocus_mcp/utils/script_execution.rb +9 -0
  106. data/lib/omnifocus_mcp/version.rb +5 -0
  107. data/lib/omnifocus_mcp.rb +102 -0
  108. 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
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## [Unreleased]
4
+
5
+ ## [1.0.0] - 2026-05-31
6
+
7
+ - Initial release
@@ -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
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
data/bin/omnifocus-mcp ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "omnifocus_mcp"
6
+
7
+ OmnifocusMcp::Mcp.start
@@ -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,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