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,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