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
48 changes: 48 additions & 0 deletions Plugin/FocusRelayBridge.omnijs/Resources/BridgeLibrary.js
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,54 @@
const request = readJSON(requestPath);
if (request.op === "ping") {
response.data = { ok: true, plugin: "FocusRelay Bridge", version: "0.1.0" };
} else if (request.op === "perform_mutation") {
const mutation = request.mutation || {};
if (!mutation.previewOnly) {
throw new Error("Mutation execution is not implemented yet. Use previewOnly=true.");
}

const targetType = mutation.targetType;
const ids = Array.isArray(mutation.targetIDs) ? mutation.targetIDs : [];
const pool = targetType === "project" ? toTaskArray(safe(() => flattenedProjects)) : toTaskArray(safe(() => flattenedTasks));
const knownIDs = {};

for (let i = 0; i < pool.length; i += 1) {
const item = pool[i];
const id = String(safe(() => item.id.primaryKey) || "");
if (id.length > 0) {
knownIDs[id] = true;
}
}

const results = ids.map(id => {
const normalized = String(id);
if (knownIDs[normalized]) {
return {
id: normalized,
status: "previewed",
message: "Validated target for preview."
};
}
return {
id: normalized,
status: "failed",
message: "Target ID not found."
};
});

const successCount = results.filter(item => item.status === "previewed").length;
const failureCount = results.length - successCount;
response.data = {
targetType: targetType,
operationKind: mutation.operation ? mutation.operation.kind : null,
previewOnly: true,
verify: Boolean(mutation.verify),
requestedCount: ids.length,
successCount: successCount,
failureCount: failureCount,
results: results,
warnings: []
};
} else if (request.op === "list_inbox" || request.op === "list_tasks") {
const filter = request.filter || {};
const debugListTasks = filter.search === "__debug_list_tasks__";
Expand Down
35 changes: 35 additions & 0 deletions Sources/OmniFocusAutomation/BridgeClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ final class BridgeClient: @unchecked Sendable {
filter: filter,
tagFilter: nil,
projectFilter: nil,
mutation: nil,
fields: fields,
page: page
)
Expand Down Expand Up @@ -86,6 +87,7 @@ final class BridgeClient: @unchecked Sendable {
filter: nil,
tagFilter: nil,
projectFilter: nil,
mutation: nil,
fields: nil,
page: nil
)
Expand Down Expand Up @@ -126,6 +128,7 @@ final class BridgeClient: @unchecked Sendable {
filter: nil,
tagFilter: nil,
projectFilter: projectFilter,
mutation: nil,
fields: fields,
page: page
)
Expand Down Expand Up @@ -176,6 +179,7 @@ final class BridgeClient: @unchecked Sendable {
filter: nil,
tagFilter: tagFilter,
projectFilter: nil,
mutation: nil,
fields: nil,
page: page
)
Expand Down Expand Up @@ -211,6 +215,7 @@ final class BridgeClient: @unchecked Sendable {
filter: nil,
tagFilter: nil,
projectFilter: nil,
mutation: nil,
fields: fields,
page: nil
)
Expand Down Expand Up @@ -252,6 +257,7 @@ final class BridgeClient: @unchecked Sendable {
filter: filter,
tagFilter: nil,
projectFilter: nil,
mutation: nil,
fields: nil,
page: nil
)
Expand All @@ -277,6 +283,7 @@ final class BridgeClient: @unchecked Sendable {
filter: filter,
tagFilter: nil,
projectFilter: nil,
mutation: nil,
fields: nil,
page: nil
)
Expand All @@ -290,6 +297,34 @@ final class BridgeClient: @unchecked Sendable {
throw AutomationError.executionFailed(message)
}

func performMutation(_ mutation: MutationRequest) throws -> MutationResponse {
try mutation.validate()

let requestId = UUID().uuidString
let request = BridgeRequest(
schemaVersion: 1,
requestId: requestId,
op: "perform_mutation",
timestamp: ISO8601DateFormatter().string(from: Date()),
userTimeZone: TimeZone.current.identifier,
id: nil,
filter: nil,
tagFilter: nil,
projectFilter: nil,
mutation: mutation,
fields: nil,
page: nil
)

let response: BridgeResponse<MutationResponse> = try sendRequest(request, responseType: MutationResponse.self)
if response.ok, let mutationResponse = response.data {
return mutationResponse
}

let message = response.error?.message ?? "Unknown bridge error"
throw AutomationError.executionFailed(message)
}

private func ensureDirectories() throws {
try [paths.baseURL, paths.requestsURL, paths.responsesURL, paths.locksURL, paths.logsURL].forEach { url in
try fileManager.createDirectory(at: url, withIntermediateDirectories: true)
Expand Down
1 change: 1 addition & 0 deletions Sources/OmniFocusAutomation/BridgeModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ struct BridgeRequest: Codable {
let filter: TaskFilter?
let tagFilter: TagFilter?
let projectFilter: ProjectFilter?
let mutation: MutationRequest?
let fields: [String]?
let page: PageRequest?
}
Expand Down
13 changes: 13 additions & 0 deletions Sources/OmniFocusAutomation/CatalogCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,19 @@ actor CatalogCache {
tags[key] = CacheEntry(value: page, expiresAt: Date().addingTimeInterval(ttl))
}

func invalidateProjects() {
projects.removeAll()
}

func invalidateTags() {
tags.removeAll()
}

func invalidateAll() {
invalidateProjects()
invalidateTags()
}

private func purgeExpired() {
let now = Date()
projects = projects.filter { $0.value.expiresAt > now }
Expand Down
92 changes: 92 additions & 0 deletions Sources/OmniFocusAutomation/OmniFocusAutomation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,20 @@ public final class OmniAutomationService: OmniFocusService {
return try decoder.decode(ProjectCounts.self, from: data)
}

public func performMutation(_ request: MutationRequest) async throws -> MutationResponse {
try request.validate()

let requestData = try requestEncoder.encode(request)
guard let requestJSON = String(data: requestData, encoding: .utf8) else {
throw AutomationError.executionFailed("Failed to encode mutation request JSON")
}

let script = mutationPreviewEvaluateScript(requestJSON: requestJSON)
let output = try runner.runJavaScript(script)
let data = Data(output.utf8)
return try decoder.decode(MutationResponse.self, from: data)
}

public func debugInboxProbe() async throws -> InboxProbe {
let script = inboxProbeScript()
let output = try runner.runJavaScript(script)
Expand Down Expand Up @@ -228,6 +242,84 @@ private struct ProjectCountsRequest: Codable {
let filter: TaskFilter
}

private func mutationPreviewEvaluateScript(requestJSON: String) -> String {
let automationScript = mutationPreviewOmniAutomationScript(requestJSON: requestJSON)
return """
(function() {
var app = Application('OmniFocus');
var script = \(jsStringLiteral(automationScript));
var result = app.evaluateJavascript(script);
if (Array.isArray(result)) {
if (result.length === 0 || result[0] === null || typeof result[0] === "undefined") {
return "";
}
return String(result[0]);
}
if (result === null || typeof result === "undefined") {
return "";
}
return String(result);
})();
"""
}

private func mutationPreviewOmniAutomationScript(requestJSON: String) -> String {
return """
(function() {
var request = \(requestJSON);

function toArray(collection) {
if (!collection) { return []; }
if (Array.isArray(collection)) { return collection; }
if (typeof collection.apply === "function") {
var items = [];
collection.apply(function(item) { items.push(item); });
return items;
}
try { return Array.from(collection); } catch (e) { return []; }
}

function objectID(item) {
try { return String(item.id.primaryKey); } catch (e) { return ""; }
}

if (!request.previewOnly) {
throw new Error("Mutation execution is not implemented yet. Use previewOnly=true.");
}

var targetType = request.targetType;
var pool = targetType === "project" ? toArray(flattenedProjects) : toArray(flattenedTasks);
var byID = {};
for (var i = 0; i < pool.length; i += 1) {
byID[objectID(pool[i])] = true;
}

var ids = Array.isArray(request.targetIDs) ? request.targetIDs : [];
var results = ids.map(function(id) {
if (byID[String(id)]) {
return { id: String(id), status: "previewed", message: "Validated target for preview." };
}
return { id: String(id), status: "failed", message: "Target ID not found." };
});

var successCount = results.filter(function(item) { return item.status === "previewed"; }).length;
var failureCount = results.length - successCount;

return JSON.stringify({
targetType: targetType,
operationKind: request.operation.kind,
previewOnly: true,
verify: Boolean(request.verify),
requestedCount: ids.length,
successCount: successCount,
failureCount: failureCount,
results: results,
warnings: []
});
})();
"""
}


public struct InboxProbe: Codable, Sendable {
public let inboxTasksCount: Int
Expand Down
8 changes: 8 additions & 0 deletions Sources/OmniFocusAutomation/OmniFocusBridgeService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,14 @@ public final class OmniFocusBridgeService: OmniFocusService {
return try client.getProjectCounts(filter: filter)
}

public func performMutation(_ request: MutationRequest) async throws -> MutationResponse {
let response = try client.performMutation(request)
if !request.previewOnly && response.successCount > 0 {
await cache.invalidateAll()
}
return response
}

public func healthCheck() throws -> BridgeHealthResult {
let response = try client.ping()
return BridgeHealthResult(
Expand Down
Loading
Loading