diff --git a/calendar/experiments/calendar/parent/ext-calendar-editor-context.sys.mjs b/calendar/experiments/calendar/parent/ext-calendar-editor-context.sys.mjs new file mode 100644 index 0000000..18872b3 --- /dev/null +++ b/calendar/experiments/calendar/parent/ext-calendar-editor-context.sys.mjs @@ -0,0 +1,105 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const OPAQUE_EDITOR_ID_PATTERN = /^ed-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const BRIDGE_SYMBOL = Symbol("calendar-editor-context-bridge"); + +function createEditorId() { + const uuid = Services.uuid.generateUUID().toString().slice(1, -1).toLowerCase(); + return `ed-${uuid}`; +} + +export class EditorContextBridge { + constructor(extension) { + if (!extension) { + throw new Error("EditorContextBridge requires an extension"); + } + this.extension = extension; + this.targetToEditorId = new Map(); + this.editorIdToTarget = new Map(); + } + + normalizeEditorId(editorId) { + if (typeof editorId != "string") { + return ""; + } + const value = editorId.trim(); + if (!value) { + return ""; + } + return OPAQUE_EDITOR_ID_PATTERN.test(value) ? value : ""; + } + + _registerTarget(kind, id, instanceId = 0) { + if ((kind != "tab" && kind != "dialog") || !Number.isInteger(id) || !Number.isInteger(instanceId)) { + return ""; + } + + const key = `${kind}:${id}:${instanceId}`; + const existingId = this.targetToEditorId.get(key); + if (existingId) { + return existingId; + } + + const editorId = createEditorId(); + this.targetToEditorId.set(key, editorId); + this.editorIdToTarget.set(editorId, { kind, id, instanceId, key }); + return editorId; + } + + registerTabTarget(tabId, editorOuterId = 0) { + return this._registerTarget("tab", tabId, editorOuterId); + } + + registerDialogTarget(dialogOuterId) { + return this._registerTarget("dialog", dialogOuterId, dialogOuterId); + } + + resolveTarget(editorId) { + const normalizedEditorId = this.normalizeEditorId(editorId); + if (!normalizedEditorId) { + return null; + } + const target = this.editorIdToTarget.get(normalizedEditorId); + if (!target) { + return null; + } + return { kind: target.kind, id: target.id, instanceId: target.instanceId }; + } + + releaseEditorId(editorId) { + const normalizedEditorId = this.normalizeEditorId(editorId); + if (!normalizedEditorId) { + return; + } + + const target = this.editorIdToTarget.get(normalizedEditorId); + if (!target) { + return; + } + + this.editorIdToTarget.delete(normalizedEditorId); + this.targetToEditorId.delete(target.key); + } + + clear() { + this.targetToEditorId.clear(); + this.editorIdToTarget.clear(); + if (this.extension[BRIDGE_SYMBOL] == this) { + delete this.extension[BRIDGE_SYMBOL]; + } + } +} + +export function getEditorContextBridge(extension) { + if (!extension) { + throw new Error("Missing extension"); + } + let bridge = extension[BRIDGE_SYMBOL]; + if (!bridge || !(bridge instanceof EditorContextBridge)) { + bridge = new EditorContextBridge(extension); + extension[BRIDGE_SYMBOL] = bridge; + } + return bridge; +} diff --git a/calendar/experiments/calendar/parent/ext-calendar-items.js b/calendar/experiments/calendar/parent/ext-calendar-items.js index f36b5b0..4fc186a 100644 --- a/calendar/experiments/calendar/parent/ext-calendar-items.js +++ b/calendar/experiments/calendar/parent/ext-calendar-items.js @@ -5,10 +5,797 @@ var { ExtensionCommon: { ExtensionAPI, EventManager } } = ChromeUtils.importESModule("resource://gre/modules/ExtensionCommon.sys.mjs"); var { ExtensionUtils: { ExtensionError } } = ChromeUtils.importESModule("resource://gre/modules/ExtensionUtils.sys.mjs"); +var { ExtensionSupport } = ChromeUtils.importESModule("resource:///modules/ExtensionSupport.sys.mjs"); var { cal } = ChromeUtils.importESModule("resource:///modules/calendar/calUtils.sys.mjs"); +const EVENT_DIALOG_URL = "chrome://calendar/content/calendar-event-dialog.xhtml"; +const EVENT_TAB_IFRAME_URL = "chrome://calendar/content/calendar-item-iframe.xhtml"; +const MESSENGER_URL = "chrome://messenger/content/messenger.xhtml"; +const EVENT_PANEL_IFRAME_ID = "calendar-item-panel-iframe"; +const EVENT_TITLE_FIELD_ID = "item-title"; +const EVENT_LOCATION_FIELD_ID = "item-location"; +const EVENT_DESCRIPTION_FIELD_ID = "item-description"; +const EVENT_EDITOR_TAB_MODES = new Set(["calendarEvent", "calendarTask"]); +function getEditorContextBridgeForExtension(extension) { + const root = `experiments-calendar-${extension.uuid}`; + const query = extension.manifest.version; + const module = ChromeUtils.importESModule( + `resource://${root}/experiments/calendar/parent/ext-calendar-editor-context.sys.mjs?${query}` + ); + return module.getEditorContextBridge(extension); +} + this.calendar_items = class extends ExtensionAPI { + _ensureEditorClosedListenerSet() { + if (!this._editorClosedListeners) { + this._editorClosedListeners = new Set(); + } + return this._editorClosedListeners; + } + + _ensureLifecycleStateMap() { + if (!this._editorLifecycleByTarget) { + this._editorLifecycleByTarget = new WeakMap(); + } + return this._editorLifecycleByTarget; + } + + _addEditorClosedListener(listener) { + this._ensureEditorClosedListenerSet().add(listener); + } + + _removeEditorClosedListener(listener) { + this._ensureEditorClosedListenerSet().delete(listener); + } + + _emitEditorClosed(info) { + const listeners = this._ensureEditorClosedListenerSet(); + for (const listener of listeners) { + try { + listener(info); + } catch (e) { + console.error("[calendar.items] onTrackedEditorClosed listener failed", e); + } + } + } + + _getEditorBridge(extension) { + if (!extension) { + throw new ExtensionError("Missing extension context"); + } + if (!this._editorBridgeByExtension) { + this._editorBridgeByExtension = new WeakMap(); + } + let bridge = this._editorBridgeByExtension.get(extension); + if (!bridge) { + bridge = getEditorContextBridgeForExtension(extension); + this._editorBridgeByExtension.set(extension, bridge); + } + return bridge; + } + + _clearEditorBridge(extension) { + if (!extension || !this._editorBridgeByExtension) { + return; + } + const bridge = this._editorBridgeByExtension.get(extension); + if (!bridge) { + return; + } + bridge.clear(); + this._editorBridgeByExtension.delete(extension); + } + + _isCalendarEditorTabInfo(tabInfo) { + const modeName = tabInfo?.mode?.name || ""; + if (EVENT_EDITOR_TAB_MODES.has(modeName)) { + return true; + } + const editorWindow = tabInfo?.iframe?.contentWindow || tabInfo?.iframe?.contentDocument?.defaultView || null; + const href = editorWindow?.location?.href || ""; + return href.startsWith(EVENT_DIALOG_URL) || href.startsWith(EVENT_TAB_IFRAME_URL); + } + + _isCalendarEditorWindow(window) { + const href = window?.location?.href || ""; + return href.startsWith(EVENT_DIALOG_URL) || href.startsWith(EVENT_TAB_IFRAME_URL); + } + + _getCalendarTabInfoForEditorWindow(window) { + if (!window || !this._isCalendarEditorWindow(window)) { + return null; + } + + const ownerWindow = window.ownerGlobal || null; + if (!ownerWindow || ownerWindow.location?.href != MESSENGER_URL) { + return null; + } + + const tabInfoList = ownerWindow.tabmail && Array.isArray(ownerWindow.tabmail.tabInfo) + ? ownerWindow.tabmail.tabInfo + : []; + for (const tabInfo of tabInfoList) { + if (!this._isCalendarEditorTabInfo(tabInfo)) { + continue; + } + const tabEditorWindow = tabInfo.iframe?.contentWindow || tabInfo.iframe?.contentDocument?.defaultView || null; + if (tabEditorWindow == window) { + return tabInfo; + } + } + + return null; + } + + _getManagedTabId(context, window) { + const manager = context?.extension?.tabManager; + if (!manager || typeof manager.getWrapper != "function" || !window) { + console.error("[calendar.items] managed tab id resolution failed: tabManager unavailable"); + return null; + } + + const tabInfo = this._getCalendarTabInfoForEditorWindow(window); + if (!tabInfo) { + console.error("[calendar.items] managed tab id resolution failed: could not map editor window to tabInfo", { + windowHref: window?.location?.href || "", + }); + return null; + } + + try { + const wrapper = manager.getWrapper(tabInfo); + const id = wrapper?.id; + if (typeof id == "number") { + return id; + } + console.error("[calendar.items] managed tab id resolution failed: tabManager.getWrapper(tabInfo) returned no numeric id", { + mode: tabInfo?.mode?.name || "", + hasNativeTab: !!tabInfo?.nativeTab, + }); + return null; + } catch (e) { + console.error("[calendar.items] managed tab id resolution failed: tabManager.getWrapper(tabInfo) threw", { + mode: tabInfo?.mode?.name || "", + hasNativeTab: !!tabInfo?.nativeTab, + error: String(e), + }); + return null; + } + } + + _getDialogOuterId(window) { + try { + const windowType = window?.document?.documentElement?.getAttribute?.("windowtype") || ""; + if (windowType != "Calendar:EventDialog" && windowType != "Calendar:EventSummaryDialog") { + return null; + } + const outerId = window?.docShell?.outerWindowID ?? window?.windowUtils?.outerWindowID; + return typeof outerId == "number" ? outerId : null; + } catch (e) { + console.error("[calendar.items] get dialog outer id failed", e); + return null; + } + } + + _getEditorOuterId(window) { + const outerId = window?.docShell?.outerWindowID ?? window?.windowUtils?.outerWindowID; + return typeof outerId == "number" ? outerId : null; + } + + _getEditorIdForWindow(context, window) { + const bridge = this._getEditorBridge(context.extension); + const tabId = this._getManagedTabId(context, window); + if (typeof tabId == "number") { + const editorOuterId = this._getEditorOuterId(window); + if (typeof editorOuterId != "number") { + console.error("[calendar.items] editor id resolution failed: missing tab editor outer window id", { tabId }); + return ""; + } + return bridge.registerTabTarget(tabId, editorOuterId); + } + + const dialogOuterId = this._getDialogOuterId(window); + if (typeof dialogOuterId == "number") { + return bridge.registerDialogTarget(dialogOuterId); + } + + return ""; + } + + _resolveTabEditorWindow(context, tabId, editorOuterId = 0) { + const tabManager = context?.extension?.tabManager; + if (!tabManager || typeof tabManager.get != "function") { + console.error("[calendar.items] tab editor resolution failed: tabManager unavailable", { tabId }); + return null; + } + + let tabWrapper = null; + try { + tabWrapper = tabManager.get(tabId); + } catch (_e) { + console.error("[calendar.items] tab editor resolution failed: tab id not found", { tabId }); + return null; + } + + const tabInfo = tabWrapper?.nativeTab || null; + if (!this._isCalendarEditorTabInfo(tabInfo)) { + console.error("[calendar.items] tab editor resolution failed: tab wrapper nativeTab is not a calendar editor tab", { tabId }); + return null; + } + + const win = tabInfo.iframe?.contentWindow || tabInfo.iframe?.contentDocument?.defaultView || null; + if (!win || win.closed || !this._isCalendarEditorWindow(win)) { + console.error("[calendar.items] tab editor resolution failed: iframe window unavailable or unexpected URL", { + tabId, + href: win?.location?.href || "", + }); + return null; + } + + if (Number.isInteger(editorOuterId) && editorOuterId > 0) { + const currentOuterId = this._getEditorOuterId(win); + if (currentOuterId != editorOuterId) { + console.error("[calendar.items] tab editor resolution failed: stale tab editor instance", { + tabId, + expectedOuterId: editorOuterId, + currentOuterId: currentOuterId ?? null, + }); + return null; + } + } + + return win; + } + + _resolveEditorWindow(context, editorId) { + const bridge = this._getEditorBridge(context.extension); + const normalizedEditorId = bridge.normalizeEditorId(editorId); + if (!normalizedEditorId) { + console.error("[calendar.items] editor resolution failed: invalid editorId format"); + return null; + } + const target = bridge.resolveTarget(normalizedEditorId); + if (!target) { + console.error("[calendar.items] editor resolution failed: unknown editorId", { editorId: normalizedEditorId }); + return null; + } + + if (target.kind == "dialog") { + if (!Services?.wm?.getOuterWindowWithId) { + console.error("[calendar.items] dialog editor resolution failed: Services.wm.getOuterWindowWithId unavailable", { editorId: normalizedEditorId }); + bridge.releaseEditorId(normalizedEditorId); + return null; + } + try { + const win = Services.wm.getOuterWindowWithId(target.id); + if (win && !win.closed && win.location?.href?.startsWith(EVENT_DIALOG_URL)) { + return win; + } + } catch (_e) { + console.error("[calendar.items] dialog editor resolution failed: getOuterWindowWithId threw", { + editorId: normalizedEditorId, + dialogOuterId: target.id, + }); + bridge.releaseEditorId(normalizedEditorId); + return null; + } + console.error("[calendar.items] dialog editor resolution failed: window unavailable or unexpected URL", { + editorId: normalizedEditorId, + dialogOuterId: target.id, + }); + bridge.releaseEditorId(normalizedEditorId); + return null; + } + + if (target.kind == "tab") { + const win = this._resolveTabEditorWindow(context, target.id, target.instanceId); + if (win) { + return win; + } + console.error("[calendar.items] tab editor resolution failed", { + editorId: normalizedEditorId, + tabId: target.id, + editorOuterId: target.instanceId ?? null, + }); + bridge.releaseEditorId(normalizedEditorId); + return null; + } + + console.error("[calendar.items] editor resolution failed: unsupported target kind", { + editorId: normalizedEditorId, + kind: target.kind, + }); + bridge.releaseEditorId(normalizedEditorId); + return null; + } + + _getEditedItemForWindow(window) { + if (!window || !window.location) { + return null; + } + + if (this._isCalendarEditorWindow(window)) { + const fromWindow = win => { + if (!win) { + return null; + } + if (win.calendarItem) { + return win.calendarItem; + } + if (win.gEvent?.event) { + return win.gEvent.event; + } + const arg0 = Array.isArray(win.arguments) ? win.arguments[0] : null; + if (arg0?.calendarItem) { + return arg0.calendarItem; + } + if (arg0?.calendarEvent) { + return arg0.calendarEvent; + } + return null; + }; + + const direct = fromWindow(window); + if (direct) { + return direct; + } + + const panelIframe = window.document?.getElementById?.(EVENT_PANEL_IFRAME_ID) || null; + const panelWin = panelIframe?.contentWindow || panelIframe?.contentDocument?.defaultView || null; + return fromWindow(panelWin); + } + + return null; + } + + _cleanupLifecycleState(target) { + if (!target) { + return; + } + + const stateMap = this._ensureLifecycleStateMap(); + const state = stateMap.get(target); + if (!state) { + return; + } + stateMap.delete(target); + if (state.editorId && state.extension) { + this._getEditorBridge(state.extension).releaseEditorId(state.editorId); + } + + const cleanup = Array.isArray(state.cleanup) ? state.cleanup : []; + while (cleanup.length) { + const fn = cleanup.pop(); + try { + fn(); + } catch (e) { + console.error("[calendar.items] cleanup lifecycle state failed", e); + } + } + } + + _cleanupLifecycleInWindow(window) { + if (!window || !window.location) { + return; + } + + if (window.location.href.startsWith(EVENT_DIALOG_URL)) { + this._cleanupLifecycleState(window); + return; + } + + if (!window.location.href.startsWith(MESSENGER_URL)) { + return; + } + + const tabmail = window.tabmail; + const tabInfoList = tabmail && Array.isArray(tabmail.tabInfo) ? tabmail.tabInfo : []; + for (const tabInfo of tabInfoList) { + if (!this._isCalendarEditorTabInfo(tabInfo)) { + continue; + } + const target = tabInfo.iframe?.contentWindow || tabInfo.iframe?.contentDocument?.defaultView || null; + this._cleanupLifecycleState(target); + } + } + + _ensureLifecycleWatch(context, window, editorId = "") { + const target = window && this._isCalendarEditorWindow(window) ? window : null; + if (!target) { + return; + } + + const stateMap = this._ensureLifecycleStateMap(); + const bridge = this._getEditorBridge(context.extension); + const normalizedEditorId = bridge.normalizeEditorId(editorId); + const nextEditorId = normalizedEditorId || this._getEditorIdForWindow(context, target); + if (!nextEditorId) { + return; + } + const nextEditorKey = nextEditorId; + if (!nextEditorKey) { + return; + } + const previous = stateMap.get(target); + if (previous) { + if (previous.editorKey == nextEditorKey) { + return; + } + this._emitEditorClosed({ + editorId: previous.editorId || "", + action: "superseded", + reason: "re-bound" + }); + this._cleanupLifecycleState(target); + } + + const state = { + extension: context.extension, + editorId: nextEditorId, + editorKey: nextEditorKey, + cleanup: [], + closed: false + }; + stateMap.set(target, state); + + const emitOnce = (action, reason) => { + if (state.closed) { + return; + } + state.closed = true; + const info = { + editorId: state.editorId || "", + action, + }; + if (reason) { + info.reason = reason; + } + this._emitEditorClosed(info); + this._cleanupLifecycleState(target); + }; + + const addListener = (type, handler, options) => { + target.addEventListener(type, handler, options); + state.cleanup.push(() => { + target.removeEventListener(type, handler, options); + }); + }; + + const isDialog = !!(target.location?.href || "").startsWith(EVENT_DIALOG_URL); + if (isDialog) { + addListener("dialogaccept", () => emitOnce("persisted", "dialogaccept"), true); + addListener("dialogextra1", () => emitOnce("persisted", "dialogextra1"), true); + addListener("dialogcancel", () => emitOnce("discarded", "dialogcancel"), true); + addListener("dialogextra2", () => emitOnce("discarded", "dialogextra2"), true); + } + + addListener("unload", () => emitOnce("discarded", "unload"), true); + } + + _assertEditorWindowOpen(window, operation) { + if (!window || window.closed) { + console.error("[calendar.items] editor window closed", { operation: operation || "" }); + throw new ExtensionError(`Editor window closed during ${operation}`); + } + } + + _getMainEditorDocument(window) { + const doc = window?.document || null; + if (!doc) { + throw new ExtensionError("Could not resolve editor document"); + } + return doc; + } + + _getPanelEditorDocument(window) { + const mainDoc = this._getMainEditorDocument(window); + const panelIframe = mainDoc.getElementById(EVENT_PANEL_IFRAME_ID); + return panelIframe?.contentDocument || null; + } + + _getEditorDocuments(window) { + const docs = []; + const mainDoc = this._getMainEditorDocument(window); + docs.push(mainDoc); + const panelDoc = this._getPanelEditorDocument(window); + if (panelDoc) { + docs.push(panelDoc); + } + return docs; + } + + _resolveValueFieldById(window, elementId, label) { + const docs = this._getEditorDocuments(window); + for (const doc of docs) { + const field = doc.getElementById(elementId); + if (field && ("value" in field)) { + return { + kind: "value", + element: field, + }; + } + } + console.error("[calendar.items] field resolution failed", { field: label, elementId }); + throw new ExtensionError(`Could not resolve writable ${label} field`); + } + + _resolveTitleField(window) { + return this._resolveValueFieldById(window, EVENT_TITLE_FIELD_ID, "title"); + } + + _resolveLocationField(window) { + return this._resolveValueFieldById(window, EVENT_LOCATION_FIELD_ID, "location"); + } + + _resolveDescriptionField(window) { + const docs = this._getEditorDocuments(window); + for (const doc of docs) { + const host = doc.getElementById(EVENT_DESCRIPTION_FIELD_ID); + const inputField = host?.inputField || null; + if (inputField && ("value" in inputField)) { + return { + kind: "value", + element: inputField, + }; + } + + if (host && ("value" in host)) { + return { + kind: "value", + element: host, + }; + } + + const htmlBody = host?.contentDocument?.body || null; + if (htmlBody) { + return { + kind: "html-body", + element: htmlBody, + }; + } + } + console.error("[calendar.items] field resolution failed", { field: "description", elementId: EVENT_DESCRIPTION_FIELD_ID }); + throw new ExtensionError("Could not resolve writable description field"); + } + + _dispatchInputEvent(element) { + if (!element) { + return; + } + const doc = element.ownerDocument || element.document; + const win = doc?.defaultView; + if (win) { + element.dispatchEvent(new win.Event("input", { bubbles: true })); + } + } + + _setFieldValue(target, value) { + const element = target?.element || null; + if (!element) { + console.error("[calendar.items] field update failed: missing resolved field target"); + throw new ExtensionError("Resolved editor field is not writable"); + } + + if (target.kind == "value") { + if (!("value" in element)) { + console.error("[calendar.items] field update failed: resolved value target has no value property"); + throw new ExtensionError("Resolved editor field is not writable"); + } + + element.focus?.(); + element.value = value; + this._dispatchInputEvent(element); + return; + } + + if (target.kind == "html-body") { + const doc = element.ownerDocument || null; + if (!doc || typeof doc.execCommand != "function") { + console.error("[calendar.items] description update failed: execCommand unavailable on html-body editor"); + throw new ExtensionError("Could not write description field"); + } + element.focus?.(); + doc.execCommand("selectAll", false, null); + const insertOk = doc.execCommand("insertText", false, value); + const normalizedValue = String(value ?? ""); + const currentValue = String(element.textContent ?? ""); + if (!insertOk && currentValue != normalizedValue) { + console.error("[calendar.items] description update failed: execCommand returned false"); + throw new ExtensionError("Could not write description field"); + } + this._dispatchInputEvent(element); + return; + } + + console.error("[calendar.items] field update failed: unknown resolved field target kind", { + kind: target.kind, + }); + throw new ExtensionError("Resolved editor field is not writable"); + } + + _snapshotResolvedFieldValues(targets) { + const readValue = target => { + if (!target || !target.element) { + return null; + } + if (target.kind == "value") { + return String(target.element.value ?? ""); + } + if (target.kind == "html-body") { + return String(target.element.textContent ?? ""); + } + console.error("[calendar.items] field snapshot failed: unknown resolved field target kind", { + kind: target.kind, + }); + throw new ExtensionError("Resolved editor field is not readable"); + }; + + return { + title: readValue(targets.title), + location: readValue(targets.location), + description: readValue(targets.description), + }; + } + + _rollbackFieldUpdates(window, targets, beforeValues, applied) { + if (!window || window.closed) { + console.error("[calendar.items] rollback skipped because editor window closed"); + return; + } + + const rollbackOrder = ["description", "location", "title"]; + for (const key of rollbackOrder) { + if (!applied[key] || !targets[key]) { + continue; + } + this._setFieldValue(targets[key], beforeValues[key] ?? ""); + } + } + + _resolveRequestedFieldTargets(window, fields) { + this._assertEditorWindowOpen(window, "field target resolution"); + const targets = {}; + + if (typeof fields.title == "string") { + targets.title = this._resolveTitleField(window); + } + + if (typeof fields.location == "string") { + targets.location = this._resolveLocationField(window); + } + + if (typeof fields.description == "string") { + targets.description = this._resolveDescriptionField(window); + } + + return targets; + } + + _applyFieldUpdates(window, fields, state = null) { + this._assertEditorWindowOpen(window, "field updates"); + const targets = state?.targets || this._resolveRequestedFieldTargets(window, fields); + const beforeValues = state?.beforeValues || this._snapshotResolvedFieldValues(targets); + + const applied = { + title: false, + location: false, + description: false + }; + + try { + if (typeof fields.title == "string") { + this._assertEditorWindowOpen(window, "title update"); + this._setFieldValue(targets.title, fields.title); + applied.title = true; + } + if (typeof fields.location == "string") { + this._assertEditorWindowOpen(window, "location update"); + this._setFieldValue(targets.location, fields.location); + applied.location = true; + } + if (typeof fields.description == "string") { + this._assertEditorWindowOpen(window, "description update"); + this._setFieldValue(targets.description, fields.description); + applied.description = true; + } + } catch (e) { + try { + this._rollbackFieldUpdates(window, targets, beforeValues, applied); + } catch (rollbackError) { + console.error("[calendar.items] rollback failed", rollbackError); + } + throw e; + } + + return applied; + } + + _validatePropertyUpdates(properties) { + for (const name of Object.keys(properties || {})) { + if (!name || typeof name != "string") { + throw new ExtensionError("Property names must be non-empty strings"); + } + } + } + + _snapshotPropertyValues(item, properties) { + this._validatePropertyUpdates(properties); + const snapshot = {}; + for (const name of Object.keys(properties || {})) { + try { + const current = item.getProperty(name); + snapshot[name] = current == null ? null : String(current); + } catch (e) { + console.error("[calendar.items] property snapshot failed", { property: name, error: String(e) }); + throw new ExtensionError(`Could not snapshot property ${name}`); + } + } + return snapshot; + } + + _applyPropertyUpdates(item, properties) { + this._validatePropertyUpdates(properties); + const appliedNames = []; + for (const [name, value] of Object.entries(properties || {})) { + try { + if (value == null) { + if (typeof item.deleteProperty == "function") { + item.deleteProperty(name); + } else { + item.setProperty(name, ""); + } + } else { + item.setProperty(name, String(value)); + } + appliedNames.push(name); + } catch (e) { + console.error("[calendar.items] property update failed", { property: name, error: String(e) }); + throw new ExtensionError(`Could not update property ${name}`); + } + } + return appliedNames; + } + + _rollbackPropertyUpdates(item, snapshot, appliedNames) { + const names = Array.isArray(appliedNames) ? appliedNames : []; + for (let i = names.length - 1; i >= 0; i--) { + const name = names[i]; + const previous = Object.prototype.hasOwnProperty.call(snapshot, name) ? snapshot[name] : null; + try { + if (previous == null) { + if (typeof item.deleteProperty == "function") { + item.deleteProperty(name); + } else { + item.setProperty(name, ""); + } + } else { + item.setProperty(name, String(previous)); + } + } catch (e) { + console.error("[calendar.items] property rollback failed", { property: name, error: String(e) }); + throw new ExtensionError(`Could not rollback property ${name}`); + } + } + } + + onShutdown() { + for (const window of ExtensionSupport.openWindows) { + try { + this._cleanupLifecycleInWindow(window); + } catch (e) { + console.error("[calendar.items] shutdown cleanup failed", e); + } + } + + if (this._editorClosedListeners) { + this._editorClosedListeners.clear(); + } + + this._clearEditorBridge(this.extension); + } + getAPI(context) { + const api = this; const uuid = context.extension.uuid; const root = `experiments-calendar-${uuid}`; const query = context.extension.manifest.version; @@ -108,7 +895,7 @@ this.calendar_items = class extends ExtensionAPI { newItem.calendar = calendar.superCalendar; if (updateProperties.metadata && isOwnCalendar(calendar, context.extension)) { - // TODO merge or replace? + // Metadata updates replace the cached payload for deterministic behavior. const cache = getCachedCalendar(calendar); cache.setMetaData(newItem.id, JSON.stringify(updateProperties.metadata)); } @@ -130,8 +917,7 @@ this.calendar_items = class extends ExtensionAPI { } if (isOwnCalendar(toCalendar, context.extension) && isOwnCalendar(fromCalendar, context.extension)) { - // TODO doing this first, the item may not be in the db and it will fail. Doing this - // after addItem, the metadata will not be available for the onCreated listener + // Copy metadata before addItem so onCreated listeners can read it immediately. const fromCache = getCachedCalendar(fromCalendar); const toCache = getCachedCalendar(toCalendar); toCache.setMetaData(item.id, fromCache.getMetaData(item.id)); @@ -150,14 +936,93 @@ this.calendar_items = class extends ExtensionAPI { }, async getCurrent(options) { - try { - // TODO This seems risky, could be null depending on remoteness - const item = context.browsingContext.embedderElement.ownerGlobal.calendarItem; - return convertItem(item, options, context.extension); - } catch (e) { - console.error(e); + const editorId = api._getEditorBridge(context.extension).normalizeEditorId(options?.editorId); + if (!editorId) { + console.error("[calendar.items] getCurrent failed: invalid editorId"); + throw new ExtensionError("editorId must be a non-empty opaque editor identifier"); + } + + let win = api._resolveEditorWindow(context, editorId); + if (!win) { + console.error("[calendar.items] getCurrent: editor window could not be resolved", { editorId }); + return null; + } + let item = api._getEditedItemForWindow(win); + if (!item) { + console.error("[calendar.items] getCurrent: no editable item found in resolved editor window", { editorId }); return null; } + api._ensureLifecycleWatch(context, win, editorId); + const converted = convertItem(item, options, context.extension); + if (converted) { + converted.editorId = editorId; + } + return converted; + }, + + async updateCurrent(updateOptions) { + const editorId = api._getEditorBridge(context.extension).normalizeEditorId(updateOptions?.editorId); + if (!editorId) { + console.error("[calendar.items] updateCurrent failed: invalid editorId"); + throw new ExtensionError("editorId must be a non-empty opaque editor identifier"); + } + + let win = api._resolveEditorWindow(context, editorId); + if (!win) { + console.error("[calendar.items] updateCurrent failed: editor window could not be resolved", { editorId }); + throw new ExtensionError("Could not resolve target editor window"); + } + let item = api._getEditedItemForWindow(win); + if (!item) { + console.error("[calendar.items] updateCurrent failed: no editable item found in resolved editor window", { editorId }); + throw new ExtensionError("Could not find current editor item"); + } + api._ensureLifecycleWatch(context, win, editorId); + + const fields = updateOptions?.fields && typeof updateOptions.fields == "object" ? updateOptions.fields : {}; + const properties = updateOptions?.properties && typeof updateOptions.properties == "object" ? updateOptions.properties : {}; + if (!Object.keys(fields).length && !Object.keys(properties).length) { + console.error("[calendar.items] updateCurrent failed: neither fields nor properties provided", { editorId }); + throw new ExtensionError("updateCurrent requires at least one field or property update"); + } + + const fieldTargets = api._resolveRequestedFieldTargets(win, fields); + const fieldBeforeValues = api._snapshotResolvedFieldValues(fieldTargets); + let fieldApplied = { + title: false, + location: false, + description: false + }; + api._assertEditorWindowOpen(win, "field updates"); + fieldApplied = api._applyFieldUpdates(win, fields, { + targets: fieldTargets, + beforeValues: fieldBeforeValues, + }); + + api._assertEditorWindowOpen(win, "property updates"); + const propertySnapshot = api._snapshotPropertyValues(item, properties); + let appliedProperties = []; + try { + appliedProperties = api._applyPropertyUpdates(item, properties); + } catch (propertyError) { + try { + api._rollbackPropertyUpdates(item, propertySnapshot, appliedProperties); + } catch (propertyRollbackError) { + console.error("[calendar.items] property rollback failed", propertyRollbackError); + } + try { + api._rollbackFieldUpdates(win, fieldTargets, fieldBeforeValues, fieldApplied); + } catch (fieldRollbackError) { + console.error("[calendar.items] field rollback after property failure failed", fieldRollbackError); + } + throw propertyError; + } + + const converted = convertItem(item, updateOptions, context.extension); + if (converted) { + converted.editorId = editorId; + } + return converted; }, onCreated: new EventManager({ @@ -183,8 +1048,8 @@ this.calendar_items = class extends ExtensionAPI { register: (fire, options) => { const observer = cal.createAdapter(Ci.calIObserver, { onModifyItem: (newItem, _oldItem) => { - // TODO calculate changeInfo - const changeInfo = {}; + // changeInfo currently signals a full item replacement. + const changeInfo = { changeType: "full" }; fire.sync(convertItem(newItem, options, context.extension), changeInfo); }, }); @@ -237,6 +1102,20 @@ this.calendar_items = class extends ExtensionAPI { }; }, }).api(), + + onTrackedEditorClosed: new EventManager({ + context, + name: "calendar.items.onTrackedEditorClosed", + register: fire => { + const listener = info => { + fire.sync(info); + }; + api._addEditorClosedListener(listener); + return () => { + api._removeEditorClosedListener(listener); + }; + }, + }).api(), }, }, }; diff --git a/calendar/experiments/calendar/parent/ext-calendarItemAction.js b/calendar/experiments/calendar/parent/ext-calendarItemAction.js index 3dcaed2..78a791d 100644 --- a/calendar/experiments/calendar/parent/ext-calendarItemAction.js +++ b/calendar/experiments/calendar/parent/ext-calendarItemAction.js @@ -2,13 +2,24 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -var { ExtensionCommon: { makeWidgetId } } = ChromeUtils.importESModule("resource://gre/modules/ExtensionCommon.sys.mjs"); - var { ExtensionParent } = ChromeUtils.importESModule("resource://gre/modules/ExtensionParent.sys.mjs"); var { ExtensionSupport } = ChromeUtils.importESModule("resource:///modules/ExtensionSupport.sys.mjs"); var { ToolbarButtonAPI } = ChromeUtils.importESModule("resource:///modules/ExtensionToolbarButtons.sys.mjs"); const calendarItemActionMap = new WeakMap(); +const CALITEM_EVENT_DIALOG_URL = "chrome://calendar/content/calendar-event-dialog.xhtml"; +const CALITEM_EVENT_TAB_IFRAME_URL = "chrome://calendar/content/calendar-item-iframe.xhtml"; +const CALITEM_MESSENGER_URL = "chrome://messenger/content/messenger.xhtml"; +const CALITEM_EDITOR_TAB_MODES = new Set(["calendarEvent", "calendarTask"]); + +function getEditorContextBridgeForExtension(extension) { + const root = `experiments-calendar-${extension.uuid}`; + const query = extension.manifest.version; + const module = ChromeUtils.importESModule( + `resource://${root}/experiments/calendar/parent/ext-calendar-editor-context.sys.mjs?${query}` + ); + return module.getEditorContextBridge(extension); +} this.calendarItemAction = class extends ToolbarButtonAPI { static for(extension) { @@ -16,7 +27,7 @@ this.calendarItemAction = class extends ToolbarButtonAPI { } onStartup() { - // TODO this is only necessary in the experiment, can drop this when moving to core. + // Experiment compatibility path: localized calendar_item_action manifest wiring. const calendarItemAction = this.extension.manifest?.calendar_item_action; if (calendarItemAction) { const localize = this.extension.localize.bind(this.extension); @@ -34,7 +45,7 @@ this.calendarItemAction = class extends ToolbarButtonAPI { this.onManifestEntry("calendar_item_action"); } - // TODO this is only necessary in the experiment, can refactor this when moving to core. + // Experiment compatibility path: ensure popupset exists in the editor dialog. ExtensionSupport.registerWindowListener("ext-calendar-itemAction-" + this.extension.id, { chromeURLs: ["chrome://calendar/content/calendar-event-dialog.xhtml"], onLoadWindow(win) { @@ -43,7 +54,11 @@ this.calendarItemAction = class extends ToolbarButtonAPI { if (!document.getElementById("mainPopupSet")) { const mainPopupSet = document.createElementNS("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul", "popupset"); mainPopupSet.id = "mainPopupSet"; - const dialog = document.querySelector("dialog"); + const dialog = document.getElementsByTagName("dialog")[0] || null; + if (!dialog) { + console.error("[calendarItemAction] could not resolve dialog root for popupset injection"); + return; + } dialog.insertBefore(mainPopupSet, dialog.firstElementChild); } } @@ -57,9 +72,14 @@ this.calendarItemAction = class extends ToolbarButtonAPI { // Core code only works for one toolbox/toolbarId. Calendar uses different ones. When porting // you can leave all of this out by either using the same ids, or adapting parent class code to // deal with ids per window url. - if (this.extension.startupReason == "ADDON_INSTALL") { - // Add it to the messenger window, the other one is already covered by parent code. + if ( + this.extension.startupReason == "ADDON_INSTALL" || + this.extension.startupReason == "ADDON_UPGRADE" + ) { + // Ensure both editor variants have the button in persisted toolbar sets + // on fresh install and profile migrations during add-on upgrade. this.addToCurrentSet("chrome://messenger/content/messenger.xhtml", "event-tab-toolbar"); + this.addToCurrentSet("chrome://calendar/content/calendar-event-dialog.xhtml", "event-toolbar"); } } @@ -87,6 +107,10 @@ this.calendarItemAction = class extends ToolbarButtonAPI { close() { super.close(); + if (this._editorBridge) { + this._editorBridge.clear(); + this._editorBridge = null; + } calendarItemActionMap.delete(this.extension); } @@ -106,7 +130,7 @@ this.calendarItemAction = class extends ToolbarButtonAPI { // This is only necessary as part of the experiment, refactor when moving to core. paint(window) { - if (window.location.href == "chrome://calendar/content/calendar-event-dialog.xhtml") { + if (window.location.href == CALITEM_EVENT_DIALOG_URL) { this.toolbarId = "event-toolbar"; } else { this.toolbarId = "event-tab-toolbar"; @@ -114,9 +138,285 @@ this.calendarItemAction = class extends ToolbarButtonAPI { return super.paint(window); } + _getDialogOuterId(window) { + const outerId = window?.docShell?.outerWindowID ?? window?.windowUtils?.outerWindowID; + return typeof outerId == "number" ? outerId : null; + } + + _getEditorOuterId(window) { + const outerId = window?.docShell?.outerWindowID ?? window?.windowUtils?.outerWindowID; + return typeof outerId == "number" ? outerId : null; + } + + _getEditorBridge() { + if (!this._editorBridge) { + this._editorBridge = getEditorContextBridgeForExtension(this.extension); + } + return this._editorBridge; + } + + _ensureDialogReleaseListener(window, editorId) { + if (!window || !editorId) { + return; + } + + if (!this._dialogReleaseByWindow) { + this._dialogReleaseByWindow = new WeakMap(); + } + + const previous = this._dialogReleaseByWindow.get(window); + if (previous?.editorId == editorId) { + return; + } + if (previous?.onUnload) { + window.removeEventListener("unload", previous.onUnload, true); + this._getEditorBridge().releaseEditorId(previous.editorId); + } + + const onUnload = () => { + this._getEditorBridge().releaseEditorId(editorId); + window.removeEventListener("unload", onUnload, true); + this._dialogReleaseByWindow.delete(window); + }; + + window.addEventListener("unload", onUnload, true); + this._dialogReleaseByWindow.set(window, { editorId, onUnload }); + } + + _isCalendarEditorTabInfo(tabInfo) { + const modeName = tabInfo?.mode?.name || ""; + if (CALITEM_EDITOR_TAB_MODES.has(modeName)) { + return true; + } + const editorWindow = tabInfo?.iframe?.contentWindow || tabInfo?.iframe?.contentDocument?.defaultView || null; + const href = editorWindow?.location?.href || ""; + return href.startsWith(CALITEM_EVENT_DIALOG_URL) || href.startsWith(CALITEM_EVENT_TAB_IFRAME_URL); + } + + _isCalendarEditorWindow(window) { + const href = window?.location?.href || ""; + return href.startsWith(CALITEM_EVENT_DIALOG_URL) || href.startsWith(CALITEM_EVENT_TAB_IFRAME_URL); + } + + _getSelectedTabInfo(window) { + const tabmail = window?.tabmail || null; + const tabInfoList = Array.isArray(tabmail?.tabInfo) ? tabmail.tabInfo : null; + if (!tabInfoList || !tabInfoList.length) { + return null; + } + + const selectedIndex = tabmail?.tabContainer?.selectedIndex; + if (Number.isInteger(selectedIndex) && selectedIndex >= 0 && selectedIndex < tabInfoList.length) { + return tabInfoList[selectedIndex]; + } + + return tabmail.currentTabInfo || null; + } + + _getManagedTabIdFromTabInfo(tabInfo) { + const tabManager = this.extension?.tabManager; + if (!tabManager || typeof tabManager.getWrapper != "function") { + console.error("[calendarItemAction] tabManager unavailable while resolving tab id"); + return null; + } + if (!this._isCalendarEditorTabInfo(tabInfo)) { + return null; + } + try { + const tabWrapper = tabManager.getWrapper(tabInfo); + const tabId = tabWrapper?.id; + if (typeof tabId == "number") { + return tabId; + } + console.error("[calendarItemAction] tabManager.getWrapper(tabInfo) returned no numeric id", { + mode: tabInfo?.mode?.name || "", + hasNativeTab: !!tabInfo?.nativeTab, + }); + return null; + } catch (e) { + console.error("[calendarItemAction] tabManager.getWrapper(tabInfo) failed", { + mode: tabInfo?.mode?.name || "", + hasNativeTab: !!tabInfo?.nativeTab, + error: String(e), + }); + return null; + } + } + + _getCalendarTabInfoForEditorWindow(window) { + if (!window || !this._isCalendarEditorWindow(window)) { + return null; + } + const ownerWindow = window.ownerGlobal || null; + if (!ownerWindow || ownerWindow.location?.href != CALITEM_MESSENGER_URL) { + return null; + } + const tabInfoList = ownerWindow.tabmail && Array.isArray(ownerWindow.tabmail.tabInfo) + ? ownerWindow.tabmail.tabInfo + : []; + for (const tabInfo of tabInfoList) { + if (!this._isCalendarEditorTabInfo(tabInfo)) { + continue; + } + const tabEditorWindow = tabInfo.iframe?.contentWindow || tabInfo.iframe?.contentDocument?.defaultView || null; + if (tabEditorWindow == window) { + return tabInfo; + } + } + return null; + } + + _getTabEditorIdFromMessengerWindow(window) { + if (!window || window.location?.href != CALITEM_MESSENGER_URL) { + return null; + } + const tabInfo = this._getSelectedTabInfo(window); + if (!this._isCalendarEditorTabInfo(tabInfo)) { + console.error("[calendarItemAction] current tab is not a calendar editor tab", { + mode: tabInfo?.mode?.name || "", + iframeHref: tabInfo?.iframe?.contentWindow?.location?.href || "", + }); + return null; + } + const tabId = this._getManagedTabIdFromTabInfo(tabInfo); + if (typeof tabId != "number") { + console.error("[calendarItemAction] could not resolve managed tab id for calendar editor tab", { + mode: tabInfo?.mode?.name || "", + iframeHref: tabInfo?.iframe?.contentWindow?.location?.href || "", + iframeId: tabInfo?.iframe?.id || "", + }); + return null; + } + const editorWindow = tabInfo?.iframe?.contentWindow || tabInfo?.iframe?.contentDocument?.defaultView || null; + const editorOuterId = this._getEditorOuterId(editorWindow); + if (typeof editorOuterId != "number") { + console.error("[calendarItemAction] could not resolve tab editor outer window id from messenger window", { + tabId, + mode: tabInfo?.mode?.name || "", + }); + return null; + } + return this._getEditorBridge().registerTabTarget(tabId, editorOuterId); + } + + _getTabEditorIdFromEditorWindow(window) { + const tabInfo = this._getCalendarTabInfoForEditorWindow(window); + if (!tabInfo) { + console.error("[calendarItemAction] could not map editor window to tabInfo", { + windowHref: window?.location?.href || "", + }); + return null; + } + const tabId = this._getManagedTabIdFromTabInfo(tabInfo); + if (typeof tabId != "number") { + console.error("[calendarItemAction] could not resolve managed tab id from editor window", { + mode: tabInfo?.mode?.name || "", + }); + return null; + } + const editorWindow = tabInfo?.iframe?.contentWindow || tabInfo?.iframe?.contentDocument?.defaultView || null; + const editorOuterId = this._getEditorOuterId(editorWindow); + if (typeof editorOuterId != "number") { + console.error("[calendarItemAction] could not resolve tab editor outer window id from editor window", { + tabId, + mode: tabInfo?.mode?.name || "", + }); + return null; + } + return this._getEditorBridge().registerTabTarget(tabId, editorOuterId); + } + + _getTriggerWindow(window, editorType) { + if (!window) { + return null; + } + if (editorType == "tab" && window.location?.href == CALITEM_EVENT_DIALOG_URL) { + const ownerWindow = window.ownerGlobal || null; + if (ownerWindow?.location?.href == CALITEM_MESSENGER_URL) { + return ownerWindow; + } + } + return window; + } + + _getEditorClickContext(window) { + const href = window?.location?.href || ""; + if (href == CALITEM_MESSENGER_URL) { + const editorId = this._getTabEditorIdFromMessengerWindow(window); + if (editorId) { + return { + editorType: "tab", + editorId, + }; + } + return null; + } + + if (href == CALITEM_EVENT_DIALOG_URL) { + const tabEditorId = this._getTabEditorIdFromEditorWindow(window); + if (tabEditorId) { + return { + editorType: "tab", + editorId: tabEditorId, + }; + } + + const dialogOuterId = this._getDialogOuterId(window); + if (typeof dialogOuterId != "number") { + return null; + } + const dialogEditorId = this._getEditorBridge().registerDialogTarget(dialogOuterId); + if (!dialogEditorId) { + return null; + } + this._ensureDialogReleaseListener(window, dialogEditorId); + return { + editorType: "dialog", + editorId: dialogEditorId, + }; + } + + return null; + } + handleEvent(event) { - super.handleEvent(event); const window = event.target.ownerGlobal; + if (event.type == "mousedown" && event.button == 0) { + if ( + event.target.tagName == "menu" || + event.target.tagName == "menuitem" || + event.target.getAttribute("type") == "menu" + ) { + return; + } + + const clickContext = this._getEditorClickContext(window); + if (!clickContext) { + console.error("[calendarItemAction] click ignored: could not resolve editor context", { + windowHref: window?.location?.href || "", + targetTag: event.target?.tagName || "", + }); + return; + } + const triggerWindow = this._getTriggerWindow(window, clickContext.editorType); + if (!triggerWindow) { + console.error("[calendarItemAction] click ignored: could not resolve trigger window", { + editorType: clickContext.editorType, + windowHref: window?.location?.href || "", + }); + return; + } + this.lastClickInfo = { + button: 0, + modifiers: this.global.clickModifiersFromEvent(event), + editorType: clickContext.editorType, + editorId: clickContext.editorId, + }; + this.triggerAction(triggerWindow); + return; + } + + super.handleEvent(event); switch (event.type) { case "popupshowing": { @@ -141,36 +441,17 @@ this.calendarItemAction = class extends ToolbarButtonAPI { } } - onShutdown() { - // TODO browserAction uses static onUninstall, this doesn't work in an experiment. + onShutdown(isAppShutdown) { + if (isAppShutdown) { + return; + } + + // Do not mutate xulStore during shutdown to preserve user toolbar customizations on upgrades. const extensionId = this.extension.id; ExtensionSupport.unregisterWindowListener("ext-calendar-itemAction-" + extensionId); - - const widgetId = makeWidgetId(extensionId); - const id = `${widgetId}-calendarItemAction-toolbarbutton`; - - const windowURLs = [ - "chrome://messenger/content/messenger.xhtml", - "chrome://calendar/content/calendar-event-dialog.xhtml" - ]; - - for (const windowURL of windowURLs) { - let currentSet = Services.xulStore.getValue( - windowURL, - "event-toolbar", - "currentset" - ); - currentSet = currentSet.split(","); - const index = currentSet.indexOf(id); - if (index >= 0) { - currentSet.splice(index, 1); - Services.xulStore.setValue( - windowURL, - "event-toolbar", - "currentset", - currentSet.join(",") - ); - } + if (this._editorBridge) { + this._editorBridge.clear(); + this._editorBridge = null; } } }; diff --git a/calendar/experiments/calendar/schema/calendar-items.json b/calendar/experiments/calendar/schema/calendar-items.json index bc44788..60f21d3 100644 --- a/calendar/experiments/calendar/schema/calendar-items.json +++ b/calendar/experiments/calendar/schema/calendar-items.json @@ -5,7 +5,7 @@ { "id": "CalendarItem", "type": "object", - "description": "TODO split event/task. Add more jscalendar props. Use format: 'date'.", + "description": "Calendar item envelope used by this API. `item` contains the payload in the selected return format.", "properties": { "id": { "type": "string" }, "calendarId": { "type": "string" }, @@ -13,6 +13,7 @@ "instance": { "type": "string", "optional": true }, "format": { "$ref": "CalendarItemFormats", "optional": true }, "item": { "$ref": "RawCalendarItem" }, + "editorId": { "$ref": "EditorId", "optional": true }, "metadata": { "type": "object", "additionalProperties": { "type": "any" }, "optional": true } } }, @@ -35,7 +36,7 @@ { "id": "CalendarItemAlarm", "type": "object", - "description": "TODO needs real structure", + "description": "Alarm payload emitted by onAlarm for the associated calendar item.", "properties": { "itemId": { "type": "string" }, "action": { "type": "string" }, @@ -43,6 +44,67 @@ "offset": { "type": "string" }, "related": { "type": "string", "enum": ["absolute", "start", "end"] } } + }, + { + "id": "EditorId", + "type": "string", + "description": "Opaque identifier for one specific open editor instance. Do not parse. Valid only while that exact editor instance remains open in the current Thunderbird session.", + "pattern": "^ed-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, + { + "id": "EditorFieldUpdates", + "type": "object", + "description": "UI field updates in the open editor. Uses dedicated field identifiers: title (`item-title`), location (`item-location`), description (`item-description`).", + "properties": { + "title": { "type": "string", "optional": true }, + "location": { "type": "string", "optional": true }, + "description": { "type": "string", "optional": true } + } + }, + { + "id": "EditorPropertyUpdates", + "type": "object", + "description": "Calendar item property updates. A string value sets/replaces a property value. A null value removes the property.", + "additionalProperties": { + "choices": [ + { "type": "string" }, + { "type": "null" } + ] + } + }, + { + "id": "EditorClosedAction", + "type": "string", + "description": "persisted: emitted for dialogaccept or dialogextra1. discarded: emitted for dialogcancel, dialogextra2, or unload. superseded: tracked editorId was replaced by a new editorId for the same UI target.", + "enum": ["persisted", "discarded", "superseded"] + }, + { + "id": "EditorClosedReason", + "type": "string", + "description": "Underlying UI signal that produced the close action.", + "enum": ["dialogaccept", "dialogextra1", "dialogcancel", "dialogextra2", "unload", "re-bound"] + }, + { + "id": "EditorClosedInfo", + "type": "object", + "description": "Lifecycle payload for one tracked editorId emitted by onTrackedEditorClosed.", + "properties": { + "editorId": { "$ref": "EditorId" }, + "action": { "$ref": "EditorClosedAction" }, + "reason": { "$ref": "EditorClosedReason", "optional": true } + } + }, + { + "id": "ItemChangeInfo", + "type": "object", + "description": "Change metadata for onUpdated. Currently `changeType` is always `full`.", + "properties": { + "changeType": { + "type": "string", + "enum": ["full"], + "optional": true + } + } } ], "functions": [ @@ -151,12 +213,31 @@ "name": "getCurrent", "async": true, "type": "function", + "description": "Get the current item for the editor identified by editorId. Returns null if that editor is no longer open or if no editable item can be resolved in that editor.", "parameters": [ { "type": "object", "name": "getOptions", - "optional": true, "properties": { + "returnFormat": { "$ref": "ReturnFormat", "optional": true }, + "editorId": { "$ref": "EditorId" } + } + } + ] + }, + { + "name": "updateCurrent", + "async": true, + "type": "function", + "description": "Update the current item for the editor identified by editorId. Requires at least one update via fields or properties. All requested UI fields are resolved before writing starts. The operation is transactional across fields and properties: if a field write fails, field changes are rolled back; if a property write fails, applied properties are rolled back and previously applied field changes are rolled back as well. Throws if the editor cannot be resolved or if any requested field target is unavailable.", + "parameters": [ + { + "type": "object", + "name": "updateOptions", + "properties": { + "editorId": { "$ref": "EditorId" }, + "fields": { "$ref": "EditorFieldUpdates", "optional": true }, + "properties": { "$ref": "EditorPropertyUpdates", "optional": true }, "returnFormat": { "$ref": "ReturnFormat", "optional": true } } } @@ -185,7 +266,7 @@ "type": "function", "parameters": [ { "name": "item", "$ref": "CalendarItem" }, - { "name": "changeInfo", "type": "object", "properties": {}, "description": "TODO needs properties" } + { "name": "changeInfo", "$ref": "ItemChangeInfo" } ], "extraParameters": [ { @@ -221,6 +302,14 @@ } } ] + }, + { + "name": "onTrackedEditorClosed", + "type": "function", + "description": "Fired for tracked editors only, after getCurrent() or updateCurrent() has been called for that editorId. Emitted once per tracked editorId. action reflects the exact close signal defined by EditorClosedAction and reason. No ordering guarantee relative to onCreated/onUpdated/onRemoved.", + "parameters": [ + { "name": "info", "$ref": "EditorClosedInfo" } + ] } ] } diff --git a/calendar/experiments/calendar/schema/calendarItemAction.json b/calendar/experiments/calendar/schema/calendarItemAction.json index 52fd362..7fab306 100644 --- a/calendar/experiments/calendar/schema/calendarItemAction.json +++ b/calendar/experiments/calendar/schema/calendarItemAction.json @@ -98,6 +98,12 @@ "^[1-9]\\d*$": {"$ref": "ImageDataType"} } }, + { + "id": "EditorId", + "type": "string", + "description": "Opaque identifier for one specific open editor instance. Do not parse. Valid only while that exact editor instance remains open in the current Thunderbird session.", + "pattern": "^ed-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$" + }, { "id": "OnClickData", "type": "object", @@ -115,6 +121,17 @@ "type": "integer", "optional": true, "description": "An integer value of button by which menu item was clicked." + }, + "editorId": { + "$ref": "EditorId", + "optional": true, + "description": "Opaque identifier of the concrete editor context where the action was invoked." + }, + "editorType": { + "type": "string", + "enum": ["dialog", "tab"], + "optional": true, + "description": "Editor variant where the action was invoked." } } } @@ -651,4 +668,3 @@ ] } ] -