omnifocus_mcp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/AGENTS.md +15 -0
- data/CHANGELOG.md +7 -0
- data/CODE_OF_CONDUCT.md +16 -0
- data/LICENSE.txt +21 -0
- data/README.md +147 -0
- data/Rakefile +12 -0
- data/bin/omnifocus-mcp +7 -0
- data/lib/omnifocus_mcp/config.rb +18 -0
- data/lib/omnifocus_mcp/infrastructure/.keep +1 -0
- data/lib/omnifocus_mcp/infrastructure/apple_script.rb +263 -0
- data/lib/omnifocus_mcp/infrastructure/apple_script_date_builder.rb +65 -0
- data/lib/omnifocus_mcp/infrastructure/js_embed.rb +39 -0
- data/lib/omnifocus_mcp/infrastructure/script_runner.rb +254 -0
- data/lib/omnifocus_mcp/infrastructure.rb +6 -0
- data/lib/omnifocus_mcp/json_rpc_compat.rb +75 -0
- data/lib/omnifocus_mcp/logger.rb +34 -0
- data/lib/omnifocus_mcp/mcp.rb +74 -0
- data/lib/omnifocus_mcp/parsers/.keep +1 -0
- data/lib/omnifocus_mcp/parsers/apple_script_envelope.rb +44 -0
- data/lib/omnifocus_mcp/parsers.rb +6 -0
- data/lib/omnifocus_mcp/resources/base.rb +87 -0
- data/lib/omnifocus_mcp/resources/flagged_resource.rb +31 -0
- data/lib/omnifocus_mcp/resources/inbox_resource.rb +31 -0
- data/lib/omnifocus_mcp/resources/perspective_resource.rb +28 -0
- data/lib/omnifocus_mcp/resources/project_resource.rb +37 -0
- data/lib/omnifocus_mcp/resources/stats_resource.rb +22 -0
- data/lib/omnifocus_mcp/resources/today_resource.rb +37 -0
- data/lib/omnifocus_mcp/result.rb +108 -0
- data/lib/omnifocus_mcp/tools/batch_report.rb +9 -0
- data/lib/omnifocus_mcp/tools/database_stats.rb +184 -0
- data/lib/omnifocus_mcp/tools/definitions/add_omnifocus_task_tool.rb +61 -0
- data/lib/omnifocus_mcp/tools/definitions/add_project_tool.rb +54 -0
- data/lib/omnifocus_mcp/tools/definitions/batch_add_items_tool.rb +105 -0
- data/lib/omnifocus_mcp/tools/definitions/batch_remove_items_tool.rb +68 -0
- data/lib/omnifocus_mcp/tools/definitions/date_formatter.rb +45 -0
- data/lib/omnifocus_mcp/tools/definitions/edit_item_tool.rb +87 -0
- data/lib/omnifocus_mcp/tools/definitions/get_perspective_view_tool.rb +57 -0
- data/lib/omnifocus_mcp/tools/definitions/key_normalizer.rb +30 -0
- data/lib/omnifocus_mcp/tools/definitions/list_perspectives_tool.rb +47 -0
- data/lib/omnifocus_mcp/tools/definitions/list_tags_tool.rb +42 -0
- data/lib/omnifocus_mcp/tools/definitions/mcp_envelope.rb +31 -0
- data/lib/omnifocus_mcp/tools/definitions/operation_factory.rb +33 -0
- data/lib/omnifocus_mcp/tools/definitions/query_omnifocus_tool.rb +187 -0
- data/lib/omnifocus_mcp/tools/definitions/remove_item_tool.rb +55 -0
- data/lib/omnifocus_mcp/tools/generators/.keep +1 -0
- data/lib/omnifocus_mcp/tools/generators/add_omnifocus_task.rb +348 -0
- data/lib/omnifocus_mcp/tools/generators/add_project.rb +141 -0
- data/lib/omnifocus_mcp/tools/generators/database_stats.rb +16 -0
- data/lib/omnifocus_mcp/tools/generators/edit_item.rb +455 -0
- data/lib/omnifocus_mcp/tools/generators/list_perspectives.rb +13 -0
- data/lib/omnifocus_mcp/tools/generators/list_tags.rb +13 -0
- data/lib/omnifocus_mcp/tools/generators/perspective_view.rb +17 -0
- data/lib/omnifocus_mcp/tools/generators/query_omnifocus.rb +571 -0
- data/lib/omnifocus_mcp/tools/generators/query_omnifocus_debug.rb +169 -0
- data/lib/omnifocus_mcp/tools/generators/remove_item.rb +61 -0
- data/lib/omnifocus_mcp/tools/generators.rb +8 -0
- data/lib/omnifocus_mcp/tools/messages/add_omnifocus_task.rb +53 -0
- data/lib/omnifocus_mcp/tools/messages/add_project.rb +28 -0
- data/lib/omnifocus_mcp/tools/messages/batch_remove_items.rb +13 -0
- data/lib/omnifocus_mcp/tools/messages/edit_item.rb +39 -0
- data/lib/omnifocus_mcp/tools/messages/list_tools.rb +15 -0
- data/lib/omnifocus_mcp/tools/messages/remove_item.rb +42 -0
- data/lib/omnifocus_mcp/tools/messages.rb +8 -0
- data/lib/omnifocus_mcp/tools/operations/add_omnifocus_task.rb +74 -0
- data/lib/omnifocus_mcp/tools/operations/add_project.rb +75 -0
- data/lib/omnifocus_mcp/tools/operations/batch_add_items/batch_item.rb +38 -0
- data/lib/omnifocus_mcp/tools/operations/batch_add_items/bulk_executor.rb +94 -0
- data/lib/omnifocus_mcp/tools/operations/batch_add_items/cycle_detector.rb +74 -0
- data/lib/omnifocus_mcp/tools/operations/batch_add_items/param_builder.rb +47 -0
- data/lib/omnifocus_mcp/tools/operations/batch_add_items/planner.rb +111 -0
- data/lib/omnifocus_mcp/tools/operations/batch_add_items.rb +149 -0
- data/lib/omnifocus_mcp/tools/operations/batch_remove_items.rb +49 -0
- data/lib/omnifocus_mcp/tools/operations/database_stats.rb +52 -0
- data/lib/omnifocus_mcp/tools/operations/edit_item.rb +79 -0
- data/lib/omnifocus_mcp/tools/operations/get_perspective_view.rb +112 -0
- data/lib/omnifocus_mcp/tools/operations/list_perspectives.rb +85 -0
- data/lib/omnifocus_mcp/tools/operations/list_tags.rb +80 -0
- data/lib/omnifocus_mcp/tools/operations/query_omnifocus.rb +74 -0
- data/lib/omnifocus_mcp/tools/operations/query_omnifocus_debug.rb +63 -0
- data/lib/omnifocus_mcp/tools/operations/remove_item.rb +75 -0
- data/lib/omnifocus_mcp/tools/operations.rb +8 -0
- data/lib/omnifocus_mcp/tools/params/mcp_boundary.rb +41 -0
- data/lib/omnifocus_mcp/tools/params.rb +106 -0
- data/lib/omnifocus_mcp/tools/presenters/batch_report.rb +55 -0
- data/lib/omnifocus_mcp/tools/presenters/list_perspectives.rb +33 -0
- data/lib/omnifocus_mcp/tools/presenters/list_tags.rb +49 -0
- data/lib/omnifocus_mcp/tools/presenters/perspective_view.rb +81 -0
- data/lib/omnifocus_mcp/tools/presenters/query_reply.rb +52 -0
- data/lib/omnifocus_mcp/tools/presenters/query_results.rb +183 -0
- data/lib/omnifocus_mcp/tools/presenters.rb +8 -0
- data/lib/omnifocus_mcp/tools/query_omnifocus_formatter.rb +9 -0
- data/lib/omnifocus_mcp/tools/query_statuses.rb +22 -0
- data/lib/omnifocus_mcp/utils/apple_script.rb +9 -0
- data/lib/omnifocus_mcp/utils/apple_script_envelope.rb +9 -0
- data/lib/omnifocus_mcp/utils/apple_script_helpers.rb +9 -0
- data/lib/omnifocus_mcp/utils/blank.rb +26 -0
- data/lib/omnifocus_mcp/utils/date_filter.rb +76 -0
- data/lib/omnifocus_mcp/utils/date_formatting.rb +9 -0
- data/lib/omnifocus_mcp/utils/iso_date.rb +27 -0
- data/lib/omnifocus_mcp/utils/omnifocus_scripts/getPerspectiveView.js +472 -0
- data/lib/omnifocus_mcp/utils/omnifocus_scripts/listPerspectives.js +59 -0
- data/lib/omnifocus_mcp/utils/omnifocus_scripts/listTags.js +58 -0
- data/lib/omnifocus_mcp/utils/omnifocus_scripts/omnifocusDump.js +223 -0
- data/lib/omnifocus_mcp/utils/script_execution.rb +9 -0
- data/lib/omnifocus_mcp/version.rb +5 -0
- data/lib/omnifocus_mcp.rb +102 -0
- metadata +166 -0
|
@@ -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
|