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,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 @@
|
|
|
1
|
+
|
|
@@ -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
|