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,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "../tools/operations/query_omnifocus"
|
|
5
|
+
|
|
6
|
+
module OmnifocusMcp
|
|
7
|
+
module Resources
|
|
8
|
+
# Tasks in a specific OmniFocus project, addressed by name.
|
|
9
|
+
#
|
|
10
|
+
# `#content` (via `#payload`) is the sole entry point.
|
|
11
|
+
class ProjectResource < Base
|
|
12
|
+
uri "omnifocus://project/{name}"
|
|
13
|
+
resource_name "project"
|
|
14
|
+
description "Tasks in a specific OmniFocus project"
|
|
15
|
+
|
|
16
|
+
FIELDS = %w[
|
|
17
|
+
id name flagged dueDate deferDate taskStatus
|
|
18
|
+
tagNames parentId note estimatedMinutes
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
def payload
|
|
22
|
+
name = params[:name].to_s
|
|
23
|
+
OmnifocusMcp.logger.warn("[resource:project] Reading project: #{name}")
|
|
24
|
+
|
|
25
|
+
params = Tools::Params::QueryOmnifocusParams.from_hash(
|
|
26
|
+
entity: "tasks",
|
|
27
|
+
filters: { project_name: name },
|
|
28
|
+
fields: FIELDS
|
|
29
|
+
)
|
|
30
|
+
Tools::Operations::QueryOmnifocus.call(params).fold(
|
|
31
|
+
on_ok: ->(match) { snake_case_keys(match.items || []) },
|
|
32
|
+
on_error: ->(err) { { error: err } }
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "../tools/operations/database_stats"
|
|
5
|
+
|
|
6
|
+
module OmnifocusMcp
|
|
7
|
+
module Resources
|
|
8
|
+
# Quick OmniFocus database statistics overview.
|
|
9
|
+
class StatsResource < Base
|
|
10
|
+
uri "omnifocus://stats"
|
|
11
|
+
resource_name "stats"
|
|
12
|
+
description "Quick OmniFocus database statistics overview"
|
|
13
|
+
|
|
14
|
+
def payload
|
|
15
|
+
Tools::Operations::DatabaseStats.get_database_stats.fold(
|
|
16
|
+
on_ok: ->(stats) { snake_case_keys(stats || {}) },
|
|
17
|
+
on_error: ->(err) { { error: err } }
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
require_relative "../tools/operations/query_omnifocus"
|
|
5
|
+
|
|
6
|
+
module OmnifocusMcp
|
|
7
|
+
module Resources
|
|
8
|
+
class TodayResource < Base
|
|
9
|
+
uri "omnifocus://today"
|
|
10
|
+
resource_name "today"
|
|
11
|
+
description "Today's agenda — tasks due today, planned for today, and overdue items"
|
|
12
|
+
|
|
13
|
+
DUE_FIELDS = %w[id name flagged dueDate projectName tagNames taskStatus].freeze
|
|
14
|
+
PLANNED_FIELDS = %w[id name flagged plannedDate projectName tagNames taskStatus].freeze
|
|
15
|
+
OVERDUE_FIELDS = DUE_FIELDS
|
|
16
|
+
|
|
17
|
+
def payload
|
|
18
|
+
OmnifocusMcp.logger.warn("[resource:today] Reading today's agenda")
|
|
19
|
+
|
|
20
|
+
{
|
|
21
|
+
due_today: items_or_empty(query(filters: { due_on: 0 }, fields: DUE_FIELDS)),
|
|
22
|
+
planned_today: items_or_empty(query(filters: { planned_on: 0 }, fields: PLANNED_FIELDS)),
|
|
23
|
+
overdue: items_or_empty(query(filters: { status: ["Overdue"] }, fields: OVERDUE_FIELDS))
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def query(filters:, fields:)
|
|
30
|
+
params = Tools::Params::QueryOmnifocusParams.from_hash(
|
|
31
|
+
entity: "tasks", filters: filters, fields: fields
|
|
32
|
+
)
|
|
33
|
+
Tools::Operations::QueryOmnifocus.call(params)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OmnifocusMcp
|
|
4
|
+
# Success/disjoint-error ADT. {#and_then} does not rescue: the block must return another Result and
|
|
5
|
+
# should not raise for domain failures (use {#map} or return Result.error).
|
|
6
|
+
# Programmer bugs and invariant violations should use +raise+, not Result.error.
|
|
7
|
+
Result = Data.define(:value, :error_message, :creation_location) do
|
|
8
|
+
def self.ok(value)
|
|
9
|
+
new(value: value, error_message: nil, creation_location: caller_locations(1, 1).first)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# @param message [Object] user-facing String or other user-facing error payload.
|
|
13
|
+
def self.error(message)
|
|
14
|
+
new(value: nil, error_message: message, creation_location: caller_locations(1, 1).first)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Pair two results; first error wins. Values become a two-element Array on success.
|
|
18
|
+
def self.zip(left, right)
|
|
19
|
+
return left if left.error?
|
|
20
|
+
return right if right.error?
|
|
21
|
+
|
|
22
|
+
Result.ok([left.ok, right.ok])
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Sequence a +Hash+ or +Array+ of Results into a single Result. Fail-fast: the first error in
|
|
26
|
+
# iteration order wins (insertion order for Hashes). On success the wrapped value mirrors the
|
|
27
|
+
# input container's shape: array-in → array-of-values-out, hash-in → hash-of-values-out.
|
|
28
|
+
def self.all(results)
|
|
29
|
+
case results
|
|
30
|
+
when Array
|
|
31
|
+
first_error = results.find(&:error?)
|
|
32
|
+
first_error || Result.ok(results.map(&:ok))
|
|
33
|
+
when Hash
|
|
34
|
+
first_error = results.each_value.find(&:error?)
|
|
35
|
+
first_error || Result.ok(results.transform_values(&:ok))
|
|
36
|
+
else
|
|
37
|
+
raise ArgumentError, "Result.all expects an Array or Hash, got #{results.class}"
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def ok? = !value.nil?
|
|
42
|
+
|
|
43
|
+
def error? = !error_message.nil?
|
|
44
|
+
|
|
45
|
+
def ok
|
|
46
|
+
raise "Cannot get value from error result: #{error_message}, #{creation_location}" if error?
|
|
47
|
+
|
|
48
|
+
value
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def ok_or(default_value) = ok? ? value : default_value
|
|
52
|
+
|
|
53
|
+
def error
|
|
54
|
+
raise "Cannot get error from ok result" if ok?
|
|
55
|
+
|
|
56
|
+
error_message
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def map
|
|
60
|
+
return self if error?
|
|
61
|
+
|
|
62
|
+
Result.ok(yield(ok))
|
|
63
|
+
rescue StandardError => e
|
|
64
|
+
Result.error(e.message)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def and_then
|
|
68
|
+
return self if error?
|
|
69
|
+
|
|
70
|
+
yield(ok)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def or_else
|
|
74
|
+
return self if ok?
|
|
75
|
+
|
|
76
|
+
yield(error)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Collapses the +if ok? ... else ... end+ shape into a single expression. The chosen branch is
|
|
80
|
+
# called with the wrapped value (or error message) and its return value is passed through verbatim;
|
|
81
|
+
# this is a terminator, not a chainable Result-returning combinator.
|
|
82
|
+
def fold(on_ok:, on_error:) = ok? ? on_ok.call(ok) : on_error.call(error)
|
|
83
|
+
|
|
84
|
+
def where = creation_location
|
|
85
|
+
|
|
86
|
+
def deconstruct = [value, error_message]
|
|
87
|
+
|
|
88
|
+
def deconstruct_keys(keys)
|
|
89
|
+
if keys.nil?
|
|
90
|
+
return error? ? { error_message: error_message } : { value: value }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
keys.filter_map do |key|
|
|
94
|
+
val = public_send(key)
|
|
95
|
+
[key, val] unless val.nil?
|
|
96
|
+
end.to_h
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def initialize(value:, error_message:, creation_location:)
|
|
102
|
+
raise ArgumentError, "Cannot have both value and error" unless value.nil? || error_message.nil?
|
|
103
|
+
raise ArgumentError, "Must provide either value or error" if value.nil? && error_message.nil?
|
|
104
|
+
|
|
105
|
+
super
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../result"
|
|
4
|
+
require_relative "../infrastructure/js_embed"
|
|
5
|
+
require_relative "../infrastructure/script_runner"
|
|
6
|
+
|
|
7
|
+
module OmnifocusMcp
|
|
8
|
+
module Tools
|
|
9
|
+
# Lightweight database overview helpers that don't require pulling the
|
|
10
|
+
# full OmniFocus dataset.
|
|
11
|
+
#
|
|
12
|
+
# Provides:
|
|
13
|
+
# * {.get_database_stats} — counts + last-modified timestamp
|
|
14
|
+
# * {.get_changes_since} — incremental change feed since a timestamp
|
|
15
|
+
# rubocop:disable Metrics/ModuleLength
|
|
16
|
+
module DatabaseStats
|
|
17
|
+
# Lightweight database statistics: counts + last-modified timestamp.
|
|
18
|
+
#
|
|
19
|
+
# @return [OmnifocusMcp::Result] +ok+ carries the stats Hash; +error+ carries a user-facing message.
|
|
20
|
+
class << self
|
|
21
|
+
def get_database_stats
|
|
22
|
+
require_relative "operations/database_stats"
|
|
23
|
+
|
|
24
|
+
Operations::DatabaseStats.get_database_stats
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Incremental change feed since `since` (a Time, DateTime, or ISO string).
|
|
28
|
+
#
|
|
29
|
+
# @return [OmnifocusMcp::Result] +ok+ carries the changes Hash; +error+ carries a user-facing message.
|
|
30
|
+
def get_changes_since(since)
|
|
31
|
+
require_relative "operations/database_stats"
|
|
32
|
+
|
|
33
|
+
Operations::DatabaseStats.get_changes_since(since)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
STATS_SCRIPT = <<~JS
|
|
37
|
+
(() => {
|
|
38
|
+
try {
|
|
39
|
+
const allTasks = flattenedTasks;
|
|
40
|
+
const activeTasks = allTasks.filter(task =>
|
|
41
|
+
task.taskStatus !== Task.Status.Completed &&
|
|
42
|
+
task.taskStatus !== Task.Status.Dropped
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
const allProjects = flattenedProjects;
|
|
46
|
+
const activeProjects = allProjects.filter(project =>
|
|
47
|
+
project.status === Project.Status.Active
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
const overdueCount = activeTasks.filter(task =>
|
|
51
|
+
task.taskStatus === Task.Status.Overdue
|
|
52
|
+
).length;
|
|
53
|
+
|
|
54
|
+
const nextActionCount = activeTasks.filter(task =>
|
|
55
|
+
task.taskStatus === Task.Status.Next
|
|
56
|
+
).length;
|
|
57
|
+
|
|
58
|
+
const flaggedCount = activeTasks.filter(task => task.flagged).length;
|
|
59
|
+
const inboxCount = activeTasks.filter(task => task.inInbox).length;
|
|
60
|
+
|
|
61
|
+
let lastModified = new Date(0);
|
|
62
|
+
allTasks.forEach(task => {
|
|
63
|
+
if (task.modificationDate && task.modificationDate > lastModified) {
|
|
64
|
+
lastModified = task.modificationDate;
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
return JSON.stringify({
|
|
69
|
+
taskCount: allTasks.length,
|
|
70
|
+
activeTaskCount: activeTasks.length,
|
|
71
|
+
projectCount: allProjects.length,
|
|
72
|
+
activeProjectCount: activeProjects.length,
|
|
73
|
+
folderCount: flattenedFolders.length,
|
|
74
|
+
tagCount: flattenedTags.filter(tag => tag.active).length,
|
|
75
|
+
overdueCount: overdueCount,
|
|
76
|
+
nextActionCount: nextActionCount,
|
|
77
|
+
flaggedCount: flaggedCount,
|
|
78
|
+
inboxCount: inboxCount,
|
|
79
|
+
lastModified: lastModified.toISOString()
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return JSON.stringify({
|
|
84
|
+
error: "Failed to get database stats: " + error.toString()
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
})();
|
|
88
|
+
JS
|
|
89
|
+
|
|
90
|
+
# rubocop:disable Metrics/MethodLength
|
|
91
|
+
def changes_script(since_iso)
|
|
92
|
+
escaped_since = Infrastructure::JsEmbed.double_quoted_string(since_iso)
|
|
93
|
+
|
|
94
|
+
<<~JS
|
|
95
|
+
(() => {
|
|
96
|
+
try {
|
|
97
|
+
const sinceDate = new Date("#{escaped_since}");
|
|
98
|
+
|
|
99
|
+
const allTasks = flattenedTasks;
|
|
100
|
+
|
|
101
|
+
const newTasks = allTasks.filter(task =>
|
|
102
|
+
task.creationDate && task.creationDate > sinceDate
|
|
103
|
+
).map(task => ({
|
|
104
|
+
id: task.id.primaryKey,
|
|
105
|
+
name: task.name,
|
|
106
|
+
creationDate: task.creationDate.toISOString()
|
|
107
|
+
}));
|
|
108
|
+
|
|
109
|
+
const updatedTasks = allTasks.filter(task =>
|
|
110
|
+
task.modificationDate &&
|
|
111
|
+
task.modificationDate > sinceDate &&
|
|
112
|
+
task.creationDate &&
|
|
113
|
+
task.creationDate <= sinceDate
|
|
114
|
+
).map(task => ({
|
|
115
|
+
id: task.id.primaryKey,
|
|
116
|
+
name: task.name,
|
|
117
|
+
modificationDate: task.modificationDate.toISOString()
|
|
118
|
+
}));
|
|
119
|
+
|
|
120
|
+
const completedTasks = allTasks.filter(task =>
|
|
121
|
+
task.completionDate &&
|
|
122
|
+
task.completionDate > sinceDate
|
|
123
|
+
).map(task => ({
|
|
124
|
+
id: task.id.primaryKey,
|
|
125
|
+
name: task.name,
|
|
126
|
+
completionDate: task.completionDate.toISOString()
|
|
127
|
+
}));
|
|
128
|
+
|
|
129
|
+
const allProjects = flattenedProjects;
|
|
130
|
+
|
|
131
|
+
const newProjects = allProjects.filter(project =>
|
|
132
|
+
project.creationDate && project.creationDate > sinceDate
|
|
133
|
+
).map(project => ({
|
|
134
|
+
id: project.id.primaryKey,
|
|
135
|
+
name: project.name,
|
|
136
|
+
creationDate: project.creationDate.toISOString()
|
|
137
|
+
}));
|
|
138
|
+
|
|
139
|
+
const updatedProjects = allProjects.filter(project =>
|
|
140
|
+
project.modificationDate &&
|
|
141
|
+
project.modificationDate > sinceDate &&
|
|
142
|
+
project.creationDate &&
|
|
143
|
+
project.creationDate <= sinceDate
|
|
144
|
+
).map(project => ({
|
|
145
|
+
id: project.id.primaryKey,
|
|
146
|
+
name: project.name,
|
|
147
|
+
modificationDate: project.modificationDate.toISOString()
|
|
148
|
+
}));
|
|
149
|
+
|
|
150
|
+
return JSON.stringify({
|
|
151
|
+
newTasks: newTasks,
|
|
152
|
+
updatedTasks: updatedTasks,
|
|
153
|
+
completedTasks: completedTasks,
|
|
154
|
+
newProjects: newProjects,
|
|
155
|
+
updatedProjects: updatedProjects
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
} catch (error) {
|
|
159
|
+
return JSON.stringify({
|
|
160
|
+
error: "Failed to get changes: " + error.toString()
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
})();
|
|
164
|
+
JS
|
|
165
|
+
end
|
|
166
|
+
# rubocop:enable Metrics/MethodLength
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
# Collapse a {Infrastructure::ScriptRunner} {Result} into a {Result} over the parsed Hash payload.
|
|
171
|
+
def script_payload_result(execution)
|
|
172
|
+
execution.and_then do |payload|
|
|
173
|
+
if payload.is_a?(Hash) && payload["error"]
|
|
174
|
+
Result.error(payload["error"])
|
|
175
|
+
else
|
|
176
|
+
Result.ok(payload)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
# rubocop:enable Metrics/ModuleLength
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fast_mcp"
|
|
4
|
+
|
|
5
|
+
require_relative "mcp_envelope"
|
|
6
|
+
require_relative "operation_factory"
|
|
7
|
+
require_relative "../messages/add_omnifocus_task"
|
|
8
|
+
require_relative "../operations/add_omnifocus_task"
|
|
9
|
+
require_relative "../params"
|
|
10
|
+
|
|
11
|
+
module OmnifocusMcp
|
|
12
|
+
module Tools
|
|
13
|
+
module Definitions
|
|
14
|
+
# `FastMcp::Tool` for `add_omnifocus_task`.
|
|
15
|
+
class AddOmniFocusTaskTool < FastMcp::Tool
|
|
16
|
+
tool_name "add_omnifocus_task"
|
|
17
|
+
description "Add a new task to OmniFocus"
|
|
18
|
+
|
|
19
|
+
arguments do
|
|
20
|
+
required(:name).filled(:string).description("The name of the task")
|
|
21
|
+
optional(:note).filled(:string).description("Additional notes for the task")
|
|
22
|
+
optional(:dueDate).filled(:string)
|
|
23
|
+
.description("The due date of the task in ISO format (YYYY-MM-DD or full ISO date)")
|
|
24
|
+
optional(:deferDate).filled(:string)
|
|
25
|
+
.description("The defer date of the task in ISO format (YYYY-MM-DD or full ISO date)")
|
|
26
|
+
optional(:plannedDate).filled(:string).description(
|
|
27
|
+
"The planned date of the task in ISO format (YYYY-MM-DD or full ISO date) - " \
|
|
28
|
+
"indicates intention to work on this task on this date"
|
|
29
|
+
)
|
|
30
|
+
optional(:flagged).filled(:bool).description("Whether the task is flagged or not")
|
|
31
|
+
optional(:estimatedMinutes).filled(:integer)
|
|
32
|
+
.description("Estimated time to complete the task, in minutes")
|
|
33
|
+
optional(:tags).array(:string).description("Tags to assign to the task")
|
|
34
|
+
optional(:projectName).filled(:string).description(
|
|
35
|
+
"The name of the project to add the task to (will add to inbox if not specified)"
|
|
36
|
+
)
|
|
37
|
+
optional(:parentTaskId).filled(:string).description("ID of the parent task (preferred for accuracy)")
|
|
38
|
+
optional(:parentTaskName).filled(:string).description(
|
|
39
|
+
"Name of the parent task (used if ID not provided; matched within project or globally if no project)"
|
|
40
|
+
)
|
|
41
|
+
optional(:hierarchyLevel).filled(:integer, gteq?: 0).description(
|
|
42
|
+
"Explicit level indicator for ordering in batch workflows (0=root) - ignored in single add"
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
extend OperationFactory
|
|
47
|
+
|
|
48
|
+
default_operation_factory { Operations::AddOmniFocusTask.method(:call) }
|
|
49
|
+
|
|
50
|
+
def call(**args)
|
|
51
|
+
McpEnvelope.safely("creating task") do
|
|
52
|
+
operation.call(Params::AddTaskParams.from_mcp(args)).fold(
|
|
53
|
+
on_ok: ->(created) { McpEnvelope::ToolReply.success(Messages::AddOmniFocusTask.success(args, created)) },
|
|
54
|
+
on_error: ->(err) { McpEnvelope::ToolReply.failure(Messages::AddOmniFocusTask.failure(err)) }
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fast_mcp"
|
|
4
|
+
|
|
5
|
+
require_relative "mcp_envelope"
|
|
6
|
+
require_relative "operation_factory"
|
|
7
|
+
require_relative "../messages/add_project"
|
|
8
|
+
require_relative "../operations/add_project"
|
|
9
|
+
require_relative "../params"
|
|
10
|
+
|
|
11
|
+
module OmnifocusMcp
|
|
12
|
+
module Tools
|
|
13
|
+
module Definitions
|
|
14
|
+
# `FastMcp::Tool` for `add_project`.
|
|
15
|
+
class AddProjectTool < FastMcp::Tool
|
|
16
|
+
tool_name "add_project"
|
|
17
|
+
description "Add a new project to OmniFocus"
|
|
18
|
+
|
|
19
|
+
arguments do
|
|
20
|
+
required(:name).filled(:string).description("The name of the project")
|
|
21
|
+
optional(:note).filled(:string).description("Additional notes for the project")
|
|
22
|
+
optional(:dueDate).filled(:string)
|
|
23
|
+
.description("The due date of the project in ISO format (YYYY-MM-DD or full ISO date)")
|
|
24
|
+
optional(:deferDate).filled(:string)
|
|
25
|
+
.description(
|
|
26
|
+
"The defer date of the project in ISO format (YYYY-MM-DD or full ISO date)"
|
|
27
|
+
)
|
|
28
|
+
optional(:flagged).filled(:bool).description("Whether the project is flagged or not")
|
|
29
|
+
optional(:estimatedMinutes).filled(:integer)
|
|
30
|
+
.description("Estimated time to complete the project, in minutes")
|
|
31
|
+
optional(:tags).array(:string).description("Tags to assign to the project")
|
|
32
|
+
optional(:folderName).filled(:string).description(
|
|
33
|
+
"The name of the folder to add the project to (will add to root if not specified)"
|
|
34
|
+
)
|
|
35
|
+
optional(:sequential).filled(:bool)
|
|
36
|
+
.description("Whether tasks in the project should be sequential (default: false)")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
extend OperationFactory
|
|
40
|
+
|
|
41
|
+
default_operation_factory { Operations::AddProject.method(:call) }
|
|
42
|
+
|
|
43
|
+
def call(**args)
|
|
44
|
+
McpEnvelope.safely("creating project") do
|
|
45
|
+
operation.call(Params::AddProjectParams.from_mcp(args)).fold(
|
|
46
|
+
on_ok: ->(_created) { McpEnvelope::ToolReply.success(Messages::AddProject.success(args)) },
|
|
47
|
+
on_error: ->(err) { McpEnvelope::ToolReply.failure(Messages::AddProject.failure(err)) }
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fast_mcp"
|
|
4
|
+
|
|
5
|
+
require_relative "mcp_envelope"
|
|
6
|
+
require_relative "operation_factory"
|
|
7
|
+
require_relative "../operations/batch_add_items"
|
|
8
|
+
require_relative "../params"
|
|
9
|
+
require_relative "../presenters/batch_report"
|
|
10
|
+
|
|
11
|
+
module OmnifocusMcp
|
|
12
|
+
module Tools
|
|
13
|
+
module Definitions
|
|
14
|
+
# `FastMcp::Tool` for `batch_add_items`.
|
|
15
|
+
class BatchAddItemsTool < FastMcp::Tool
|
|
16
|
+
tool_name "batch_add_items"
|
|
17
|
+
description "Add multiple tasks or projects to OmniFocus in a single operation"
|
|
18
|
+
|
|
19
|
+
# rubocop:disable Metrics/BlockLength
|
|
20
|
+
arguments do
|
|
21
|
+
required(:items).array(:hash) do
|
|
22
|
+
required(:type).filled(included_in?: %w[task project])
|
|
23
|
+
.description("Type of item to add ('task' or 'project')")
|
|
24
|
+
required(:name).filled(:string).description("The name of the item")
|
|
25
|
+
optional(:note).filled(:string).description("Additional notes for the item")
|
|
26
|
+
optional(:dueDate).filled(:string)
|
|
27
|
+
.description("The due date in ISO format (YYYY-MM-DD or full ISO date)")
|
|
28
|
+
optional(:deferDate).filled(:string)
|
|
29
|
+
.description("The defer date in ISO format (YYYY-MM-DD or full ISO date)")
|
|
30
|
+
optional(:plannedDate).filled(:string).description(
|
|
31
|
+
"The planned date in ISO format (YYYY-MM-DD or full ISO date) - tasks only"
|
|
32
|
+
)
|
|
33
|
+
optional(:flagged).filled(:bool).description("Whether the item is flagged or not")
|
|
34
|
+
optional(:estimatedMinutes).filled(:integer)
|
|
35
|
+
.description("Estimated time to complete the item, in minutes")
|
|
36
|
+
optional(:tags).array(:string).description("Tags to assign to the item")
|
|
37
|
+
|
|
38
|
+
optional(:projectName).filled(:string)
|
|
39
|
+
.description("For tasks: The name of the project to add the task to")
|
|
40
|
+
optional(:parentTaskId).filled(:string).description("For tasks: ID of the parent task")
|
|
41
|
+
optional(:parentTaskName).filled(:string).description(
|
|
42
|
+
"For tasks: Name of the parent task (scoped to project when provided)"
|
|
43
|
+
)
|
|
44
|
+
optional(:tempId).filled(:string).description("For tasks: Temporary ID for within-batch references")
|
|
45
|
+
optional(:parentTempId).filled(:string)
|
|
46
|
+
.description("For tasks: Reference to parent's tempId within the batch")
|
|
47
|
+
optional(:hierarchyLevel).filled(:integer, gteq?: 0)
|
|
48
|
+
.description("Optional ordering hint (0=root, 1=child, ...)")
|
|
49
|
+
|
|
50
|
+
optional(:folderName).filled(:string)
|
|
51
|
+
.description("For projects: The name of the folder to add the project to")
|
|
52
|
+
optional(:sequential).filled(:bool)
|
|
53
|
+
.description("For projects: Whether tasks in the project should be sequential")
|
|
54
|
+
end.description("Array of items (tasks or projects) to add")
|
|
55
|
+
optional(:createSequentially).filled(:bool).description(
|
|
56
|
+
"Process parents before children; when false, best-effort order will still try to resolve parents first"
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
# rubocop:enable Metrics/BlockLength
|
|
60
|
+
|
|
61
|
+
extend OperationFactory
|
|
62
|
+
|
|
63
|
+
default_operation_factory { Operations::BatchAddItems.method(:call) }
|
|
64
|
+
|
|
65
|
+
def call(**args)
|
|
66
|
+
McpEnvelope.safely("processing batch operation") do
|
|
67
|
+
items = Array(args[:items]).map { |item| Params::BatchAddItemParams.from_mcp(item) }
|
|
68
|
+
result = operation.call(items)
|
|
69
|
+
|
|
70
|
+
result.fold(
|
|
71
|
+
on_ok: ->(per_item) { success_reply(per_item, items) },
|
|
72
|
+
on_error: ->(error) { failure_reply(error) }
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def failure_reply(error)
|
|
80
|
+
OmnifocusMcp.logger.warn("[batch_add_items] failure result: #{error.inspect}")
|
|
81
|
+
|
|
82
|
+
McpEnvelope::ToolReply.failure(
|
|
83
|
+
Presenters::BatchReport.format_failure(
|
|
84
|
+
error, results: [], items: []
|
|
85
|
+
) { |item_result, item| Presenters::BatchReport.add_detail(item_result, item) }
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def success_reply(per_item, items)
|
|
90
|
+
text = Presenters::BatchReport.format_success(
|
|
91
|
+
past_tense: "added", failure_verb: "add", results: per_item, items: items
|
|
92
|
+
) do |item_result, item|
|
|
93
|
+
Presenters::BatchReport.add_detail(item_result, item)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
if Presenters::BatchReport.all_failed?(per_item)
|
|
97
|
+
McpEnvelope::ToolReply.failure(text)
|
|
98
|
+
else
|
|
99
|
+
McpEnvelope::ToolReply.success(text)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|