Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
458 changes: 419 additions & 39 deletions Plugin/FocusRelayBridge.omnijs/Resources/BridgeLibrary.js

Large diffs are not rendered by default.

70 changes: 70 additions & 0 deletions Sources/FocusRelayCLI/CLIHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,73 @@ struct TaskFilterOptions: ParsableArguments {
)
}
}

struct TaskPatchOptions: ParsableArguments {
@Option(help: "New task name.")
var name: String? = nil

@Option(help: "Replace the task note with this value.")
var note: String? = nil

@Option(name: .customLong("note-append"), help: "Append this string to the task note.")
var noteAppend: String? = nil

@Option(help: "Set flagged state (true/false).")
var flagged: Bool? = nil

@Option(name: .customLong("estimated-minutes"), help: "Set estimated minutes.")
var estimatedMinutes: Int? = nil

@Option(name: .customLong("due-date"), help: "Set due date as ISO8601.")
var dueDate: String? = nil

@Flag(name: .customLong("clear-due-date"), help: "Clear the due date.")
var clearDueDate: Bool = false

@Option(name: .customLong("defer-date"), help: "Set defer date as ISO8601.")
var deferDate: String? = nil

@Flag(name: .customLong("clear-defer-date"), help: "Clear the defer date.")
var clearDeferDate: Bool = false

@Option(name: .customLong("tag-add"), help: "Comma-separated tag IDs to add.")
var tagAdd: String? = nil

@Option(name: .customLong("tag-remove"), help: "Comma-separated tag IDs to remove.")
var tagRemove: String? = nil

@Option(name: .customLong("tag-set"), help: "Comma-separated tag IDs to set exactly.")
var tagSet: String? = nil

@Flag(name: .customLong("tag-clear"), help: "Clear all tags from the task.")
var tagClear: Bool = false

func makeTaskPatchMutation() throws -> TaskPatchMutation {
let add = FieldList.parse(tagAdd)
let remove = FieldList.parse(tagRemove)
let set = FieldList.parse(tagSet)
let tagMutation: TagMutation? = (add.isEmpty && remove.isEmpty && set.isEmpty && !tagClear)
? nil
: TagMutation(
add: add.isEmpty ? nil : add,
remove: remove.isEmpty ? nil : remove,
set: set.isEmpty ? nil : set,
clear: tagClear
)

let patch = TaskPatchMutation(
name: name,
note: note,
noteAppend: noteAppend,
flagged: flagged,
estimatedMinutes: estimatedMinutes,
dueDate: try ISO8601DateParser.parseOptional(dueDate, argumentName: "--due-date"),
clearDueDate: clearDueDate,
deferDate: try ISO8601DateParser.parseOptional(deferDate, argumentName: "--defer-date"),
clearDeferDate: clearDeferDate,
tags: tagMutation
)
try patch.validate()
return patch
}
}
41 changes: 41 additions & 0 deletions Sources/FocusRelayCLI/FocusRelayCLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ struct FocusRelayCLI: AsyncParsableCommand {
GetTask.self,
ListProjects.self,
ListTags.self,
UpdateTasks.self,
TaskCounts.self,
ProjectCounts.self,
DebugInboxProbe.self,
Expand Down Expand Up @@ -185,6 +186,46 @@ struct ListTags: AsyncParsableCommand {
}
}

struct UpdateTasks: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "update-tasks",
abstract: "Apply one shared task patch to multiple task IDs.",
aliases: ["update_tasks"]
)

@Argument(help: "Task IDs to update.")
var ids: [String] = []

@OptionGroup var patch: TaskPatchOptions

@Flag(name: .customLong("preview-only"), help: "Validate and resolve targets without mutating.")
var previewOnly: Bool = false

@Flag(help: "Verify the final state after mutation.")
var verify: Bool = false

@Option(name: .customLong("return-fields"), help: "Comma-separated task fields to include in per-item results.")
var returnFields: String?

func run() async throws {
let service = OmniFocusBridgeService()
let request = MutationRequest(
targetType: .task,
targetIDs: ids,
operation: MutationOperation(
kind: .updateTasks,
taskPatch: try patch.makeTaskPatchMutation()
),
previewOnly: previewOnly,
verify: verify,
returnFields: FieldList.parse(returnFields)
)

let result = try await service.performMutation(request)
print(try encodeJSON(result))
}
}

struct TaskCounts: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "task-counts",
Expand Down
66 changes: 66 additions & 0 deletions Sources/FocusRelayServer/FocusRelayServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,53 @@ public enum FocusRelayServer {
),
annotations: .init(readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false)
),
Tool(
name: "update_tasks",
description: "Apply one shared task field patch to multiple task IDs. Supports name, note replace, note append, flagged, estimated minutes, due date set/clear, defer date set/clear, and deterministic tag add/remove/set/clear operations.\n\nV1 constraints:\n- task IDs only\n- one shared patch for all targets\n- no completion changes\n- no moves/reparenting\n- no plannedDate writes\n\nUse previewOnly=true to validate without mutating. Use verify=true to confirm the final state. Use returnFields to request compact post-write task fields in the per-item results.",
inputSchema: toolSchema(
properties: [
"targetIDs": .object([
"type": .string("array"),
"description": .string("Task IDs to update."),
"items": .object(["type": .string("string")])
]),
"taskPatch": .object([
"type": .string("object"),
"description": .string("Shared task patch applied to every task ID in targetIDs."),
"properties": .object([
"name": propertySchema(type: "string", description: "Set a new task name."),
"note": propertySchema(type: "string", description: "Replace the task note."),
"noteAppend": propertySchema(type: "string", description: "Append text to the task note."),
"flagged": propertySchema(type: "boolean", description: "Set flagged state."),
"estimatedMinutes": propertySchema(type: "integer", description: "Set estimated minutes."),
"dueDate": propertySchema(type: "string", description: "Set due date as ISO8601 UTC.", examples: [.string("2026-04-18T12:00:00Z")]),
"clearDueDate": propertySchema(type: "boolean", description: "Clear the due date."),
"deferDate": propertySchema(type: "string", description: "Set defer date as ISO8601 UTC.", examples: [.string("2026-04-19T09:00:00Z")]),
"clearDeferDate": propertySchema(type: "boolean", description: "Clear the defer date."),
"tags": .object([
"type": .string("object"),
"description": .string("Deterministic tag mutation. Tag IDs only in v1."),
"properties": .object([
"add": .object(["type": .string("array"), "items": .object(["type": .string("string")])]),
"remove": .object(["type": .string("array"), "items": .object(["type": .string("string")])]),
"set": .object(["type": .string("array"), "items": .object(["type": .string("string")])]),
"clear": propertySchema(type: "boolean", description: "Clear all tags.")
])
])
])
]),
"previewOnly": propertySchema(type: "boolean", description: "Validate and resolve targets without mutating."),
"verify": propertySchema(type: "boolean", description: "Verify the final state after mutation."),
"returnFields": .object([
"type": .string("array"),
"description": .string("Optional task fields to return in per-item results after mutation."),
"items": .object(["type": .string("string")])
])
],
required: ["targetIDs", "taskPatch"]
),
annotations: .init(readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false)
),
Tool(
name: "get_task_counts",
description: "Get task counts for a filter. Returns {total, available, completed, flagged}.",
Expand Down Expand Up @@ -376,6 +423,25 @@ public enum FocusRelayServer {
let items = result.items.map { makeTagOutput(from: $0, fields: fieldSet, includeTaskCounts: includeTaskCounts) }
let output = PageOutput(items: items, nextCursor: result.nextCursor, returnedCount: result.returnedCount, totalCount: result.totalCount)
return .init(content: [.text(try encodeJSON(output))])
case "update_tasks":
let targetIDs = try decodeArgument([String].self, from: params.arguments, key: "targetIDs") ?? []
let taskPatch = try decodeArgument(TaskPatchMutation.self, from: params.arguments, key: "taskPatch")
let previewOnly = try decodeArgument(Bool.self, from: params.arguments, key: "previewOnly") ?? false
let verify = try decodeArgument(Bool.self, from: params.arguments, key: "verify") ?? false
let returnFields = decodeStringArray(params.arguments?["returnFields"])
let request = MutationRequest(
targetType: .task,
targetIDs: targetIDs,
operation: MutationOperation(
kind: .updateTasks,
taskPatch: taskPatch
),
previewOnly: previewOnly,
verify: verify,
returnFields: returnFields
)
let result = try await service.performMutation(request)
return .init(content: [.text(try encodeJSON(result))])
case "get_task_counts":
let filter = try decodeArgument(TaskFilter.self, from: params.arguments, key: "filter") ?? TaskFilter()
let counts = try await service.getTaskCounts(filter: filter)
Expand Down
6 changes: 6 additions & 0 deletions Sources/OmniFocusAutomation/OmniFocusAutomation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,12 @@ public final class OmniAutomationService: OmniFocusService {
public func performMutation(_ request: MutationRequest) async throws -> MutationResponse {
try request.validate()

if request.operation.kind == .updateTasks {
// Keep the first real mutation path in the bridge transport so CLI and MCP
// share one execution implementation for v1 task updates.
return try await OmniFocusBridgeService().performMutation(request)
}

let requestData = try requestEncoder.encode(request)
guard let requestJSON = String(data: requestData, encoding: .utf8) else {
throw AutomationError.executionFailed("Failed to encode mutation request JSON")
Expand Down
79 changes: 79 additions & 0 deletions Sources/OmniFocusCore/MutationModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,43 @@ public struct TagMutation: Codable, Sendable, Equatable {
self.set = set
self.clear = clear
}

public var isEmpty: Bool {
!clear &&
(add?.isEmpty ?? true) &&
(remove?.isEmpty ?? true) &&
(set?.isEmpty ?? true)
}

public func validate() throws {
if let set, set.isEmpty {
throw MutationValidationError("Tag set operations must include at least one tag ID.")
}
if let add, add.isEmpty {
throw MutationValidationError("Tag add operations must include at least one tag ID.")
}
if let remove, remove.isEmpty {
throw MutationValidationError("Tag remove operations must include at least one tag ID.")
}
if set != nil && (add != nil || remove != nil || clear) {
throw MutationValidationError("Tag set operations cannot be combined with add, remove, or clear.")
}
if clear && (add != nil || remove != nil) {
throw MutationValidationError("Tag clear operations cannot be combined with add or remove.")
}
if isEmpty {
throw MutationValidationError("Tag operations must include add, remove, set, or clear.")
}
try validateUniqueIdentifiers(add, label: "Tag add")
try validateUniqueIdentifiers(remove, label: "Tag remove")
try validateUniqueIdentifiers(set, label: "Tag set")
if let add, let remove {
let overlap = Set(add).intersection(remove)
if !overlap.isEmpty {
throw MutationValidationError("Tag add and remove operations must not reference the same tag IDs.")
}
}
}
}

public struct TaskPatchMutation: Codable, Sendable, Equatable {
Expand Down Expand Up @@ -147,6 +184,19 @@ public struct TaskPatchMutation: Codable, Sendable, Equatable {
!clearDeferDate &&
tags == nil
}

public func validate() throws {
if dueDate != nil && clearDueDate {
throw MutationValidationError("Task patches cannot set and clear dueDate in the same request.")
}
if deferDate != nil && clearDeferDate {
throw MutationValidationError("Task patches cannot set and clear deferDate in the same request.")
}
if let estimatedMinutes, estimatedMinutes < 0 {
throw MutationValidationError("estimatedMinutes must be zero or greater.")
}
try tags?.validate()
}
}

public struct ProjectPatchMutation: Codable, Sendable, Equatable {
Expand Down Expand Up @@ -304,6 +354,7 @@ public struct MutationRequest: Codable, Sendable, Equatable {
guard let taskPatch = operation.taskPatch, !taskPatch.isEmpty else {
throw MutationValidationError("update_tasks requires a non-empty taskPatch.")
}
try taskPatch.validate()
case .setTasksCompletion:
guard targetType == .task else {
throw MutationValidationError("set_tasks_completion requires task targets.")
Expand Down Expand Up @@ -347,7 +398,28 @@ public struct MutationRequest: Codable, Sendable, Equatable {
throw MutationValidationError("move_projects requires a move payload.")
}
}

if let returnFields {
let allowedFields = targetType == .task ? Self.allowedTaskReturnFields : Self.allowedProjectReturnFields
let unsupportedFields = returnFields.filter { !allowedFields.contains($0) }
if !unsupportedFields.isEmpty {
throw MutationValidationError("Unsupported returnFields for \(targetType.rawValue) mutations: \(unsupportedFields.joined(separator: ", ")).")
}
}
}

private static let allowedTaskReturnFields: Set<String> = [
"id", "name", "note", "projectID", "projectName",
"tagIDs", "tagNames", "dueDate", "plannedDate", "deferDate",
"completionDate", "completed", "flagged", "estimatedMinutes", "available"
]

private static let allowedProjectReturnFields: Set<String> = [
"id", "name", "note", "status", "flagged", "lastReviewDate",
"nextReviewDate", "reviewInterval", "availableTasks", "remainingTasks",
"completedTasks", "droppedTasks", "totalTasks", "hasChildren", "nextTask",
"containsSingletonActions", "isStalled", "completionDate"
]
}

public struct MutationItemResult: Codable, Sendable, Equatable {
Expand Down Expand Up @@ -412,3 +484,10 @@ public struct MutationValidationError: Error, LocalizedError, Sendable {

public var errorDescription: String? { message }
}

private func validateUniqueIdentifiers(_ ids: [String]?, label: String) throws {
guard let ids else { return }
if Set(ids).count != ids.count {
throw MutationValidationError("\(label) operations must not contain duplicate tag IDs.")
}
}
34 changes: 34 additions & 0 deletions Tests/FocusRelayCLITests/FocusRelayCLITests.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import Foundation
import Testing
@testable import FocusRelayCLI
import OmniFocusCore

@Test
func fieldListParsesCommaSeparatedValues() {
Expand All @@ -26,6 +27,39 @@ func iso8601DateParserRejectsInvalidDates() {
#expect(didThrow)
}

@Test
func taskPatchOptionsBuildSharedTaskPatch() throws {
let options = try TaskPatchOptions.parse([
"--name", "Renamed",
"--note-append", "\nFollow up",
"--flagged", "true",
"--estimated-minutes", "30",
"--due-date", "2026-04-18T12:00:00Z",
"--tag-add", "tag-1,tag-2"
])

let patch = try options.makeTaskPatchMutation()

#expect(patch.name == "Renamed")
#expect(patch.noteAppend == "\nFollow up")
#expect(patch.flagged == true)
#expect(patch.estimatedMinutes == 30)
#expect(patch.dueDate != nil)
#expect(patch.tags?.add == ["tag-1", "tag-2"])
}

@Test
func taskPatchOptionsRejectConflictingTagModes() {
let options = try! TaskPatchOptions.parse([
"--tag-add", "tag-1",
"--tag-set", "tag-2"
])

#expect(throws: MutationValidationError.self) {
_ = try options.makeTaskPatchMutation()
}
}

@Test
func benchmarkGateTaskCountScenariosCoverBoundaryAndFlaggedCases() {
let contractNames = gateTaskCountContractScenarios().map(\.name)
Expand Down
Loading