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,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../result"
4
+ require_relative "../../infrastructure/script_runner"
5
+
6
+ module OmnifocusMcp
7
+ module Tools
8
+ module Generators
9
+ # Debug variant of `QueryOmnifocus` that returns raw field information
10
+ # for a single sample item. Useful for understanding what fields are
11
+ # actually exposed by the OmniFocus JS API.
12
+ class QueryOmnifocusDebug
13
+ ENTITIES = %w[task project folder].freeze
14
+ private_constant :ENTITIES
15
+
16
+ # @param entity ['task'|'project'|'folder']
17
+ # @return [OmnifocusMcp::Result] +ok+ carries the parsed JSON Hash from the script
18
+ class << self
19
+ def call(entity)
20
+ require_relative "../operations/query_omnifocus_debug"
21
+
22
+ Operations::QueryOmnifocusDebug.call(entity)
23
+ end
24
+
25
+ # Build the OmniJS debug script for the given entity.
26
+ # @param entity [String] one of {ENTITIES}
27
+ def generate_debug_script(entity)
28
+ <<~JS
29
+ (() => {
30
+ try {
31
+ let item;
32
+ const entityType = "#{entity}";
33
+
34
+ if (entityType === "task") {
35
+ item = flattenedTasks[0];
36
+ } else if (entityType === "project") {
37
+ item = flattenedProjects[0];
38
+ } else if (entityType === "folder") {
39
+ item = flattenedFolders[0];
40
+ }
41
+
42
+ if (!item) {
43
+ return JSON.stringify({ error: "No items found" });
44
+ }
45
+
46
+ const properties = {};
47
+ const skipProps = ['constructor', 'toString', 'valueOf'];
48
+
49
+ for (let prop in item) {
50
+ if (skipProps.includes(prop)) continue;
51
+
52
+ try {
53
+ const value = item[prop];
54
+ const valueType = typeof value;
55
+
56
+ if (value === null) {
57
+ properties[prop] = { type: 'null', value: null };
58
+ } else if (value === undefined) {
59
+ properties[prop] = { type: 'undefined', value: undefined };
60
+ } else if (valueType === 'function') {
61
+ properties[prop] = { type: 'function', value: '[Function]' };
62
+ } else if (value instanceof Date) {
63
+ properties[prop] = { type: 'Date', value: value.toISOString() };
64
+ } else if (Array.isArray(value)) {
65
+ properties[prop] = {
66
+ type: 'Array',
67
+ length: value.length,
68
+ sample: value.length > 0 ? value[0] : null
69
+ };
70
+ } else if (valueType === 'object') {
71
+ if (value.id && value.id.primaryKey) {
72
+ properties[prop] = {
73
+ type: 'OFObject',
74
+ id: value.id.primaryKey,
75
+ name: value.name || null
76
+ };
77
+ } else {
78
+ properties[prop] = { type: 'object', keys: Object.keys(value) };
79
+ }
80
+ } else {
81
+ properties[prop] = { type: valueType, value: value };
82
+ }
83
+ } catch (e) {
84
+ properties[prop] = { type: 'error', error: e.toString() };
85
+ }
86
+ }
87
+
88
+ const checkProps = [
89
+ 'id', 'name', 'note', 'flagged', 'dueDate', 'deferDate',
90
+ 'estimatedMinutes', 'modificationDate', 'creationDate',
91
+ 'completionDate', 'taskStatus', 'status', 'tasks', 'projects',
92
+ 'containingProject', 'parentFolder', 'parent', 'children'
93
+ ];
94
+
95
+ const expectedProps = {};
96
+ checkProps.forEach(prop => {
97
+ try {
98
+ const value = item[prop];
99
+ if (value !== undefined) {
100
+ if (value && value.id && value.id.primaryKey) {
101
+ expectedProps[prop] = {
102
+ exists: true,
103
+ type: 'OFObject',
104
+ id: value.id.primaryKey
105
+ };
106
+ } else if (value instanceof Date) {
107
+ expectedProps[prop] = {
108
+ exists: true,
109
+ type: 'Date',
110
+ value: value.toISOString()
111
+ };
112
+ } else if (Array.isArray(value)) {
113
+ expectedProps[prop] = {
114
+ exists: true,
115
+ type: 'Array',
116
+ length: value.length
117
+ };
118
+ } else {
119
+ expectedProps[prop] = {
120
+ exists: true,
121
+ type: typeof value,
122
+ value: value
123
+ };
124
+ }
125
+ } else {
126
+ expectedProps[prop] = { exists: false };
127
+ }
128
+ } catch (e) {
129
+ expectedProps[prop] = { exists: false, error: e.toString() };
130
+ }
131
+ });
132
+
133
+ return JSON.stringify({
134
+ entityType: entityType,
135
+ itemName: item.name || 'Unnamed',
136
+ allProperties: properties,
137
+ expectedProperties: expectedProps
138
+ }, null, 2);
139
+
140
+ } catch (error) {
141
+ return JSON.stringify({ error: error.toString() });
142
+ }
143
+ })();
144
+ JS
145
+ end
146
+
147
+ private
148
+
149
+ def classify_response(response)
150
+ shape = response.is_a?(Hash) ? response.transform_keys(&:to_sym) : nil
151
+
152
+ case shape
153
+ in { error: String => msg }
154
+ OmnifocusMcp::Result.error(msg)
155
+ in Hash
156
+ OmnifocusMcp::Result.ok(response)
157
+ in nil
158
+ OmnifocusMcp::Result.error("Unexpected response from query_omnifocus_debug: #{response.inspect}")
159
+ end
160
+ end
161
+
162
+ def unknown_entity_message(entity)
163
+ "Unknown entity: #{entity.inspect}. Must be one of #{ENTITIES.join(", ")}"
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../infrastructure/apple_script"
4
+ require_relative "../../utils/blank"
5
+ require_relative "../params"
6
+
7
+ module OmnifocusMcp
8
+ module Tools
9
+ module Generators
10
+ class RemoveItem
11
+ class << self
12
+ def generate_apple_script(params = nil, **kwargs)
13
+ merge_params(params, kwargs).then do |params|
14
+ params = Params::McpBoundary.coerce(Params::RemoveItemParams, params)
15
+ return missing_identifier_error if Utils::Blank.blank?(params.id, params.name)
16
+
17
+ id = Infrastructure::AppleScript.escape(params.id.to_s)
18
+ name = Infrastructure::AppleScript.escape(params.name.to_s)
19
+ item_type = params.item_type.to_s
20
+
21
+ Infrastructure::AppleScript.tell_document(document_body(item_type:, id:, name:))
22
+ end
23
+ end
24
+
25
+ private
26
+
27
+ def merge_params(params, kwargs)
28
+ return params || {} if kwargs.empty?
29
+
30
+ base = params.respond_to?(:to_h) ? params.to_h : params || {}
31
+ base.merge(kwargs)
32
+ end
33
+
34
+ def missing_identifier_error
35
+ %(return "{\\"success\\":false,\\"error\\":\\"Either id or name must be provided\\"}")
36
+ end
37
+
38
+ def document_body(item_type:, id:, name:)
39
+ <<~APPLESCRIPT.chomp
40
+ -- Find the item to remove
41
+ #{Infrastructure::AppleScript.find_item(var: "foundItem", item_type: item_type, id: id, name: name)}
42
+ -- If we found the item, remove it
43
+ if foundItem is not missing value then
44
+ set itemName to name of foundItem
45
+ set itemId to id of foundItem as string
46
+
47
+ -- Delete the item
48
+ delete foundItem
49
+
50
+ -- Return success
51
+ return "{\\"success\\":true,\\"id\\":\\"" & itemId & "\\",\\"name\\":\\"" & itemName & "\\"}"
52
+ else
53
+ return "{\\"success\\":false,\\"error\\":\\"Item not found\\"}"
54
+ end if
55
+ APPLESCRIPT
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmnifocusMcp
4
+ module Tools
5
+ module Generators
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../definitions/date_formatter"
4
+
5
+ module OmnifocusMcp
6
+ module Tools
7
+ module Messages
8
+ module AddOmniFocusTask
9
+ class << self
10
+ def success(args, result)
11
+ location_text = location_for(args, result.placement)
12
+ tag_text = tag_text_for(args[:tags])
13
+ due_text = if args[:dueDate]
14
+ " due on #{Definitions::DateFormatter.format_date(args[:dueDate],
15
+ style: :locale)}"
16
+ else
17
+ ""
18
+ end
19
+ warning = placement_warning(args, result.placement)
20
+
21
+ %(✅ Task "#{args[:name]}" created successfully #{location_text}#{due_text}#{tag_text}.#{warning})
22
+ end
23
+
24
+ def failure(error) = "Failed to create task: #{error}"
25
+
26
+ private
27
+
28
+ def location_for(args, placement)
29
+ case placement
30
+ when "parent" then "under the parent task"
31
+ when "project" then args[:projectName] ? %(in project "#{args[:projectName]}") : "in a project"
32
+ else "in your inbox"
33
+ end
34
+ end
35
+
36
+ def tag_text_for(tags)
37
+ tags && !tags.empty? ? " with tags: #{tags.join(", ")}" : ""
38
+ end
39
+
40
+ def placement_warning(args, placement)
41
+ return "" if placement.nil? || placement == "parent"
42
+
43
+ parent_requested = args[:parentTaskId] || args[:parentTaskName]
44
+ return "" unless parent_requested
45
+
46
+ location = placement == "project" ? "in project" : "in inbox"
47
+ "\n⚠️ Parent not found; task created #{location}."
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../definitions/date_formatter"
4
+
5
+ module OmnifocusMcp
6
+ module Tools
7
+ module Messages
8
+ module AddProject
9
+ class << self
10
+ def success(args)
11
+ location = args[:folderName] ? %(in folder "#{args[:folderName]}") : "at the root level"
12
+ tags = args[:tags] && !args[:tags].empty? ? " with tags: #{args[:tags].join(", ")}" : ""
13
+ due = if args[:dueDate]
14
+ " due on #{Definitions::DateFormatter.format_date(args[:dueDate], style: :locale)}"
15
+ else
16
+ ""
17
+ end
18
+ sequential = args[:sequential] ? " (sequential)" : " (parallel)"
19
+
20
+ %(✅ Project "#{args[:name]}" created successfully #{location}#{due}#{tags}#{sequential}.)
21
+ end
22
+
23
+ def failure(error) = "Failed to create project: #{error}"
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmnifocusMcp
4
+ module Tools
5
+ module Messages
6
+ module BatchRemoveItems
7
+ class << self
8
+ def missing_identifier = "Each item must have either id or name provided to remove it."
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmnifocusMcp
4
+ module Tools
5
+ module Messages
6
+ module EditItem
7
+ class << self
8
+ def missing_identifier = "Either id or name must be provided to edit an item."
9
+
10
+ def success(args, edited)
11
+ label = args[:itemType] == "task" ? "Task" : "Project"
12
+ changed = edited.changed_properties ? " (#{edited.changed_properties})" : ""
13
+ %(✅ #{label} "#{edited.name}" updated successfully#{changed}.)
14
+ end
15
+
16
+ def failure(args, error)
17
+ base = "Failed to update #{args[:itemType]}"
18
+ return base unless error
19
+
20
+ if error.include?("Item not found")
21
+ not_found_message(args)
22
+ else
23
+ "#{base}: #{error}"
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def not_found_message(args)
30
+ msg = "#{args[:itemType].capitalize} not found"
31
+ msg += %( with ID "#{args[:id]}") if args[:id]
32
+ msg += %(#{args[:id] ? " or" : " with"} name "#{args[:name]}") if args[:name]
33
+ "#{msg}."
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmnifocusMcp
4
+ module Tools
5
+ module Messages
6
+ module ListTools
7
+ class << self
8
+ def list_tags_failure(error) = "Failed to list tags: #{error}"
9
+ def list_perspectives_failure(error) = "Failed to list perspectives: #{error}"
10
+ def perspective_view_failure(error) = "Failed to get perspective view: #{error}"
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmnifocusMcp
4
+ module Tools
5
+ module Messages
6
+ module RemoveItem
7
+ class << self
8
+ def missing_identifier = "Either id or name must be provided to remove an item."
9
+
10
+ def invalid_item_type(item_type)
11
+ "Invalid item type: #{item_type}. Must be either 'task' or 'project'."
12
+ end
13
+
14
+ def success(args, removed)
15
+ label = args[:itemType] == "task" ? "Task" : "Project"
16
+ %(✅ #{label} "#{removed.name}" removed successfully.)
17
+ end
18
+
19
+ def failure(args, error)
20
+ base = "Failed to remove #{args[:itemType]}"
21
+ return base unless error
22
+
23
+ if error.include?("Item not found")
24
+ not_found_message(args)
25
+ else
26
+ "#{base}: #{error}"
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def not_found_message(args)
33
+ msg = "#{args[:itemType].capitalize} not found"
34
+ msg += %( with ID "#{args[:id]}") if args[:id]
35
+ msg += %(#{args[:id] ? " or" : " with"} name "#{args[:name]}") if args[:name]
36
+ "#{msg}."
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmnifocusMcp
4
+ module Tools
5
+ module Messages
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../infrastructure/script_runner"
4
+ require_relative "../../parsers/apple_script_envelope"
5
+ require_relative "../../result"
6
+ require_relative "../generators/add_omnifocus_task"
7
+ require_relative "../params"
8
+
9
+ module OmnifocusMcp
10
+ module Tools
11
+ module Operations
12
+ class AddOmniFocusTask
13
+ Created = Generators::AddOmniFocusTask::Created
14
+
15
+ class << self
16
+ def call(params = nil, script_runner: Infrastructure::ScriptRunner, **kwargs)
17
+ merge_params(params, kwargs).then { new(script_runner:).call(it) }
18
+ end
19
+
20
+ def generate_apple_script(...) = Generators::AddOmniFocusTask.generate_apple_script(...)
21
+ def generate_bulk_apple_script(...) = Generators::AddOmniFocusTask.generate_bulk_apple_script(...)
22
+
23
+ private
24
+
25
+ def merge_params(params, kwargs)
26
+ return params || {} if kwargs.empty?
27
+
28
+ base = params.respond_to?(:to_h) ? params.to_h : params || {}
29
+ base.merge(kwargs)
30
+ end
31
+ end
32
+
33
+ def initialize(script_runner: Infrastructure::ScriptRunner, generator: Generators::AddOmniFocusTask)
34
+ @script_runner = script_runner
35
+ @generator = generator
36
+ end
37
+
38
+ def call(params)
39
+ params = Params::McpBoundary.coerce(Params::AddTaskParams, params)
40
+ generator.generate_apple_script(params).then { |script| run_script(script) }
41
+ rescue StandardError => e
42
+ OmnifocusMcp.logger.warn("[add_omnifocus_task] Error: #{e}")
43
+ OmnifocusMcp::Result.error(e.message || "Unknown error in add_omnifocus_task")
44
+ end
45
+
46
+ private
47
+
48
+ attr_reader :script_runner, :generator
49
+
50
+ def run_script(script)
51
+ stdout, stderr, status = script_runner.execute_applescript(script)
52
+
53
+ OmnifocusMcp.logger.warn("[add_omnifocus_task] AppleScript stderr: #{stderr}") if stderr && !stderr.empty?
54
+ return OmnifocusMcp::Result.error(applescript_run_failure(stderr:, status:)) unless status.success?
55
+
56
+ parse_result(stdout)
57
+ end
58
+
59
+ def parse_result(stdout)
60
+ Parsers::AppleScriptEnvelope.parse(stdout:, default_error: "Unknown error in add_omnifocus_task") do |hash|
61
+ OmnifocusMcp::Result.ok(Created.new(task_id: hash["taskId"], placement: hash["placement"]))
62
+ end
63
+ end
64
+
65
+ def applescript_run_failure(stderr:, status:)
66
+ exit_code = status.respond_to?(:exitstatus) ? status.exitstatus : status
67
+ message = "osascript failed (exit #{exit_code})"
68
+ message += ": #{stderr.strip}" unless stderr.nil? || stderr.empty?
69
+ message
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../infrastructure/script_runner"
4
+ require_relative "../../parsers/apple_script_envelope"
5
+ require_relative "../../result"
6
+ require_relative "../generators/add_project"
7
+ require_relative "../params"
8
+
9
+ module OmnifocusMcp
10
+ module Tools
11
+ module Operations
12
+ class AddProject
13
+ Created = Data.define(:project_id)
14
+
15
+ class << self
16
+ def call(params = nil, script_runner: Infrastructure::ScriptRunner, **kwargs)
17
+ merge_params(params, kwargs).then { new(script_runner:).call(it) }
18
+ end
19
+
20
+ def generate_apple_script(params = nil, **)
21
+ Generators::AddProject.generate_apple_script(params, **)
22
+ end
23
+
24
+ private
25
+
26
+ def merge_params(params, kwargs)
27
+ return params || {} if kwargs.empty?
28
+
29
+ base = params.respond_to?(:to_h) ? params.to_h : params || {}
30
+ base.merge(kwargs)
31
+ end
32
+ end
33
+
34
+ def initialize(script_runner: Infrastructure::ScriptRunner, generator: Generators::AddProject)
35
+ @script_runner = script_runner
36
+ @generator = generator
37
+ end
38
+
39
+ def call(params)
40
+ params = Params::McpBoundary.coerce(Params::AddProjectParams, params)
41
+ generator.generate_apple_script(params).then { |script| run_script(script) }
42
+ rescue StandardError => e
43
+ OmnifocusMcp.logger.warn("[add_project] Error: #{e}")
44
+ OmnifocusMcp::Result.error(e.message || "Unknown error in add_project")
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :script_runner, :generator
50
+
51
+ def run_script(script)
52
+ stdout, stderr, status = script_runner.execute_applescript(script)
53
+
54
+ OmnifocusMcp.logger.warn("[add_project] AppleScript stderr: #{stderr}") if stderr && !stderr.empty?
55
+ return OmnifocusMcp::Result.error(applescript_run_failure(stderr:, status:)) unless status.success?
56
+
57
+ parse_result(stdout)
58
+ end
59
+
60
+ def parse_result(stdout)
61
+ Parsers::AppleScriptEnvelope.parse(stdout:, default_error: "Unknown error in add_project") do |hash|
62
+ OmnifocusMcp::Result.ok(Created.new(project_id: hash["projectId"]))
63
+ end
64
+ end
65
+
66
+ def applescript_run_failure(stderr:, status:)
67
+ exit_code = status.respond_to?(:exitstatus) ? status.exitstatus : status
68
+ message = "osascript failed (exit #{exit_code})"
69
+ message += ": #{stderr.strip}" unless stderr.nil? || stderr.empty?
70
+ message
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../result"
4
+
5
+ module OmnifocusMcp
6
+ module Tools
7
+ module Operations
8
+ class BatchAddItems
9
+ # In-flight bookkeeping for one item in a batch. The original payload
10
+ # and its position in the input array are read-only; status and result
11
+ # are mutated as the batch processes.
12
+ class BatchItem
13
+ attr_reader :payload, :index
14
+ attr_accessor :status, :result
15
+
16
+ def initialize(payload:, index:)
17
+ @payload = payload
18
+ @index = index
19
+ @status = :pending
20
+ @result = nil
21
+ end
22
+
23
+ def pending? = @status == :pending
24
+
25
+ def fail!(message)
26
+ @status = :failed
27
+ @result = OmnifocusMcp::Result.error(message)
28
+ end
29
+
30
+ def succeed!(value)
31
+ @status = :succeeded
32
+ @result = OmnifocusMcp::Result.ok(value)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end