diff options
Diffstat (limited to '')
7 files changed, 7994 insertions, 0 deletions
diff --git a/comm/calendar/base/content/item-editing/calendar-item-editing.js b/comm/calendar/base/content/item-editing/calendar-item-editing.js new file mode 100644 index 0000000000..a280e62f48 --- /dev/null +++ b/comm/calendar/base/content/item-editing/calendar-item-editing.js @@ -0,0 +1,849 @@ +/* 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/. */ + +/* import-globals-from ../calendar-management.js */ +/* import-globals-from ../calendar-views-utils.js */ + +/* globals goUpdateCommand */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); +var { CalTransactionManager } = ChromeUtils.import("resource:///modules/CalTransactionManager.jsm"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalAddTransaction: "resource:///modules/CalTransactionManager.jsm", + CalDeleteTransaction: "resource:///modules/CalTransactionManager.jsm", + CalEvent: "resource:///modules/CalEvent.jsm", + CalModifyTransaction: "resource:///modules/CalTransactionManager.jsm", + CalTodo: "resource:///modules/CalTodo.jsm", +}); + +/* exported modifyEventWithDialog, undo, redo, setContextPartstat */ + +/** + * The global calendar transaction manager. + * + * @type {CalTransactionManager} + */ +var gCalTransactionMgr = CalTransactionManager.getInstance(); + +/** + * If a batch transaction is active, it is stored here. + * + * @type {CalBatchTransaction?} + */ +var gCalBatchTransaction = null; + +/** + * Sets the default values for new items, taking values from either the passed + * parameters or the preferences. + * + * @param {calIItemBase} aItem - The item to set up. + * @param {?calICalendar} aCalendar - The calendar to apply. + * @param {?calIDateTime} aStartDate - The start date to set. + * @param {?calIDateTime} aEndDate - The end date/due date to set. + * @param {?calIDateTime} aInitialDate - The reference date for the date pickers. + * @param {boolean} [aForceAllday=false] - Force the event/task to be an all-day item. + * @param {calIAttendee[]} aAttendees - Attendees to add, if `aItem` is an event. + */ +function setDefaultItemValues( + aItem, + aCalendar = null, + aStartDate = null, + aEndDate = null, + aInitialDate = null, + aForceAllday = false, + aAttendees = [] +) { + function endOfDay(aDate) { + let eod = aDate ? aDate.clone() : cal.dtz.now(); + eod.hour = Services.prefs.getIntPref("calendar.view.dayendhour", 19); + eod.minute = 0; + eod.second = 0; + return eod; + } + function startOfDay(aDate) { + let sod = aDate ? aDate.clone() : cal.dtz.now(); + sod.hour = Services.prefs.getIntPref("calendar.view.daystarthour", 8); + sod.minute = 0; + sod.second = 0; + return sod; + } + + let initialDate = aInitialDate ? aInitialDate.clone() : cal.dtz.now(); + initialDate.isDate = true; + + if (aItem.isEvent()) { + if (aStartDate) { + aItem.startDate = aStartDate.clone(); + if (aStartDate.isDate && !aForceAllday) { + // This is a special case where the date is specified, but the + // time is not. To take care, we setup up the time to our + // default event start time. + aItem.startDate = cal.dtz.getDefaultStartDate(aItem.startDate); + } else if (aForceAllday) { + // If the event should be forced to be allday, then don't set up + // any default hours and directly make it allday. + aItem.startDate.isDate = true; + aItem.startDate.timezone = cal.dtz.floating; + } + } else { + // If no start date was passed, then default to the next full hour + // of today, but with the date of the selected day + aItem.startDate = cal.dtz.getDefaultStartDate(initialDate); + } + + if (aEndDate) { + aItem.endDate = aEndDate.clone(); + if (aForceAllday) { + // XXX it is currently not specified, how callers that force all + // day should pass the end date. Right now, they should make + // sure that the end date is 00:00:00 of the day after. + aItem.endDate.isDate = true; + aItem.endDate.timezone = cal.dtz.floating; + } + } else { + aItem.endDate = aItem.startDate.clone(); + if (aForceAllday) { + // All day events need to go to the beginning of the next day. + aItem.endDate.day++; + } else { + // If the event is not all day, then add the default event + // length. + aItem.endDate.minute += Services.prefs.getIntPref("calendar.event.defaultlength", 60); + } + } + + // Free/busy status is only valid for events, must not be set for tasks. + aItem.setProperty("TRANSP", cal.item.getEventDefaultTransparency(aForceAllday)); + + for (let attendee of aAttendees) { + aItem.addAttendee(attendee); + } + } else if (aItem.isTodo()) { + let now = cal.dtz.now(); + let initDate = initialDate ? initialDate.clone() : now; + initDate.isDate = false; + initDate.hour = now.hour; + initDate.minute = now.minute; + initDate.second = now.second; + + if (aStartDate) { + aItem.entryDate = aStartDate.clone(); + } else { + let defaultStart = Services.prefs.getStringPref("calendar.task.defaultstart", "none"); + if ( + Services.prefs.getIntPref("calendar.alarms.onfortodos", 0) == 1 && + defaultStart == "none" + ) { + // start date is required if we want to set an alarm + defaultStart = "offsetcurrent"; + } + + let units = Services.prefs.getStringPref("calendar.task.defaultstartoffsetunits", "minutes"); + if (!["days", "hours", "minutes"].includes(units)) { + units = "minutes"; + } + let startOffset = cal.createDuration(); + startOffset[units] = Services.prefs.getIntPref("calendar.task.defaultstartoffset", 0); + let start; + + switch (defaultStart) { + case "none": + break; + case "startofday": + start = startOfDay(initDate); + break; + case "tomorrow": + start = startOfDay(initDate); + start.day++; + break; + case "nextweek": + start = startOfDay(initDate); + start.day += 7; + break; + case "offsetcurrent": + start = initDate.clone(); + start.addDuration(startOffset); + break; + case "offsetnexthour": + start = initDate.clone(); + start.second = 0; + start.minute = 0; + start.hour++; + start.addDuration(startOffset); + break; + } + + if (start) { + aItem.entryDate = start; + } + } + + if (aEndDate) { + aItem.dueDate = aEndDate.clone(); + } else { + let defaultDue = Services.prefs.getStringPref("calendar.task.defaultdue", "none"); + + let units = Services.prefs.getStringPref("calendar.task.defaultdueoffsetunits", "minutes"); + if (!["days", "hours", "minutes"].includes(units)) { + units = "minutes"; + } + let dueOffset = cal.createDuration(); + dueOffset[units] = Services.prefs.getIntPref("calendar.task.defaultdueoffset", 0); + + let start = aItem.entryDate ? aItem.entryDate.clone() : initDate.clone(); + let due; + + switch (defaultDue) { + case "none": + break; + case "endofday": + due = endOfDay(start); + // go to tomorrow if we're past the end of today + if (start.compare(due) > 0) { + due.day++; + } + break; + case "tomorrow": + due = endOfDay(start); + due.day++; + break; + case "nextweek": + due = endOfDay(start); + due.day += 7; + break; + case "offsetcurrent": + due = start.clone(); + due.addDuration(dueOffset); + break; + case "offsetnexthour": + due = start.clone(); + due.second = 0; + due.minute = 0; + due.hour++; + due.addDuration(dueOffset); + break; + } + + if (aItem.entryDate && due && aItem.entryDate.compare(due) > 0) { + // due can't be earlier than start date. + due = aItem.entryDate; + } + + if (due) { + aItem.dueDate = due; + } + } + } + + // Calendar + aItem.calendar = aCalendar || getSelectedCalendar(); + + // Alarms + cal.alarms.setDefaultValues(aItem); +} + +/** + * Creates an event with the calendar event dialog. + * + * @param {?calICalendar} calendar - The calendar to create the event in + * @param {?calIDateTime} startDate - The event's start date. + * @param {?calIDateTime} endDate - The event's end date. + * @param {?string} summary - The event's title. + * @param {?calIEvent} event - A template event to show in the dialog + * @param {?boolean} forceAllDay - Make sure the event shown in the dialog is an all-day event. + * @param {?calIAttendee} attendees - Attendees to add to the event. + */ +function createEventWithDialog( + calendar, + startDate, + endDate, + summary, + event, + forceAllDay, + attendees +) { + let onNewEvent = function (item, opcalendar, originalItem, listener, extresponse = null) { + if (item.id) { + // If the item already has an id, then this is the result of + // saving the item without closing, and then saving again. + doTransaction("modify", item, opcalendar, originalItem, listener, extresponse); + } else { + // Otherwise, this is an addition + doTransaction("add", item, opcalendar, null, listener, extresponse); + } + }; + + if (event) { + if (!event.isMutable) { + event = event.clone(); + } + // If the event should be created from a template, then make sure to + // remove the id so that the item obtains a new id when doing the + // transaction + event.id = null; + + if (forceAllDay) { + event.startDate.isDate = true; + event.endDate.isDate = true; + if (event.startDate.compare(event.endDate) == 0) { + // For a one day all day event, the end date must be 00:00:00 of + // the next day. + event.endDate.day++; + } + } + + if (!event.calendar) { + event.calendar = calendar || getSelectedCalendar(); + } + } else { + event = new CalEvent(); + + let refDate = currentView().selectedDay?.clone(); + setDefaultItemValues(event, calendar, startDate, endDate, refDate, forceAllDay, attendees); + if (summary) { + event.title = summary; + } + } + openEventDialog(event, event.calendar, "new", onNewEvent); +} + +/** + * Creates a task with the calendar event dialog. + * + * @param calendar (optional) The calendar to create the task in + * @param dueDate (optional) The task's due date. + * @param summary (optional) The task's title. + * @param todo (optional) A template task to show in the dialog. + * @param initialDate (optional) The initial date for new task datepickers + */ +function createTodoWithDialog(calendar, dueDate, summary, todo, initialDate) { + let onNewItem = function (item, opcalendar, originalItem, listener, extresponse = null) { + if (item.id) { + // If the item already has an id, then this is the result of + // saving the item without closing, and then saving again. + doTransaction("modify", item, opcalendar, originalItem, listener, extresponse); + } else { + // Otherwise, this is an addition + doTransaction("add", item, opcalendar, null, listener, extresponse); + } + }; + + if (todo) { + // If the todo should be created from a template, then make sure to + // remove the id so that the item obtains a new id when doing the + // transaction + if (todo.id) { + todo = todo.clone(); + todo.id = null; + } + + if (!todo.calendar) { + todo.calendar = calendar || getSelectedCalendar(); + } + } else { + todo = new CalTodo(); + setDefaultItemValues(todo, calendar, null, dueDate, initialDate); + + if (summary) { + todo.title = summary; + } + } + + openEventDialog(todo, calendar, "new", onNewItem, initialDate); +} + +/** + * Opens the passed event item for viewing. This enables the modify callback in + * openEventDialog so invitation responses can be edited. + * + * @param {calIItemBase} item - The calendar item to view. + */ +function openEventDialogForViewing(item) { + function onDialogComplete(newItem, calendar, originalItem, listener, extresponse) { + doTransaction("modify", newItem, calendar, originalItem, listener, extresponse); + } + openEventDialog(item, item.calendar, "view", onDialogComplete); +} + +/** + * Modifies the passed event in the event dialog. + * + * @param aItem The item to modify. + * @param aPromptOccurrence If the user should be prompted to select if the + * parent item or occurrence should be modified. + * @param initialDate (optional) The initial date for new task datepickers + * @param aCounterProposal (optional) An object representing the counterproposal + * { + * {JsObject} result: { + * type: {String} "OK"|"OUTDATED"|"NOTLATESTUPDATE"|"ERROR"|"NODIFF" + * descr: {String} a technical description of the problem if type is ERROR or NODIFF, + * otherwise an empty string + * }, + * (empty if result.type = "ERROR"|"NODIFF"){Array} differences: [{ + * property: {String} a property that is subject to the proposal + * proposed: {String} the proposed value + * original: {String} the original value + * }] + * } + */ +function modifyEventWithDialog(aItem, aPromptOccurrence, initialDate = null, aCounterProposal) { + let dlg = cal.item.findWindow(aItem); + if (dlg) { + dlg.focus(); + return; + } + + let onModifyItem = function (item, calendar, originalItem, listener, extresponse = null) { + doTransaction("modify", item, calendar, originalItem, listener, extresponse); + }; + + let item = aItem; + let response; + if (aPromptOccurrence !== false) { + [item, , response] = promptOccurrenceModification(aItem, true, "edit"); + } + + if (item && (response || response === undefined)) { + openEventDialog(item, item.calendar, "modify", onModifyItem, initialDate, aCounterProposal); + } +} + +/** + * @callback onDialogComplete + * + * @param {calIItemBase} newItem + * @param {calICalendar} calendar + * @param {calIItemBase} originalItem + * @param {?calIOperationListener} listener + * @param {?object} extresponse + */ + +/** + * Opens the event dialog with the given item (task OR event). + * + * @param {calIItemBase} calendarItem - The item to open the dialog with. + * @param {calICalendar} calendar - The calendar to open the dialog with. + * @param {string} mode - The operation the dialog should do + * ("new", "view", "modify"). + * @param {onDialogComplete} callback - The callback to call when the dialog + * has completed. + * @param {?calIDateTime} initialDate - The initial date for new task + * datepickers. + * @param {?object} counterProposal - An object representing the + * counterproposal - see description + * for modifyEventWithDialog(). + */ +function openEventDialog( + calendarItem, + calendar, + mode, + callback, + initialDate = null, + counterProposal +) { + let dlg = cal.item.findWindow(calendarItem); + if (dlg) { + dlg.focus(); + return; + } + + // Set up some defaults + mode = mode || "new"; + calendar = calendar || getSelectedCalendar(); + let calendars = cal.manager.getCalendars(); + calendars = calendars.filter(cal.acl.isCalendarWritable); + + let isItemSupported; + if (calendarItem.isTodo()) { + isItemSupported = function (aCalendar) { + return aCalendar.getProperty("capabilities.tasks.supported") !== false; + }; + } else if (calendarItem.isEvent()) { + isItemSupported = function (aCalendar) { + return aCalendar.getProperty("capabilities.events.supported") !== false; + }; + } + + // Filter out calendars that don't support the given calendar item + calendars = calendars.filter(isItemSupported); + + // Filter out calendar/items that we cannot write to/modify + if (mode == "new") { + calendars = calendars.filter(cal.acl.userCanAddItemsToCalendar); + } else if (mode == "modify") { + calendars = calendars.filter(aCalendar => { + /* If the calendar is the item calendar, we check that the item + * can be modified. If the calendar is NOT the item calendar, we + * check that the user can remove items from that calendar and + * add items to the current one. + */ + let isSameCalendar = calendarItem.calendar == aCalendar; + let canModify = cal.acl.userCanModifyItem(calendarItem); + let canMoveItems = + cal.acl.userCanDeleteItemsFromCalendar(calendarItem.calendar) && + cal.acl.userCanAddItemsToCalendar(aCalendar); + + return isSameCalendar ? canModify : canMoveItems; + }); + } + + if ( + mode == "new" && + (!cal.acl.isCalendarWritable(calendar) || + !cal.acl.userCanAddItemsToCalendar(calendar) || + !isItemSupported(calendar)) + ) { + if (calendars.length < 1) { + // There are no writable calendars or no calendar supports the given + // item. Don't show the dialog. + return; + } + // Pick the first calendar that supports the item and is writable + calendar = calendars[0]; + if (calendarItem) { + // XXX The dialog currently uses the items calendar as a first + // choice. Since we are shortly before a release to keep + // regression risk low, explicitly set the item's calendar here. + calendarItem.calendar = calendars[0]; + } + } + + // Setup the window arguments + let args = {}; + args.calendarEvent = calendarItem; + args.calendar = calendar; + args.mode = mode; + args.onOk = callback; + args.initialStartDateValue = initialDate || cal.dtz.getDefaultStartDate(); + args.counterProposal = counterProposal; + args.inTab = Services.prefs.getBoolPref("calendar.item.editInTab", false); + // this will be called if file->new has been selected from within the dialog + args.onNewEvent = function (opcalendar) { + createEventWithDialog(opcalendar, null, null); + }; + args.onNewTodo = function (opcalendar) { + createTodoWithDialog(opcalendar); + }; + + // the dialog will reset this to auto when it is done loading. + window.setCursor("wait"); + + // Ask the provider if this item is an invitation. If this is the case, + // we'll open the summary dialog since the user is not allowed to change + // the details of the item. + let isInvitation = + calendar.supportsScheduling && calendar.getSchedulingSupport().isInvitation(calendarItem); + + // open the dialog modeless + let url; + let isEditable = mode == "modify" && !isInvitation && cal.acl.userCanModifyItem(calendarItem); + + if (cal.acl.isCalendarWritable(calendar) && (mode == "new" || isEditable)) { + // Currently the read-only summary dialog is never opened in a tab. + if (args.inTab) { + url = "chrome://calendar/content/calendar-item-iframe.xhtml"; + } else { + url = "chrome://calendar/content/calendar-event-dialog.xhtml"; + } + } else { + url = "chrome://calendar/content/calendar-summary-dialog.xhtml"; + args.inTab = false; + args.isInvitation = isInvitation; + } + + if (args.inTab) { + args.url = url; + let tabmail = document.getElementById("tabmail"); + let tabtype = args.calendarEvent.isEvent() ? "calendarEvent" : "calendarTask"; + tabmail.openTab(tabtype, args); + } else { + // open in a window + openDialog(url, "_blank", "chrome,titlebar,toolbar,resizable", args); + } +} + +/** + * Prompts the user how the passed item should be modified. If the item is an + * exception or already a parent item, the item is returned without prompting. + * If "all occurrences" is specified, the parent item is returned. If "this + * occurrence only" is specified, then aItem is returned. If "this and following + * occurrences" is selected, aItem's parentItem is modified so that the + * recurrence rules end (UNTIL) just before the given occurrence. If + * aNeedsFuture is specified, a new item is made from the part that was stripped + * off the passed item. + * + * EXDATEs and RDATEs that do not fit into the items recurrence are removed. If + * the modified item or the future item only consist of a single occurrence, + * they are changed to be single items. + * + * @param aItem The item or array of items to check. + * @param aNeedsFuture If true, the future item is parsed. + * This parameter can for example be + * false if a deletion is being made. + * @param aAction Either "edit" or "delete". Sets up + * the labels in the occurrence prompt + * @returns [modifiedItem, futureItem, promptResponse] + * modifiedItem is a single item or array + * of items depending on the past aItem + * + * If "this and all following" was chosen, + * an array containing the item *until* + * the given occurrence (modifiedItem), + * and the item *after* the given + * occurrence (futureItem). + * + * If any other option was chosen, + * futureItem is null and the + * modifiedItem is either the parent item + * or the passed occurrence, or null if + * the dialog was canceled. + * + * The promptResponse parameter gives the + * response of the dialog as a constant. + */ +function promptOccurrenceModification(aItem, aNeedsFuture, aAction) { + const CANCEL = 0; + const MODIFY_OCCURRENCE = 1; + const MODIFY_FOLLOWING = 2; + const MODIFY_PARENT = 3; + + let futureItems = false; + let pastItems = []; + let returnItem = null; + let type = CANCEL; + let items = Array.isArray(aItem) ? aItem : [aItem]; + + // Check if this actually is an instance of a recurring event + if (items.every(item => item == item.parentItem)) { + type = MODIFY_PARENT; + } else if (aItem && items.length) { + // Prompt the user. Setting modal blocks the dialog until it is closed. We + // use rv to pass our return value. + let rv = { value: CANCEL, items, action: aAction }; + window.openDialog( + "chrome://calendar/content/calendar-occurrence-prompt.xhtml", + "PromptOccurrenceModification", + "centerscreen,chrome,modal,titlebar", + rv + ); + type = rv.value; + } + + switch (type) { + case MODIFY_PARENT: + pastItems = items.map(item => item.parentItem); + break; + case MODIFY_FOLLOWING: + // TODO tbd in a different bug + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + case MODIFY_OCCURRENCE: + pastItems = items; + break; + case CANCEL: + // Since we have not set past or futureItem, the return below will + // take care. + break; + } + if (aItem) { + returnItem = Array.isArray(aItem) ? pastItems : pastItems[0]; + } + return [returnItem, futureItems, type]; +} + +// Undo/Redo code + +/** + * Create and commit a transaction with the given arguments to the transaction + * manager. Also updates the undo/redo menu. + * + * @param action The action to do. + * @param item The new item to add/modify/delete + * @param calendar The calendar to do the transaction on + * @param oldItem (optional) some actions require an old item + * @param observer (optional) the observer to call when complete. + * @param extResponse (optional) JS object with additional parameters for sending itip messages + * (see also description of checkAndSend in calItipUtils.jsm) + */ +async function doTransaction(action, item, calendar, oldItem, observer, extResponse = null) { + // This is usually a user-initiated transaction, so make sure the calendar + // this transaction is happening on is visible. + top.ensureCalendarVisible(calendar); + + let manager = gCalBatchTransaction || gCalTransactionMgr; + let trn; + switch (action) { + case "add": + trn = new CalAddTransaction(item, calendar, oldItem, extResponse); + break; + case "modify": + trn = new CalModifyTransaction(item, calendar, oldItem, extResponse); + break; + case "delete": + trn = new CalDeleteTransaction(item, calendar, oldItem, extResponse); + break; + default: + throw new Components.Exception( + `Invalid action specified "${action}"`, + Cr.NS_ERROR_ILLEGAL_VALUE + ); + } + + await manager.commit(trn); + + // If a batch transaction is active, do not update the menu as + // endBatchTransaction() will take care of that. + if (gCalBatchTransaction) { + return; + } + + observer?.onTransactionComplete(trn.item, trn.oldItem); + updateUndoRedoMenu(); +} + +/** + * Undo the last operation done through the transaction manager. + */ +function undo() { + if (canUndo()) { + gCalTransactionMgr.undo(); + updateUndoRedoMenu(); + } +} + +/** + * Redo the last undone operation in the transaction manager. + */ +function redo() { + if (canRedo()) { + gCalTransactionMgr.redo(); + updateUndoRedoMenu(); + } +} + +/** + * Start a batch transaction on the transaction manager. + */ +function startBatchTransaction() { + gCalBatchTransaction = gCalTransactionMgr.beginBatch(); +} + +/** + * End a previously started batch transaction. NOTE: be sure to call this in a + * try-catch-finally-block in case you have code that could fail between + * startBatchTransaction and this call. + */ +function endBatchTransaction() { + gCalBatchTransaction = null; + updateUndoRedoMenu(); +} + +/** + * Checks if the last operation can be undone (or if there is a last operation + * at all). + */ +function canUndo() { + return gCalTransactionMgr.canUndo(); +} + +/** + * Checks if the last undone operation can be redone. + */ +function canRedo() { + return gCalTransactionMgr.canRedo(); +} + +/** + * Update the undo and redo commands. + */ +function updateUndoRedoMenu() { + goUpdateCommand("cmd_undo"); + goUpdateCommand("cmd_redo"); +} + +/** + * Updates the partstat of the calendar owner for specified items triggered by a + * context menu operation + * + * For a documentation of the expected bahaviours for different use cases of + * dealing with context menu partstat actions, see also setupAttendanceMenu(...) + * in calendar-ui-utils.js + * + * @param {EventTarget} aTarget the target of the triggering event + * @param {Array} aItems an array of calEvent or calIToDo items + */ +function setContextPartstat(aTarget, aItems) { + /** + * Provides the participation representing the user for a provided item + * + * @param {calEvent|calTodo} aItem The calendar item to inspect + * @returns {?calIAttendee} An calIAttendee object or null if no + * participant was detected + */ + function getParticipant(aItem) { + let party = null; + if (cal.itip.isInvitation(aItem)) { + party = cal.itip.getInvitedAttendee(aItem); + } else if (aItem.organizer && aItem.getAttendees().length) { + let calOrgId = aItem.calendar.getProperty("organizerId"); + if (calOrgId.toLowerCase() == aItem.organizer.id.toLowerCase()) { + party = aItem.organizer; + } + } + return party; + } + + startBatchTransaction(); + try { + // TODO: make sure we overwrite the partstat of all occurrences in + // the selection, if the partstat of the respective master item is + // changed - see matrix in the doc block of setupAttendanceMenu(...) + // in calendar-ui-utils.js + + for (let oldItem of aItems) { + // Skip this item if its calendar is read only. + if (oldItem.calendar.readOnly) { + continue; + } + if (aTarget.getAttribute("scope") == "all-occurrences") { + oldItem = oldItem.parentItem; + } + let attendee = getParticipant(oldItem); + if (attendee) { + // skip this item if the partstat for the participant hasn't + // changed. otherwise we would always perform update operations + // for recurring events on both, the master and the occurrence + // item + let partStat = aTarget.getAttribute("respvalue"); + if (attendee.participationStatus == partStat) { + continue; + } + + let newItem = oldItem.clone(); + let newAttendee = attendee.clone(); + newAttendee.participationStatus = partStat; + if (newAttendee.isOrganizer) { + newItem.organizer = newAttendee; + } else { + newItem.removeAttendee(attendee); + newItem.addAttendee(newAttendee); + } + + let extResponse = null; + if (aTarget.hasAttribute("respmode")) { + let mode = aTarget.getAttribute("respmode"); + let itipMode = Ci.calIItipItem[mode]; + extResponse = { responseMode: itipMode }; + } + + doTransaction("modify", newItem, newItem.calendar, oldItem, null, extResponse); + } + } + } catch (e) { + cal.ERROR("Error setting partstat: " + e + "\r\n"); + } finally { + endBatchTransaction(); + } +} diff --git a/comm/calendar/base/content/item-editing/calendar-item-iframe.js b/comm/calendar/base/content/item-editing/calendar-item-iframe.js new file mode 100644 index 0000000000..bdabd21356 --- /dev/null +++ b/comm/calendar/base/content/item-editing/calendar-item-iframe.js @@ -0,0 +1,4302 @@ +/* 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/. */ + +/* exported onEventDialogUnload, changeUndiscloseCheckboxStatus, + * categoryPopupHiding, categoryTextboxKeypress, + * toggleKeepDuration, dateTimeControls2State, onUpdateAllDay, + * openNewEvent, openNewTask, openNewMessage, + * deleteAllAttachments, copyAttachment, attachmentLinkKeyPress, + * attachmentDblClick, attachmentClick, notifyUser, + * removeNotification, chooseRecentTimezone, showTimezonePopup, + * attendeeDblClick, setAttendeeContext, removeAttendee, + * removeAllAttendees, sendMailToUndecidedAttendees, checkUntilDate, + * applyValues + */ + +/* global MozElements */ + +/* import-globals-from ../../../../mail/components/compose/content/editor.js */ +/* import-globals-from ../../../../mail/components/compose/content/editorUtilities.js */ +/* import-globals-from ../calendar-ui-utils.js */ +/* import-globals-from ../dialogs/calendar-dialog-utils.js */ +/* globals gTimezonesEnabled */ // Set by calendar-item-panel.js. + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { + recurrenceRule2String, + splitRecurrenceRules, + checkRecurrenceRule, + countOccurrences, + hasUnsupported, +} = ChromeUtils.import("resource:///modules/calendar/calRecurrenceUtils.jsm"); +var { PluralForm } = ChromeUtils.importESModule("resource://gre/modules/PluralForm.sys.mjs"); +var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalAttachment: "resource:///modules/CalAttachment.jsm", + CalAttendee: "resource:///modules/CalAttendee.jsm", + CalRecurrenceInfo: "resource:///modules/CalRecurrenceInfo.jsm", +}); + +window.addEventListener("load", onLoad); +window.addEventListener("unload", onEventDialogUnload); + +var cloudFileAccounts; +try { + ({ cloudFileAccounts } = ChromeUtils.import("resource:///modules/cloudFileAccounts.jsm")); +} catch (e) { + // This will fail on Seamonkey, but that's ok since the pref for cloudfiles + // is false, which means the UI will not be shown +} + +// the following variables are constructed if the jsContext this file +// belongs to gets constructed. all those variables are meant to be accessed +// from within this file only. +var gStartTime = null; +var gEndTime = null; +var gItemDuration = null; +var gStartTimezone = null; +var gEndTimezone = null; +var gUntilDate = null; +var gIsReadOnly = false; +var gAttachMap = {}; +var gConfirmCancel = true; +var gLastRepeatSelection = 0; +var gIgnoreUpdate = false; +var gWarning = false; +var gPreviousCalendarId = null; +var gTabInfoObject; +var gLastAlarmSelection = 0; +var gConfig = { + priority: 0, + privacy: null, + status: "NONE", + showTimeAs: null, + percentComplete: 0, +}; +// The following variables are set by the load handler function of the +// parent context, so that they are already set before iframe content load: +// - gTimezoneEnabled + +XPCOMUtils.defineLazyGetter(this, "gEventNotification", () => { + return new MozElements.NotificationBox(element => { + document.getElementById("event-dialog-notifications").append(element); + }); +}); + +var eventDialogRequestObserver = { + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), + + observe(aSubject, aTopic, aData) { + if ( + aTopic == "http-on-modify-request" && + aSubject instanceof Ci.nsIChannel && + aSubject.loadInfo && + aSubject.loadInfo.loadingDocument && + aSubject.loadInfo.loadingDocument == + document.getElementById("item-description").contentDocument + ) { + aSubject.cancel(Cr.NS_ERROR_ABORT); + } + }, +}; + +var eventDialogQuitObserver = { + QueryInterface: ChromeUtils.generateQI(["nsIObserver"]), + + observe(aSubject, aTopic, aData) { + // Check whether or not we want to veto the quit request (unless another + // observer already did. + if ( + aTopic == "quit-application-requested" && + aSubject instanceof Ci.nsISupportsPRBool && + !aSubject.data + ) { + aSubject.data = !onCancel(); + } + }, +}; + +var eventDialogCalendarObserver = { + QueryInterface: ChromeUtils.generateQI(["calIObserver"]), + + target: null, + isObserving: false, + + onModifyItem(aNewItem, aOldItem) { + if ( + this.isObserving && + "calendarItem" in window && + window.calendarItem && + window.calendarItem.id == aOldItem.id + ) { + let doUpdate = true; + + // The item has been modified outside the dialog. We only need to + // prompt if there have been local changes also. + if (isItemChanged()) { + let promptTitle = cal.l10n.getCalString("modifyConflictPromptTitle"); + let promptMessage = cal.l10n.getCalString("modifyConflictPromptMessage"); + let promptButton1 = cal.l10n.getCalString("modifyConflictPromptButton1"); + let promptButton2 = cal.l10n.getCalString("modifyConflictPromptButton2"); + let flags = + Ci.nsIPromptService.BUTTON_TITLE_IS_STRING * Ci.nsIPromptService.BUTTON_POS_0 + + Ci.nsIPromptService.BUTTON_TITLE_IS_STRING * Ci.nsIPromptService.BUTTON_POS_1; + + let choice = Services.prompt.confirmEx( + window, + promptTitle, + promptMessage, + flags, + promptButton1, + promptButton2, + null, + null, + {} + ); + if (!choice) { + doUpdate = false; + } + } + + let item = aNewItem; + if (window.calendarItem.recurrenceId && aNewItem.recurrenceInfo) { + item = aNewItem.recurrenceInfo.getOccurrenceFor(window.calendarItem.recurrenceId) || item; + } + window.calendarItem = item; + + if (doUpdate) { + loadDialog(window.calendarItem); + } + } + }, + + onDeleteItem(aDeletedItem) { + if ( + this.isObserving && + "calendarItem" in window && + window.calendarItem && + window.calendarItem.id == aDeletedItem.id + ) { + cancelItem(); + } + }, + + onStartBatch() {}, + onEndBatch() {}, + onLoad() {}, + onAddItem() {}, + onError() {}, + onPropertyChanged() {}, + onPropertyDeleting() {}, + + observe(aCalendar) { + // use the new calendar if one was passed, otherwise use the last one + this.target = aCalendar || this.target; + if (this.target) { + this.cancel(); + this.target.addObserver(this); + this.isObserving = true; + } + }, + + cancel() { + if (this.isObserving && this.target) { + this.target.removeObserver(this); + this.isObserving = false; + } + }, +}; + +/** + * Checks if the given calendar supports notifying attendees. The item is needed + * since calendars may support notifications for only some types of items. + * + * @param {calICalendar} aCalendar - The calendar to check + * @param {calIItemBase} aItem - The item to check support for + */ +function canNotifyAttendees(aCalendar, aItem) { + try { + let calendar = aCalendar.QueryInterface(Ci.calISchedulingSupport); + return calendar.canNotify("REQUEST", aItem) && calendar.canNotify("CANCEL", aItem); + } catch (exc) { + return false; + } +} + +/** + * Sends an asynchronous message to the parent context that contains the + * iframe. Additional properties of aMessage are generally arguments + * that will be passed to the function named in aMessage.command. + * + * @param {object} aMessage - The message to pass to the parent context + * @param {string} aMessage.command - The name of a function to call + */ +function sendMessage(aMessage) { + parent.postMessage(aMessage, "*"); +} + +/** + * Receives asynchronous messages from the parent context that contains the iframe. + * + * @param {MessageEvent} aEvent - Contains the message being received + */ +function receiveMessage(aEvent) { + let validOrigin = gTabmail ? "chrome://messenger" : "chrome://calendar"; + if (aEvent.origin !== validOrigin) { + return; + } + switch (aEvent.data.command) { + case "editAttendees": + editAttendees(); + break; + case "attachURL": + attachURL(); + break; + case "onCommandDeleteItem": + onCommandDeleteItem(); + break; + case "onCommandSave": + onCommandSave(aEvent.data.isClosing); + break; + case "onAccept": + onAccept(); + break; + case "onCancel": + onCancel(aEvent.data.iframeId); + break; + case "openNewEvent": + openNewEvent(); + break; + case "openNewTask": + openNewTask(); + break; + case "editConfigState": { + Object.assign(gConfig, aEvent.data.argument); + updateConfigState(aEvent.data.argument); + break; + } + case "editToDoStatus": { + let textbox = document.getElementById("percent-complete-textbox"); + textbox.value = aEvent.data.value; + updateToDoStatus("percent-changed"); + break; + } + case "postponeTask": + postponeTask(aEvent.data.value); + break; + case "toggleTimezoneLinks": + gTimezonesEnabled = aEvent.data.checked; // eslint-disable-line + updateDateTime(); + break; + case "closingWindowWithTabs": { + let response = onCancel(aEvent.data.id, true); + sendMessage({ + command: "replyToClosingWindowWithTabs", + response, + }); + break; + } + case "attachFileByAccountKey": + attachFileByAccountKey(aEvent.data.accountKey); + break; + case "triggerUpdateSaveControls": + updateParentSaveControls(); + break; + } +} + +/** + * Sets up the event dialog from the window arguments, also setting up all + * dialog controls from the window's item. + */ +function onLoad() { + window.addEventListener("message", receiveMessage); + + // first of all retrieve the array of + // arguments this window has been called with. + let args = window.arguments[0]; + + intializeTabOrWindowVariables(); + + // Needed so we can call switchToTab for the prompt about saving + // unsaved changes, to show the tab that the prompt is for. + if (gInTab) { + gTabInfoObject = gTabmail.currentTabInfo; + } + + // the most important attribute we expect from the + // arguments is the item we'll edit in the dialog. + let item = args.calendarEvent; + + // set the iframe's top level id for event vs task + if (item.isTodo()) { + setDialogId(document.documentElement, "calendar-task-dialog-inner"); + } + + document.getElementById("item-title").placeholder = cal.l10n.getString( + "calendar-event-dialog", + item.isEvent() ? "newEvent" : "newTask" + ); + + window.onAcceptCallback = args.onOk; + window.mode = args.mode; + + // we store the item in the window to be able + // to access this from any location. please note + // that the item is either an occurrence [proxy] + // or the stand-alone item [single occurrence item]. + window.calendarItem = item; + // store the initial date value for datepickers in New Task dialog + window.initialStartDateValue = args.initialStartDateValue; + + window.attendeeTabLabel = document.getElementById("event-grid-tab-attendees").label; + window.attachmentTabLabel = document.getElementById("event-grid-tab-attachments").label; + + // Store the array of attendees on the window for later retrieval. Clone each + // existing attendee to prevent modifying objects referenced elsewhere. + const attendees = item.getAttendees() ?? []; + window.attendees = attendees.map(attendee => attendee.clone()); + + window.organizer = null; + if (item.organizer) { + window.organizer = item.organizer.clone(); + } else if (attendees.length > 0) { + // Previous versions of calendar may not have set the organizer correctly. + let organizerId = item.calendar.getProperty("organizerId"); + if (organizerId) { + let organizer = new CalAttendee(); + organizer.id = cal.email.removeMailTo(organizerId); + organizer.commonName = item.calendar.getProperty("organizerCN"); + organizer.isOrganizer = true; + window.organizer = organizer; + } + } + + // we store the recurrence info in the window so it + // can be accessed from any location. since the recurrence + // info is a property of the parent item we need to check + // whether or not this item is a proxy or a parent. + let parentItem = item; + if (parentItem.parentItem != parentItem) { + parentItem = parentItem.parentItem; + } + + window.recurrenceInfo = null; + if (parentItem.recurrenceInfo) { + window.recurrenceInfo = parentItem.recurrenceInfo.clone(); + } + + // Set initial values for datepickers in New Tasks dialog + if (item.isTodo()) { + let initialDatesValue = cal.dtz.dateTimeToJsDate(args.initialStartDateValue); + document.getElementById("completed-date-picker").value = initialDatesValue; + document.getElementById("todo-entrydate").value = initialDatesValue; + document.getElementById("todo-duedate").value = initialDatesValue; + } + loadDialog(window.calendarItem); + + if (args.counterProposal) { + window.counterProposal = args.counterProposal; + displayCounterProposal(); + } + + gMainWindow.setCursor("auto"); + + document.getElementById("item-title").select(); + + // This causes the app to ask if the window should be closed when the + // application is closed. + Services.obs.addObserver(eventDialogQuitObserver, "quit-application-requested"); + + // This stops the editor from loading remote HTTP(S) content. + Services.obs.addObserver(eventDialogRequestObserver, "http-on-modify-request"); + + // Normally, Enter closes a <dialog>. We want this to rather on Ctrl+Enter. + // Stopping event propagation doesn't seem to work, so just overwrite the + // function that does this. + if (!gInTab) { + document.documentElement._hitEnter = function () {}; + } + + // set up our calendar event observer + eventDialogCalendarObserver.observe(item.calendar); + + // Disable save and save close buttons and menuitems if the item + // title is empty. + updateTitle(); + + cal.view.colorTracker.registerWindow(window); + + top.document.commandDispatcher.addCommandUpdater( + document.getElementById("styleMenuItems"), + "style", + "*" + ); + EditorSharedStartup(); + + // We want to keep HTML output as simple as possible, so don't try to use divs + // as separators. As a bonus, this avoids a bug in the editor which sometimes + // causes the user to have to hit enter twice for it to take effect. + const editor = GetCurrentEditor(); + editor.document.execCommand("defaultparagraphseparator", false, "br"); + + onLoad.hasLoaded = true; +} +// Set a variable to allow or prevent actions before the dialog is done loading. +onLoad.hasLoaded = false; + +function onEventDialogUnload() { + Services.obs.removeObserver(eventDialogRequestObserver, "http-on-modify-request"); + Services.obs.removeObserver(eventDialogQuitObserver, "quit-application-requested"); + eventDialogCalendarObserver.cancel(); +} + +/** + * Handler function to be called when the accept button is pressed. + * + * @returns Returns true if the window should be closed + */ +function onAccept() { + dispose(); + onCommandSave(true); + if (!gWarning) { + sendMessage({ command: "closeWindowOrTab" }); + } + return !gWarning; +} + +/** + * Asks the user if the item should be saved and does so if requested. If the + * user cancels, the window should stay open. + * + * XXX Could possibly be consolidated into onCancel() + * + * @returns Returns true if the window should be closed. + */ +function onCommandCancel() { + // Allow closing if the item has not changed and no warning dialog has to be showed. + if (!isItemChanged() && !gWarning) { + return true; + } + + if (gInTab && gTabInfoObject) { + // Switch to the tab that the prompt refers to. + gTabmail.switchToTab(gTabInfoObject); + } + + let promptTitle = cal.l10n.getCalString( + window.calendarItem.isEvent() ? "askSaveTitleEvent" : "askSaveTitleTask" + ); + let promptMessage = cal.l10n.getCalString( + window.calendarItem.isEvent() ? "askSaveMessageEvent" : "askSaveMessageTask" + ); + + let flags = + Ci.nsIPromptService.BUTTON_TITLE_SAVE * Ci.nsIPromptService.BUTTON_POS_0 + + Ci.nsIPromptService.BUTTON_TITLE_CANCEL * Ci.nsIPromptService.BUTTON_POS_1 + + Ci.nsIPromptService.BUTTON_TITLE_DONT_SAVE * Ci.nsIPromptService.BUTTON_POS_2; + + let choice = Services.prompt.confirmEx( + null, + promptTitle, + promptMessage, + flags, + null, + null, + null, + null, + {} + ); + switch (choice) { + case 0: // Save + let itemTitle = document.getElementById("item-title"); + if (!itemTitle.value) { + itemTitle.value = cal.l10n.getCalString("eventUntitled"); + } + onCommandSave(true); + return true; + case 2: // Don't save + // Don't show any warning dialog when closing without saving. + gWarning = false; + return true; + default: + // Cancel + return false; + } +} + +/** + * Handler function to be called when the cancel button is pressed. + * aPreventClose is true when closing the main window but leaving the tab open. + * + * @param {string} aIframeId (optional) iframe id of the tab to be closed + * @param {boolean} aPreventClose (optional) True means don't close, just ask about saving + * @returns {boolean} True if the tab or window should be closed + */ +function onCancel(aIframeId, aPreventClose) { + // The datepickers need to remove the focus in order to trigger the + // validation of the values just edited, with the keyboard, but not yet + // confirmed (i.e. not followed by a click, a tab or enter keys pressure). + document.documentElement.focus(); + + if (!gConfirmCancel || (gConfirmCancel && onCommandCancel())) { + dispose(); + // Don't allow closing the dialog when the user inputs a wrong + // date then closes the dialog and answers with "Save" in + // the "Save Event" dialog. Don't allow closing the dialog if + // the main window is being closed but the tabs in it are not. + + if (!gWarning && !aPreventClose) { + sendMessage({ command: "closeWindowOrTab", iframeId: aIframeId }); + } + return !gWarning; + } + return false; +} + +/** + * Cancels (closes) either the window or the tab, for example when the + * item is being deleted. + */ +function cancelItem() { + gConfirmCancel = false; + if (gInTab) { + onCancel(); + } else { + sendMessage({ command: "cancelDialog" }); + } +} + +/** + * Get the currently selected calendar from the menulist of calendars. + * + * @returns The currently selected calendar. + */ +function getCurrentCalendar() { + return document.getElementById("item-calendar").selectedItem.calendar; +} + +/** + * Sets up all dialog controls from the information of the passed item. + * + * @param aItem The item to parse information out of. + */ +function loadDialog(aItem) { + loadDateTime(aItem); + + document.getElementById("item-title").value = aItem.title; + document.getElementById("item-location").value = aItem.getProperty("LOCATION"); + + // add calendars to the calendar menulist + let calendarList = document.getElementById("item-calendar"); + let indexToSelect = appendCalendarItems( + aItem, + calendarList, + aItem.calendar || window.arguments[0].calendar + ); + if (indexToSelect > -1) { + calendarList.selectedIndex = indexToSelect; + } + + // Categories + loadCategories(aItem); + + // Attachment + loadCloudProviders(); + + let hasAttachments = capSupported("attachments"); + let attachments = aItem.getAttachments(); + if (hasAttachments && attachments && attachments.length > 0) { + for (let attachment of attachments) { + addAttachment(attachment); + } + } else { + updateAttachment(); + } + + // URL link + let itemUrl = window.calendarItem.getProperty("URL")?.trim() || ""; + let showLink = showOrHideItemURL(itemUrl); + updateItemURL(showLink, itemUrl); + + // Description + let editorElement = document.getElementById("item-description"); + let editor = editorElement.getHTMLEditor(editorElement.contentWindow); + + let link = editorElement.contentDocument.createElement("link"); + link.rel = "stylesheet"; + link.href = "chrome://messenger/skin/shared/editorContent.css"; + editorElement.contentDocument.head.appendChild(link); + + try { + let checker = editor.getInlineSpellChecker(true); + checker.enableRealTimeSpell = Services.prefs.getBoolPref("mail.spellcheck.inline", true); + } catch (ex) { + // No dictionaries. + } + + if (aItem.descriptionText) { + let docFragment = cal.view.textToHtmlDocumentFragment( + aItem.descriptionText, + editorElement.contentDocument, + aItem.descriptionHTML + ); + editor.flags = + editor.eEditorMailMask | editor.eEditorNoCSSMask | editor.eEditorAllowInteraction; + editor.enableUndo(false); + editor.forceCompositionEnd(); + editor.rootElement.replaceChildren(docFragment); + // This reinitialises the editor after we replaced its contents. + editor.insertText(""); + editor.enableUndo(true); + } + + editor.resetModificationCount(); + + if (aItem.isTodo()) { + // Task completed date + if (aItem.completedDate) { + updateToDoStatus(aItem.status, cal.dtz.dateTimeToJsDate(aItem.completedDate)); + } else { + updateToDoStatus(aItem.status); + } + + // Task percent complete + let percentCompleteInteger = 0; + let percentCompleteProperty = aItem.getProperty("PERCENT-COMPLETE"); + if (percentCompleteProperty != null) { + percentCompleteInteger = parseInt(percentCompleteProperty, 10); + } + if (percentCompleteInteger < 0) { + percentCompleteInteger = 0; + } else if (percentCompleteInteger > 100) { + percentCompleteInteger = 100; + } + gConfig.percentComplete = percentCompleteInteger; + document.getElementById("percent-complete-textbox").value = percentCompleteInteger; + } + + // When in a window, set Item-Menu label to Event or Task + if (!gInTab) { + let isEvent = aItem.isEvent(); + + let labelString = isEvent ? "itemMenuLabelEvent" : "itemMenuLabelTask"; + let label = cal.l10n.getString("calendar-event-dialog", labelString); + + let accessKeyString = isEvent ? "itemMenuAccesskeyEvent2" : "itemMenuAccesskeyTask2"; + let accessKey = cal.l10n.getString("calendar-event-dialog", accessKeyString); + sendMessage({ + command: "initializeItemMenu", + label, + accessKey, + }); + } + + // Repeat details + let [repeatType, untilDate] = getRepeatTypeAndUntilDate(aItem); + loadRepeat(repeatType, untilDate, aItem); + + // load reminders details + let alarmsMenu = document.querySelector(".item-alarm"); + window.gLastAlarmSelection = loadReminders(aItem.getAlarms(), alarmsMenu, getCurrentCalendar()); + + // Synchronize link-top-image with keep-duration-button status + let keepAttribute = document.getElementById("keepduration-button").getAttribute("keep") == "true"; + document.getElementById("link-image-top").setAttribute("keep", keepAttribute); + + updateDateTime(); + + updateCalendar(); + + let notifyCheckbox = document.getElementById("notify-attendees-checkbox"); + let undiscloseCheckbox = document.getElementById("undisclose-attendees-checkbox"); + let disallowcounterCheckbox = document.getElementById("disallow-counter-checkbox"); + if (canNotifyAttendees(aItem.calendar, aItem)) { + // visualize that the server will send out mail: + notifyCheckbox.checked = true; + // hide these controls as this a client only feature + undiscloseCheckbox.disabled = true; + } else { + let itemProp = aItem.getProperty("X-MOZ-SEND-INVITATIONS"); + notifyCheckbox.checked = + aItem.calendar.getProperty("imip.identity") && + (itemProp === null + ? Services.prefs.getBoolPref("calendar.itip.notify", true) + : itemProp == "TRUE"); + let undiscloseProp = aItem.getProperty("X-MOZ-SEND-INVITATIONS-UNDISCLOSED"); + undiscloseCheckbox.checked = + undiscloseProp === null + ? Services.prefs.getBoolPref("calendar.itip.separateInvitationPerAttendee") + : undiscloseProp == "TRUE"; + // disable checkbox, if notifyCheckbox is not checked + undiscloseCheckbox.disabled = !notifyCheckbox.checked; + } + // this may also be a server exposed calendar property from exchange servers - if so, this + // probably should overrule the client-side config option + let disallowCounterProp = aItem.getProperty("X-MICROSOFT-DISALLOW-COUNTER"); + disallowcounterCheckbox.checked = disallowCounterProp == "TRUE"; + // if we're in reschedule mode, it's pointless to enable the control + disallowcounterCheckbox.disabled = !!window.counterProposal; + + updateAttendeeInterface(); + updateRepeat(true); + updateReminder(true); + + // Status + if (aItem.isEvent()) { + gConfig.status = aItem.hasProperty("STATUS") ? aItem.getProperty("STATUS") : "NONE"; + if (gConfig.status == "NONE") { + sendMessage({ command: "showCmdStatusNone" }); + } + updateConfigState({ status: gConfig.status }); + } else { + let itemStatus = aItem.getProperty("STATUS"); + let todoStatus = document.getElementById("todo-status"); + todoStatus.value = itemStatus; + if (!todoStatus.selectedItem) { + // No selected item means there was no <menuitem> that matches the + // value given. Select the "NONE" item by default. + todoStatus.value = "NONE"; + } + } + + // Priority, Privacy, Transparency + gConfig.priority = parseInt(aItem.priority, 10); + gConfig.privacy = aItem.privacy; + gConfig.showTimeAs = aItem.getProperty("TRANSP"); + + // update in outer parent context + updateConfigState(gConfig); + + if (aItem.getAttendees().length && !aItem.descriptionText) { + let tabs = document.getElementById("event-grid-tabs"); + let attendeeTab = document.getElementById("event-grid-tab-attendees"); + tabs.selectedItem = attendeeTab; + } +} + +/** + * Enables/disables undiscloseCheckbox on (un)checking notifyCheckbox + */ +function changeUndiscloseCheckboxStatus() { + let notifyCheckbox = document.getElementById("notify-attendees-checkbox"); + let undiscloseCheckbox = document.getElementById("undisclose-attendees-checkbox"); + undiscloseCheckbox.disabled = !notifyCheckbox.checked; + updateParentSaveControls(); +} + +/** + * Loads the item's categories into the category panel + * + * @param aItem The item to load into the category panel + */ +function loadCategories(aItem) { + let itemCategories = aItem.getCategories(); + let categoryList = cal.category.fromPrefs(); + for (let cat of itemCategories) { + if (!categoryList.includes(cat)) { + categoryList.push(cat); + } + } + cal.l10n.sortArrayByLocaleCollator(categoryList); + + // Make sure the maximum number of categories is applied to the listbox + let calendar = getCurrentCalendar(); + let maxCount = calendar.getProperty("capabilities.categories.maxCount"); + + let categoryPopup = document.getElementById("item-categories-popup"); + if (maxCount == 1) { + let item = document.createXULElement("menuitem"); + item.setAttribute("class", "menuitem-iconic"); + item.setAttribute("label", cal.l10n.getCalString("None")); + item.setAttribute("type", "radio"); + if (itemCategories.length === 0) { + item.setAttribute("checked", "true"); + } + categoryPopup.appendChild(item); + } + for (let cat of categoryList) { + let item = document.createXULElement("menuitem"); + item.setAttribute("class", "menuitem-iconic calendar-category"); + item.setAttribute("label", cat); + item.setAttribute("value", cat); + item.setAttribute("type", maxCount === null || maxCount > 1 ? "checkbox" : "radio"); + if (itemCategories.includes(cat)) { + item.setAttribute("checked", "true"); + } + let cssSafeId = cal.view.formatStringForCSSRule(cat); + item.style.setProperty("--item-color", `var(--category-${cssSafeId}-color)`); + categoryPopup.appendChild(item); + } + + updateCategoryMenulist(); +} + +/** + * Updates the category menulist to show the correct label, depending on the + * selected categories in the category panel + */ +function updateCategoryMenulist() { + let categoryMenulist = document.getElementById("item-categories"); + let categoryPopup = document.getElementById("item-categories-popup"); + + // Make sure the maximum number of categories is applied to the listbox + let calendar = getCurrentCalendar(); + let maxCount = calendar.getProperty("capabilities.categories.maxCount"); + + // Hide the categories listbox and label in case categories are not + // supported + document.getElementById("event-grid-category-row").toggleAttribute("hidden", maxCount === 0); + + let label; + let categoryList = categoryPopup.querySelectorAll("menuitem.calendar-category[checked]"); + if (categoryList.length > 1) { + label = cal.l10n.getCalString("multipleCategories"); + } else if (categoryList.length == 1) { + label = categoryList[0].getAttribute("label"); + } else { + label = cal.l10n.getCalString("None"); + } + categoryMenulist.setAttribute("label", label); + + let labelBox = categoryMenulist.shadowRoot.querySelector("#label-box"); + let labelLabel = labelBox.querySelector("#label"); + for (let box of labelBox.querySelectorAll("box")) { + box.remove(); + } + for (let i = 0; i < categoryList.length; i++) { + let box = labelBox.insertBefore(document.createXULElement("box"), labelLabel); + // Normal CSS selectors like :first-child don't work on shadow DOM items, + // so we have to set up something they do work on. + let parts = ["color"]; + if (i == 0) { + parts.push("first"); + } + if (i == categoryList.length - 1) { + parts.push("last"); + } + box.setAttribute("part", parts.join(" ")); + box.style.setProperty("--item-color", categoryList[i].style.getPropertyValue("--item-color")); + } +} + +/** + * Updates the categories menulist label and decides if the popup should close + * + * @param aItem The popuphiding event + * @returns Whether the popup should close + */ +function categoryPopupHiding(event) { + updateCategoryMenulist(); + let calendar = getCurrentCalendar(); + let maxCount = calendar.getProperty("capabilities.categories.maxCount"); + if (maxCount === null || maxCount > 1) { + return event.target.localName != "menuitem"; + } + return true; +} + +/** + * Prompts for a new category name, then adds it to the list + */ +function categoryTextboxKeypress(event) { + let category = event.target.value; + let categoryPopup = document.getElementById("item-categories-popup"); + switch (event.key) { + case "Tab": + case "ArrowDown": + case "ArrowUp": { + event.target.blur(); + event.preventDefault(); + + let keyCode = event.key == "ArrowUp" ? KeyboardEvent.DOM_VK_UP : KeyboardEvent.DOM_VK_DOWN; + categoryPopup.dispatchEvent(new KeyboardEvent("keydown", { keyCode })); + categoryPopup.dispatchEvent(new KeyboardEvent("keyup", { keyCode })); + return; + } + case "Escape": + if (category) { + event.target.value = ""; + } else { + categoryPopup.hidePopup(); + } + event.preventDefault(); + return; + case "Enter": + category = category.trim(); + if (category != "") { + break; + } + return; + default: + return; + } + event.preventDefault(); + + let categoryList = categoryPopup.querySelectorAll("menuitem.calendar-category"); + let categories = Array.from(categoryList, cat => cat.getAttribute("value")); + + let newIndex = categories.indexOf(category); + if (newIndex > -1) { + categoryList[newIndex].setAttribute("checked", true); + } else { + const localeCollator = new Intl.Collator(); + let compare = localeCollator.compare; + newIndex = cal.data.binaryInsert(categories, category, compare, true); + + let calendar = getCurrentCalendar(); + let maxCount = calendar.getProperty("capabilities.categories.maxCount"); + + let item = document.createXULElement("menuitem"); + item.setAttribute("class", "menuitem-iconic calendar-category"); + item.setAttribute("label", category); + item.setAttribute("value", category); + item.setAttribute("type", maxCount === null || maxCount > 1 ? "checkbox" : "radio"); + item.setAttribute("checked", true); + categoryPopup.insertBefore(item, categoryList[newIndex]); + } + + event.target.value = ""; + // By pushing this to the end of the event loop, the other checked items in the list + // are cleared, where only one category is allowed. + setTimeout(updateCategoryMenulist, 0); +} + +/** + * Saves the selected categories into the passed item + * + * @param aItem The item to set the categories on + */ +function saveCategories(aItem) { + let categoryPopup = document.getElementById("item-categories-popup"); + let categoryList = Array.from( + categoryPopup.querySelectorAll("menuitem.calendar-category[checked]"), + cat => cat.getAttribute("label") + ); + aItem.setCategories(categoryList); +} + +/** + * Sets up all date related controls from the passed item + * + * @param item The item to parse information out of. + */ +function loadDateTime(item) { + let kDefaultTimezone = cal.dtz.defaultTimezone; + if (item.isEvent()) { + let startTime = item.startDate; + let endTime = item.endDate; + let duration = endTime.subtractDate(startTime); + + // Check if an all-day event has been passed in (to adapt endDate). + if (startTime.isDate) { + startTime = startTime.clone(); + endTime = endTime.clone(); + + endTime.day--; + duration.days--; + } + + // store the start/end-times as calIDateTime-objects + // converted to the default timezone. store the timezones + // separately. + gStartTimezone = startTime.timezone; + gEndTimezone = endTime.timezone; + gStartTime = startTime.getInTimezone(kDefaultTimezone); + gEndTime = endTime.getInTimezone(kDefaultTimezone); + gItemDuration = duration; + } + + if (item.isTodo()) { + let startTime = null; + let endTime = null; + let duration = null; + + let hasEntryDate = item.entryDate != null; + if (hasEntryDate) { + startTime = item.entryDate; + gStartTimezone = startTime.timezone; + startTime = startTime.getInTimezone(kDefaultTimezone); + } else { + gStartTimezone = kDefaultTimezone; + } + let hasDueDate = item.dueDate != null; + if (hasDueDate) { + endTime = item.dueDate; + gEndTimezone = endTime.timezone; + endTime = endTime.getInTimezone(kDefaultTimezone); + } else { + gEndTimezone = kDefaultTimezone; + } + if (hasEntryDate && hasDueDate) { + duration = endTime.subtractDate(startTime); + } + document.getElementById("cmd_attendees").setAttribute("disabled", true); + document.getElementById("keepduration-button").disabled = !(hasEntryDate && hasDueDate); + sendMessage({ + command: "updateConfigState", + argument: { attendeesCommand: false }, + }); + gStartTime = startTime; + gEndTime = endTime; + gItemDuration = duration; + } else { + sendMessage({ + command: "updateConfigState", + argument: { attendeesCommand: true }, + }); + } +} + +/** + * Toggles the "keep" attribute every time the keepduration-button is pressed. + */ +function toggleKeepDuration() { + let kdb = document.getElementById("keepduration-button"); + let keepAttribute = kdb.getAttribute("keep") == "true"; + // To make the "keep" attribute persistent, it mustn't be removed when in + // false state (bug 15232). + kdb.setAttribute("keep", keepAttribute ? "false" : "true"); + document.getElementById("link-image-top").setAttribute("keep", !keepAttribute); +} + +/** + * Handler function to be used when the Start time or End time of the event have + * changed. + * When changing the Start date, the End date changes automatically so the + * event/task's duration stays the same. Instead the End date is not linked + * to the Start date unless the the keepDurationButton has the "keep" attribute + * set to true. In this case modifying the End date changes the Start date in + * order to keep the same duration. + * + * @param aStartDatepicker If true the Start or Entry datepicker has changed, + * otherwise the End or Due datepicker has changed. + */ +function dateTimeControls2State(aStartDatepicker) { + if (gIgnoreUpdate) { + return; + } + let keepAttribute = document.getElementById("keepduration-button").getAttribute("keep") == "true"; + let allDay = document.getElementById("event-all-day").checked; + let startWidgetId; + let endWidgetId; + if (window.calendarItem.isEvent()) { + startWidgetId = "event-starttime"; + endWidgetId = "event-endtime"; + } else { + if (!document.getElementById("todo-has-entrydate").checked) { + gItemDuration = null; + } + if (!document.getElementById("todo-has-duedate").checked) { + gItemDuration = null; + } + startWidgetId = "todo-entrydate"; + endWidgetId = "todo-duedate"; + } + + let saveStartTime = gStartTime; + let saveEndTime = gEndTime; + let kDefaultTimezone = cal.dtz.defaultTimezone; + + if (gStartTime) { + // jsDate is always in OS timezone, thus we create a calIDateTime + // object from the jsDate representation then we convert the timezone + // in order to keep gStartTime in default timezone. + if (gTimezonesEnabled || allDay) { + gStartTime = cal.dtz.jsDateToDateTime( + document.getElementById(startWidgetId).value, + gStartTimezone + ); + gStartTime = gStartTime.getInTimezone(kDefaultTimezone); + } else { + gStartTime = cal.dtz.jsDateToDateTime( + document.getElementById(startWidgetId).value, + kDefaultTimezone + ); + } + gStartTime.isDate = allDay; + } + if (gEndTime) { + if (aStartDatepicker) { + // Change the End date in order to keep the duration. + gEndTime = gStartTime.clone(); + if (gItemDuration) { + gEndTime.addDuration(gItemDuration); + } + } else { + let timezone = gEndTimezone; + if (timezone.isUTC) { + if (gStartTime && !cal.data.compareObjects(gStartTimezone, gEndTimezone)) { + timezone = gStartTimezone; + } + } + if (gTimezonesEnabled || allDay) { + gEndTime = cal.dtz.jsDateToDateTime(document.getElementById(endWidgetId).value, timezone); + gEndTime = gEndTime.getInTimezone(kDefaultTimezone); + } else { + gEndTime = cal.dtz.jsDateToDateTime( + document.getElementById(endWidgetId).value, + kDefaultTimezone + ); + } + gEndTime.isDate = allDay; + if (keepAttribute && gItemDuration) { + // Keepduration-button links the the Start to the End date. We + // have to change the Start date in order to keep the duration. + let fduration = gItemDuration.clone(); + fduration.isNegative = true; + gStartTime = gEndTime.clone(); + gStartTime.addDuration(fduration); + } + } + } + + if (allDay) { + gStartTime.isDate = true; + gEndTime.isDate = true; + gItemDuration = gEndTime.subtractDate(gStartTime); + } + + // calculate the new duration of start/end-time. + // don't allow for negative durations. + let warning = false; + let stringWarning = ""; + if (!aStartDatepicker && gStartTime && gEndTime) { + if (gEndTime.compare(gStartTime) >= 0) { + gItemDuration = gEndTime.subtractDate(gStartTime); + } else { + gStartTime = saveStartTime; + gEndTime = saveEndTime; + warning = true; + stringWarning = cal.l10n.getCalString("warningEndBeforeStart"); + } + } + + let startChanged = false; + if (gStartTime && saveStartTime) { + startChanged = gStartTime.compare(saveStartTime) != 0; + } + // Preset the date in the until-datepicker's minimonth to the new start + // date if it has changed. + if (startChanged) { + let startDate = cal.dtz.dateTimeToJsDate(gStartTime.getInTimezone(cal.dtz.floating)); + document.getElementById("repeat-until-datepicker").extraDate = startDate; + } + + // Sort out and verify the until date if the start date has changed. + if (gUntilDate && startChanged) { + // Make the time part of the until date equal to the time of start date. + updateUntildateRecRule(); + + // Don't allow for until date earlier than the start date. + if (gUntilDate.compare(gStartTime) < 0) { + // We have to restore valid dates. Since the user has intentionally + // changed the start date, it looks reasonable to restore a valid + // until date equal to the start date. + gUntilDate = gStartTime.clone(); + // Update the until-date-picker. In case of "custom" rule, the + // recurrence string is going to be changed by updateDateTime() below. + if ( + !document.getElementById("repeat-untilDate").hidden && + document.getElementById("repeat-details").hidden + ) { + document.getElementById("repeat-until-datepicker").value = cal.dtz.dateTimeToJsDate( + gUntilDate.getInTimezone(cal.dtz.floating) + ); + } + + warning = true; + stringWarning = cal.l10n.getCalString("warningUntilDateBeforeStart"); + } + } + + updateDateTime(); + updateTimezone(); + updateAccept(); + + if (warning) { + // Disable the "Save" and "Save and Close" commands as long as the + // warning dialog is showed. + enableAcceptCommand(false); + gWarning = true; + let callback = function () { + Services.prompt.alert(null, document.title, stringWarning); + gWarning = false; + updateAccept(); + }; + setTimeout(callback, 1); + } +} + +/** + * Updates the entry date checkboxes, used for example when choosing an alarm: + * the entry date needs to be checked in that case. + */ +function updateEntryDate() { + updateDateCheckboxes("todo-entrydate", "todo-has-entrydate", { + isValid() { + return gStartTime != null; + }, + setDateTime(date) { + gStartTime = date; + }, + }); +} + +/** + * Updates the due date checkboxes. + */ +function updateDueDate() { + updateDateCheckboxes("todo-duedate", "todo-has-duedate", { + isValid() { + return gEndTime != null; + }, + setDateTime(date) { + gEndTime = date; + }, + }); +} + +/** + * Common function used by updateEntryDate and updateDueDate to set up the + * checkboxes correctly. + * + * @param aDatePickerId The XUL id of the datepicker to update. + * @param aCheckboxId The XUL id of the corresponding checkbox. + * @param aDateTime An object implementing the isValid and setDateTime + * methods. XXX explain. + */ +function updateDateCheckboxes(aDatePickerId, aCheckboxId, aDateTime) { + if (gIgnoreUpdate) { + return; + } + + if (!window.calendarItem.isTodo()) { + return; + } + + // force something to get set if there was nothing there before + aDatePickerId.value = document.getElementById(aDatePickerId).value; + + // first of all disable the datetime picker if we don't have a date + let hasDate = document.getElementById(aCheckboxId).checked; + aDatePickerId.disabled = !hasDate; + + // create a new datetime object if date is now checked for the first time + if (hasDate && !aDateTime.isValid()) { + let date = cal.dtz.jsDateToDateTime( + document.getElementById(aDatePickerId).value, + cal.dtz.defaultTimezone + ); + aDateTime.setDateTime(date); + } else if (!hasDate && aDateTime.isValid()) { + aDateTime.setDateTime(null); + } + + // calculate the duration if possible + let hasEntryDate = document.getElementById("todo-has-entrydate").checked; + let hasDueDate = document.getElementById("todo-has-duedate").checked; + if (hasEntryDate && hasDueDate) { + let start = cal.dtz.jsDateToDateTime(document.getElementById("todo-entrydate").value); + let end = cal.dtz.jsDateToDateTime(document.getElementById("todo-duedate").value); + gItemDuration = end.subtractDate(start); + } else { + gItemDuration = null; + } + document.getElementById("keepduration-button").disabled = !(hasEntryDate && hasDueDate); + updateDateTime(); + updateTimezone(); +} + +/** + * Get the item's recurrence information for displaying in dialog controls. + * + * @param {object} aItem - The calendar item + * @returns {string[]} An array of two strings: [repeatType, untilDate] + */ +function getRepeatTypeAndUntilDate(aItem) { + let recurrenceInfo = window.recurrenceInfo; + let repeatType = "none"; + let untilDate = "forever"; + + /** + * Updates the until date (locally and globally). + * + * @param aRule The recurrence rule + */ + let updateUntilDate = aRule => { + if (!aRule.isByCount) { + if (aRule.isFinite) { + gUntilDate = aRule.untilDate.clone().getInTimezone(cal.dtz.defaultTimezone); + untilDate = cal.dtz.dateTimeToJsDate(gUntilDate.getInTimezone(cal.dtz.floating)); + } else { + gUntilDate = null; + } + } + }; + + if (recurrenceInfo) { + repeatType = "custom"; + let ritems = recurrenceInfo.getRecurrenceItems(); + let rules = []; + let exceptions = []; + for (let ritem of ritems) { + if (ritem.isNegative) { + exceptions.push(ritem); + } else { + rules.push(ritem); + } + } + if (rules.length == 1) { + let rule = cal.wrapInstance(rules[0], Ci.calIRecurrenceRule); + if (rule) { + switch (rule.type) { + case "DAILY": { + let byparts = [ + "BYSECOND", + "BYMINUTE", + "BYHOUR", + "BYMONTHDAY", + "BYYEARDAY", + "BYWEEKNO", + "BYMONTH", + "BYSETPOS", + ]; + if (!checkRecurrenceRule(rule, byparts)) { + let ruleComp = rule.getComponent("BYDAY"); + if (rule.interval == 1) { + if (ruleComp.length > 0) { + if (ruleComp.length == 5) { + let found = false; + for (let i = 0; i < 5; i++) { + if (ruleComp[i] != i + 2) { + found = true; + break; + } + } + if (!found && (!rule.isFinite || !rule.isByCount)) { + repeatType = "every.weekday"; + updateUntilDate(rule); + } + } + } else if (!rule.isFinite || !rule.isByCount) { + repeatType = "daily"; + updateUntilDate(rule); + } + } + } + break; + } + case "WEEKLY": { + let byparts = [ + "BYSECOND", + "BYMINUTE", + "BYDAY", + "BYHOUR", + "BYMONTHDAY", + "BYYEARDAY", + "BYWEEKNO", + "BYMONTH", + "BYSETPOS", + ]; + if (!checkRecurrenceRule(rule, byparts)) { + let weekType = ["weekly", "bi.weekly"]; + if ( + (rule.interval == 1 || rule.interval == 2) && + (!rule.isFinite || !rule.isByCount) + ) { + repeatType = weekType[rule.interval - 1]; + updateUntilDate(rule); + } + } + break; + } + case "MONTHLY": { + let byparts = [ + "BYSECOND", + "BYMINUTE", + "BYDAY", + "BYHOUR", + "BYMONTHDAY", + "BYYEARDAY", + "BYWEEKNO", + "BYMONTH", + "BYSETPOS", + ]; + if (!checkRecurrenceRule(rule, byparts)) { + if (rule.interval == 1 && (!rule.isFinite || !rule.isByCount)) { + repeatType = "monthly"; + updateUntilDate(rule); + } + } + break; + } + case "YEARLY": { + let byparts = [ + "BYSECOND", + "BYMINUTE", + "BYDAY", + "BYHOUR", + "BYMONTHDAY", + "BYYEARDAY", + "BYWEEKNO", + "BYMONTH", + "BYSETPOS", + ]; + if (!checkRecurrenceRule(rule, byparts)) { + if (rule.interval == 1 && (!rule.isFinite || !rule.isByCount)) { + repeatType = "yearly"; + updateUntilDate(rule); + } + } + break; + } + } + } + } + } + return [repeatType, untilDate]; +} + +/** + * Updates the XUL UI with the repeat type and the until date. + * + * @param {string} aRepeatType - The type of repeat + * @param {string} aUntilDate - The until date + * @param {object} aItem - The calendar item + */ +function loadRepeat(aRepeatType, aUntilDate, aItem) { + document.getElementById("item-repeat").value = aRepeatType; + let repeatMenu = document.getElementById("item-repeat"); + gLastRepeatSelection = repeatMenu.selectedIndex; + + if (aItem.parentItem != aItem) { + document.getElementById("item-repeat").setAttribute("disabled", "true"); + document.getElementById("repeat-until-datepicker").setAttribute("disabled", "true"); + } + // Show the repeat-until-datepicker and set its date + document.getElementById("repeat-untilDate").hidden = false; + document.getElementById("repeat-details").hidden = true; + document.getElementById("repeat-until-datepicker").value = aUntilDate; +} + +/** + * Update reminder related elements on the dialog. + * + * @param aSuppressDialogs If true, controls are updated without prompting + * for changes with the custom dialog + */ +function updateReminder(aSuppressDialogs) { + window.gLastAlarmSelection = commonUpdateReminder( + document.querySelector(".item-alarm"), + window.calendarItem, + window.gLastAlarmSelection, + getCurrentCalendar(), + document.querySelector(".reminder-details"), + window.gStartTimezone || window.gEndTimezone, + aSuppressDialogs + ); + updateAccept(); +} + +/** + * Saves all values the user chose on the dialog to the passed item + * + * @param item The item to save to. + */ +function saveDialog(item) { + // Calendar + item.calendar = getCurrentCalendar(); + + cal.item.setItemProperty(item, "title", document.getElementById("item-title").value); + cal.item.setItemProperty(item, "LOCATION", document.getElementById("item-location").value); + + saveDateTime(item); + + if (item.isTodo()) { + let percentCompleteInteger = 0; + if (document.getElementById("percent-complete-textbox").value != "") { + percentCompleteInteger = parseInt( + document.getElementById("percent-complete-textbox").value, + 10 + ); + } + if (percentCompleteInteger < 0) { + percentCompleteInteger = 0; + } else if (percentCompleteInteger > 100) { + percentCompleteInteger = 100; + } + cal.item.setItemProperty(item, "PERCENT-COMPLETE", percentCompleteInteger); + } + + // Categories + saveCategories(item); + + // Attachment + // We want the attachments to be up to date, remove all first. + item.removeAllAttachments(); + + // Now add back the new ones + for (let hashId in gAttachMap) { + let att = gAttachMap[hashId]; + item.addAttachment(att); + } + + // Description + let editorElement = document.getElementById("item-description"); + let editor = editorElement.getHTMLEditor(editorElement.contentWindow); + if (editor.documentModified) { + // Get editor output as HTML. We request raw output to avoid any + // pretty-printing which may cause issues with Google Calendar (see comments + // in calViewUtils.fixGoogleCalendarDescription() for more information). + let mode = + Ci.nsIDocumentEncoder.OutputRaw | + Ci.nsIDocumentEncoder.OutputDropInvisibleBreak | + Ci.nsIDocumentEncoder.OutputBodyOnly; + + const editorOutput = editor.outputToString("text/html", mode); + + // The editor gives us output wrapped in a body tag. We don't really want + // that, so strip it. (Yes, it's a regex with HTML, but a _very_ specific + // one.) We use the `s` flag to match across newlines in case there's a + // <pre/> tag, in which case <br/> will not be inserted. + item.descriptionHTML = editorOutput.replace(/^<body>(.+)<\/body>$/s, "$1"); + } + + // Event Status + if (item.isEvent()) { + if (gConfig.status && gConfig.status != "NONE") { + item.setProperty("STATUS", gConfig.status); + } else { + item.deleteProperty("STATUS"); + } + } else { + let status = document.getElementById("todo-status").value; + if (status != "COMPLETED") { + item.completedDate = null; + } + cal.item.setItemProperty(item, "STATUS", status == "NONE" ? null : status); + } + + // set the "PRIORITY" property if a valid priority has been + // specified (any integer value except *null*) OR the item + // already specifies a priority. in any other case we don't + // need this property and can safely delete it. we need this special + // handling since the WCAP provider always includes the priority + // with value *null* and we don't detect changes to this item if + // we delete this property. + if (capSupported("priority") && (gConfig.priority || item.hasProperty("PRIORITY"))) { + item.setProperty("PRIORITY", gConfig.priority); + } else { + item.deleteProperty("PRIORITY"); + } + + // Transparency + if (gConfig.showTimeAs) { + item.setProperty("TRANSP", gConfig.showTimeAs); + } else { + item.deleteProperty("TRANSP"); + } + + // Privacy + cal.item.setItemProperty(item, "CLASS", gConfig.privacy, "privacy"); + + if (item.status == "COMPLETED" && item.isTodo()) { + let elementValue = document.getElementById("completed-date-picker").value; + item.completedDate = cal.dtz.jsDateToDateTime(elementValue); + } + + saveReminder(item, getCurrentCalendar(), document.querySelector(".item-alarm")); +} + +/** + * Save date and time related values from the dialog to the passed item. + * + * @param item The item to save to. + */ +function saveDateTime(item) { + // Changes to the start date don't have to change the until date. + untilDateCompensation(item); + + if (item.isEvent()) { + let startTime = gStartTime.getInTimezone(gStartTimezone); + let endTime = gEndTime.getInTimezone(gEndTimezone); + let isAllDay = document.getElementById("event-all-day").checked; + if (isAllDay) { + startTime = startTime.clone(); + endTime = endTime.clone(); + startTime.isDate = true; + endTime.isDate = true; + endTime.day += 1; + } else { + startTime = startTime.clone(); + startTime.isDate = false; + endTime = endTime.clone(); + endTime.isDate = false; + } + cal.item.setItemProperty(item, "startDate", startTime); + cal.item.setItemProperty(item, "endDate", endTime); + } + if (item.isTodo()) { + let startTime = gStartTime && gStartTime.getInTimezone(gStartTimezone); + let endTime = gEndTime && gEndTime.getInTimezone(gEndTimezone); + cal.item.setItemProperty(item, "entryDate", startTime); + cal.item.setItemProperty(item, "dueDate", endTime); + } +} + +/** + * Changes the until date in the rule in order to compensate the automatic + * correction caused by the function onStartDateChange() when saving the + * item. + * It allows to keep the until date set in the dialog irrespective of the + * changes that the user has done to the start date. + */ +function untilDateCompensation(aItem) { + // The current start date in the item is always the date that we get + // when opening the dialog or after the last save. + let startDate = aItem[cal.dtz.startDateProp(aItem)]; + + if (aItem.recurrenceInfo) { + let rrules = splitRecurrenceRules(aItem.recurrenceInfo); + let rule = rrules[0][0]; + if (!rule.isByCount && rule.isFinite && startDate) { + let compensation = startDate.subtractDate(gStartTime); + if (compensation != "PT0S") { + let untilDate = rule.untilDate.clone(); + untilDate.addDuration(compensation); + rule.untilDate = untilDate; + } + } + } +} + +/** + * Updates the dialog title based on item type and if the item is new or to be + * modified. + */ +function updateTitle() { + let strName; + if (window.calendarItem.isEvent()) { + strName = window.mode == "new" ? "newEventDialog" : "editEventDialog"; + } else if (window.calendarItem.isTodo()) { + strName = window.mode == "new" ? "newTaskDialog" : "editTaskDialog"; + } else { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + sendMessage({ + command: "updateTitle", + prefix: cal.l10n.getCalString(strName), + title: document.getElementById("item-title").value, + }); +} + +/** + * Update the disabled status of the accept button. The button is enabled if all + * parts of the dialog have options selected that make sense. + * constraining factors like + */ +function updateAccept() { + let enableAccept = true; + let kDefaultTimezone = cal.dtz.defaultTimezone; + let startDate; + let endDate; + let isEvent = window.calendarItem.isEvent(); + + // don't allow for end dates to be before start dates + if (isEvent) { + startDate = cal.dtz.jsDateToDateTime(document.getElementById("event-starttime").value); + endDate = cal.dtz.jsDateToDateTime(document.getElementById("event-endtime").value); + } else { + startDate = document.getElementById("todo-has-entrydate").checked + ? cal.dtz.jsDateToDateTime(document.getElementById("todo-entrydate").value) + : null; + endDate = document.getElementById("todo-has-duedate").checked + ? cal.dtz.jsDateToDateTime(document.getElementById("todo-duedate").value) + : null; + } + + if (startDate && endDate) { + if (gTimezonesEnabled) { + let startTimezone = gStartTimezone; + let endTimezone = gEndTimezone; + if (endTimezone.isUTC) { + if (!cal.data.compareObjects(gStartTimezone, gEndTimezone)) { + endTimezone = gStartTimezone; + } + } + + startDate = startDate.getInTimezone(kDefaultTimezone); + endDate = endDate.getInTimezone(kDefaultTimezone); + + startDate.timezone = startTimezone; + endDate.timezone = endTimezone; + } + + startDate = startDate.getInTimezone(kDefaultTimezone); + endDate = endDate.getInTimezone(kDefaultTimezone); + + // For all-day events we are not interested in times and compare only + // dates. + if (isEvent && document.getElementById("event-all-day").checked) { + // jsDateToDateTime returns the values in UTC. Depending on the + // local timezone and the values selected in datetimepicker the date + // in UTC might be shifted to the previous or next day. + // For example: The user (with local timezone GMT+05) selected + // Feb 10 2006 00:00:00. The corresponding value in UTC is + // Feb 09 2006 19:00:00. If we now set isDate to true we end up with + // a date of Feb 09 2006 instead of Feb 10 2006 resulting in errors + // during the following comparison. + // Calling getInTimezone() ensures that we use the same dates as + // displayed to the user in datetimepicker for comparison. + startDate.isDate = true; + endDate.isDate = true; + } + } + + if (endDate && startDate && endDate.compare(startDate) == -1) { + enableAccept = false; + } + + enableAcceptCommand(enableAccept); + + return enableAccept; +} + +/** + * Enables/disables the commands cmd_accept and cmd_save related to the + * save operation. + * + * @param aEnable true: enables the command + */ +function enableAcceptCommand(aEnable) { + sendMessage({ command: "enableAcceptCommand", argument: aEnable }); +} + +// Global variables used to restore start and end date-time when changing the +// "all day" status in the onUpdateAllday() function. +var gOldStartTime = null; +var gOldEndTime = null; +var gOldStartTimezone = null; +var gOldEndTimezone = null; + +/** + * Handler function to update controls and state in consequence of the "all + * day" checkbox being clicked. + */ +function onUpdateAllDay() { + if (!window.calendarItem.isEvent()) { + return; + } + let allDay = document.getElementById("event-all-day").checked; + let kDefaultTimezone = cal.dtz.defaultTimezone; + + if (allDay) { + // Store date-times and related timezones so we can restore + // if the user unchecks the "all day" checkbox. + gOldStartTime = gStartTime.clone(); + gOldEndTime = gEndTime.clone(); + gOldStartTimezone = gStartTimezone; + gOldEndTimezone = gEndTimezone; + // When events that end at 0:00 become all-day events, we need to + // subtract a day from the end date because the real end is midnight. + if (gEndTime.hour == 0 && gEndTime.minute == 0) { + let tempStartTime = gStartTime.clone(); + let tempEndTime = gEndTime.clone(); + tempStartTime.isDate = true; + tempEndTime.isDate = true; + tempStartTime.day++; + if (tempEndTime.compare(tempStartTime) >= 0) { + gEndTime.day--; + } + } + } else { + gStartTime.isDate = false; + gEndTime.isDate = false; + if (!gOldStartTime && !gOldEndTime) { + // The checkbox has been unchecked for the first time, the event + // was an "All day" type, so we have to set default values. + gStartTime.hour = cal.dtz.getDefaultStartDate(window.initialStartDateValue).hour; + gEndTime.hour = gStartTime.hour; + gEndTime.minute += Services.prefs.getIntPref("calendar.event.defaultlength", 60); + gOldStartTimezone = kDefaultTimezone; + gOldEndTimezone = kDefaultTimezone; + } else { + // Restore date-times previously stored. + gStartTime.hour = gOldStartTime.hour; + gStartTime.minute = gOldStartTime.minute; + gEndTime.hour = gOldEndTime.hour; + gEndTime.minute = gOldEndTime.minute; + // When we restore 0:00 as end time, we need to add one day to + // the end date in order to include the last day until midnight. + if (gEndTime.hour == 0 && gEndTime.minute == 0) { + gEndTime.day++; + } + } + } + gStartTimezone = allDay ? cal.dtz.floating : gOldStartTimezone; + gEndTimezone = allDay ? cal.dtz.floating : gOldEndTimezone; + setShowTimeAs(allDay); + + updateAllDay(); +} + +/** + * This function sets the enabled/disabled state of the following controls: + * - 'event-starttime' + * - 'event-endtime' + * - 'timezone-starttime' + * - 'timezone-endtime' + * the state depends on whether or not the event is configured as 'all-day' or not. + */ +function updateAllDay() { + if (gIgnoreUpdate) { + return; + } + + if (!window.calendarItem.isEvent()) { + return; + } + + let allDay = document.getElementById("event-all-day").checked; + if (allDay) { + document.getElementById("event-starttime").setAttribute("timepickerdisabled", true); + document.getElementById("event-endtime").setAttribute("timepickerdisabled", true); + } else { + document.getElementById("event-starttime").removeAttribute("timepickerdisabled"); + document.getElementById("event-endtime").removeAttribute("timepickerdisabled"); + } + + gStartTime.isDate = allDay; + gEndTime.isDate = allDay; + gItemDuration = gEndTime.subtractDate(gStartTime); + + updateDateTime(); + updateUntildateRecRule(); + updateRepeatDetails(); + updateAccept(); +} + +/** + * Use the window arguments to cause the opener to create a new event on the + * item's calendar + */ +function openNewEvent() { + let item = window.calendarItem; + let args = window.arguments[0]; + args.onNewEvent(item.calendar); +} + +/** + * Use the window arguments to cause the opener to create a new event on the + * item's calendar + */ +function openNewTask() { + let item = window.calendarItem; + let args = window.arguments[0]; + args.onNewTodo(item.calendar); +} + +/** + * Update the transparency status of this dialog, depending on if the event + * is all-day or not. + * + * @param allDay If true, the event is all-day + */ +function setShowTimeAs(allDay) { + gConfig.showTimeAs = cal.item.getEventDefaultTransparency(allDay); + updateConfigState({ showTimeAs: gConfig.showTimeAs }); +} + +function editAttendees() { + let savedWindow = window; + let calendar = getCurrentCalendar(); + + let callback = function (attendees, organizer, startTime, endTime) { + savedWindow.attendees = attendees; + savedWindow.organizer = organizer; + + // if a participant was added or removed we switch to the attendee + // tab, so the user can see the change directly + let tabs = document.getElementById("event-grid-tabs"); + let attendeeTab = document.getElementById("event-grid-tab-attendees"); + tabs.selectedItem = attendeeTab; + + let duration = endTime.subtractDate(startTime); + startTime = startTime.clone(); + endTime = endTime.clone(); + let kDefaultTimezone = cal.dtz.defaultTimezone; + gStartTimezone = startTime.timezone; + gEndTimezone = endTime.timezone; + gStartTime = startTime.getInTimezone(kDefaultTimezone); + gEndTime = endTime.getInTimezone(kDefaultTimezone); + gItemDuration = duration; + updateAttendeeInterface(); + updateDateTime(); + updateAllDay(); + + if (isAllDay != gStartTime.isDate) { + setShowTimeAs(gStartTime.isDate); + } + }; + + let startTime = gStartTime.getInTimezone(gStartTimezone); + let endTime = gEndTime.getInTimezone(gEndTimezone); + + let isAllDay = document.getElementById("event-all-day").checked; + if (isAllDay) { + startTime.isDate = true; + endTime.isDate = true; + endTime.day += 1; + } else { + startTime.isDate = false; + endTime.isDate = false; + } + let args = {}; + args.startTime = startTime; + args.endTime = endTime; + args.displayTimezone = gTimezonesEnabled; + args.attendees = window.attendees; + args.organizer = window.organizer && window.organizer.clone(); + args.calendar = calendar; + args.item = window.calendarItem; + args.onOk = callback; + + // open the dialog modally + openDialog( + "chrome://calendar/content/calendar-event-dialog-attendees.xhtml", + "_blank", + "chrome,titlebar,modal,resizable", + args + ); +} + +/** + * Updates the UI outside of the iframe (toolbar, menu, statusbar, etc.) + * for changes in priority, privacy, status, showTimeAs/transparency, + * and/or other properties. This function should be called any time that + * gConfig.privacy, gConfig.priority, etc. are updated. + * + * Privacy and priority updates depend on the selected calendar. If the + * selected calendar does not support them, or only supports certain + * values, these are removed from the UI. + * + * @param {object} aArg - Container + * @param {string} aArg.privacy - (optional) The new privacy value + * @param {short} aArg.priority - (optional) The new priority value + * @param {string} aArg.status - (optional) The new status value + * @param {string} aArg.showTimeAs - (optional) The new transparency value + */ +function updateConfigState(aArg) { + // We include additional info for priority and privacy. + if (aArg.hasOwnProperty("priority")) { + aArg.hasPriority = capSupported("priority"); + } + if (aArg.hasOwnProperty("privacy")) { + Object.assign(aArg, { + hasPrivacy: capSupported("privacy"), + calendarType: getCurrentCalendar().type, + privacyValues: capValues("privacy", ["PUBLIC", "CONFIDENTIAL", "PRIVATE"]), + }); + } + + // For tasks, do not include showTimeAs + if (aArg.hasOwnProperty("showTimeAs") && window.calendarItem.isTodo()) { + delete aArg.showTimeAs; + if (Object.keys(aArg).length == 0) { + return; + } + } + + sendMessage({ command: "updateConfigState", argument: aArg }); +} + +/** + * Add menu items to the UI for attaching files using cloud providers. + */ +function loadCloudProviders() { + let cloudFileEnabled = Services.prefs.getBoolPref("mail.cloud_files.enabled", false); + let cmd = document.getElementById("cmd_attach_cloud"); + let message = { + command: "setElementAttribute", + argument: { id: "cmd_attach_cloud", attribute: "hidden", value: null }, + }; + + if (!cloudFileEnabled) { + // If cloud file support is disabled, just hide the attach item + cmd.hidden = true; + message.argument.value = true; + sendMessage(message); + return; + } + + let isHidden = cloudFileAccounts.configuredAccounts.length == 0; + cmd.hidden = isHidden; + message.argument.value = isHidden; + sendMessage(message); + + let itemObjects = []; + + for (let cloudProvider of cloudFileAccounts.configuredAccounts) { + // Create a serializable object to pass in a message outside the iframe + let itemObject = {}; + itemObject.displayName = cloudFileAccounts.getDisplayName(cloudProvider); + itemObject.label = cal.l10n.getString("calendar-event-dialog", "attachViaFilelink", [ + itemObject.displayName, + ]); + itemObject.cloudProviderAccountKey = cloudProvider.accountKey; + if (cloudProvider.iconURL) { + itemObject.class = "menuitem-iconic"; + itemObject.image = cloudProvider.iconURL; + } + + itemObjects.push(itemObject); + + // Create a menu item from the serializable object + let item = document.createXULElement("menuitem"); + item.setAttribute("label", itemObject.label); + item.setAttribute("observes", "cmd_attach_cloud"); + item.setAttribute( + "oncommand", + "attachFile(event.target.cloudProvider); event.stopPropagation();" + ); + + if (itemObject.class) { + item.setAttribute("class", itemObject.class); + item.setAttribute("image", itemObject.image); + } + + // Add the menu item to places inside the iframe where we advertise cloud providers + let attachmentPopup = document.getElementById("attachment-popup"); + attachmentPopup.appendChild(item).cloudProvider = cloudProvider; + } + + // Add the items to places outside the iframe where we advertise cloud providers + sendMessage({ command: "loadCloudProviders", items: itemObjects }); +} + +/** + * Prompts the user to attach an url to this item. + */ +function attachURL() { + if (Services.prompt) { + // ghost in an example... + let result = { value: "http://" }; + let confirm = Services.prompt.prompt( + window, + cal.l10n.getString("calendar-event-dialog", "specifyLinkLocation"), + cal.l10n.getString("calendar-event-dialog", "enterLinkLocation"), + result, + null, + { value: 0 } + ); + + if (confirm) { + try { + // If something bogus was entered, Services.io.newURI may fail. + let attachment = new CalAttachment(); + attachment.uri = Services.io.newURI(result.value); + addAttachment(attachment); + // we switch to the attachment tab if it is not already displayed + // to allow the user to see the attachment was added + let tabs = document.getElementById("event-grid-tabs"); + let attachTab = document.getElementById("event-grid-tab-attachments"); + tabs.selectedItem = attachTab; + } catch (e) { + // TODO We might want to show a warning instead of just not + // adding the file + } + } + } +} + +/** + * Attach a file using a cloud provider, identified by its accountKey. + * + * @param {string} aAccountKey - The accountKey for a cloud provider + */ +function attachFileByAccountKey(aAccountKey) { + for (let cloudProvider of cloudFileAccounts.configuredAccounts) { + if (aAccountKey == cloudProvider.accountKey) { + attachFile(cloudProvider); + return; + } + } +} + +/** + * Attach a file to the item. Not passing a cloud provider is currently unsupported. + * + * @param cloudProvider If set, the cloud provider will be used for attaching + */ +function attachFile(cloudProvider) { + if (!cloudProvider) { + cal.ERROR( + "[calendar-event-dialog] Could not attach file without cloud provider" + cal.STACK(10) + ); + } + + let filePicker = Cc["@mozilla.org/filepicker;1"].createInstance(Ci.nsIFilePicker); + filePicker.init( + window, + cal.l10n.getString("calendar-event-dialog", "selectAFile"), + Ci.nsIFilePicker.modeOpenMultiple + ); + + // Check for the last directory + let lastDir = lastDirectory(); + if (lastDir) { + filePicker.displayDirectory = lastDir; + } + + filePicker.open(rv => { + if (rv != Ci.nsIFilePicker.returnOK || !filePicker.files) { + return; + } + + // Create the attachment + for (let file of filePicker.files) { + let fileHandler = Services.io + .getProtocolHandler("file") + .QueryInterface(Ci.nsIFileProtocolHandler); + let uriSpec = fileHandler.getURLSpecFromActualFile(file); + + if (!(uriSpec in gAttachMap)) { + // If the attachment hasn't been added, then set the last display + // directory. + lastDirectory(uriSpec); + + // ... and add the attachment. + let attachment = new CalAttachment(); + if (cloudProvider) { + attachment.uri = Services.io.newURI(uriSpec); + } else { + // TODO read file into attachment + } + addAttachment(attachment, cloudProvider); + } + } + }); +} + +/** + * Helper function to remember the last directory chosen when attaching files. + * + * @param aFileUri (optional) If passed, the last directory will be set and + * returned. If null, the last chosen directory + * will be returned. + * @returns The last directory that was set with this function. + */ +function lastDirectory(aFileUri) { + if (aFileUri) { + // Act similar to a setter, save the passed uri. + let uri = Services.io.newURI(aFileUri); + let file = uri.QueryInterface(Ci.nsIFileURL).file; + lastDirectory.mValue = file.parent.QueryInterface(Ci.nsIFile); + } + + // In any case, return the value + return lastDirectory.mValue === undefined ? null : lastDirectory.mValue; +} + +/** + * Turns an url into a string that can be used in UI. + * - For a file:// url, shows the filename. + * - For a http:// url, removes protocol and trailing slash + * + * @param aUri The uri to parse. + * @returns A string that can be used in UI. + */ +function makePrettyName(aUri) { + let name = aUri.spec; + + if (aUri.schemeIs("file")) { + name = aUri.spec.split("/").pop(); + } else if (aUri.schemeIs("http")) { + name = aUri.spec.replace(/\/$/, "").replace(/^http:\/\//, ""); + } + return name; +} + +/** + * Asynchronously uploads the given attachment to the cloud provider, updating + * the passed listItem as things progress. + * + * @param attachment A calIAttachment to upload. + * @param cloudFileAccount The cloud file account used for uploading. + * @param listItem The listitem in attachment-link listbox to update. + */ +function uploadCloudAttachment(attachment, cloudFileAccount, listItem) { + let file = attachment.uri.QueryInterface(Ci.nsIFileURL).file; + let image = listItem.querySelector("img"); + listItem.attachCloudFileAccount = cloudFileAccount; + image.setAttribute("src", "chrome://global/skin/icons/loading.png"); + // WebExtension APIs do not support calendar tabs. + cloudFileAccount.uploadFile(null, file, attachment.name).then( + upload => { + delete gAttachMap[attachment.hashId]; + attachment.uri = Services.io.newURI(upload.url); + attachment.setParameter("FILENAME", file.leafName); + attachment.setParameter("X-SERVICE-ICONURL", upload.serviceIcon); + listItem.setAttribute("label", file.leafName); + gAttachMap[attachment.hashId] = attachment; + image.setAttribute("src", upload.serviceIcon); + listItem.attachCloudFileUpload = upload; + updateAttachment(); + }, + statusCode => { + cal.ERROR( + "[calendar-event-dialog] Uploading cloud attachment failed. Status code: " + + statusCode.result + ); + + // Uploading failed. First of all, show an error icon. Also, + // delete it from the attach map now, this will make sure it is + // not serialized if the user saves. + image.setAttribute("src", "chrome://messenger/skin/icons/error.png"); + delete gAttachMap[attachment.hashId]; + + // Keep the item for a while so the user can see something failed. + // When we have a nice notification bar, we can show more info + // about the failure. + setTimeout(() => { + listItem.remove(); + updateAttachment(); + }, 5000); + } + ); +} + +/** + * Adds the given attachment to dialog controls. + * + * @param attachment The calIAttachment object to add + * @param cloudFileAccount (optional) If set, the given cloud file account will be used. + */ +function addAttachment(attachment, cloudFileAccount) { + if (!attachment || !attachment.hashId || attachment.hashId in gAttachMap) { + return; + } + + // We currently only support uri attachments + if (attachment.uri) { + let documentLink = document.getElementById("attachment-link"); + let listItem = document.createXULElement("richlistitem"); + let image = document.createElement("img"); + image.setAttribute("alt", ""); + image.width = "24"; + image.height = "24"; + // Allow the moz-icon src to be invalid. + image.classList.add("invisible-on-broken"); + listItem.appendChild(image); + let label = document.createXULElement("label"); + label.setAttribute("value", makePrettyName(attachment.uri)); + label.setAttribute("crop", "end"); + listItem.appendChild(label); + listItem.setAttribute("tooltiptext", attachment.uri.spec); + if (cloudFileAccount) { + if (attachment.uri.schemeIs("file")) { + // Its still a local url, needs to be uploaded + image.setAttribute("src", "chrome://messenger/skin/icons/connecting.png"); + uploadCloudAttachment(attachment, cloudFileAccount, listItem); + } else { + let cloudFileIconURL = attachment.getParameter("X-SERVICE-ICONURL"); + image.setAttribute("src", cloudFileIconURL); + let leafName = attachment.getParameter("FILENAME"); + if (leafName) { + listItem.setAttribute("label", leafName); + } + } + } else if (attachment.uri.schemeIs("file")) { + image.setAttribute("src", "moz-icon://" + attachment.uri.spec); + } else { + let leafName = attachment.getParameter("FILENAME"); + let cloudFileIconURL = attachment.getParameter("X-SERVICE-ICONURL"); + let cloudFileEnabled = Services.prefs.getBoolPref("mail.cloud_files.enabled", false); + + if (leafName) { + // TODO security issues? + listItem.setAttribute("label", leafName); + } + if (cloudFileIconURL && cloudFileEnabled) { + image.setAttribute("src", cloudFileIconURL); + } else { + let iconSrc = attachment.uri.spec.length ? attachment.uri.spec : "dummy.html"; + if (attachment.formatType) { + iconSrc = "goat?contentType=" + attachment.formatType; + } else { + // let's try to auto-detect + let parts = iconSrc.substr(attachment.uri.scheme.length + 2).split("/"); + if (parts.length) { + iconSrc = parts[parts.length - 1]; + } + } + image.setAttribute("src", "moz-icon://" + iconSrc); + } + } + + // Now that everything is set up, add it to the attachment box. + documentLink.appendChild(listItem); + + // full attachment object is stored here + listItem.attachment = attachment; + + // Update the number of rows and save our attachment globally + documentLink.rows = documentLink.getRowCount(); + } + + gAttachMap[attachment.hashId] = attachment; + updateAttachment(); +} + +/** + * Removes the currently selected attachment from the dialog controls. + * + * XXX This could use a dialog maybe? + */ +function deleteAttachment() { + let documentLink = document.getElementById("attachment-link"); + let item = documentLink.selectedItem; + delete gAttachMap[item.attachment.hashId]; + + if (item.attachCloudFileAccount && item.attachCloudFileUpload) { + try { + // WebExtension APIs do not support calendar tabs. + item.attachCloudFileAccount + .deleteFile(null, item.attachCloudFileUpload.id) + .catch(statusCode => { + // TODO With a notification bar, we could actually show this error. + cal.ERROR( + "[calendar-event-dialog] Deleting cloud attachment " + + "failed, file will remain on server. " + + " Status code: " + + statusCode + ); + }); + } catch (e) { + cal.ERROR( + "[calendar-event-dialog] Deleting cloud attachment " + + "failed, file will remain on server. " + + "Exception: " + + e + ); + } + } + item.remove(); + + updateAttachment(); +} + +/** + * Removes all attachments from the dialog controls. + */ +function deleteAllAttachments() { + let documentLink = document.getElementById("attachment-link"); + let itemCount = documentLink.getRowCount(); + let canRemove = itemCount < 2; + + if (itemCount > 1) { + let removeText = PluralForm.get( + itemCount, + cal.l10n.getString("calendar-event-dialog", "removeAttachmentsText") + ); + let removeTitle = cal.l10n.getString("calendar-event-dialog", "removeCalendarsTitle"); + canRemove = Services.prompt.confirm( + window, + removeTitle, + removeText.replace("#1", itemCount), + {} + ); + } + + if (canRemove) { + while (documentLink.lastChild) { + documentLink.lastChild.attachment = null; + documentLink.lastChild.remove(); + } + gAttachMap = {}; + } + updateAttachment(); +} + +/** + * Opens the selected attachment using the external protocol service. + * + * @see nsIExternalProtocolService + */ +function openAttachment() { + // Only one file has to be selected and we don't handle base64 files at all + let documentLink = document.getElementById("attachment-link"); + if (documentLink.selectedItem) { + let attURI = documentLink.selectedItem.attachment.uri; + let externalLoader = Cc["@mozilla.org/uriloader/external-protocol-service;1"].getService( + Ci.nsIExternalProtocolService + ); + // TODO There should be a nicer dialog + externalLoader.loadURI(attURI); + } +} + +/** + * Copies the link location of the first selected attachment to the clipboard + */ +function copyAttachment() { + let documentLink = document.getElementById("attachment-link"); + if (documentLink.selectedItem) { + let attURI = documentLink.selectedItem.attachment.uri.spec; + let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService(Ci.nsIClipboardHelper); + clipboard.copyString(attURI); + } +} + +/** + * Handler function to handle pressing keys in the attachment listbox. + * + * @param aEvent The DOM event caused by the key press. + */ +function attachmentLinkKeyPress(aEvent) { + switch (aEvent.key) { + case "Backspace": + case "Delete": + deleteAttachment(); + break; + case "Enter": + openAttachment(); + aEvent.preventDefault(); + break; + } +} + +/** + * Handler function to take care of double clicking on an attachment + * + * @param aEvent The DOM event caused by the clicking. + */ +function attachmentDblClick(aEvent) { + let item = aEvent.target; + while (item && item.localName != "richlistbox" && item.localName != "richlistitem") { + item = item.parentNode; + } + + // left double click on a list item + if (item.localName == "richlistitem" && aEvent.button == 0) { + openAttachment(); + } +} + +/** + * Handler function to take care of right clicking on an attachment or the attachment list + * + * @param aEvent The DOM event caused by the clicking. + */ +function attachmentClick(aEvent) { + let item = aEvent.target.triggerNode; + while (item && item.localName != "richlistbox" && item.localName != "richlistitem") { + item = item.parentNode; + } + + for (let node of aEvent.target.children) { + if (item.localName == "richlistitem" || node.id == "attachment-popup-attachPage") { + node.removeAttribute("hidden"); + } else { + node.setAttribute("hidden", "true"); + } + } +} + +/** + * Helper function to show a notification in the event-dialog's notificationbox + * + * @param aMessage the message text to show + * @param aValue string identifying the notification + * @param aPriority (optional) the priority of the warning (info, critical), default is 'warn' + * @param aImage (optional) URL of image to appear on the notification + * @param aButtonset (optional) array of button descriptions to appear on the notification + * @param aCallback (optional) a function to handle events from the notificationbox + */ +function notifyUser(aMessage, aValue, aPriority, aImage, aButtonset, aCallback) { + // only append, if the notification does not already exist + if (gEventNotification.getNotificationWithValue(aValue) == null) { + const prioMap = { + info: gEventNotification.PRIORITY_INFO_MEDIUM, + critical: gEventNotification.PRIORITY_CRITICAL_MEDIUM, + }; + let prio = prioMap[aPriority] || gEventNotification.PRIORITY_WARNING_MEDIUM; + gEventNotification.appendNotification( + aValue, + { + label: aMessage, + image: aImage, + priority: prio, + eventCallback: aCallback, + }, + aButtonset + ); + } +} + +/** + * Remove a notification from the notifiactionBox + * + * @param {string} aValue - string identifying the notification to remove + */ +function removeNotification(aValue) { + let notification = gEventNotification.getNotificationWithValue(aValue); + if (notification) { + gEventNotification.removeNotification(notification); + } +} + +/** + * Update the dialog controls related to the item's calendar. + */ +function updateCalendar() { + let item = window.calendarItem; + let calendar = getCurrentCalendar(); + + let cssSafeId = cal.view.formatStringForCSSRule(calendar.id); + document + .getElementById("item-calendar") + .style.setProperty("--item-color", `var(--calendar-${cssSafeId}-backcolor)`); + + gIsReadOnly = calendar.readOnly; + + if (!gPreviousCalendarId) { + gPreviousCalendarId = item.calendar.id; + } + + // We might have to change the organizer, let's see + let calendarOrgId = calendar.getProperty("organizerId"); + if (window.organizer && calendarOrgId && calendar.id != gPreviousCalendarId) { + window.organizer.id = calendarOrgId; + window.organizer.commonName = calendar.getProperty("organizerCN"); + gPreviousCalendarId = calendar.id; + updateAttendeeInterface(); + } + + if (!canNotifyAttendees(calendar, item) && calendar.getProperty("imip.identity")) { + document.getElementById("notify-attendees-checkbox").removeAttribute("disabled"); + document.getElementById("undisclose-attendees-checkbox").removeAttribute("disabled"); + } else { + document.getElementById("notify-attendees-checkbox").setAttribute("disabled", "true"); + document.getElementById("undisclose-attendees-checkbox").setAttribute("disabled", "true"); + } + + // update the accept button + updateAccept(); + + // TODO: the code above decided about whether or not the item is readonly. + // below we enable/disable all controls based on this decision. + // unfortunately some controls need to be disabled based on some other + // criteria. this is why we enable all controls in case the item is *not* + // readonly and run through all those updateXXX() functions to disable + // them again based on the specific logic build into those function. is this + // really a good idea? + if (gIsReadOnly) { + let disableElements = document.getElementsByAttribute("disable-on-readonly", "true"); + for (let element of disableElements) { + if (element.namespaceURI == "http://www.w3.org/1999/xhtml") { + element.setAttribute("disabled", "disabled"); + } else { + element.setAttribute("disabled", "true"); + } + + // we mark link-labels with the hyperlink attribute, since we need + // to remove their class in case they get disabled. TODO: it would + // be better to create a small binding for those link-labels + // instead of adding those special stuff. + if (element.hasAttribute("hyperlink")) { + element.removeAttribute("class"); + element.removeAttribute("onclick"); + } + } + + let collapseElements = document.getElementsByAttribute("collapse-on-readonly", "true"); + for (let element of collapseElements) { + element.setAttribute("collapsed", "true"); + } + } else { + sendMessage({ command: "removeDisableAndCollapseOnReadonly" }); + + let enableElements = document.getElementsByAttribute("disable-on-readonly", "true"); + for (let element of enableElements) { + element.removeAttribute("disabled"); + if (element.hasAttribute("hyperlink")) { + element.classList.add("text-link"); + } + } + + let collapseElements = document.getElementsByAttribute("collapse-on-readonly", "true"); + for (let element of collapseElements) { + element.removeAttribute("collapsed"); + } + + if (item.isTodo()) { + // Task completed date + if (item.completedDate) { + updateToDoStatus(item.status, cal.dtz.dateTimeToJsDate(item.completedDate)); + } else { + updateToDoStatus(item.status); + } + } + + // disable repeat menupopup if this is an occurrence + item = window.calendarItem; + if (item.parentItem != item) { + document.getElementById("item-repeat").setAttribute("disabled", "true"); + document.getElementById("repeat-until-datepicker").setAttribute("disabled", "true"); + let repeatDetails = document.getElementById("repeat-details"); + let numChilds = repeatDetails.children.length; + for (let i = 0; i < numChilds; i++) { + let node = repeatDetails.children[i]; + node.setAttribute("disabled", "true"); + node.removeAttribute("class"); + node.removeAttribute("onclick"); + } + } + + // If the item is a proxy occurrence/instance, a few things aren't + // valid. + if (item.parentItem != item) { + document.getElementById("item-calendar").setAttribute("disabled", "true"); + + // don't allow to revoke the entrydate of recurring todo's. + disableElementWithLock("todo-has-entrydate", "permanent-lock"); + } + + // update datetime pickers, disable checkboxes if dates are required by + // recurrence or reminders. + updateRepeat(true); + updateReminder(true); + updateAllDay(); + } + + // Make sure capabilities are reflected correctly + updateCapabilities(); +} + +/** + * Opens the recurrence dialog modally to allow the user to edit the recurrence + * rules. + */ +function editRepeat() { + let args = {}; + args.calendarEvent = window.calendarItem; + args.recurrenceInfo = window.recurrenceInfo; + args.startTime = gStartTime; + args.endTime = gEndTime; + + let savedWindow = window; + args.onOk = function (recurrenceInfo) { + savedWindow.recurrenceInfo = recurrenceInfo; + }; + + window.setCursor("wait"); + + // open the dialog modally + openDialog( + "chrome://calendar/content/calendar-event-dialog-recurrence.xhtml", + "_blank", + "chrome,titlebar,modal,resizable,centerscreen", + args + ); +} + +/** + * This function is responsible for propagating UI state to controls + * depending on the repeat setting of an item. This functionality is used + * after the dialog has been loaded as well as if the repeat pattern has + * been changed. + * + * @param aSuppressDialogs If true, controls are updated without prompting + * for changes with the recurrence dialog + * @param aItemRepeatCall True when the function is being called from + * the item-repeat menu list. It allows to detect + * a change from the "custom" option. + */ +function updateRepeat(aSuppressDialogs, aItemRepeatCall) { + function setUpEntrydateForTask(item) { + // if this item is a task, we need to make sure that it has + // an entry-date, otherwise we can't create a recurrence. + if (item.isTodo()) { + // automatically check 'has entrydate' if needed. + if (!document.getElementById("todo-has-entrydate").checked) { + document.getElementById("todo-has-entrydate").checked = true; + + // make sure gStartTime is properly initialized + updateEntryDate(); + } + + // disable the checkbox to indicate that we need + // the entry-date. the 'disabled' state will be + // revoked if the user turns off the repeat pattern. + disableElementWithLock("todo-has-entrydate", "repeat-lock"); + } + } + + let repeatMenu = document.getElementById("item-repeat"); + let repeatValue = repeatMenu.selectedItem.getAttribute("value"); + let repeatUntilDate = document.getElementById("repeat-untilDate"); + let repeatDetails = document.getElementById("repeat-details"); + + if (repeatValue == "none") { + repeatUntilDate.hidden = true; + repeatDetails.hidden = true; + window.recurrenceInfo = null; + let item = window.calendarItem; + if (item.isTodo()) { + enableElementWithLock("todo-has-entrydate", "repeat-lock"); + } + } else if (repeatValue == "custom") { + // the user selected custom repeat pattern. we now need to bring + // up the appropriate dialog in order to let the user specify the + // new rule. First of all, retrieve the item we want to specify + // the custom repeat pattern for. + let item = window.calendarItem; + + setUpEntrydateForTask(item); + + // retrieve the current recurrence info, we need this + // to find out whether or not the user really created + // a new repeat pattern. + let recurrenceInfo = window.recurrenceInfo; + + // now bring up the recurrence dialog. + // don't pop up the dialog if aSuppressDialogs was specified or if + // called during initialization of the dialog. + if (!aSuppressDialogs && repeatMenu.hasAttribute("last-value")) { + editRepeat(); + } + + // Assign gUntilDate on the first run or when returning from the + // edit recurrence dialog. + if (window.recurrenceInfo) { + let rrules = splitRecurrenceRules(window.recurrenceInfo); + let rule = rrules[0][0]; + gUntilDate = null; + if (!rule.isByCount && rule.isFinite && rule.untilDate) { + gUntilDate = rule.untilDate.clone().getInTimezone(cal.dtz.defaultTimezone); + } + } + + // we need to address two separate cases here. + // 1)- We need to revoke the selection of the repeat + // drop down list in case the user didn't specify + // a new repeat pattern (i.e. canceled the dialog); + // - re-enable the 'has entrydate' option in case + // we didn't end up with a recurrence rule. + // 2) Check whether the new recurrence rule needs the + // recurrence details text or it can be displayed + // only with the repeat-until-datepicker. + if (recurrenceInfo == window.recurrenceInfo) { + repeatMenu.selectedIndex = gLastRepeatSelection; + if (item.isTodo()) { + if (!window.recurrenceInfo) { + enableElementWithLock("todo-has-entrydate", "repeat-lock"); + } + } + } else { + repeatUntilDate.hidden = true; + repeatDetails.hidden = false; + // From the Edit Recurrence dialog, the rules "every day" and + // "every weekday" don't need the recurrence details text when they + // have only the until date. The getRepeatTypeAndUntilDate() + // function verifies whether this is the case. + let [repeatType, untilDate] = getRepeatTypeAndUntilDate(item); + loadRepeat(repeatType, untilDate, window.calendarItem); + } + } else { + let item = window.calendarItem; + let recurrenceInfo = window.recurrenceInfo || item.recurrenceInfo; + let proposedUntilDate = (gStartTime || window.initialStartDateValue).clone(); + + if (recurrenceInfo) { + recurrenceInfo = recurrenceInfo.clone(); + let rrules = splitRecurrenceRules(recurrenceInfo); + let rule = rrules[0][0]; + + // If the previous rule was "custom" we have to recover the until + // date, or the last occurrence's date in order to set the + // repeat-until-datepicker with the same date. + if (aItemRepeatCall && repeatUntilDate.hidden && !repeatDetails.hidden) { + let repeatDate; + if (!rule.isByCount || !rule.isFinite) { + if (rule.isFinite) { + repeatDate = rule.untilDate.getInTimezone(cal.dtz.floating); + repeatDate = cal.dtz.dateTimeToJsDate(repeatDate); + } else { + repeatDate = "forever"; + } + } else { + // Try to recover the last occurrence in 10(?) years. + let endDate = gStartTime.clone(); + endDate.year += 10; + let lastOccurrenceDate = null; + let dates = recurrenceInfo.getOccurrenceDates(gStartTime, endDate, 0); + if (dates) { + lastOccurrenceDate = dates[dates.length - 1]; + } + repeatDate = (lastOccurrenceDate || proposedUntilDate).getInTimezone(cal.dtz.floating); + repeatDate = cal.dtz.dateTimeToJsDate(repeatDate); + } + document.getElementById("repeat-until-datepicker").value = repeatDate; + } + if (rrules[0].length > 0) { + recurrenceInfo.deleteRecurrenceItem(rule); + } + } else { + // New event proposes "forever" as default until date. + recurrenceInfo = new CalRecurrenceInfo(item); + document.getElementById("repeat-until-datepicker").value = "forever"; + } + + repeatUntilDate.hidden = false; + repeatDetails.hidden = true; + + let recRule = cal.createRecurrenceRule(); + recRule.interval = 1; + switch (repeatValue) { + case "daily": + recRule.type = "DAILY"; + break; + case "weekly": + recRule.type = "WEEKLY"; + break; + case "every.weekday": + recRule.type = "DAILY"; + recRule.setComponent("BYDAY", [2, 3, 4, 5, 6]); + break; + case "bi.weekly": + recRule.type = "WEEKLY"; + recRule.interval = 2; + break; + case "monthly": + recRule.type = "MONTHLY"; + break; + case "yearly": + recRule.type = "YEARLY"; + break; + } + + setUpEntrydateForTask(item); + updateUntildateRecRule(recRule); + + recurrenceInfo.insertRecurrenceItemAt(recRule, 0); + window.recurrenceInfo = recurrenceInfo; + + if (item.isTodo()) { + if (!document.getElementById("todo-has-entrydate").checked) { + document.getElementById("todo-has-entrydate").checked = true; + } + disableElementWithLock("todo-has-entrydate", "repeat-lock"); + } + + // Preset the until-datepicker's minimonth to the start date. + let startDate = cal.dtz.dateTimeToJsDate(gStartTime.getInTimezone(cal.dtz.floating)); + document.getElementById("repeat-until-datepicker").extraDate = startDate; + } + + gLastRepeatSelection = repeatMenu.selectedIndex; + repeatMenu.setAttribute("last-value", repeatValue); + + updateRepeatDetails(); + updateEntryDate(); + updateDueDate(); + updateAccept(); +} + +/** + * Update the until date in the recurrence rule in order to set + * the same time of the start date. + * + * @param recRule (optional) The recurrence rule + */ +function updateUntildateRecRule(recRule) { + if (!recRule) { + let recurrenceInfo = window.recurrenceInfo; + if (!recurrenceInfo) { + return; + } + let rrules = splitRecurrenceRules(recurrenceInfo); + recRule = rrules[0][0]; + } + let defaultTimezone = cal.dtz.defaultTimezone; + let repeatUntilDate = null; + + let itemRepeat = document.getElementById("item-repeat").selectedItem.value; + if (itemRepeat == "none") { + return; + } else if (itemRepeat == "custom") { + repeatUntilDate = gUntilDate; + } else { + let untilDatepickerDate = document.getElementById("repeat-until-datepicker").value; + if (untilDatepickerDate != "forever") { + repeatUntilDate = cal.dtz.jsDateToDateTime(untilDatepickerDate, defaultTimezone); + } + } + + if (repeatUntilDate) { + if (onLoad.hasLoaded) { + repeatUntilDate.isDate = gStartTime.isDate; // Enforce same value type as DTSTART + if (!gStartTime.isDate) { + repeatUntilDate.hour = gStartTime.hour; + repeatUntilDate.minute = gStartTime.minute; + repeatUntilDate.second = gStartTime.second; + } + } + recRule.untilDate = repeatUntilDate.clone(); + gUntilDate = repeatUntilDate.clone().getInTimezone(defaultTimezone); + } else { + // Rule that recurs forever or with a "count" number of recurrences. + gUntilDate = null; + } +} + +/** + * Updates the UI controls related to a task's completion status. + * + * @param {string} aStatus - The item's completion status or a string + * that allows to identify a change in the + * percent-complete's textbox. + * @param {Date} aCompletedDate - The item's completed date (as a JSDate). + */ +function updateToDoStatus(aStatus, aCompletedDate = null) { + // RFC2445 doesn't support completedDates without the todo's status + // being "COMPLETED", however twiddling the status menulist shouldn't + // destroy that information at this point (in case you change status + // back to COMPLETED). When we go to store this VTODO as .ics the + // date will get lost. + + // remember the original values + let oldPercentComplete = parseInt(document.getElementById("percent-complete-textbox").value, 10); + let oldCompletedDate = document.getElementById("completed-date-picker").value; + + // If the percent completed has changed to 100 or from 100 to another + // value, the status must change. + if (aStatus == "percent-changed") { + let selectedIndex = document.getElementById("todo-status").selectedIndex; + let menuItemCompleted = selectedIndex == 3; + let menuItemNotSpecified = selectedIndex == 0; + if (oldPercentComplete == 100) { + aStatus = "COMPLETED"; + } else if (menuItemCompleted || menuItemNotSpecified) { + aStatus = "IN-PROCESS"; + } + } + + switch (aStatus) { + case null: + case "": + case "NONE": + oldPercentComplete = 0; + document.getElementById("todo-status").selectedIndex = 0; + document.getElementById("percent-complete-textbox").setAttribute("disabled", "true"); + document.getElementById("percent-complete-label").setAttribute("disabled", "true"); + break; + case "CANCELLED": + document.getElementById("todo-status").selectedIndex = 4; + document.getElementById("percent-complete-textbox").setAttribute("disabled", "true"); + document.getElementById("percent-complete-label").setAttribute("disabled", "true"); + break; + case "COMPLETED": + document.getElementById("todo-status").selectedIndex = 3; + document.getElementById("percent-complete-textbox").removeAttribute("disabled"); + document.getElementById("percent-complete-label").removeAttribute("disabled"); + // if there is no aCompletedDate, set it to the previous value + if (!aCompletedDate) { + aCompletedDate = oldCompletedDate; + } + break; + case "IN-PROCESS": + document.getElementById("todo-status").selectedIndex = 2; + document.getElementById("completed-date-picker").setAttribute("disabled", "true"); + document.getElementById("percent-complete-textbox").removeAttribute("disabled"); + document.getElementById("percent-complete-label").removeAttribute("disabled"); + break; + case "NEEDS-ACTION": + document.getElementById("todo-status").selectedIndex = 1; + document.getElementById("percent-complete-textbox").removeAttribute("disabled"); + document.getElementById("percent-complete-label").removeAttribute("disabled"); + break; + } + + let newPercentComplete; + if ((aStatus == "IN-PROCESS" || aStatus == "NEEDS-ACTION") && oldPercentComplete == 100) { + newPercentComplete = 0; + document.getElementById("completed-date-picker").value = oldCompletedDate; + document.getElementById("completed-date-picker").setAttribute("disabled", "true"); + } else if (aStatus == "COMPLETED") { + newPercentComplete = 100; + document.getElementById("completed-date-picker").value = aCompletedDate; + document.getElementById("completed-date-picker").removeAttribute("disabled"); + } else { + newPercentComplete = oldPercentComplete; + document.getElementById("completed-date-picker").value = oldCompletedDate; + document.getElementById("completed-date-picker").setAttribute("disabled", "true"); + } + + gConfig.percentComplete = newPercentComplete; + document.getElementById("percent-complete-textbox").value = newPercentComplete; + if (gInTab) { + sendMessage({ + command: "updateConfigState", + argument: { percentComplete: newPercentComplete }, + }); + } +} + +/** + * Saves all dialog controls back to the item. + * + * @returns a copy of the original item with changes made. + */ +function saveItem() { + // we need to clone the item in order to apply the changes. + // it is important to not apply the changes to the original item + // (even if it happens to be mutable) in order to guarantee + // that providers see a proper oldItem/newItem pair in case + // they rely on this fact (e.g. WCAP does). + let originalItem = window.calendarItem; + let item = originalItem.clone(); + + // override item's recurrenceInfo *before* serializing date/time-objects. + if (!item.recurrenceId) { + item.recurrenceInfo = window.recurrenceInfo; + } + + // serialize the item + saveDialog(item); + + item.organizer = window.organizer; + + item.removeAllAttendees(); + if (window.attendees && window.attendees.length > 0) { + for (let attendee of window.attendees) { + item.addAttendee(attendee); + } + + let notifyCheckbox = document.getElementById("notify-attendees-checkbox"); + if (notifyCheckbox.disabled) { + item.deleteProperty("X-MOZ-SEND-INVITATIONS"); + } else { + item.setProperty("X-MOZ-SEND-INVITATIONS", notifyCheckbox.checked ? "TRUE" : "FALSE"); + } + let undiscloseCheckbox = document.getElementById("undisclose-attendees-checkbox"); + if (undiscloseCheckbox.disabled) { + item.deleteProperty("X-MOZ-SEND-INVITATIONS-UNDISCLOSED"); + } else { + item.setProperty( + "X-MOZ-SEND-INVITATIONS-UNDISCLOSED", + undiscloseCheckbox.checked ? "TRUE" : "FALSE" + ); + } + let disallowcounterCheckbox = document.getElementById("disallow-counter-checkbox"); + let xProp = window.calendarItem.getProperty("X-MICROSOFT-DISALLOW-COUNTER"); + // we want to leave an existing x-prop in case the checkbox is disabled as we need to + // roundtrip x-props that are not exclusively under our control + if (!disallowcounterCheckbox.disabled) { + // we only set the prop if we need to + if (disallowcounterCheckbox.checked) { + item.setProperty("X-MICROSOFT-DISALLOW-COUNTER", "TRUE"); + } else if (xProp) { + item.setProperty("X-MICROSOFT-DISALLOW-COUNTER", "FALSE"); + } + } + } + + // We check if the organizerID is different from our + // calendar-user-address-set. The organzerID is the owner of the calendar. + // If it's different, that is because someone is acting on behalf of + // the organizer. + if (item.organizer && item.calendar.aclEntry) { + let userAddresses = item.calendar.aclEntry.getUserAddresses(); + if ( + userAddresses.length > 0 && + !cal.email.attendeeMatchesAddresses(item.organizer, userAddresses) + ) { + let organizer = item.organizer.clone(); + organizer.setProperty("SENT-BY", "mailto:" + userAddresses[0]); + item.organizer = organizer; + } + } + return item; +} + +/** + * Action to take when the user chooses to save. This can happen either by + * saving directly or the user selecting to save after being prompted when + * closing the dialog. + * + * This function also takes care of notifying this dialog's caller that the item + * is saved. + * + * @param aIsClosing If true, the save action originates from the + * save prompt just before the window is closing. + */ +function onCommandSave(aIsClosing) { + // The datepickers need to remove the focus in order to trigger the + // validation of the values just edited, with the keyboard, but not yet + // confirmed (i.e. not followed by a click, a tab or enter keys pressure). + document.documentElement.focus(); + + // Don't save if a warning dialog about a wrong input date must be showed. + if (gWarning) { + return; + } + + eventDialogCalendarObserver.cancel(); + + let originalItem = window.calendarItem; + let item = saveItem(); + let calendar = getCurrentCalendar(); + adaptScheduleAgent(item); + + item.makeImmutable(); + // Set the item for now, the callback below will set the full item when the + // call succeeded + window.calendarItem = item; + + // When the call is complete, we need to set the new item, so that the + // dialog is up to date. + + // XXX Do we want to disable the dialog or at least the save button until + // the call is complete? This might help when the user tries to save twice + // before the call is complete. In that case, we do need a progress bar and + // the ability to cancel the operation though. + let listener = { + onTransactionComplete(aItem) { + let aId = aItem.id; + let aCalendar = aItem.calendar; + // Check if the current window has a calendarItem first, because in case of undo + // window refers to the main window and we would get a 'calendarItem is undefined' warning. + if (!aIsClosing && "calendarItem" in window) { + // If we changed the calendar of the item, onOperationComplete will be called multiple + // times. We need to make sure we're receiving the update on the right calendar. + if ( + (!window.calendarItem.id || aId == window.calendarItem.id) && + aCalendar.id == window.calendarItem.calendar.id + ) { + if (window.calendarItem.recurrenceId) { + // TODO This workaround needs to be removed in bug 396182 + // We are editing an occurrence. Make sure that the returned + // item is the same occurrence, not its parent item. + let occ = aItem.recurrenceInfo.getOccurrenceFor(window.calendarItem.recurrenceId); + window.calendarItem = occ; + } else { + // We are editing the parent item, no workarounds needed + window.calendarItem = aItem; + } + + // We now have an item, so we must change to an edit. + window.mode = "modify"; + updateTitle(); + eventDialogCalendarObserver.observe(window.calendarItem.calendar); + } + } + // this triggers the update of the imipbar in case this is a rescheduling case + if (window.counterProposal && window.counterProposal.onReschedule) { + window.counterProposal.onReschedule(); + } + }, + onGetResult(calendarItem, status, itemType, detail, items) {}, + }; + let resp = document.getElementById("notify-attendees-checkbox").checked + ? Ci.calIItipItem.AUTO + : Ci.calIItipItem.NONE; + let extResponse = { responseMode: resp }; + window.onAcceptCallback(item, calendar, originalItem, listener, extResponse); +} + +/** + * This function is called when the user chooses to delete an Item + * from the Event/Task dialog + * + */ +function onCommandDeleteItem() { + // only ask for confirmation, if the User changed anything on a new item or we modify an existing item + if (isItemChanged() || window.mode != "new") { + if (!cal.window.promptDeleteItems(window.calendarItem, true)) { + return; + } + } + + if (window.mode == "new") { + cancelItem(); + } else { + let deleteListener = { + // when deletion of item is complete, close the dialog + onTransactionComplete(item) { + // Check if the current window has a calendarItem first, because in case of undo + // window refers to the main window and we would get a 'calendarItem is undefined' warning. + if ("calendarItem" in window) { + if (item.id == window.calendarItem.id) { + cancelItem(); + } else { + eventDialogCalendarObserver.observe(window.calendarItem.calendar); + } + } + }, + }; + + eventDialogCalendarObserver.cancel(); + if (window.calendarItem.parentItem.recurrenceInfo && window.calendarItem.recurrenceId) { + // if this is a single occurrence of a recurring item + if (countOccurrences(window.calendarItem) == 1) { + // this is the last occurrence, hence we delete the parent item + // to not leave a parent item without children in the calendar + gMainWindow.doTransaction( + "delete", + window.calendarItem.parentItem, + window.calendarItem.calendar, + null, + deleteListener + ); + } else { + // we just need to remove the occurrence + let newItem = window.calendarItem.parentItem.clone(); + newItem.recurrenceInfo.removeOccurrenceAt(window.calendarItem.recurrenceId); + gMainWindow.doTransaction( + "modify", + newItem, + newItem.calendar, + window.calendarItem.parentItem, + deleteListener + ); + } + } else { + gMainWindow.doTransaction( + "delete", + window.calendarItem, + window.calendarItem.calendar, + null, + deleteListener + ); + } + } +} + +/** + * Postpone the task's start date/time and due date/time. ISO 8601 + * format: "PT1H", "P1D", and "P1W" are 1 hour, 1 day, and 1 week. (We + * use this format intentionally instead of a calIDuration object because + * those objects cannot be serialized for message passing with iframes.) + * + * @param {string} aDuration - A duration in ISO 8601 format + */ +function postponeTask(aDuration) { + let duration = cal.createDuration(aDuration); + if (gStartTime != null) { + gStartTime.addDuration(duration); + } + if (gEndTime != null) { + gEndTime.addDuration(duration); + } + updateDateTime(); +} + +/** + * Prompts the user to change the start timezone. + */ +function editStartTimezone() { + editTimezone( + "timezone-starttime", + gStartTime.getInTimezone(gStartTimezone), + editStartTimezone.complete + ); +} +editStartTimezone.complete = function (datetime) { + let equalTimezones = false; + if (gStartTimezone && gEndTimezone) { + if (gStartTimezone == gEndTimezone) { + equalTimezones = true; + } + } + gStartTimezone = datetime.timezone; + if (equalTimezones) { + gEndTimezone = datetime.timezone; + } + updateDateTime(); +}; + +/** + * Prompts the user to change the end timezone. + */ +function editEndTimezone() { + editTimezone("timezone-endtime", gEndTime.getInTimezone(gEndTimezone), editEndTimezone.complete); +} +editEndTimezone.complete = function (datetime) { + gEndTimezone = datetime.timezone; + updateDateTime(); +}; + +/** + * Called to choose a recent timezone from the timezone popup. + * + * @param event The event with a target that holds the timezone id value. + */ +function chooseRecentTimezone(event) { + let tzid = event.target.value; + let timezonePopup = document.getElementById("timezone-popup"); + + if (tzid != "custom") { + let zone = cal.timezoneService.getTimezone(tzid); + let datetime = timezonePopup.dateTime.getInTimezone(zone); + timezonePopup.editTimezone.complete(datetime); + } +} + +/** + * Opens the timezone popup on the node the event target points at. + * + * @param event The event causing the popup to open + * @param dateTime The datetime for which the timezone should be modified + * @param editFunc The function to be called when the custom menuitem is clicked. + */ +function showTimezonePopup(event, dateTime, editFunc) { + // Don't do anything for right/middle-clicks. Also, don't show the popup if + // the opening node is disabled. + if (event.button != 0 || event.target.disabled) { + return; + } + + let timezonePopup = document.getElementById("timezone-popup"); + let timezoneDefaultItem = document.getElementById("timezone-popup-defaulttz"); + let timezoneSeparator = document.getElementById("timezone-popup-menuseparator"); + let defaultTimezone = cal.dtz.defaultTimezone; + let recentTimezones = cal.dtz.getRecentTimezones(true); + + // Set up the right editTimezone function, so the custom item can use it. + timezonePopup.editTimezone = editFunc; + timezonePopup.dateTime = dateTime; + + // Set up the default timezone item + timezoneDefaultItem.value = defaultTimezone.tzid; + timezoneDefaultItem.label = defaultTimezone.displayName; + + // Clear out any old recent timezones + while (timezoneDefaultItem.nextElementSibling != timezoneSeparator) { + timezoneDefaultItem.nextElementSibling.remove(); + } + + // Fill in the new recent timezones + for (let timezone of recentTimezones) { + let menuItem = document.createXULElement("menuitem"); + menuItem.setAttribute("value", timezone.tzid); + menuItem.setAttribute("label", timezone.displayName); + timezonePopup.insertBefore(menuItem, timezoneDefaultItem.nextElementSibling); + } + + // Show the popup + timezonePopup.openPopup(event.target, "after_start", 0, 0, true); +} + +/** + * Common function of edit(Start|End)Timezone() to prompt the user for a + * timezone change. + * + * @param aElementId The XUL element id of the timezone label. + * @param aDateTime The Date/Time of the time to change zone on. + * @param aCallback What to do when the user has chosen a zone. + */ +function editTimezone(aElementId, aDateTime, aCallback) { + if (document.getElementById(aElementId).hasAttribute("disabled")) { + return; + } + + // prepare the arguments that will be passed to the dialog + let args = {}; + args.time = aDateTime; + args.calendar = getCurrentCalendar(); + args.onOk = function (datetime) { + cal.dtz.saveRecentTimezone(datetime.timezone.tzid); + return aCallback(datetime); + }; + + // open the dialog modally + openDialog( + "chrome://calendar/content/calendar-event-dialog-timezone.xhtml", + "_blank", + "chrome,titlebar,modal,resizable,centerscreen", + args + ); +} + +/** + * This function initializes the following controls: + * - 'event-starttime' + * - 'event-endtime' + * - 'event-all-day' + * - 'todo-has-entrydate' + * - 'todo-entrydate' + * - 'todo-has-duedate' + * - 'todo-duedate' + * The date/time-objects are either displayed in their respective + * timezone or in the default timezone. This decision is based + * on whether or not 'cmd_timezone' is checked. + * the necessary information is taken from the following variables: + * - 'gStartTime' + * - 'gEndTime' + * - 'window.calendarItem' (used to decide about event/task) + */ +function updateDateTime() { + gIgnoreUpdate = true; + + let item = window.calendarItem; + // Convert to default timezone if the timezone option + // is *not* checked, otherwise keep the specific timezone + // and display the labels in order to modify the timezone. + if (gTimezonesEnabled) { + if (item.isEvent()) { + let startTime = gStartTime.getInTimezone(gStartTimezone); + let endTime = gEndTime.getInTimezone(gEndTimezone); + + document.getElementById("event-all-day").checked = startTime.isDate; + + // In the case where the timezones are different but + // the timezone of the endtime is "UTC", we convert + // the endtime into the timezone of the starttime. + if (startTime && endTime) { + if (!cal.data.compareObjects(startTime.timezone, endTime.timezone)) { + if (endTime.timezone.isUTC) { + endTime = endTime.getInTimezone(startTime.timezone); + } + } + } + + // before feeding the date/time value into the control we need + // to set the timezone to 'floating' in order to avoid the + // automatic conversion back into the OS timezone. + startTime.timezone = cal.dtz.floating; + endTime.timezone = cal.dtz.floating; + + document.getElementById("event-starttime").value = cal.dtz.dateTimeToJsDate(startTime); + document.getElementById("event-endtime").value = cal.dtz.dateTimeToJsDate(endTime); + } + + if (item.isTodo()) { + let startTime = gStartTime && gStartTime.getInTimezone(gStartTimezone); + let endTime = gEndTime && gEndTime.getInTimezone(gEndTimezone); + let hasEntryDate = startTime != null; + let hasDueDate = endTime != null; + + if (hasEntryDate && hasDueDate) { + document.getElementById("todo-has-entrydate").checked = hasEntryDate; + startTime.timezone = cal.dtz.floating; + document.getElementById("todo-entrydate").value = cal.dtz.dateTimeToJsDate(startTime); + + document.getElementById("todo-has-duedate").checked = hasDueDate; + endTime.timezone = cal.dtz.floating; + document.getElementById("todo-duedate").value = cal.dtz.dateTimeToJsDate(endTime); + } else if (hasEntryDate) { + document.getElementById("todo-has-entrydate").checked = hasEntryDate; + startTime.timezone = cal.dtz.floating; + document.getElementById("todo-entrydate").value = cal.dtz.dateTimeToJsDate(startTime); + + startTime.timezone = cal.dtz.floating; + document.getElementById("todo-duedate").value = cal.dtz.dateTimeToJsDate(startTime); + } else if (hasDueDate) { + endTime.timezone = cal.dtz.floating; + document.getElementById("todo-entrydate").value = cal.dtz.dateTimeToJsDate(endTime); + + document.getElementById("todo-has-duedate").checked = hasDueDate; + endTime.timezone = cal.dtz.floating; + document.getElementById("todo-duedate").value = cal.dtz.dateTimeToJsDate(endTime); + } else { + startTime = window.initialStartDateValue; + startTime.timezone = cal.dtz.floating; + endTime = startTime.clone(); + + document.getElementById("todo-entrydate").value = cal.dtz.dateTimeToJsDate(startTime); + document.getElementById("todo-duedate").value = cal.dtz.dateTimeToJsDate(endTime); + } + } + } else { + let kDefaultTimezone = cal.dtz.defaultTimezone; + + if (item.isEvent()) { + let startTime = gStartTime.getInTimezone(kDefaultTimezone); + let endTime = gEndTime.getInTimezone(kDefaultTimezone); + document.getElementById("event-all-day").checked = startTime.isDate; + + // before feeding the date/time value into the control we need + // to set the timezone to 'floating' in order to avoid the + // automatic conversion back into the OS timezone. + startTime.timezone = cal.dtz.floating; + endTime.timezone = cal.dtz.floating; + document.getElementById("event-starttime").value = cal.dtz.dateTimeToJsDate(startTime); + document.getElementById("event-endtime").value = cal.dtz.dateTimeToJsDate(endTime); + } + + if (item.isTodo()) { + let startTime = gStartTime && gStartTime.getInTimezone(kDefaultTimezone); + let endTime = gEndTime && gEndTime.getInTimezone(kDefaultTimezone); + let hasEntryDate = startTime != null; + let hasDueDate = endTime != null; + + if (hasEntryDate && hasDueDate) { + document.getElementById("todo-has-entrydate").checked = hasEntryDate; + startTime.timezone = cal.dtz.floating; + document.getElementById("todo-entrydate").value = cal.dtz.dateTimeToJsDate(startTime); + + document.getElementById("todo-has-duedate").checked = hasDueDate; + endTime.timezone = cal.dtz.floating; + document.getElementById("todo-duedate").value = cal.dtz.dateTimeToJsDate(endTime); + } else if (hasEntryDate) { + document.getElementById("todo-has-entrydate").checked = hasEntryDate; + startTime.timezone = cal.dtz.floating; + document.getElementById("todo-entrydate").value = cal.dtz.dateTimeToJsDate(startTime); + + startTime.timezone = cal.dtz.floating; + document.getElementById("todo-duedate").value = cal.dtz.dateTimeToJsDate(startTime); + } else if (hasDueDate) { + endTime.timezone = cal.dtz.floating; + document.getElementById("todo-entrydate").value = cal.dtz.dateTimeToJsDate(endTime); + + document.getElementById("todo-has-duedate").checked = hasDueDate; + endTime.timezone = cal.dtz.floating; + document.getElementById("todo-duedate").value = cal.dtz.dateTimeToJsDate(endTime); + } else { + startTime = window.initialStartDateValue; + startTime.timezone = cal.dtz.floating; + endTime = startTime.clone(); + + document.getElementById("todo-entrydate").value = cal.dtz.dateTimeToJsDate(startTime); + document.getElementById("todo-duedate").value = cal.dtz.dateTimeToJsDate(endTime); + } + } + } + + updateTimezone(); + updateAllDay(); + updateRepeatDetails(); + + gIgnoreUpdate = false; +} + +/** + * This function initializes the following controls: + * - 'timezone-starttime' + * - 'timezone-endtime' + * the timezone-links show the corrosponding names of the + * start/end times. If 'cmd_timezone' is not checked + * the links will be collapsed. + */ +function updateTimezone() { + function updateTimezoneElement(aTimezone, aId, aDateTime) { + let element = document.getElementById(aId); + if (!element) { + return; + } + + if (aTimezone) { + element.removeAttribute("collapsed"); + element.value = aTimezone.displayName || aTimezone.tzid; + if (!aDateTime || !aDateTime.isValid || gIsReadOnly || aDateTime.isDate) { + if (element.hasAttribute("class")) { + element.setAttribute("class-on-enabled", element.getAttribute("class")); + element.removeAttribute("class"); + } + if (element.hasAttribute("onclick")) { + element.setAttribute("onclick-on-enabled", element.getAttribute("onclick")); + element.removeAttribute("onclick"); + } + element.setAttribute("disabled", "true"); + } else { + if (element.hasAttribute("class-on-enabled")) { + element.setAttribute("class", element.getAttribute("class-on-enabled")); + element.removeAttribute("class-on-enabled"); + } + if (element.hasAttribute("onclick-on-enabled")) { + element.setAttribute("onclick", element.getAttribute("onclick-on-enabled")); + element.removeAttribute("onclick-on-enabled"); + } + element.removeAttribute("disabled"); + } + } else { + element.setAttribute("collapsed", "true"); + } + } + + // convert to default timezone if the timezone option + // is *not* checked, otherwise keep the specific timezone + // and display the labels in order to modify the timezone. + if (gTimezonesEnabled) { + updateTimezoneElement(gStartTimezone, "timezone-starttime", gStartTime); + updateTimezoneElement(gEndTimezone, "timezone-endtime", gEndTime); + } else { + document.getElementById("timezone-starttime").setAttribute("collapsed", "true"); + document.getElementById("timezone-endtime").setAttribute("collapsed", "true"); + } +} + +/** + * Updates dialog controls related to item attachments + */ +function updateAttachment() { + let hasAttachments = capSupported("attachments"); + document.getElementById("cmd_attach_url").setAttribute("disabled", !hasAttachments); + + // update the attachment tab label to make the number of (uri) attachments visible + // even if another tab is displayed + let attachments = Object.values(gAttachMap).filter(aAtt => aAtt.uri); + let attachmentTab = document.getElementById("event-grid-tab-attachments"); + if (attachments.length) { + attachmentTab.label = cal.l10n.getString("calendar-event-dialog", "attachmentsTabLabel", [ + attachments.length, + ]); + } else { + attachmentTab.label = window.attachmentTabLabel; + } + + sendMessage({ + command: "updateConfigState", + argument: { attachUrlCommand: hasAttachments }, + }); +} + +/** + * Returns whether to show or hide the related link on the dialog + * (rfc2445 URL property). + * + * @param {string} aUrl - The url in question. + * @returns {boolean} true for show and false for hide + */ +function showOrHideItemURL(url) { + if (!url) { + return false; + } + let handler; + let uri; + try { + uri = Services.io.newURI(url); + handler = Services.io.getProtocolHandler(uri.scheme); + } catch (e) { + // No protocol handler for the given protocol, or invalid uri + // hideOrShow(false); + return false; + } + // Only show if its either an internal protocol handler, or its external + // and there is an external app for the scheme + handler = cal.wrapInstance(handler, Ci.nsIExternalProtocolHandler); + return !handler || handler.externalAppExistsForScheme(uri.scheme); +} + +/** + * Updates the related link on the dialog (rfc2445 URL property). + * + * @param {boolean} aShow - Show the link (true) or not (false) + * @param {string} aUrl - The url + */ +function updateItemURL(aShow, aUrl) { + // Hide or show the link + document.getElementById("event-grid-link-separator").toggleAttribute("hidden", !aShow); + document.getElementById("event-grid-link-row").toggleAttribute("hidden", !aShow); + + // Set the url for the link + if (aShow && aUrl.length) { + setTimeout(() => { + // HACK the url-link doesn't crop when setting the value in onLoad + let label = document.getElementById("url-link"); + label.setAttribute("value", aUrl); + label.setAttribute("href", aUrl); + }, 0); + } +} + +/** + * This function updates dialog controls related to attendees. + */ +function updateAttendeeInterface() { + // sending email invitations currently only supported for events + let attendeeTab = document.getElementById("event-grid-tab-attendees"); + let attendeePanel = document.getElementById("event-grid-tabpanel-attendees"); + let notifyOptions = document.getElementById("notify-options"); + if (window.calendarItem.isEvent()) { + attendeeTab.removeAttribute("collapsed"); + attendeePanel.removeAttribute("collapsed"); + notifyOptions.removeAttribute("collapsed"); + + let organizerRow = document.getElementById("item-organizer-row"); + if (window.organizer && window.organizer.id) { + let existingLabel = organizerRow.querySelector(":scope > .attendee-label"); + if (existingLabel) { + organizerRow.removeChild(existingLabel); + } + organizerRow.appendChild( + cal.invitation.createAttendeeLabel(document, window.organizer, window.attendees) + ); + organizerRow.hidden = false; + } else { + organizerRow.hidden = true; + } + + let attendeeContainer = document.querySelector(".item-attendees-list-container"); + if (attendeeContainer.firstChild) { + attendeeContainer.firstChild.remove(); + } + attendeeContainer.appendChild(cal.invitation.createAttendeesList(document, window.attendees)); + for (let label of attendeeContainer.querySelectorAll(".attendee-label")) { + label.addEventListener("dblclick", attendeeDblClick); + label.setAttribute("tabindex", "0"); + } + + // update the attendee tab label to make the number of attendees + // visible even if another tab is displayed + if (window.attendees.length) { + attendeeTab.label = cal.l10n.getString("calendar-event-dialog", "attendeesTabLabel", [ + window.attendees.length, + ]); + } else { + attendeeTab.label = window.attendeeTabLabel; + } + } else { + attendeeTab.setAttribute("collapsed", "true"); + attendeePanel.setAttribute("collapsed", "true"); + } + updateParentSaveControls(); +} + +/** + * Update the save controls in parent context depending on the whether attendees + * exist for this event and notifying is enabled + */ +function updateParentSaveControls() { + let mode = + window.calendarItem.isEvent() && + window.organizer && + window.organizer.id && + window.attendees && + window.attendees.length > 0 && + document.getElementById("notify-attendees-checkbox").checked; + + sendMessage({ + command: "updateSaveControls", + argument: { sendNotSave: mode }, + }); +} + +/** + * This function updates dialog controls related to recurrence, in this case the + * text describing the recurrence rule. + */ +function updateRepeatDetails() { + // Don't try to show the details text for + // anything but a custom recurrence rule. + let recurrenceInfo = window.recurrenceInfo; + let itemRepeat = document.getElementById("item-repeat"); + let repeatDetails = document.getElementById("repeat-details"); + if (itemRepeat.value == "custom" && recurrenceInfo && !hasUnsupported(recurrenceInfo)) { + let item = window.calendarItem; + document.getElementById("repeat-untilDate").hidden = true; + // Try to create a descriptive string from the rule(s). + let kDefaultTimezone = cal.dtz.defaultTimezone; + let event = item.isEvent(); + + let startDate = document.getElementById(event ? "event-starttime" : "todo-entrydate").value; + let endDate = document.getElementById(event ? "event-endtime" : "todo-duedate").value; + startDate = cal.dtz.jsDateToDateTime(startDate, kDefaultTimezone); + endDate = cal.dtz.jsDateToDateTime(endDate, kDefaultTimezone); + + let allDay = document.getElementById("event-all-day").checked; + let detailsString = recurrenceRule2String(recurrenceInfo, startDate, endDate, allDay); + + if (!detailsString) { + detailsString = cal.l10n.getString("calendar-event-dialog", "ruleTooComplex"); + } + repeatDetails.hidden = false; + + // Now display the string. + let lines = detailsString.split("\n"); + while (repeatDetails.children.length > lines.length) { + repeatDetails.lastChild.remove(); + } + let numChilds = repeatDetails.children.length; + for (let i = 0; i < lines.length; i++) { + if (i >= numChilds) { + let newNode = repeatDetails.children[0].cloneNode(true); + repeatDetails.appendChild(newNode); + } + repeatDetails.children[i].value = lines[i]; + repeatDetails.children[i].setAttribute("tooltiptext", detailsString); + } + } else { + repeatDetails.hidden = true; + } +} + +/** + * This function does not strictly check if the given attendee has the status + * TENTATIVE, but also if he hasn't responded. + * + * @param aAttendee The attendee to check. + * @returns True, if the attendee hasn't responded. + */ +function isAttendeeUndecided(aAttendee) { + return ( + aAttendee.participationStatus != "ACCEPTED" && + aAttendee.participationStatus != "DECLINED" && + aAttendee.participationStatus != "DELEGATED" + ); +} + +/** + * Event handler for dblclick on attendee items. + * + * @param aEvent The popupshowing event + */ +function attendeeDblClick(aEvent) { + // left mouse button + if (aEvent.button == 0) { + editAttendees(); + } +} + +/** + * Event handler to set up the attendee-popup. This builds the popup menuitems. + * + * @param aEvent The popupshowing event + */ +function setAttendeeContext(aEvent) { + if (window.attendees.length == 0) { + // we just need the option to open the attendee dialog in this case + let popup = document.getElementById("attendee-popup"); + let invite = document.getElementById("attendee-popup-invite-menuitem"); + for (let node of popup.children) { + if (node == invite) { + node.removeAttribute("hidden"); + } else { + node.setAttribute("hidden", "true"); + } + } + } else { + if (window.attendees.length > 1) { + let removeall = document.getElementById("attendee-popup-removeallattendees-menuitem"); + removeall.removeAttribute("hidden"); + } + document.getElementById("attendee-popup-sendemail-menuitem").removeAttribute("hidden"); + document.getElementById("attendee-popup-sendtentativeemail-menuitem").removeAttribute("hidden"); + document.getElementById("attendee-popup-first-separator").removeAttribute("hidden"); + + // setup attendee specific menu items if appropriate otherwise hide respective menu items + let mailto = document.getElementById("attendee-popup-emailattendee-menuitem"); + let remove = document.getElementById("attendee-popup-removeattendee-menuitem"); + let secondSeparator = document.getElementById("attendee-popup-second-separator"); + let attId = + aEvent.target.getAttribute("attendeeid") || + aEvent.target.parentNode.getAttribute("attendeeid"); + let attendee = window.attendees.find(aAtt => aAtt.id == attId); + if (attendee) { + mailto.removeAttribute("hidden"); + remove.removeAttribute("hidden"); + secondSeparator.removeAttribute("hidden"); + + mailto.setAttribute("label", attendee.toString()); + mailto.attendee = attendee; + remove.attendee = attendee; + } else { + mailto.setAttribute("hidden", "true"); + remove.setAttribute("hidden", "true"); + secondSeparator.setAttribute("hidden", "true"); + } + + if (window.attendees.some(isAttendeeUndecided)) { + document.getElementById("cmd_email_undecided").removeAttribute("disabled"); + } else { + document.getElementById("cmd_email_undecided").setAttribute("disabled", "true"); + } + } +} + +/** + * Removes the selected attendee from the window + * + * @param aAttendee + */ +function removeAttendee(aAttendee) { + if (aAttendee) { + window.attendees = window.attendees.filter(aAtt => aAtt != aAttendee); + updateAttendeeInterface(); + } +} + +/** + * Removes all attendees from the window + */ +function removeAllAttendees() { + window.attendees = []; + window.organizer = null; + updateAttendeeInterface(); +} + +/** + * Send Email to all attendees that haven't responded or are tentative. + * + * @param aAttendees The attendees to check. + */ +function sendMailToUndecidedAttendees(aAttendees) { + let targetAttendees = aAttendees.filter(isAttendeeUndecided); + sendMailToAttendees(targetAttendees); +} + +/** + * Send Email to all given attendees. + * + * @param aAttendees The attendees to send mail to. + */ +function sendMailToAttendees(aAttendees) { + let toList = cal.email.createRecipientList(aAttendees); + let item = saveItem(); + let emailSubject = cal.l10n.getString("calendar-event-dialog", "emailSubjectReply", [item.title]); + let identity = window.calendarItem.calendar.getProperty("imip.identity"); + cal.email.sendTo(toList, emailSubject, null, identity); +} + +/** + * Make sure all fields that may have calendar specific capabilities are updated + */ +function updateCapabilities() { + updateAttachment(); + updateConfigState({ + priority: gConfig.priority, + privacy: gConfig.privacy, + }); + updateReminderDetails( + document.querySelector(".reminder-details"), + document.querySelector(".item-alarm"), + getCurrentCalendar() + ); + updateCategoryMenulist(); +} + +/** + * find out if the User already changed values in the Dialog + * + * @return: true if the values in the Dialog have changed. False otherwise. + */ +function isItemChanged() { + let newItem = saveItem(); + let oldItem = window.calendarItem; + + if (newItem.calendar.id == oldItem.calendar.id && cal.item.compareContent(newItem, oldItem)) { + return false; + } + return true; +} + +/** + * Test if a specific capability is supported + * + * @param aCap The capability from "capabilities.<aCap>.supported" + */ +function capSupported(aCap) { + let calendar = getCurrentCalendar(); + return calendar.getProperty("capabilities." + aCap + ".supported") !== false; +} + +/** + * Return the values for a certain capability. + * + * @param aCap The capability from "capabilities.<aCap>.values" + * @returns The values for this capability + */ +function capValues(aCap, aDefault) { + let calendar = getCurrentCalendar(); + let vals = calendar.getProperty("capabilities." + aCap + ".values"); + return vals === null ? aDefault : vals; +} + +/** + * Checks the until date just entered in the datepicker in order to avoid + * setting a date earlier than the start date. + * Restores the previous correct date; sets the warning flag to prevent closing + * the dialog when the user enters a wrong until date. + */ +function checkUntilDate() { + let repeatUntilDate = document.getElementById("repeat-until-datepicker").value; + if (repeatUntilDate == "forever") { + updateRepeat(); + // "forever" is never earlier than another date. + return; + } + + // Check whether the date is valid. Set the correct time just in this case. + let untilDate = cal.dtz.jsDateToDateTime(repeatUntilDate, gStartTime.timezone); + let startDate = gStartTime.clone(); + startDate.isDate = true; + if (untilDate.compare(startDate) < 0) { + // Invalid date: restore the previous date. Since we are checking an + // until date, a null value for gUntilDate means repeat "forever". + document.getElementById("repeat-until-datepicker").value = gUntilDate + ? cal.dtz.dateTimeToJsDate(gUntilDate.getInTimezone(cal.dtz.floating)) + : "forever"; + gWarning = true; + let callback = function () { + // Disable the "Save" and "Save and Close" commands as long as the + // warning dialog is showed. + enableAcceptCommand(false); + + Services.prompt.alert( + null, + document.title, + cal.l10n.getCalString("warningUntilDateBeforeStart") + ); + enableAcceptCommand(true); + gWarning = false; + }; + setTimeout(callback, 1); + } else { + // Valid date: set the time equal to start date time. + gUntilDate = untilDate; + updateUntildateRecRule(); + } +} + +/** + * Displays a counterproposal if any + */ +function displayCounterProposal() { + if ( + !window.counterProposal || + !window.counterProposal.attendee || + !window.counterProposal.proposal + ) { + return; + } + + let propLabels = document.getElementById("counter-proposal-property-labels"); + let propValues = document.getElementById("counter-proposal-property-values"); + let idCounter = 0; + let comment; + + for (let proposal of window.counterProposal.proposal) { + if (proposal.property == "COMMENT") { + if (proposal.proposed && !proposal.original) { + comment = proposal.proposed; + } + } else { + let label = lookupCounterLabel(proposal); + let value = formatCounterValue(proposal); + if (label && value) { + // setup label node + let propLabel = propLabels.firstElementChild.cloneNode(false); + propLabel.id = propLabel.id + "-" + idCounter; + propLabel.control = propLabel.control + "-" + idCounter; + propLabel.removeAttribute("collapsed"); + propLabel.value = label; + // setup value node + let propValue = propValues.firstElementChild.cloneNode(false); + propValue.id = propLabel.control; + propValue.removeAttribute("collapsed"); + propValue.value = value; + // append nodes + propLabels.appendChild(propLabel); + propValues.appendChild(propValue); + idCounter++; + } + } + } + + let attendeeId = + window.counterProposal.attendee.CN || + cal.email.removeMailTo(window.counterProposal.attendee.id || ""); + let partStat = window.counterProposal.attendee.participationStatus; + if (partStat == "DECLINED") { + partStat = "counterSummaryDeclined"; + } else if (partStat == "TENTATIVE") { + partStat = "counterSummaryTentative"; + } else if (partStat == "ACCEPTED") { + partStat = "counterSummaryAccepted"; + } else if (partStat == "DELEGATED") { + partStat = "counterSummaryDelegated"; + } else if (partStat == "NEEDS-ACTION") { + partStat = "counterSummaryNeedsAction"; + } else { + cal.LOG("Unexpected partstat " + partStat + " detected."); + // we simply reset partStat not display the summary text of the counter box + // to avoid the window of death + partStat = null; + } + + if (idCounter > 0) { + if (partStat && attendeeId.length) { + document.getElementById("counter-proposal-summary").value = cal.l10n.getString( + "calendar-event-dialog", + partStat, + [attendeeId] + ); + document.getElementById("counter-proposal-summary").removeAttribute("collapsed"); + } + if (comment) { + document.getElementById("counter-proposal-comment").value = comment; + document.getElementById("counter-proposal-box").removeAttribute("collapsed"); + } + document.getElementById("counter-proposal-box").removeAttribute("collapsed"); + + if (window.counterProposal.oldVersion) { + // this is a counterproposal to a previous version of the event - we should notify the + // user accordingly + notifyUser( + "counterProposalOnPreviousVersion", + cal.l10n.getString("calendar-event-dialog", "counterOnPreviousVersionNotification"), + "warn" + ); + } + if (window.calendarItem.getProperty("X-MICROSOFT-DISALLOW-COUNTER") == "TRUE") { + // this is a counterproposal although the user disallowed countering when sending the + // invitation, so we notify the user accordingly + notifyUser( + "counterProposalOnCounteringDisallowed", + cal.l10n.getString("calendar-event-dialog", "counterOnCounterDisallowedNotification"), + "warn" + ); + } + } +} + +/** + * Get the property label to display for a counterproposal based on the respective label used in + * the dialog + * + * @param {JSObject} aProperty The property to check for a label + * @returns {string | null} The label to display or null if no such label + */ +function lookupCounterLabel(aProperty) { + let nodeIds = getPropertyMap(); + let labels = + nodeIds.has(aProperty.property) && + document.getElementsByAttribute("control", nodeIds.get(aProperty.property)); + let labelValue; + if (labels && labels.length) { + // as label control assignment should be unique, we can just take the first result + labelValue = labels[0].value; + } else { + cal.LOG( + "Unsupported property " + + aProperty.property + + " detected when setting up counter " + + "box labels." + ); + } + return labelValue; +} + +/** + * Get the property value to display for a counterproposal as currently supported + * + * @param {JSObject} aProperty The property to check for a label + * @returns {string | null} The value to display or null if the property is not supported + */ +function formatCounterValue(aProperty) { + const dateProps = ["DTSTART", "DTEND"]; + const stringProps = ["SUMMARY", "LOCATION"]; + + let val; + if (dateProps.includes(aProperty.property)) { + let localTime = aProperty.proposed.getInTimezone(cal.dtz.defaultTimezone); + val = cal.dtz.formatter.formatDateTime(localTime); + if (gTimezonesEnabled) { + let tzone = localTime.timezone.displayName || localTime.timezone.tzid; + val += " " + tzone; + } + } else if (stringProps.includes(aProperty.property)) { + val = aProperty.proposed; + } else { + cal.LOG( + "Unsupported property " + aProperty.property + " detected when setting up counter box values." + ); + } + return val; +} + +/** + * Get a map of property names and labels of currently supported properties + * + * @returns {Map} + */ +function getPropertyMap() { + let map = new Map(); + map.set("SUMMARY", "item-title"); + map.set("LOCATION", "item-location"); + map.set("DTSTART", "event-starttime"); + map.set("DTEND", "event-endtime"); + return map; +} + +/** + * Applies the proposal or original data to the respective dialog fields + * + * @param {string} aType Either 'proposed' or 'original' + */ +function applyValues(aType) { + if (!window.counterProposal || (aType != "proposed" && aType != "original")) { + return; + } + let originalBtn = document.getElementById("counter-original-btn"); + if (originalBtn.disabled) { + // The button is disabled when opening the dialog/tab, which makes it more obvious to the + // user that he/she needs to apply the proposal values prior to saving & sending. + // Once that happened, we leave both options to the user without toggling the button states + // to avoid needing to listen to manual changes to do that correctly + originalBtn.removeAttribute("disabled"); + } + let nodeIds = getPropertyMap(); + window.counterProposal.proposal.forEach(aProperty => { + if (aProperty.property != "COMMENT") { + let valueNode = + nodeIds.has(aProperty.property) && document.getElementById(nodeIds.get(aProperty.property)); + if (valueNode) { + if (["DTSTART", "DTEND"].includes(aProperty.property)) { + valueNode.value = cal.dtz.dateTimeToJsDate(aProperty[aType]); + } else { + valueNode.value = aProperty[aType]; + } + } + } + }); +} + +/** + * Opens the context menu for the editor element. + * + * Since its content is, well, content, its contextmenu event is + * eaten by the context menu actor before the element's default + * context menu processing. Since we know that the editor runs + * in the parent process, we can just listen directly to the event. + */ +function openEditorContextMenu(event) { + let popup = document.getElementById("editorContext"); + popup.openPopupAtScreen(event.screenX, event.screenY, true, event); + event.preventDefault(); +} + +// Thunderbird's dialog is mail-centric, but we just want a lightweight prompt. +function insertLink() { + let href = { value: "" }; + let editor = GetCurrentEditor(); + let existingLink = editor.getSelectedElement("href"); + if (existingLink) { + editor.selectElement(existingLink); + href.value = existingLink.getAttribute("href"); + } + let text = GetSelectionAsText().trim() || href.value || GetString("EmptyHREFError"); + let title = GetString("Link"); + if (Services.prompt.prompt(window, title, text, href, null, {})) { + if (!href.value) { + // Remove the link + EditorRemoveTextProperty("href", ""); + } else if (editor.selection.isCollapsed) { + // Insert a link with its href as the text + let link = editor.createElementWithDefaults("a"); + link.setAttribute("href", href.value); + link.textContent = href.value; + editor.insertElementAtSelection(link, false); + } else { + // Change the href of the selection + let link = editor.createElementWithDefaults("a"); + link.setAttribute("href", href.value); + editor.insertLinkAroundSelection(link); + } + } +} diff --git a/comm/calendar/base/content/item-editing/calendar-item-iframe.xhtml b/comm/calendar/base/content/item-editing/calendar-item-iframe.xhtml new file mode 100644 index 0000000000..60180a0e4b --- /dev/null +++ b/comm/calendar/base/content/item-editing/calendar-item-iframe.xhtml @@ -0,0 +1,1225 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- 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/. --> + +<!-- XXX some of these css files may not be needed here. --> +<?xml-stylesheet type="text/css" href="chrome://global/skin/global.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/calendar-alarms.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/widgets/minimonth.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/calendar-attendees.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/calendar-event-dialog.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/datetimepickers.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/primaryToolbar.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/messenger.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/contextMenu.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/shared/dialogs/calendar-event-dialog.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/themeableDialog.css"?> + +<!DOCTYPE html [ <!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd"> +<!ENTITY % globalDTD SYSTEM "chrome://calendar/locale/global.dtd"> +<!ENTITY % calendarDTD SYSTEM "chrome://calendar/locale/calendar.dtd"> +<!ENTITY % eventDialogDTD SYSTEM "chrome://calendar/locale/calendar-event-dialog.dtd"> +<!ENTITY % messengercomposeDTD SYSTEM "chrome://messenger/locale/messengercompose/messengercompose.dtd" > +<!ENTITY % editorOverlayDTD SYSTEM "chrome://messenger/locale/messengercompose/editorOverlay.dtd"> +%brandDTD; %globalDTD; %calendarDTD; %eventDialogDTD; %messengercomposeDTD; %editorOverlayDTD; ]> +<html + id="calendar-event-dialog-inner" + xmlns="http://www.w3.org/1999/xhtml" + lightweightthemes="true" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + scrolling="false" +> + <head> + <title></title> + <link rel="localization" href="toolkit/global/textActions.ftl" /> + <link rel="localization" href="calendar/calendar-editable-item.ftl" /> + <link rel="localization" href="calendar/calendar-widgets.ftl" /> + <script defer="defer" src="chrome://messenger/content/globalOverlay.js"></script> + <script defer="defer" src="chrome://global/content/editMenuOverlay.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-dialog-utils.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-ui-utils.js"></script> + <script defer="defer" src="chrome://calendar/content/calApplicationUtils.js"></script> + <script defer="defer" src="chrome://calendar/content/calendar-statusbar.js"></script> + <script defer="defer" src="chrome://calendar/content/widgets/calendar-minimonth.js"></script> + <script defer="defer" src="chrome://calendar/content/widgets/datetimepickers.js"></script> + <script defer="defer" src="chrome://messenger/content/messengercompose/editor.js"></script> + <script + defer="defer" + src="chrome://messenger/content/messengercompose/editorUtilities.js" + ></script> + <script + defer="defer" + src="chrome://messenger/content/messengercompose/ComposerCommands.js" + ></script> + <script defer="defer" src="chrome://calendar/content/calendar-item-iframe.js"></script> + </head> + <html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <commandset id=""> + <command id="cmd_recurrence" oncommand="editRepeat();" /> + <command id="cmd_attendees" oncommand="editAttendees();" /> + <command id="cmd_email" oncommand="sendMailToAttendees(window.attendees);" /> + <command + id="cmd_email_undecided" + oncommand="sendMailToUndecidedAttendees(window.attendees);" + /> + <command id="cmd_attach_url" disable-on-readonly="true" oncommand="attachURL()" /> + <command id="cmd_attach_cloud" disable-on-readonly="true" /> + <command id="cmd_openAttachment" oncommand="openAttachment()" /> + <command id="cmd_copyAttachment" oncommand="copyAttachment()" /> + <command + id="cmd_deleteAttachment" + disable-on-readonly="true" + oncommand="deleteAttachment()" + /> + <command + id="cmd_deleteAllAttachments" + disable-on-readonly="true" + oncommand="deleteAllAttachments()" + /> + <command + id="cmd_applyProposal" + disable-on-readonly="true" + oncommand="applyValues('proposed')" + /> + <command + id="cmd_applyOriginal" + disable-on-readonly="true" + oncommand="applyValues('original')" + /> + </commandset> + + <!-- style related commands that update on creation, and on selection change --> + <!-- not using commandupdater directly, as it has to listen to the parent --> + <commandset id="styleMenuItems" oncommandupdate="goUpdateComposerMenuItems(this)"> + <command id="cmd_renderedHTMLEnabler" disabled="true" /> + <command + id="cmd_bold" + state="false" + oncommand="doStyleUICommand('cmd_bold')" + disabled="true" + /> + <command + id="cmd_italic" + state="false" + oncommand="doStyleUICommand('cmd_italic')" + disabled="true" + /> + <command + id="cmd_underline" + state="false" + oncommand="doStyleUICommand('cmd_underline')" + disabled="true" + /> + + <command id="cmd_ul" state="false" oncommand="doStyleUICommand('cmd_ul')" disabled="true" /> + <command id="cmd_ol" state="false" oncommand="doStyleUICommand('cmd_ol')" disabled="true" /> + + <command id="cmd_indent" oncommand="goDoCommand('cmd_indent')" disabled="true" /> + <command id="cmd_outdent" oncommand="goDoCommand('cmd_outdent')" disabled="true" /> + + <command id="cmd_align" state="" disabled="true" /> + </commandset> + + <keyset id="editorKeys"> + <key id="boldkb" key="&styleBoldCmd.key;" observes="cmd_bold" modifiers="accel" /> + <key id="italickb" key="&styleItalicCmd.key;" observes="cmd_italic" modifiers="accel" /> + <key + id="underlinekb" + key="&styleUnderlineCmd.key;" + observes="cmd_underline" + modifiers="accel" + /> + <key + id="increaseindentkb" + key="&increaseIndent.key;" + observes="cmd_indent" + modifiers="accel" + /> + <key + id="decreaseindentkb" + key="&decreaseIndent.key;" + observes="cmd_outdent" + modifiers="accel" + /> + </keyset> + + <menupopup id="editorContext" onpopupshowing="goUpdateGlobalEditMenuItems(true);"> + <menuitem data-l10n-id="text-action-undo" command="cmd_undo" /> + <menuseparator /> + <menuitem data-l10n-id="text-action-cut" command="cmd_cut" /> + <menuitem data-l10n-id="text-action-copy" command="cmd_copy" /> + <menuitem data-l10n-id="text-action-paste" command="cmd_paste" /> + <menuitem data-l10n-id="text-action-delete" command="cmd_item_delete" /> + <menuseparator /> + <menuitem data-l10n-id="text-action-select-all" command="cmd_selectAll" /> + </menupopup> + + <!-- Counter information section --> + <hbox id="counter-proposal-box" collapsed="true"> + <vbox> + <description id="counter-proposal-summary" collapsed="true" crop="end" /> + <hbox id="counter-proposal"> + <vbox id="counter-proposal-property-labels"> + <label + id="counter-proposal-property-label" + control="counter-proposal-property-value" + collapsed="true" + value="" + /> + </vbox> + <vbox id="counter-proposal-property-values"> + <description + id="counter-proposal-property-value" + crop="end" + collapsed="true" + value="" + /> + </vbox> + </hbox> + <description id="counter-proposal-comment" collapsed="true" crop="end" /> + </vbox> + <spacer flex="1" /> + <vbox id="counter-buttons"> + <button + id="counter-proposal-btn" + label="&counter.button.proposal.label;" + crop="end" + command="cmd_applyProposal" + orient="horizontal" + class="counter-buttons" + accesskey="&counter.button.proposal.accesskey;" + tooltip="&counter.button.proposal.tooltip2;" + /> + <button + id="counter-original-btn" + label="&counter.button.original.label;" + crop="end" + command="cmd_applyOriginal" + orient="horizontal" + disabled="true" + class="counter-buttons" + accesskey="&counter.button.original.accesskey;" + tooltip="&counter.button.original.tooltip2;" + /> + </vbox> + </hbox> + + <vbox id="event-dialog-notifications"> + <!-- notificationbox will be added here lazily. --> + </vbox> + + <html:table id="event-grid"> + <!-- Calendar --> + <html:tr> + <html:th> + <label + id="item-calendar-label" + value="&event.calendar.label;" + accesskey="&event.calendar.accesskey;" + control="item-calendar" + disable-on-readonly="true" + /> + </html:th> + <html:td id="event-grid-item-calendar-td"> + <menulist id="item-calendar" disable-on-readonly="true" oncommand="updateCalendar();" /> + </html:td> + </html:tr> + + <!-- Title --> + <html:tr id="event-grid-title-row"> + <html:th> + <label + id="item-title-label" + value="&event.title.textbox.label;" + accesskey="&event.title.textbox.accesskey;" + control="item-title" + disable-on-readonly="true" + /> + </html:th> + <html:td class="event-input-td"> + <html:input + id="item-title" + disable-on-readonly="true" + oninput="updateTitle()" + aria-labelledby="item-title-label" + /> + </html:td> + </html:tr> + + <!-- Location --> + <html:tr id="event-grid-location-row"> + <html:th> + <label + id="item-location-label" + value="&event.location.label;" + accesskey="&event.location.accesskey;" + control="item-location" + disable-on-readonly="true" + /> + </html:th> + <html:td class="event-input-td"> + <html:input + id="item-location" + disable-on-readonly="true" + aria-labelledby="item-location-label" + /> + </html:td> + </html:tr> + + <!-- Category --> + <html:tr id="event-grid-category-row"> + <html:th> + <hbox id="event-grid-category-labels-box"> + <label + id="item-categories-label" + value="&event.categories.label;" + accesskey="&event.categories.accesskey;" + control="item-categories" + disable-on-readonly="true" + /> + </hbox> + </html:th> + <html:td id="event-grid-category-td"> + <menulist id="item-categories" type="panel-menulist" disable-on-readonly="true"> + <menupopup + id="item-categories-popup" + onpopuphiding="return categoryPopupHiding(event);" + > + <html:input + id="item-categories-textbox" + placeholder="&event.categories.textbox.label;" + onblur="this.parentNode.removeAttribute('ignorekeys');" + onfocus="this.parentNode.setAttribute('ignorekeys', 'true');" + onkeypress="categoryTextboxKeypress(event);" + /> + <menuseparator /> + </menupopup> + </menulist> + </html:td> + </html:tr> + + <html:tr class="separator"> + <html:td colspan="2"></html:td> + </html:tr> + + <!-- All-Day --> + <html:tr id="event-grid-allday-row" class="event-only"> + <html:th> </html:th> + <html:td> + <checkbox + id="event-all-day" + disable-on-readonly="true" + label="&event.alldayevent.label;" + accesskey="&event.alldayevent.accesskey;" + oncommand="onUpdateAllDay();" + /> + </html:td> + </html:tr> + + <!-- StartDate --> + <html:tr id="event-grid-startdate-row"> + <html:th id="event-grid-startdate-th"> + <hbox id="event-grid-startdate-label-box" align="center"> + <label + value="&event.from.label;" + accesskey="&event.from.accesskey;" + control="event-starttime" + class="event-only" + disable-on-readonly="true" + /> + <label + value="&task.from.label;" + accesskey="&task.from.accesskey;" + control="todo-has-entrydate" + class="todo-only" + disable-on-readonly="true" + /> + </hbox> + </html:th> + <html:td id="event-grid-startdate-td"> + <hbox id="event-grid-startdate-picker-box"> + <datetimepicker + id="event-starttime" + class="event-only" + disable-on-readonly="true" + onchange="dateTimeControls2State(true);" + /> + <checkbox + id="todo-has-entrydate" + class="todo-only checkbox-no-label" + disable-on-readonly="true" + oncommand="updateEntryDate();" + /> + <datetimepicker + id="todo-entrydate" + class="todo-only" + disable-on-readonly="true" + onchange="dateTimeControls2State(true);" + /> + <vbox> + <hbox> + <html:img + id="link-image-top" + src="chrome://calendar/skin/shared/link-image-top.svg" + alt="" + class="keepduration-link-image" + keep="true" + /> + </hbox> + <spacer flex="1" /> + <toolbarbutton + id="keepduration-button" + accesskey="&event.dialog.keepDurationButton.accesskey;" + oncommand="toggleKeepDuration();" + persist="keep" + keep="false" + tooltiptext="&event.dialog.keepDurationButton.tooltip;" + /> + </vbox> + <hbox align="center"> + <label + id="timezone-starttime" + class="text-link" + collapsed="true" + crop="end" + disable-on-readonly="true" + hyperlink="true" + onclick="showTimezonePopup(event, gStartTime.getInTimezone(gStartTimezone), editStartTimezone)" + /> + </hbox> + </hbox> + </html:td> + </html:tr> + + <!-- EndDate --> + <html:tr id="event-grid-enddate-row"> + <html:th> + <hbox id="event-grid-enddate-label-box" align="center"> + <label + value="&event.to.label;" + accesskey="&event.to.accesskey;" + control="event-endtime" + class="event-only" + disable-on-readonly="true" + /> + <label + value="&task.to.label;" + accesskey="&task.to.accesskey;" + control="todo-has-duedate" + class="todo-only" + disable-on-readonly="true" + /> + </hbox> + </html:th> + <html:td id="event-grid-enddate-td"> + <vbox id="event-grid-enddate-vbox"> + <hbox id="event-grid-enddate-picker-box"> + <datetimepicker + id="event-endtime" + class="event-only" + disable-on-readonly="true" + onchange="dateTimeControls2State(false);" + /> + <checkbox + id="todo-has-duedate" + class="todo-only checkbox-no-label" + disable-on-readonly="true" + oncommand="updateDueDate();" + /> + <datetimepicker + id="todo-duedate" + class="todo-only" + disable-on-readonly="true" + onchange="dateTimeControls2State(false);" + /> + <vbox pack="end"> + <html:img + id="link-image-bottom" + alt="" + src="chrome://calendar/skin/shared/link-image-bottom.svg" + class="keepduration-link-image" + /> + </vbox> + <hbox align="center"> + <label + id="timezone-endtime" + class="text-link" + collapsed="true" + crop="end" + disable-on-readonly="true" + flex="1" + hyperlink="true" + onclick="showTimezonePopup(event, gEndTime.getInTimezone(gEndTimezone), editEndTimezone)" + /> + </hbox> + </hbox> + </vbox> + </html:td> + </html:tr> + + <html:tr id="event-grid-todo-status-row" class="todo-only"> + <html:th> + <label + id="todo-status-label" + value="&task.status.label;" + accesskey="&task.status.accesskey;" + control="todo-status" + disable-on-readonly="true" + /> + </html:th> + <html:td id="event-grid-todo-status-td"> + <hbox id="event-grid-todo-status-picker-box" align="center"> + <menulist + id="todo-status" + class="todo-only" + disable-on-readonly="true" + oncommand="updateToDoStatus(this.value);" + > + <menupopup id="todo-status-menupopup"> + <menuitem + id="todo-status-none-menuitem" + label="&newevent.todoStatus.none.label;" + value="NONE" + /> + <menuitem + id="todo-status-needsaction-menuitem" + label="&newevent.status.needsaction.label;" + value="NEEDS-ACTION" + /> + <menuitem + id="todo-status-inprogress-menuitem" + label="&newevent.status.inprogress.label;" + value="IN-PROCESS" + /> + <menuitem + id="todo-status-completed-menuitem" + label="&newevent.status.completed.label;" + value="COMPLETED" + /> + <menuitem + id="todo-status-canceled-menuitem" + label="&newevent.todoStatus.cancelled.label;" + value="CANCELLED" + /> + </menupopup> + </menulist> + <datepicker + id="completed-date-picker" + class="todo-only" + disable-on-readonly="true" + disabled="true" + value="" + /> + <html:input + id="percent-complete-textbox" + type="number" + class="size3 input-inline" + min="0" + max="100" + disable-on-readonly="true" + oninput="updateToDoStatus('percent-changed')" + onselect="updateToDoStatus('percent-changed')" + /> + <label + id="percent-complete-label" + class="todo-only" + disable-on-readonly="true" + value="&newtodo.percentcomplete.label;" + /> + </hbox> + </html:td> + </html:tr> + + <!-- Recurrence --> + <html:tr id="event-grid-recurrence-row"> + <html:th> + <label + value="&event.repeat.label;" + accesskey="&event.repeat.accesskey;" + control="item-repeat" + disable-on-readonly="true" + /> + </html:th> + <html:td id="event-grid-recurrence-td"> + <hbox id="event-grid-recurrence-picker-box" align="center" flex="1"> + <menulist + id="item-repeat" + disable-on-readonly="true" + oncommand="updateRepeat(null, true)" + > + <menupopup id="item-repeat-menupopup"> + <menuitem + id="repeat-none-menuitem" + label="&event.repeat.does.not.repeat.label;" + selected="true" + value="none" + /> + <menuitem + id="repeat-daily-menuitem" + label="&event.repeat.daily.label;" + value="daily" + /> + <menuitem + id="repeat-weekly-menuitem" + label="&event.repeat.weekly.label;" + value="weekly" + /> + <menuitem + id="repeat-weekday-menuitem" + label="&event.repeat.every.weekday.label;" + value="every.weekday" + /> + <menuitem + id="repeat-biweekly-menuitem" + label="&event.repeat.bi.weekly.label;" + value="bi.weekly" + /> + <menuitem + id="repeat-monthly-menuitem" + label="&event.repeat.monthly.label;" + value="monthly" + /> + <menuitem + id="repeat-yearly-menuitem" + label="&event.repeat.yearly.label;" + value="yearly" + /> + <menuseparator id="item-repeat-separator" /> + <menuitem + id="repeat-custom-menuitem" + label="&event.repeat.custom.label;" + value="custom" + /> + </menupopup> + </menulist> + <hbox id="repeat-untilDate" align="center" hidden="true"> + <label + value="&event.until.label;" + accesskey="&event.until.accesskey;" + control="repeat-until-datepicker" + disable-on-readonly="true" + /> + <datepicker + id="repeat-until-datepicker" + flex="1" + type="forever" + disable-on-readonly="true" + onchange="if (onLoad.hasLoaded) { checkUntilDate(); }" + value="" + /> + </hbox> + <vbox id="repeat-details" flex="1" hidden="true"> + <label + id="repeat-details-label" + class="text-link" + crop="end" + disable-on-readonly="true" + hyperlink="true" + flex="1" + onclick="if (onLoad.hasLoaded) { updateRepeat(); }" + /> + </vbox> + </hbox> + </html:td> + </html:tr> + + <html:tr class="separator"> + <html:td colspan="2"></html:td> + </html:tr> + + <!-- Reminder (Alarm) --> + <html:tr id="event-grid-alarm-row"> + <html:th> + <label + value="&event.reminder.label;" + accesskey="&event.reminder.accesskey;" + control="item-alarm" + disable-on-readonly="true" + /> + </html:th> + <html:td> + <hbox id="event-grid-alarm-picker-box" align="center"> + <menulist + id="item-alarm" + class="item-alarm" + disable-on-readonly="true" + oncommand="updateReminder()" + > + <menupopup id="item-alarm-menupopup"> + <menuitem + id="reminder-none-menuitem" + label="&event.reminder.none.label;" + selected="true" + value="none" + /> + <menuseparator id="reminder-none-separator" /> + <menuitem + id="reminder-0minutes-menuitem" + label="&event.reminder.0minutes.before.label;" + length="0" + origin="before" + relation="START" + unit="minutes" + /> + <menuitem + id="reminder-5minutes-menuitem" + label="&event.reminder.5minutes.before.label;" + length="5" + origin="before" + relation="START" + unit="minutes" + /> + <menuitem + id="reminder-15minutes-menuitem" + label="&event.reminder.15minutes.before.label;" + length="15" + origin="before" + relation="START" + unit="minutes" + /> + <menuitem + id="reminder-30minutes-menuitem" + label="&event.reminder.30minutes.before.label;" + length="30" + origin="before" + relation="START" + unit="minutes" + /> + <menuseparator id="reminder-minutes-separator" /> + <menuitem + id="reminder-1hour-menuitem" + label="&event.reminder.1hour.before.label;" + length="1" + origin="before" + relation="START" + unit="hours" + /> + <menuitem + id="reminder-2hours-menuitem" + label="&event.reminder.2hours.before.label;" + length="2" + origin="before" + relation="START" + unit="hours" + /> + <menuitem + id="reminder-12hours-menuitem" + label="&event.reminder.12hours.before.label;" + length="12" + origin="before" + relation="START" + unit="hours" + /> + <menuseparator id="reminder-hours-separator" /> + <menuitem + id="reminder-1day-menuitem" + label="&event.reminder.1day.before.label;" + length="1" + origin="before" + relation="START" + unit="days" + /> + <menuitem + id="reminder-2days-menuitem" + label="&event.reminder.2days.before.label;" + length="2" + origin="before" + relation="START" + unit="days" + /> + <menuitem + id="reminder-1week-menuitem" + label="&event.reminder.1week.before.label;" + length="7" + origin="before" + relation="START" + unit="days" + /> + <menuseparator id="reminder-custom-separator" /> + <menuitem + class="reminder-custom-menuitem" + label="&event.reminder.custom.label;" + value="custom" + /> + </menupopup> + </menulist> + <hbox class="reminder-details"> + <hbox class="alarm-icons-box" align="center" /> + <!-- TODO oncommand? onkeypress? --> + <label + class="reminder-multiple-alarms-label text-link" + hidden="true" + value="&event.reminder.multiple.label;" + disable-on-readonly="true" + flex="1" + hyperlink="true" + onclick="updateReminder()" + /> + <label + class="reminder-single-alarms-label text-link" + hidden="true" + disable-on-readonly="true" + flex="1" + hyperlink="true" + onclick="updateReminder()" + /> + </hbox> + </hbox> + </html:td> + </html:tr> + + <html:tr id="event-grid-link-separator" class="separator" hidden="hidden"> + <html:td colspan="2"></html:td> + </html:tr> + + <html:tr id="event-grid-link-row" hidden="hidden"> + <html:th> + <label value="&event.url.label;" control="url-link" /> + </html:th> + <html:td> + <label + id="url-link" + class="text-link" + onclick="launchBrowser(this.getAttribute('href'), event)" + oncommand="launchBrowser(this.getAttribute('href'), event)" + crop="end" + /> + </html:td> + </html:tr> + + <html:tr class="separator"> + <html:td colspan="2"></html:td> + </html:tr> + </html:table> + + <vbox id="event-grid-tab-vbox" flex="1"> + <!-- Multi purpose tab box --> + <hbox id="event-grid-tab-box-row"> + <tabbox id="event-grid-tabbox" selectedIndex="0" flex="1"> + <tabs id="event-grid-tabs"> + <tab + id="event-grid-tab-description" + label="&event.description.label;" + accesskey="&event.description.accesskey;" + /> + <tab + id="event-grid-tab-attachments" + label="&event.attachments.label;" + accesskey="&event.attachments.accesskey;" + /> + <tab + id="event-grid-tab-attendees" + label="&event.attendees.label;" + accesskey="&event.attendees.accesskey;" + collapsed="true" + /> + </tabs> + <tabpanels id="event-grid-tabpanels" flex="1"> + <tabpanel id="event-grid-tabpanel-description" orient="vertical"> + <toolbox id="FormatToolbox" mode="icons"> + <toolbar + id="FormatToolbar" + class="inline-toolbar chromeclass-toolbar themeable-full" + nowindowdrag="true" + > + <toolbarbutton + id="paragraphButton" + type="menu" + wantdropmarker="true" + class="formatting-button" + tooltiptext="&ParagraphSelect.tooltip;" + oncommand="goDoCommandParams('cmd_paragraphState', event.target.value);" + observes="cmd_renderedHTMLEnabler" + > + <menupopup id="paragraphPopup"> + <menuitem id="toolbarmenu_bodyText" label="&bodyTextCmd.label;" value="" /> + <menuitem id="toolbarmenu_h1" label="&heading1Cmd.label;" value="h1" /> + <menuitem id="toolbarmenu_h2" label="&heading2Cmd.label;" value="h2" /> + <menuitem id="toolbarmenu_h3" label="&heading3Cmd.label;" value="h3" /> + <menuitem id="toolbarmenu_h4" label="&heading4Cmd.label;" value="h4" /> + <menuitem id="toolbarmenu_h5" label="&heading5Cmd.label;" value="h5" /> + <menuitem id="toolbarmenu_h6" label="&heading6Cmd.label;" value="h6" /> + <menuitem + id="toolbarmenu_pre" + label="¶graphPreformatCmd.label;" + value="pre" + /> + </menupopup> + </toolbarbutton> + <toolbarseparator class="toolbarseparator-standard" /> + <toolbarbutton + id="boldButton" + class="formatting-button" + tooltiptext="&boldToolbarCmd.tooltip;" + type="checkbox" + autoCheck="false" + observes="cmd_bold" + /> + <toolbarbutton + id="italicButton" + class="formatting-button" + tooltiptext="&italicToolbarCmd.tooltip;" + type="checkbox" + autoCheck="false" + observes="cmd_italic" + /> + <toolbarbutton + id="underlineButton" + class="formatting-button" + tooltiptext="&underlineToolbarCmd.tooltip;" + type="checkbox" + autoCheck="false" + observes="cmd_underline" + /> + <toolbarseparator class="toolbarseparator-standard" /> + <toolbarbutton + id="ulButton" + class="formatting-button" + tooltiptext="&bulletListToolbarCmd.tooltip;" + type="radio" + group="lists" + autoCheck="false" + observes="cmd_ul" + /> + <toolbarbutton + id="olButton" + class="formatting-button" + tooltiptext="&numberListToolbarCmd.tooltip;" + type="radio" + group="lists" + autoCheck="false" + observes="cmd_ol" + /> + <toolbarbutton + id="outdentButton" + class="formatting-button" + tooltiptext="&outdentToolbarCmd.tooltip;" + observes="cmd_outdent" + /> + <toolbarbutton + id="indentButton" + class="formatting-button" + tooltiptext="&indentToolbarCmd.tooltip;" + observes="cmd_indent" + /> + <toolbarseparator class="toolbarseparator-standard" /> + <toolbarbutton + id="AlignPopupButton" + type="menu" + wantdropmarker="true" + class="formatting-button" + tooltiptext="&AlignPopupButton.tooltip;" + observes="cmd_align" + > + <menupopup id="AlignPopup"> + <menuitem + id="AlignLeftItem" + class="menuitem-iconic" + label="&alignLeft.label;" + oncommand="doStatefulCommand('cmd_align', 'left')" + tooltiptext="&alignLeftButton.tooltip;" + /> + <menuitem + id="AlignCenterItem" + class="menuitem-iconic" + label="&alignCenter.label;" + oncommand="doStatefulCommand('cmd_align', 'center')" + tooltiptext="&alignCenterButton.tooltip;" + /> + <menuitem + id="AlignRightItem" + class="menuitem-iconic" + label="&alignRight.label;" + oncommand="doStatefulCommand('cmd_align', 'right')" + tooltiptext="&alignRightButton.tooltip;" + /> + <menuitem + id="AlignJustifyItem" + class="menuitem-iconic" + label="&alignJustify.label;" + oncommand="doStatefulCommand('cmd_align', 'justify')" + tooltiptext="&alignJustifyButton.tooltip;" + /> + </menupopup> + </toolbarbutton> + <toolbarbutton + id="linkButton" + class="formatting-button" + tooltiptext="&linkToolbarCmd.label;" + oncommand="insertLink();" + observes="cmd_renderedHTMLEnabler" + /> + <toolbarbutton + id="smileButtonMenu" + type="menu" + wantdropmarker="true" + class="formatting-button" + tooltiptext="&SmileButton.tooltip;" + observes="cmd_renderedHTMLEnabler" + > + <menupopup id="smileyPopup" class="no-icon-menupopup"> + <menuitem + id="smileySmile" + class="menuitem-iconic" + label="🙂 &smiley1Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '🙂')" + /> + <menuitem + id="smileyFrown" + class="menuitem-iconic" + label="🙁 &smiley2Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '🙁')" + /> + <menuitem + id="smileyWink" + class="menuitem-iconic" + label="😉 &smiley3Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😉')" + /> + <menuitem + id="smileyTongue" + class="menuitem-iconic" + label="😛 &smiley4Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😛')" + /> + <menuitem + id="smileyLaughing" + class="menuitem-iconic" + label="😂 &smiley5Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😂')" + /> + <menuitem + id="smileyEmbarassed" + class="menuitem-iconic" + label="😳 &smiley6Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😳')" + /> + <menuitem + id="smileyUndecided" + class="menuitem-iconic" + label="😕 &smiley7Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😕')" + /> + <menuitem + id="smileySurprise" + class="menuitem-iconic" + label="😮 &smiley8Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😮')" + /> + <menuitem + id="smileyKiss" + class="menuitem-iconic" + label="😘 &smiley9Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😘')" + /> + <menuitem + id="smileyYell" + class="menuitem-iconic" + label="😠 &smiley10Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😠')" + /> + <menuitem + id="smileyCool" + class="menuitem-iconic" + label="😎 &smiley11Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😎')" + /> + <menuitem + id="smileyMoney" + class="menuitem-iconic" + label="🤑 &smiley12Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '🤑')" + /> + <menuitem + id="smileyFoot" + class="menuitem-iconic" + label="😬 &smiley13Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😬')" + /> + <menuitem + id="smileyInnocent" + class="menuitem-iconic" + label="😇 &smiley14Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😇')" + /> + <menuitem + id="smileyCry" + class="menuitem-iconic" + label="😭 &smiley15Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '😭')" + /> + <menuitem + id="smileySealed" + class="menuitem-iconic" + label="🤐 &smiley16Cmd.label;" + oncommand="goDoCommandParams('cmd_smiley', '🤐')" + /> + </menupopup> + </toolbarbutton> + </toolbar> + </toolbox> + <editor + id="item-description" + type="content" + primary="true" + editortype="html" + oncontextmenu="openEditorContextMenu(event);" + disable-on-readonly="true" + flex="1" + /> + </tabpanel> + <tabpanel id="event-grid-tabpanel-attachments"> + <vbox flex="1"> + <richlistbox + id="attachment-link" + seltype="single" + context="attachment-popup" + rows="3" + flex="1" + disable-on-readonly="true" + onkeypress="attachmentLinkKeyPress(event)" + ondblclick="attachmentDblClick(event);" + /> + </vbox> + </tabpanel> + <tabpanel id="event-grid-tabpanel-attendees" collapsed="true"> + <vbox flex="1"> + <hbox id="item-organizer-row" hidden="true" align="start"> + <label value="&read.only.organizer.label;" /> + </hbox> + <vbox + class="item-attendees-list-container" + dialog-type="event" + flex="1" + context="attendee-popup" + oncontextmenu="setAttendeeContext(event)" + disable-on-readonly="true" + /> + </vbox> + </tabpanel> + </tabpanels> + <hbox + id="notify-options" + dialog-type="event" + align="center" + collapsed="true" + disable-on-readonly="true" + > + <checkbox + id="notify-attendees-checkbox" + label="&event.attendees.notify.label;" + accesskey="&event.attendees.notify.accesskey;" + oncommand="changeUndiscloseCheckboxStatus();" + pack="start" + /> + <checkbox + id="undisclose-attendees-checkbox" + label="&event.attendees.notifyundisclosed.label;" + accesskey="&event.attendees.notifyundisclosed.accesskey;" + tooltiptext="&event.attendees.notifyundisclosed.tooltip;" + pack="start" + /> + <checkbox + id="disallow-counter-checkbox" + label="&event.attendees.disallowcounter.label;" + accesskey="&event.attendees.disallowcounter.accesskey;" + tooltiptext="&event.attendees.disallowcounter.tooltip;" + pack="start" + /> + </hbox> + </tabbox> + </hbox> + </vbox> + + <popupset id="event-dialog-popupset"> + <menupopup id="attendee-popup"> + <menuitem + id="attendee-popup-invite-menuitem" + label="&event.invite.attendees.label;" + accesskey="&event.invite.attendees.accesskey;" + command="cmd_attendees" + disable-on-readonly="true" + /> + <menuitem + id="attendee-popup-removeallattendees-menuitem" + label="&event.remove.attendees.label2;" + accesskey="&event.remove.attendees.accesskey;" + oncommand="removeAllAttendees()" + disable-on-readonly="true" + crop="end" + /> + <menuitem + id="attendee-popup-removeattendee-menuitem" + label="&event.remove.attendee.label;" + accesskey="&event.remove.attendee.accesskey;" + oncommand="removeAttendee(event.target.attendee)" + crop="end" + /> + <menuseparator id="attendee-popup-first-separator" /> + <menuitem + id="attendee-popup-sendemail-menuitem" + label="&event.email.attendees.label;" + accesskey="&event.email.attendees.accesskey;" + command="cmd_email" + /> + <menuitem + id="attendee-popup-sendtentativeemail-menuitem" + label="&event.email.tentative.attendees.label;" + accesskey="&event.email.tentative.attendees.accesskey;" + command="cmd_email_undecided" + /> + <menuseparator id="attendee-popup-second-separator" /> + <menuitem + id="attendee-popup-emailattendee-menuitem" + oncommand="sendMailToAttendees([event.target.attendee])" + crop="end" + /> + </menupopup> + <menupopup id="attachment-popup" onpopupshowing="attachmentClick(event)"> + <menuitem + id="attachment-popup-open" + label="&event.attachments.popup.open.label;" + accesskey="&event.attachments.popup.open.accesskey;" + command="cmd_openAttachment" + /> + <menuitem + id="attachment-popup-copy" + label="&calendar.copylink.label;" + accesskey="&calendar.copylink.accesskey;" + command="cmd_copyAttachment" + /> + <menuitem + id="attachment-popup-delete" + label="&event.attachments.popup.remove.label;" + accesskey="&event.attachments.popup.remove.accesskey;" + command="cmd_deleteAttachment" + /> + <menuitem + id="attachment-popup-deleteAll" + label="&event.attachments.popup.removeAll.label;" + accesskey="&event.attachments.popup.removeAll.accesskey;" + command="cmd_deleteAllAttachments" + /> + <menuseparator /> + <menuitem + id="attachment-popup-attachPage" + label="&event.attachments.popup.attachPage.label;" + accesskey="&event.attachments.popup.attachPage.accesskey;" + command="cmd_attach_url" + /> + </menupopup> + <menupopup id="timezone-popup" position="after_start" oncommand="chooseRecentTimezone(event)"> + <menuitem id="timezone-popup-defaulttz" /> + <menuseparator id="timezone-popup-menuseparator" /> + <menuitem + id="timezone-custom-menuitem" + label="&event.timezone.custom.label;" + value="custom" + oncommand="this.parentNode.editTimezone()" + /> + </menupopup> + </popupset> + </html:body> +</html> diff --git a/comm/calendar/base/content/item-editing/calendar-item-panel.inc.xhtml b/comm/calendar/base/content/item-editing/calendar-item-panel.inc.xhtml new file mode 100644 index 0000000000..c567053ee6 --- /dev/null +++ b/comm/calendar/base/content/item-editing/calendar-item-panel.inc.xhtml @@ -0,0 +1,130 @@ +# 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/. + +# This file requires the following localization files: +# chrome://calendar/locale/global.dtd +# chrome://calendar/locale/calendar.dtd +# chrome://calendar/locale/calendar-event-dialog.dtd +# chrome://lightning/locale/lightning-toolbar.dtd + + <vbox id="calendarItemPanel" collapsed="true"> + + <!-- The id of the inner vbox and the iframe are set dynamically + when a tab is created. --> + <vbox flex="1" + id="dummy-calendar-event-dialog-tab" + class="calendar-event-dialog-tab"> + + <!-- Commands --> + <commandset id="itemCommands"> + <command id="cmd_save" + disable-on-readonly="true" + oncommand="onCommandSave()"/> + <command id="cmd_item_delete" + disable-on-readonly="true" + oncommand="onCommandDeleteItem()"/> + + <!-- View menu --> + <command id="cmd_customize" + oncommand="onCommandCustomize()"/> + + <!-- status --> + <command id="cmd_status_none" + oncommand="editStatus(event.target)" + hidden="true" + value="NONE"/> + <command id="cmd_status_tentative" + oncommand="editStatus(event.target)" + value="TENTATIVE"/> + <command id="cmd_status_confirmed" + oncommand="editStatus(event.target)" + value="CONFIRMED"/> + <command id="cmd_status_cancelled" + oncommand="editStatus(event.target)" + value="CANCELLED"/> + + <!-- priority --> + <command id="cmd_priority_none" + oncommand="editPriority(event.target)" + value="0"/> + <command id="cmd_priority_low" + oncommand="editPriority(event.target)" + value="9"/> + <command id="cmd_priority_normal" + oncommand="editPriority(event.target)" + value="5"/> + <command id="cmd_priority_high" + oncommand="editPriority(event.target)" + value="1"/> + + <!-- freebusy --> + <command id="cmd_showtimeas_busy" + oncommand="editShowTimeAs(event.target)" + value="OPAQUE"/> + <command id="cmd_showtimeas_free" + oncommand="editShowTimeAs(event.target)" + value="TRANSPARENT"/> + + <!-- attendees --> + <command id="cmd_attendees" + oncommand="editAttendees();"/> + + <!-- accept, attachments, timezone --> + <command id="cmd_accept" + disable-on-readonly="true" + oncommand="sendMessage({ command: 'onAccept' });"/> + <command id="cmd_attach_url" + disable-on-readonly="true" + oncommand="attachURL()"/> + <command id="cmd_attach_cloud" + disable-on-readonly="true"/> + <command id="cmd_timezone" + persist="checked" + checked="false" + oncommand="toggleTimezoneLinks()"/> + </commandset> + + <keyset id="calendar-event-dialog-keyset"> + <key id="save-key" + modifiers="accel, shift" + key="&event.dialog.save.key;" + command="cmd_save"/> + <key id="saveandclose-key" + modifiers="accel" + key="&event.dialog.saveandclose.key;" + command="cmd_accept"/> + <key id="saveandclose-key2" + modifiers="accel" + keycode="VK_RETURN" + command="cmd_accept"/> + </keyset> + + <toolbox id="event-toolbox" + class="mail-toolbox" + mode="full" + defaultmode="full" + iconsize="small" + defaulticonsize="small" + labelalign="end" + defaultlabelalign="end"> + <toolbarpalette id="event-toolbarpalette"> +#include calendar-item-toolbar.inc.xhtml + </toolbarpalette> + <!-- toolboxid is set here since we move the toolbar around for tabs --> + <toolbar is="customizable-toolbar" id="event-tab-toolbar" + toolbarname="&event.menu.view.toolbars.event.label;" + accesskey="&event.menu.view.toolbars.event.accesskey;" + toolboxid="event-toolbox" + class="chromeclass-toolbar inline-toolbar themeable-full" + customizable="true" + labelalign="end" + defaultlabelalign="end" + context="event-dialog-toolbar-context-menu" + defaultset="button-saveandclose,button-attendees,button-privacy,button-url,button-priority,button-status,button-freebusy,button-delete,spring"/> + </toolbox> + + <iframe id="calendar-item-panel-iframe" flex="1"/> + + </vbox> + </vbox> diff --git a/comm/calendar/base/content/item-editing/calendar-item-panel.js b/comm/calendar/base/content/item-editing/calendar-item-panel.js new file mode 100644 index 0000000000..f2550d509a --- /dev/null +++ b/comm/calendar/base/content/item-editing/calendar-item-panel.js @@ -0,0 +1,1143 @@ +/* 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/. */ + +/* exported onLoadCalendarItemPanel, onCancel, onCommandSave, + * onCommandDeleteItem, editAttendees, editPrivacy, editPriority, + * editStatus, editShowTimeAs, updateShowTimeAs, editToDoStatus, + * postponeTask, toggleTimezoneLinks, attachURL, + * onCommandViewToolbar, onCommandCustomize, attachFileByAccountKey, + * onUnloadCalendarItemPanel, openNewEvent, openNewTask, + * openNewMessage + */ + +/* import-globals-from ../../../../mail/base/content/globalOverlay.js */ +/* import-globals-from ../dialogs/calendar-dialog-utils.js */ +/* import-globals-from ../calendar-ui-utils.js */ + +// XXX Need to determine which of these we really need here. +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); +var { MailServices } = ChromeUtils.import("resource:///modules/MailServices.jsm"); + +var gTabmail; +window.addEventListener( + "DOMContentLoaded", + () => { + // gTabmail is null if we are in a dialog window and not in a tab. + gTabmail = document.getElementById("tabmail") || null; + + if (!gTabmail) { + // In a dialog window the following menu item functions need to be + // defined. In a tab they are defined elsewhere. To prevent errors in + // the log they are defined here (before the onLoad function is called). + /** + * Update menu items that rely on focus. + */ + window.goUpdateGlobalEditMenuItems = () => { + goUpdateCommand("cmd_undo"); + goUpdateCommand("cmd_redo"); + goUpdateCommand("cmd_cut"); + goUpdateCommand("cmd_copy"); + goUpdateCommand("cmd_paste"); + goUpdateCommand("cmd_selectAll"); + }; + /** + * Update menu items that rely on the current selection. + */ + window.goUpdateSelectEditMenuItems = () => { + goUpdateCommand("cmd_cut"); + goUpdateCommand("cmd_copy"); + goUpdateCommand("cmd_delete"); + goUpdateCommand("cmd_selectAll"); + }; + /** + * Update menu items that relate to undo/redo. + */ + window.goUpdateUndoEditMenuItems = () => { + goUpdateCommand("cmd_undo"); + goUpdateCommand("cmd_redo"); + }; + /** + * Update menu items that depend on clipboard contents. + */ + window.goUpdatePasteMenuItems = () => { + goUpdateCommand("cmd_paste"); + }; + } + }, + { once: true } +); + +// Stores the ids of the iframes of currently open event/task tabs, used +// when window is closed to prompt for saving changes. +var gItemTabIds = []; +var gItemTabIdsCopy; + +// gConfig is used when switching tabs to restore the state of +// toolbar, statusbar, and menubar for the current tab. +var gConfig = { + isEvent: null, + privacy: null, + hasPrivacy: null, + calendarType: null, + privacyValues: null, + priority: null, + hasPriority: null, + status: null, + percentComplete: null, + showTimeAs: null, + // whether commands are enabled or disabled + attendeesCommand: null, // cmd_attendees + attachUrlCommand: null, // cmd_attach_url + timezonesEnabled: false, // cmd_timezone +}; + +/** + * Receive an asynchronous message from the iframe. + * + * @param {MessageEvent} aEvent - Contains the message being received + */ +function receiveMessage(aEvent) { + if (aEvent.origin !== "chrome://calendar") { + return; + } + switch (aEvent.data.command) { + case "initializeItemMenu": + initializeItemMenu(aEvent.data.label, aEvent.data.accessKey); + break; + case "cancelDialog": + document.querySelector("dialog").cancelDialog(); + break; + case "closeWindowOrTab": + closeWindowOrTab(aEvent.data.iframeId); + break; + case "showCmdStatusNone": + document.getElementById("cmd_status_none").removeAttribute("hidden"); + break; + case "updateTitle": + updateTitle(aEvent.data.prefix, aEvent.data.title); + break; + case "updateConfigState": + updateItemTabState(aEvent.data.argument); + Object.assign(gConfig, aEvent.data.argument); + break; + case "enableAcceptCommand": + enableAcceptCommand(aEvent.data.argument); + break; + case "replyToClosingWindowWithTabs": + handleWindowClose(aEvent.data.response); + break; + case "removeDisableAndCollapseOnReadonly": + removeDisableAndCollapseOnReadonly(); + break; + case "setElementAttribute": { + let arg = aEvent.data.argument; + document.getElementById(arg.id)[arg.attribute] = arg.value; + break; + } + case "loadCloudProviders": { + loadCloudProviders(aEvent.data.items); + break; + } + case "updateSaveControls": { + updateSaveControls(aEvent.data.argument.sendNotSave); + break; + } + } +} + +window.addEventListener("message", receiveMessage); + +/** + * Send an asynchronous message to an iframe. Additional properties of + * aMessage are generally arguments that will be passed to the function + * named in aMessage.command. If aIframeId is omitted, the message will + * be sent to the iframe of the current tab. + * + * @param {object} aMessage - Contains the message being sent + * @param {string} aMessage.command - The name of a function to call + * @param {string} aIframeId - (optional) id of an iframe to send the message to + */ +function sendMessage(aMessage, aIframeId) { + let iframeId = gTabmail + ? aIframeId || gTabmail.currentTabInfo.iframe.id + : "calendar-item-panel-iframe"; + let iframe = document.getElementById(iframeId); + iframe.contentWindow.postMessage(aMessage, "*"); +} + +/** + * When the user closes the window, this function handles prompting them + * to save any unsaved changes for any open item tabs, before closing the + * window, or not if 'cancel' was clicked. Requires sending and receiving + * async messages from the iframes of all open item tabs. + * + * @param {boolean} aResponse - The response from the tab's iframe + */ +function handleWindowClose(aResponse) { + if (!aResponse) { + // Cancel was clicked, just leave the window open. We're done. + } else if (gItemTabIdsCopy.length > 0) { + // There are more unsaved changes in tabs to prompt the user about. + let nextId = gItemTabIdsCopy.shift(); + sendMessage({ command: "closingWindowWithTabs", id: nextId }, nextId); + } else { + // Close the window, there are no more unsaved changes in tabs. + window.removeEventListener("close", windowCloseListener); + window.close(); + } +} + +/** + * Listener function for window close. We prevent the window from + * closing, then for each open tab we prompt the user to save any + * unsaved changes with handleWindowClose. + * + * @param {object} aEvent - The window close event + */ +function windowCloseListener(aEvent) { + aEvent.preventDefault(); + gItemTabIdsCopy = gItemTabIds.slice(); + handleWindowClose(true); +} + +/** + * Load handler for the outer parent context that contains the iframe. + * + * @param {string} aIframeId - (optional) Id of the iframe in this tab + * @param {string} aUrl - (optional) The url to load in the iframe + */ +function onLoadCalendarItemPanel(aIframeId, aUrl) { + let iframe; + let iframeSrc; + let dialog = document.querySelector("dialog"); + + if (!gTabmail) { + gTabmail = document.getElementById("tabmail") || null; + // This should not happen. + if (gTabmail) { + console.warn( + "gTabmail was undefined on document load and is defined now, that should not happen." + ); + } + } + if (gTabmail) { + // tab case + let iframeId = aIframeId || gTabmail.currentTabInfo.iframe.id; + iframe = document.getElementById(iframeId); + iframeSrc = aUrl; + + // Add a listener to detect close events, prompt user about saving changes. + window.addEventListener("close", windowCloseListener); + } else { + // window dialog case + iframe = document.createXULElement("iframe"); + iframeSrc = "chrome://calendar/content/calendar-item-iframe.xhtml"; + + iframe.setAttribute("id", "calendar-item-panel-iframe"); + iframe.setAttribute("flex", "1"); + + // Note: iframe.contentWindow is undefined before the iframe is inserted here. + dialog.insertBefore(iframe, document.getElementById("status-bar")); + + iframe.contentWindow.addEventListener( + "load", + () => { + // Push setting dimensions to the end of the event queue. + setTimeout(() => { + let body = iframe.contentDocument.body; + // Make sure the body does not exceed its content's size. + body.style.width = "fit-content"; + body.style.height = "fit-content"; + let { scrollHeight, scrollWidth } = body; + iframe.style.minHeight = `${scrollHeight}px`; + iframe.style.minWidth = `${scrollWidth}px`; + // Reset the body. + body.style.width = null; + body.style.height = null; + }); + }, + { once: true } + ); + + // Move the args so they are positioned relative to the iframe, + // for the window dialog just as they are for the tab. + // XXX Should we delete the arguments here in the parent context + // so they are only accessible in one place? + iframe.contentWindow.arguments = [window.arguments[0]]; + + // hide the ok and cancel dialog buttons + let accept = dialog.getButton("accept"); + let cancel = dialog.getButton("cancel"); + accept.setAttribute("collapsed", "true"); + cancel.setAttribute("collapsed", "true"); + cancel.parentNode.setAttribute("collapsed", "true"); + + document.addEventListener("dialogaccept", event => { + let itemTitle = iframe.contentDocument.documentElement.querySelector("#item-title"); + // Prevent dialog from saving if title is empty. + if (!itemTitle.value) { + event.preventDefault(); + return; + } + sendMessage({ command: "onAccept" }); + event.preventDefault(); + }); + + document.addEventListener("dialogcancel", event => { + sendMessage({ command: "onCancel" }); + event.preventDefault(); + }); + + // set toolbar icon color for light or dark themes + if (typeof window.ToolbarIconColor !== "undefined") { + window.ToolbarIconColor.init(); + } + } + + // event or task + let calendarItem = iframe.contentWindow.arguments[0].calendarEvent; + gConfig.isEvent = calendarItem.isEvent(); + + // for tasks in a window dialog, set the dialog id for CSS selection. + if (!gTabmail) { + if (gConfig.isEvent) { + setDialogId(dialog, "calendar-event-dialog"); + } else { + setDialogId(dialog, "calendar-task-dialog"); + } + } + + // timezones enabled + gConfig.timezonesEnabled = getTimezoneCommandState(); + iframe.contentWindow.gTimezonesEnabled = gConfig.timezonesEnabled; + + // set the iframe src, which loads the iframe's contents + iframe.setAttribute("src", iframeSrc); +} + +/** + * Unload handler for the outer parent context that contains the iframe. + * Currently only called for windows and not tabs. + */ +function onUnloadCalendarItemPanel() { + if (!gTabmail) { + // window dialog case + if (typeof window.ToolbarIconColor !== "undefined") { + window.ToolbarIconColor.uninit(); + } + } +} + +/** + * Updates the UI. Called when a user makes a change and when an + * event/task tab is shown. When a tab is shown aArg contains the gConfig + * data for that event/task. We pass the full tab state object to the + * update functions and they just use the properties they need from it. + * + * @param {object} aArg - Its properties hold data about the event/task + */ +function updateItemTabState(aArg) { + const lookup = { + privacy: updatePrivacy, + priority: updatePriority, + status: updateStatus, + showTimeAs: updateShowTimeAs, + percentComplete: updateMarkCompletedMenuItem, + attendeesCommand: updateAttendeesCommand, + attachUrlCommand: updateAttachment, + timezonesEnabled: updateTimezoneCommand, + }; + for (let key of Object.keys(aArg)) { + let procedure = lookup[key]; + if (procedure) { + procedure(aArg); + } + } +} + +/** + * When in a window, set Item-Menu label to Event or Task. + * + * @param {string} aLabel - The new name for the menu + * @param {string} aAccessKey - The access key for the menu + */ +function initializeItemMenu(aLabel, aAccessKey) { + let menuItem = document.getElementById("item-menu"); + menuItem.setAttribute("label", aLabel); + menuItem.setAttribute("accesskey", aAccessKey); +} + +/** + * Handler for when tab is cancelled. (calendar.item.editInTab = true) + * + * @param {string} aIframeId - The id of the iframe + */ +function onCancel(aIframeId) { + sendMessage({ command: "onCancel", iframeId: aIframeId }, aIframeId); + // We return false to prevent closing of a window until we + // can ask the user about saving any unsaved changes. + return false; +} + +/** + * Closes tab or window. Called after prompting to save any unsaved changes. + * + * @param {string} aIframeId - The id of the iframe + */ +function closeWindowOrTab(iframeId) { + if (gTabmail) { + if (iframeId) { + // Find the tab associated with this iframeId, and close it. + let myTabInfo = gTabmail.tabInfo.filter(x => "iframe" in x && x.iframe.id == iframeId)[0]; + myTabInfo.allowTabClose = true; + gTabmail.closeTab(myTabInfo); + } else { + gTabmail.currentTabInfo.allowTabClose = true; + gTabmail.removeCurrentTab(); + } + } else { + window.close(); + } +} + +/** + * Handler for saving the event or task. + * + * @param {boolean} aIsClosing - Is the tab or window closing + */ +function onCommandSave(aIsClosing) { + sendMessage({ command: "onCommandSave", isClosing: aIsClosing }); +} + +/** + * Handler for deleting the event or task. + */ +function onCommandDeleteItem() { + sendMessage({ command: "onCommandDeleteItem" }); +} + +/** + * Disable the saving options according to the item title. + * + * @param {boolean} disabled - True if the save options needs to be disabled else false. + */ +function disableSaving(disabled) { + let cmdSave = document.getElementById("cmd_save"); + if (cmdSave) { + cmdSave.setAttribute("disabled", disabled); + } + let cmdAccept = document.getElementById("cmd_accept"); + if (cmdAccept) { + cmdAccept.setAttribute("disabled", disabled); + } +} + +/** + * Update the title of the tab or window. + * + * @param {string} prefix - The prefix string according to the item. + * @param {string} title - The item title. + */ +function updateTitle(prefix, title) { + disableSaving(!title); + let newTitle = prefix + ": " + title; + if (gTabmail) { + gTabmail.currentTabInfo.title = newTitle; + gTabmail.setTabTitle(gTabmail.currentTabInfo); + } else { + document.title = newTitle; + } +} + +/** + * Open a new event. + */ +function openNewEvent() { + sendMessage({ command: "openNewEvent" }); +} + +/** + * Open a new task. + */ +function openNewTask() { + sendMessage({ command: "openNewTask" }); +} + +/** + * Open a new Thunderbird compose window. + */ +function openNewMessage() { + MailServices.compose.OpenComposeWindow( + null, + null, + null, + Ci.nsIMsgCompType.New, + Ci.nsIMsgCompFormat.Default, + null, + null, + null + ); +} + +/** + * Handler for edit attendees command. + */ +function editAttendees() { + sendMessage({ command: "editAttendees" }); +} + +/** + * Sends a message to set the gConfig values in the iframe. + * + * @param {object} aArg - Container + * @param {string} aArg.privacy - (optional) New privacy value + * @param {short} aArg.priority - (optional) New priority value + * @param {string} aArg.status - (optional) New status value + * @param {string} aArg.showTimeAs - (optional) New showTimeAs / transparency value + */ +function editConfigState(aArg) { + sendMessage({ command: "editConfigState", argument: aArg }); +} + +/** + * Handler for changing privacy. aEvent is used for the popup menu + * event-privacy-menupopup in the Privacy toolbar button. + * + * @param {Node} aTarget Has the new privacy in its "value" attribute + * @param {XULCommandEvent} aEvent - (optional) the UI element selection event + */ +function editPrivacy(aTarget, aEvent) { + if (aEvent) { + aEvent.stopPropagation(); + } + // "privacy" is indeed the correct attribute to use here + let newPrivacy = aTarget.getAttribute("privacy"); + editConfigState({ privacy: newPrivacy }); +} + +/** + * Updates the UI according to the privacy setting and the selected + * calendar. If the selected calendar does not support privacy or only + * certain values, these are removed from the UI. This function should + * be called any time that privacy setting is updated. + * + * @param {object} aArg Contains privacy properties + * @param {string} aArg.privacy The new privacy value + * @param {boolean} aArg.hasPrivacy Whether privacy is supported + * @param {string} aArg.calendarType The type of calendar + * @param {string[]} aArg.privacyValues The possible privacy values + */ +function updatePrivacy(aArg) { + if (aArg.hasPrivacy) { + // Update privacy capabilities (toolbar) + let menupopup = document.getElementById("event-privacy-menupopup"); + if (menupopup) { + // Only update the toolbar if the button is actually there + for (let node of menupopup.children) { + let currentProvider = node.getAttribute("provider"); + if (node.hasAttribute("privacy")) { + let currentPrivacyValue = node.getAttribute("privacy"); + // Collapsed state + + // Hide the toolbar if the value is unsupported or is for a + // specific provider and doesn't belong to the current provider. + if ( + !aArg.privacyValues.includes(currentPrivacyValue) || + (currentProvider && currentProvider != aArg.calendarType) + ) { + node.setAttribute("collapsed", "true"); + } else { + node.removeAttribute("collapsed"); + } + + // Checked state + if (aArg.privacy == currentPrivacyValue) { + node.setAttribute("checked", "true"); + } else { + node.removeAttribute("checked"); + } + } + } + } + + // Update privacy capabilities (menu) but only if we are not in a tab. + if (!gTabmail) { + menupopup = document.getElementById("options-privacy-menupopup"); + for (let node of menupopup.children) { + let currentProvider = node.getAttribute("provider"); + if (node.hasAttribute("privacy")) { + let currentPrivacyValue = node.getAttribute("privacy"); + // Collapsed state + + // Hide the menu if the value is unsupported or is for a + // specific provider and doesn't belong to the current provider. + if ( + !aArg.privacyValues.includes(currentPrivacyValue) || + (currentProvider && currentProvider != aArg.calendarType) + ) { + node.setAttribute("collapsed", "true"); + } else { + node.removeAttribute("collapsed"); + } + + // Checked state + if (aArg.privacy == currentPrivacyValue) { + node.setAttribute("checked", "true"); + } else { + node.removeAttribute("checked"); + } + } + } + } + + // Update privacy capabilities (statusbar) + let privacyPanel = document.getElementById("status-privacy"); + let hasAnyPrivacyValue = false; + for (let node of privacyPanel.children) { + let currentProvider = node.getAttribute("provider"); + if (node.hasAttribute("privacy")) { + let currentPrivacyValue = node.getAttribute("privacy"); + + // Hide the panel if the value is unsupported or is for a + // specific provider and doesn't belong to the current provider, + // or is not the items privacy value + if ( + !aArg.privacyValues.includes(currentPrivacyValue) || + (currentProvider && currentProvider != aArg.calendarType) || + aArg.privacy != currentPrivacyValue + ) { + node.setAttribute("collapsed", "true"); + } else { + node.removeAttribute("collapsed"); + hasAnyPrivacyValue = true; + } + } + } + + // Don't show the status panel if no valid privacy value is selected + if (hasAnyPrivacyValue) { + privacyPanel.removeAttribute("collapsed"); + } else { + privacyPanel.setAttribute("collapsed", "true"); + } + } else { + // aArg.hasPrivacy is false + document.getElementById("button-privacy").disabled = true; + document.getElementById("status-privacy").collapsed = true; + // in the tab case the menu item does not exist + let privacyMenuItem = document.getElementById("options-privacy-menu"); + if (privacyMenuItem) { + document.getElementById("options-privacy-menu").disabled = true; + } + } +} + +/** + * Handler to change the priority. + * + * @param {Node} aTarget - Has the new priority in its "value" attribute + */ +function editPriority(aTarget) { + let newPriority = parseInt(aTarget.getAttribute("value"), 10); + editConfigState({ priority: newPriority }); +} + +/** + * Updates the dialog controls related to priority. + * + * @param {object} aArg Contains priority properties + * @param {string} aArg.priority The new priority value + * @param {boolean} aArg.hasPriority - Whether priority is supported + */ +function updatePriority(aArg) { + // Set up capabilities + if (document.getElementById("button-priority")) { + document.getElementById("button-priority").disabled = !aArg.hasPriority; + } + if (!gTabmail && document.getElementById("options-priority-menu")) { + document.getElementById("options-priority-menu").disabled = !aArg.hasPriority; + } + document.getElementById("status-priority").collapsed = !aArg.hasPriority; + + if (aArg.hasPriority) { + let priorityLevel = "none"; + if (aArg.priority >= 1 && aArg.priority <= 4) { + priorityLevel = "high"; + } else if (aArg.priority == 5) { + priorityLevel = "normal"; + } else if (aArg.priority >= 6 && aArg.priority <= 9) { + priorityLevel = "low"; + } + + let priorityNone = document.getElementById("cmd_priority_none"); + let priorityLow = document.getElementById("cmd_priority_low"); + let priorityNormal = document.getElementById("cmd_priority_normal"); + let priorityHigh = document.getElementById("cmd_priority_high"); + + priorityNone.setAttribute("checked", priorityLevel == "none" ? "true" : "false"); + priorityLow.setAttribute("checked", priorityLevel == "low" ? "true" : "false"); + priorityNormal.setAttribute("checked", priorityLevel == "normal" ? "true" : "false"); + priorityHigh.setAttribute("checked", priorityLevel == "high" ? "true" : "false"); + + // Status bar panel + let priorityPanel = document.getElementById("status-priority"); + let image = priorityPanel.querySelector("img"); + if (priorityLevel === "none") { + // If the priority is none, don't show the status bar panel + priorityPanel.setAttribute("collapsed", "true"); + image.removeAttribute("data-l10n-id"); + image.setAttribute("alt", ""); + image.removeAttribute("src"); + } else { + priorityPanel.removeAttribute("collapsed"); + image.setAttribute("alt", cal.l10n.getString("calendar", `${priorityLevel}Priority`)); + image.setAttribute( + "src", + `chrome://calendar/skin/shared/statusbar-priority-${priorityLevel}.svg` + ); + } + } +} + +/** + * Handler for changing the status. + * + * @param {Node} aTarget - Has the new status in its "value" attribute + */ +function editStatus(aTarget) { + let newStatus = aTarget.getAttribute("value"); + editConfigState({ status: newStatus }); +} + +/** + * Update the dialog controls related to status. + * + * @param {object} aArg - Contains the new status value + * @param {string} aArg.status - The new status value + */ +function updateStatus(aArg) { + const statusLabels = [ + "status-status-tentative-label", + "status-status-confirmed-label", + "status-status-cancelled-label", + ]; + const commands = [ + "cmd_status_none", + "cmd_status_tentative", + "cmd_status_confirmed", + "cmd_status_cancelled", + ]; + let found = false; + document.getElementById("status-status").collapsed = true; + commands.forEach((aElement, aIndex, aArray) => { + let node = document.getElementById(aElement); + let matches = node.getAttribute("value") == aArg.status; + found = found || matches; + + node.setAttribute("checked", matches ? "true" : "false"); + + if (aIndex > 0) { + statusLabels[aIndex - 1].hidden = !matches; + if (matches) { + document.getElementById("status-status").collapsed = false; + } + } + }); + if (!found) { + // The current Status value is invalid. Change the status to + // "not specified" and update the status again. + sendMessage({ command: "editStatus", value: "NONE" }); + } +} + +/** + * Handler for changing the transparency. + * + * @param {Node} aTarget - Has the new transparency in its "value" attribute + */ +function editShowTimeAs(aTarget) { + let newValue = aTarget.getAttribute("value"); + editConfigState({ showTimeAs: newValue }); +} + +/** + * Update the dialog controls related to transparency. + * + * @param {object} aArg - Contains the new transparency value + * @param {string} aArg.showTimeAs - The new transparency value + */ +function updateShowTimeAs(aArg) { + let showAsBusy = document.getElementById("cmd_showtimeas_busy"); + let showAsFree = document.getElementById("cmd_showtimeas_free"); + + showAsBusy.setAttribute("checked", aArg.showTimeAs == "OPAQUE" ? "true" : "false"); + showAsFree.setAttribute("checked", aArg.showTimeAs == "TRANSPARENT" ? "true" : "false"); + + document.getElementById("status-freebusy").collapsed = + aArg.showTimeAs != "OPAQUE" && aArg.showTimeAs != "TRANSPARENT"; + document.getElementById("status-freebusy-free-label").hidden = aArg.showTimeAs == "OPAQUE"; + document.getElementById("status-freebusy-busy-label").hidden = aArg.showTimeAs == "TRANSPARENT"; +} + +/** + * Change the task percent complete (and thus task status). + * + * @param {short} aPercentComplete - The new percent complete value + */ +function editToDoStatus(aPercentComplete) { + sendMessage({ command: "editToDoStatus", value: aPercentComplete }); +} + +/** + * Check or uncheck the "Mark updated" menu item in "Events and Tasks" + * menu based on the percent complete value. + * + * @param {object} aArg - Container + * @param {short} aArg.percentComplete - The percent complete value + */ +function updateMarkCompletedMenuItem(aArg) { + // Command only for tab case, function only to be executed in dialog windows. + if (gTabmail) { + let completedCommand = document.getElementById("calendar_toggle_completed_command"); + let isCompleted = aArg.percentComplete == 100; + completedCommand.setAttribute("checked", isCompleted); + } +} + +/** + * Postpone the task's start date/time and due date/time. ISO 8601 + * format: "PT1H", "P1D", and "P1W" are 1 hour, 1 day, and 1 week. (We + * use this format intentionally instead of a calIDuration object because + * those objects cannot be serialized for message passing with iframes.) + * + * @param {string} aDuration - A duration in ISO 8601 format + */ +function postponeTask(aDuration) { + sendMessage({ command: "postponeTask", value: aDuration }); +} + +/** + * Get the timezone button state. + * + * @returns {boolean} True is active/checked and false is inactive/unchecked + */ +function getTimezoneCommandState() { + let cmdTimezone = document.getElementById("cmd_timezone"); + return cmdTimezone.getAttribute("checked") == "true"; +} + +/** + * Set the timezone button state. Used to keep the toolbar button in + * sync when switching tabs. + * + * @param {object} aArg - Contains timezones property + * @param {boolean} aArg.timezonesEnabled - Are timezones enabled? + */ +function updateTimezoneCommand(aArg) { + let cmdTimezone = document.getElementById("cmd_timezone"); + cmdTimezone.setAttribute("checked", aArg.timezonesEnabled); + gConfig.timezonesEnabled = aArg.timezonesEnabled; +} + +/** + * Toggles the command that allows enabling the timezone links in the dialog. + */ +function toggleTimezoneLinks() { + let cmdTimezone = document.getElementById("cmd_timezone"); + let currentState = getTimezoneCommandState(); + cmdTimezone.setAttribute("checked", currentState ? "false" : "true"); + gConfig.timezonesEnabled = !currentState; + sendMessage({ command: "toggleTimezoneLinks", checked: !currentState }); +} + +/** + * Prompts the user to attach an url to this item. + */ +function attachURL() { + sendMessage({ command: "attachURL" }); +} + +/** + * Updates dialog controls related to item attachments. + * + * @param {object} aArg Container + * @param {boolean} aArg.attachUrlCommand - Enable the attach url command? + */ +function updateAttachment(aArg) { + document.getElementById("cmd_attach_url").setAttribute("disabled", !aArg.attachUrlCommand); +} + +/** + * Updates attendees command enabled/disabled state. + * + * @param {object} aArg Container + * @param {boolean} aArg.attendeesCommand - Enable the attendees command? + */ +function updateAttendeesCommand(aArg) { + document.getElementById("cmd_attendees").setAttribute("disabled", !aArg.attendeesCommand); +} + +/** + * Enables/disables the commands cmd_accept and cmd_save related to the + * save operation. + * + * @param {boolean} aEnable - Enable the commands? + */ +function enableAcceptCommand(aEnable) { + document.getElementById("cmd_accept").setAttribute("disabled", !aEnable); + document.getElementById("cmd_save").setAttribute("disabled", !aEnable); +} + +/** + * Enable and un-collapse all elements that are disable-on-readonly and + * collapse-on-readonly. + */ +function removeDisableAndCollapseOnReadonly() { + let enableElements = document.getElementsByAttribute("disable-on-readonly", "true"); + for (let element of enableElements) { + element.removeAttribute("disabled"); + } + let collapseElements = document.getElementsByAttribute("collapse-on-readonly", "true"); + for (let element of collapseElements) { + element.removeAttribute("collapsed"); + } +} + +/** + * Handler to toggle toolbar visibility. + * + * @param {string} aToolbarId - The id of the toolbar node to toggle + * @param {string} aMenuitemId - The corresponding menuitem in the view menu + */ +function onCommandViewToolbar(aToolbarId, aMenuItemId) { + let toolbar = document.getElementById(aToolbarId); + let menuItem = document.getElementById(aMenuItemId); + + if (!toolbar || !menuItem) { + return; + } + + let toolbarCollapsed = toolbar.collapsed; + + // toggle the checkbox + menuItem.setAttribute("checked", toolbarCollapsed); + + // toggle visibility of the toolbar + toolbar.collapsed = !toolbarCollapsed; + + Services.xulStore.persist(toolbar, "collapsed"); + Services.xulStore.persist(menuItem, "checked"); +} + +/** + * Called after the customize toolbar dialog has been closed by the + * user. We need to restore the state of all buttons and commands of + * all customizable toolbars. + * + * @param {boolean} aToolboxChanged - When true the toolbox has changed + */ +function dialogToolboxCustomizeDone(aToolboxChanged) { + // Re-enable menu items (disabled during toolbar customization). + let menubarId = gTabmail ? "mail-menubar" : "event-menubar"; + let menubar = document.getElementById(menubarId); + for (let menuitem of menubar.children) { + menuitem.removeAttribute("disabled"); + } + + // make sure our toolbar buttons have the correct enabled state restored to them... + document.commandDispatcher.updateCommands("itemCommands"); + + // Enable the toolbar context menu items + document.getElementById("cmd_customize").removeAttribute("disabled"); + + // Update privacy items to make sure the toolbarbutton's menupopup is set + // correctly + updatePrivacy(gConfig); +} + +/** + * Handler to start the customize toolbar dialog for the event dialog's toolbar. + */ +function onCommandCustomize() { + // install the callback that handles what needs to be + // done after a toolbar has been customized. + let toolboxId = "event-toolbox"; + + let toolbox = document.getElementById(toolboxId); + toolbox.customizeDone = dialogToolboxCustomizeDone; + + // Disable menu items during toolbar customization. + let menubarId = gTabmail ? "mail-menubar" : "event-menubar"; + let menubar = document.getElementById(menubarId); + for (let menuitem of menubar.children) { + menuitem.setAttribute("disabled", true); + } + + // Disable the toolbar context menu items + document.getElementById("cmd_customize").setAttribute("disabled", "true"); + + let wintype = document.documentElement.getAttribute("windowtype"); + wintype = wintype.replace(/:/g, ""); + + window.openDialog( + "chrome://messenger/content/customizeToolbar.xhtml", + "CustomizeToolbar" + wintype, + "chrome,all,dependent", + document.getElementById(toolboxId), // toolbox dom node + false, // is mode toolbar yes/no? + null, // callback function + "dialog" + ); // name of this mode +} + +/** + * Add menu items to the UI for attaching files using a cloud provider. + * + * @param {object[]} aItemObjects - Array of objects that each contain + * data to create a menuitem + */ +function loadCloudProviders(aItemObjects) { + /** + * Deletes any existing menu items in aParentNode that have a + * cloudProviderAccountKey attribute. + * + * @param {Node} aParentNode - A menupopup containing menu items + */ + function deleteAlreadyExisting(aParentNode) { + for (let node of aParentNode.children) { + if (node.cloudProviderAccountKey) { + aParentNode.removeChild(node); + } + } + } + + // Delete any existing menu items with a cloudProviderAccountKey, + // needed for the tab case to prevent duplicate menu items, and + // helps keep the menu items current. + let toolbarPopup = document.getElementById("button-attach-menupopup"); + if (toolbarPopup) { + deleteAlreadyExisting(toolbarPopup); + } + let optionsPopup = document.getElementById("options-attachments-menupopup"); + if (optionsPopup) { + deleteAlreadyExisting(optionsPopup); + } + + for (let itemObject of aItemObjects) { + // Create a menu item. + let item = document.createXULElement("menuitem"); + item.setAttribute("label", itemObject.label); + item.setAttribute("observes", "cmd_attach_cloud"); + item.setAttribute( + "oncommand", + "attachFileByAccountKey(event.target.cloudProviderAccountKey); event.stopPropagation();" + ); + + if (itemObject.class) { + item.setAttribute("class", itemObject.class); + item.setAttribute("image", itemObject.image); + } + + // Add the menu item to the UI. + if (toolbarPopup) { + toolbarPopup.appendChild(item.cloneNode(true)).cloudProviderAccountKey = + itemObject.cloudProviderAccountKey; + } + if (optionsPopup) { + // This one doesn't need to clone, just use the item itself. + optionsPopup.appendChild(item).cloudProviderAccountKey = itemObject.cloudProviderAccountKey; + } + } +} + +/** + * Send a message to attach a file using a given cloud provider, + * to be identified by the cloud provider's accountKey. + * + * @param {string} aAccountKey - The accountKey for a cloud provider + */ +function attachFileByAccountKey(aAccountKey) { + sendMessage({ command: "attachFileByAccountKey", accountKey: aAccountKey }); +} + +/** + * Updates the save controls depending on whether the event has attendees + * + * @param {boolean} aSendNotSave + */ +function updateSaveControls(aSendNotSave) { + if (window.calItemSaveControls && window.calItemSaveControls.state == aSendNotSave) { + return; + } + + let saveBtn = document.getElementById("button-save"); + let saveandcloseBtn = document.getElementById("button-saveandclose"); + let saveMenu = + document.getElementById("item-save-menuitem") || + document.getElementById("calendar-save-menuitem"); + let saveandcloseMenu = + document.getElementById("item-saveandclose-menuitem") || + document.getElementById("calendar-save-and-close-menuitem"); + + // we store the initial label and tooltip values to be able to reset later + if (!window.calItemSaveControls) { + window.calItemSaveControls = { + state: false, + saveMenu: { label: saveMenu.label }, + saveandcloseMenu: { label: saveandcloseMenu.label }, + saveBtn: null, + saveandcloseBtn: null, + }; + // we need to check for each button whether it exists since toolbarbuttons + // can be removed by customizing + if (saveBtn) { + window.window.calItemSaveControls.saveBtn = { + label: saveBtn.label, + tooltiptext: saveBtn.tooltip, + }; + } + if (saveandcloseBtn) { + window.window.calItemSaveControls.saveandcloseBtn = { + label: saveandcloseBtn.label, + tooltiptext: saveandcloseBtn.tooltip, + }; + } + } + + // we update labels and tooltips but leave accesskeys as they are + window.calItemSaveControls.state = aSendNotSave; + if (aSendNotSave) { + if (saveBtn) { + saveBtn.label = cal.l10n.getString("calendar-event-dialog", "saveandsendButtonLabel"); + saveBtn.tooltiptext = cal.l10n.getString("calendar-event-dialog", "saveandsendButtonTooltip"); + saveBtn.setAttribute("mode", "send"); + } + if (saveandcloseBtn) { + saveandcloseBtn.label = cal.l10n.getString( + "calendar-event-dialog", + "sendandcloseButtonLabel" + ); + saveandcloseBtn.tooltiptext = cal.l10n.getString( + "calendar-event-dialog", + "sendandcloseButtonTooltip" + ); + saveandcloseBtn.setAttribute("mode", "send"); + } + saveMenu.label = cal.l10n.getString("calendar-event-dialog", "saveandsendMenuLabel"); + saveandcloseMenu.label = cal.l10n.getString("calendar-event-dialog", "sendandcloseMenuLabel"); + } else { + if (saveBtn) { + saveBtn.label = window.calItemSaveControls.saveBtn.label; + saveBtn.tooltiptext = window.calItemSaveControls.saveBtn.tooltip; + saveBtn.removeAttribute("mode"); + } + if (saveandcloseBtn) { + saveandcloseBtn.label = window.calItemSaveControls.saveandcloseBtn.label; + saveandcloseBtn.tooltiptext = window.calItemSaveControls.saveandcloseBtn.tooltip; + saveandcloseBtn.removeAttribute("mode"); + } + saveMenu.label = window.calItemSaveControls.saveMenu.label; + saveandcloseMenu.label = window.calItemSaveControls.saveandcloseMenu.label; + } +} diff --git a/comm/calendar/base/content/item-editing/calendar-item-toolbar.inc.xhtml b/comm/calendar/base/content/item-editing/calendar-item-toolbar.inc.xhtml new file mode 100644 index 0000000000..5d0c744086 --- /dev/null +++ b/comm/calendar/base/content/item-editing/calendar-item-toolbar.inc.xhtml @@ -0,0 +1,164 @@ +# 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/. + + <toolbarbutton id="button-save" + mode="dialog" + class="cal-event-toolbarbutton toolbarbutton-1" + label="&event.toolbar.save.label2;" + tooltiptext="&event.toolbar.save.tooltip2;" + command="cmd_save"/> + <toolbarbutton id="button-saveandclose" + mode="dialog" + class="cal-event-toolbarbutton toolbarbutton-1" + label="&event.toolbar.saveandclose.label;" + tooltiptext="&event.toolbar.saveandclose.tooltip;" + command="cmd_accept"/> + <toolbarbutton id="button-attendees" + mode="dialog" + class="cal-event-toolbarbutton toolbarbutton-1 event-only" + disable-on-readonly="true" + label="&event.toolbar.attendees.label;" + tooltiptext="&event.toolbar.attendees.tooltip;" + command="cmd_attendees"/> + <toolbarbutton id="button-privacy" + mode="dialog" + class="cal-event-toolbarbutton toolbarbutton-1" + type="menu" + wantdropmarker="true" + disable-on-readonly="true" + label="&event.toolbar.privacy.label;" + tooltiptext="&event.toolbar.privacy.tooltip;"> + <menupopup id="event-privacy-menupopup"> + <menuitem id="event-privacy-public-menuitem" + name="event-privacy-group" + label="&event.menu.options.privacy.public.label;" + type="radio" + privacy="PUBLIC" + oncommand="editPrivacy(this, event)"/> + <menuitem id="event-privacy-confidential-menuitem" + name="event-privacy-group" + label="&event.menu.options.privacy.confidential.label;" + type="radio" + privacy="CONFIDENTIAL" + oncommand="editPrivacy(this, event)"/> + <menuitem id="event-privacy-private-menuitem" + name="event-privacy-group" + label="&event.menu.options.privacy.private.label;" + type="radio" + privacy="PRIVATE" + oncommand="editPrivacy(this, event)"/> + </menupopup> + </toolbarbutton> + <toolbarbutton id="button-url" + mode="dialog" + class="cal-event-toolbarbutton toolbarbutton-1" + type="menu" + wantdropmarker="true" + label="&event.attachments.menubutton.label;" + tooltiptext="&event.toolbar.attachments.tooltip;" + disable-on-readonly="true"> + <menupopup id="button-attach-menupopup"> + <menuitem id="button-attach-url" + label="&event.attachments.url.label;" + command="cmd_attach_url"/> + <!-- Additional items are added here in loadCloudProviders(). --> + </menupopup> + </toolbarbutton> + <toolbarbutton id="button-delete" + mode="dialog" + class="cal-event-toolbarbutton toolbarbutton-1" + label="&event.toolbar.delete.label;" + tooltiptext="&event.toolbar.delete.tooltip;" + command="cmd_item_delete" + disable-on-readonly="true"/> + <toolbarbutton id="button-priority" + mode="dialog" + class="cal-event-toolbarbutton toolbarbutton-1" + type="menu" + wantdropmarker="true" + disable-on-readonly="true" + label="&event.menu.options.priority2.label;" + tooltiptext="&event.toolbar.priority.tooltip;"> + <menupopup id="event-priority-menupopup"> + <menuitem id="event-priority-none-menuitem" + name="event-priority-group" + label="&event.menu.options.priority.notspecified.label;" + type="radio" + command="cmd_priority_none"/> + <menuitem id="event-priority-low-menuitem" + name="event-priority-group" + label="&event.menu.options.priority.low.label;" + type="radio" + command="cmd_priority_low"/> + <menuitem id="event-priority-normal-menuitem" + name="event-priority-group" + label="&event.menu.options.priority.normal.label;" + type="radio" + command="cmd_priority_normal"/> + <menuitem id="event-priority-high-menuitem" + name="event-priority-group" + label="&event.menu.options.priority.high.label;" + type="radio" + command="cmd_priority_high"/> + </menupopup> + </toolbarbutton> + <toolbarbutton id="button-status" + mode="dialog" + class="cal-event-toolbarbutton toolbarbutton-1 event-only" + type="menu" + wantdropmarker="true" + disable-on-readonly="true" + label="&newevent.status.label;" + tooltiptext="&event.toolbar.status.tooltip;"> + <menupopup id="event-status-menupopup"> + <menuitem id="event-status-none-menuitem" + name="event-status-group" + label="&newevent.eventStatus.none.label;" + type="radio" + command="cmd_status_none"/> + <menuitem id="event-status-tentative-menuitem" + name="event-status-group" + label="&newevent.status.tentative.label;" + type="radio" + command="cmd_status_tentative"/> + <menuitem id="event-status-confirmed-menuitem" + name="event-status-group" + label="&newevent.status.confirmed.label;" + type="radio" + command="cmd_status_confirmed"/> + <menuitem id="event-status-cancelled-menuitem" + name="event-status-group" + label="&newevent.eventStatus.cancelled.label;" + type="radio" + command="cmd_status_cancelled"/> + </menupopup> + </toolbarbutton> + <toolbarbutton id="button-freebusy" + mode="dialog" + class="cal-event-toolbarbutton toolbarbutton-1 event-only" + type="menu" + wantdropmarker="true" + disable-on-readonly="true" + label="&event.menu.options.show.time.label;" + tooltiptext="&event.toolbar.freebusy.tooltip;"> + <menupopup id="event-freebusy-menupopup"> + <menuitem id="event-freebusy-busy-menuitem" + name="event-freebusy-group" + label="&event.menu.options.show.time.busy.label;" + type="radio" + command="cmd_showtimeas_busy"/> + <menuitem id="event-freebusy-free-menuitem" + name="event-freebusy-group" + label="&event.menu.options.show.time.free.label;" + type="radio" + command="cmd_showtimeas_free"/> + </menupopup> + </toolbarbutton> + <toolbarbutton id="button-timezones" + mode="dialog" + type="checkbox" + class="cal-event-toolbarbutton toolbarbutton-1" + label="&event.menu.options.timezone2.label;" + tooltiptext="&event.menu.options.timezone2.label;" + command="cmd_timezone"/> diff --git a/comm/calendar/base/content/item-editing/calendar-task-editing.js b/comm/calendar/base/content/item-editing/calendar-task-editing.js new file mode 100644 index 0000000000..33f67f81d7 --- /dev/null +++ b/comm/calendar/base/content/item-editing/calendar-task-editing.js @@ -0,0 +1,181 @@ +/* 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/. */ + +/* import-globals-from ../calendar-management.js */ +/* import-globals-from ../calendar-ui-utils.js */ +/* import-globals-from calendar-item-editing.js */ + +var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + +var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs"); + +XPCOMUtils.defineLazyModuleGetters(this, { + CalTodo: "resource:///modules/CalTodo.jsm", +}); + +/** + * Used by the "quick add" feature for tasks, for example in the task view or + * the uniinder-todo. + * + * NOTE: many of the following methods are called without taskEdit being the + * |this| object. + */ + +var taskEdit = { + /** + * Helper function to set readonly and aria-disabled states and the value + * for a given target. + * + * @param aTarget The ID or XUL node to set the value + * @param aDisable A boolean if the target should be disabled. + * @param aValue The value that should be set on the target. + */ + setupTaskField(aTarget, aDisable, aValue) { + aTarget.value = aValue; + aTarget.readOnly = aDisable; + aTarget.ariaDisabled = aDisable; + }, + + /** + * Handler function to call when the quick-add input gains focus. + * + * @param aEvent The DOM focus event + */ + onFocus(aEvent) { + let edit = aEvent.target; + let calendar = getSelectedCalendar(); + edit.showsInstructions = true; + + if (calendar.getProperty("capabilities.tasks.supported") === false) { + taskEdit.setupTaskField(edit, true, cal.l10n.getCalString("taskEditInstructionsCapability")); + } else if (cal.acl.isCalendarWritable(calendar)) { + edit.showsInstructions = false; + taskEdit.setupTaskField(edit, false, edit.savedValue || ""); + } else { + taskEdit.setupTaskField(edit, true, cal.l10n.getCalString("taskEditInstructionsReadonly")); + } + }, + + /** + * Handler function to call when the quick-add input loses focus. + * + * @param aEvent The DOM blur event + */ + onBlur(aEvent) { + let edit = aEvent.target; + let calendar = getSelectedCalendar(); + if (!calendar) { + // this must be a first run, we don't have a calendar yet + return; + } + + if (calendar.getProperty("capabilities.tasks.supported") === false) { + taskEdit.setupTaskField(edit, true, cal.l10n.getCalString("taskEditInstructionsCapability")); + } else if (cal.acl.isCalendarWritable(calendar)) { + if (!edit.showsInstructions) { + edit.savedValue = edit.value || ""; + } + taskEdit.setupTaskField(edit, false, cal.l10n.getCalString("taskEditInstructions")); + } else { + taskEdit.setupTaskField(edit, true, cal.l10n.getCalString("taskEditInstructionsReadonly")); + } + + edit.showsInstructions = true; + }, + + /** + * Handler function to call on keypress for the quick-add input. + * + * @param aEvent The DOM keypress event + */ + onKeyPress(aEvent) { + if (aEvent.key == "Enter") { + let edit = aEvent.target; + if (edit.value && edit.value.length > 0) { + let item = new CalTodo(); + setDefaultItemValues(item); + item.title = edit.value; + + edit.value = ""; + doTransaction("add", item, item.calendar, null, null); + } + } + }, + + /** + * Helper function to call onBlur for all fields with class name + * "task-edit-field". + */ + callOnBlurForAllTaskFields() { + let taskEditFields = document.getElementsByClassName("task-edit-field"); + for (let i = 0; i < taskEditFields.length; i++) { + taskEdit.onBlur({ target: taskEditFields[i] }); + } + }, + + /** + * Load function to set up all quick-add inputs. The input must + * have the class "task-edit-field". + */ + onLoad(aEvent) { + cal.view.getCompositeCalendar(window).addObserver(taskEdit.compositeObserver); + taskEdit.callOnBlurForAllTaskFields(); + }, + + /** + * Window load function to clean up all quick-add fields. + */ + onUnload() { + cal.view.getCompositeCalendar(window).removeObserver(taskEdit.compositeObserver); + }, + + /** + * Observer to watch for changes to the selected calendar. + * + * @see calIObserver + * @see calICompositeObserver + */ + compositeObserver: { + QueryInterface: ChromeUtils.generateQI(["calIObserver", "calICompositeObserver"]), + + // calIObserver: + onStartBatch() {}, + onEndBatch() {}, + onLoad(aCalendar) {}, + onAddItem(aItem) {}, + onModifyItem(aNewItem, aOldItem) {}, + onDeleteItem(aDeletedItem) {}, + onError(aCalendar, aErrNo, aMessage) {}, + + onPropertyChanged(aCalendar, aName, aValue, aOldValue) { + if (aCalendar.id != getSelectedCalendar().id) { + // Optimization: if the given calendar isn't the selected calendar, + // then we don't need to change any readonly/disabled states. + return; + } + + switch (aName) { + case "readOnly": + case "disabled": { + taskEdit.callOnBlurForAllTaskFields(); + break; + } + } + }, + + onPropertyDeleting(aCalendar, aName) { + // Since the old value is not used directly in onPropertyChanged, + // but should not be the same as the value, set it to a different + // value. + this.onPropertyChanged(aCalendar, aName, null, null); + }, + + // calICompositeObserver: + onCalendarAdded(aCalendar) {}, + onCalendarRemoved(aCalendar) {}, + onDefaultCalendarChanged(aNewDefault) { + taskEdit.callOnBlurForAllTaskFields(); + }, + }, +}; |