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
257 changes: 257 additions & 0 deletions Plugin/FocusRelayBridge.omnijs/Resources/BridgeLibrary.js
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,139 @@
return taskReturnedFields(task, returnFields);
}

function destinationLabel(move, projectByID, taskByID) {
if (move.destinationKind === "inbox") {
return "inbox";
}
if (move.destinationKind === "project") {
const project = projectByID[move.destinationID];
const name = String(safe(() => project.name) || move.destinationID);
return "project " + name;
}
if (move.destinationKind === "parent_task") {
const task = taskByID[move.destinationID];
const name = String(safe(() => task.name) || move.destinationID);
return "parent task " + name;
}
return String(move.destinationKind);
}

function buildMoveDestination(move, projectByID, taskByID) {
const position = String(move.position || "ending").toLowerCase();
if (move.destinationKind === "inbox") {
return {
ok: true,
location: position === "beginning" ? inbox.beginning : inbox.ending,
label: "inbox"
};
}
if (move.destinationKind === "project") {
const project = projectByID[move.destinationID];
if (!project) {
return { ok: false, message: "Destination project ID not found." };
}
return {
ok: true,
location: position === "beginning" ? project.beginning : project.ending,
label: "project " + String(safe(() => project.name) || move.destinationID)
};
}
if (move.destinationKind === "parent_task") {
const parentTask = taskByID[move.destinationID];
if (!parentTask) {
return { ok: false, message: "Destination parent task ID not found." };
}
return {
ok: true,
location: position === "beginning" ? parentTask.beginning : parentTask.ending,
parentTask: parentTask,
label: "parent task " + String(safe(() => parentTask.name) || move.destinationID)
};
}
return { ok: false, message: "Unsupported move destination kind " + String(move.destinationKind) + "." };
}

function validateMoveMutation(move, targetIDs, projectByID, taskByID) {
if (!move || !move.destinationKind) {
return "move_tasks requires a move payload.";
}

const position = String(move.position || "ending").toLowerCase();
if (position !== "beginning" && position !== "ending") {
return "Move position must be beginning or ending.";
}

if (move.destinationKind === "inbox") {
if (move.destinationID !== undefined && move.destinationID !== null) {
return "Inbox moves must not include a destinationID.";
}
return null;
}

if (!move.destinationID) {
return "Move destination requires a destinationID.";
}

if (move.destinationKind === "project") {
return projectByID[move.destinationID] ? null : "Destination project ID not found.";
}

if (move.destinationKind === "parent_task") {
const parentTask = taskByID[move.destinationID];
if (!parentTask) {
return "Destination parent task ID not found.";
}
for (let i = 0; i < targetIDs.length; i += 1) {
const task = taskByID[targetIDs[i]];
if (!task) { continue; }
const taskID = String(safe(() => task.id.primaryKey) || "");
const parentID = String(safe(() => parentTask.id.primaryKey) || "");
if (taskID === parentID) {
return "Tasks cannot be moved under themselves.";
}
const descendantIDs = new Set(toTaskArray(safe(() => task.flattenedTasks)).map(item => String(safe(() => item.id.primaryKey) || "")));
if (descendantIDs.has(parentID)) {
return "Tasks cannot be moved under one of their descendants.";
}
}
return null;
}

return "Unsupported move destination kind " + String(move.destinationKind) + ".";
}

function verifyTaskMove(task, move, destination, projectByID, taskByID) {
const project = safe(() => task.containingProject);
const parent = safe(() => task.parent);
const projectID = String(safe(() => project.id.primaryKey) || "");
const parentID = String(safe(() => parent.id.primaryKey) || "");

if (move.destinationKind === "inbox") {
if (!Boolean(safe(() => task.inInbox))) {
return "task did not return to inbox.";
}
return null;
}

if (move.destinationKind === "project") {
const destinationID = String(move.destinationID || "");
if (projectID !== destinationID) {
return "task project did not match the requested destination.";
}
return null;
}

if (move.destinationKind === "parent_task") {
const destinationID = String(move.destinationID || "");
if (parentID !== destinationID) {
return "task parent did not match the requested destination.";
}
return null;
}

return "Unsupported move destination kind " + String(move.destinationKind) + ".";
}

// ============================================================
// STATUS MODULE - Single Source of Truth for OmniFocus Status
// ============================================================
Expand Down Expand Up @@ -877,6 +1010,130 @@
warnings: []
};
}
} else if (operation.kind === "move_tasks") {
const projects = toTaskArray(safe(() => flattenedProjects));
const tasks = toTaskArray(safe(() => flattenedTasks));
const projectByID = {};
const taskByID = {};
projects.forEach(project => {
const id = String(safe(() => project.id.primaryKey) || "");
if (id.length > 0) { projectByID[id] = project; }
});
tasks.forEach(task => {
const id = String(safe(() => task.id.primaryKey) || "");
if (id.length > 0) { taskByID[id] = task; }
});

const moveError = validateMoveMutation(operation.move, ids, projectByID, taskByID);
if (moveError) {
const results = ids.map(id => ({
id: id,
status: "failed",
message: moveError
}));
response.data = {
targetType: targetType,
operationKind: operation.kind,
previewOnly: Boolean(mutation.previewOnly),
verify: Boolean(mutation.verify),
requestedCount: ids.length,
successCount: 0,
failureCount: results.length,
results: results,
warnings: []
};
} else {
const destination = buildMoveDestination(operation.move, projectByID, taskByID);
if (!destination.ok) {
const results = ids.map(id => ({
id: id,
status: "failed",
message: destination.message
}));
response.data = {
targetType: targetType,
operationKind: operation.kind,
previewOnly: Boolean(mutation.previewOnly),
verify: Boolean(mutation.verify),
requestedCount: ids.length,
successCount: 0,
failureCount: results.length,
results: results,
warnings: []
};
} else {
const results = [];
let successCount = 0;
let mutatedAny = false;

ids.forEach(id => {
const task = taskByID[id];
if (!task) {
results.push({
id: id,
status: "failed",
message: "Target ID not found."
});
return;
}

if (mutation.previewOnly) {
results.push({
id: id,
status: "previewed",
message: "Validated move target and destination " + destination.label + " for preview."
});
successCount += 1;
return;
}

moveTasks([task], destination.location);
mutatedAny = true;

if (mutation.verify) {
const verificationError = verifyTaskMove(task, operation.move, destination, projectByID, taskByID);
if (verificationError) {
results.push({
id: id,
status: "failed",
message: "Mutation applied but verification failed: " + verificationError,
returnedFields: taskReturnedFields(task, mutation.returnFields)
});
return;
}
}

let message = "Task moved to " + destination.label + ".";
if (mutation.verify) {
message = message.replace(/\.$/, "") + " Verified.";
}

results.push({
id: id,
status: "mutated",
message: message,
returnedFields: taskReturnedFields(task, mutation.returnFields)
});
successCount += 1;
});

if (mutatedAny) {
safe(() => save());
}

response.data = {
targetType: targetType,
operationKind: operation.kind,
previewOnly: Boolean(mutation.previewOnly),
verify: Boolean(mutation.verify),
requestedCount: ids.length,
successCount: successCount,
failureCount: results.length - successCount,
results: results,
warnings: []
};
}
}
} else {
if (!mutation.previewOnly) {
throw new Error("Mutation execution is not implemented yet for " + String(operation.kind) + ". Use previewOnly=true.");
Expand Down
1 change: 1 addition & 0 deletions Sources/FocusRelayCLI/CLIHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import Foundation
import OmniFocusCore

extension MutationCompletionState: ExpressibleByArgument {}
extension MutationMoveDestinationKind: ExpressibleByArgument {}

enum FieldList {
static func parse(_ raw: String?) -> [String] {
Expand Down
52 changes: 52 additions & 0 deletions Sources/FocusRelayCLI/FocusRelayCLI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ struct FocusRelayCLI: AsyncParsableCommand {
ListTags.self,
UpdateTasks.self,
SetTasksCompletion.self,
MoveTasks.self,
TaskCounts.self,
ProjectCounts.self,
DebugInboxProbe.self,
Expand Down Expand Up @@ -268,6 +269,57 @@ struct SetTasksCompletion: AsyncParsableCommand {
}
}

struct MoveTasks: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "move-tasks",
abstract: "Move or reparent multiple tasks to one shared destination.",
aliases: ["move_tasks"]
)

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

@Option(help: "Destination kind: inbox, project, or parent_task.")
var destinationKind: MutationMoveDestinationKind

@Option(help: "Destination ID for project or parent_task moves.")
var destinationID: String?

@Option(help: "Placement within the destination: beginning or ending.")
var position: String = "ending"

@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: .moveTasks,
move: MoveMutation(
destinationKind: destinationKind,
destinationID: destinationID,
position: position
)
),
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
Loading