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,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmnifocusMcp
4
+ module Tools
5
+ module Presenters
6
+ # Shared formatting for batch add/remove tool replies.
7
+ module BatchReport
8
+ class << self
9
+ def format_success(past_tense:, failure_verb:, results:, items:, &detail)
10
+ success_count = results.count(&:ok?)
11
+ failure_count = results.count(&:error?)
12
+
13
+ message = "✅ Successfully #{past_tense} #{success_count} items."
14
+ message += " ⚠️ Failed to #{failure_verb} #{failure_count} items." if failure_count.positive?
15
+
16
+ lines = results.each_with_index.map { |r, i| yield(r, items[i]) }
17
+ "#{message}\n\n#{lines.join("\n")}"
18
+ end
19
+
20
+ # True when every per-item result failed (and at least one item was processed).
21
+ def all_failed?(results) = results.any? && results.all?(&:error?)
22
+
23
+ def format_failure(error_message, results: [], items: [], &detail)
24
+ if results.any?
25
+ lines = results.each_with_index.map { |r, i| yield(r, items[i]) }
26
+ "Failed to process batch operation.\n\n#{lines.join("\n")}"
27
+ else
28
+ "Failed to process batch operation.\n\nNo items processed. #{error_message || ""}"
29
+ end
30
+ end
31
+
32
+ def add_detail(result, original)
33
+ item_type = original.type
34
+ item_name = original.name
35
+ if result.ok?
36
+ %(- ✅ #{item_type}: "#{item_name}")
37
+ else
38
+ %(- ❌ #{item_type}: "#{item_name}" - Error: #{result.error || "Unknown error"})
39
+ end
40
+ end
41
+
42
+ def remove_detail(result, original)
43
+ item_type = original.item_type
44
+ if result.ok?
45
+ %(- ✅ #{item_type}: "#{result.ok.name}")
46
+ else
47
+ identifier = original.id || original.name
48
+ "- ❌ #{item_type}: #{identifier} - Error: #{result.error}"
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmnifocusMcp
4
+ module Tools
5
+ module Presenters
6
+ module ListPerspectives
7
+ class << self
8
+ def format(perspectives)
9
+ return "No perspectives found." if perspectives.empty?
10
+
11
+ built_in = perspectives.select { |perspective| perspective["type"] == "builtin" }
12
+ custom = perspectives.select { |perspective| perspective["type"] == "custom" }
13
+
14
+ output = "## Available Perspectives (#{perspectives.length})\n\n"
15
+ append_group(output:, title: "Built-in Perspectives", perspectives: built_in)
16
+ output << "\n" if built_in.any? && custom.any?
17
+ append_group(output:, title: "Custom Perspectives", perspectives: custom)
18
+ output
19
+ end
20
+
21
+ private
22
+
23
+ def append_group(output:, title:, perspectives:)
24
+ return if perspectives.empty?
25
+
26
+ output << "### #{title}\n"
27
+ perspectives.each { |perspective| output << "• #{perspective["name"]}\n" }
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmnifocusMcp
4
+ module Tools
5
+ module Presenters
6
+ module ListTags
7
+ class << self
8
+ def format(tags)
9
+ return "No tags found." if tags.empty?
10
+
11
+ nested_tags, top_level_tags = tags.partition { |tag| tag["parentTagID"] }
12
+ children_by_parent = nested_tags.group_by { |tag| tag["parentTagID"] }
13
+
14
+ output = "## Tags (#{tags.length})\n\n"
15
+ append_top_level_tags(output:, top_level_tags:, children_by_parent:)
16
+ append_orphaned_children(output:, top_level_tags:, children_by_parent:)
17
+ output
18
+ end
19
+
20
+ private
21
+
22
+ def append_top_level_tags(output:, top_level_tags:, children_by_parent:)
23
+ top_level_tags.each do |tag|
24
+ output << format_tag(tag, "")
25
+ (children_by_parent[tag["id"]] || []).each do |child|
26
+ output << format_tag(child, " ")
27
+ end
28
+ end
29
+ end
30
+
31
+ def append_orphaned_children(output:, top_level_tags:, children_by_parent:)
32
+ rendered_parents = top_level_tags.to_set { |tag| tag["id"] }
33
+ children_by_parent.each do |parent_id, children|
34
+ next if rendered_parents.include?(parent_id)
35
+
36
+ children.each { |child| output << format_tag(child, "") }
37
+ end
38
+ end
39
+
40
+ def format_tag(tag, indent)
41
+ status = tag["active"] ? "" : " (inactive)"
42
+ tasks = (tag["taskCount"] || 0).positive? ? " [#{tag["taskCount"]} tasks]" : ""
43
+ "#{indent}- **#{tag["name"]}**#{status}#{tasks} (id: #{tag["id"]})\n"
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../definitions/date_formatter"
4
+
5
+ module OmnifocusMcp
6
+ module Tools
7
+ module Presenters
8
+ module PerspectiveView
9
+ class << self
10
+ def format(perspective_name, items, limit)
11
+ output = "## #{perspective_name} Perspective (#{items.length} items)\n\n"
12
+
13
+ if items.empty?
14
+ output << "No items visible in this perspective."
15
+ return output
16
+ end
17
+
18
+ items.each { |item| append_item(output, item) }
19
+ append_limit_warning(output:, items:, limit:)
20
+ output
21
+ end
22
+
23
+ private
24
+
25
+ def append_item(output, item)
26
+ output << "• #{format_parts(item).join(" ")}\n"
27
+ append_note_preview(output, item["note"]) if item["note"] && !item["note"].to_s.strip.empty?
28
+ end
29
+
30
+ def format_parts(item)
31
+ parts = []
32
+ flag = item["flagged"] ? "🚩 " : ""
33
+ checkbox = item["completed"] ? "☑" : "☐"
34
+ parts << "#{checkbox} #{flag}#{item["name"] || "Unnamed"}"
35
+
36
+ parts << "(#{item["projectName"]})" if item["projectName"]
37
+ if item["dueDate"]
38
+ parts << "[due: #{Definitions::DateFormatter.format_date(item["dueDate"],
39
+ style: :compact)}]"
40
+ end
41
+ if item["deferDate"]
42
+ parts << "[defer: #{Definitions::DateFormatter.format_date(item["deferDate"],
43
+ style: :compact)}]"
44
+ end
45
+ append_estimate(parts, item["estimatedMinutes"])
46
+ parts << "<#{item["tagNames"].join(",")}>" if item["tagNames"] && !item["tagNames"].empty?
47
+ parts << "##{item["taskStatus"].downcase}" if item["taskStatus"] && item["taskStatus"] != "Available"
48
+ parts << "[#{item["id"]}]" if item["id"]
49
+ parts
50
+ end
51
+
52
+ def append_estimate(parts, estimated_minutes)
53
+ return unless estimated_minutes
54
+
55
+ minutes = estimated_minutes.to_i
56
+ formatted = if minutes >= 60
57
+ remainder = minutes % 60
58
+ remainder.positive? ? "#{minutes / 60}h#{remainder}m" : "#{minutes / 60}h"
59
+ else
60
+ "#{minutes}m"
61
+ end
62
+ parts << "(#{formatted})"
63
+ end
64
+
65
+ def append_note_preview(output, note)
66
+ first_line = note.strip.split("\n").first.to_s
67
+ preview = first_line.slice(0, 80)
68
+ ellipsis = note.length > 80 || note.include?("\n") ? "..." : ""
69
+ output << " └─ #{preview}#{ellipsis}\n"
70
+ end
71
+
72
+ def append_limit_warning(output:, items:, limit:)
73
+ return unless items.length == limit
74
+
75
+ output << "\n⚠️ Results limited to #{limit} items. More may be available in this perspective."
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "query_results"
6
+
7
+ module OmnifocusMcp
8
+ module Tools
9
+ module Presenters
10
+ module QueryReply
11
+ class << self
12
+ def format(args:, params:, match:)
13
+ return JSON.pretty_generate(json_payload(args:, params:, match:)) if output_format(params) == "json"
14
+ return "Found #{match.count} #{params.entity} matching your criteria." if params.summary
15
+
16
+ items = match.items || []
17
+ output = QueryResults.format_query_results(items:, entity: params.entity, filters: args[:filters])
18
+ output += limit_warning(params.limit) if params.limit && items.length == params.limit
19
+ output
20
+ end
21
+
22
+ def failure(error) = "Query failed: #{error}"
23
+
24
+ private
25
+
26
+ def output_format(params)
27
+ params.respond_to?(:to_h) ? params.to_h[:format] : nil
28
+ end
29
+
30
+ def json_payload(args:, params:, match:)
31
+ {
32
+ entity: params.entity,
33
+ count: match.count,
34
+ items: match.items,
35
+ filters: args[:filters] || {},
36
+ fields: params.fields,
37
+ limit: params.limit,
38
+ sortBy: args[:sortBy],
39
+ sortOrder: params.sort_order,
40
+ includeCompleted: params.include_completed == true,
41
+ summary: params.summary == true
42
+ }
43
+ end
44
+
45
+ def limit_warning(limit)
46
+ "\n\n⚠️ Results limited to #{limit} items. More may be available."
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../utils/blank"
4
+ require_relative "../../utils/iso_date"
5
+
6
+ module OmnifocusMcp
7
+ module Tools
8
+ module Presenters
9
+ # Pure-string formatters for `query_omnifocus` results.
10
+ #
11
+ # Exposes `format_tasks`, `format_projects`, `format_folders`,
12
+ # `format_query_results`, `format_filters`. Used by
13
+ # {Definitions::QueryOmnifocusTool} to build the user-facing text reply.
14
+ module QueryResults
15
+ class << self
16
+ def format_query_results(items:, entity:, filters: nil)
17
+ return "No #{entity} found matching the specified criteria." if items.nil? || items.empty?
18
+
19
+ output = "## Query Results: #{items.length} #{entity}\n\n"
20
+
21
+ output << "Filters applied: #{format_filters(filters)}\n\n" if filters && !filters.empty?
22
+
23
+ output << case entity.to_s
24
+ when "tasks" then format_tasks(items)
25
+ when "projects" then format_projects(items)
26
+ when "folders" then format_folders(items)
27
+ else "Unsupported entity: #{entity}"
28
+ end
29
+
30
+ output
31
+ end
32
+
33
+ def format_filters(filters)
34
+ f = stringify_keys(filters)
35
+ parts = []
36
+
37
+ parts << %(project ID: "#{f["projectId"]}") if f["projectId"]
38
+ parts << %(project: "#{f["projectName"]}") if f["projectName"]
39
+ parts << %(task: "#{f["taskName"]}") if f["taskName"]
40
+ parts << %(folder ID: "#{f["folderId"]}") if f["folderId"]
41
+ parts << "tags: [#{Array(f["tags"]).join(", ")}]" if f["tags"]
42
+ parts << "status: [#{Array(f["status"]).join(", ")}]" if f["status"]
43
+ parts << "flagged: #{f["flagged"]}" unless f["flagged"].nil?
44
+
45
+ parts << format_within(label: "due", value: f["dueWithin"]) if f["dueWithin"]
46
+ parts << format_deferred(f["deferredUntil"]) if f["deferredUntil"]
47
+ parts << format_within(label: "planned", value: f["plannedWithin"]) if f["plannedWithin"]
48
+
49
+ parts << "has note: #{f["hasNote"]}" unless f["hasNote"].nil?
50
+ parts << "inbox: #{f["inbox"]}" unless f["inbox"].nil?
51
+
52
+ parts << format_on(label: "due", value: f["dueOn"]) unless f["dueOn"].nil?
53
+ parts << format_on(label: "defer", value: f["deferOn"]) unless f["deferOn"].nil?
54
+ parts << format_on(label: "planned", value: f["plannedOn"]) unless f["plannedOn"].nil?
55
+
56
+ parts << "added within #{f["addedWithin"]} days" unless f["addedWithin"].nil?
57
+ parts << "added on day #{f["addedOn"]}" unless f["addedOn"].nil?
58
+ parts << "repeating: #{f["isRepeating"]}" unless f["isRepeating"].nil?
59
+ parts << "completed within #{f["completedWithin"]} days" unless f["completedWithin"].nil?
60
+ parts << "completed on day #{f["completedOn"]}" unless f["completedOn"].nil?
61
+
62
+ parts.join(", ")
63
+ end
64
+
65
+ def format_tasks(tasks)
66
+ tasks.map { format_task(it) }
67
+ .join("\n")
68
+ end
69
+
70
+ def format_projects(projects)
71
+ projects.map { format_project(it) }
72
+ .join("\n")
73
+ end
74
+
75
+ def format_folders(folders)
76
+ folders.map { format_folder(it) }
77
+ .join("\n")
78
+ end
79
+
80
+ def format_task(task)
81
+ t = stringify_keys(task)
82
+ parts = []
83
+
84
+ flag = t["flagged"] ? "🚩 " : ""
85
+ parts << "• #{flag}#{t["name"] || "Unnamed"}"
86
+
87
+ parts << "[#{t["id"]}]" if t["id"]
88
+ parts << "(#{t["projectName"]})" if t["projectName"]
89
+
90
+ append_date(parts, label: "due", value: t["dueDate"])
91
+ append_date(parts, label: "defer", value: t["deferDate"])
92
+ append_date(parts, label: "planned", value: t["plannedDate"])
93
+
94
+ if t["estimatedMinutes"]
95
+ minutes = t["estimatedMinutes"].to_i
96
+ formatted = minutes >= 60 ? "#{minutes / 60}h" : "#{minutes}m"
97
+ parts << "(#{formatted})"
98
+ end
99
+
100
+ parts << "<#{Array(t["tagNames"]).join(",")}>" if t["tagNames"] && !t["tagNames"].empty?
101
+ parts << "##{t["taskStatus"].downcase}" if t["taskStatus"]
102
+
103
+ unless t["isRepeating"].nil?
104
+ parts << (t["isRepeating"] ? "[repeating]" : "[not repeating]")
105
+ end
106
+
107
+ parts << "[rule: #{t["repetitionRule"]}]" if t["repetitionRule"]
108
+ parts << "[parent: #{t["parentId"]}]" if t["parentId"]
109
+
110
+ if t["hasChildren"] && t["childIds"] && !t["childIds"].empty?
111
+ parts << "[children: #{t["childIds"].join(", ")}]"
112
+ end
113
+
114
+ append_date(parts, label: "created", value: t["creationDate"])
115
+ append_date(parts, label: "modified", value: t["modificationDate"])
116
+ append_date(parts, label: "completed", value: t["completionDate"])
117
+
118
+ result = parts.join(" ")
119
+ result += "\n Note: #{t["note"]}" unless Utils::Blank.blank?(t["note"])
120
+ result
121
+ end
122
+
123
+ def format_project(project)
124
+ p = stringify_keys(project)
125
+
126
+ status = p["status"] && p["status"] != "Active" ? " [#{p["status"]}]" : ""
127
+ folder = p["folderName"] ? " 📁 #{p["folderName"]}" : ""
128
+ task_count = p["taskCount"].nil? ? "" : " (#{p["taskCount"]} tasks)"
129
+ flagged = p["flagged"] ? "🚩 " : ""
130
+ due = format_due_segment(p["dueDate"])
131
+
132
+ result = "P: #{flagged}#{p["name"]}#{status}#{due}#{folder}#{task_count}"
133
+ result += "\n Note: #{p["note"]}" unless Utils::Blank.blank?(p["note"])
134
+ result
135
+ end
136
+
137
+ def format_folder(folder)
138
+ f = stringify_keys(folder)
139
+
140
+ project_count = f["projectCount"].nil? ? "" : " (#{f["projectCount"]} projects)"
141
+ path = f["path"] ? " 📍 #{f["path"]}" : ""
142
+
143
+ "F: #{f["name"]}#{project_count}#{path}"
144
+ end
145
+
146
+ private
147
+
148
+ def stringify_keys(hash)
149
+ return {} if hash.nil?
150
+
151
+ hash.each_with_object({}) { |(k, v), acc| acc[k.to_s] = v }
152
+ end
153
+
154
+ def format_within(label:, value:)
155
+ value.is_a?(String) ? "#{label} within #{value}" : "#{label} within #{value} days"
156
+ end
157
+
158
+ def format_deferred(value)
159
+ if value.is_a?(String)
160
+ "deferred within #{value}"
161
+ else
162
+ "deferred becoming available within #{value} days"
163
+ end
164
+ end
165
+
166
+ def format_on(label:, value:)
167
+ value.is_a?(String) ? "#{label} on #{value}" : "#{label} on day +#{value}"
168
+ end
169
+
170
+ def format_due_segment(date_str)
171
+ formatted = Utils::IsoDate.to_date_only(date_str)
172
+ formatted ? " [due: #{formatted}]" : ""
173
+ end
174
+
175
+ def append_date(parts, label:, value:)
176
+ formatted = Utils::IsoDate.to_date_only(value)
177
+ parts << "[#{label}: #{formatted}]" if formatted
178
+ end
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmnifocusMcp
4
+ module Tools
5
+ module Presenters
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "presenters/query_results"
4
+
5
+ module OmnifocusMcp
6
+ module Tools
7
+ QueryOmnifocusFormatter = Presenters::QueryResults
8
+ end
9
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmnifocusMcp
4
+ module Tools
5
+ # Valid status filter values for `query_omnifocus`, shared by the MCP schema,
6
+ # generated OmniJS, and specs.
7
+ module QueryStatuses
8
+ TASK = %w[Next Available Blocked DueSoon Overdue Completed Dropped].freeze
9
+ PROJECT = %w[Active OnHold Done Dropped].freeze
10
+
11
+ class << self
12
+ def task_list_for_schema
13
+ TASK.map { |s| "'#{s}'" }.join(", ")
14
+ end
15
+
16
+ def project_list_for_schema
17
+ PROJECT.map { |s| "'#{s}'" }.join(", ")
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../infrastructure/apple_script"
4
+
5
+ module OmnifocusMcp
6
+ module Utils
7
+ AppleScript = Infrastructure::AppleScript
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../parsers/apple_script_envelope"
4
+
5
+ module OmnifocusMcp
6
+ module Utils
7
+ AppleScriptEnvelope = Parsers::AppleScriptEnvelope
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../infrastructure/apple_script"
4
+
5
+ module OmnifocusMcp
6
+ module Utils
7
+ AppleScriptHelpers = Infrastructure::AppleScript
8
+ end
9
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmnifocusMcp
4
+ module Utils
5
+ # Lightweight "blank" predicate without pulling in ActiveSupport.
6
+ #
7
+ # A value is *blank* when it is `nil` or its `to_s` is the empty string.
8
+ # The variadic form returns `true` only when **every** argument is blank,
9
+ # which is exactly the shape the audit's hot spots want:
10
+ #
11
+ # Utils::Blank.blank?(args[:id], args[:name])
12
+ # # => true when both the id and the name are missing or empty
13
+ #
14
+ # Replaces 12+ hand-rolled `x.nil? || x.to_s.empty?` chains across the
15
+ # codebase (audit item #19).
16
+ module Blank
17
+ module_function
18
+
19
+ def blank?(*values)
20
+ return true if values.empty?
21
+
22
+ values.all? { |v| v.to_s.empty? }
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ module OmnifocusMcp
6
+ module Utils
7
+ # Parses MCP date-filter wire values and converts them to days-from-today.
8
+ #
9
+ # Named filters are modeled as symbols (`:today`, `:tomorrow`, …) after
10
+ # parsing; only string input is accepted at the MCP boundary. Numeric and
11
+ # ISO-date inputs resolve directly to day offsets.
12
+ #
13
+ module DateFilter
14
+ STRING_TO_NAMED = {
15
+ "today" => :today,
16
+ "tomorrow" => :tomorrow,
17
+ "this week" => :this_week,
18
+ "next week" => :next_week
19
+ }.freeze
20
+
21
+ ISO_DATE_RE = /\A\d{4}-\d{2}-\d{2}\z/
22
+
23
+ # Parse an MCP wire value into a Ruby-native filter (Symbol or Numeric).
24
+ #
25
+ # `today:` defaults to `Date.today` so ISO dates are resolved relative to a
26
+ # pinned reference date in tests.
27
+ class << self
28
+ def parse(input, today: Date.today)
29
+ return input if input.is_a?(Numeric)
30
+ return input if input.is_a?(Symbol)
31
+
32
+ raise_invalid!(input) unless input.is_a?(String) && !input.empty?
33
+
34
+ normalized = input.strip.downcase
35
+ return STRING_TO_NAMED[normalized] if STRING_TO_NAMED.key?(normalized)
36
+ return (parse_iso_date(normalized:, original: input) - today).to_i if ISO_DATE_RE.match?(normalized)
37
+
38
+ raise_invalid!(input)
39
+ end
40
+
41
+ # Convert a parsed filter to a days-from-`today` Integer for query scripts.
42
+ def to_days(filter)
43
+ case filter
44
+ when :today then 0
45
+ when :tomorrow then 1
46
+ when :this_week then 7
47
+ when :next_week then 14
48
+ when Numeric then filter
49
+ else
50
+ raise ArgumentError, "expected Symbol or Numeric date filter, got #{filter.inspect}"
51
+ end
52
+ end
53
+
54
+ # MCP boundary helper: parse and convert to days in one step.
55
+ def resolve(input, today: Date.today)
56
+ to_days(parse(input, today: today))
57
+ end
58
+
59
+ private
60
+
61
+ def parse_iso_date(normalized:, original:)
62
+ Date.iso8601(normalized)
63
+ rescue Date::Error
64
+ raise_invalid!(original)
65
+ end
66
+
67
+ def raise_invalid!(input)
68
+ raise ArgumentError,
69
+ "Invalid date filter value: \"#{input}\". " \
70
+ 'Use a number, "today", "tomorrow", "this week", "next week", ' \
71
+ "or an ISO date (YYYY-MM-DD)."
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../infrastructure/apple_script_date_builder"
4
+
5
+ module OmnifocusMcp
6
+ module Utils
7
+ DateFormatting = Infrastructure::AppleScriptDateBuilder
8
+ end
9
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ require_relative "blank"
6
+
7
+ module OmnifocusMcp
8
+ module Utils
9
+ # Normalizes date strings to ISO 8601 for machine-oriented output (query
10
+ # results, resource JSON). Human-readable formatting lives in
11
+ # {Tools::Definitions::DateFormatter}.
12
+ #
13
+ module IsoDate
14
+ module_function
15
+
16
+ # Return a YYYY-MM-DD string, or +nil+ when the input is blank or
17
+ # unparseable.
18
+ def to_date_only(value)
19
+ return nil if Blank.blank?(value)
20
+
21
+ Date.parse(value.to_s).strftime("%Y-%m-%d")
22
+ rescue ArgumentError
23
+ nil
24
+ end
25
+ end
26
+ end
27
+ end