diff --git a/.eslintrc.js b/.eslintrc.js index 829deee..510cba8 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -50,6 +50,9 @@ module.exports = { global: true, Services: true, }, + rules: { + "mozilla/reject-importGlobalProperties": "off" + } } ] }; diff --git a/calendar/experiments/calendar/child/ext-calendar-timezones.js b/calendar/experiments/calendar/child/ext-calendar-timezones.js new file mode 100644 index 0000000..0487ba0 --- /dev/null +++ b/calendar/experiments/calendar/child/ext-calendar-timezones.js @@ -0,0 +1,39 @@ +/* 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/. */ + +var { ExtensionCommon: { ExtensionAPI } } = ChromeUtils.importESModule("resource://gre/modules/ExtensionCommon.sys.mjs"); + +var { default: ICAL } = ChromeUtils.importESModule("resource:///modules/calendar/Ical.sys.mjs"); + +var { cal } = ChromeUtils.importESModule("resource:///modules/calendar/calUtils.sys.mjs"); + +this.calendar_timezones = class extends ExtensionAPI { + getAPI(_context) { + return { + calendar: { + timezones: { + get timezoneIds() { + return cal.timezoneService.timezoneIds; + }, + get currentZone() { + cal.timezoneService.wrappedJSObject._updateDefaultTimezone(); + return cal.timezoneService.defaultTimezone?.tzid; + }, + getDefinition(tzid, returnFormat) { + const timezoneDatabase = Cc["@mozilla.org/calendar/timezone-database;1"].getService( + Ci.calITimezoneDatabase + ); + let zoneInfo = timezoneDatabase.getTimezoneDefinition(tzid); + + if (returnFormat == "jcal") { + zoneInfo = ICAL.parse(zoneInfo); + } + + return zoneInfo; + }, + } + } + }; + } +}; diff --git a/calendar/experiments/calendar/ext-calendar-utils.sys.mjs b/calendar/experiments/calendar/ext-calendar-utils.sys.mjs index 453073c..3aa1354 100644 --- a/calendar/experiments/calendar/ext-calendar-utils.sys.mjs +++ b/calendar/experiments/calendar/ext-calendar-utils.sys.mjs @@ -2,6 +2,10 @@ * 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/. */ +/* + * WARNING: This file usually doesn't live reload, you need to restart Thunderbird after editing + */ + var { ExtensionUtils: { ExtensionError, promiseEvent } } = ChromeUtils.importESModule("resource://gre/modules/ExtensionUtils.sys.mjs"); var { cal } = ChromeUtils.importESModule("resource:///modules/calendar/calUtils.sys.mjs"); @@ -45,7 +49,6 @@ export function getCachedCalendar(calendar) { } export function isCachedCalendar(id) { - // TODO make this better return id.endsWith("#cache"); } @@ -60,12 +63,13 @@ export function convertCalendar(extension, calendar) { name: calendar.name, url: calendar.uri.spec, readOnly: calendar.readOnly, + visible: !!calendar.getProperty("calendar-main-in-composite"), + showReminders: !calendar.getProperty("suppressAlarms"), enabled: !calendar.getProperty("disabled"), color: calendar.getProperty("color") || "#A8C2E1", }; if (isOwnCalendar(calendar, extension)) { - // TODO find a better way to define the cache id props.cacheId = calendar.superCalendar.id + "#cache"; props.capabilities = unwrapCalendar(calendar.superCalendar).capabilities; // TODO needs deep clone? } @@ -73,65 +77,90 @@ export function convertCalendar(extension, calendar) { return props; } -export function propsToItem(props, baseItem) { - let item; - if (baseItem) { - item = baseItem; - } else if (props.type == "event") { - item = new CalEvent(); - cal.dtz.setDefaultStartEndHour(item); - } else if (props.type == "task") { - item = new CalTodo(); - cal.dtz.setDefaultStartEndHour(item); - } else { - throw new ExtensionError("Invalid item type: " + props.type); +function parseJcalData(jcalComp) { + function generateItem(jcalSubComp) { + let item; + if (jcalSubComp.name == "vevent") { + item = new CalEvent(); + } else if (jcalSubComp.name == "vtodo") { + item = new CalTodo(); + } else { + throw new ExtensionError("Invalid item component"); + } + + // TODO use calIcalComponent directly when bringing this to core + const comp = cal.icsService.createIcalComponent(jcalSubComp.name); + comp.wrappedJSObject.innerObject = jcalSubComp; + + item.icalComponent = comp; + return item; } - if (props.formats?.use == "ical") { - item.icalString = props.formats.ical; - } else if (props.formats?.use == "jcal") { - try { - item.icalString = ICAL.stringify(props.formats.jcal); - } catch (e) { - let jsonstring; - try { - jsonstring = JSON.stringify(props.formats.jcal, null, 2); - } catch { - jsonstring = props.formats.jcal; + if (jcalComp.name == "vevent" || jcalComp.name == "vtodo") { + // Single item only, no exceptions + return generateItem(jcalComp); + } else if (jcalComp.name == "vcalendar") { + // A vcalendar with vevents or vtodos + const exceptions = []; + let parent; + + for (const subComp of jcalComp.getAllSubcomponents()) { + if (subComp.name != "vevent" && subComp.name != "vtodo") { + continue; } - throw new ExtensionError("Could not parse jCal: " + e + "\n" + jsonstring); - } - } else { - if (props.id) { - item.id = props.id; - } - if (props.title) { - item.title = props.title; - } - if (props.description) { - item.setProperty("description", props.description); + if (subComp.hasProperty("recurrence-id")) { + exceptions.push(subComp); + continue; + } + + if (parent) { + throw new ExtensionError("Cannot parse more than one parent item"); + } + + parent = generateItem(subComp); } - if (props.location) { - item.setProperty("location", props.location); + + if (!parent) { + throw new ExtensionError("TODO need to retrieve a parent item from storage"); } - if (props.categories) { - item.setCategories(props.categories); + + if (exceptions.length && !parent.recurrenceInfo) { + throw new ExtensionError("Exceptions were supplied to a non-recurring item"); } - if (props.type == "event") { - // TODO need to do something about timezone - if (props.startDate) { - item.startDate = cal.createDateTime(props.startDate); + for (const exception of exceptions) { + const excItem = generateItem(exception); + if (excItem.id != parent.id || parent.isEvent() != excItem.isEvent()) { + throw new ExtensionError("Exception does not relate to parent item"); } - if (props.endDate) { - item.endDate = cal.createDateTime(props.endDate); - } - } else if (props.type == "task") { - // entryDate, dueDate, completedDate, isCompleted, duration + parent.recurrenceInfo.modifyException(excItem, true); + } + return parent; + } + throw new ExtensionError("Don't know how to handle component type " + jcalComp.name); +} + +export function propsToItem(props) { + let jcalComp; + + if (props.format == "ical") { + try { + jcalComp = new ICAL.Component(ICAL.parse(props.item)); + } catch (e) { + throw new ExtensionError("Could not parse iCalendar", { cause: e }); } + return parseJcalData(jcalComp); + } else if (props.format == "jcal") { + try { + jcalComp = new ICAL.Component(props.item); + } catch (e) { + throw new ExtensionError("Could not parse jCal", { cause: e }); + } + return parseJcalData(jcalComp); } - return item; + + throw new ExtensionError("Invalid item format: " + props.format); } export function convertItem(item, options, extension) { @@ -141,18 +170,22 @@ export function convertItem(item, options, extension) { const props = {}; - if (item instanceof Ci.calIEvent) { + if (item.isEvent()) { props.type = "event"; - } else if (item instanceof Ci.calITodo) { + } else if (item.isTodo()) { props.type = "task"; + } else { + throw new ExtensionError(`Encountered unknown item type for ${item.calendar.id}/${item.id}`); } props.id = item.id; props.calendarId = item.calendar.superCalendar.id; - props.title = item.title || ""; - props.description = item.getProperty("description") || ""; - props.location = item.getProperty("location") || ""; - props.categories = item.getCategories(); + + const recId = item.recurrenceId?.getInTimezone(cal.timezoneService.UTC)?.icalString; + if (recId) { + const jcalId = ICAL.design.icalendar.value[recId.length == 8 ? "date" : "date-time"].fromICAL(recId); + props.instance = jcalId; + } if (isOwnCalendar(item.calendar, extension)) { props.metadata = {}; @@ -166,34 +199,27 @@ export function convertItem(item, options, extension) { } if (options?.returnFormat) { - props.formats = { use: null }; - let formats = options.returnFormat; - if (!Array.isArray(formats)) { - formats = [formats]; - } + props.format = options.returnFormat; - for (const format of formats) { - switch (format) { - case "ical": - props.formats.ical = item.icalString; - break; - case "jcal": - // TODO shortcut when using icaljs backend - props.formats.jcal = ICAL.parse(item.icalString); - break; - default: - throw new ExtensionError("Invalid format specified: " + format); - } + const serializer = Cc["@mozilla.org/calendar/ics-serializer;1"].createInstance( + Ci.calIIcsSerializer + ); + serializer.addItems([item]); + const icalString = serializer.serializeToString(); + + switch (options.returnFormat) { + case "ical": + props.item = icalString; + break; + case "jcal": + // TODO shortcut when using icaljs backend + props.item = ICAL.parse(icalString); + break; + default: + throw new ExtensionError("Invalid format specified: " + options.returnFormat); } } - if (props.type == "event") { - props.startDate = item.startDate.icalString; - props.endDate = item.endDate.icalString; - } else if (props.type == "task") { - // TODO extra properties - } - return props; } @@ -250,7 +276,6 @@ export async function setupE10sBrowser(extension, browser, parent, initOptions={ } sheets.push("chrome://browser/content/extension-popup-panel.css"); - const initBrowser = () => { ExtensionParent.apiManager.emit("extension-browser-inserted", browser); const mm = browser.messageManager; diff --git a/calendar/experiments/calendar/parent/ext-calendar-calendars.js b/calendar/experiments/calendar/parent/ext-calendar-calendars.js index 01d9238..e3fe6b8 100644 --- a/calendar/experiments/calendar/parent/ext-calendar-calendars.js +++ b/calendar/experiments/calendar/parent/ext-calendar-calendars.js @@ -19,7 +19,7 @@ this.calendar_calendars = class extends ExtensionAPI { return { calendar: { calendars: { - async query({ type, url, name, color, readOnly, enabled }) { + async query({ type, url, name, color, readOnly, enabled, visible }) { const calendars = cal.manager.getCalendars(); let pattern = null; @@ -56,6 +56,10 @@ this.calendar_calendars = class extends ExtensionAPI { matches = false; } + if (visible != null & calendar.getProperty("calendar-main-in-composite") != visible) { + matches = false; + } + if (readOnly != null && calendar.readOnly != readOnly) { matches = false; } @@ -65,7 +69,6 @@ this.calendar_calendars = class extends ExtensionAPI { .map(calendar => convertCalendar(context.extension, calendar)); }, async get(id) { - // TODO find a better way to determine cache id if (id.endsWith("#cache")) { const calendar = unwrapCalendar(cal.manager.getCalendarById(id.substring(0, id.length - 6))); const own = calendar.offlineStorage && isOwnCalendar(calendar, context.extension); @@ -87,6 +90,12 @@ this.calendar_calendars = class extends ExtensionAPI { if (typeof createProperties.color != "undefined") { calendar.setProperty("color", createProperties.color); } + if (typeof createProperties.visible != "undefined") { + calendar.setProperty("calendar-main-in-composite", createProperties.visible); + } + if (typeof createProperties.showReminders != "undefined") { + calendar.setProperty("suppressAlarms", !createProperties.showReminders); + } cal.manager.registerCalendar(calendar); @@ -114,6 +123,14 @@ this.calendar_calendars = class extends ExtensionAPI { calendar.setProperty("disabled", !updateProperties.enabled); } + if (updateProperties.visible != null) { + calendar.setProperty("calendar-main-in-composite", updateProperties.visible); + } + + if (updateProperties.showReminders != null) { + calendar.setProperty("suppressAlarms", !updateProperties.showReminders); + } + for (const prop of ["readOnly", "name", "color"]) { if (updateProperties[prop] != null) { calendar.setProperty(prop, updateProperties[prop]); @@ -124,6 +141,7 @@ this.calendar_calendars = class extends ExtensionAPI { // TODO validate capability names const unwrappedCalendar = calendar.wrappedJSObject.mUncachedCalendar.wrappedJSObject; unwrappedCalendar.capabilities = Object.assign({}, unwrappedCalendar.capabilities, updateProperties.capabilities); + calendar.setProperty("extensionCapabilities", JSON.stringify(unwrappedCalendar.capabilities)); } if (updateProperties.lastError !== undefined) { @@ -237,6 +255,12 @@ this.calendar_calendars = class extends ExtensionAPI { case "uri": fire.sync(converted, { url: value?.spec }); break; + case "suppressAlarms": + fire.sync(converted, { showReminders: !value }); + break; + case "calendar-main-in-composite": + fire.sync(converted, { visible: value }); + break; case "disabled": fire.sync(converted, { enabled: !value }); break; diff --git a/calendar/experiments/calendar/parent/ext-calendar-items.js b/calendar/experiments/calendar/parent/ext-calendar-items.js index dd7ac0f..e4dca08 100644 --- a/calendar/experiments/calendar/parent/ext-calendar-items.js +++ b/calendar/experiments/calendar/parent/ext-calendar-items.js @@ -91,12 +91,15 @@ this.calendar_items = class extends ExtensionAPI { if (!oldItem) { throw new ExtensionError("Could not find item " + id); } - if (oldItem instanceof Ci.calIEvent) { + if (oldItem.isEvent()) { updateProperties.type = "event"; - } else if (oldItem instanceof Ci.calITodo) { + } else if (oldItem.isTodo()) { updateProperties.type = "task"; + } else { + throw new ExtensionError(`Encountered unknown item type for ${calendarId}/${id}`); } - const newItem = propsToItem(updateProperties, oldItem?.clone()); + + const newItem = propsToItem(updateProperties); newItem.calendar = calendar.superCalendar; if (updateProperties.metadata && isOwnCalendar(calendar, context.extension)) { @@ -141,6 +144,17 @@ this.calendar_items = class extends ExtensionAPI { await calendar.deleteItem(item); }, + 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); + return null; + } + }, + onCreated: new EventManager({ context, name: "calendar.items.onCreated", diff --git a/calendar/experiments/calendar/parent/ext-calendar-provider.js b/calendar/experiments/calendar/parent/ext-calendar-provider.js index 3bb60a3..89964eb 100644 --- a/calendar/experiments/calendar/parent/ext-calendar-provider.js +++ b/calendar/experiments/calendar/parent/ext-calendar-provider.js @@ -2,11 +2,23 @@ * 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: { ExtensionAPI, EventManager } } = ChromeUtils.importESModule("resource://gre/modules/ExtensionCommon.sys.mjs"); +var { ExtensionCommon: { ExtensionAPI, EventManager, EventEmitter } } = ChromeUtils.importESModule("resource://gre/modules/ExtensionCommon.sys.mjs"); +var { ExtensionUtils: { ExtensionError } } = ChromeUtils.importESModule("resource://gre/modules/ExtensionUtils.sys.mjs"); var { cal } = ChromeUtils.importESModule("resource:///modules/calendar/calUtils.sys.mjs"); var { ExtensionSupport } = ChromeUtils.importESModule("resource:///modules/ExtensionSupport.sys.mjs"); +// TODO move me +function getNewCalendarWindow() { + // This window is missing a windowtype attribute + for (const win of Services.wm.getEnumerator(null)) { + if (win.location == "chrome://calendar/content/calendar-creation.xhtml") { + return win; + } + } + return null; +} + // TODO move me class ItemError extends Error { static CONFLICT = "CONFLICT"; @@ -36,12 +48,18 @@ function convertProps(props, extension) { calendar.setProperty("readOnly", props.readOnly); calendar.setProperty("disabled", props.enabled === false); calendar.setProperty("color", props.color || "#A8C2E1"); + calendar.capabilities = props.capabilities; // TODO validation necessary? calendar.uri = Services.io.newURI(props.url); return calendar; } +function stackContains(part) { + return new Error().stack.includes(part); +} + + class ExtCalendarProvider { QueryInterface = ChromeUtils.generateQI(["calICalendarProvider"]); @@ -116,17 +134,8 @@ class ExtCalendar extends cal.provider.BaseClass { return this.extension.id; } - get capabilities() { - if (!this._capabilities) { - this._capabilities = this.extension.manifest.calendar_provider.capabilities || {}; - } - return this._capabilities; - } - set capabilities(val) { - this._capabilities = val; - } - canRefresh = true; + capabilities = {}; get id() { return super.id; @@ -134,6 +143,12 @@ class ExtCalendar extends cal.provider.BaseClass { set id(val) { super.id = val; if (this.id && this.uri) { + try { + this.capabilities = JSON.parse(super.getProperty("extensionCapabilities")); + } catch (e) { + this.capabilities = this.extension.manifest.calendar_provider.capabilities || {}; + } + this.extension.emit("calendar.provider.onInit", this); } } @@ -226,7 +241,15 @@ class ExtCalendar extends cal.provider.BaseClass { async adoptItem(aItem) { const adoptCallback = this._cachedAdoptItemCallback; try { - const items = await this.extension.emit("calendar.provider.onItemCreated", this, aItem); + // TODO There should be an easier way to determine this + const options = {}; + if (stackContains("calItipUtils")) { + options.invitation = true; + } else if (stackContains("playbackOfflineItems")) { + options.offline = true; + } + + const items = await this.extension.emit("calendar.provider.onItemCreated", this, aItem, options); const { item, metadata } = items.find(props => props.item) || {}; if (!item) { throw new Components.Exception("Did not receive item from extension", Cr.NS_ERROR_FAILURE); @@ -253,7 +276,9 @@ class ExtCalendar extends cal.provider.BaseClass { return item; } catch (e) { let code; - if (e instanceof ItemError) { + if (e.message.startsWith("NetworkError")) { + code = Cr.NS_ERROR_NET_INTERRUPT; + } else if (e instanceof ItemError) { code = e.xpcomReason; } else { code = e.result || Cr.NS_ERROR_FAILURE; @@ -291,6 +316,13 @@ class ExtCalendar extends cal.provider.BaseClass { async modifyItem(aNewItem, aOldItem, aOptions = {}) { const modifyCallback = this._cachedModifyItemCallback; + // TODO There should be an easier way to determine this + if (stackContains("calItipUtils")) { + aOptions.invitation = true; + } else if (stackContains("playbackOfflineItems")) { + aOptions.offline = true; + } + try { const results = await this.extension.emit( "calendar.provider.onItemUpdated", @@ -320,7 +352,9 @@ class ExtCalendar extends cal.provider.BaseClass { return item; } catch (e) { let code; - if (e instanceof ItemError) { + if (e.message.startsWith("NetworkError")) { + code = Cr.NS_ERROR_NET_INTERRUPT; + } else if (e instanceof ItemError) { if (e.reason == ItemError.CONFLICT) { const overwrite = cal.provider.promptOverwrite("modify", aOldItem); if (overwrite) { @@ -339,6 +373,13 @@ class ExtCalendar extends cal.provider.BaseClass { } async deleteItem(aItem, aOptions = {}) { + // TODO There should be an easier way to determine this + if (stackContains("calItipUtils")) { + aOptions.invitation = true; + } else if (stackContains("playbackOfflineItems")) { + aOptions.offline = true; + } + try { const results = await this.extension.emit( "calendar.provider.onItemRemoved", @@ -360,7 +401,9 @@ class ExtCalendar extends cal.provider.BaseClass { this.observers.notify("onDeleteItem", [aItem]); } catch (e) { let code; - if (e instanceof ItemError) { + if (e.message.startsWith("NetworkError")) { + code = Cr.NS_ERROR_NET_INTERRUPT; + } else if (e instanceof ItemError) { if (e.reason == ItemError.CONFLICT) { const overwrite = cal.provider.promptOverwrite("delete", aItem); if (overwrite) { @@ -423,21 +466,24 @@ class ExtFreeBusyProvider { async getFreeBusyIntervals(aCalId, aRangeStart, aRangeEnd, aBusyTypes, aListener) { try { const TYPE_MAP = { + unknown: Ci.calIFreeBusyInterval.UNKNOWN, free: Ci.calIFreeBusyInterval.FREE, busy: Ci.calIFreeBusyInterval.BUSY, unavailable: Ci.calIFreeBusyInterval.BUSY_UNAVAILABLE, tentative: Ci.calIFreeBusyInterval.BUSY_TENTATIVE, }; const attendee = aCalId.replace(/^mailto:/, ""); - const start = aRangeStart.icalString; - const end = aRangeEnd.icalString; + const start = cal.dtz.toRFC3339(aRangeStart); + const end = cal.dtz.toRFC3339(aRangeEnd); const types = ["free", "busy", "unavailable", "tentative"].filter((type, index) => aBusyTypes & (1 << index)); - const results = await this.fire.async({ attendee, start, end, types }); + const results = await this.fire.async(attendee, start, end, types); aListener.onResult({ status: Cr.NS_OK }, results.map(interval => new cal.provider.FreeBusyInterval(aCalId, TYPE_MAP[interval.type], - cal.createDateTime(interval.start), - cal.createDateTime(interval.end)))); + cal.dtz.fromRFC3339(interval.start, cal.dtz.UTC), + cal.dtz.fromRFC3339(interval.end, cal.dtz.UTC) + ) + )); } catch (e) { console.error(e); aListener.onResult({ status: e.result || Cr.NS_ERROR_FAILURE }, e.message || e); @@ -456,11 +502,44 @@ this.calendar_provider = class extends ExtensionAPI { .QueryInterface(Ci.nsIResProtocolHandler) .setSubstitution("tb-experiments-calendar", this.extension.rootURI); - const { setupE10sBrowser } = ChromeUtils.importESModule("resource://tb-experiments-calendar/experiments/calendar/ext-calendar-utils.sys.mjs"); + const { setupE10sBrowser, unwrapCalendar } = ChromeUtils.importESModule("resource://tb-experiments-calendar/experiments/calendar/ext-calendar-utils.sys.mjs"); ChromeUtils.registerWindowActor("CalendarProvider", { child: { esModuleURI: "resource://tb-experiments-calendar/experiments/calendar/child/ext-calendar-provider-actor.sys.mjs" } }); - ExtensionSupport.registerWindowListener("ext-calendar-provider-" + this.extension.id, { + ExtensionSupport.registerWindowListener("ext-calendar-provider-properties-" + this.extension.id, { + chromeURLs: ["chrome://calendar/content/calendar-properties-dialog.xhtml"], + onLoadWindow: (win) => { + const calendar = unwrapCalendar(win.arguments[0].calendar); + if (calendar.type != "ext-" + this.extension.id) { + return; + } + + // Work around a bug where the notification is shown when imip is disabled + if (calendar.getProperty("imip.identity.disabled")) { + win.gIdentityNotification.removeAllNotifications(); + } + + const minRefresh = calendar.capabilities?.minimumRefresh; + + if (minRefresh) { + const refInterval = win.document.getElementById("calendar-refreshInterval-menupopup"); + for (const node of [...refInterval.children]) { + const nodeval = parseInt(node.getAttribute("value"), 10); + if (nodeval < minRefresh && nodeval != 0) { + node.remove(); + } + } + } + + const mutable = calendar.capabilities?.mutable; + + if (!mutable) { + win.document.getElementById("read-only").disabled = true; + } + } + }); + + ExtensionSupport.registerWindowListener("ext-calendar-provider-creation-" + this.extension.id, { chromeURLs: ["chrome://calendar/content/calendar-creation.xhtml"], onLoadWindow: (win) => { const provider = this.extension.manifest.calendar_provider; @@ -483,19 +562,42 @@ this.calendar_provider = class extends ExtensionAPI { browser.fixupAndLoadURIString(calendarType.panelSrc, { triggeringPrincipal: this.extension.principal }); }); - win.gButtonHandlers.forNodeId["panel-addon-calendar-settings"].accept = calendarType.onCreated; + win.gButtonHandlers.forNodeId["panel-addon-calendar-settings"].accept = (event) => { + const addonPanel = win.document.getElementById("panel-addon-calendar-settings"); + if (addonPanel.dataset.addonForward) { + event.preventDefault(); + event.target.getButton("accept").disabled = true; + win.gAddonAdvance.emit("advance", "forward", addonPanel.dataset.addonForward).finally(() => { + event.target.getButton("accept").disabled = false; + }); + } else if (calendarType.onCreated) { + calendarType.onCreated(); + } else { + win.close(); + } + }; + win.gButtonHandlers.forNodeId["panel-addon-calendar-settings"].extra2 = (_event) => { + const addonPanel = win.document.getElementById("panel-addon-calendar-settings"); + + if (addonPanel.dataset.addonBackward) { + win.gAddonAdvance.emit("advance", "back", addonPanel.dataset.addonBackward); + } else { + win.selectPanel("panel-select-calendar-type"); + + // Reload the window, the add-on might expect to do some initial setup when going + // back and forward again. + win.setUpAddonCalendarSettingsPanel(extCalendarType); + } + }; }; - win.registerCalendarType({ + const extCalendarType = { label: this.extension.localize(provider.name), panelSrc: this.extension.getURL(this.extension.localize(provider.creation_panel)), - onCreated: () => { - // TODO temporary - const browser = win.document.getElementById("panel-addon-calendar-settings").lastElementChild; - const actor = browser.browsingContext.currentWindowGlobal.getActor("CalendarProvider"); - actor.sendAsyncMessage("postMessage", { message: "create", origin: this.extension.getURL("") }); - } - }); + }; + win.registerCalendarType(extCalendarType); + + win.gAddonAdvance = new EventEmitter(); } } }); @@ -504,7 +606,8 @@ this.calendar_provider = class extends ExtensionAPI { if (isAppShutdown) { return; } - ExtensionSupport.unregisterWindowListener("ext-calendar-provider-" + this.extension.id); + ExtensionSupport.unregisterWindowListener("ext-calendar-provider-creation-" + this.extension.id); + ExtensionSupport.unregisterWindowListener("ext-calendar-provider-properties-" + this.extension.id); ChromeUtils.unregisterWindowActor("CalendarProvider"); if (this.extension.manifest.calendar_provider) { @@ -568,7 +671,7 @@ this.calendar_provider = class extends ExtensionAPI { } if (props?.type) { - item = propsToItem(props, item); + item = propsToItem(props); } if (!item.id) { item.id = cal.getUUID(); @@ -598,7 +701,7 @@ this.calendar_provider = class extends ExtensionAPI { return { error: props.error }; } if (props?.type) { - item = propsToItem(props, item); + item = propsToItem(props); } return { item, metadata: props?.metadata }; }; @@ -702,6 +805,55 @@ this.calendar_provider = class extends ExtensionAPI { }; } }).api(), + + + // New calendar dialog + async setAdvanceAction({ forward, back, label }) { + const window = getNewCalendarWindow(); + if (!window) { + throw new ExtensionError("New calendar wizard is not open"); + } + const addonPanel = window.document.getElementById("panel-addon-calendar-settings"); + if (forward) { + addonPanel.dataset.addonForward = forward; + } else { + delete addonPanel.dataset.addonForward; + } + + if (back) { + addonPanel.dataset.addonBackward = back; + } else { + delete addonPanel.dataset.addonBackward; + } + + addonPanel.setAttribute("buttonlabelaccept", label); + if (!addonPanel.hidden) { + window.updateButton("accept", addonPanel); + } + }, + onAdvanceNewCalendar: new EventManager({ + context, + name: "calendar.provider.onAdvanceNewCalendar", + register: fire => { + const handler = async (event, direction, actionId) => { + const result = await fire.async(actionId); + + if (direction == "forward" && result !== false) { + getNewCalendarWindow()?.close(); + } + }; + + const win = getNewCalendarWindow(); + if (!win) { + throw new ExtensionError("New calendar wizard is not open"); + } + + win.gAddonAdvance.on("advance", handler); + return () => { + getNewCalendarWindow()?.gAddonAdvance.off("advance", handler); + }; + }, + }).api() }, }, }; diff --git a/calendar/experiments/calendar/parent/ext-calendar-timezones.js b/calendar/experiments/calendar/parent/ext-calendar-timezones.js new file mode 100644 index 0000000..0422bf9 --- /dev/null +++ b/calendar/experiments/calendar/parent/ext-calendar-timezones.js @@ -0,0 +1,48 @@ +/* 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/. */ + +var { ExtensionCommon: { ExtensionAPI, EventManager } } = ChromeUtils.importESModule("resource://gre/modules/ExtensionCommon.sys.mjs"); + +var { cal } = ChromeUtils.importESModule("resource:///modules/calendar/calUtils.sys.mjs"); + +this.calendar_timezones = class extends ExtensionAPI { + getAPI(context) { + return { + calendar: { + timezones: { + onUpdated: new EventManager({ + context, + name: "calendar.timezones.onUpdated", + register: fire => { + cal.timezoneService.wrappedJSObject._updateDefaultTimezone(); + let lastValue = cal.timezoneService.defaultTimezone?.tzid; + + const observer = { + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), + observe(_subject, _topic, _data) { + // Make sure the default timezone is updated before firing + cal.timezoneService.wrappedJSObject._updateDefaultTimezone(); + const currentValue = cal.timezoneService.defaultTimezone?.tzid; + if (currentValue != lastValue) { + lastValue = currentValue; + fire.sync(currentValue); + } + } + }; + + Services.prefs.addObserver("calendar.timezone.useSystemTimezone", observer); + Services.prefs.addObserver("calendar.timezone.local", observer); + Services.obs.addObserver(observer, "default-timezone-changed"); + return () => { + Services.obs.removeObserver(observer, "default-timezone-changed"); + Services.prefs.removeObserver("calendar.timezone.local", observer); + Services.prefs.removeObserver("calendar.timezone.useSystemTimezone", observer); + }; + }, + }).api(), + } + } + }; + } +}; diff --git a/calendar/experiments/calendar/parent/ext-calendarItemDetails.js b/calendar/experiments/calendar/parent/ext-calendarItemDetails.js index fa9e56f..d0d6fad 100644 --- a/calendar/experiments/calendar/parent/ext-calendarItemDetails.js +++ b/calendar/experiments/calendar/parent/ext-calendarItemDetails.js @@ -4,24 +4,36 @@ var { ExtensionCommon: { ExtensionAPI, makeWidgetId } } = 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"); +Cu.importGlobalProperties(["URL"]); + this.calendarItemDetails = class extends ExtensionAPI { onLoadCalendarItemPanel(window, origLoadCalendarItemPanel, iframeId, url) { const { setupE10sBrowser } = ChromeUtils.importESModule("resource://tb-experiments-calendar/experiments/calendar/ext-calendar-utils.sys.mjs"); const res = origLoadCalendarItemPanel(iframeId, url); - if (this.extension.manifest.calendar_item_details) { - let panelFrame; - if (window.tabmail) { - panelFrame = window.document.getElementById(iframeId || window.tabmail.currentTabInfo.iframe?.id); - } else { - panelFrame = window.document.getElementById("calendar-item-panel-iframe"); - } + if (!this.extension.manifest.calendar_item_details) { + return res; + } + let panelFrame; + if (window.tabmail) { + panelFrame = window.document.getElementById(iframeId || window.tabmail.currentTabInfo.iframe?.id); + } else { + panelFrame = window.document.getElementById("calendar-item-panel-iframe"); + } - panelFrame.contentWindow.addEventListener("load", (event) => { - const document = event.target.ownerGlobal.document; + panelFrame.contentWindow.addEventListener("load", (event) => { + const document = event.target.ownerGlobal.document; + let areas = this.extension.manifest.calendar_item_details.allowed_areas || ["secondary"]; + if (!Array.isArray(areas)) { + areas = [areas]; + } + + if (areas.includes("secondary")) { const widgetId = makeWidgetId(this.extension.id); const tabs = document.getElementById("event-grid-tabs"); @@ -40,17 +52,87 @@ this.calendarItemDetails = class extends ExtensionAPI { const browser = document.createXULElement("browser"); browser.setAttribute("flex", "1"); - const loadPromise = setupE10sBrowser(this.extension, browser, tabpanel); - return loadPromise.then(() => { - browser.fixupAndLoadURIString(this.extension.manifest.calendar_item_details.default_content, { triggeringPrincipal: this.extension.principal }); + const options = { maxWidth: null, fixedWidth: true }; + setupE10sBrowser(this.extension, browser, tabpanel, options).then(() => { + const target = new URL(this.extension.manifest.calendar_item_details.default_content); + target.searchParams.set("area", "secondary"); + browser.fixupAndLoadURIString(target.href, { triggeringPrincipal: this.extension.principal }); }); - }); - } + } else if (areas.includes("inline")) { + const tabbox = document.getElementById("event-grid"); + + const browserRow = tabbox.appendChild(document.createElementNS("http://www.w3.org/1999/xhtml", "tr")); + const browserCell = browserRow.appendChild(document.createElementNS("http://www.w3.org/1999/xhtml", "td")); + browserRow.className = "event-grid-link-row"; + browserCell.setAttribute("colspan", "2"); + + const separator = tabbox.appendChild(document.createElementNS("http://www.w3.org/1999/xhtml", "tr")); + separator.className = "separator"; + const separatorCell = separator.appendChild(document.createElementNS("http://www.w3.org/1999/xhtml", "td")); + separatorCell.setAttribute("colspan", "2"); + + const browser = document.createXULElement("browser"); + browser.setAttribute("flex", "1"); + + // TODO The real version will need a max-height and auto-resizing + browser.style.height = "200px"; + browser.style.width = "100%"; + browser.style.display = "block"; + + // Fix an annoying bug, this should be part of a different patch + document.getElementById("url-link").style.maxWidth = "42em"; + + const options = { maxWidth: null, fixedWidth: true }; + setupE10sBrowser(this.extension, browser, browserCell, options).then(() => { + const target = new URL(this.extension.manifest.calendar_item_details.default_content); + target.searchParams.set("area", "inline"); + browser.fixupAndLoadURIString(target.href, { triggeringPrincipal: this.extension.principal }); + }); + } + }); return res; } + onLoadSummary(window) { + const { setupE10sBrowser } = ChromeUtils.importESModule("resource://tb-experiments-calendar/experiments/calendar/ext-calendar-utils.sys.mjs"); + + const document = window.document; + + // Fix an annoying bug, this should be part of a different patch + document.querySelector(".url-link").style.maxWidth = "42em"; + + let areas = this.extension.manifest.calendar_item_details.allowed_areas || ["secondary"]; + if (!Array.isArray(areas)) { + areas = [areas]; + } + + + if (areas.includes("summary")) { + const summaryBox = document.querySelector(".item-summary-box"); + + const browser = document.createXULElement("browser"); + browser.id = "ext-calendar-item-details-" + this.extension.id; + browser.style.minHeight = "150px"; + document.getElementById(browser.id)?.remove(); + + const separator = document.createXULElement("separator"); + separator.id = "ext-calendar-item-details-separator-" + this.extension.id; + separator.className = "groove"; + + document.getElementById(separator.id)?.remove(); + summaryBox.appendChild(separator); + + const options = { maxWidth: null, fixedWidth: true }; + setupE10sBrowser(this.extension, browser, summaryBox, options).then(() => { + const target = new URL(this.extension.manifest.calendar_item_details.default_content); + target.searchParams.set("area", "summary"); + browser.fixupAndLoadURIString(target.href, { triggeringPrincipal: this.extension.principal }); + }); + } + } + onStartup() { const calendarItemDetails = this.extension.manifest?.calendar_item_details; if (calendarItemDetails) { @@ -66,9 +148,22 @@ this.calendarItemDetails = class extends ExtensionAPI { if (calendarItemDetails.default_title) { calendarItemDetails.default_title = localize(calendarItemDetails.default_title); } + + const areas = calendarItemDetails.allowed_areas; + if (Array.isArray(areas) && areas.includes("inline") && areas.includes("secondary")) { + throw new ExtensionError("Cannot show calendar_item_details both inline and secondary"); + } } + ExtensionSupport.registerWindowListener("ext-calendarItemDetails-summary-" + this.extension.id, { + chromeURLs: [ + "chrome://calendar/content/calendar-summary-dialog.xhtml" + ], + onLoadWindow: (window) => { + this.onLoadSummary(window); + } + }); - ExtensionSupport.registerWindowListener("ext-calendarItemDetails-" + this.extension.id, { + ExtensionSupport.registerWindowListener("ext-calendarItemDetails-event-" + this.extension.id, { chromeURLs: [ "chrome://messenger/content/messenger.xhtml", "chrome://calendar/content/calendar-event-dialog.xhtml" @@ -87,7 +182,8 @@ this.calendarItemDetails = class extends ExtensionAPI { }); } onShutdown() { - ExtensionSupport.unregisterWindowListener("ext-calendarItemDetails-" + this.extension.id); + ExtensionSupport.unregisterWindowListener("ext-calendarItemDetails-event-" + this.extension.id); + ExtensionSupport.unregisterWindowListener("ext-calendarItemDetails-summary-" + this.extension.id); for (const wnd of ExtensionSupport.openWindows) { if (wnd.location.href == "chrome://messenger/content/messenger.xhtml") { diff --git a/calendar/experiments/calendar/schema/calendar-calendars.json b/calendar/experiments/calendar/schema/calendar-calendars.json index 7b81444..388fae5 100644 --- a/calendar/experiments/calendar/schema/calendar-calendars.json +++ b/calendar/experiments/calendar/schema/calendar-calendars.json @@ -13,6 +13,8 @@ "url": { "type": "string" }, "readOnly": { "type": "boolean" }, "enabled": { "type": "boolean" }, + "visible": { "type": "boolean" }, + "showReminders": { "type": "boolean" }, "color": { "type": "string", "optional": true }, "capabilities": { "$ref": "CalendarCapabilities", "optional": true } } @@ -25,6 +27,8 @@ "url": { "type": "string" }, "readOnly": { "type": "boolean" }, "enabled": { "type": "boolean" }, + "visible": { "type": "boolean" }, + "showReminders": { "type": "boolean" }, "color": { "type": "string", "optional": true } } }, @@ -78,7 +82,8 @@ } }, "requires_network": { "type": "boolean", "optional": true }, - "minimum_refresh_interval": { "type": "integer", "minimum": -1, "optional": true } + "minimum_refresh_interval": { "type": "integer", "minimum": -1, "optional": true }, + "mutable": { "type": "boolean", "optional": true, "default": true } } } ], @@ -103,6 +108,7 @@ "name": { "type": "string", "optional": true }, "color": { "type": "string", "optional": true }, "readOnly": { "type": "boolean", "optional": true }, + "visible": { "type": "boolean", "optional": true }, "enabled": { "type": "boolean", "optional": true } } } @@ -130,6 +136,8 @@ "url": { "type": "string" }, "readOnly": { "type": "boolean", "optional": true }, "enabled": { "type": "boolean", "optional": true }, + "visible": { "type": "boolean", "optional": true }, + "showReminders": { "type": "boolean", "optional": true }, "color": { "type": "string", "optional": true }, "capabilities": { "$ref": "CalendarCapabilities", "optional": true } } diff --git a/calendar/experiments/calendar/schema/calendar-items.json b/calendar/experiments/calendar/schema/calendar-items.json index 5fa6dc0..d8ae00a 100644 --- a/calendar/experiments/calendar/schema/calendar-items.json +++ b/calendar/experiments/calendar/schema/calendar-items.json @@ -10,29 +10,15 @@ "id": { "type": "string" }, "calendarId": { "type": "string" }, "type": { "type": "string", "enum": ["event", "task"] }, - "title": { "type": "string", "optional": true }, - "description": { "type": "string", "optional": true }, - "location": { "type": "string", "optional": true }, - "categories": { "type": "array", "items": { "type": "string" }, "optional": true }, - "startDate": { "type": "string", "optional": true }, - "endDate": { "type": "string", "optional": true }, - "formats": { "$ref": "RawCalendarItem", "optional": true }, + "instance": { "type": "string", "optional": true }, + "format": { "$ref": "CalendarItemFormat", "optional": true }, + "item": { "$ref": "RawCalendarItem" }, "metadata": { "type": "object", "additionalProperties": { "type": "any" }, "optional": true } } }, { "id": "RawCalendarItem", - "type": "object", - "properties": { - "use": { - "choices": [ - { "type": "null" }, - { "$ref": "CalendarItemFormats" } - ] - }, - "ical": { "type": "string", "optional": true }, - "jcal": { "type": "any", "optional": true } - } + "type": "any" }, { "id": "CalendarItemFormats", @@ -115,13 +101,8 @@ "properties": { "id": { "type": "string", "optional": true }, "type": { "type": "string", "enum": ["event", "task"] }, - "title": { "type": "string", "optional": true }, - "description": { "type": "string", "optional": true }, - "location": { "type": "string", "optional": true }, - "categories": { "type": "array", "items": { "type": "string" }, "optional": true }, - "startDate": { "type": "string", "optional": true }, - "endDate": { "type": "string", "optional": true }, - "formats": { "$ref": "RawCalendarItem", "optional": true }, + "format": { "$ref": "CalendarItemFormats", "optional": true }, + "item": { "$ref": "RawCalendarItem" }, "returnFormat": { "$ref": "ReturnFormat", "optional": true }, "metadata": { "type": "object", "properties": {}, "additionalProperties": { "type": "any" }, "optional": true } } @@ -139,13 +120,8 @@ "name": "updateProperties", "type": "object", "properties": { - "title": { "type": "string", "optional": true }, - "description": { "type": "string", "optional": true }, - "location": { "type": "string", "optional": true }, - "categories": { "type": "array", "items": { "type": "string" }, "optional": true }, - "startDate": { "type": "string", "optional": true }, - "endDate": { "type": "string", "optional": true }, - "formats": { "$ref": "RawCalendarItem", "optional": true }, + "format": { "$ref": "CalendarItemFormat", "optional": true }, + "item": { "$ref": "RawCalendarItem" }, "returnFormat": { "$ref": "ReturnFormat", "optional": true }, "metadata": { "type": "object", "additionalProperties": { "type": "any" }, "optional": true } } @@ -170,6 +146,21 @@ { "type": "string", "name": "calendarId" }, { "type": "string", "name": "id" } ] + }, + { + "name": "getCurrent", + "async": true, + "type": "function", + "parameters": [ + { + "type": "object", + "name": "getOptions", + "optional": true, + "properties": { + "returnFormat": { "$ref": "ReturnFormat", "optional": true } + } + } + ] } ], "events": [ diff --git a/calendar/experiments/calendar/schema/calendar-provider.json b/calendar/experiments/calendar/schema/calendar-provider.json index 36abeab..ce3a4fd 100644 --- a/calendar/experiments/calendar/schema/calendar-provider.json +++ b/calendar/experiments/calendar/schema/calendar-provider.json @@ -38,10 +38,20 @@ } } }, { - "id": "ItemOptions", + "id": "ItemOperationOptions", "type": "object", "description": "Options for the create/modify/delete event handlers", "properties": { + "invitation": { + "type": "boolean", + "description": "If true, the item operation is from an invitation", + "optional": true + }, + "offline": { + "type": "boolean", + "description": "If true, an offline operation is being replayed", + "optional": true + }, "force": { "type": "boolean", "description": "If true, instruct the provider to force overwrite changes (i.e. after a conflict)", @@ -49,6 +59,23 @@ } } }], + "functions": [ + { + "name": "setAdvanceAction", + "async": true, + "type": "function", + "parameters": [ + { + "type": "object", + "properties": { + "forward": { "type": "string" }, + "back": { "type": "string", "optional": true }, + "label": { "type": "string" } + } + } + ] + } + ], "events": [ { "name": "onItemCreated", @@ -56,7 +83,7 @@ "parameters": [ { "name": "calendar", "$ref": "calendar.calendars.Calendar" }, { "name": "item", "$ref": "calendar.items.CalendarItem" }, - { "name": "options", "$ref": "calendar.provider.ItemOptions" } + { "name": "options", "$ref": "calendar.provider.ItemOperationOptions" } ], "extraParameters": [ { @@ -82,7 +109,7 @@ { "name": "calendar", "$ref": "calendar.calendars.Calendar" }, { "name": "item", "$ref": "calendar.items.CalendarItem" }, { "name": "oldItem", "$ref": "calendar.items.CalendarItem" }, - { "name": "options", "$ref": "calendar.provider.ItemOptions" } + { "name": "options", "$ref": "calendar.provider.ItemOperationOptions" } ], "extraParameters": [ { @@ -107,7 +134,7 @@ "parameters": [ { "name": "calendar", "$ref": "calendar.calendars.Calendar" }, { "name": "item", "$ref": "calendar.items.CalendarItem" }, - { "name": "options", "$ref": "calendar.provider.ItemOptions" } + { "name": "options", "$ref": "calendar.provider.ItemOperationOptions" } ], "extraParameters": [ { @@ -177,6 +204,13 @@ { "name": "savePassword", "type": "boolean" }, { "name": "extraProperties", "type": "object" } ] + }, + { + "name": "onAdvanceNewCalendar", + "type": "function", + "parameters": [ + { "name": "id", "type": "string" } + ] } ] } diff --git a/calendar/experiments/calendar/schema/calendar-timezones.json b/calendar/experiments/calendar/schema/calendar-timezones.json new file mode 100644 index 0000000..fa496fe --- /dev/null +++ b/calendar/experiments/calendar/schema/calendar-timezones.json @@ -0,0 +1,50 @@ + +[ + { + "namespace": "calendar.timezones", + "properties": { + "currentZone": { + "description": "The current timezone id", + "type": "string" + }, + "timezoneIds": { + "description": "The current timezone id", + "type": "array", + "items": { "type": "string" } + } + }, + "functions": [ + { + "name": "getDefinition", + "type": "function", + "description": "Retrieve the vtimezone definition of a timezone with the specified id", + "parameters": [ + { + "type": "string", + "name": "tzid", + "description": "The timezone id to retrieve defintiion for" + }, + { + "$ref": "calendar.items.CalendarItemFormats", + "name":"returnFormat", + "optional": true, + "default": "ical", + "description": "The return format of the definition" + } + ], + "returns": { + "type": "string" + } + } + ], + "events": [ + { + "name": "onUpdated", + "type": "function", + "parameters": [ + { "name": "tzid", "type": "string" } + ] + } + ] + } +] diff --git a/calendar/experiments/calendar/schema/calendarItemDetails.json b/calendar/experiments/calendar/schema/calendarItemDetails.json index f126525..b712067 100644 --- a/calendar/experiments/calendar/schema/calendarItemDetails.json +++ b/calendar/experiments/calendar/schema/calendarItemDetails.json @@ -39,6 +39,17 @@ "optional": true, "description": "Enable browser styles. See the `MDN documentation on browser styles `__ for more information.", "default": false + }, + "allowed_areas": { + "optional": true, + "default": "secondary", + "choices": [ + { "$ref": "CalendarItemDetailsArea" }, + { + "type": "array", + "items": { "$ref": "CalendarItemDetailsArea" } + } + ] } }, "optional": true @@ -49,9 +60,15 @@ }, { "namespace": "calendarItemDetails", - "description": "TODO", "permissions": ["manifest:calendar_item_details"], - "types": [], + "types": [ + { + "id": "CalendarItemDetailsArea", + "description": "Describes the area(s) where the item details should be displayed", + "type": "string", + "enum": ["secondary", "inline", "summary"] + } + ], "functions": [], "events": [] } diff --git a/calendar/manifest.json b/calendar/manifest.json index d1c458e..c54188e 100644 --- a/calendar/manifest.json +++ b/calendar/manifest.json @@ -86,6 +86,23 @@ ] } }, + "calendar_timezones": { + "schema": "experiments/calendar/schema/calendar-timezones.json", + "parent": { + "scopes": ["addon_parent"], + "script": "experiments/calendar/parent/ext-calendar-timezones.js", + "paths": [ + ["calendar", "timezones"] + ] + }, + "child": { + "scopes": ["addon_child"], + "script": "experiments/calendar/child/ext-calendar-timezones.js", + "paths": [ + ["calendar", "timezones"] + ] + } + }, "calendarItemAction": { "schema": "experiments/calendar/schema/calendarItemAction.json", "parent": {