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