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
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fast_mcp"
4
+
5
+ require_relative "mcp_envelope"
6
+ require_relative "operation_factory"
7
+ require_relative "../messages/remove_item"
8
+ require_relative "../operations/remove_item"
9
+ require_relative "../params"
10
+ require_relative "../../utils/blank"
11
+
12
+ module OmnifocusMcp
13
+ module Tools
14
+ module Definitions
15
+ # `FastMcp::Tool` for `remove_item`.
16
+ class RemoveItemTool < FastMcp::Tool
17
+ tool_name "remove_item"
18
+ description "Remove a task or project from OmniFocus"
19
+
20
+ arguments do
21
+ optional(:id).filled(:string).description("The ID of the task or project to remove")
22
+ optional(:name).filled(:string)
23
+ .description("The name of the task or project to remove (as fallback if ID not provided)")
24
+ required(:itemType).filled(included_in?: %w[task project])
25
+ .description("Type of item to remove ('task' or 'project')")
26
+ end
27
+
28
+ extend OperationFactory
29
+
30
+ default_operation_factory { Operations::RemoveItem.method(:call) }
31
+
32
+ def call(**args)
33
+ if missing_identifier?(args)
34
+ return McpEnvelope::ToolReply.failure(Messages::RemoveItem.missing_identifier).to_envelope
35
+ end
36
+
37
+ unless %w[task project].include?(args[:itemType])
38
+ return McpEnvelope::ToolReply.failure(Messages::RemoveItem.invalid_item_type(args[:itemType])).to_envelope
39
+ end
40
+
41
+ McpEnvelope.safely("removing #{args[:itemType]}") do
42
+ operation.call(Params::RemoveItemParams.from_mcp(args)).fold(
43
+ on_ok: ->(removed) { McpEnvelope::ToolReply.success(Messages::RemoveItem.success(args, removed)) },
44
+ on_error: ->(err) { McpEnvelope::ToolReply.failure(Messages::RemoveItem.failure(args, err)) }
45
+ )
46
+ end
47
+ end
48
+
49
+ private
50
+
51
+ def missing_identifier?(args) = Utils::Blank.blank?(args[:id], args[:name])
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,348 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ require_relative "../../infrastructure/apple_script"
6
+ require_relative "../../infrastructure/apple_script_date_builder"
7
+ require_relative "../../parsers/apple_script_envelope"
8
+ require_relative "../../utils/blank"
9
+ require_relative "../../result"
10
+ require_relative "../../infrastructure/script_runner"
11
+ require_relative "../params"
12
+
13
+ module OmnifocusMcp
14
+ module Tools
15
+ module Generators
16
+ # Add a task to OmniFocus.
17
+ #
18
+ # Returns an {OmnifocusMcp::Result} whose +ok+ payload is a {Created}
19
+ # carrying the new task's id and placement.
20
+ class AddOmniFocusTask
21
+ Created = Data.define(:task_id, :placement)
22
+
23
+ # Generate pure AppleScript for task creation.
24
+ # @param params [Tools::Params::AddTaskParams]
25
+ class << self
26
+ def generate_apple_script(params)
27
+ params = Params::McpBoundary.coerce(Params::AddTaskParams, params)
28
+ fields = extract_fields(params)
29
+ date_pre_script, date_vars = build_date_pre_scripts(params)
30
+
31
+ body = document_body(fields:, date_vars:)
32
+
33
+ preamble = date_pre_script.empty? ? "" : "#{date_pre_script}\n"
34
+ preamble + Infrastructure::AppleScript.tell_document(body)
35
+ end
36
+
37
+ # Run the generated AppleScript against OmniFocus and parse the JSON result.
38
+ # @param params [Tools::Params::AddTaskParams]
39
+ def call(params)
40
+ require_relative "../operations/add_omnifocus_task"
41
+
42
+ Operations::AddOmniFocusTask.call(params)
43
+ end
44
+
45
+ # Combine multiple independent tasks into one osascript invocation.
46
+ def generate_bulk_apple_script(params_list)
47
+ pre_scripts = params_list.flat_map { |params| date_pre_script_for(params) }
48
+
49
+ bodies = params_list.map { |params| bulk_item_body(params) }
50
+ preamble = pre_scripts.join("\n\n")
51
+ preamble += "\n\n" unless preamble.empty?
52
+ preamble += <<~APPLESCRIPT
53
+ set bulkTaskIds to {}
54
+ set bulkPlacements to {}
55
+ APPLESCRIPT
56
+
57
+ inner = (bodies + [bulk_finalize_return]).join("\n\n")
58
+ preamble + "\n\n#{Infrastructure::AppleScript.tell_document(inner)}"
59
+ end
60
+
61
+ private
62
+
63
+ def parse_result(stdout)
64
+ Parsers::AppleScriptEnvelope.parse(stdout:, default_error: "Unknown error in add_omnifocus_task") do |hash|
65
+ OmnifocusMcp::Result.ok(Created.new(task_id: hash["taskId"], placement: hash["placement"]))
66
+ end
67
+ end
68
+
69
+ def applescript_run_failure(stderr:, status:)
70
+ exit_code = status.respond_to?(:exitstatus) ? status.exitstatus : status
71
+ message = "osascript failed (exit #{exit_code})"
72
+ message += ": #{stderr.strip}" unless stderr.nil? || stderr.empty?
73
+ message
74
+ end
75
+
76
+ # Collect and escape the input params once. Returns a Hash with
77
+ # plain Ruby values + AppleScript-escaped strings.
78
+ def extract_fields(params)
79
+ {
80
+ name: Infrastructure::AppleScript.escape(params.name.to_s),
81
+ note: Infrastructure::AppleScript.escape(params.note.to_s),
82
+ project_name: Infrastructure::AppleScript.escape(params.project_name.to_s),
83
+ parent_task_id: Infrastructure::AppleScript.escape(params.parent_task_id.to_s),
84
+ parent_task_name: Infrastructure::AppleScript.escape(params.parent_task_name.to_s),
85
+ flagged: params.flagged == true,
86
+ estimated_minutes: estimated_minutes(params.estimated_minutes),
87
+ tags: params.tags || []
88
+ }
89
+ end
90
+
91
+ def estimated_minutes(value)
92
+ return "" if Utils::Blank.blank?(value)
93
+
94
+ value.to_s
95
+ end
96
+
97
+ # Build the AppleScript that initialises each date variable
98
+ # *outside* the tell block. Returns [pre_script, vars_hash].
99
+ def build_date_pre_scripts(params)
100
+ assignments = {}
101
+ pre_scripts = []
102
+
103
+ %i[due_date defer_date planned_date].each do |key|
104
+ value = params.public_send(key)
105
+ next if Utils::Blank.blank?(value)
106
+
107
+ iso = value.to_s
108
+
109
+ var = "#{key.to_s.split("_").first}Date#{random_suffix}"
110
+ pre_scripts << Infrastructure::AppleScriptDateBuilder.create_date_outside_tell_block(iso, var)
111
+ assignments[key] = var
112
+ end
113
+
114
+ [pre_scripts.join("\n\n"), assignments]
115
+ end
116
+
117
+ def document_body(fields:, date_vars:, finalize: :single)
118
+ [
119
+ "-- Resolve parent task if provided",
120
+ "set newTask to missing value",
121
+ "set parentTask to missing value",
122
+ %(set placement to ""),
123
+ "",
124
+ parent_task_resolution(fields),
125
+ "",
126
+ task_creation(fields),
127
+ "",
128
+ property_setters(fields:, date_vars:),
129
+ "",
130
+ placement_derivation(fields[:project_name]),
131
+ "",
132
+ "-- Get the task ID",
133
+ "set taskId to id of newTask as string",
134
+ tag_assignments_block(fields[:tags]),
135
+ finalize == :bulk ? bulk_record_result : success_return(fields[:name])
136
+ ].compact.join("\n")
137
+ end
138
+
139
+ # AppleScript fragment appended after each task in a bulk add.
140
+ def bulk_record_result
141
+ <<~APPLESCRIPT.chomp
142
+ set end of bulkTaskIds to taskId
143
+ set end of bulkPlacements to placement
144
+ APPLESCRIPT
145
+ end
146
+
147
+ # Build tell-block body for one task inside a bulk script.
148
+ def date_pre_script_for(params)
149
+ params = Params::McpBoundary.coerce(Params::AddTaskParams, params)
150
+ pre, = build_date_pre_scripts(params)
151
+ pre.empty? ? [] : [pre]
152
+ end
153
+
154
+ def bulk_item_body(params)
155
+ params = Params::McpBoundary.coerce(Params::AddTaskParams, params)
156
+ fields = extract_fields(params)
157
+ _pre, date_vars = build_date_pre_scripts(params)
158
+ document_body(fields:, date_vars:, finalize: :bulk)
159
+ end
160
+
161
+ def bulk_finalize_return
162
+ <<~APPLESCRIPT.chomp
163
+ -- Build JSON array of {taskId, placement} objects
164
+ set jsonItems to "["
165
+ repeat with i from 1 to count of bulkTaskIds
166
+ set tid to item i of bulkTaskIds
167
+ set plc to item i of bulkPlacements
168
+ set jsonItems to jsonItems & "{\\"taskId\\":\\"" & tid & "\\",\\"placement\\":\\"" & plc & "\\"}"
169
+ if i < count of bulkTaskIds then set jsonItems to jsonItems & ","
170
+ end repeat
171
+ set jsonItems to jsonItems & "]"
172
+ return "{\\"success\\":true,\\"items\\":" & jsonItems & "}"
173
+ APPLESCRIPT
174
+ end
175
+
176
+ def success_return(escaped_name)
177
+ payload = [
178
+ %(\\"taskId\\":\\"" & taskId & "\\"),
179
+ %(\\"name\\":\\"#{escaped_name}\\"),
180
+ %(\\"placement\\":\\"" & placement & "\\")
181
+ ].join(",")
182
+ %(return "{\\"success\\":true,#{payload}}")
183
+ end
184
+
185
+ # Two-step parent task lookup: first by explicit id (if given),
186
+ # then by name (if no id resolved). When a project is also given,
187
+ # the parent must live in that project.
188
+ def parent_task_resolution(fields)
189
+ <<~APPLESCRIPT.chomp
190
+ #{parent_lookup_by_id(fields)}
191
+
192
+ #{parent_lookup_by_name(fields)}
193
+ APPLESCRIPT
194
+ end
195
+
196
+ def parent_lookup_by_id(fields)
197
+ <<~APPLESCRIPT.chomp
198
+ if "#{fields[:parent_task_id]}" is not "" then
199
+ try
200
+ set parentTask to first flattened task where id = "#{fields[:parent_task_id]}"
201
+ end try
202
+ if parentTask is missing value then
203
+ try
204
+ set parentTask to first inbox task where id = "#{fields[:parent_task_id]}"
205
+ end try
206
+ end if
207
+ -- If projectName provided, ensure parent is within that project
208
+ if parentTask is not missing value and "#{fields[:project_name]}" is not "" then
209
+ try
210
+ set pproj to containing project of parentTask
211
+ if pproj is missing value or name of pproj is not "#{fields[:project_name]}" then set parentTask to missing value
212
+ end try
213
+ end if
214
+ end if
215
+ APPLESCRIPT
216
+ end
217
+
218
+ # rubocop:disable Metrics/MethodLength
219
+ def parent_lookup_by_name(fields)
220
+ <<~APPLESCRIPT.chomp
221
+ if parentTask is missing value and "#{fields[:parent_task_name]}" is not "" then
222
+ if "#{fields[:project_name]}" is not "" then
223
+ -- Find by name but constrain to the specified project
224
+ try
225
+ set parentTask to first flattened task where name = "#{fields[:parent_task_name]}"
226
+ end try
227
+ if parentTask is not missing value then
228
+ try
229
+ set pproj to containing project of parentTask
230
+ if pproj is missing value or name of pproj is not "#{fields[:project_name]}" then set parentTask to missing value
231
+ end try
232
+ end if
233
+ else
234
+ -- No project specified; allow global or inbox match by name
235
+ try
236
+ set parentTask to first flattened task where name = "#{fields[:parent_task_name]}"
237
+ end try
238
+ if parentTask is missing value then
239
+ try
240
+ set parentTask to first inbox task where name = "#{fields[:parent_task_name]}"
241
+ end try
242
+ end if
243
+ end if
244
+ end if
245
+ APPLESCRIPT
246
+ end
247
+ # rubocop:enable Metrics/MethodLength
248
+
249
+ # Pick the container: explicit parent, project root, or inbox.
250
+ def task_creation(fields)
251
+ <<~APPLESCRIPT.chomp
252
+ if parentTask is not missing value then
253
+ -- Create task under parent task
254
+ set newTask to make new task with properties {name:"#{fields[:name]}"} at end of tasks of parentTask
255
+ else if "#{fields[:project_name]}" is not "" then
256
+ -- Create under specified project
257
+ try
258
+ set theProject to first flattened project where name = "#{fields[:project_name]}"
259
+ set newTask to make new task with properties {name:"#{fields[:name]}"} at end of tasks of theProject
260
+ on error
261
+ return "{\\"success\\":false,\\"error\\":\\"Project not found: #{fields[:project_name]}\\"}"
262
+ end try
263
+ else
264
+ -- Fallback to inbox
265
+ set newTask to make new inbox task with properties {name:"#{fields[:name]}"}
266
+ end if
267
+ APPLESCRIPT
268
+ end
269
+
270
+ def property_setters(fields:, date_vars:)
271
+ lines = ["-- Set task properties"]
272
+ lines << %(set note of newTask to "#{fields[:note]}") unless fields[:note].empty?
273
+ %i[due_date defer_date planned_date].each do |key|
274
+ next unless date_vars[key]
275
+
276
+ lines.concat(date_setter(key, date_vars[key]))
277
+ end
278
+ lines << "set flagged of newTask to true" if fields[:flagged]
279
+ minutes = fields[:estimated_minutes]
280
+ lines << "set estimated minutes of newTask to #{minutes}" unless minutes.empty?
281
+ lines.join("\n")
282
+ end
283
+
284
+ def date_setter(key, value)
285
+ label = key.to_s.split("_").first
286
+ ["-- Set #{label} date", "set #{label} date of newTask to #{value}"]
287
+ end
288
+
289
+ # rubocop:disable Metrics/MethodLength
290
+ def placement_derivation(project_name)
291
+ <<~APPLESCRIPT.chomp
292
+ -- Derive placement from container; distinguish real parent vs project root task
293
+ try
294
+ set placement to "inbox"
295
+ set ctr to container of newTask
296
+ set cclass to class of ctr as string
297
+ set ctrId to id of ctr as string
298
+ if cclass is "project" then
299
+ set placement to "project"
300
+ else if cclass is "task" then
301
+ if parentTask is not missing value then
302
+ set parentId to id of parentTask as string
303
+ if ctrId is equal to parentId then
304
+ set placement to "parent"
305
+ else
306
+ -- Likely the project's root task; treat as project
307
+ set placement to "project"
308
+ end if
309
+ else
310
+ -- No explicit parent requested; container is root task -> treat as project
311
+ set placement to "project"
312
+ end if
313
+ else
314
+ set placement to "inbox"
315
+ end if
316
+ on error
317
+ -- If container access fails (e.g., inbox), default based on projectName
318
+ if "#{project_name}" is not "" then
319
+ set placement to "project"
320
+ else
321
+ set placement to "inbox"
322
+ end if
323
+ end try
324
+ APPLESCRIPT
325
+ end
326
+ # rubocop:enable Metrics/MethodLength
327
+
328
+ def tag_assignments_block(tags)
329
+ return nil if tags.empty?
330
+
331
+ blocks = tags.map do |tag|
332
+ Infrastructure::AppleScript.tag_assignment(
333
+ item_var: "newTask",
334
+ tag_name: Infrastructure::AppleScript.escape(tag.to_s)
335
+ )
336
+ end
337
+
338
+ "\n-- Add tags if provided\n#{blocks.join("\n")}"
339
+ end
340
+
341
+ def random_suffix
342
+ SecureRandom.hex(5)
343
+ end
344
+ end
345
+ end
346
+ end
347
+ end
348
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ require_relative "../../infrastructure/apple_script"
6
+ require_relative "../../infrastructure/apple_script_date_builder"
7
+ require_relative "../../utils/blank"
8
+ require_relative "../params"
9
+
10
+ module OmnifocusMcp
11
+ module Tools
12
+ module Generators
13
+ class AddProject
14
+ class << self
15
+ def generate_apple_script(params = nil, **kwargs)
16
+ merge_params(params, kwargs).then do |params|
17
+ params = Params::McpBoundary.coerce(Params::AddProjectParams, params)
18
+ fields = extract_fields(params)
19
+ date_pre_script, date_vars = build_date_pre_scripts(params)
20
+ body = document_body(fields:, date_vars:)
21
+ preamble = date_pre_script.empty? ? "" : "#{date_pre_script}\n"
22
+
23
+ preamble + Infrastructure::AppleScript.tell_document(body)
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def merge_params(params, kwargs)
30
+ return params || {} if kwargs.empty?
31
+
32
+ base = params.respond_to?(:to_h) ? params.to_h : params || {}
33
+ base.merge(kwargs)
34
+ end
35
+
36
+ def extract_fields(params)
37
+ {
38
+ name: Infrastructure::AppleScript.escape(params.name.to_s),
39
+ note: Infrastructure::AppleScript.escape(params.note.to_s),
40
+ folder_name: params.folder_name.to_s,
41
+ flagged: params.flagged == true,
42
+ sequential: params.sequential == true,
43
+ estimated_minutes: estimated_minutes(params.estimated_minutes),
44
+ tags: params.tags || []
45
+ }
46
+ end
47
+
48
+ def estimated_minutes(value)
49
+ return "" if Utils::Blank.blank?(value)
50
+
51
+ value.to_s
52
+ end
53
+
54
+ def build_date_pre_scripts(params)
55
+ pre_scripts = []
56
+ vars = {}
57
+
58
+ %i[due_date defer_date].each do |key|
59
+ value = params.public_send(key)
60
+ next if Utils::Blank.blank?(value)
61
+
62
+ var = "#{key.to_s.split("_").first}Date#{SecureRandom.hex(5)}"
63
+ pre_scripts << Infrastructure::AppleScriptDateBuilder.create_date_outside_tell_block(value.to_s, var)
64
+ vars[key] = var
65
+ end
66
+
67
+ [pre_scripts.join("\n\n"), vars]
68
+ end
69
+
70
+ def document_body(fields:, date_vars:)
71
+ [
72
+ project_creation(fields),
73
+ "",
74
+ property_setters(fields:, date_vars:),
75
+ "",
76
+ "-- Get the project ID",
77
+ "set projectId to id of newProject as string",
78
+ tag_assignments_block(fields[:tags]),
79
+ %(return "{\\"success\\":true,\\"projectId\\":\\"" & projectId & "\\",\\"name\\":\\"#{fields[:name]}\\"}")
80
+ ].compact.join("\n")
81
+ end
82
+
83
+ def project_creation(fields)
84
+ if fields[:folder_name].empty?
85
+ <<~APPLESCRIPT.chomp
86
+ -- Create project at the root level
87
+ set newProject to make new project with properties {name:"#{fields[:name]}"}
88
+ APPLESCRIPT
89
+ else
90
+ escaped = Infrastructure::AppleScript.escape(fields[:folder_name])
91
+ error_json = %({\\"success\\":false,\\"error\\":\\"Folder not found: #{escaped}\\"})
92
+ folder_lookup = Infrastructure::AppleScript.generate_folder_lookup_script(
93
+ raw_folder_path: fields[:folder_name], var_name: "theFolder", error_return_json: error_json
94
+ )
95
+ <<~APPLESCRIPT.chomp
96
+ -- Find the folder (supports nested paths like "Work/Engineering")
97
+ #{folder_lookup}
98
+ set newProject to make new project with properties {name:"#{fields[:name]}"} at end of projects of theFolder
99
+ APPLESCRIPT
100
+ end
101
+ end
102
+
103
+ def property_setters(fields:, date_vars:)
104
+ lines = ["-- Set project properties"]
105
+ lines << %(set note of newProject to "#{fields[:note]}") unless fields[:note].empty?
106
+
107
+ %i[due_date defer_date].each do |key|
108
+ lines.concat(date_setter_lines(key, date_vars[key])) if date_vars[key]
109
+ end
110
+
111
+ lines << "set flagged of newProject to true" if fields[:flagged]
112
+
113
+ minutes = fields[:estimated_minutes]
114
+ lines << "set estimated minutes of newProject to #{minutes}" unless minutes.empty?
115
+ lines << "set sequential of newProject to #{fields[:sequential]}"
116
+ lines.join("\n")
117
+ end
118
+
119
+ def date_setter_lines(key, var)
120
+ label = key.to_s.split("_").first
121
+ ["-- Set #{label} date", "set #{label} date of newProject to #{var}"]
122
+ end
123
+
124
+ def tag_assignments_block(tags)
125
+ return nil if tags.empty?
126
+
127
+ blocks = tags.map do |tag|
128
+ Infrastructure::AppleScript.tag_assignment(
129
+ item_var: "newProject",
130
+ tag_name: Infrastructure::AppleScript.escape(tag.to_s)
131
+ )
132
+ end
133
+
134
+ "\n-- Add tags if provided\n#{blocks.join("\n")}"
135
+ end
136
+ end
137
+ end
138
+ # rubocop:enable Metrics/ClassLength
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../database_stats"
4
+
5
+ module OmnifocusMcp
6
+ module Tools
7
+ module Generators
8
+ class DatabaseStats
9
+ class << self
10
+ def stats_script = Tools::DatabaseStats.singleton_class.const_get(:STATS_SCRIPT)
11
+ def changes_script(...) = Tools::DatabaseStats.changes_script(...)
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end