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,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../parsers/apple_script_envelope"
4
+ require_relative "../../../infrastructure/script_runner"
5
+ require_relative "../../../utils/blank"
6
+ require_relative "../../../result"
7
+ require_relative "../../generators/add_omnifocus_task"
8
+ require_relative "param_builder"
9
+
10
+ module OmnifocusMcp
11
+ module Tools
12
+ module Operations
13
+ class BatchAddItems
14
+ # Runs eligible batch adds in a single osascript invocation instead of
15
+ # one process per item. Large MCP batches (20+ tasks) were timing out
16
+ # when each item spawned its own osascript call.
17
+ module BulkExecutor
18
+ # Independent task batches at or above this size use one osascript call.
19
+ BULK_MIN_ITEMS = 2
20
+
21
+ class << self
22
+ # @param batch_items [Array<BatchItem>] only pending items are executed
23
+ # @return [Array<OmnifocusMcp::Result>, nil] per-item results in pending
24
+ # order, or +nil+ when bulk is not eligible or the script fails
25
+ def run(batch_items, execute_applescript: Infrastructure::ScriptRunner.method(:execute_applescript))
26
+ pending = batch_items.select(&:pending?)
27
+ return nil unless eligible?(pending)
28
+
29
+ params_list = pending.map do |bi|
30
+ ParamBuilder.task(bi.payload, parent_task_id: nil, project_name: bi.payload.project_name)
31
+ end
32
+ script = Generators::AddOmniFocusTask.generate_bulk_apple_script(params_list)
33
+
34
+ stdout, stderr, status = execute_applescript.call(script)
35
+ log_stderr(stderr)
36
+
37
+ return nil unless status.success?
38
+
39
+ parse_bulk_results(stdout, expected_count: pending.length)
40
+ end
41
+
42
+ def eligible?(batch_items)
43
+ return false if batch_items.length < BULK_MIN_ITEMS
44
+
45
+ batch_items.all? { |bi| bi.payload.type.to_s == "task" } &&
46
+ batch_items.none? { |bi| needs_sequential_resolution?(bi.payload) }
47
+ end
48
+
49
+ def needs_sequential_resolution?(payload)
50
+ [
51
+ payload.parent_temp_id,
52
+ payload.parent_task_id,
53
+ payload.parent_task_name
54
+ ].any? { !Utils::Blank.blank?(it) }
55
+ end
56
+
57
+ private
58
+
59
+ def log_stderr(stderr)
60
+ return if stderr.nil? || stderr.empty?
61
+
62
+ OmnifocusMcp.logger.warn("[batch_add_items] AppleScript stderr: #{stderr}")
63
+ end
64
+
65
+ def parse_bulk_results(stdout, expected_count:)
66
+ parsed = Parsers::AppleScriptEnvelope.parse(
67
+ stdout:,
68
+ default_error: "Unknown error in batch_add_items bulk add"
69
+ ) do |hash|
70
+ items = hash["items"]
71
+ unless items.is_a?(Array) && items.length == expected_count
72
+ next OmnifocusMcp::Result.error("item count mismatch")
73
+ end
74
+
75
+ results = items.map { |item| parse_bulk_item(item) }
76
+ results.find(&:error?) || OmnifocusMcp::Result.ok(results)
77
+ end
78
+
79
+ parsed.error? ? nil : parsed.ok
80
+ end
81
+
82
+ def parse_bulk_item(item)
83
+ unless item.is_a?(Hash) && item["taskId"]
84
+ return OmnifocusMcp::Result.error("Missing taskId in bulk response")
85
+ end
86
+
87
+ OmnifocusMcp::Result.ok(item["taskId"])
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../utils/blank"
4
+
5
+ module OmnifocusMcp
6
+ module Tools
7
+ module Operations
8
+ class BatchAddItems
9
+ # DFS over a temp_id -> parent_temp_id graph; collects a message for
10
+ # every temp_id participating in any cycle.
11
+ class CycleDetector
12
+ # @param temp_index [Hash{String => BatchItem}]
13
+ def initialize(temp_index)
14
+ @temp_index = temp_index
15
+ @visiting = Set.new
16
+ @visited = Set.new
17
+ @in_cycle = Set.new
18
+ @stack = []
19
+ @messages = {}
20
+ end
21
+
22
+ def detect
23
+ @temp_index.each_key { |tid| visit(tid) }
24
+ @messages
25
+ end
26
+
27
+ private
28
+
29
+ def visit(temp_id)
30
+ return if @visited.include?(temp_id)
31
+ return if @in_cycle.include?(temp_id)
32
+ return if @visiting.include?(temp_id)
33
+
34
+ @visiting.add(temp_id)
35
+ @stack.push(temp_id)
36
+
37
+ parent_temp = @temp_index[temp_id].payload.parent_temp_id
38
+ if parent_in_graph?(parent_temp)
39
+ if @visiting.include?(parent_temp)
40
+ record_cycle(parent_temp)
41
+ else
42
+ visit(parent_temp)
43
+ end
44
+ end
45
+
46
+ @stack.pop
47
+ @visiting.delete(temp_id)
48
+ @visited.add(temp_id)
49
+ end
50
+
51
+ def parent_in_graph?(parent_temp)
52
+ !Utils::Blank.blank?(parent_temp) && @temp_index.key?(parent_temp)
53
+ end
54
+
55
+ def record_cycle(parent_temp)
56
+ start_idx = @stack.index(parent_temp)
57
+ cycle_ids = @stack[start_idx..] + [parent_temp]
58
+ path_text = cycle_ids.map { |tid| display_name(tid) }.join(" -> ")
59
+
60
+ cycle_ids.each do |tid|
61
+ @in_cycle.add(tid)
62
+ @messages[tid] = "Cycle detected: #{path_text}"
63
+ end
64
+ end
65
+
66
+ def display_name(temp_id)
67
+ name = @temp_index[temp_id].payload.name.to_s
68
+ name.empty? ? temp_id : name
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../params"
4
+
5
+ module OmnifocusMcp
6
+ module Tools
7
+ module Operations
8
+ class BatchAddItems
9
+ # Build typed param objects for nested add primitives from a batch item.
10
+ module ParamBuilder
11
+ class << self
12
+ def project(payload)
13
+ Params::AddProjectParams.new(
14
+ name: payload.name,
15
+ note: payload.note,
16
+ due_date: payload.due_date,
17
+ defer_date: payload.defer_date,
18
+ flagged: payload.flagged,
19
+ estimated_minutes: payload.estimated_minutes,
20
+ tags: payload.tags,
21
+ folder_name: payload.folder_name,
22
+ sequential: payload.sequential
23
+ )
24
+ end
25
+
26
+ def task(payload, parent_task_id:, project_name:)
27
+ Params::AddTaskParams.new(
28
+ name: payload.name,
29
+ note: payload.note,
30
+ due_date: payload.due_date,
31
+ defer_date: payload.defer_date,
32
+ planned_date: payload.planned_date,
33
+ flagged: payload.flagged,
34
+ estimated_minutes: payload.estimated_minutes,
35
+ tags: payload.tags,
36
+ project_name: project_name,
37
+ parent_task_id: parent_task_id,
38
+ parent_task_name: payload.parent_task_name,
39
+ hierarchy_level: payload.hierarchy_level
40
+ )
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../../utils/blank"
4
+ require_relative "batch_item"
5
+ require_relative "cycle_detector"
6
+
7
+ module OmnifocusMcp
8
+ module Tools
9
+ module Operations
10
+ class BatchAddItems
11
+ # Resolves within-batch hierarchy metadata for batch add operations.
12
+ class Planner
13
+ Resolved = Data.define(:id, :type, :name)
14
+
15
+ def initialize(batch_items)
16
+ @batch_items = batch_items
17
+ @temp_resolved = {}
18
+ end
19
+
20
+ def prepare!
21
+ mark_cycle_failures(cycle_messages: cycle_detector.detect)
22
+ mark_unknown_parent_temp_id
23
+ end
24
+
25
+ # Stable order: by hierarchy_level (nil -> 0), then original index.
26
+ def processing_order
27
+ @batch_items.sort_by { |item| [item.payload.hierarchy_level || 0, item.index] }
28
+ end
29
+
30
+ # @return [Array(String?, String?, Boolean)] (parent_task_id,
31
+ # project_name, ready). ready is false when the task depends on a
32
+ # parent_temp_id that has not resolved yet, signalling a deferral.
33
+ def resolve_task_parent(payload)
34
+ parent_task_id = payload.parent_task_id
35
+ project_name = payload.project_name
36
+
37
+ return [parent_task_id, project_name, true] unless need_temp_resolution?(payload)
38
+
39
+ resolved = @temp_resolved[payload.parent_temp_id]
40
+ return [parent_task_id, project_name, false] unless resolved
41
+
42
+ if resolved.type == "project"
43
+ [parent_task_id, resolved.name, true]
44
+ else
45
+ [resolved.id, project_name, true]
46
+ end
47
+ end
48
+
49
+ def record_resolution(payload:, id:, type:)
50
+ temp_id = payload.temp_id
51
+ return if Utils::Blank.blank?(temp_id)
52
+
53
+ @temp_resolved[temp_id] = Resolved.new(id: id, type: type, name: payload.name)
54
+ end
55
+
56
+ def finalize_unresolved!
57
+ @batch_items.each do |item|
58
+ next unless item.pending?
59
+
60
+ item.fail!(unresolved_reason(item))
61
+ end
62
+ end
63
+
64
+ private
65
+
66
+ def temp_index
67
+ @temp_index ||= @batch_items.each_with_object({}) do |item, index|
68
+ temp = item.payload.temp_id
69
+ index[temp] = item unless Utils::Blank.blank?(temp)
70
+ end
71
+ end
72
+
73
+ def cycle_detector
74
+ CycleDetector.new(temp_index)
75
+ end
76
+
77
+ def mark_cycle_failures(cycle_messages:)
78
+ cycle_messages.each { |temp_id, message| temp_index[temp_id].fail!(message) }
79
+ end
80
+
81
+ def mark_unknown_parent_temp_id
82
+ @batch_items.each do |item|
83
+ next unless item.pending?
84
+
85
+ parent_temp = item.payload.parent_temp_id
86
+ next if Utils::Blank.blank?(parent_temp)
87
+ next if temp_index.key?(parent_temp)
88
+ next unless Utils::Blank.blank?(item.payload.parent_task_id)
89
+
90
+ item.fail!("Unknown parentTempId: #{parent_temp}")
91
+ end
92
+ end
93
+
94
+ def need_temp_resolution?(payload)
95
+ Utils::Blank.blank?(payload.parent_task_id) &&
96
+ !Utils::Blank.blank?(payload.parent_temp_id)
97
+ end
98
+
99
+ def unresolved_reason(item)
100
+ parent_temp = item.payload.parent_temp_id
101
+ if !Utils::Blank.blank?(parent_temp) && !@temp_resolved.key?(parent_temp)
102
+ "Unresolved parentTempId: #{parent_temp}"
103
+ else
104
+ "Unresolved dependency or cycle"
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../infrastructure/script_runner"
4
+ require_relative "../../result"
5
+ require_relative "../params"
6
+ require_relative "add_omnifocus_task"
7
+ require_relative "add_project"
8
+ require_relative "batch_add_items/planner"
9
+ require_relative "batch_add_items/batch_item"
10
+ require_relative "batch_add_items/bulk_executor"
11
+ require_relative "batch_add_items/param_builder"
12
+
13
+ module OmnifocusMcp
14
+ module Tools
15
+ module Operations
16
+ class BatchAddItems
17
+ class << self
18
+ def call(items, add_task: Operations::AddOmniFocusTask.method(:call),
19
+ add_project: Operations::AddProject.method(:call),
20
+ execute_applescript: Infrastructure::ScriptRunner.method(:execute_applescript),
21
+ bulk_executor: BulkExecutor)
22
+ new(add_task:, add_project:, execute_applescript:, bulk_executor:).call(items)
23
+ end
24
+ end
25
+
26
+ def initialize(add_task:, add_project:, execute_applescript:, bulk_executor:)
27
+ @add_task = add_task
28
+ @add_project = add_project
29
+ @execute_applescript = execute_applescript
30
+ @bulk_executor = bulk_executor
31
+ end
32
+
33
+ def call(items)
34
+ batch_items = Array(items).map { |item| coerce_item(item) }
35
+ .then { |coerced| build_batch_items(coerced) }
36
+ planner = Planner.new(batch_items).tap(&:prepare!)
37
+
38
+ return OmnifocusMcp::Result.ok(batch_items.map(&:result)) if try_bulk_add!(batch_items:)
39
+
40
+ process_items(ordered: planner.processing_order, planner:)
41
+ planner.finalize_unresolved!
42
+
43
+ OmnifocusMcp::Result.ok(batch_items.map(&:result))
44
+ rescue StandardError => e
45
+ OmnifocusMcp.logger.warn("[batch_add_items] Error: #{e}")
46
+ OmnifocusMcp::Result.error(e.message || "Unknown error in batch_add_items")
47
+ end
48
+
49
+ private
50
+
51
+ attr_reader :add_task, :add_project, :execute_applescript, :bulk_executor
52
+
53
+ def try_bulk_add!(batch_items:)
54
+ bulk_results = bulk_executor.run(batch_items, execute_applescript:)
55
+ return false unless bulk_results
56
+
57
+ batch_items.select(&:pending?)
58
+ .each_with_index do |batch_item, index|
59
+ apply_bulk_result(batch_item, bulk_results[index])
60
+ end
61
+ true
62
+ rescue StandardError => e
63
+ OmnifocusMcp.logger.warn("[batch_add_items] bulk path failed (#{e.message}); falling back to sequential")
64
+ false
65
+ end
66
+
67
+ def apply_bulk_result(batch_item, result)
68
+ if result.ok?
69
+ batch_item.succeed!(result.ok)
70
+ else
71
+ batch_item.fail!(result.error)
72
+ end
73
+ end
74
+
75
+ def coerce_item(item)
76
+ case item
77
+ when Params::BatchAddItemParams then item
78
+ when Hash then Params::BatchAddItemParams.from_hash(item)
79
+ else raise ArgumentError, "expected BatchAddItemParams or Hash, got #{item.class}"
80
+ end
81
+ end
82
+
83
+ def build_batch_items(items)
84
+ items.each_with_index.map { |payload, index| BatchItem.new(payload:, index:) }
85
+ end
86
+
87
+ def process_items(ordered:, planner:)
88
+ loop do
89
+ pending = ordered.select(&:pending?)
90
+ break if pending.empty?
91
+
92
+ process_pending_items(pending:, planner:)
93
+ break if ordered.count(&:pending?) == pending.length
94
+ end
95
+ end
96
+
97
+ def process_pending_items(pending:, planner:)
98
+ total = pending.length
99
+ pending.each_with_index do |batch_item, index|
100
+ OmnifocusMcp.logger.warn(
101
+ "[batch_add_items] sequential progress #{index + 1}/#{total}: #{batch_item.payload.name}"
102
+ )
103
+ process_single_item(batch_item:, planner:)
104
+ end
105
+ end
106
+
107
+ def process_single_item(batch_item:, planner:)
108
+ if batch_item.payload.type.to_s == "project"
109
+ run_project(batch_item:, planner:)
110
+ else
111
+ run_task(batch_item:, planner:)
112
+ end
113
+ rescue StandardError => e
114
+ batch_item.fail!(e.message || "Unknown error processing item")
115
+ end
116
+
117
+ def run_project(batch_item:, planner:)
118
+ payload = batch_item.payload
119
+ project_result = add_project.call(ParamBuilder.project(payload))
120
+
121
+ if project_result.error?
122
+ batch_item.fail!(project_result.error)
123
+ else
124
+ project_id = project_result.ok.project_id
125
+ batch_item.succeed!(project_id)
126
+ planner.record_resolution(payload:, id: project_id, type: "project")
127
+ end
128
+ end
129
+
130
+ def run_task(batch_item:, planner:)
131
+ payload = batch_item.payload
132
+ parent_task_id, project_name, ready = planner.resolve_task_parent(payload)
133
+ return unless ready
134
+
135
+ params = ParamBuilder.task(payload, parent_task_id:, project_name:)
136
+ task_result = add_task.call(params)
137
+
138
+ if task_result.error?
139
+ batch_item.fail!(task_result.error)
140
+ else
141
+ task_id = task_result.ok.task_id
142
+ batch_item.succeed!(task_id)
143
+ planner.record_resolution(payload:, id: task_id, type: "task")
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../result"
4
+ require_relative "../params"
5
+ require_relative "remove_item"
6
+
7
+ module OmnifocusMcp
8
+ module Tools
9
+ module Operations
10
+ class BatchRemoveItems
11
+ class << self
12
+ def call(items, remove: Operations::RemoveItem.method(:call))
13
+ new(remove:).call(items)
14
+ end
15
+ end
16
+
17
+ def initialize(remove:)
18
+ @remove = remove
19
+ end
20
+
21
+ def call(items)
22
+ Array(items).map { |item| remove_one(coerce_item(item)) }
23
+ .then { |results| OmnifocusMcp::Result.ok(results) }
24
+ rescue StandardError => e
25
+ OmnifocusMcp.logger.warn("[batch_remove_items] Error: #{e}")
26
+ OmnifocusMcp::Result.error(e.message || "Unknown error in batch_remove_items")
27
+ end
28
+
29
+ private
30
+
31
+ attr_reader :remove
32
+
33
+ def coerce_item(item)
34
+ case item
35
+ when Params::BatchRemoveItemParams then item
36
+ when Hash then Params::BatchRemoveItemParams.from_hash(item)
37
+ else raise ArgumentError, "expected BatchRemoveItemParams or Hash, got #{item.class}"
38
+ end
39
+ end
40
+
41
+ def remove_one(item)
42
+ remove.call(item)
43
+ rescue StandardError => e
44
+ OmnifocusMcp::Result.error(e.message || "Unknown error processing item")
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../infrastructure/script_runner"
4
+ require_relative "../../result"
5
+ require_relative "../generators/database_stats"
6
+
7
+ module OmnifocusMcp
8
+ module Tools
9
+ module Operations
10
+ class DatabaseStats
11
+ class << self
12
+ def get_database_stats(script_runner: Infrastructure::ScriptRunner)
13
+ new(script_runner:).get_database_stats
14
+ end
15
+
16
+ def get_changes_since(since, script_runner: Infrastructure::ScriptRunner)
17
+ new(script_runner:).get_changes_since(since)
18
+ end
19
+ end
20
+
21
+ def initialize(script_runner: Infrastructure::ScriptRunner, generator: Generators::DatabaseStats)
22
+ @script_runner = script_runner
23
+ @generator = generator
24
+ end
25
+
26
+ def get_database_stats
27
+ script_payload_result(script_runner.execute_omnifocus_source(generator.stats_script))
28
+ end
29
+
30
+ def get_changes_since(since)
31
+ iso = since.respond_to?(:utc) ? since.utc.iso8601 : since.to_s
32
+
33
+ script_payload_result(script_runner.execute_omnifocus_source(generator.changes_script(iso)))
34
+ end
35
+
36
+ private
37
+
38
+ attr_reader :script_runner, :generator
39
+
40
+ def script_payload_result(execution)
41
+ execution.and_then do |payload|
42
+ if payload.is_a?(Hash) && payload["error"]
43
+ Result.error(payload["error"])
44
+ else
45
+ Result.ok(payload)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,79 @@
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/edit_item"
7
+ require_relative "../params"
8
+
9
+ module OmnifocusMcp
10
+ module Tools
11
+ module Operations
12
+ class EditItem
13
+ Edited = Generators::EditItem::Edited
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::EditItem.generate_apple_script(...)
21
+
22
+ private
23
+
24
+ def merge_params(params, kwargs)
25
+ return params || {} if kwargs.empty?
26
+
27
+ base = params.respond_to?(:to_h) ? params.to_h : params || {}
28
+ base.merge(kwargs)
29
+ end
30
+ end
31
+
32
+ def initialize(script_runner: Infrastructure::ScriptRunner, generator: Generators::EditItem)
33
+ @script_runner = script_runner
34
+ @generator = generator
35
+ end
36
+
37
+ def call(params)
38
+ params = Params::McpBoundary.coerce(Params::EditItemParams, params)
39
+ generator.generate_apple_script(params).then { |script| run_script(script) }
40
+ rescue StandardError => e
41
+ OmnifocusMcp.logger.warn("[edit_item] Error: #{e}")
42
+ OmnifocusMcp::Result.error(e.message || "Unknown error in edit_item")
43
+ end
44
+
45
+ private
46
+
47
+ attr_reader :script_runner, :generator
48
+
49
+ def run_script(script)
50
+ stdout, stderr, status = script_runner.execute_applescript(script)
51
+
52
+ OmnifocusMcp.logger.warn("[edit_item] AppleScript stderr: #{stderr}") if stderr && !stderr.empty?
53
+ return OmnifocusMcp::Result.error(applescript_run_failure(stderr:, status:)) unless status.success?
54
+
55
+ parse_result(stdout)
56
+ end
57
+
58
+ def parse_result(stdout)
59
+ Parsers::AppleScriptEnvelope.parse(stdout:, default_error: "Unknown error in edit_item") do |hash|
60
+ OmnifocusMcp::Result.ok(
61
+ Edited.new(
62
+ id: hash["id"],
63
+ name: hash["name"],
64
+ changed_properties: hash["changedProperties"]
65
+ )
66
+ )
67
+ end
68
+ end
69
+
70
+ def applescript_run_failure(stderr:, status:)
71
+ exit_code = status.respond_to?(:exitstatus) ? status.exitstatus : status
72
+ message = "osascript failed (exit #{exit_code})"
73
+ message += ": #{stderr.strip}" unless stderr.nil? || stderr.empty?
74
+ message
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end