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,571 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../result"
|
|
4
|
+
require_relative "../../infrastructure/js_embed"
|
|
5
|
+
require_relative "../../infrastructure/script_runner"
|
|
6
|
+
require_relative "../query_statuses"
|
|
7
|
+
require_relative "../params"
|
|
8
|
+
|
|
9
|
+
module OmnifocusMcp
|
|
10
|
+
module Tools
|
|
11
|
+
module Generators
|
|
12
|
+
# Read-side primitive: build and execute a JXA/OmniJS script that walks
|
|
13
|
+
# the OmniFocus database and returns matching tasks/projects/folders.
|
|
14
|
+
#
|
|
15
|
+
# camelCase MCP arguments are translated to idiomatic snake_case at the
|
|
16
|
+
# tool boundary (see {Tools::Params::QueryOmnifocusParams.from_mcp});
|
|
17
|
+
# this primitive only ever sees {Tools::Params::QueryOmnifocusParams}.
|
|
18
|
+
#
|
|
19
|
+
# Returns an {OmnifocusMcp::Result} whose +ok+ payload is a {Match} with
|
|
20
|
+
# +items+ (Array, +nil+ when called with +summary: true+) and +count+
|
|
21
|
+
# (Integer, may be +nil+ when the script omits it).
|
|
22
|
+
class QueryOmnifocus
|
|
23
|
+
Match = Data.define(:items, :count)
|
|
24
|
+
|
|
25
|
+
# Maps API/tool sort field names to OmniFocus JS property names on raw items.
|
|
26
|
+
SORT_FIELD_ALIASES = {
|
|
27
|
+
"name" => "name",
|
|
28
|
+
"dueDate" => "dueDate",
|
|
29
|
+
"deferDate" => "deferDate",
|
|
30
|
+
"plannedDate" => "plannedDate",
|
|
31
|
+
"estimatedMinutes" => "estimatedMinutes",
|
|
32
|
+
"taskStatus" => "taskStatus",
|
|
33
|
+
"status" => "status",
|
|
34
|
+
"flagged" => "flagged",
|
|
35
|
+
"modified" => "modified",
|
|
36
|
+
"modificationDate" => "modified",
|
|
37
|
+
"added" => "added",
|
|
38
|
+
"creationDate" => "added"
|
|
39
|
+
}.freeze
|
|
40
|
+
private_constant :SORT_FIELD_ALIASES
|
|
41
|
+
|
|
42
|
+
class << self
|
|
43
|
+
def escape_jxa(str) = Infrastructure::JsEmbed.double_quoted_string(str)
|
|
44
|
+
|
|
45
|
+
# Run the generated query against OmniFocus and return a Result.
|
|
46
|
+
def call(params)
|
|
47
|
+
require_relative "../operations/query_omnifocus"
|
|
48
|
+
|
|
49
|
+
Operations::QueryOmnifocus.call(params)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The OmniJS script emits one of:
|
|
53
|
+
# { items:, count:, error: null } — normal listing
|
|
54
|
+
# { count:, error: null } — summary mode (no items field)
|
|
55
|
+
# { error: "...", items: [], count: 0 } — JS try/catch path
|
|
56
|
+
# so the +error+ branch must be checked first; the JS payload always
|
|
57
|
+
# carries +items+/+count+ alongside +error+ when it fails.
|
|
58
|
+
#
|
|
59
|
+
# JSON.parse hands us String keys; pattern matching wants Symbols. A
|
|
60
|
+
# non-Hash response (e.g. raw stdout from a parse failure) becomes
|
|
61
|
+
# +nil+ here so the +in nil+ arm can surface it as an error.
|
|
62
|
+
def classify_response(response:, summary:)
|
|
63
|
+
shape = response.is_a?(Hash) ? response.transform_keys(&:to_sym) : nil
|
|
64
|
+
|
|
65
|
+
case shape
|
|
66
|
+
in { error: String => msg }
|
|
67
|
+
OmnifocusMcp::Result.error(msg)
|
|
68
|
+
in { items: Array => items, count: Integer => count }
|
|
69
|
+
OmnifocusMcp::Result.ok(build_match(items:, count:, summary: summary))
|
|
70
|
+
in { count: Integer => count }
|
|
71
|
+
OmnifocusMcp::Result.ok(Match.new(items: nil, count: count))
|
|
72
|
+
in Hash
|
|
73
|
+
OmnifocusMcp::Result.ok(Match.new(items: nil, count: nil))
|
|
74
|
+
in nil
|
|
75
|
+
OmnifocusMcp::Result.error("Unexpected response from queryOmnifocus: #{response.inspect}")
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Build a {Match} from a normal items+count payload. In +summary+ mode
|
|
80
|
+
# the items array is dropped; otherwise the array is passed through
|
|
81
|
+
# verbatim.
|
|
82
|
+
def build_match(items:, count:, summary:) = Match.new(items: summary ? nil : items, count: count)
|
|
83
|
+
|
|
84
|
+
# Build the full JXA query script for the given params.
|
|
85
|
+
def generate_query_script(params)
|
|
86
|
+
params = Params::McpBoundary.coerce(Params::QueryOmnifocusParams, params)
|
|
87
|
+
entity = params.entity.to_s
|
|
88
|
+
filters = params.filters || {}
|
|
89
|
+
fields = params.fields
|
|
90
|
+
limit = params.limit
|
|
91
|
+
sort_by = params.sort_by
|
|
92
|
+
sort_order = params.sort_order
|
|
93
|
+
include_completed = params.include_completed == true
|
|
94
|
+
summary = params.summary == true
|
|
95
|
+
|
|
96
|
+
filter_conditions = generate_filter_conditions(entity:, filters:)
|
|
97
|
+
field_mapping = generate_field_mapping(entity, fields:)
|
|
98
|
+
sort_property = resolve_sort_field(sort_by)
|
|
99
|
+
sort_logic = sort_property ? generate_sort_logic(sort_property, sort_order:) : ""
|
|
100
|
+
limit_logic = limit.is_a?(Integer) && limit.positive? ? "filtered = filtered.slice(0, #{limit});" : ""
|
|
101
|
+
|
|
102
|
+
build_query_script(
|
|
103
|
+
entity: entity,
|
|
104
|
+
include_completed: include_completed,
|
|
105
|
+
summary: summary,
|
|
106
|
+
filter_conditions: filter_conditions,
|
|
107
|
+
field_mapping: field_mapping,
|
|
108
|
+
sort_logic: sort_logic,
|
|
109
|
+
limit_logic: limit_logic
|
|
110
|
+
)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Build the per-item filter expressions for the JXA `filter` body.
|
|
114
|
+
def generate_filter_conditions(entity:, filters:)
|
|
115
|
+
f = filters || {}
|
|
116
|
+
conditions = []
|
|
117
|
+
|
|
118
|
+
if entity == "tasks"
|
|
119
|
+
apply_task_name_filters(filters: f, conditions:)
|
|
120
|
+
apply_task_id_filters(filters: f, conditions:)
|
|
121
|
+
apply_tag_status_filters(filters: f, conditions:, entity: "tasks")
|
|
122
|
+
apply_task_date_filters(filters: f, conditions:)
|
|
123
|
+
apply_task_misc_filters(filters: f, conditions:)
|
|
124
|
+
elsif entity == "projects"
|
|
125
|
+
apply_project_folder_filter(filters: f, conditions:)
|
|
126
|
+
apply_tag_status_filters(filters: f, conditions:, entity: "projects")
|
|
127
|
+
apply_project_date_filters(filters: f, conditions:)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
conditions.join("\n")
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Generate a JXA `filtered.sort(...)` block keyed by a whitelisted property name.
|
|
134
|
+
def generate_sort_logic(sort_property, sort_order: nil)
|
|
135
|
+
order = sort_order.to_s == "desc" ? -1 : 1
|
|
136
|
+
|
|
137
|
+
<<~JS
|
|
138
|
+
filtered.sort((a, b) => {
|
|
139
|
+
let aVal = a.#{sort_property};
|
|
140
|
+
let bVal = b.#{sort_property};
|
|
141
|
+
|
|
142
|
+
// Handle null/undefined values
|
|
143
|
+
if (aVal == null && bVal == null) return 0;
|
|
144
|
+
if (aVal == null) return 1;
|
|
145
|
+
if (bVal == null) return -1;
|
|
146
|
+
|
|
147
|
+
// Compare based on type
|
|
148
|
+
if (typeof aVal === 'string') {
|
|
149
|
+
return aVal.localeCompare(bVal) * #{order};
|
|
150
|
+
} else if (aVal instanceof Date) {
|
|
151
|
+
return (aVal.getTime() - bVal.getTime()) * #{order};
|
|
152
|
+
} else {
|
|
153
|
+
return (aVal - bVal) * #{order};
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
JS
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Build the per-item field-projection block. With no `fields` array
|
|
160
|
+
# (nil or empty), returns the default field set for the entity.
|
|
161
|
+
# Otherwise builds explicit mappings for each requested field.
|
|
162
|
+
def generate_field_mapping(entity, fields: nil)
|
|
163
|
+
if fields.nil? || fields.empty?
|
|
164
|
+
return default_task_mapping if entity == "tasks"
|
|
165
|
+
return default_project_mapping if entity == "projects"
|
|
166
|
+
return default_folder_mapping if entity == "folders"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
mappings = fields.map { |field| field_mapping_for(field) }.join(",\n ")
|
|
170
|
+
|
|
171
|
+
<<~JS
|
|
172
|
+
return {
|
|
173
|
+
#{mappings}
|
|
174
|
+
};
|
|
175
|
+
JS
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def apply_task_name_filters(filters:, conditions:)
|
|
179
|
+
if filters[:project_name]
|
|
180
|
+
safe_name = escape_jxa(filters[:project_name].to_s.downcase)
|
|
181
|
+
conditions << <<~JS
|
|
182
|
+
if (item.containingProject) {
|
|
183
|
+
const projectName = item.containingProject.name.toLowerCase();
|
|
184
|
+
if (!projectName.includes("#{safe_name}")) return false;
|
|
185
|
+
} else if ("#{safe_name}" !== "inbox") {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
JS
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
return unless filters[:task_name]
|
|
192
|
+
|
|
193
|
+
safe_name = escape_jxa(filters[:task_name].to_s.downcase)
|
|
194
|
+
conditions << <<~JS
|
|
195
|
+
const taskName = (item.name || "").toLowerCase();
|
|
196
|
+
if (!taskName.includes("#{safe_name}")) return false;
|
|
197
|
+
JS
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# rubocop:disable Metrics/MethodLength
|
|
201
|
+
def apply_task_id_filters(filters:, conditions:)
|
|
202
|
+
if filters[:project_id]
|
|
203
|
+
safe_id = escape_jxa(filters[:project_id])
|
|
204
|
+
conditions << <<~JS
|
|
205
|
+
if (!item.containingProject ||
|
|
206
|
+
item.containingProject.id.primaryKey !== "#{safe_id}") {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
JS
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
return unless filters[:folder_id]
|
|
213
|
+
|
|
214
|
+
conditions << <<~JS
|
|
215
|
+
{
|
|
216
|
+
const targetFolderId = "#{escape_jxa(filters[:folder_id])}";
|
|
217
|
+
let matchesFolder = false;
|
|
218
|
+
if (item.containingProject && item.containingProject.parentFolder) {
|
|
219
|
+
let folder = item.containingProject.parentFolder;
|
|
220
|
+
while (folder) {
|
|
221
|
+
if (folder.id.primaryKey === targetFolderId) {
|
|
222
|
+
matchesFolder = true;
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
folder = folder.parentFolder;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (!matchesFolder) return false;
|
|
229
|
+
}
|
|
230
|
+
JS
|
|
231
|
+
end
|
|
232
|
+
# rubocop:enable Metrics/MethodLength
|
|
233
|
+
|
|
234
|
+
def apply_tag_status_filters(filters:, conditions:, entity:)
|
|
235
|
+
if entity == "tasks" && filters[:tags] && !filters[:tags].empty?
|
|
236
|
+
tag_condition = filters[:tags].map do |tag|
|
|
237
|
+
%(item.tags.some(t => t.name === "#{escape_jxa(tag)}"))
|
|
238
|
+
end.join(" || ")
|
|
239
|
+
conditions << "if (!(#{tag_condition})) return false;"
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
return unless filters[:status] && !filters[:status].empty?
|
|
243
|
+
|
|
244
|
+
status_map = if entity == "tasks"
|
|
245
|
+
"taskStatusMap[item.taskStatus]"
|
|
246
|
+
else
|
|
247
|
+
"projectStatusMap[item.status]"
|
|
248
|
+
end
|
|
249
|
+
status_condition = filters[:status].map { |s| %(#{status_map} === "#{escape_jxa(s)}") }.join(" || ")
|
|
250
|
+
conditions << "if (!(#{status_condition})) return false;"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def apply_task_date_filters(filters:, conditions:)
|
|
254
|
+
conditions << "if (item.flagged !== #{filters[:flagged]}) return false;" unless filters[:flagged].nil?
|
|
255
|
+
|
|
256
|
+
push_within(conditions:, field: "dueDate", value: filters[:due_within])
|
|
257
|
+
push_within(conditions:, field: "plannedDate", value: filters[:planned_within])
|
|
258
|
+
push_within(conditions:, field: "deferDate", value: filters[:deferred_until])
|
|
259
|
+
|
|
260
|
+
push_same_day(conditions:, field: "dueDate", value: filters[:due_on])
|
|
261
|
+
push_same_day(conditions:, field: "deferDate", value: filters[:defer_on])
|
|
262
|
+
push_same_day(conditions:, field: "plannedDate", value: filters[:planned_on])
|
|
263
|
+
|
|
264
|
+
push_within_past(conditions:, field: "added", value: filters[:added_within])
|
|
265
|
+
push_same_day(conditions:, field: "added", value: filters[:added_on])
|
|
266
|
+
|
|
267
|
+
push_within_past(conditions:, field: "completionDate", value: filters[:completed_within])
|
|
268
|
+
push_same_day(conditions:, field: "completionDate", value: filters[:completed_on])
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def apply_task_misc_filters(filters:, conditions:)
|
|
272
|
+
unless filters[:is_repeating].nil?
|
|
273
|
+
conditions << if filters[:is_repeating]
|
|
274
|
+
"if (item.repetitionRule === null) return false;"
|
|
275
|
+
else
|
|
276
|
+
"if (item.repetitionRule !== null) return false;"
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
unless filters[:has_note].nil?
|
|
281
|
+
conditions << <<~JS
|
|
282
|
+
const hasNote = item.note && item.note.trim().length > 0;
|
|
283
|
+
if (hasNote !== #{filters[:has_note]}) return false;
|
|
284
|
+
JS
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
return if filters[:inbox].nil?
|
|
288
|
+
|
|
289
|
+
conditions << if filters[:inbox]
|
|
290
|
+
"if (!item.inInbox) return false;"
|
|
291
|
+
else
|
|
292
|
+
"if (item.inInbox) return false;"
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def apply_project_folder_filter(filters:, conditions:)
|
|
297
|
+
return unless filters[:folder_id]
|
|
298
|
+
|
|
299
|
+
conditions << <<~JS
|
|
300
|
+
{
|
|
301
|
+
const targetFolderId = "#{escape_jxa(filters[:folder_id])}";
|
|
302
|
+
let matchesFolder = false;
|
|
303
|
+
if (item.parentFolder) {
|
|
304
|
+
let folder = item.parentFolder;
|
|
305
|
+
while (folder) {
|
|
306
|
+
if (folder.id.primaryKey === targetFolderId) {
|
|
307
|
+
matchesFolder = true;
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
folder = folder.parentFolder;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (!matchesFolder) return false;
|
|
314
|
+
}
|
|
315
|
+
JS
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def apply_project_date_filters(filters:, conditions:)
|
|
319
|
+
push_within_past(conditions:, field: "added", value: filters[:added_within])
|
|
320
|
+
push_same_day(conditions:, field: "added", value: filters[:added_on])
|
|
321
|
+
push_within_past(conditions:, field: "completionDate", value: filters[:completed_within])
|
|
322
|
+
push_same_day(conditions:, field: "completionDate", value: filters[:completed_on])
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Forward-looking date filter: passes if the date exists and is at most
|
|
326
|
+
# `n_days` in the future. Same semantics as the OmniJS date filter helpers.
|
|
327
|
+
def push_within(conditions:, field:, value:)
|
|
328
|
+
return if value.nil?
|
|
329
|
+
|
|
330
|
+
conditions << <<~JS
|
|
331
|
+
if (!item.#{field} || !checkDateFilter(item.#{field}, #{value})) {
|
|
332
|
+
return false;
|
|
333
|
+
}
|
|
334
|
+
JS
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Backward-looking variant: passes if the date is within the last N days.
|
|
338
|
+
def push_within_past(conditions:, field:, value:)
|
|
339
|
+
return if value.nil?
|
|
340
|
+
|
|
341
|
+
conditions << <<~JS
|
|
342
|
+
if (!item.#{field} || !checkDateWithinPast(item.#{field}, #{value})) {
|
|
343
|
+
return false;
|
|
344
|
+
}
|
|
345
|
+
JS
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Exact-day match (offset relative to today).
|
|
349
|
+
def push_same_day(conditions:, field:, value:)
|
|
350
|
+
return if value.nil?
|
|
351
|
+
|
|
352
|
+
conditions << "if (!checkSameDay(item.#{field}, #{value})) return false;"
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def default_task_mapping
|
|
356
|
+
<<~JS
|
|
357
|
+
const obj = {
|
|
358
|
+
id: item.id.primaryKey,
|
|
359
|
+
name: item.name || "",
|
|
360
|
+
flagged: item.flagged || false,
|
|
361
|
+
taskStatus: taskStatusMap[item.taskStatus] || "Unknown",
|
|
362
|
+
dueDate: formatDate(item.dueDate),
|
|
363
|
+
deferDate: formatDate(item.deferDate),
|
|
364
|
+
plannedDate: formatDate(item.plannedDate),
|
|
365
|
+
tagNames: item.tags ? item.tags.map(t => t.name) : [],
|
|
366
|
+
projectName: item.containingProject ? item.containingProject.name : (item.inInbox ? "Inbox" : null),
|
|
367
|
+
estimatedMinutes: item.estimatedMinutes || null,
|
|
368
|
+
note: item.note || ""
|
|
369
|
+
};
|
|
370
|
+
return obj;
|
|
371
|
+
JS
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
def default_project_mapping
|
|
375
|
+
<<~JS
|
|
376
|
+
const taskArray = item.tasks || [];
|
|
377
|
+
return {
|
|
378
|
+
id: item.id.primaryKey,
|
|
379
|
+
name: item.name || "",
|
|
380
|
+
status: projectStatusMap[item.status] || "Unknown",
|
|
381
|
+
folderName: item.parentFolder ? item.parentFolder.name : null,
|
|
382
|
+
taskCount: taskArray.length,
|
|
383
|
+
flagged: item.flagged || false,
|
|
384
|
+
dueDate: formatDate(item.dueDate),
|
|
385
|
+
deferDate: formatDate(item.deferDate),
|
|
386
|
+
note: item.note || ""
|
|
387
|
+
};
|
|
388
|
+
JS
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def default_folder_mapping
|
|
392
|
+
<<~JS
|
|
393
|
+
const projectArray = item.projects || [];
|
|
394
|
+
return {
|
|
395
|
+
id: item.id.primaryKey,
|
|
396
|
+
name: item.name || "",
|
|
397
|
+
projectCount: projectArray.length,
|
|
398
|
+
path: item.container ? item.container.name + "/" + item.name : item.name
|
|
399
|
+
};
|
|
400
|
+
JS
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
FIELD_MAPPINGS = {
|
|
404
|
+
"id" => "id: item.id.primaryKey",
|
|
405
|
+
"taskStatus" => "taskStatus: taskStatusMap[item.taskStatus]",
|
|
406
|
+
"status" => "status: projectStatusMap[item.status]",
|
|
407
|
+
"modificationDate" => "modificationDate: formatDate(item.modified)",
|
|
408
|
+
"modified" => "modificationDate: formatDate(item.modified)",
|
|
409
|
+
"creationDate" => "creationDate: formatDate(item.added)",
|
|
410
|
+
"added" => "creationDate: formatDate(item.added)",
|
|
411
|
+
"completionDate" => "completionDate: item.completionDate ? formatDate(item.completionDate) : null",
|
|
412
|
+
"dueDate" => "dueDate: formatDate(item.dueDate)",
|
|
413
|
+
"deferDate" => "deferDate: formatDate(item.deferDate)",
|
|
414
|
+
"plannedDate" => "plannedDate: formatDate(item.plannedDate)",
|
|
415
|
+
"effectiveDueDate" => "effectiveDueDate: formatDate(item.effectiveDueDate)",
|
|
416
|
+
"effectiveDeferDate" => "effectiveDeferDate: formatDate(item.effectiveDeferDate)",
|
|
417
|
+
"effectivePlannedDate" => "effectivePlannedDate: formatDate(item.effectivePlannedDate)",
|
|
418
|
+
"tagNames" => "tagNames: item.tags ? item.tags.map(t => t.name) : []",
|
|
419
|
+
"tags" => "tags: item.tags ? item.tags.map(t => t.id.primaryKey) : []",
|
|
420
|
+
"projectName" => 'projectName: item.containingProject ? item.containingProject.name : (item.inInbox ? "Inbox" : null)',
|
|
421
|
+
"projectId" => "projectId: item.containingProject ? item.containingProject.id.primaryKey : null",
|
|
422
|
+
"parentId" => "parentId: item.parent ? item.parent.id.primaryKey : null",
|
|
423
|
+
"childIds" => "childIds: item.children ? item.children.map(c => c.id.primaryKey) : []",
|
|
424
|
+
"hasChildren" => "hasChildren: item.children ? item.children.length > 0 : false",
|
|
425
|
+
"folderName" => "folderName: item.parentFolder ? item.parentFolder.name : null",
|
|
426
|
+
"folderID" => "folderID: item.parentFolder ? item.parentFolder.id.primaryKey : null",
|
|
427
|
+
"taskCount" => "taskCount: item.tasks ? item.tasks.length : 0",
|
|
428
|
+
"tasks" => "tasks: item.tasks ? item.tasks.map(t => t.id.primaryKey) : []",
|
|
429
|
+
"projectCount" => "projectCount: item.projects ? item.projects.length : 0",
|
|
430
|
+
"projects" => "projects: item.projects ? item.projects.map(p => p.id.primaryKey) : []",
|
|
431
|
+
"subfolders" => "subfolders: item.folders ? item.folders.map(f => f.id.primaryKey) : []",
|
|
432
|
+
"path" => 'path: item.container ? item.container.name + "/" + item.name : item.name',
|
|
433
|
+
"isRepeating" => "isRepeating: item.repetitionRule !== null",
|
|
434
|
+
"repetitionRule" => "repetitionRule: item.repetitionRule ? item.repetitionRule.toString() : null",
|
|
435
|
+
"estimatedMinutes" => "estimatedMinutes: item.estimatedMinutes || null",
|
|
436
|
+
"note" => 'note: item.note || ""'
|
|
437
|
+
}.freeze
|
|
438
|
+
|
|
439
|
+
def field_mapping_for(field)
|
|
440
|
+
field_str = field.to_s
|
|
441
|
+
FIELD_MAPPINGS[field_str] || "#{field_str}: item.#{field_str} !== undefined ? item.#{field_str} : null"
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def resolve_sort_field(sort_by)
|
|
445
|
+
return nil if sort_by.nil?
|
|
446
|
+
|
|
447
|
+
SORT_FIELD_ALIASES[sort_by.to_s]
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
def js_task_status_map_entries
|
|
451
|
+
QueryStatuses::TASK.map do |status|
|
|
452
|
+
%([Task.Status.#{status}]: "#{status}")
|
|
453
|
+
end.join(",\n ")
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def js_project_status_map_entries
|
|
457
|
+
QueryStatuses::PROJECT.map do |status|
|
|
458
|
+
%([Project.Status.#{status}]: "#{status}")
|
|
459
|
+
end.join(",\n ")
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def build_query_script(entity:, include_completed:, summary:, filter_conditions:, field_mapping:, sort_logic:,
|
|
463
|
+
limit_logic:)
|
|
464
|
+
<<~JS
|
|
465
|
+
(() => {
|
|
466
|
+
try {
|
|
467
|
+
|
|
468
|
+
function formatDate(date) {
|
|
469
|
+
if (!date) return null;
|
|
470
|
+
return date.toISOString();
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function checkDateFilter(itemDate, daysFromNow) {
|
|
474
|
+
if (!itemDate) return false;
|
|
475
|
+
const futureDate = new Date();
|
|
476
|
+
futureDate.setDate(futureDate.getDate() + daysFromNow);
|
|
477
|
+
return itemDate <= futureDate;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function checkDateWithinPast(itemDate, daysAgo) {
|
|
481
|
+
if (!itemDate) return false;
|
|
482
|
+
const pastDate = new Date();
|
|
483
|
+
pastDate.setDate(pastDate.getDate() - daysAgo);
|
|
484
|
+
pastDate.setHours(0, 0, 0, 0);
|
|
485
|
+
return itemDate >= pastDate;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function checkSameDay(itemDate, daysFromNow) {
|
|
489
|
+
if (!itemDate) return false;
|
|
490
|
+
const target = new Date();
|
|
491
|
+
target.setDate(target.getDate() + daysFromNow);
|
|
492
|
+
return itemDate.getFullYear() === target.getFullYear() &&
|
|
493
|
+
itemDate.getMonth() === target.getMonth() &&
|
|
494
|
+
itemDate.getDate() === target.getDate();
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
const taskStatusMap = {
|
|
498
|
+
#{js_task_status_map_entries}
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
const projectStatusMap = {
|
|
502
|
+
#{js_project_status_map_entries}
|
|
503
|
+
};
|
|
504
|
+
|
|
505
|
+
let items = [];
|
|
506
|
+
const entityType = "#{entity}";
|
|
507
|
+
|
|
508
|
+
if (entityType === "tasks") {
|
|
509
|
+
items = flattenedTasks;
|
|
510
|
+
} else if (entityType === "projects") {
|
|
511
|
+
items = flattenedProjects;
|
|
512
|
+
} else if (entityType === "folders") {
|
|
513
|
+
items = flattenedFolders;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
let filtered = items.filter(item => {
|
|
517
|
+
if (!#{include_completed}) {
|
|
518
|
+
if (entityType === "tasks") {
|
|
519
|
+
if (item.taskStatus === Task.Status.Completed ||
|
|
520
|
+
item.taskStatus === Task.Status.Dropped) {
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
} else if (entityType === "projects") {
|
|
524
|
+
if (item.status === Project.Status.Done ||
|
|
525
|
+
item.status === Project.Status.Dropped) {
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
#{filter_conditions}
|
|
532
|
+
|
|
533
|
+
return true;
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
#{sort_logic}
|
|
537
|
+
|
|
538
|
+
#{limit_logic}
|
|
539
|
+
|
|
540
|
+
if (#{summary}) {
|
|
541
|
+
return JSON.stringify({
|
|
542
|
+
count: filtered.length,
|
|
543
|
+
error: null
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const results = filtered.map(item => {
|
|
548
|
+
#{field_mapping}
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
return JSON.stringify({
|
|
552
|
+
items: results,
|
|
553
|
+
count: results.length,
|
|
554
|
+
error: null
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
} catch (error) {
|
|
558
|
+
return JSON.stringify({
|
|
559
|
+
error: "Script execution error: " + error.toString(),
|
|
560
|
+
items: [],
|
|
561
|
+
count: 0
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
})();
|
|
565
|
+
JS
|
|
566
|
+
end
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
end
|
|
570
|
+
end
|
|
571
|
+
end
|