diff options
author | Matt A. Tobin <email@mattatobin.com> | 2022-03-26 20:18:05 -0500 |
---|---|---|
committer | Matt A. Tobin <email@mattatobin.com> | 2022-03-26 20:18:05 -0500 |
commit | c3dc8a1f81c2148a64bc99a194da4c10614e9b95 (patch) | |
tree | 6915845b08018db4ee37f09a7a8ea9b4c17ebb27 /calendar/base | |
parent | c0d30f29a0a1d418442c9dc05c83ac6ef2921d15 (diff) | |
download | aura-central-c3dc8a1f81c2148a64bc99a194da4c10614e9b95.tar.gz |
Manually re-add calendar
Diffstat (limited to 'calendar/base')
308 files changed, 76200 insertions, 0 deletions
diff --git a/calendar/base/content/agenda-listbox.js b/calendar/base/content/agenda-listbox.js new file mode 100644 index 000000000..ced5811ee --- /dev/null +++ b/calendar/base/content/agenda-listbox.js @@ -0,0 +1,1130 @@ +/* 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/. */ + +Components.utils.import("resource://gre/modules/Services.jsm"); +Components.utils.import("resource://gre/modules/Preferences.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + +function Synthetic(aOpen, aDuration, aMultiday) { + this.open = aOpen; + this.duration = aDuration; + this.multiday = aMultiday; +} + +var agendaListbox = { + agendaListboxControl: null, + mPendingRefreshJobs: null, + kDefaultTimezone: null, + showsToday: false, + soonDays: 5 +}; + +/** + * Initialize the agenda listbox, used on window load. + */ +agendaListbox.init = function() { + this.agendaListboxControl = document.getElementById("agenda-listbox"); + this.agendaListboxControl.removeAttribute("suppressonselect"); + let showTodayHeader = (document.getElementById("today-header-hidden").getAttribute("checked") == "true"); + let showTomorrowHeader = (document.getElementById("tomorrow-header-hidden").getAttribute("checked") == "true"); + let showSoonHeader = (document.getElementById("nextweek-header-hidden").getAttribute("checked") == "true"); + this.today = new Synthetic(showTodayHeader, 1, false); + this.addPeriodListItem(this.today, "today-header"); + this.tomorrow = new Synthetic(showTomorrowHeader, 1, false); + this.soonDays = getSoondaysPreference(); + this.soon = new Synthetic(showSoonHeader, this.soonDays, true); + this.periods = [this.today, this.tomorrow, this.soon]; + this.mPendingRefreshJobs = new Map(); + + let prefObserver = { + observe: function(aSubject, aTopic, aPrefName) { + switch (aPrefName) { + case "calendar.agendaListbox.soondays": + agendaListbox.soonDays = getSoondaysPreference(); + agendaListbox.updateSoonSection(); + break; + } + } + }; + Services.prefs.addObserver("calendar.agendaListbox", prefObserver, false); + + // Make sure the agenda listbox is unloaded + window.addEventListener("unload", () => { + Services.prefs.removeObserver("calendar.agendaListbox", prefObserver); + this.uninit(); + }, false); +}; + +/** + * Clean up the agenda listbox, used on window unload. + */ +agendaListbox.uninit = function() { + if (this.calendar) { + this.calendar.removeObserver(this.calendarObserver); + } + + for (let period of this.periods) { + if (period.listItem) { + period.listItem.getCheckbox() + .removeEventListener("CheckboxStateChange", + this.onCheckboxChange, + true); + } + } +}; + +/** + * Adds a period item to the listbox. This is a section of the today pane like + * "Today", "Tomorrow", and is usually a <agenda-checkbox-richlist-item> tag. A + * copy of the template node is made and added to the agenda listbox. + * + * @param aPeriod The period item to add. + * @param aItemId The id of an <agenda-checkbox-richlist-item> to add to, + * without the "-hidden" suffix. + */ +agendaListbox.addPeriodListItem = function(aPeriod, aItemId) { + aPeriod.listItem = document.getElementById(aItemId + "-hidden").cloneNode(true); + agendaListbox.agendaListboxControl.appendChild(aPeriod.listItem); + aPeriod.listItem.id = aItemId; + aPeriod.listItem.getCheckbox().setChecked(aPeriod.open); + aPeriod.listItem.getCheckbox().addEventListener("CheckboxStateChange", this.onCheckboxChange, true); +}; + +/** + * Remove a period item from the agenda listbox. + * @see agendaListbox::addPeriodListItem + */ +agendaListbox.removePeriodListItem = function(aPeriod) { + if (aPeriod.listItem) { + aPeriod.listItem.getCheckbox().removeEventListener("CheckboxStateChange", this.onCheckboxChange, true); + if (aPeriod.listItem) { + aPeriod.listItem.remove(); + aPeriod.listItem = null; + } + } +}; + +/** + * Handler function called when changing the checkbox state on period items. + * + * @param event The DOM event that triggered the checkbox state change. + */ +agendaListbox.onCheckboxChange = function(event) { + let periodCheckbox = event.target; + let lopen = (periodCheckbox.getAttribute("checked") == "true"); + let listItem = getParentNodeOrThis(periodCheckbox, "agenda-checkbox-richlist-item"); + let period = listItem.getItem(); + period.open = lopen; + // as the agenda-checkboxes are only transient we have to set the "checked" + // attribute at their hidden origins to make that attribute persistent. + document.getElementById(listItem.id + "-hidden").setAttribute("checked", + periodCheckbox.getAttribute("checked")); + if (lopen) { + agendaListbox.refreshCalendarQuery(period.start, period.end); + } else { + listItem = listItem.nextSibling; + let leaveloop; + do { + leaveloop = (listItem == null); + if (!leaveloop) { + let nextItemSibling = listItem.nextSibling; + leaveloop = !agendaListbox.isEventListItem(listItem); + if (!leaveloop) { + listItem.remove(); + listItem = nextItemSibling; + } + } + } while (!leaveloop); + } + calendarController.onSelectionChanged({ detail: [] }); +}; + +/** + * Handler function called when an agenda listbox item is selected + * + * @param aListItem The agenda-base-richlist-item that was selected. + */ +agendaListbox.onSelect = function(aListItem) { + let listbox = document.getElementById("agenda-listbox"); + let item = aListItem || listbox.selectedItem; + if (aListItem) { + listbox.selectedItem = item; + } + calendarController.onSelectionChanged({ detail: agendaListbox.getSelectedItems() }); +}; + +/** + * Handler function called when the agenda listbox becomes focused + */ +agendaListbox.onFocus = function() { + calendarController.onSelectionChanged({ detail: agendaListbox.getSelectedItems() }); +}; + +/** + * Handler function called when the agenda listbox loses focus. + */ +agendaListbox.onBlur = function() { + calendarController.onSelectionChanged({ detail: [] }); +}; + + +/** + * Handler function called when a key was pressed on the agenda listbox + */ +agendaListbox.onKeyPress = function(aEvent) { + let listItem = aEvent.target; + if (listItem.localName == "richlistbox") { + listItem = listItem.selectedItem; + } + switch (aEvent.keyCode) { + case aEvent.DOM_VK_RETURN: + document.getElementById("agenda_edit_event_command").doCommand(); + break; + case aEvent.DOM_VK_DELETE: + document.getElementById("agenda_delete_event_command").doCommand(); + aEvent.stopPropagation(); + aEvent.preventDefault(); + break; + case aEvent.DOM_VK_LEFT: + if (!this.isEventListItem(listItem)) { + listItem.getCheckbox().setChecked(false); + } + break; + case aEvent.DOM_VK_RIGHT: + if (!this.isEventListItem(listItem)) { + listItem.getCheckbox().setChecked(true); + } + break; + } +}; + +/** + * Calls the event dialog to edit the currently selected item + */ +agendaListbox.editSelectedItem = function() { + let listItem = document.getElementById("agenda-listbox").selectedItem; + if (listItem) { + modifyEventWithDialog(listItem.occurrence, null, true); + } +}; + +/** + * Finds the appropriate period for the given item, i.e finds "Tomorrow" if the + * item occurrs tomorrow. + * + * @param aItem The item to find the period for. + */ +agendaListbox.findPeriodsForItem = function(aItem) { + let retPeriods = []; + for (let i = 0; i < this.periods.length; i++) { + if (this.periods[i].open) { + if (checkIfInRange(aItem, this.periods[i].start, this.periods[i].end)) { + retPeriods.push(this.periods[i]); + } + } + } + return retPeriods; +}; + +/** + * Gets the start of the earliest period shown in the agenda listbox + */ +agendaListbox.getStart = function() { + let retStart = null; + for (let i = 0; i < this.periods.length; i++) { + if (this.periods[i].open) { + retStart = this.periods[i].start; + break; + } + } + return retStart; +}; + +/** + * Gets the end of the latest period shown in the agenda listbox + */ +agendaListbox.getEnd = function() { + let retEnd = null; + for (let i = this.periods.length - 1; i >= 0; i--) { + if (this.periods[i].open) { + retEnd = this.periods[i].end; + break; + } + } + return retEnd; +}; + +/** + * Adds an item to an agenda period before another existing item. + * + * @param aNewItem The calIItemBase to add. + * @param aAgendaItem The existing item to insert before. + * @param aPeriod The period to add the item to. + * @param visible If true, the item should be visible. + * @return The newly created XUL element. + */ +agendaListbox.addItemBefore = function(aNewItem, aAgendaItem, aPeriod, visible) { + let newelement = null; + if (aNewItem.startDate.isDate) { + newelement = createXULElement("agenda-allday-richlist-item"); + } else { + newelement = createXULElement("agenda-richlist-item"); + } + // set the item at the richlistItem. When the duration of the period + // is bigger than 1 (day) the starttime of the item has to include + // information about the day of the item + if (aAgendaItem == null) { + this.agendaListboxControl.appendChild(newelement); + } else { + this.agendaListboxControl.insertBefore(newelement, aAgendaItem); + } + newelement.setOccurrence(aNewItem, aPeriod); + newelement.removeAttribute("selected"); + return newelement; +}; + +/** + * Adds an item to the agenda listbox. This function finds the correct period + * for the item and inserts it correctly so the period stays sorted. + * + * @param aItem The calIItemBase to add. + * @return The newly created XUL element. + */ +agendaListbox.addItem = function(aItem) { + if (!isEvent(aItem)) { + return null; + } + let periods = this.findPeriodsForItem(aItem); + if (periods.length == 0) { + return null; + } + let newlistItem = null; + for (let i = 0; i < periods.length; i++) { + let period = periods[i]; + let complistItem = period.listItem; + let visible = complistItem.getCheckbox().checked; + if (aItem.startDate.isDate && period.duration == 1 && aItem.duration.days == 1) { + if (this.getListItems(aItem, period).length == 0) { + this.addItemBefore(aItem, period.listItem.nextSibling, period, visible); + } + } else { + do { + complistItem = complistItem.nextSibling; + if (this.isEventListItem(complistItem)) { + let compitem = complistItem.occurrence; + if (this.isSameEvent(aItem, compitem)) { + // The same event occurs on several calendars but we only + // display the first one. + // TODO: find a way to display this special circumstance + break; + } else if (this.isBefore(aItem, compitem, period)) { + if (this.isSameEvent(aItem, compitem)) { + newlistItem = this.addItemBefore(aItem, complistItem, period, visible); + break; + } else { + newlistItem = this.addItemBefore(aItem, complistItem, period, visible); + break; + } + } + } else { + newlistItem = this.addItemBefore(aItem, complistItem, period, visible); + break; + } + } while (complistItem); + } + } + return newlistItem; +}; + +/** + * Checks if the given item happens before the comparison item. + * + * @param aItem The item to compare. + * @param aCompItem The item to compare with. + * @param aPeriod The period where the items are inserted. + * @return True, if the aItem happens before aCompItem. + */ +agendaListbox.isBefore = function(aItem, aCompItem, aPeriod) { + let itemDate = this.comparisonDate(aItem, aPeriod); + let compItemDate = this.comparisonDate(aCompItem, aPeriod); + let itemDateEndDate = itemDate.clone(); + itemDateEndDate.day++; + + if (compItemDate.day == itemDate.day) { + // In the same day the order is: + // - all-day events (single day); + // - all-day events spanning multiple days: start, end, intermediate; + // - events and events spanning multiple days: start, end, (sorted by + // time) and intermediate. + if (itemDate.isDate && aItem.duration.days == 1) { + // all-day events with duration one day + return true; + } else if (itemDate.isDate) { + if (aItem.startDate.compare(itemDate) == 0) { + // starting day of an all-day events spannig multiple days + return !compItemDate.isDate || aCompItem.duration.days != 1; + } else if (aItem.endDate.compare(itemDateEndDate) == 0) { + // ending day of an all-day events spannig multiple days + return !compItemDate.isDate || + (aCompItem.duration.days != 1 && + aCompItem.startDate.compare(compItemDate) != 0); + } else { + // intermediate day of an all-day events spannig multiple days + return !compItemDate.isDate; + } + } else if (aCompItem.startDate.isDate) { + return false; + } + } + // Non all-day event sorted by date-time. When equal, sorted by start + // date-time then by end date-time. + let comp = itemDate.compare(compItemDate); + if (comp == 0) { + comp = aItem.startDate.compare(aCompItem.startDate); + if (comp == 0) { + comp = aItem.endDate.compare(aCompItem.endDate); + } + } + return (comp <= 0); +}; + +/** + * Returns the start or end date of an item according to which of them + * must be displayed in a given period of the agenda + * + * @param aItem The item to compare. + * @param aPeriod The period where the item is inserted. + * @return The start or end date of the item showed in the agenda. + */ +agendaListbox.comparisonDate = function(aItem, aPeriod) { + let periodStartDate = aPeriod.start.clone(); + periodStartDate.isDate = true; + let periodEndDate = aPeriod.end.clone(); + periodEndDate.day--; + let startDate = aItem.startDate.clone(); + startDate.isDate = true; + let endDate = aItem.endDate.clone(); + + let endDateToReturn = aItem.endDate.clone(); + if (aItem.startDate.isDate && aPeriod.duration == 1) { + endDateToReturn = periodEndDate.clone(); + } else if (endDate.isDate) { + endDateToReturn.day--; + } else if (endDate.hour == 0 && endDate.minute == 0) { + // End at midnight -> end date in the day where midnight occurs + endDateToReturn.day--; + endDateToReturn.hour = 23; + endDateToReturn.minute = 59; + endDateToReturn.second = 59; + } + endDate.isDate = true; + if (startDate.compare(endDate) != 0 && + startDate.compare(periodStartDate) < 0) { + // returns a end date when the item is a multiday event AND + // it starts before the given period + return endDateToReturn; + } + return aItem.startDate.clone(); +}; + +/** + * Gets the listitems for a given item, possibly in a given period. + * + * @param aItem The item to get the list items for. + * @param aPeriod (optional) the period to search in. + * @return An array of list items for the given item. + */ +agendaListbox.getListItems = function(aItem, aPeriod) { + let retlistItems = []; + let periods = [aPeriod]; + if (!aPeriod) { + periods = this.findPeriodsForItem(aItem); + } + if (periods.length > 0) { + for (let i = 0; i < periods.length; i++) { + let period = periods[i]; + let complistItem = period.listItem; + let leaveloop; + do { + complistItem = complistItem.nextSibling; + leaveloop = !this.isEventListItem(complistItem); + if (!leaveloop) { + if (this.isSameEvent(aItem, complistItem.occurrence)) { + retlistItems.push(complistItem); + break; + } + } + } while (!leaveloop); + } + } + return retlistItems; +}; + +/** + * Removes the given item from the agenda listbox + * + * @param aItem The item to remove. + * @param aMoveSelection If true, the selection will be moved to the next + * sibling that is not an period item. + * @return Returns true if the removed item was selected. + */ +agendaListbox.deleteItem = function(aItem, aMoveSelection) { + let isSelected = false; + let listItems = this.getListItems(aItem); + if (listItems.length > 0) { + for (let i = listItems.length - 1; i >= 0; i--) { + let listItem = listItems[i]; + let isSelected2 = listItem.selected; + if (isSelected2 && !isSelected) { + isSelected = true; + if (aMoveSelection) { + this.moveSelection(); + } + } + listItem.remove(); + } + } + return isSelected; +}; + +/** + * Remove all items belonging to the specified calendar. + * + * @param aCalendar The item to compare. + */ +agendaListbox.deleteItemsFromCalendar = function(aCalendar) { + let childNodes = Array.from(this.agendaListboxControl.childNodes); + for (let childNode of childNodes) { + if (childNode && childNode.occurrence && + childNode.occurrence.calendar.id == aCalendar.id) { + childNode.remove(); + } + } +}; + +/** + * Compares two items to see if they have the same id and their start date + * matches + * + * @param aItem The item to compare. + * @param aCompItem The item to compare with. + * @return True, if the items match with the above noted criteria. + */ +agendaListbox.isSameEvent = function(aItem, aCompItem) { + return aItem.id == aCompItem.id && + aItem[calGetStartDateProp(aItem)].compare(aCompItem[calGetStartDateProp(aCompItem)]) == 0; +}; + +/** + * Checks if the currently selected node in the listbox is an Event item (not a + * period item). + * + * @return True, if the node is not a period item. + */ +agendaListbox.isEventSelected = function() { + let listItem = this.agendaListboxControl.selectedItem; + if (listItem) { + return this.isEventListItem(listItem); + } + return false; +}; + +/** + * Delete the selected item from its calendar (if it is an event item) + * + * @param aDoNotConfirm If true, the user will not be prompted. + */ +agendaListbox.deleteSelectedItem = function(aDoNotConfirm) { + let listItem = this.agendaListboxControl.selectedItem; + if (this.isEventListItem(listItem)) { + let selectedItems = [listItem.occurrence]; + calendarViewController.deleteOccurrences(selectedItems.length, + selectedItems, + false, + aDoNotConfirm); + } +}; + +/** + * If a Period item is targeted by the passed DOM event, opens the event dialog + * with the period's start date prefilled. + * + * @param aEvent The DOM event that targets the period. + */ +agendaListbox.createNewEvent = function(aEvent) { + if (!this.isEventListItem(aEvent.target)) { + // Create new event for the date currently displayed in the agenda. Setting + // isDate = true automatically makes the start time be the next full hour. + let eventStart = agendaListbox.today.start.clone(); + eventStart.isDate = true; + if (calendarController.isCommandEnabled("calendar_new_event_command")) { + createEventWithDialog(getSelectedCalendar(), eventStart); + } + } +}; + +/** + * Sets up the context menu for the agenda listbox + * + * @param popup The <menupopup> element to set up. + */ +agendaListbox.setupContextMenu = function(popup) { + let listItem = this.agendaListboxControl.selectedItem; + let enabled = this.isEventListItem(listItem); + let menuitems = popup.childNodes; + for (let i = 0; i < menuitems.length; i++) { + setBooleanAttribute(menuitems[i], "disabled", !enabled); + } + + let menu = document.getElementById("calendar-today-pane-menu-attendance-menu"); + setupAttendanceMenu(menu, agendaListbox.getSelectedItems({})); +}; + + +/** + * Refreshes the agenda listbox. If aStart or aEnd is not passed, the agenda + * listbox's limiting dates will be used. + * + * @param aStart (optional) The start date for the item query. + * @param aEnd (optional) The end date for the item query. + * @param aCalendar (optional) If specified, the single calendar from + * which the refresh will occur. + */ +agendaListbox.refreshCalendarQuery = function(aStart, aEnd, aCalendar) { + let refreshJob = { + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calIOperationListener]), + agendaListbox: this, + calendar: null, + calId: null, + operation: null, + cancelled: false, + + onOperationComplete: function(aOpCalendar, aStatus, aOperationType, aId, aDateTime) { + if (this.agendaListbox.mPendingRefreshJobs.has(this.calId)) { + this.agendaListbox.mPendingRefreshJobs.delete(this.calId); + } + + if (!this.cancelled) { + setCurrentEvent(); + } + }, + + onGetResult: function(aOpCalendar, aStatus, aItemType, aDetail, aCount, aItems) { + if (this.cancelled || !Components.isSuccessCode(aStatus)) { + return; + } + for (let item of aItems) { + this.agendaListbox.addItem(item); + } + }, + + cancel: function() { + this.cancelled = true; + let operation = cal.wrapInstance(this.operation, Components.interfaces.calIOperation); + if (operation && operation.isPending) { + operation.cancel(); + this.operation = null; + } + }, + + execute: function() { + if (!(aStart || aEnd || aCalendar)) { + this.agendaListbox.removeListItems(); + } + + if (!aCalendar) { + aCalendar = this.agendaListbox.calendar; + } + if (!aStart) { + aStart = this.agendaListbox.getStart(); + } + if (!aEnd) { + aEnd = this.agendaListbox.getEnd(); + } + if (!(aStart || aEnd || aCalendar)) { + return; + } + + if (aCalendar.type == "composite") { + // we're refreshing from the composite calendar, so we can cancel + // all other pending refresh jobs. + this.calId = "composite"; + for (let job of this.agendaListbox.mPendingRefreshJobs.values()) { + job.cancel(); + } + this.agendaListbox.mPendingRefreshJobs.clear(); + } else { + this.calId = aCalendar.id; + if (this.agendaListbox.mPendingRefreshJobs.has(this.calId)) { + this.agendaListbox.mPendingRefreshJobs.get(this.calId).cancel(); + this.agendaListbox.mPendingRefreshJobs.delete(this.calId); + } + } + this.calendar = aCalendar; + + let filter = this.calendar.ITEM_FILTER_CLASS_OCCURRENCES | + this.calendar.ITEM_FILTER_TYPE_EVENT; + let operation = this.calendar.getItems(filter, 0, aStart, aEnd, this); + operation = cal.wrapInstance(operation, Components.interfaces.calIOperation); + if (operation && operation.isPending) { + this.operation = operation; + this.agendaListbox.mPendingRefreshJobs.set(this.calId, this); + } + } + }; + + refreshJob.execute(); +}; + +/** + * Sets up the calendar for the agenda listbox. + */ +agendaListbox.setupCalendar = function() { + this.init(); + if (this.calendar == null) { + this.calendar = getCompositeCalendar(); + } + if (this.calendar) { + // XXX This always gets called, does that happen on purpose? + this.calendar.removeObserver(this.calendarObserver); + } + this.calendar.addObserver(this.calendarObserver); + if (this.mListener) { + this.mListener.updatePeriod(); + } +}; + +/** + * Refreshes the period dates, especially when a period is showing "today". + * Usually called at midnight to update the agenda pane. Also retrieves the + * items from the calendar. + * + * @see #refreshCalendarQuery + * @param newDate The first date to show if the agenda pane doesn't show + * today. + */ +agendaListbox.refreshPeriodDates = function(newDate) { + this.kDefaultTimezone = calendarDefaultTimezone(); + // Today: now until midnight of tonight + let oldshowstoday = this.showstoday; + this.showstoday = this.showsToday(newDate); + if ((this.showstoday) && (!oldshowstoday)) { + this.addPeriodListItem(this.tomorrow, "tomorrow-header"); + this.addPeriodListItem(this.soon, "nextweek-header"); + } else if (!this.showstoday) { + this.removePeriodListItem(this.tomorrow); + this.removePeriodListItem(this.soon); + } + newDate.isDate = true; + for (let i = 0; i < this.periods.length; i++) { + let curPeriod = this.periods[i]; + newDate.hour = newDate.minute = newDate.second = 0; + if (i == 0 && this.showstoday) { + curPeriod.start = now(); + } else { + curPeriod.start = newDate.clone(); + } + newDate.day += curPeriod.duration; + curPeriod.end = newDate.clone(); + curPeriod.listItem.setItem(curPeriod, this.showstoday); + } + this.refreshCalendarQuery(); +}; + +/** + * Adds a listener to this agenda listbox. + * + * @param aListener The listener to add. + */ +agendaListbox.addListener = function(aListener) { + this.mListener = aListener; +}; + +/** + * Checks if the agenda listbox is showing "today". Without arguments, this + * function assumes the today attribute of the agenda listbox. + * + * @param aStartDate (optional) The day to check if its "today". + * @return Returns true if today is shown. + */ +agendaListbox.showsToday = function(aStartDate) { + let lstart = aStartDate; + if (!lstart) { + lstart = this.today.start; + } + let lshowsToday = sameDay(now(), lstart); + if (lshowsToday) { + this.periods = [this.today, this.tomorrow, this.soon]; + } else { + this.periods = [this.today]; + } + return lshowsToday; +}; + +/** + * Moves the selection. Moves down unless the next item is a period item, in + * which case the selection moves up. + */ +agendaListbox.moveSelection = function() { + if (this.isEventListItem(this.agendaListboxControl.selectedItem.nextSibling)) { + this.agendaListboxControl.goDown(); + } else { + this.agendaListboxControl.goUp(); + } +}; + +/** + * Gets an array of selected items. If a period node is selected, it is not + * included. + * + * @return An array with all selected items. + */ +agendaListbox.getSelectedItems = function() { + let items = []; + if (this.isEventListItem(this.agendaListboxControl.selectedItem)) { + // If at some point we support selecting multiple items, this array can + // be expanded. + items = [this.agendaListboxControl.selectedItem.occurrence]; + } + return items; +}; + +/** + * Checks if the passed node in the listbox is an Event item (not a + * period item). + * + * @param aListItem The node to check for. + * @return True, if the node is not a period item. + */ +agendaListbox.isEventListItem = function(aListItem) { + let isListItem = (aListItem != null); + if (isListItem) { + let localName = aListItem.localName; + isListItem = (localName == "agenda-richlist-item" || + localName == "agenda-allday-richlist-item"); + } + return isListItem; +}; + +/** + * Removes all Event items, keeping the period items intact. + */ +agendaListbox.removeListItems = function() { + let listItem = this.agendaListboxControl.lastChild; + if (listItem) { + let leaveloop = false; + do { + let newlistItem = null; + if (listItem) { + newlistItem = listItem.previousSibling; + } else { + leaveloop = true; + } + if (this.isEventListItem(listItem)) { + if (listItem == this.agendaListboxControl.firstChild) { + leaveloop = true; + } else { + listItem.remove(); + } + } + listItem = newlistItem; + } while (!leaveloop); + } +}; + +/** + * Gets the list item node by its associated event's hashId. + * + * @return The XUL node if successful, otherwise null. + */ +agendaListbox.getListItemByHashId = function(ahashId) { + let listItem = this.agendaListboxControl.firstChild; + let leaveloop = false; + do { + if (this.isEventListItem(listItem)) { + if (listItem.occurrence.hashId == ahashId) { + return listItem; + } + } + listItem = listItem.nextSibling; + leaveloop = (listItem == null); + } while (!leaveloop); + return null; +}; + +/** + * The operation listener used for calendar queries. + * Implements calIOperationListener. + */ +agendaListbox.calendarOpListener = { agendaListbox: agendaListbox }; + +/** + * Calendar and composite observer, used to keep agenda listbox up to date. + * @see calIObserver + * @see calICompositeObserver + */ +agendaListbox.calendarObserver = { agendaListbox: agendaListbox }; + +agendaListbox.calendarObserver.QueryInterface = function(aIID) { + if (!aIID.equals(Components.interfaces.calIObserver) && + !aIID.equals(Components.interfaces.calICompositeObserver) && + !aIID.equals(Components.interfaces.nsISupports)) { + throw Components.results.NS_ERROR_NO_INTERFACE; + } + return this; +}; + +// calIObserver: +agendaListbox.calendarObserver.onStartBatch = function() { +}; + +agendaListbox.calendarObserver.onEndBatch = function() { +}; + +agendaListbox.calendarObserver.onLoad = function() { + this.agendaListbox.refreshCalendarQuery(); +}; + +agendaListbox.calendarObserver.onAddItem = function(item) { + if (!isEvent(item)) { + return; + } + // get all sub items if it is a recurring item + let occs = this.getOccurrencesBetween(item); + occs.forEach(this.agendaListbox.addItem, this.agendaListbox); + setCurrentEvent(); +}; + +agendaListbox.calendarObserver.getOccurrencesBetween = function(aItem) { + let occs = []; + let start = this.agendaListbox.getStart(); + let end = this.agendaListbox.getEnd(); + if (start && end) { + occs = aItem.getOccurrencesBetween(start, end, {}); + } + return occs; +}; + +agendaListbox.calendarObserver.onDeleteItem = function(item, rebuildFlag) { + this.onLocalDeleteItem(item, true); +}; + +agendaListbox.calendarObserver.onLocalDeleteItem = function(item, moveSelection) { + if (!isEvent(item)) { + return false; + } + let selectedItemHashId = -1; + // get all sub items if it is a recurring item + let occs = this.getOccurrencesBetween(item); + for (let i = 0; i < occs.length; i++) { + let isSelected = this.agendaListbox.deleteItem(occs[i], moveSelection); + if (isSelected) { + selectedItemHashId = occs[i].hashId; + } + } + return selectedItemHashId; +}; + +agendaListbox.calendarObserver.onModifyItem = function(newItem, oldItem) { + let selectedItemHashId = this.onLocalDeleteItem(oldItem, false); + if (!isEvent(newItem)) { + return; + } + this.onAddItem(newItem); + if (selectedItemHashId != -1) { + let listItem = agendaListbox.getListItemByHashId(selectedItemHashId); + if (listItem) { + agendaListbox.agendaListboxControl.clearSelection(); + agendaListbox.agendaListboxControl.ensureElementIsVisible(listItem); + agendaListbox.agendaListboxControl.selectedItem = listItem; + } + } + setCurrentEvent(); +}; + +agendaListbox.calendarObserver.onError = function(cal, errno, msg) {}; + +agendaListbox.calendarObserver.onPropertyChanged = function(aCalendar, aName, aValue, aOldValue) { + switch (aName) { + case "disabled": + this.agendaListbox.refreshCalendarQuery(); + break; + case "color": + for (let node = agendaListbox.agendaListboxControl.firstChild; + node; + node = node.nextSibling) { + // Change color on all nodes that don't do so themselves, which + // is currently only he agenda-richlist-item + if (node.localName != "agenda-richlist-item") { + continue; + } + node.refreshColor(); + } + break; + } +}; + +agendaListbox.calendarObserver.onPropertyDeleting = function(aCalendar, aName) { + this.onPropertyChanged(aCalendar, aName, null, null); +}; + + +agendaListbox.calendarObserver.onCalendarRemoved = function(aCalendar) { + if (!aCalendar.getProperty("disabled")) { + this.agendaListbox.deleteItemsFromCalendar(aCalendar); + } +}; + +agendaListbox.calendarObserver.onCalendarAdded = function(aCalendar) { + if (!aCalendar.getProperty("disabled")) { + this.agendaListbox.refreshCalendarQuery(null, null, aCalendar); + } +}; + +agendaListbox.calendarObserver.onDefaultCalendarChanged = function(aCalendar) { +}; + +/** + * Updates the "Upcoming" section of today pane when preference soondays changes + **/ +agendaListbox.updateSoonSection = function() { + this.soon.duration = this.soonDays; + this.soon.open = true; + let soonHeader = document.getElementById("nextweek-header"); + if (soonHeader) { + soonHeader.setItem(this.soon, true); + agendaListbox.refreshPeriodDates(now()); + } +}; + +/** + * Updates the event considered "current". This goes through all "today" items + * and sets the "current" attribute on all list items that are currently + * occurring. + * + * @see scheduleNextCurrentEventUpdate + */ +function setCurrentEvent() { + if (!agendaListbox.showsToday() || !agendaListbox.today.open) { + return; + } + + let msScheduleTime = -1; + let complistItem = agendaListbox.tomorrow.listItem.previousSibling; + let removelist = []; + let anow = now(); + let msuntillend = 0; + let msuntillstart = 0; + let leaveloop; + do { + leaveloop = !agendaListbox.isEventListItem(complistItem); + if (!leaveloop) { + msuntillstart = complistItem.occurrence.startDate + .getInTimezone(agendaListbox.kDefaultTimezone) + .subtractDate(anow).inSeconds; + if (msuntillstart <= 0) { + msuntillend = complistItem.occurrence.endDate + .getInTimezone(agendaListbox.kDefaultTimezone) + .subtractDate(anow).inSeconds; + if (msuntillend > 0) { + complistItem.setAttribute("current", "true"); + if (msuntillend < msScheduleTime || msScheduleTime == -1) { + msScheduleTime = msuntillend; + } + } else { + removelist.push(complistItem); + } + } else { + complistItem.removeAttribute("current"); + } + if (msScheduleTime == -1 || msuntillstart < msScheduleTime) { + if (msuntillstart > 0) { + msScheduleTime = msuntillstart; + } + } + } + if (!leaveloop) { + complistItem = complistItem.previousSibling; + } + } while (!leaveloop); + + if (msScheduleTime > -1) { + scheduleNextCurrentEventUpdate(setCurrentEvent, msScheduleTime * 1000); + } + + if (removelist) { + if (removelist.length > 0) { + for (let i = 0; i < removelist.length; i++) { + removelist[i].remove(); + } + } + } +} + +var gEventTimer; + +/** + * Creates a timer that will fire after the next event is current. + * Pass in a function as aRefreshCallback that should be called at that time. + * + * @param aRefreshCallback The function to call when the next event is + * current. + * @param aMsUntil The number of milliseconds until the next event + * is current. + */ +function scheduleNextCurrentEventUpdate(aRefreshCallback, aMsUntil) { + // Is an nsITimer/callback extreme overkill here? Yes, but it's necessary to + // workaround bug 291386. If we don't, we stand a decent chance of getting + // stuck in an infinite loop. + let udCallback = { + notify: function(timer) { + aRefreshCallback(); + } + }; + + if (gEventTimer) { + gEventTimer.cancel(); + } else { + // Observer for wake after sleep/hibernate/standby to create new timers and refresh UI + let wakeObserver = { + observe: function(aSubject, aTopic, aData) { + if (aTopic == "wake_notification") { + aRefreshCallback(); + } + } + }; + // Add observer + Services.obs.addObserver(wakeObserver, "wake_notification", false); + + // Remove observer on unload + window.addEventListener("unload", () => { + Services.obs.removeObserver(wakeObserver, "wake_notification"); + }, false); + + gEventTimer = Components.classes["@mozilla.org/timer;1"] + .createInstance(Components.interfaces.nsITimer); + } + gEventTimer.initWithCallback(udCallback, aMsUntil, gEventTimer.TYPE_ONE_SHOT); +} + +/** + * Gets a right value for calendar.agendaListbox.soondays preference, avoid + * erroneus values edited in the lightning.js preference file + **/ +function getSoondaysPreference() { + let prefName = "calendar.agendaListbox.soondays"; + let soonpref = Preferences.get(prefName, 5); + + if (soonpref > 0 && soonpref <= 28) { + if (soonpref % 7 != 0) { + let intSoonpref = Math.floor(soonpref / 7) * 7; + soonpref = (intSoonpref == 0 ? soonpref : intSoonpref); + Preferences.set(prefName, soonpref, "INT"); + } + } else { + soonpref = soonpref > 28 ? 28 : 1; + Preferences.set(prefName, soonpref, "INT"); + } + return soonpref; +} diff --git a/calendar/base/content/agenda-listbox.xml b/calendar/base/content/agenda-listbox.xml new file mode 100644 index 000000000..bc205b7a0 --- /dev/null +++ b/calendar/base/content/agenda-listbox.xml @@ -0,0 +1,289 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<bindings id="agenda-list-bindings" + xmlns="http://www.mozilla.org/xbl" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:xbl="http://www.mozilla.org/xbl"> + + <binding id="agenda-base-richlist-item" + extends="chrome://global/content/bindings/richlistbox.xml#richlistitem"> + <implementation> + <field name="mOccurrence">null</field>; + <property name="occurrence" readonly="true"> + <getter><![CDATA[ + return this.mOccurrence; + ]]></getter> + </property> + </implementation> + + <handlers> + <handler event="click" phase="capturing"><![CDATA[ + if (event.detail == 1) { + agendaListbox.onSelect(this); + } else if (event.button == 0) { + // We only care about button 0 doubleclick events + document.getElementById("agenda_edit_event_command").doCommand(); + event.stopPropagation(); + event.preventDefault(); + } + ]]></handler> + <handler event="mouseover"><![CDATA[ + event.stopPropagation(); + onMouseOverItem(event); + ]]></handler> + </handlers> + </binding> + + <binding id="agenda-checkbox-richlist-item" + extends="chrome://global/content/bindings/richlistbox.xml#richlistitem"> + <content> + <xul:treenode-checkbox class="agenda-checkbox" anonid="agenda-checkbox-widget" + flex="1" + xbl:inherits="selected,label,hidden,disabled"/> + </content> + <implementation> + <field name="kCheckbox">null</field>; + <constructor><![CDATA[ + this.kCheckbox = document.getAnonymousElementByAttribute(this, "anonid", "agenda-checkbox-widget"); + ]]></constructor> + + <method name="getItem"> + <body><![CDATA[ + return this.mItem; + ]]></body> + </method> + + <method name="setItem"> + <parameter name="synthetic"/> + <parameter name="showsToday"/> + <body><![CDATA[ + this.mItem = synthetic; + let duration = synthetic.duration; + if (showsToday) { + this.kCheckbox.label = this.getAttribute("title"); + if (this.id == "nextweek-header") { + if (duration > 7) { + this.kCheckbox.label += " (" + unitPluralForm(duration / 7, "weeks") + ")"; + } else { + this.kCheckbox.label += " (" + unitPluralForm(duration, "days") + ")"; + } + } + } else if (synthetic.duration == 1) { + this.kCheckbox.label = getDateFormatter().formatDate(synthetic.start); + } else { + this.kCheckbox.label = getDateFormatter().formatInterval(synthetic.start, synthetic.end); + } + ]]></body> + </method> + + <method name="getCheckbox"> + <body><![CDATA[ + return this.kCheckbox; + ]]></body> + </method> + </implementation> + </binding> + + <binding id="agenda-allday-richlist-item" + extends="chrome://calendar/content/agenda-listbox.xml#agenda-base-richlist-item"> + <content tooltip="itemTooltip"> + <xul:hbox anonid="agenda-container-box" + class="agenda-allday-container-box" + xbl:inherits="selected,disabled" + flex="1"> + <xul:vbox pack="center" flex="1"> + <xul:label anonid="agenda-allDayEvent-date" class="agenda-event-start" + crop="end" xbl:inherits="selected" hidden="true"/> + <xul:hbox flex="1" align="start"> + <xul:image anonid="agenda-multiDayEvent-image" class="agenda-multiDayEvent-image"/> + <xul:calendar-month-day-box-item anonid="allday-item" + flex="1" + flat="true"/> + </xul:hbox> + </xul:vbox> + </xul:hbox> + </content> + <implementation> + <field name="mAllDayItem">null</field>; + + <constructor><![CDATA[ + this.mAllDayItem = document.getAnonymousElementByAttribute(this, "anonid", "allday-item"); + ]]></constructor> + + <method name="setOccurrence"> + <parameter name="aOccurrence"/> + <parameter name="aPeriod"/> + <body><![CDATA[ + this.mOccurrence = aOccurrence; + this.mAllDayItem.occurrence = aOccurrence; + let dateFormatter = cal.getDateFormatter(); + let periodStartDate = aPeriod.start.clone(); + periodStartDate.isDate = true; + let periodEndDate = aPeriod.end; + let startDate = this.mOccurrence[calGetStartDateProp(this.mOccurrence)] + .getInTimezone(calendarDefaultTimezone()); + let endDate = this.mOccurrence[calGetEndDateProp(this.mOccurrence)] + .getInTimezone(calendarDefaultTimezone()); + let endPreviousDay = endDate.clone(); + endPreviousDay.day--; + // Show items's date for long periods but also for "Upcoming" + // period with one day duration. + let showDate = aPeriod.multiday || aPeriod.duration > 1; + + let date = ""; + let iconType = ""; + let allDayDateLabel = document.getAnonymousElementByAttribute(this, "anonid", "agenda-allDayEvent-date"); + setBooleanAttribute(allDayDateLabel, "hidden", !showDate); + if (startDate.compare(endPreviousDay) == 0) { + // All day event one day duration. + date = dateFormatter.formatDate(startDate); + } else if (startDate.compare(periodStartDate) >= 0 && + startDate.compare(periodEndDate) <= 0) { + // All day event spanning multiple days. + iconType = "start"; + date = dateFormatter.formatDate(startDate); + } else if (endDate.compare(periodStartDate) >= 0 && + endDate.compare(periodEndDate) <= 0) { + iconType = "end"; + date = dateFormatter.formatDate(endPreviousDay); + } else { + iconType = "continue"; + hideElement(allDayDateLabel); + } + + let multiDayImage = document.getAnonymousElementByAttribute(this, "anonid", "agenda-multiDayEvent-image"); + multiDayImage.setAttribute("type", iconType); + // class wrap causes allday items to wrap its text in today-pane + let addWrap = document.getAnonymousElementByAttribute(this.mAllDayItem, "anonid", "eventbox"); + addWrap.classList.add("wrap"); + addWrap = document.getAnonymousElementByAttribute(this.mAllDayItem, "anonid", "event-detail-box"); + addWrap.classList.add("wrap"); + allDayDateLabel.value = date; + ]]></body> + </method> + </implementation> + + <handlers> + <handler event="dragstart" phase="capturing"><![CDATA[ + invokeEventDragSession(this.mAllDayItem.occurrence.clone(), this); + event.stopPropagation(); + event.preventDefault(); + ]]></handler> + </handlers> + </binding> + + <binding id="agenda-richlist-item" + extends="chrome://calendar/content/agenda-listbox.xml#agenda-base-richlist-item"> + <content tooltip="itemTooltip"> + <xul:hbox anonid="agenda-container-box" class="agenda-container-box" xbl:inherits="selected,disabled,current" flex="1"> + <xul:hbox> + <xul:vbox> + <xul:image anonid="agenda-calendar-image" class="agenda-calendar-image"/> + <xul:spacer flex="1"/> + </xul:vbox> + </xul:hbox> + <xul:vbox anonid="agenda-description" flex="1"> + <xul:hbox align="start"> + <xul:image anonid="agenda-multiDayEvent-image" class="agenda-multiDayEvent-image"/> + <xul:label anonid="agenda-event-start" class="agenda-event-start" crop="end" flex="1" xbl:inherits="selected"/> + </xul:hbox> + <xul:label anonid="agenda-event-title" class="agenda-event-title" crop="end" xbl:inherits="selected"/> + </xul:vbox> + </xul:hbox> + </content> + + <implementation> + <method name="setOccurrence"> + <parameter name="aItem"/> + <parameter name="aPeriod"/> + <body><![CDATA[ + this.mOccurrence = aItem; + this.setAttribute("status", aItem.status); + let dateFormatter = Components.classes["@mozilla.org/calendar/datetime-formatter;1"] + .getService(Components.interfaces.calIDateTimeFormatter); + + let periodStartDate = aPeriod.start.clone(); + periodStartDate.isDate = true; + let periodEndDate = aPeriod.end.clone(); + periodEndDate.day--; + let start = this.mOccurrence[calGetStartDateProp(this.mOccurrence)] + .getInTimezone(calendarDefaultTimezone()); + let end = this.mOccurrence[calGetEndDateProp(this.mOccurrence)] + .getInTimezone(calendarDefaultTimezone()); + let startDate = start.clone(); + startDate.isDate = true; + let endDate = end.clone(); + endDate.isDate = true; + let endAtMidnight = (end.hour == 0 && end.minute == 0); + if (endAtMidnight) { + endDate.day--; + } + // Show items's date for long periods but also for "Upcoming" + // period with one day duration. + let longFormat = aPeriod.multiday || aPeriod.duration > 1; + + let duration = ""; + let iconType = ""; + if (startDate.compare(endDate) == 0) { + // event that starts and ends in the same day, midnight included + duration = longFormat ? dateFormatter.formatDateTime(start) + : dateFormatter.formatTime(start); + } else if (startDate.compare(periodStartDate) >= 0 && + startDate.compare(periodEndDate) <= 0) { + // event spanning multiple days, start date within period + iconType = "start"; + duration = longFormat ? dateFormatter.formatDateTime(start) + : dateFormatter.formatTime(start); + } else if (endDate.compare(periodStartDate) >= 0 && + endDate.compare(periodEndDate) <= 0) { + // event spanning multiple days, end date within period + iconType = "end"; + if (endAtMidnight) { + duration = dateFormatter.formatDate(endDate) + " "; + duration = longFormat ? duration + calGetString("dateFormat", "midnight") + : calGetString("dateFormat", "midnight"); + } else { + duration = longFormat ? dateFormatter.formatDateTime(end) + : dateFormatter.formatTime(end); + } + } else { + iconType = "continue"; + } + let multiDayImage = document.getAnonymousElementByAttribute(this, "anonid", "agenda-multiDayEvent-image"); + multiDayImage.setAttribute("type", iconType); + let durationbox = document.getAnonymousElementByAttribute(this, "anonid", "agenda-event-start"); + durationbox.textContent = duration; + + // show items with time only (today & tomorrow) as one line. + if (longFormat) { + let titlebox = document.getAnonymousElementByAttribute(this, "anonid", "agenda-event-title"); + titlebox.textContent = aItem.title; + } else { + durationbox.textContent += " " + aItem.title; + } + this.refreshColor(); + ]]></body> + </method> + + <method name="refreshColor"> + <body><![CDATA[ + let calcolor = (this.mOccurrence && + this.mOccurrence.calendar.getProperty("color")) || + "#a8c2e1"; + + let imagebox = document.getAnonymousElementByAttribute(this, "anonid", "agenda-calendar-image"); + imagebox.setAttribute("style", "background-color: " + calcolor + ";"); + ]]></body> + </method> + </implementation> + + <handlers> + <handler event="dragstart"><![CDATA[ + invokeEventDragSession(this.mOccurrence.clone(), this); + ]]></handler> + </handlers> + </binding> +</bindings> diff --git a/calendar/base/content/calendar-base-view.xml b/calendar/base/content/calendar-base-view.xml new file mode 100644 index 000000000..bd7e72d0d --- /dev/null +++ b/calendar/base/content/calendar-base-view.xml @@ -0,0 +1,924 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<bindings id="calendar-multiday-view-bindings" + xmlns="http://www.mozilla.org/xbl" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:xbl="http://www.mozilla.org/xbl"> + + <binding id="calendar-base-view"> + <resources> + <stylesheet src="chrome://calendar-common/skin/calendar-alarms.css"/> + </resources> + <implementation> + <field name="mWeekStartOffset">0</field> + <field name="mRangeStartDate">null</field>; + <field name="mRangeEndDate">null</field>; + <field name="mWorkdaysOnly">false</field> + <field name="mPendingRefreshJobs">null</field> + <field name="mCalendar">null</field> + <field name="mController">null</field> + <field name="mStartDate">null</field> + <field name="mEndDate">null</field> + <field name="mTasksInView">false</field> + <field name="mShowCompleted">false</field> + <field name="mDisplayDaysOff">true</field> + <field name="mDaysOffArray">[0, 6]</field> + <field name="mTimezone">null</field> + <field name="mFlashingEvents">null</field> + <field name="mSelectedItems">[]</field> + <field name="mLongWeekdayTotalPixels">-1</field> + <field name="mResizeHandler">null</field> + <field name="mDropShadowsLength">null</field> + <field name="mShadowOffset">null</field> + <field name="mDropShadows">null</field> + <field name="mMagnifyAmount">0</field> + <field name="mPixelScrollDelta">0</field> + <field name="mViewStart">null</field> + <field name="mViewEnd">null</field> + <field name="mToggleStatus">0</field> + <field name="mLog">null</field> + <field name="mToggleStatusFlag"><![CDATA[ + ({ + WorkdaysOnly: 1, + TasksInView: 2, + ShowCompleted: 4, + }) + ]]></field> + + <field name="mPrefObserver"><![CDATA[ + ({ + calView: this, + observe: function(subj, topic, pref) { + this.calView.handlePreference(subj, topic, pref); + return; + } + }) + ]]></field> + + <field name="mObserver"><![CDATA[ + // the calIObserver, calICompositeObserver, and calIAlarmServiceObserver + ({ + QueryInterface: XPCOMUtils.generateQI([ + Components.interfaces.calIObserver, + Components.interfaces.calIAlarmServiceObserver, + Components.interfaces.calICompositeObserver + ]), + + calView: this, + + onStartBatch: function() { + }, + onEndBatch: function() { + }, + + onLoad: function() { + this.calView.refresh(); + }, + + onAddItem: function(aItem) { + if (cal.isToDo(aItem)) { + if (!aItem.entryDate && !aItem.dueDate) { + return; + } + if (!this.calView.mTasksInView) { + return; + } + if (aItem.isCompleted && !this.calView.mShowCompleted) { + return; + } + } + + let occs = aItem.getOccurrencesBetween(this.calView.startDate, + this.calView.queryEndDate, + {}); + for (let occ of occs) { + this.calView.doAddItem(occ); + } + return; + }, + + onModifyItem: function(aNewItem, aOldItem) { + if (cal.isToDo(aNewItem) && cal.isToDo(aOldItem) && + !this.calView.mTasksInView) { + return; + } + let occs; + + if (!cal.isToDo(aOldItem) || aOldItem.entryDate || aOldItem.dueDate) { + occs = aOldItem.getOccurrencesBetween(this.calView.startDate, + this.calView.queryEndDate, + {}); + for (let occ of occs) { + this.calView.doDeleteItem(occ); + } + } + if (cal.isToDo(aNewItem)) { + if ((!aNewItem.entryDate && !aNewItem.dueDate) || !this.calView.mTasksInView) { + return; + } + if (aNewItem.isCompleted && !this.calView.mShowCompleted) { + return; + } + } + + occs = aNewItem.getOccurrencesBetween(this.calView.startDate, + this.calView.queryEndDate, + {}); + for (let occ of occs) { + this.calView.doAddItem(occ); + } + }, + + onDeleteItem: function(aItem) { + if (cal.isToDo(aItem)) { + if (!this.calView.mTasksInView) { + return; + } + if (!aItem.entryDate && !aItem.dueDate) { + return; + } + if (aItem.isCompleted && !this.calView.mShowCompleted) { + return; + } + } + + let occs = aItem.getOccurrencesBetween(this.calView.startDate, + this.calView.queryEndDate, + {}); + for (let occ of occs) { + this.calView.doDeleteItem(occ); + } + }, + + onError: function(aCalendar, aErrNo, aMessage) { }, + + onPropertyChanged: function(aCalendar, aName, aValue, aOldValue) { + switch (aName) { + case "suppressAlarms": + if (!Preferences.get("calendar.alarms.indicator.show", true) || + aCalendar.getProperty("capabilities.alarms.popup.supported") === false) { + break; + } + // else fall through + case "readOnly": + case "disabled": + // XXXvv we can be smarter about how we handle this stuff + this.calView.refresh(); + break; + } + }, + + onPropertyDeleting: function(aCalendar, aName) { + // Values are not important here yet. + this.onPropertyChanged(aCalendar, aName, null, null); + }, + + // + // calIAlarmServiceObserver stuff + // + onAlarm: function(aAlarmItem) { + this.calView.flashAlarm(aAlarmItem, false); + }, + + onRemoveAlarmsByItem: function(aItem) { + // Stop the flashing for the item. + this.calView.flashAlarm(aItem, true); + }, + + onRemoveAlarmsByCalendar: function(aCalendar) { + // Stop the flashing for all items of this calendar + for (let key in this.calView.mFlashingEvents) { + let item = this.calView.mFlashingEvents[key]; + if (item.calendar.id == aCalendar.id) { + this.calView.flashAlarm(item, true); + } + } + }, + + onAlarmsLoaded: function(aCalendar) {}, + + // + // calICompositeObserver stuff + // XXXvv we can be smarter about how we handle this stuff + // + onCalendarAdded: function(aCalendar) { + if (!aCalendar.getProperty("disabled")) { + this.calView.addItemsFromCalendar(aCalendar); + } + }, + + onCalendarRemoved: function(aCalendar) { + if (!aCalendar.getProperty("disabled")) { + this.calView.deleteItemsFromCalendar(aCalendar); + } + }, + + onDefaultCalendarChanged: function(aNewDefaultCalendar) { + // don't care, for now + } + }) + ]]></field> + + <constructor><![CDATA[ + Components.utils.import("resource://gre/modules/Services.jsm"); + Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + Components.utils.import("resource://gre/modules/Preferences.jsm"); + Components.utils.import("resource:///modules/gloda/log4moz.js"); + + const kWorkdaysCommand = "calendar_toggle_workdays_only_command"; + const kTasksInViewCommand = "calendar_toggle_tasks_in_view_command"; + const kShowCompleted = "calendar_toggle_show_completed_in_view_command"; + const kOrientation = "calendar_toggle_orientation_command"; + + this.workdaysOnly = (document.getElementById(kWorkdaysCommand) + .getAttribute("checked") == "true"); + this.tasksInView = (document.getElementById(kTasksInViewCommand) + .getAttribute("checked") == "true"); + this.rotated = (document.getElementById(kOrientation) + .getAttribute("checked") == "true"); + this.showCompleted = (document.getElementById(kShowCompleted) + .getAttribute("checked") == "true"); + + this.mTimezone = calendarDefaultTimezone(); + let alarmService = Components.classes["@mozilla.org/calendar/alarm-service;1"] + .getService(Components.interfaces.calIAlarmService); + alarmService.addObserver(this.mObserver); + this.setAttribute("type", this.type); + this.mResizeHandler = () => { + this.onResize(this); + }; + this.viewBroadcaster.addEventListener(this.getAttribute("type") + "viewresized", this.mResizeHandler, true); + // add a preference observer to monitor changes + Services.prefs.addObserver("calendar.", this.mPrefObserver, false); + this.weekStartOffset = Preferences.get("calendar.week.start", 0); + this.updateDaysOffPrefs(); + this.mPendingRefreshJobs = new Map(); + this.mLog = Log4Moz.getConfiguredLogger("calBaseView"); + this.mFlashingEvents = {}; + ]]></constructor> + + <destructor><![CDATA[ + Components.utils.import("resource://gre/modules/Services.jsm"); + + if (this.mCalendar) { + this.mCalendar.removeObserver(this.mObserver); + } + let alarmService = Components.classes["@mozilla.org/calendar/alarm-service;1"] + .getService(Components.interfaces.calIAlarmService); + alarmService.removeObserver(this.mObserver); + this.viewBroadcaster.removeEventListener(this.getAttribute("type") + "viewresized", this.mResizeHandler, true); + Services.prefs.removeObserver("calendar.", this.mPrefObserver); + ]]></destructor> + + <property name="type" readonly="true"> + <getter><![CDATA[ + let typelist = this.id.split("-"); + return typelist[0]; + ]]></getter> + </property> + + <property name="viewBroadcaster" readonly="true" + onget="return document.getElementById('calendarviewBroadcaster')"/> + + <property name="labeldaybox" readonly="true" + onget="return document.getAnonymousElementByAttribute(this, 'anonid', 'labeldaybox')"/> + + <property name="rotated" + onget="return (this.orient == 'horizontal')" + onset="return (this.orient = (val ? 'horizontal' : 'vertical'))"/> + <property name="supportsRotation" readonly="true" + onget="return false"/> + <property name="displayDaysOff" + onget="return this.mDisplayDaysOff;" + onset="return (this.mDisplayDaysOff = val);"/> + <property name="controller" + onget="return this.mController;" + onset="return (this.mController = val);"/> + <property name="daysOffArray" + onget="return this.mDaysOffArray;" + onset="return (this.mDaysOffArray = val);"/> + <property name="tasksInView" + onget="return this.mTasksInView;" + onset="return (this.mTasksInView = val);"/> + <property name="showCompleted" + onget="return this.mShowCompleted;" + onset="return (this.mShowCompleted = val);"/> + <property name="timezone" + onget="return this.mTimezone;" + onset="return (this.mTimezone = val);"/> + <property name="workdaysOnly" + onget="return this.mWorkdaysOnly;" + onset="return (this.mWorkdaysOnly = val);"/> + <property name="supportsWorkdaysOnly" readonly="true" + onget="return true;"/> + <property name="supportsZoom" readonly="true" + onget="return false;"/> + <property name="selectionObserver" readonly="true" + onget="return this.mSelectionObserver;"/> + <property name="startDay" readonly="true" + onget="return this.startDate;"/> + <property name="endDay" readonly="true" + onget="return this.endDate;"/> + <property name="supportDisjointDates" readonly="true" + onget="return false;"/> + <property name="hasDisjointDates" readonly="true" + onget="return false;"/> + <property name="rangeStartDate" + onget="return this.mRangeStartDate;" + onset="return (this.mRangeStartDate = val);"/> + <property name="rangeEndDate" + onget="return this.mRangeEndDate;" + onset="return (this.mRangeEndDate = val);"/> + <property name="observerID" readonly="true" + onget="return 'base-view-observer';"/> + + <property name="weekStartOffset"> + <getter><![CDATA[ + return this.mWeekStartOffset; + ]]></getter> + <setter><![CDATA[ + this.mWeekStartOffset = val; + return val; + ]]></setter> + </property> + + <property name="displayCalendar"> + <getter><![CDATA[ + return this.mCalendar; + ]]></getter> + <setter><![CDATA[ + if (this.mCalendar) { + this.mCalendar.removeObserver(this.mObserver); + } + this.mCalendar = val; + this.mCalendar.addObserver(this.mObserver); + this.refresh(); + return val; + ]]></setter> + </property> + + <property name="initialized"> + <getter><![CDATA[ + let retval; + + // Some views throw when accessing an uninitialized startDay + try { + retval = this.displayCalendar && this.startDay && + this.endDay; + } catch (ex) { + return false; + } + return retval; + ]]></getter> + </property> + + <method name="goToDay"> + <parameter name="aDate"/> + <body><![CDATA[ + this.showDate(aDate); + ]]></body> + </method> + + <method name="getRangeDescription"> + <body><![CDATA[ + return getDateFormatter().formatInterval(this.rangeStartDate, this.rangeEndDate); + ]]></body> + </method> + + <!-- This function guarantees that the + labels are clipped in the instance that the overflow occurrs, + avoiding horizontal scrollbars from showing up for a short period. --> + <method name="adjustWeekdayLength"> + <parameter name="forceShortName"/> + <body><![CDATA[ + let useShortNames = false; + let labeldayboxkids = this.labeldaybox.childNodes; + if (!labeldayboxkids || !labeldayboxkids[0]) { + useShortNames = true; + } else if (forceShortName && forceShortName === true) { + useShortNames = true; + } else { + let clientWidth = document.getAnonymousElementByAttribute(this, "anonid", "mainbox").clientWidth; + let timespacer = document.getAnonymousElementByAttribute(this, "anonid", "headertimespacer"); + if (timespacer) { + clientWidth -= timespacer.clientWidth; + } + if (this.getLongWeekdayTotalPixels() > 0.95 * clientWidth) { + useShortNames = true; + } + } + for (let i = 0; i < labeldayboxkids.length; i++) { + labeldayboxkids[i].shortWeekNames = useShortNames; + } + ]]></body> + </method> + + <method name="today"> + <body><![CDATA[ + let date = cal.jsDateToDateTime(new Date()).getInTimezone(this.mTimezone); + date.isDate = true; + return date; + ]]></body> + </method> + + <method name="isVisible"> + <body><![CDATA[ + return (this.nodeName == currentView().nodeName); + ]]></body> + </method> + + <method name="refresh"> + <body><![CDATA[ + if (this.isVisible()) { + this.addItemsFromCalendar(this.mCalendar); + } + ]]></body> + </method> + + <!-- force refresh visible and invisible views. + This method is added because when only a preference is toggled, the start + and end date of views are unchanged, therefore those views behind the + "scene" might stay the same upon switch to them. --> + <method name="forceRefresh"> + <body><![CDATA[ + this.addItemsFromCalendar(this.mCalendar); + ]]></body> + </method> + + <method name="addItemsFromCalendar"> + <parameter name="aCalendar"/> + <body><![CDATA[ + let refreshJob = { + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calIOperationListener]), + calView: this, + calendar: null, + calId: null, + operation: null, + cancelled: false, + + onOperationComplete: function(aOpCalendar, aStatus, aOperationType, aId, aDateTime) { + this.calView.mLog.info("Refresh complete of calendar " + this.calId); + if (this.calView.mPendingRefreshJobs.has(this.calId)) { + this.calView.mPendingRefreshJobs.delete(this.calId); + } + + if (!this.cancelled) { + // Fire event + this.calView.fireEvent("viewloaded", aOperationType); + } + }, + + onGetResult: function(aOpCalendar, aStatus, aItemType, aDetail, aCount, aItems) { + if (this.cancelled || !Components.isSuccessCode(aStatus)) { + return; + } + + for (let item of aItems) { + if (!cal.isToDo(item) || item.entryDate || item.dueDate) { + this.calView.doAddItem(item); + } + } + }, + + cancel: function() { + this.calView.mLog.info("Refresh cancelled for calendar " + this.calId); + this.cancelled = true; + let operation = cal.wrapInstance(this.operation, Components.interfaces.calIOperation); + if (operation && operation.isPending) { + operation.cancel(); + this.operation = null; + } + }, + + execute: function() { + if (!this.calView.startDate || !this.calView.endDate || !aCalendar) { + return; + } + + if (aCalendar.type == "composite") { + // we're refreshing from the composite calendar, so we can cancel + // all other pending refresh jobs. + this.calView.mLog.info("Refreshing composite calendar, cancelling all pending refreshes"); + this.calId = "composite"; + for (let job of this.calView.mPendingRefreshJobs.values()) { + job.cancel(); + } + this.calView.mPendingRefreshJobs.clear(); + this.calView.relayout(); + } else { + this.calId = aCalendar.id; + if (this.calView.mPendingRefreshJobs.has(this.calId)) { + this.calView.mPendingRefreshJobs.get(this.calId).cancel(); + this.calView.mPendingRefreshJobs.delete(this.calId); + } + } + this.calendar = aCalendar; + + // start our items query; for a disjoint date range + // we get all the items, and just filter out the ones we don't + // care about in addItem + let filter = this.calendar.ITEM_FILTER_CLASS_OCCURRENCES; + if (this.calView.mShowCompleted) { + filter |= this.calendar.ITEM_FILTER_COMPLETED_ALL; + } else { + filter |= this.calendar.ITEM_FILTER_COMPLETED_NO; + } + + if (this.calView.mTasksInView) { + filter |= this.calendar.ITEM_FILTER_TYPE_ALL; + } else { + filter |= this.calendar.ITEM_FILTER_TYPE_EVENT; + } + + let operation = this.calendar.getItems(filter, + 0, + this.calView.startDate, + this.calView.queryEndDate, + this); + operation = cal.wrapInstance(operation, Components.interfaces.calIOperation); + if (operation && operation.isPending) { + this.operation = operation; + this.calView.mPendingRefreshJobs.set(this.calId, this); + } + } + }; + + refreshJob.execute(); + ]]></body> + </method> + + <method name="deleteItemsFromCalendar"> + <parameter name="aCalendar"/> + <body><![CDATA[ + /* This method must be implemented in subclasses. */ + throw Components.results.NS_ERROR_NOT_IMPLEMENTED; + ]]></body> + </method> + + <!-- the end date that should be used for getItems and similar queries --> + <property name="queryEndDate" readonly="true"> + <getter><![CDATA[ + let end = this.endDate; + if (!end) { + return null; + } + end = end.clone(); + end.day += 1; + end.isDate = true; + return end; + ]]></getter> + </property> + + <method name="fireEvent"> + <parameter name="aEventName"/> + <parameter name="aEventDetail"/> + <body><![CDATA[ + let event = document.createEvent("Events"); + event.initEvent(aEventName, true, false); + event.detail = aEventDetail; + this.dispatchEvent(event); + ]]></body> + </method> + + <method name="removeDropShadows"> + <body><![CDATA[ + let dropbox = document.getAnonymousElementByAttribute(this, "dropbox", "true"); + if (dropbox && dropbox !== undefined) { + dropbox.setAttribute("dropbox", "false"); + } + ]]></body> + </method> + + <method name="getLongWeekdayTotalPixels"> + <body><![CDATA[ + if (this.mLongWeekdayTotalPixels <= 0) { + let maxDayWidth = 0; + for (let label of this.labeldaybox.childNodes) { + if (label.localName && label.localName == "calendar-day-label") { + label.shortWeekNames = false; + let curPixelLength = label.getLongWeekdayPixels(); + maxDayWidth = Math.max(maxDayWidth, curPixelLength); + } + } + if (maxDayWidth > 0) { + this.mLongWeekdayTotalPixels = (maxDayWidth * this.labeldaybox.childNodes.length) + 10; + } + } + return this.mLongWeekdayTotalPixels; + ]]></body> + </method> + + <!-- A preference handler which is called by the preference observer. + Can be overwritten in derived bindings. --> + <method name="handleCommonPreference"> + <parameter name="aSubject"/> + <parameter name="aTopic"/> + <parameter name="aPreference"/> + <body><![CDATA[ + // refresh view if categories seem to have changed + if (aPreference.startsWith("calendar.category.color")) { + this.refreshView(); + return; + } + switch (aPreference) { + case "calendar.week.d0sundaysoff": + case "calendar.week.d1mondaysoff": + case "calendar.week.d2tuesdaysoff": + case "calendar.week.d3wednesdaysoff": + case "calendar.week.d4thursdaysoff": + case "calendar.week.d5fridaysoff": + case "calendar.week.d6saturdaysoff": + this.updateDaysOffPrefs(); + break; + case "calendar.timezone.local": + this.timezone = calendarDefaultTimezone(); + this.refreshView(); + break; + case "calendar.alarms.indicator.show": + // Break here to ensure the view is refreshed + break; + case "calendar.week.start": + this.weekStartOffset = aSubject.getIntPref(aPreference); + break; + case "calendar.date.format": + this.refreshView(); + break; + default: + return; + } + this.refreshView(); + ]]></body> + </method> + + <method name="updateDaysOffPrefs"> + <body><![CDATA[ + const weekPrefix = "calendar.week."; + const prefNames = ["d0sundaysoff", "d1mondaysoff", "d2tuesdaysoff", + "d3wednesdaysoff", "d4thursdaysoff", + "d5fridaysoff", "d6saturdaysoff"]; + const defaults = ["true", "false", "false", "false", + "false", "false", "true"]; + let daysOff = []; + for (let i in prefNames) { + if (Preferences.get(weekPrefix + prefNames[i], defaults[i])) { + daysOff.push(Number(i)); + } + } + this.daysOffArray = daysOff; + ]]></body> + </method> + + <method name="refreshView"> + <body><![CDATA[ + if (!this.startDay || !this.endDay) { + // don't refresh if we're not initialized + return; + } + // Just refresh, the goToDay function will notice + this.goToDay(this.selectedDay); + this.forceRefresh(); + ]]></body> + </method> + + <!-- Default implementations follow, these make things easier for + extensions that don't need certain features. --> + <method name="handlePreference"> + <parameter name="aSubject"/> + <parameter name="aTopic"/> + <parameter name="aPref"/> + <body><![CDATA[ + // Do nothing by default + ]]></body> + </method> + <method name="setDateRange"> + <parameter name="aStartDate"/> + <parameter name="aEndDate"/> + <body><![CDATA[ + cal.navigationBar.setDateRange(aStartDate, aEndDate); + ]]></body> + </method> + + <property name="selectedDay" + onget="return this.startDate" + onset="return this.startDate"/> + + <method name="getSelectedItems"> + <parameter name="aCount"/> + <body><![CDATA[ + aCount.value = this.mSelectedItems.length; + return this.mSelectedItems; + ]]></body> + </method> + <method name="setSelectedItems"> + <parameter name="aCount"/> + <parameter name="aItems"/> + <body><![CDATA[ + this.mSelectedItems = aItems.concat([]); + return this.mSelectedItems; + ]]></body> + </method> + + <method name="getDateList"> + <parameter name="aCount"/> + <parameter name="aDates"/> + <body><![CDATA[ + let start = this.startDate.clone(); + while (start.compare(this.endDate) <= 0) { + dates.push(start); + start.day++; + } + aCount.value = dates.length; + return dates; + ]]></body> + </method> + + <method name="flashAlarm"> + <parameter name="aAlarmItem"/> + <parameter name="aStop"/> + <body><![CDATA[ + // Do nothing by default + ]]></body> + </method> + + <method name="zoomIn"> + <parameter name="aLevel"/> + <body><![CDATA[ + ]]></body> + </method> + <method name="zoomOut"> + <parameter name="aLevel"/> + <body><![CDATA[ + ]]></body> + </method> + <method name="zoomReset"> + <body><![CDATA[ + ]]></body> + </method> + </implementation> + + <handlers> + <handler event="move"><![CDATA[ + this.moveView(event.detail); + ]]></handler> + <handler event="keypress"><![CDATA[ + const kKE = Components.interfaces.nsIDOMKeyEvent; + switch (event.keyCode) { + case kKE.DOM_VK_PAGE_UP: + this.moveView(-1); + break; + case kKE.DOM_VK_PAGE_DOWN: + this.moveView(1); + break; + } + ]]></handler> + <handler event="wheel"><![CDATA[ + const pixelThreshold = 150; + if (event.shiftKey && Preferences.get("calendar.view.mousescroll", true)) { + if (event.deltaMode == event.DOM_DELTA_LINE) { + if (event.deltaY != 0) { + deltaView = event.deltaY < 0 ? -1 : 1; + } + } else if (event.deltaMode == event.DOM_DELTA_PIXEL) { + this.mPixelScrollDelta += event.deltaY; + if (this.mPixelScrollDelta > pixelThreshold) { + deltaView = 1; + this.mPixelScrollDelta = 0; + } else if (this.mPixelScrollDelta < -pixelThreshold) { + deltaView = -1; + this.mPixelScrollDelta = 0; + } + } + + if (deltaView != 0) { + this.moveView(deltaView); + } + } + + // Prevent default scroll handling + event.preventDefault(); + ]]></handler> + <handler event="MozRotateGesture"><![CDATA[ + // Threshold for the minimum and maximum angle we should accept + // rotation for. 90 degrees minimum is most logical, but 45 degrees + // allows you to rotate with one hand. + const MIN_ROTATE_ANGLE = 45; + const MAX_ROTATE_ANGLE = 180; + + let absval = Math.abs(event.delta); + if (this.supportsRotation && + absval >= MIN_ROTATE_ANGLE && + absval < MAX_ROTATE_ANGLE) { + toggleOrientation(); + event.preventDefault(); + } + ]]></handler> + <handler event="MozMagnifyGestureStart"><![CDATA[ + this.mMagnifyAmount = 0; + ]]></handler> + <handler event="MozMagnifyGestureUpdate"><![CDATA[ + // Threshold as to how much magnification causes the zoom to happen + const THRESHOLD = 30; + + if (this.supportsZoom) { + this.mMagnifyAmount += event.delta; + + if (this.mMagnifyAmount > THRESHOLD) { + this.zoomOut(); + this.mMagnifyAmount = 0; + } else if (this.mMagnifyAmount < -THRESHOLD) { + this.zoomIn(); + this.mMagnifyAmount = 0; + } + event.preventDefault(); + } + ]]></handler> + <handler event="MozSwipeGesture"><![CDATA[ + if ((event.direction == SimpleGestureEvent.DIRECTION_UP && !this.rotated) || + (event.direction == SimpleGestureEvent.DIRECTION_LEFT && this.rotated)) { + this.moveView(-1); + } else if ((event.direction == SimpleGestureEvent.DIRECTION_DOWN && !this.rotated) || + (event.direction == SimpleGestureEvent.DIRECTION_RIGHT && this.rotated)) { + this.moveView(1); + } + ]]></handler> + </handlers> + </binding> + + <binding id="calendar-day-label" extends="xul:box"> + <content flex="1" pack="center"> + <xul:label anonid="longWeekdayName" class="calendar-day-label-name" xbl:inherits="selected,relation"/> + <xul:label anonid="shortWeekdayName" class="calendar-day-label-name" hidden="true" xbl:inherits="selected,relation"/> + </content> + <implementation> + <field name="mWeekday">-1</field> + <field name="longWeekdayPixels">0</field> + <field name="mDate">null</field> + <property name="longName" readonly="true" + onget="return document.getAnonymousElementByAttribute(this, 'anonid', 'longWeekdayName');"/> + <property name="shortName" readonly="true" + onget="return document.getAnonymousElementByAttribute(this, 'anonid', 'shortWeekdayName');"/> + <property name="weekDay"> + <getter>return this.mWeekday;</getter> + <setter><![CDATA[ + this.mWeekday = val % 7; + this.longName.value = getDateFormatter().dayName(val); + this.shortName.value = getDateFormatter().shortDayName(val); + return this.mWeekday; + ]]></setter> + </property> + + <property name="date"> + <getter><![CDATA[ + return this.mDate; + ]]></getter> + <setter><![CDATA[ + this.mDate = val; + let dateFormatter = cal.getDateFormatter(); + let label = cal.calGetString("calendar", "dayHeaderLabel", + [dateFormatter.shortDayName(val.weekday), + dateFormatter.formatDateWithoutYear(val)]); + this.shortName.setAttribute("value", label); + label = cal.calGetString("calendar", "dayHeaderLabel", + [dateFormatter.dayName(val.weekday), + dateFormatter.formatDateWithoutYear(val)]); + this.longName.setAttribute("value", label); + return val; + ]]></setter> + </property> + + <property name="shortWeekNames"> + <getter><![CDATA[ + ]]></getter> + <setter><![CDATA[ + // cache before change, in case we are switching to short + this.getLongWeekdayPixels(); + setBooleanAttribute(this.longName, "hidden", val); + setBooleanAttribute(this.shortName, "hidden", !val); + return val; + ]]></setter> + </property> + + <method name="getLongWeekdayPixels"> + <body><![CDATA[ + // Only do this if the long weekdays are visible and we haven't already cached. + let longNameWidth = this.longName.boxObject.width; + if (longNameWidth > 0) { + this.longWeekdayPixels = longNameWidth + + getSummarizedStyleValues(this.longName, ["margin-left", "margin-right"]); + this.longWeekdayPixels += getSummarizedStyleValues(this, ["border-left-width", + "padding-left", "padding-right"]); + return this.longWeekdayPixels; + } else { + // in this case the weekdaypixels have not yet been layouted; + // by definition 0 is returned + return 0; + } + ]]></body> + </method> + </implementation> + </binding> +</bindings> diff --git a/calendar/base/content/calendar-bindings.css b/calendar/base/content/calendar-bindings.css new file mode 100644 index 000000000..e34071c94 --- /dev/null +++ b/calendar/base/content/calendar-bindings.css @@ -0,0 +1,46 @@ +/* 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/. */ + +@namespace url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"); + + +calendar-day-view { + -moz-binding: url(chrome://calendar/content/calendar-views.xml#calendar-day-view); +} + +calendar-week-view { + -moz-binding: url(chrome://calendar/content/calendar-views.xml#calendar-week-view); +} + +calendar-multiweek-view { + -moz-binding: url(chrome://calendar/content/calendar-views.xml#calendar-multiweek-view); +} + +calendar-month-view { + -moz-binding: url(chrome://calendar/content/calendar-views.xml#calendar-month-view); +} + +calendar-task-tree { + -moz-binding: url(chrome://calendar/content/calendar-task-tree.xml#calendar-task-tree); +} + +menupopup[type="task-progress"] > arrowscrollbox { + -moz-binding: url(chrome://calendar/content/calendar-menus.xml#task-progress-menupopup); +} + +menupopup[type="task-priority"] > arrowscrollbox { + -moz-binding: url(chrome://calendar/content/calendar-menus.xml#task-priority-menupopup); +} + +task-menupopup { + -moz-binding: url(chrome://calendar/content/calendar-menus.xml#task-menupopup); +} + +calendar-caption { + -moz-binding: url("chrome://calendar/content/calendar-item-bindings.xml#calendar-caption"); +} + +.item-date-row { + -moz-binding: url(chrome://calendar/content/calendar-item-bindings.xml#item-date-row); +} diff --git a/calendar/base/content/calendar-calendars-list.xul b/calendar/base/content/calendar-calendars-list.xul new file mode 100644 index 000000000..fcca18e9c --- /dev/null +++ b/calendar/base/content/calendar-calendars-list.xul @@ -0,0 +1,76 @@ +<?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/. --> + +<?xml-stylesheet href="chrome://calendar/skin/calendar-management.css" type="text/css"?> + +<!DOCTYPE overlay SYSTEM "chrome://calendar/locale/calendar.dtd"> + +<overlay id="calendar-list-overlay" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <popupset id="calendar-popupset"> + <menupopup id="list-calendars-context-menu" + onpopupshowing="return calendarListSetupContextMenu(event);"> + <menuitem id="list-calendars-context-togglevisible" + accesskeyshow="&calendar.context.showcalendar.accesskey;" + accesskeyhide="&calendar.context.hidecalendar.accesskey;" + oncommand="toggleCalendarVisible(document.getElementById('list-calendars-context-menu').contextCalendar);"/> + <menuitem id="list-calendars-context-showonly" + accesskey="&calendar.context.showonly.accesskey;" + oncommand="showOnlyCalendar(document.getElementById('list-calendars-context-menu').contextCalendar);"/> + <menuitem id="list-calendars-context-showall" + label="&calendar.context.showall.label;" + accesskey="&calendar.context.showall.accesskey;" + oncommand="showAllCalendars();"/> + <menuseparator id="list-calendars-context-showops-menuseparator"/> + <menuitem id="list-calendars-context-new" + label="&calendar.context.newserver.label;" + accesskey="&calendar.context.newserver.accesskey;" + observes="calendar_new_calendar_command"/> + <menuitem id="list-calendars-context-find" + label="&calendar.context.findcalendar.label;" + accesskey="&calendar.context.findcalendar.accesskey;" + oncommand="openCalendarSubscriptionsDialog();"/> + <menuitem id="list-calendars-context-delete" + labeldelete="&calendar.context.deleteserver2.label;" + labelremove="&calendar.context.removeserver.label;" + labelunsubscribe="&calendar.context.unsubscribeserver.label;" + accesskeydelete="&calendar.context.deleteserver2.accesskey;" + accesskeyremove="&calendar.context.removeserver.accesskey;" + accesskeyunsubscribe="&calendar.context.unsubscribeserver.accesskey;" + observes="calendar_delete_calendar_command"/> + <menuseparator id="list-calendars-context-itemops-menuseparator"/> + <menuitem id="list-calendars-context-export" + label="&calendar.context.export.label;" + accesskey="&calendar.context.export.accesskey;" + oncommand="exportEntireCalendar(document.getElementById('list-calendars-context-menu').contextCalendar);"/> + <menuitem id="list-calendars-context-publish" + label="&calendar.context.publish.label;" + accesskey="&calendar.context.publish.accesskey;" + observes="calendar_publish_selected_calendar_command"/> + <menuseparator id="list-calendars-context-export-menuseparator"/> + <menuitem id="list-calendars-context-reload" + label="&calendar.context.synccalendars.label;" + accesskey="&calendar.context.synccalendars.accesskey;" + observes="calendar_reload_remote_calendars"/> + <menuseparator id="list-calendars-context-reload-menuseparator"/> + <menuitem id="list-calendars-context-edit" + label="&calendar.context.properties.label;" + accesskey="&calendar.context.properties.accesskey;" + observes="calendar_edit_calendar_command"/> + </menupopup> + <tooltip id="calendar-list-tooltip" + onpopupshowing="return calendarListTooltipShowing(event)"/> + </popupset> + + <calendar-list-tree id="calendar-list-tree-widget" + type="full" + writable="true" + allowdrag="true" + onSortOrderChanged="updateSortOrderPref(event)" + onselect="document.commandDispatcher.updateCommands('calendar_commands')" + childtooltip="calendar-list-tooltip" + childcontext="list-calendars-context-menu"/> +</overlay> diff --git a/calendar/base/content/calendar-chrome-startup.js b/calendar/base/content/calendar-chrome-startup.js new file mode 100644 index 000000000..e63688776 --- /dev/null +++ b/calendar/base/content/calendar-chrome-startup.js @@ -0,0 +1,165 @@ +/* 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/. */ + +Components.utils.import("resource://gre/modules/iteratorUtils.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); +Components.utils.import("resource://gre/modules/Preferences.jsm"); + +/* exported commonInitCalendar, commonFinishCalendar */ + +/** + * Common initialization steps for calendar chrome windows. + */ +function commonInitCalendar() { + // Move around toolbarbuttons and whatever is needed in the UI. + migrateCalendarUI(); + + // Load the Calendar Manager + loadCalendarManager(); + + // Restore the last shown calendar view + switchCalendarView(getLastCalendarView(), false); + + // set up the unifinder + prepareCalendarToDoUnifinder(); + + // Make sure we update ourselves if the program stays open over midnight + scheduleMidnightUpdate(refreshUIBits); + + // Set up the command controller from calendar-common-sets.js + injectCalendarCommandController(); + + // Set up item and day selection listeners + getViewDeck().addEventListener("dayselect", observeViewDaySelect, false); + getViewDeck().addEventListener("itemselect", calendarController.onSelectionChanged, true); + + // Start alarm service + Components.classes["@mozilla.org/calendar/alarm-service;1"] + .getService(Components.interfaces.calIAlarmService) + .startup(); + document.getElementById("calsidebar_splitter").addEventListener("command", onCalendarViewResize, false); + window.addEventListener("resize", onCalendarViewResize, true); + + // Set up the category colors + categoryManagement.initCategories(); + + // Set up window pref observers + calendarWindowPrefs.init(); + + /* Ensure the new items commands state can be setup properly even when no + * calendar support refreshes (i.e. the "onLoad" notification) or when none + * are active. In specific cases such as for file-based ICS calendars can + * happen, the initial "onLoad" will already have been triggered at this + * point (see bug 714431 comment 29). We thus inconditionnally invoke + * calendarUpdateNewItemsCommand until somebody writes code that enables the + * checking of the calendar readiness (getProperty("ready") ?). + */ + calendarUpdateNewItemsCommand(); +} + +/** + * Common unload steps for calendar chrome windows. + */ +function commonFinishCalendar() { + // Unload the calendar manager + unloadCalendarManager(); + + // clean up the unifinder + finishCalendarToDoUnifinder(); + + // Remove the command controller + removeCalendarCommandController(); + + document.getElementById("calsidebar_splitter").removeEventListener("command", onCalendarViewResize, false); + window.removeEventListener("resize", onCalendarViewResize, true); + + // Clean up the category colors + categoryManagement.cleanupCategories(); + + // Clean up window pref observers + calendarWindowPrefs.cleanup(); +} + +/** + * Handler function to create |viewtype + "viewresized"| events that are + * dispatched through the calendarviewBroadcaster. + * + * XXX this has nothing to do with startup, needs to go somewhere else. + */ +function onCalendarViewResize(aEvent) { + let event = document.createEvent("Events"); + event.initEvent(currentView().type + "viewresized", true, false); + document.getElementById("calendarviewBroadcaster").dispatchEvent(event); +} + +/** + * TODO: The systemcolors pref observer really only needs to be set up once, so + * ideally this code should go into a component. This should be taken care of when + * there are more prefs that need to be observed on a global basis that don't fit + * into the calendar manager. + */ +var calendarWindowPrefs = { + + /** nsISupports QueryInterface */ + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.nsIObserver]), + + /** Initialize the preference observers */ + init: function() { + Services.prefs.addObserver("calendar.view.useSystemColors", this, false); + Services.ww.registerNotification(this); + + // Trigger setting pref on all open windows + this.observe(null, "nsPref:changed", "calendar.view.useSystemColors"); + }, + + /** Cleanup the preference observers */ + cleanup: function() { + Services.prefs.removeObserver("calendar.view.useSystemColors", this); + Services.ww.unregisterNotification(this); + }, + + /** + * Observer function called when a pref has changed + * + * @see nsIObserver + */ + observe: function(aSubject, aTopic, aData) { + if (aTopic == "nsPref:changed") { + switch (aData) { + case "calendar.view.useSystemColors": { + let attributeValue = Preferences.get("calendar.view.useSystemColors", false) && "true"; + for (let win in fixIterator(Services.ww.getWindowEnumerator())) { + setElementValue(win.document.documentElement, attributeValue, "systemcolors"); + } + break; + } + } + } else if (aTopic == "domwindowopened") { + let win = aSubject.QueryInterface(Components.interfaces.nsIDOMWindow); + win.addEventListener("load", () => { + let attributeValue = Preferences.get("calendar.view.useSystemColors", false) && "true"; + setElementValue(win.document.documentElement, attributeValue, "systemcolors"); + }, false); + } + } +}; + +/** + * Migrate calendar UI. This function is called at each startup and can be used + * to change UI items that require js code intervention + */ +function migrateCalendarUI() { + const UI_VERSION = 3; + let currentUIVersion = Preferences.get("calendar.ui.version"); + if (currentUIVersion >= UI_VERSION) { + return; + } + + try { + Preferences.set("calendar.ui.version", UI_VERSION); + } catch (e) { + cal.ERROR("Error upgrading UI from " + currentUIVersion + " to " + + UI_VERSION + ": " + e); + } +} diff --git a/calendar/base/content/calendar-clipboard.js b/calendar/base/content/calendar-clipboard.js new file mode 100644 index 000000000..20011ab9a --- /dev/null +++ b/calendar/base/content/calendar-clipboard.js @@ -0,0 +1,201 @@ +/* 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/. */ + +Components.utils.import("resource://gre/modules/Services.jsm"); +Components.utils.import("resource://calendar/modules/calUtils.jsm"); + +/* exported cutToClipboard, pasteFromClipboard */ + +/** + * Test if a writable calendar is selected, and if the clipboard has items that + * can be pasted into Calendar. The data must be of type "text/calendar" or + * "text/unicode". + * + * @return If true, pasting is currently possible. + */ +function canPaste() { + let selectedCal = getSelectedCalendar(); + if (!selectedCal || !cal.isCalendarWritable(selectedCal)) { + return false; + } + + const flavors = ["text/calendar", "text/unicode"]; + return Services.clipboard.hasDataMatchingFlavors(flavors, + flavors.length, + Components.interfaces.nsIClipboard.kGlobalClipboard); +} + +/** + * Copy the ics data of the current view's selected events to the clipboard and + * deletes the events on success + */ +function cutToClipboard() { + if (copyToClipboard()) { + deleteSelectedItems(); + } +} + +/** + * Copy the ics data of the items in calendarItemArray to the clipboard. Fills + * both text/unicode and text/calendar mime types. + * + * @param calendarItemArray (optional) an array of items to copy. If not + * passed, the current view's selected items will + * be used. + * @return A boolean indicating if the operation succeeded. + */ +function copyToClipboard(calendarItemArray) { + if (!calendarItemArray) { + calendarItemArray = getSelectedItems(); + } + + if (!calendarItemArray.length) { + cal.LOG("[calendar-clipboard] No items to copy."); + return false; + } + + let icsSerializer = Components.classes["@mozilla.org/calendar/ics-serializer;1"] + .createInstance(Components.interfaces.calIIcsSerializer); + icsSerializer.addItems(calendarItemArray, calendarItemArray.length); + let icsString = icsSerializer.serializeToString(); + + let clipboard = Services.clipboard; + let trans = Components.classes["@mozilla.org/widget/transferable;1"] + .createInstance(Components.interfaces.nsITransferable); + + if (trans && clipboard) { + // Register supported data flavors + trans.init(null); + trans.addDataFlavor("text/calendar"); + trans.addDataFlavor("text/unicode"); + + // Create the data objects + let icsWrapper = Components.classes["@mozilla.org/supports-string;1"] + .createInstance(Components.interfaces.nsISupportsString); + icsWrapper.data = icsString; + + // Add data objects to transferable + // Both Outlook 2000 client and Lotus Organizer use text/unicode + // when pasting iCalendar data. + trans.setTransferData("text/calendar", + icsWrapper, + icsWrapper.data.length * 2); // double byte data + trans.setTransferData("text/unicode", + icsWrapper, + icsWrapper.data.length * 2); + + clipboard.setData(trans, + null, + Components.interfaces.nsIClipboard.kGlobalClipboard); + + return true; + } + return false; +} + +/** + * Reads ics data from the clipboard, parses it into items and inserts the items + * into the currently selected calendar. + */ +function pasteFromClipboard() { + if (!canPaste()) { + return; + } + + let clipboard = Services.clipboard; + let trans = Components.classes["@mozilla.org/widget/transferable;1"] + .createInstance(Components.interfaces.nsITransferable); + + if (!trans || !clipboard) { + return; + } + + // Register the wanted data flavors (highest fidelity first!) + trans.init(null); + trans.addDataFlavor("text/calendar"); + trans.addDataFlavor("text/unicode"); + + // Get transferable from clipboard + clipboard.getData(trans, Components.interfaces.nsIClipboard.kGlobalClipboard); + + // Ask transferable for the best flavor. + let flavor = {}; + let data = {}; + trans.getAnyTransferData(flavor, data, {}); + data = data.value.QueryInterface(Components.interfaces.nsISupportsString).data; + switch (flavor.value) { + case "text/calendar": + case "text/unicode": { + let destCal = getSelectedCalendar(); + if (!destCal) { + return; + } + + let icsParser = Components.classes["@mozilla.org/calendar/ics-parser;1"] + .createInstance(Components.interfaces.calIIcsParser); + try { + icsParser.parseString(data); + } catch (e) { + // Ignore parser errors from the clipboard data, if it fails + // there will just be 0 items. + } + + let items = icsParser.getItems({}); + if (items.length == 0) { + return; + } + + // If there are multiple items on the clipboard, the earliest + // should be set to the selected day and the rest adjusted. + let earliestDate = null; + for (let item of items) { + let date = null; + if (item.startDate) { + date = item.startDate.clone(); + } else if (item.entryDate) { + date = item.entryDate.clone(); + } else if (item.dueDate) { + date = item.dueDate.clone(); + } + + if (!date) { + continue; + } + + if (!earliestDate || date.compare(earliestDate) < 0) { + earliestDate = date; + } + } + let firstDate = currentView().selectedDay; + + let offset = null; + if (earliestDate) { + // Timezones and DT/DST time may differ between the earliest item + // and the selected day. Determine the offset between the + // earliestDate in local time and the selected day in whole days. + earliestDate = earliestDate.getInTimezone(calendarDefaultTimezone()); + earliestDate.isDate = true; + offset = firstDate.subtractDate(earliestDate); + let deltaDST = firstDate.timezoneOffset - earliestDate.timezoneOffset; + offset.inSeconds += deltaDST; + } + + startBatchTransaction(); + for (let item of items) { + let newItem = item.clone(); + // Set new UID to allow multiple paste actions of the same + // clipboard content. + newItem.id = cal.getUUID(); + if (offset) { + cal.shiftItem(newItem, offset); + } + doTransaction("add", newItem, destCal, null, null); + } + endBatchTransaction(); + break; + } + default: + break; + } +} diff --git a/calendar/base/content/calendar-common-sets.js b/calendar/base/content/calendar-common-sets.js new file mode 100644 index 000000000..55736ca7a --- /dev/null +++ b/calendar/base/content/calendar-common-sets.js @@ -0,0 +1,950 @@ +/* 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/. */ + +Components.utils.import("resource://gre/modules/Services.jsm"); + +/* exported injectCalendarCommandController, removeCalendarCommandController, + * setupContextItemType, minimonthPick, getSelectedItems, + * deleteSelectedItems, calendarUpdateNewItemsCommand + */ + +var CalendarDeleteCommandEnabled = false; +var CalendarNewEventsCommandEnabled = false; +var CalendarNewTasksCommandEnabled = false; + +/** + * Command controller to execute calendar specific commands + * @see nsICommandController + */ +var calendarController = { + defaultController: null, + + commands: { + // Common commands + "calendar_new_event_command": true, + "calendar_new_event_context_command": true, + "calendar_modify_event_command": true, + "calendar_delete_event_command": true, + + "calendar_modify_focused_item_command": true, + "calendar_delete_focused_item_command": true, + + "calendar_new_todo_command": true, + "calendar_new_todo_context_command": true, + "calendar_new_todo_todaypane_command": true, + "calendar_modify_todo_command": true, + "calendar_modify_todo_todaypane_command": true, + "calendar_delete_todo_command": true, + + "calendar_new_calendar_command": true, + "calendar_edit_calendar_command": true, + "calendar_delete_calendar_command": true, + + "calendar_import_command": true, + "calendar_export_command": true, + "calendar_export_selection_command": true, + + "calendar_publish_selected_calendar_command": true, + "calendar_publish_calendar_command": true, + "calendar_publish_selected_events_command": true, + + "calendar_view_next_command": true, + "calendar_view_prev_command": true, + + "calendar_toggle_orientation_command": true, + "calendar_toggle_workdays_only_command": true, + + "calendar_day-view_command": true, + "calendar_week-view_command": true, + "calendar_multiweek-view_command": true, + "calendar_month-view_command": true, + + "calendar_task_filter_command": true, + "calendar_task_filter_todaypane_command": true, + "calendar_reload_remote_calendars": true, + "calendar_show_unifinder_command": true, + "calendar_toggle_completed_command": true, + "calendar_percentComplete-0_command": true, + "calendar_percentComplete-25_command": true, + "calendar_percentComplete-50_command": true, + "calendar_percentComplete-75_command": true, + "calendar_percentComplete-100_command": true, + "calendar_priority-0_command": true, + "calendar_priority-9_command": true, + "calendar_priority-5_command": true, + "calendar_priority-1_command": true, + "calendar_general-priority_command": true, + "calendar_general-progress_command": true, + "calendar_general-postpone_command": true, + "calendar_postpone-1hour_command": true, + "calendar_postpone-1day_command": true, + "calendar_postpone-1week_command": true, + "calendar_task_category_command": true, + + "calendar_attendance_command": true, + + // for events/tasks in a tab + "cmd_save": true, + "cmd_accept": true, + + // Pseudo commands + "calendar_in_foreground": true, + "calendar_in_background": true, + "calendar_mode_calendar": true, + "calendar_mode_task": true, + + "cmd_selectAll": true + }, + + updateCommands: function() { + for (let command in this.commands) { + goUpdateCommand(command); + } + }, + + supportsCommand: function(aCommand) { + if (aCommand in this.commands) { + return true; + } + if (this.defaultContoller) { + return this.defaultContoller.supportsCommand(aCommand); + } + return false; + }, + + isCommandEnabled: function(aCommand) { + switch (aCommand) { + case "calendar_new_event_command": + case "calendar_new_event_context_command": + return CalendarNewEventsCommandEnabled; + case "calendar_modify_focused_item_command": + return this.item_selected; + case "calendar_modify_event_command": + return this.item_selected; + case "calendar_delete_focused_item_command": + return CalendarDeleteCommandEnabled && this.selected_items_writable; + case "calendar_delete_event_command": + return CalendarDeleteCommandEnabled && this.selected_items_writable; + case "calendar_new_todo_command": + case "calendar_new_todo_context_command": + case "calendar_new_todo_todaypane_command": + return CalendarNewTasksCommandEnabled; + case "calendar_modify_todo_command": + case "calendar_modify_todo_todaypane_command": + return this.todo_items_selected; + // This code is temporarily commented out due to + // bug 469684 Unifinder-todo: raising of the context menu fires blur-event + // this.todo_tasktree_focused; + case "calendar_edit_calendar_command": + return this.isCalendarInForeground(); + case "calendar_task_filter_command": + return true; + case "calendar_delete_todo_command": + if (!CalendarDeleteCommandEnabled) { + return false; + } + // falls through otherwise + case "calendar_toggle_completed_command": + case "calendar_percentComplete-0_command": + case "calendar_percentComplete-25_command": + case "calendar_percentComplete-50_command": + case "calendar_percentComplete-75_command": + case "calendar_percentComplete-100_command": + case "calendar_priority-0_command": + case "calendar_priority-9_command": + case "calendar_priority-5_command": + case "calendar_priority-1_command": + case "calendar_task_category_command": + case "calendar_general-progress_command": + case "calendar_general-priority_command": + case "calendar_general-postpone_command": + case "calendar_postpone-1hour_command": + case "calendar_postpone-1day_command": + case "calendar_postpone-1week_command": + return ((this.isCalendarInForeground() || this.todo_tasktree_focused) && + this.writable && + this.todo_items_selected && + this.todo_items_writable) || + document.getElementById("tabmail").currentTabInfo.mode.type == "calendarTask"; + case "calendar_delete_calendar_command": + return this.isCalendarInForeground() && !this.last_calendar; + case "calendar_import_command": + return this.writable; + case "calendar_export_selection_command": + return this.item_selected; + case "calendar_toggle_orientation_command": + return this.isInMode("calendar") && + currentView().supportsRotation; + case "calendar_toggle_workdays_only_command": + return this.isInMode("calendar") && + currentView().supportsWorkdaysOnly; + case "calendar_publish_selected_events_command": + return this.item_selected; + + case "calendar_reload_remote_calendar": + return !this.no_network_calendars && !this.offline; + case "calendar_attendance_command": { + let attendSel = false; + if (this.todo_tasktree_focused) { + attendSel = this.writable && + this.todo_items_invitation && + this.todo_items_selected && + this.todo_items_writable; + } else { + attendSel = this.item_selected && + this.selected_events_invitation && + this.selected_items_writable; + } + + // Small hack, we want to hide instead of disable. + setBooleanAttribute("calendar_attendance_command", "hidden", !attendSel); + return attendSel; + } + + // The following commands all just need the calendar in foreground, + // make sure you take care when changing things here. + case "calendar_view_next_command": + case "calendar_view_prev_command": + case "calendar_in_foreground": + return this.isCalendarInForeground(); + case "calendar_in_background": + return !this.isCalendarInForeground(); + + // The following commands need calendar mode, be careful when + // changing things. + case "calendar_day-view_command": + case "calendar_week-view_command": + case "calendar_multiweek-view_command": + case "calendar_month-view_command": + case "calendar_show_unifinder_command": + case "calendar_mode_calendar": + return this.isInMode("calendar"); + + case "calendar_mode_task": + return this.isInMode("task"); + + case "cmd_selectAll": + if (this.todo_tasktree_focused || this.isInMode("calendar")) { + return true; + } else if (this.defaultController.supportsCommand(aCommand)) { + return this.defaultController.isCommandEnabled(aCommand); + } + break; + + // for events/tasks in a tab + case "cmd_save": + // falls through + case "cmd_accept": { + let tabType = document.getElementById("tabmail").currentTabInfo.mode.type; + return tabType == "calendarTask" || tabType == "calendarEvent"; + } + + default: + if (this.defaultController && !this.isCalendarInForeground()) { + // The delete-button demands a special handling in mail-mode + // as it is supposed to delete an element of the focused pane + if (aCommand == "cmd_delete" || aCommand == "button_delete") { + let focusedElement = document.commandDispatcher.focusedElement; + if (focusedElement) { + if (focusedElement.getAttribute("id") == "agenda-listbox") { + return agendaListbox.isEventSelected(); + } else if (focusedElement.className == "calendar-task-tree") { + return this.writable && + this.todo_items_selected && + this.todo_items_writable; + } + } + } + + if (this.defaultController.supportsCommand(aCommand)) { + return this.defaultController.isCommandEnabled(aCommand); + } + } + if (aCommand in this.commands) { + // All other commands we support should be enabled by default + return true; + } + } + return false; + }, + + doCommand: function(aCommand) { + switch (aCommand) { + // Common Commands + case "calendar_new_event_command": + createEventWithDialog(getSelectedCalendar(), + getDefaultStartDate(currentView().selectedDay)); + break; + case "calendar_new_event_context_command": { + let newStart = currentView().selectedDateTime; + if (!newStart) { + newStart = getDefaultStartDate(currentView().selectedDay); + } + createEventWithDialog(getSelectedCalendar(), newStart, + null, null, null, + newStart.isDate == true); + break; + } + case "calendar_modify_event_command": + editSelectedEvents(); + break; + case "calendar_modify_focused_item_command": { + let focusedElement = document.commandDispatcher.focusedElement; + if (!focusedElement && this.defaultController && !this.isCalendarInForeground()) { + this.defaultController.doCommand(aCommand); + } else { + let focusedRichListbox = getParentNodeOrThis(focusedElement, "richlistbox"); + if (focusedRichListbox && focusedRichListbox.id == "agenda-listbox") { + agendaListbox.editSelectedItem(); + } else if (focusedElement && focusedElement.className == "calendar-task-tree") { + modifyTaskFromContext(); + } else if (this.isInMode("calendar")) { + editSelectedEvents(); + } + } + break; + } + case "calendar_delete_event_command": + deleteSelectedEvents(); + break; + case "calendar_delete_focused_item_command": { + let focusedElement = document.commandDispatcher.focusedElement; + if (!focusedElement && this.defaultController && !this.isCalendarInForeground()) { + this.defaultController.doCommand(aCommand); + } else { + let focusedRichListbox = getParentNodeOrThis(focusedElement, "richlistbox"); + if (focusedRichListbox && focusedRichListbox.id == "agenda-listbox") { + agendaListbox.deleteSelectedItem(false); + } else if (focusedElement && focusedElement.className == "calendar-task-tree") { + deleteToDoCommand(null, false); + } else if (this.isInMode("calendar")) { + deleteSelectedEvents(); + } + } + break; + } + case "calendar_new_todo_command": + createTodoWithDialog(getSelectedCalendar(), + null, null, null, + getDefaultStartDate(currentView().selectedDay)); + break; + case "calendar_new_todo_context_command": { + let initialDate = currentView().selectedDateTime; + if (!initialDate || initialDate.isDate) { + initialDate = getDefaultStartDate(currentView().selectedDay); + } + createTodoWithDialog(getSelectedCalendar(), + null, null, null, + initialDate); + break; + } + case "calendar_new_todo_todaypane_command": + createTodoWithDialog(getSelectedCalendar(), + null, null, null, + getDefaultStartDate(agendaListbox.today.start)); + break; + case "calendar_delete_todo_command": + deleteToDoCommand(); + break; + case "calendar_modify_todo_command": + modifyTaskFromContext(null, getDefaultStartDate(currentView().selectedDay)); + break; + case "calendar_modify_todo_todaypane_command": + modifyTaskFromContext(null, getDefaultStartDate(agendaListbox.today.start)); + break; + + case "calendar_new_calendar_command": + openCalendarWizard(); + break; + case "calendar_edit_calendar_command": + openCalendarProperties(getSelectedCalendar()); + break; + case "calendar_delete_calendar_command": + promptDeleteCalendar(getSelectedCalendar()); + break; + + case "calendar_import_command": + loadEventsFromFile(); + break; + case "calendar_export_command": + exportEntireCalendar(); + break; + case "calendar_export_selection_command": + saveEventsToFile(currentView().getSelectedItems({})); + break; + + case "calendar_publish_selected_calendar_command": + publishEntireCalendar(getSelectedCalendar()); + break; + case "calendar_publish_calendar_command": + publishEntireCalendar(); + break; + case "calendar_publish_selected_events_command": + publishCalendarData(); + break; + + case "calendar_reload_remote_calendars": + getCompositeCalendar().refresh(); + break; + case "calendar_show_unifinder_command": + toggleUnifinder(); + break; + case "calendar_view_next_command": + currentView().moveView(1); + break; + case "calendar_view_prev_command": + currentView().moveView(-1); + break; + case "calendar_toggle_orientation_command": + toggleOrientation(); + break; + case "calendar_toggle_workdays_only_command": + toggleWorkdaysOnly(); + break; + + case "calendar_day-view_command": + switchCalendarView("day", true); + break; + case "calendar_week-view_command": + switchCalendarView("week", true); + break; + case "calendar_multiweek-view_command": + switchCalendarView("multiweek", true); + break; + case "calendar_month-view_command": + switchCalendarView("month", true); + break; + case "calendar_attendance_command": + // This command is actually handled inline, since it takes a value + break; + + case "cmd_selectAll": + if (!this.todo_tasktree_focused && + this.defaultController && !this.isCalendarInForeground()) { + // Unless a task tree is focused, make the default controller + // take care. + this.defaultController.doCommand(aCommand); + } else { + selectAllItems(); + } + break; + + default: + if (this.defaultController && !this.isCalendarInForeground()) { + // If calendar is not in foreground, let the default controller take + // care. If we don't have a default controller, just continue. + this.defaultController.doCommand(aCommand); + return; + } + + } + return; + }, + + onEvent: function(aEvent) { + }, + + isCalendarInForeground: function() { + return gCurrentMode && gCurrentMode != "mail"; + }, + + isInMode: function(mode) { + switch (mode) { + case "mail": + return !isCalendarInForeground(); + case "calendar": + return gCurrentMode && gCurrentMode == "calendar"; + case "task": + return gCurrentMode && gCurrentMode == "task"; + } + return false; + }, + + onSelectionChanged: function(aEvent) { + let selectedItems = aEvent.detail; + + calendarUpdateDeleteCommand(selectedItems); + calendarController.item_selected = selectedItems && (selectedItems.length > 0); + + let selLength = (selectedItems === undefined ? 0 : selectedItems.length); + let selected_events_readonly = 0; + let selected_events_requires_network = 0; + let selected_events_invitation = 0; + + if (selLength > 0) { + for (let item of selectedItems) { + if (item.calendar.readOnly) { + selected_events_readonly++; + } + if (item.calendar.getProperty("requiresNetwork") && + !item.calendar.getProperty("cache.enabled") && + !item.calendar.getProperty("cache.always")) { + selected_events_requires_network++; + } + + if (cal.isInvitation(item)) { + selected_events_invitation++; + } else if (item.organizer) { + // If we are the organizer and there are attendees, then + // this is likely also an invitation. + let calOrgId = item.calendar.getProperty("organizerId"); + if (item.organizer.id == calOrgId && item.getAttendees({}).length) { + selected_events_invitation++; + } + } + } + } + + calendarController.selected_events_readonly = + (selected_events_readonly == selLength); + + calendarController.selected_events_requires_network = + (selected_events_requires_network == selLength); + calendarController.selected_events_invitation = + (selected_events_invitation == selLength); + + calendarController.updateCommands(); + calendarController2.updateCommands(); + document.commandDispatcher.updateCommands("mail-toolbar"); + }, + + /** + * Condition Helpers + */ + + // These attributes will be set up manually. + item_selected: false, + selected_events_readonly: false, + selected_events_requires_network: false, + selected_events_invitation: false, + + /** + * Returns a boolean indicating if its possible to write items to any + * calendar. + */ + get writable() { + return cal.getCalendarManager().getCalendars({}).some(cal.isCalendarWritable); + }, + + /** + * Returns a boolean indicating if the application is currently in offline + * mode. + */ + get offline() { + return Services.io.offline; + }, + + /** + * Returns a boolean indicating if all calendars are readonly. + */ + get all_readonly() { + let calMgr = getCalendarManager(); + return (calMgr.readOnlyCalendarCount == calMgr.calendarCount); + }, + + /** + * Returns a boolean indicating if all calendars are local + */ + get no_network_calendars() { + return (getCalendarManager().networkCalendarCount == 0); + }, + + /** + * Returns a boolean indicating if there are calendars that don't require + * network access. + */ + get has_local_calendars() { + let calMgr = getCalendarManager(); + return (calMgr.networkCalendarCount < calMgr.calendarCount); + }, + + /** + * Returns a boolean indicating if there are cached calendars and thus that don't require + * network access. + */ + get has_cached_calendars() { + let calMgr = getCalendarManager(); + let calendars = calMgr.getCalendars({}); + for (let calendar of calendars) { + if (calendar.getProperty("cache.enabled") || calendar.getProperty("cache.always")) { + return true; + } + } + return false; + }, + + /** + * Returns a boolean indicating that there is only one calendar left. + */ + get last_calendar() { + return (getCalendarManager().calendarCount < 2); + }, + + /** + * Returns a boolean indicating that all local calendars are readonly + */ + get all_local_calendars_readonly() { + // We might want to speed this part up by keeping track of this in the + // calendar manager. + let calendars = getCalendarManager().getCalendars({}); + let count = calendars.length; + for (let calendar of calendars) { + if (!isCalendarWritable(calendar)) { + count--; + } + } + return (count == 0); + }, + + /** + * Returns a boolean indicating that at least one of the items selected + * in the current view has a writable calendar. + */ + get selected_items_writable() { + return this.writable && + this.item_selected && + !this.selected_events_readonly && + (!this.offline || !this.selected_events_requires_network); + }, + + /** + * Returns a boolean indicating that tasks are selected. + */ + get todo_items_selected() { + let selectedTasks = getSelectedTasks(); + return (selectedTasks.length > 0); + }, + + + get todo_items_invitation() { + let selectedTasks = getSelectedTasks(); + let selected_tasks_invitation = 0; + + for (let item of selectedTasks) { + if (cal.isInvitation(item)) { + selected_tasks_invitation++; + } else if (item.organizer) { + // If we are the organizer and there are attendees, then + // this is likely also an invitation. + let calOrgId = item.calendar.getProperty("organizerId"); + if (item.organizer.id == calOrgId && item.getAttendees({}).length) { + selected_tasks_invitation++; + } + } + } + + return (selectedTasks.length == selected_tasks_invitation); + }, + + /** + * Returns a boolean indicating that at least one task in the selection is + * on a calendar that is writable. + */ + get todo_items_writable() { + let selectedTasks = getSelectedTasks(); + for (let task of selectedTasks) { + if (isCalendarWritable(task.calendar)) { + return true; + } + } + return false; + } +}; + +/** + * XXX This is a temporary hack so we can release 1.0b2. This will soon be + * superceeded by a new command controller architecture. + */ +var calendarController2 = { + defaultController: null, + + commands: { + cmd_cut: true, + cmd_copy: true, + cmd_paste: true, + cmd_undo: true, + cmd_redo: true, + cmd_print: true, + cmd_pageSetup: true, + + cmd_printpreview: true, + button_print: true, + button_delete: true, + cmd_delete: true, + cmd_properties: true, + cmd_goForward: true, + cmd_goBack: true, + cmd_fullZoomReduce: true, + cmd_fullZoomEnlarge: true, + cmd_fullZoomReset: true, + cmd_showQuickFilterBar: true + }, + + // These functions can use the same from the calendar controller for now. + updateCommands: calendarController.updateCommands, + supportsCommand: calendarController.supportsCommand, + onEvent: calendarController.onEvent, + + isCommandEnabled: function(aCommand) { + switch (aCommand) { + // Thunderbird Commands + case "cmd_cut": + return calendarController.selected_items_writable; + case "cmd_copy": + return calendarController.item_selected; + case "cmd_paste": + return canPaste(); + case "cmd_undo": + goSetMenuValue(aCommand, "valueDefault"); + return canUndo(); + case "cmd_redo": + goSetMenuValue(aCommand, "valueDefault"); + return canRedo(); + case "button_delete": + case "cmd_delete": + return calendarController.isCommandEnabled("calendar_delete_focused_item_command"); + case "cmd_fullZoomReduce": + case "cmd_fullZoomEnlarge": + case "cmd_fullZoomReset": + return calendarController.isInMode("calendar") && + currentView().supportsZoom; + case "cmd_properties": + case "cmd_printpreview": + return false; + case "cmd_showQuickFilterBar": + return calendarController.isInMode("task"); + default: + return true; + } + }, + + doCommand: function(aCommand) { + switch (aCommand) { + case "cmd_cut": + cutToClipboard(); + break; + case "cmd_copy": + copyToClipboard(); + break; + case "cmd_paste": + pasteFromClipboard(); + break; + case "cmd_undo": + undo(); + break; + case "cmd_redo": + redo(); + break; + case "cmd_pageSetup": + PrintUtils.showPageSetup(); + break; + case "button_print": + case "cmd_print": + calPrint(); + break; + + // Thunderbird commands + case "cmd_goForward": + currentView().moveView(1); + break; + case "cmd_goBack": + currentView().moveView(-1); + break; + case "cmd_fullZoomReduce": + currentView().zoomIn(); + break; + case "cmd_fullZoomEnlarge": + currentView().zoomOut(); + break; + case "cmd_fullZoomReset": + currentView().zoomReset(); + break; + case "cmd_showQuickFilterBar": + document.getElementById("task-text-filter-field").select(); + break; + + case "button_delete": + case "cmd_delete": + calendarController.doCommand("calendar_delete_focused_item_command"); + break; + } + } +}; + +/** + * Inserts the command controller into the document. On Lightning, also make + * sure that it is inserted before the conflicting thunderbird command + * controller. + */ +function injectCalendarCommandController() { + // We need to put our new command controller *before* the one that + // gets installed by thunderbird. Since we get called pretty early + // during startup we need to install the function below as a callback + // that periodically checks when the original thunderbird controller + // gets alive. Please note that setTimeout with a value of 0 means that + // we leave the current thread in order to re-enter the message loop. + + let tbController = top.controllers.getControllerForCommand("cmd_runJunkControls"); + if (tbController) { + calendarController.defaultController = tbController; + top.controllers.insertControllerAt(0, calendarController); + document.commandDispatcher.updateCommands("calendar_commands"); + } else { + setTimeout(injectCalendarCommandController, 0); + } +} + +/** + * Remove the calendar command controller from the document. + */ +function removeCalendarCommandController() { + top.controllers.removeController(calendarController); +} + +/** + * Handler function to set up the item context menu, depending on the given + * items. Changes the delete menuitem to fit the passed items. + * + * @param event The DOM popupshowing event that is triggered by opening + * the context menu. + * @param items An array of items (usually the selected items) to adapt + * the context menu for. + * @return True, to show the popup menu. + */ +function setupContextItemType(event, items) { + function adaptModificationMenuItem(aMenuItemId, aItemType) { + let menuItem = document.getElementById(aMenuItemId); + if (menuItem) { + menuItem.setAttribute("label", calGetString("calendar", "delete" + aItemType + "Label")); + menuItem.setAttribute("accesskey", calGetString("calendar", "delete" + aItemType + "Accesskey")); + } + } + if (items.some(isEvent) && items.some(isToDo)) { + event.target.setAttribute("type", "mixed"); + adaptModificationMenuItem("calendar-item-context-menu-delete-menuitem", "Item"); + } else if (items.length && isEvent(items[0])) { + event.target.setAttribute("type", "event"); + adaptModificationMenuItem("calendar-item-context-menu-delete-menuitem", "Event"); + } else if (items.length && isToDo(items[0])) { + event.target.setAttribute("type", "todo"); + adaptModificationMenuItem("calendar-item-context-menu-delete-menuitem", "Task"); + } else { + event.target.removeAttribute("type"); + adaptModificationMenuItem("calendar-item-context-menu-delete-menuitem", "Item"); + } + + let menu = document.getElementById("calendar-item-context-menu-attendance-menu"); + setupAttendanceMenu(menu, items); + + return true; +} + +/** + * Shows the given date in the current view, if in calendar mode. + * + * XXX This function is misplaced, should go to calendar-views.js or a minimonth + * specific js file. + * + * @param aNewDate The new date as a JSDate. + */ +function minimonthPick(aNewDate) { + if (gCurrentMode == "calendar" || gCurrentMode == "task") { + let cdt = cal.jsDateToDateTime(aNewDate, currentView().timezone); + cdt.isDate = true; + currentView().goToDay(cdt); + + // update date filter for task tree + let tree = document.getElementById("calendar-task-tree"); + tree.updateFilter(); + } +} + +/** + * Selects all items, based on which mode we are currently in and what task tree is focused + */ +function selectAllItems() { + if (calendarController.todo_tasktree_focused) { + getTaskTree().selectAll(); + } else if (calendarController.isInMode("calendar")) { + selectAllEvents(); + } +} + +/** + * Returns the selected items, based on which mode we are currently in and what task tree is focused + */ +function getSelectedItems() { + if (calendarController.todo_tasktree_focused) { + return getSelectedTasks(); + } + + return currentView().getSelectedItems({}); +} + +/** + * Deletes the selected items, based on which mode we are currently in and what task tree is focused + */ +function deleteSelectedItems() { + if (calendarController.todo_tasktree_focused) { + deleteToDoCommand(); + } else if (calendarController.isInMode("calendar")) { + deleteSelectedEvents(); + } +} + +function calendarUpdateNewItemsCommand() { + // keep current current status + let oldEventValue = CalendarNewEventsCommandEnabled; + let oldTaskValue = CalendarNewTasksCommandEnabled; + + // define command set to update + let eventCommands = ["calendar_new_event_command", + "calendar_new_event_context_command"]; + let taskCommands = ["calendar_new_todo_command", + "calendar_new_todo_context_command", + "calendar_new_todo_todaypane_command"]; + + // re-calculate command status + CalendarNewEventsCommandEnabled = false; + CalendarNewTasksCommandEnabled = false; + let calendars = cal.getCalendarManager().getCalendars({}).filter(cal.isCalendarWritable).filter(userCanAddItemsToCalendar); + if (calendars.some(cal.isEventCalendar)) { + CalendarNewEventsCommandEnabled = true; + } + if (calendars.some(cal.isTaskCalendar)) { + CalendarNewTasksCommandEnabled = true; + } + + // update command status if required + if (CalendarNewEventsCommandEnabled != oldEventValue) { + eventCommands.forEach(goUpdateCommand); + } + if (CalendarNewTasksCommandEnabled != oldTaskValue) { + taskCommands.forEach(goUpdateCommand); + } +} + +function calendarUpdateDeleteCommand(selectedItems) { + let oldValue = CalendarDeleteCommandEnabled; + CalendarDeleteCommandEnabled = (selectedItems.length > 0); + + /* we must disable "delete" when at least one item cannot be deleted */ + for (let item of selectedItems) { + if (!userCanDeleteItemsFromCalendar(item.calendar)) { + CalendarDeleteCommandEnabled = false; + break; + } + } + + if (CalendarDeleteCommandEnabled != oldValue) { + let commands = ["calendar_delete_event_command", + "calendar_delete_todo_command", + "calendar_delete_focused_item_command", + "button_delete", + "cmd_delete"]; + for (let command of commands) { + goUpdateCommand(command); + } + } +} diff --git a/calendar/base/content/calendar-common-sets.xul b/calendar/base/content/calendar-common-sets.xul new file mode 100644 index 000000000..aa7b1135c --- /dev/null +++ b/calendar/base/content/calendar-common-sets.xul @@ -0,0 +1,577 @@ +<?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/. --> + +<!DOCTYPE overlay [ + <!ENTITY % calendarDTD SYSTEM "chrome://calendar/locale/calendar.dtd" > %calendarDTD; + <!ENTITY % eventDialogDTD SYSTEM "chrome://calendar/locale/calendar-event-dialog.dtd"> %eventDialogDTD; + <!ENTITY % menuOverlayDTD SYSTEM "chrome://calendar/locale/menuOverlay.dtd" > %menuOverlayDTD; +]> + +<overlay id="calendar-common-sets-overlay" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <stringbundleset id="calendar_stringbundles"> + <stringbundle id="bundle_branding" src="chrome://branding/locale/brand.properties"/> + </stringbundleset> + <script type="application/javascript" src="chrome://calendar/content/calendar-common-sets.js"/> + + <broadcasterset id="calendar_broadcasters"> + <broadcaster id="modeBroadcaster" mode="calendar"/> + <broadcaster id="calendarviewBroadcaster"/> + <broadcaster id="unifinder-todo-filter-broadcaster" + persist="value" + value="throughcurrent"/> + </broadcasterset> + + <commandset id="calendar_commands" + commandupdater="true" + events="calendar_commands" + oncommandupdate="calendarController.updateCommands()"> + <command id="calendar_new_event_command" oncommand="goDoCommand('calendar_new_event_command')"/> + <command id="calendar_new_event_context_command" oncommand="goDoCommand('calendar_new_event_context_command')"/> + <command id="calendar_modify_event_command" oncommand="goDoCommand('calendar_modify_event_command')"/> + <command id="calendar_delete_event_command" oncommand="goDoCommand('calendar_delete_event_command')"/> + + <command id="calendar_new_todo_command" oncommand="goDoCommand('calendar_new_todo_command')"/> + <command id="calendar_new_todo_context_command" oncommand="goDoCommand('calendar_new_todo_context_command')"/> + <command id="calendar_new_todo_todaypane_command" oncommand="goDoCommand('calendar_new_todo_todaypane_command')"/> + <command id="calendar_modify_todo_command" oncommand="goDoCommand('calendar_modify_todo_command')"/> + <command id="calendar_modify_todo_todaypane_command" oncommand="goDoCommand('calendar_modify_todo_todaypane_command')"/> + <command id="calendar_delete_todo_command" oncommand="goDoCommand('calendar_delete_todo_command')"/> + + <command id="calendar_modify_focused_item_command" oncommand="goDoCommand('calendar_modify_focused_item_command')"/> + <command id="calendar_delete_focused_item_command" oncommand="goDoCommand('calendar_delete_focused_item_command')"/> + + <command id="calendar_new_calendar_command" oncommand="goDoCommand('calendar_new_calendar_command')"/> + <command id="calendar_edit_calendar_command" oncommand="goDoCommand('calendar_edit_calendar_command')"/> + <command id="calendar_delete_calendar_command" oncommand="goDoCommand('calendar_delete_calendar_command')"/> + + <command id="calendar_import_command" oncommand="goDoCommand('calendar_import_command')"/> + <command id="calendar_export_command" oncommand="goDoCommand('calendar_export_command')"/> + <command id="calendar_export_selection_command" oncommand="goDoCommand('calendar_export_selection_command')"/> + + <command id="calendar_publish_selected_calendar_command" oncommand="goDoCommand('calendar_publish_selected_calendar_command')"/> + <command id="calendar_publish_calendar_command" oncommand="goDoCommand('calendar_publish_calendar_command')"/> + <command id="calendar_publish_selected_events_command" oncommand="goDoCommand('calendar_publish_selected_events_command')"/> + + <command id="calendar_reload_remote_calendars" oncommand="goDoCommand('calendar_reload_remote_calendars')"/> + + <command id="calendar_show_unifinder_command" oncommand="goDoCommand('calendar_show_unifinder_command')"/> + <!-- The dash instead of the underscore is intended. the 'xxx-view' part should be the id of the view in the deck --> + <command id="calendar_day-view_command" oncommand="goDoCommand('calendar_day-view_command')"/> + <command id="calendar_week-view_command" oncommand="goDoCommand('calendar_week-view_command')"/> + <command id="calendar_multiweek-view_command" oncommand="goDoCommand('calendar_multiweek-view_command')"/> + <command id="calendar_month-view_command" oncommand="goDoCommand('calendar_month-view_command')"/> + <command id="calendar_task_category_command"/> + <command id="calendar_toggle_completed_command" oncommand="toggleCompleted(event)"/> + <command id="calendar_percentComplete-0_command" oncommand="contextChangeTaskProgress(event, 0)"/> + <command id="calendar_percentComplete-25_command" oncommand="contextChangeTaskProgress(event, 25)"/> + <command id="calendar_percentComplete-50_command" oncommand="contextChangeTaskProgress(event, 50)"/> + <command id="calendar_percentComplete-75_command" oncommand="contextChangeTaskProgress(event, 75)"/> + <command id="calendar_percentComplete-100_command" oncommand="contextChangeTaskProgress(event, 100)"/> + <command id="calendar_priority-0_command" oncommand="contextChangeTaskPriority(event, 0)"/> + <command id="calendar_priority-9_command" oncommand="contextChangeTaskPriority(event, 9)"/> + <command id="calendar_priority-5_command" oncommand="contextChangeTaskPriority(event, 5)"/> + <command id="calendar_priority-1_command" oncommand="contextChangeTaskPriority(event, 1)"/> + <command id="calendar_general-priority_command" oncommand="goDoCommand('calendar_general-priority_command')"/> + <command id="calendar_general-progress_command" oncommand="goDoCommand('calendar_general-progress_command')"/> + <command id="calendar_general-postpone_command"/> + <command id="calendar_postpone-1hour_command" oncommand="contextPostponeTask(event, 'PT1H')"/> + <command id="calendar_postpone-1day_command" oncommand="contextPostponeTask(event, 'P1D')"/> + <command id="calendar_postpone-1week_command" oncommand="contextPostponeTask(event, 'P1W')"/> + <command id="calendar_toggle_orientation_command" persist="checked" oncommand="goDoCommand('calendar_toggle_orientation_command')"/> + <command id="calendar_toggle_workdays_only_command" persist="checked" oncommand="goDoCommand('calendar_toggle_workdays_only_command')"/> + <command id="calendar_toggle_tasks_in_view_command" persist="checked" oncommand="toggleTasksInView()"/> + <command id="calendar_toggle_show_completed_in_view_command" persist="checked" oncommand="toggleShowCompletedInView()"/> + <command id="calendar_toggle_calendarsidebar_command" oncommand="togglePaneSplitter('calsidebar_splitter')"/> + <command id="calendar_toggle_minimonthpane_command" oncommand="document.getElementById('minimonth-pane').togglePane(event)"/> + <command id="calendar_toggle_calendarlist_command" oncommand="document.getElementById('calendar-list-pane').togglePane(event)"/> + <command id="calendar_task_filter_command" oncommand="taskViewUpdate(event.explicitOriginalTarget.getAttribute('value'))"/> + <command id="calendar_task_filter_todaypane_command" oncommand="updateCalendarToDoUnifinder(event.explicitOriginalTarget.getAttribute('value'))"/> + <command id="calendar_toggle_filter_command" oncommand="document.getElementById('task-filter-pane').togglePane(event)"/> + <command id="calendar_view_next_command" oncommand="goDoCommand('calendar_view_next_command')"/> + <command id="calendar_view_today_command" oncommand="currentView().moveView()"/> + <command id="calendar_view_prev_command" oncommand="goDoCommand('calendar_view_prev_command')"/> + + <!-- this is a pseudo-command that is disabled when in calendar mode --> + <command id="calendar_in_foreground"/> + <!-- this is a pseudo-command that is disabled when not in calendar mode --> + <command id="calendar_in_background"/> + + <!-- These commands are enabled when in calendar or task mode, respectively --> + <command id="calendar_mode_calendar"/> + <command id="calendar_mode_task"/> + + <command id="calendar_attendance_command"/> + </commandset> + + <keyset id="calendar-keys"> + + +// For linux tab switching reservers alt+number, where on windows that's ctrl. +// Use the available modifiers for each platform. +// Can't use the OPTION key on OSX, so we will use SHIFT+OPTION on the Mac. +#ifdef XP_UNIX +// Linux +#define CAL_VIEW_MODIFIERS accel +#else +// Windows +#define CAL_VIEW_MODIFIERS alt +#endif + <key id="calendar-day-view-key" key="1" + observes="calendar_day-view_command" +#expand modifiers="__CAL_VIEW_MODIFIERS__"/> + <key id="calendar-week-view-key" key="2" + observes="calendar_week-view_command" +#expand modifiers="__CAL_VIEW_MODIFIERS__"/> + <key id="calendar-multiweek-view-key" key="3" + observes="calendar_multiweek-view_command" +#expand modifiers="__CAL_VIEW_MODIFIERS__"/> + <key id="calendar-month-view-key" key="4" + observes="calendar_month-view_command" +#expand modifiers="__CAL_VIEW_MODIFIERS__"/> + <key id="calendar-go-to-today-key" keycode="VK_END" observes="calendar_go_to_today_command" modifiers="alt"/> + <key id="calendar-delete-item-key" keycode="VK_DELETE" observes="calendar_delete_event_command"/> + <key id="calendar-delete-todo-key" keycode="VK_DELETE" observes="calendar_delete_todo_command"/> + </keyset> + + <popupset id="calendar-popupset"> + <!-- Tooltips --> + <tooltip id="eventTreeTooltip" + onpopupshowing="return showToolTip(this, unifinderTreeView.getItemFromEvent(event))" + noautohide="true"/> + + <tooltip id="taskTreeTooltip" + onpopupshowing="return showToolTip(this, getTaskTree().getTaskFromEvent(event))" + noautohide="true"/> + + <tooltip id="itemTooltip" + noautohide="true"/> + + <!-- CALENDAR ITEM CONTEXT MENU --> + <menupopup id="calendar-item-context-menu" onpopupshowing="return setupContextItemType(event, currentView().getSelectedItems({}));"> + <menuitem id="calendar-item-context-menu-modify-menuitem" + label="&calendar.context.modifyorviewitem.label;" + accesskey="&calendar.context.modifyorviewitem.accesskey;" + observes="calendar_modify_event_command"/> + <menuitem id="calendar-item-context-menu-newevent-menutitem" + label="&calendar.context.newevent.label;" + accesskey="&calendar.context.newevent.accesskey;" + key="calendar-new-event-key" + observes="calendar_new_event_context_command"/> + <menuitem id="calendar-item-context-menu-newtodo-menuitem" + label="&calendar.context.newtodo.label;" + accesskey="&calendar.context.newtodo.accesskey;" + key="calendar-new-todo-key" + observes="calendar_new_todo_context_command"/> + <menuseparator id="calendar-item-context-menuseparator-adddeletemodify"/> + <menuitem id="calendar-item-context-menu-cut-menuitem" + label="&calendar.context.cutevent.label;" + accesskey="&calendar.context.cutevent.accesskey;" + key="key_cut" + observes="cmd_cut" + command="cmd_cut"/> + <menuitem id="calendar-item-context-menu-copy-menuitem" + label="&calendar.context.copyevent.label;" + accesskey="&calendar.context.copyevent.accesskey;" + key="key_copy" + observes="cmd_copy" + command="cmd_copy"/> + <menuitem id="calendar-item-context-menu-paste-menuitem" + label="&calendar.context.pasteevent.label;" + accesskey="&calendar.context.pasteevent.accesskey;" + key="key_paste" + observes="cmd_paste" + command="cmd_paste"/> + <menuseparator id="calendar-item-context-separator-cutcopypaste"/> + <menu id="calendar-item-context-menu-convert-menu" + label="&calendar.context.convertmenu.label;" + accesskey="&calendar.context.convertmenu.accesskey.calendar;"> + <menupopup id="calendar-item-context-menu-convert-menupopup"> + <menuitem id="calendar-view-context-menu-convert-message-menuitem" + label="&calendar.context.convertmenu.message.label;" + accesskey="&calendar.context.convertmenu.message.accesskey;" + oncommand="calendarMailButtonDNDObserver.onDropItems(currentView().getSelectedItems({}))"/> + <menuitem id="calendar-item-context-menu-convert-event-menuitem" + class="todo-only" + label="&calendar.context.convertmenu.event.label;" + accesskey="&calendar.context.convertmenu.event.accesskey;" + oncommand="calendarCalendarButtonDNDObserver.onDropItems(currentView().getSelectedItems({}))"/> + <menuitem id="calendar-item-context-menu-convert-task-menuitem" + class="event-only" + label="&calendar.context.convertmenu.task.label;" + accesskey="&calendar.context.convertmenu.task.accesskey;" + oncommand="calendarTaskButtonDNDObserver.onDropItems(currentView().getSelectedItems({}))"/> + </menupopup> + </menu> + <menuseparator id="calendar-menuseparator-before-delete"/> + <!-- the label and accesskey of the following menuitem is set during runtime, + and depends on wether the item is a task or an event--> + <menuitem id="calendar-item-context-menu-delete-menuitem" + key="calendar-delete-item-key" + observes="calendar_delete_event_command"/> + <menu id="calendar-item-context-menu-attendance-menu" + class="attendance-menu" + label="&calendar.context.attendance.menu.label;" + accesskey="&calendar.context.attendance.menu.accesskey;" + oncommand="setContextPartstat(event.target.value, event.target.getAttribute('scope'), currentView().getSelectedItems({}))" + observes="calendar_attendance_command"> + <menupopup id="calendar-item-context-menu-attendance-menupopup"> + <label id="calendar-item-context-attendance-thisoccurrence-label" + class="calendar-context-heading-label" + scope="all-occurrences" + value="&calendar.context.attendance.occurrence.label;"/> + <menuitem id="calendar-item-context-menu-attend-accept-menuitem" + type="radio" + scope="this-occurrence" + name="calendar-item-context-attendance" + label="&read.only.accept.label;" value="ACCEPTED"/> + <menuitem id="calendar-item-context-menu-attend-tentative-menuitem" + type="radio" + scope="this-occurrence" + name="calendar-item-context-attendance" + label="&read.only.tentative.label;" value="TENTATIVE"/> + <menuitem id="calendar-item-context-menu-attend-declined-menuitem" + type="radio" + scope="this-occurrence" + name="calendar-item-context-attendance" + label="&read.only.decline.label;" value="DECLINED"/> + <menuitem id="calendar-item-context-menu-attend-needsaction-menuitem" + type="radio" + scope="this-occurrence" + name="calendar-item-context-attendance" + label="&read.only.needs.action.label;" value="NEEDS-ACTION"/> + <label id="calendar-item-context-attendance-alloccurrence-label" + class="calendar-context-heading-label" + scope="all-occurrences" + value="&calendar.context.attendance.all.label;"/> + <menuitem id="calendar-item-context-menu-attend-accept-all-menuitem" + type="radio" + scope="all-occurrences" + name="calendar-item-context-attendance-all" + label="&read.only.accept.label;" value="ACCEPTED"/> + <menuitem id="calendar-item-context-menu-attend-tentative-all-menuitem" + type="radio" + scope="all-occurrences" + name="calendar-item-context-attendance-all" + label="&read.only.tentative.label;" value="TENTATIVE"/> + <menuitem id="calendar-item-context-menu-attend-declined-all-menuitem" + type="radio" + scope="all-occurrences" + name="calendar-item-context-attendance-all" + label="&read.only.decline.label;" value="DECLINED"/> + <menuitem id="calendar-item-context-menu-attend-needsaction-all-menuitem" + type="radio" + scope="all-occurrences" + name="calendar-item-context-attendance-all" + label="&read.only.needs.action.label;" value="NEEDS-ACTION"/> + </menupopup> + </menu> + </menupopup> + + <!-- CALENDAR VIEW CONTEXT MENU --> + <menupopup id="calendar-view-context-menu"> + <menuitem id="calendar-view-context-menu-newevent" + label="&calendar.context.newevent.label;" + observes="calendar_new_event_context_command" + accesskey="&calendar.context.newevent.accesskey;" + key="calendar-new-event-key"/> + <menuitem id="calendar-view-context-menu-newtodo" + label="&calendar.context.newtodo.label;" + observes="calendar_new_todo_context_command" + accesskey="&calendar.context.newtodo.accesskey;" + key="calendar-new-todo-key"/> + <!-- These labels are set dynamically, based on the current view --> + <menuitem id="calendar-view-context-menu-previous" + label="" + accesskey="" + observes="calendar_view_prev_command" + label-day="&calendar.prevday.label;" + label-week="&calendar.prevweek.label;" + label-multiweek="&calendar.prevweek.label;" + label-month="&calendar.prevmonth.label;" + accesskey-day="&calendar.prevday.accesskey;" + accesskey-week="&calendar.prevweek.accesskey;" + accesskey-multiweek="&calendar.prevweek.accesskey;" + accesskey-month="&calendar.prevmonth.accesskey;"/> + <menuitem id="calendar-view-context-menu-next" + label="" + observes="calendar_view_next_command" + label-day="&calendar.nextday.label;" + label-week="&calendar.nextweek.label;" + label-multiweek="&calendar.nextweek.label;" + label-month="&calendar.nextmonth.label;" + accesskey-day="&calendar.nextday.accesskey;" + accesskey-week="&calendar.nextweek.accesskey;" + accesskey-multiweek="&calendar.nextweek.accesskey;" + accesskey-month="&calendar.nextmonth.accesskey;"/> + <menuseparator id="calendar-item-context-separator-cutcopypaste"/> + <!-- Cut and copy doesn't make sense in the views, but only showing paste + makes it look like something is missing. Disable by default. --> + <menuitem id="calendar-view-context-menu-cut-menuitem" + label="&calendar.context.cutevent.label;" + accesskey="&calendar.context.cutevent.accesskey;" + key="key_cut" + disabled="true"/> + <menuitem id="calendar-view-context-menu-copy-menuitem" + label="&calendar.context.copyevent.label;" + accesskey="&calendar.context.copyevent.accesskey;" + key="key_copy" + disabled="true"/> + <menuitem id="calendar-view-context-menu-paste-menuitem" + label="&calendar.context.pasteevent.label;" + accesskey="&calendar.context.pasteevent.accesskey;" + key="key_paste" + observes="cmd_paste" + command="cmd_paste"/> + </menupopup> + + <!-- TASK ITEM CONTEXT MENU --> + <menupopup id="taskitem-context-menu" + onpopupshowing="changeContextMenuForTask(event);" + onpopuphiding="handleTaskContextMenuStateChange(event);"> + <menuitem id="task-context-menu-modify" + label="&calendar.context.modifyorviewtask.label;" + accesskey="&calendar.context.modifyorviewtask.accesskey;" + command="calendar_modify_todo_command" + observes="calendar_modify_todo_command"/> + <menuitem id="task-context-menu-modify-todaypane" + label="&calendar.context.modifyorviewtask.label;" + accesskey="&calendar.context.modifyorviewtask.accesskey;" + command="calendar_modify_todo_todaypane_command" + observes="calendar_modify_todo_todaypane_command"/> + <menuitem id="task-context-menu-new" + label="&calendar.context.newtodo.label;" + accesskey="&calendar.context.newtodo.accesskey;" + key="calendar-new-todo-key" + command="calendar_new_todo_command" + observes="calendar_new_todo_command"/> + <menuitem id="task-context-menu-new-todaypane" + label="&calendar.context.newtodo.label;" + accesskey="&calendar.context.newtodo.accesskey;" + key="calendar-new-todo-key" + command="calendar_new_todo_todaypane_command" + observes="calendar_new_todo_todaypane_command"/> + <menuseparator id="task-context-menuseparator-cutcopypaste"/> + <menuitem id="task-context-menu-cut-menuitem" + label="&calendar.context.cutevent.label;" + accesskey="&calendar.context.cutevent.accesskey;" + key="key_cut" + observes="cmd_cut" + command="cmd_cut"/> + <menuitem id="task-context-menu-copy-menuitem" + label="&calendar.context.copyevent.label;" + accesskey="&calendar.context.copyevent.accesskey;" + key="key_copy" + observes="cmd_copy" + command="cmd_copy"/> + <menuitem id="task-context-menu-paste-menuitem" + label="&calendar.context.pasteevent.label;" + accesskey="&calendar.context.pasteevent.accesskey;" + key="key_paste" + observes="cmd_paste" + command="cmd_paste"/> + <menuseparator id="calendar-menuseparator-beforemarkcompleted"/> + <menuitem id="calendar-context-markcompleted" + type="checkbox" + autocheck="false" + label="&calendar.context.markcompleted.label;" + accesskey="&calendar.context.markcompleted.accesskey;" + observes="calendar_toggle_completed_command" + command="calendar_toggle_completed_command"/> + <menu id="task-context-menu-progress" + label="&calendar.context.progress.label;" + accesskey="&calendar.context.progress.accesskey;" + command="calendar_general-progress_command" + observes="calendar_general-progress_command"> + <menupopup id="progress-menupopup" type="task-progress"/> + </menu> + <menu id="task-context-menu-priority" + label="&calendar.context.priority.label;" + accesskey="&calendar.context.priority.accesskey;" + command="calendar_general-priority_command" + observes="calendar_general-priority_command"> + <menupopup id="priority-menupopup" type="task-priority"/> + </menu> + <menu id="task-context-menu-postpone" + label="&calendar.context.postpone.label;" + accesskey="&calendar.context.postpone.accesskey;" + command="calendar_general-postpone_command" + observes="calendar_general-postpone_command"> + <menupopup id="task-context-postpone-menupopup"> + <menuitem id="task-context-postpone-1hour" + label="&calendar.context.postpone.1hour.label;" + accesskey="&calendar.context.postpone.1hour.accesskey;" + observes="calendar_postpone-1hour_command"/> + <menuitem id="task-context-postpone-1day" + label="&calendar.context.postpone.1day.label;" + accesskey="&calendar.context.postpone.1day.accesskey;" + observes="calendar_postpone-1day_command"/> + <menuitem id="task-context-postpone-1week" + label="&calendar.context.postpone.1week.label;" + accesskey="&calendar.context.postpone.1week.accesskey;" + observes="calendar_postpone-1week_command"/> + </menupopup> + </menu> + <menu id="calendar-context-calendar-menu" + label="&calendar.calendar.label;" + accesskey="&calendar.calendar.accesskey;"> + <menupopup id="calendar-context-calendar-menupopup" + onpopupshowing="addCalendarNames(event);"/> + </menu> + <menuseparator id="task-context-menu-separator-conversion"/> + <menu id="task-context-menu-convert" + label="&calendar.context.convertmenu.label;" + accesskey="&calendar.context.convertmenu.accesskey.calendar;"> + <menupopup id="task-context-convert-menupopup"> + <menuitem id="calendar-context-converttomessage" + label="&calendar.context.convertmenu.message.label;" + accesskey="&calendar.context.convertmenu.message.accesskey;" + oncommand="tasksToMail(event)"/> + <menuitem id="calendar-context-converttoevent" + label="&calendar.context.convertmenu.event.label;" + accesskey="&calendar.context.convertmenu.event.accesskey;" + oncommand="tasksToEvents(event)"/> + </menupopup> + </menu> + <menuseparator/> + <menuitem id="task-context-menu-delete" + label="&calendar.context.deletetask.label;" + accesskey="&calendar.context.deletetask.accesskey;" + command="calendar_delete_todo_command" + observes="calendar_delete_todo_command"/> + <menu id="task-context-menu-attendance-menu" + class="attendance-menu" + label="&calendar.context.attendance.menu.label;" + accesskey="&calendar.context.attendance.menu.accesskey;" + oncommand="setContextPartstat(event.target.value, event.target.getAttribute('scope'), getSelectedTasks())" + observes="calendar_attendance_command"> + <menupopup id="task-context-menu-attendance-menupopup"> + <label id="task-context-attendance-thisoccurrence-label" + class="calendar-context-heading-label" + scope="all-occurrences" + value="&calendar.context.attendance.occurrence.label;"/> + <menuitem id="task-context-menu-attend-accept-menuitem" + type="radio" + scope="this-occurrence" + name="task-context-attendance" + label="&read.only.accept.label;" value="ACCEPTED"/> + <menuitem id="task-context-menu-attend-tentative-menuitem" + type="radio" + scope="this-occurrence" + name="task-context-attendance" + label="&read.only.tentative.label;" value="TENTATIVE"/> + <menuitem id="task-context-menu-attend-declined-menuitem" + type="radio" + scope="this-occurrence" + name="task-context-attendance" + label="&read.only.decline.label;" value="DECLINED"/> + <menuitem id="task-context-menu-attend-needsaction-menuitem" + type="radio" + scope="this-occurrence" + name="task-context-attendance" + label="&read.only.needs.action.label;" value="NEEDS-ACTION"/> + <label id="task-context-attendance-alloccurrence-label" + class="calendar-context-heading-label" + scope="all-occurrences" + value="&calendar.context.attendance.all.label;"/> + <menuitem id="task-context-menu-attend-accept-all-menuitem" + type="radio" + scope="all-occurrences" + name="task-context-attendance-all" + label="&read.only.accept.label;" value="ACCEPTED"/> + <menuitem id="task-context-menu-attend-tentative-all-menuitem" + type="radio" + scope="all-occurrences" + name="task-context-attendance-all" + label="&read.only.tentative.label;" value="TENTATIVE"/> + <menuitem id="task-context-menu-attend-declined-all-menuitem" + type="radio" + scope="all-occurrences" + name="task-context-attendance-all" + label="&read.only.decline.label;" value="DECLINED"/> + <menuitem id="task-context-menu-attend-needsaction-all-menuitem" + type="radio" + scope="all-occurrences" + name="task-context-attendance-all" + label="&read.only.needs.action.label;" value="NEEDS-ACTION"/> + </menupopup> + </menu> + <menuseparator id="task-context-menu-separator-filter"/> + <menu id="task-context-menu-filter-todaypane" + label="&calendar.tasks.view.filtertasks.label;" + accesskey="&calendar.tasks.view.filtertasks.accesskey;"> + <menupopup id="task-context-menu-filter-todaypane-popup"> + <observes element="unifinder-todo-filter-broadcaster" + attribute="value" + onbroadcast="checkRadioControl(this.parentNode, document.getElementById(this.getAttribute('element')).getAttribute('value'));"/> + <menuitem id="task-context-menu-filter-todaypane-current" + name="filtergrouptodaypane" + value="throughcurrent" + type="radio" + command="calendar_task_filter_todaypane_command" + label="&calendar.task.filter.current.label;" + accesskey="&calendar.task.filter.current.accesskey;"/> + <menuitem id="task-context-menu-filter-todaypane-today" + name="filtergrouptodaypane" + value="throughtoday" + type="radio" + command="calendar_task_filter_todaypane_command" + label="&calendar.task.filter.today.label;" + accesskey="&calendar.task.filter.today.accesskey;"/> + <menuitem id="task-context-menu-filter-todaypane-next7days" + name="filtergrouptodaypane" + value="throughsevendays" + type="radio" + command="calendar_task_filter_todaypane_command" + label="&calendar.task.filter.next7days.label;" + accesskey="&calendar.task.filter.next7days.accesskey;"/> + <menuitem id="task-context-menu-filter-todaypane-notstarted" + name="filtergrouptodaypane" + value="notstarted" + type="radio" + command="calendar_task_filter_todaypane_command" + label="&calendar.task.filter.notstarted.label;" + accesskey="&calendar.task.filter.notstarted.accesskey;"/> + <menuitem id="task-context-menu-filter-todaypane-overdue" + name="filtergrouptodaypane" + value="overdue" + type="radio" + command="calendar_task_filter_todaypane_command" + label="&calendar.task.filter.overdue.label;" + accesskey="&calendar.task.filter.overdue.accesskey;"/> + <menuitem id="task-context-menu-filter-todaypane-completed" + name="filtergrouptodaypane" + type="radio" + value="completed" + command="calendar_task_filter_todaypane_command" + label="&calendar.task.filter.completed.label;" + accesskey="&calendar.task.filter.completed.accesskey;"/> + <menuitem id="task-context-menu-filter-todaypane-open" + name="filtergrouptodaypane" + type="radio" + value="open" + command="calendar_task_filter_todaypane_command" + label="&calendar.task.filter.open.label;" + accesskey="&calendar.task.filter.open.accesskey;"/> + <menuitem id="task-context-menu-filter-todaypane-all" + name="filtergrouptodaypane" + value="all" + type="radio" + command="calendar_task_filter_todaypane_command" + label="&calendar.task.filter.all.label;" + accesskey="&calendar.task.filter.all.accesskey;"/> + </menupopup> + </menu> + </menupopup> + + <!-- TASKVIEW LINK CONTEXT MENU --> + <menupopup id="taskview-link-context-menu"> + <menuitem id="taskview-link-context-menu-copy" + label="&calendar.copylink.label;" + accesskey="&calendar.copylink.accesskey;" + oncommand="taskViewCopyLink(document.popupNode)"/> + </menupopup> + </popupset> +</overlay> diff --git a/calendar/base/content/calendar-daypicker.xml b/calendar/base/content/calendar-daypicker.xml new file mode 100644 index 000000000..17f217b18 --- /dev/null +++ b/calendar/base/content/calendar-daypicker.xml @@ -0,0 +1,265 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<bindings xmlns="http://www.mozilla.org/xbl" + xmlns:xbl="http://www.mozilla.org/xbl" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <!-- + ######################################################################## + ## daypicker + ######################################################################## + --> + + <binding id="daypicker" display="xul:button" + extends="chrome://global/content/bindings/button.xml#button-base"> + <resources> + <stylesheet src="chrome://calendar/skin/calendar-daypicker.css"/> + </resources> + <content> + <xul:hbox anonid="daypickerId" class="daypickerclass" align="center" flex="1"> + <xul:label anonid="daytext" + class="toolbarbutton-text" + flex="1" + xbl:inherits="value=label"/> + </xul:hbox> + </content> + <implementation> + <method name="onmodified"> + <parameter name="aEvent"/> + <body><![CDATA[ + if (aEvent.attrName == "checked") { + let event = document.createEvent("Events"); + event.initEvent("select", true, true); + this.calendar.dispatchEvent(event); + } + ]]></body> + </method> + <constructor><![CDATA[ + this.setAttribute("autoCheck", "true"); + this.setAttribute("type", "checkbox"); + this.addEventListener("DOMAttrModified", this.onmodified, false); + ]]></constructor> + </implementation> + </binding> + + <!-- + ######################################################################## + ## daypicker-weekday + ######################################################################## + --> + + <binding id="daypicker-weekday" extends="xul:box"> + <resources> + <stylesheet src="chrome://calendar/skin/calendar-daypicker.css"/> + </resources> + + <content> + <xul:hbox anonid="mainbox" flex="1"> + <xul:daypicker bottom="true" xbl:inherits="disabled,mode=id"/> + <xul:daypicker bottom="true" xbl:inherits="disabled,mode=id"/> + <xul:daypicker bottom="true" xbl:inherits="disabled,mode=id"/> + <xul:daypicker bottom="true" xbl:inherits="disabled,mode=id"/> + <xul:daypicker bottom="true" xbl:inherits="disabled,mode=id"/> + <xul:daypicker bottom="true" xbl:inherits="disabled,mode=id"/> + <xul:daypicker bottom="true" right="true" xbl:inherits="disabled,mode=id"/> + </xul:hbox> + </content> + + <implementation> + <!-- + The weekday-picker manages an array of selected days of the week and + the 'days' property is the interface to this array. the expected argument is + an array containing integer elements, where each element represents a selected + day of the week, starting with SUNDAY=1. + --> + <property name="days"> + <setter><![CDATA[ + let mainbox = + document.getAnonymousElementByAttribute( + this, "anonid", "mainbox"); + let numChilds = mainbox.childNodes.length; + for (let i = 0; i < numChilds; i++) { + let child = mainbox.childNodes[i]; + child.removeAttribute("checked"); + } + for (let i = 0; i < val.length; i++) { + let index = val[i] - 1 - this.weekStartOffset; + if (index < 0) { + index += 7; + } + mainbox.childNodes[index].setAttribute("checked", "true"); + } + return val; + ]]></setter> + <getter><![CDATA[ + let mainbox = + document.getAnonymousElementByAttribute( + this, "anonid", "mainbox"); + let numChilds = mainbox.childNodes.length; + let days = []; + for (let i = 0; i < numChilds; i++) { + let child = mainbox.childNodes[i]; + if (child.getAttribute("checked") == "true") { + let index = i + this.weekStartOffset; + if (index >= 7) { + index -= 7; + } + days.push(index + 1); + } + } + return days; + ]]></getter> + </property> + + <constructor><![CDATA[ + Components.utils.import("resource://gre/modules/Services.jsm"); + Components.utils.import("resource://gre/modules/Preferences.jsm"); + + this.weekStartOffset = Preferences.get("calendar.week.start", 0); + + let props = + Services.strings.createBundle( + "chrome://calendar/locale/dateFormat.properties"); + let mainbox = + document.getAnonymousElementByAttribute( + this, "anonid", "mainbox"); + let numChilds = mainbox.childNodes.length; + for (let i = 0; i < numChilds; i++) { + let child = mainbox.childNodes[i]; + let dow = i + this.weekStartOffset; + if (dow >= 7) { + dow -= 7; + } + let day = props.GetStringFromName("day." + (dow + 1) + ".Mmm"); + child.label = day; + child.calendar = this; + } + ]]></constructor> + </implementation> + </binding> + + <!-- + ######################################################################## + ## daypicker-monthday + ######################################################################## + --> + + <binding id="daypicker-monthday" extends="xul:box"> + <resources> + <stylesheet src="chrome://calendar/skin/calendar-daypicker.css"/> + </resources> + + <content> + <xul:vbox anonid="mainbox" class="daypicker-monthday-mainbox" flex="1" > + <xul:hbox class="daypicker-row" flex="1"> + <daypicker label="1" xbl:inherits="disabled, mode=id"/> + <daypicker label="2" xbl:inherits="disabled, mode=id"/> + <daypicker label="3" xbl:inherits="disabled, mode=id"/> + <daypicker label="4" xbl:inherits="disabled, mode=id"/> + <daypicker label="5" xbl:inherits="disabled, mode=id"/> + <daypicker label="6" xbl:inherits="disabled, mode=id"/> + <daypicker label="7" right="true" xbl:inherits="disabled, mode=id"/> + </xul:hbox> + <xul:hbox class="daypicker-row" flex="1"> + <daypicker label="8" xbl:inherits="disabled, mode=id"/> + <daypicker label="9" xbl:inherits="disabled, mode=id"/> + <daypicker label="10" xbl:inherits="disabled, mode=id"/> + <daypicker label="11" xbl:inherits="disabled, mode=id"/> + <daypicker label="12" xbl:inherits="disabled, mode=id"/> + <daypicker label="13" xbl:inherits="disabled, mode=id"/> + <daypicker label="14" right="true" xbl:inherits="disabled, mode=id"/> + </xul:hbox> + <xul:hbox class="daypicker-row" flex="1"> + <daypicker label="15" xbl:inherits="disabled, mode=id"/> + <daypicker label="16" xbl:inherits="disabled, mode=id"/> + <daypicker label="17" xbl:inherits="disabled, mode=id"/> + <daypicker label="18" xbl:inherits="disabled, mode=id"/> + <daypicker label="19" xbl:inherits="disabled, mode=id"/> + <daypicker label="20" xbl:inherits="disabled, mode=id"/> + <daypicker label="21" right="true" xbl:inherits="disabled, mode=id"/> + </xul:hbox> + <xul:hbox class="daypicker-row" flex="1"> + <daypicker label="22" xbl:inherits="disabled, mode=id"/> + <daypicker label="23" xbl:inherits="disabled, mode=id"/> + <daypicker label="24" xbl:inherits="disabled, mode=id"/> + <daypicker label="25" xbl:inherits="disabled, mode=id"/> + <daypicker label="26" xbl:inherits="disabled, mode=id"/> + <daypicker label="27" xbl:inherits="disabled, mode=id"/> + <daypicker label="28" right="true" xbl:inherits="disabled, mode=id"/> + </xul:hbox> + <xul:hbox class="daypicker-row" flex="1"> + <daypicker bottom="true" label="29" xbl:inherits="disabled, mode=id"/> + <daypicker bottom="true" label="30" xbl:inherits="disabled, mode=id"/> + <daypicker bottom="true" label="31" xbl:inherits="disabled, mode=id"/> + <daypicker bottom="true" right="true" label="" xbl:inherits="disabled, mode=id"/> + </xul:hbox> + </xul:vbox> + </content> + <implementation> + <property name="days"> + <setter><![CDATA[ + let mainbox = + document.getAnonymousElementByAttribute( + this, "anonid", "mainbox"); + let numRows = mainbox.childNodes.length; + let days = []; + for (let i = 0; i < numRows; i++) { + let row = mainbox.childNodes[i]; + let numChilds = row.childNodes.length; + for (let j = 0; j < numChilds; j++) { + let child = row.childNodes[j]; + child.removeAttribute("checked"); + days.push(child); + } + } + for (let i = 0; i < val.length; i++) { + let lastDayOffset = val[i] == -1 ? 0 : -1; + let index = (val[i] < 0 ? val[i] + days.length + lastDayOffset + : val[i] - 1); + days[index].setAttribute("checked", "true"); + } + return val; + ]]></setter> + <getter><![CDATA[ + let mainbox = + document.getAnonymousElementByAttribute( + this, "anonid", "mainbox"); + let numRows = mainbox.childNodes.length; + let days = []; + for (let i = 0; i < numRows; i++) { + let row = mainbox.childNodes[i]; + let numChilds = row.childNodes.length; + for (let j = 0; j < numChilds; j++) { + let child = row.childNodes[j]; + if (child.getAttribute("checked") == "true") { + days.push(Number(child.label) ? Number(child.label) : -1); + } + } + } + return days; + ]]></getter> + </property> + + <constructor><![CDATA[ + let mainbox = + document.getAnonymousElementByAttribute( + this, "anonid", "mainbox"); + let numRows = mainbox.childNodes.length; + let child = null; + for (let i = 0; i < numRows; i++) { + let row = mainbox.childNodes[i]; + let numChilds = row.childNodes.length; + for (let j = 0; j < numChilds; j++) { + child = row.childNodes[j]; + child.calendar = this; + } + } + let labelLastDay = calGetString("calendar-event-dialog", "eventRecurrenceMonthlyLastDayLabel"); + child.setAttribute("label", labelLastDay); + ]]></constructor> + </implementation> + </binding> +</bindings> diff --git a/calendar/base/content/calendar-dnd-listener.js b/calendar/base/content/calendar-dnd-listener.js new file mode 100644 index 000000000..9901a0332 --- /dev/null +++ b/calendar/base/content/calendar-dnd-listener.js @@ -0,0 +1,596 @@ +/* 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/. */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://calendar/modules/calAlarmUtils.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); +Components.utils.import("resource://gre/modules/Preferences.jsm"); + +var itemConversion = { + + /** + * Converts an email message to a calendar item. + * + * @param aItem The target calIItemBase. + * @param aMessage The nsIMsgHdr to convert from. + */ + calendarItemFromMessage: function iC_calendarItemFromMessage(aItem, aMsgHdr) { + let msgFolder = aMsgHdr.folder; + let msgUri = msgFolder.getUriForMsg(aMsgHdr); + + aItem.calendar = getSelectedCalendar(); + aItem.title = aMsgHdr.mime2DecodedSubject; + + cal.setDefaultStartEndHour(aItem); + cal.alarms.setDefaultValues(aItem); + + let messenger = Components.classes["@mozilla.org/messenger;1"] + .createInstance(Components.interfaces.nsIMessenger); + let streamListener = Components.classes["@mozilla.org/network/sync-stream-listener;1"] + .createInstance(Components.interfaces.nsISyncStreamListener); + messenger.messageServiceFromURI(msgUri).streamMessage(msgUri, + streamListener, + null, + null, + false, + "", + false); + + let plainTextMessage = ""; + plainTextMessage = msgFolder.getMsgTextFromStream(streamListener.inputStream, + aMsgHdr.Charset, + 65536, + 32768, + false, + true, + {}); + aItem.setProperty("DESCRIPTION", plainTextMessage); + }, + + /** + * Copy base item properties from aItem to aTarget. This includes properties + * like title, location, description, priority, transparency, + * attendees, categories, calendar, recurrence and possibly more. + * + * @param aItem The item to copy from. + * @param aTarget the item to copy to. + */ + copyItemBase: function iC_copyItemBase(aItem, aTarget) { + const copyProps = ["SUMMARY", "LOCATION", "DESCRIPTION", + "URL", "CLASS", "PRIORITY"]; + + for (var prop of copyProps) { + aTarget.setProperty(prop, aItem.getProperty(prop)); + } + + // Attendees + var attendees = aItem.getAttendees({}); + for (var attendee of attendees) { + aTarget.addAttendee(attendee.clone()); + } + + // Categories + var categories = aItem.getCategories({}); + aTarget.setCategories(categories.length, categories); + + // Organizer + aTarget.organizer = (aItem.organizer ? aItem.organizer.clone() : null); + + // Calendar + aTarget.calendar = getSelectedCalendar(); + + // Recurrence + if (aItem.recurrenceInfo) { + aTarget.recurrenceInfo = aItem.recurrenceInfo.clone(); + aTarget.recurrenceInfo.item = aTarget; + } + }, + + /** + * Creates a task from the passed event. This function copies the base item + * and a few event specific properties (dates, alarms, ...). + * + * @param aEvent The event to copy from. + * @return The resulting task. + */ + taskFromEvent: function iC_taskFromEvent(aEvent) { + let item = cal.createTodo(); + + this.copyItemBase(aEvent, item); + + // Dates and alarms + if (!aEvent.startDate.isDate && !aEvent.endDate.isDate) { + // Dates + item.entryDate = aEvent.startDate.clone(); + item.dueDate = aEvent.endDate.clone(); + + // Alarms + for (let alarm of aEvent.getAlarms({})) { + item.addAlarm(alarm.clone()); + } + item.alarmLastAck = (aEvent.alarmLastAck ? + aEvent.alarmLastAck.clone() : + null); + } + + // Map Status values + let statusMap = { + "TENTATIVE": "NEEDS-ACTION", + "CONFIRMED": "IN-PROCESS", + "CANCELLED": "CANCELLED" + }; + if (aEvent.getProperty("STATUS") in statusMap) { + item.setProperty("STATUS", statusMap[aEvent.getProperty("STATUS")]); + } + return item; + }, + + /** + * Creates an event from the passed task. This function copies the base item + * and a few task specific properties (dates, alarms, ...). If the task has + * no due date, the default event length is used. + * + * @param aTask The task to copy from. + * @return The resulting event. + */ + eventFromTask: function iC_eventFromTask(aTask) { + let item = cal.createEvent(); + + this.copyItemBase(aTask, item); + + // Dates and alarms + item.startDate = aTask.entryDate; + if (!item.startDate) { + if (aTask.dueDate) { + item.startDate = aTask.dueDate.clone(); + item.startDate.minute -= Preferences.get("calendar.event.defaultlength", 60); + } else { + item.startDate = cal.getDefaultStartDate(); + } + } + + item.endDate = aTask.dueDate; + if (!item.endDate) { + // Make the event be the default event length if no due date was + // specified. + item.endDate = item.startDate.clone(); + item.endDate.minute += Preferences.get("calendar.event.defaultlength", 60); + } + + // Alarms + for (let alarm of aTask.getAlarms({})) { + item.addAlarm(alarm.clone()); + } + item.alarmLastAck = (aTask.alarmLastAck ? + aTask.alarmLastAck.clone() : + null); + + // Map Status values + let statusMap = { + "NEEDS-ACTION": "TENTATIVE", + "COMPLETED": "CONFIRMED", + "IN-PROCESS": "CONFIRMED", + "CANCELLED": "CANCELLED" + }; + if (aTask.getProperty("STATUS") in statusMap) { + item.setProperty("STATUS", statusMap[aTask.getProperty("STATUS")]); + } + return item; + } +}; + +/** + * A base class for drag and drop observers + * @class calDNDBaseObserver + */ +function calDNDBaseObserver() { + ASSERT(false, "Inheriting objects call calDNDBaseObserver!"); +} + +calDNDBaseObserver.prototype = { + // initialize this class's members + initBase: function calDNDInitBase() { + }, + + getSupportedFlavours: function calDNDGetFlavors() { + var flavourSet = new FlavourSet(); + flavourSet.appendFlavour("text/calendar"); + flavourSet.appendFlavour("text/x-moz-url"); + flavourSet.appendFlavour("text/x-moz-message"); + flavourSet.appendFlavour("text/unicode"); + flavourSet.appendFlavour("application/x-moz-file"); + return flavourSet; + }, + + /** + * Action to take when dropping the event. + */ + + onDrop: function calDNDDrop(aEvent, aTransferData, aDragSession) { + var transferable = Components.classes["@mozilla.org/widget/transferable;1"] + .createInstance(Components.interfaces.nsITransferable); + transferable.init(null); + transferable.addDataFlavor("text/calendar"); + transferable.addDataFlavor("text/x-moz-url"); + transferable.addDataFlavor("text/x-moz-message"); + transferable.addDataFlavor("text/unicode"); + transferable.addDataFlavor("application/x-moz-file"); + + aDragSession.getData(transferable, 0); + + var data = new Object(); + var bestFlavor = new Object(); + var length = new Object(); + transferable.getAnyTransferData(bestFlavor, data, length); + + try { + data = data.value.QueryInterface(Components.interfaces.nsISupportsString); + } catch (exc) { + // we currently only supports strings: + return; + } + + // Treat unicode data with VEVENT in it as text/calendar + if (bestFlavor.value == "text/unicode" && data.toString().includes("VEVENT")) { + bestFlavor.value = "text/calendar"; + } + + var destCal = getSelectedCalendar(); + switch (bestFlavor.value) { + case "text/calendar": + var parser = Components.classes["@mozilla.org/calendar/ics-parser;1"] + .createInstance(Components.interfaces.calIIcsParser); + parser.parseString(data); + this.onDropItems(parser.getItems({}).concat(parser.getParentlessItems({}))); + break; + case "text/unicode": + var droppedUrl = this.retrieveURLFromData(data, bestFlavor.value); + if (!droppedUrl) + return; + + var url = makeURL(droppedUrl); + + var localFileInstance = Components.classes["@mozilla.org/file/local;1"] + .createInstance(Components.interfaces.nsILocalFile); + localFileInstance.initWithPath(url.path); + + var inputStream = Components.classes["@mozilla.org/network/file-input-stream;1"] + .createInstance(Components.interfaces.nsIFileInputStream); + inputStream.init(localFileInstance, + MODE_RDONLY, + parseInt("0444", 8), + {}); + + try { + //XXX support csv + var importer = Components.classes["@mozilla.org/calendar/import;1?type=ics"] + .getService(Components.interfaces.calIImporter); + var items = importer.importFromStream(inputStream, {}); + this.onDropItems(items); + } + finally { + inputStream.close(); + } + + break; + case "application/x-moz-file-promise": + case "text/x-moz-url": + var uri = Services.io.newURI(data.toString(), null, null); + var loader = Components.classes["@mozilla.org/network/unichar-stream-loader;1"] + .createInstance(Components.interfaces.nsIUnicharStreamLoader); + var channel = Services.io.newChannelFromURI2(uri, + null, + Services.scriptSecurityManager.getSystemPrincipal(), + null, + Components.interfaces.nsILoadInfo.SEC_NORMAL, + Components.interfaces.nsIContentPolicy.TYPE_OTHER); + channel.loadFlags |= Components.interfaces.nsIRequest.LOAD_BYPASS_CACHE; + + var self = this; + + var listener = { + + // nsIUnicharStreamLoaderObserver: + onDetermineCharset: function(loader, context, firstSegment, length) { + var charset = null; + if (loader && loader.channel) { + charset = channel.contentCharset; + } + if (!charset || charset.length == 0) { + charset = "UTF-8"; + } + return charset; + }, + + onStreamComplete: function(loader, context, status, unicharString) { + var parser = Components.classes["@mozilla.org/calendar/ics-parser;1"] + .createInstance(Components.interfaces.calIIcsParser); + parser.parseString(unicharString); + self.onDropItems(parser.getItems({}).concat(parser.getParentlessItems({}))); + } + }; + + try { + loader.init(listener, Components.interfaces.nsIUnicharStreamLoader.DEFAULT_SEGMENT_SIZE); + channel.asyncOpen(loader, null); + } catch(e) { + Components.utils.reportError(e) + } + break; + case "text/x-moz-message": + this.onDropMessage(messenger.msgHdrFromURI(data)); + break; + default: + ASSERT(false, "unknown data flavour:" + bestFlavor.value+'\n'); + break; + } + }, + + onDragStart: function calDNDStart(aEvent, aTransferData, aDragAction) {}, + onDragOver: function calDNDOver(aEvent, aFlavor, aDragSession) {}, + onDragExit: function calDNDExit(aEvent, aDragSession) {}, + + onDropItems: function calDNDDropItems(aItems) {}, + onDropMessage: function calDNDDropMessage(aMessage) {}, + + + retrieveURLFromData: function calDNDRetrieveURL(aData, aFlavor) { + var data; + switch (aFlavor) { + case "text/unicode": + data = aData.toString(); + var separator = data.indexOf("\n"); + if (separator != -1) + data = data.substr(0, separator); + return data; + case "application/x-moz-file": + return aData.URL; + default: + return null; + } + } +}; + +/** + * calViewDNDObserver::calViewDNDObserver + * + * Drag'n'drop handler for the calendar views. This handler is + * derived from the base handler and just implements specific actions. + */ +function calViewDNDObserver() { + this.wrappedJSObject = this; + this.initBase(); +} + +calViewDNDObserver.prototype = { + __proto__: calDNDBaseObserver.prototype, + + /** + * calViewDNDObserver::onDropItems + * + * Gets called in case we're dropping an array of items + * on one of the calendar views. In this case we just + * try to add these items to the currently selected calendar. + */ + onDropItems: function(aItems) { + var destCal = getSelectedCalendar(); + startBatchTransaction(); + try { + for (var item of aItems) { + doTransaction('add', item, destCal, null, null); + } + } + finally { + endBatchTransaction(); + } + } +}; + +/** + * calMailButtonDNDObserver::calMailButtonDNDObserver + * + * Drag'n'drop handler for the 'mail mode'-button. This handler is + * derived from the base handler and just implements specific actions. + */ +function calMailButtonDNDObserver() { + this.wrappedJSObject = this; + this.initBase(); +} + +calMailButtonDNDObserver.prototype = { + __proto__: calDNDBaseObserver.prototype, + + /** + * calMailButtonDNDObserver::onDropItems + * + * Gets called in case we're dropping an array of items + * on the 'mail mode'-button. + * + * @param aItems An array of items to handle. + */ + onDropItems: function(aItems) { + if (aItems && aItems.length > 0) { + let item = aItems[0]; + let recipients = cal.getRecipientList(item.getAttendees({})); + let identity = item.calendar.getProperty("imip.identity"); + sendMailTo(recipients, item.title, item.getProperty("DESCRIPTION"), identity); + } + }, + + /** + * calMailButtonDNDObserver::onDropMessage + * + * Gets called in case we're dropping a message + * on the 'mail mode'-button. + * + * @param aMessage The message to handle. + */ + onDropMessage: function(aMessage) { + } +}; + +/** + * calCalendarButtonDNDObserver::calCalendarButtonDNDObserver + * + * Drag'n'drop handler for the 'calendar mode'-button. This handler is + * derived from the base handler and just implements specific actions. + */ +function calCalendarButtonDNDObserver() { + this.wrappedJSObject = this; + this.initBase(); +} + +calCalendarButtonDNDObserver.prototype = { + __proto__: calDNDBaseObserver.prototype, + + /** + * calCalendarButtonDNDObserver::onDropItems + * + * Gets called in case we're dropping an array of items + * on the 'calendar mode'-button. + * + * @param aItems An array of items to handle. + */ + onDropItems: function(aItems) { + for (var item of aItems) { + var newItem = item; + if (isToDo(item)) { + newItem = itemConversion.eventFromTask(item); + } + createEventWithDialog(null, null, null, null, newItem); + } + }, + + /** + * calCalendarButtonDNDObserver::onDropMessage + * + * Gets called in case we're dropping a message on the + * 'calendar mode'-button. In this case we create a new + * event from the mail. We open the default event dialog + * and just use the subject of the message as the event title. + * + * @param aMessage The message to handle. + */ + onDropMessage: function(aMessage) { + var newItem = createEvent(); + itemConversion.calendarItemFromMessage(newItem, aMessage); + createEventWithDialog(null, null, null, null, newItem); + } +}; + +/** + * calTaskButtonDNDObserver::calTaskButtonDNDObserver + * + * Drag'n'drop handler for the 'task mode'-button. This handler is + * derived from the base handler and just implements specific actions. + */ +function calTaskButtonDNDObserver() { + this.wrappedJSObject = this; + this.initBase(); +} + +calTaskButtonDNDObserver.prototype = { + __proto__: calDNDBaseObserver.prototype, + + /** + * calTaskButtonDNDObserver::onDropItems + * + * Gets called in case we're dropping an array of items + * on the 'task mode'-button. + * + * @param aItems An array of items to handle. + */ + onDropItems: function(aItems) { + for (var item of aItems) { + var newItem = item; + if (isEvent(item)) { + newItem = itemConversion.taskFromEvent(item); + } + createTodoWithDialog(null, null, null, newItem); + } + }, + + /** + * calTaskButtonDNDObserver::onDropMessage + * + * Gets called in case we're dropping a message + * on the 'task mode'-button. + * + * @param aMessage The message to handle. + */ + onDropMessage: function(aMessage) { + var todo = createTodo(); + itemConversion.calendarItemFromMessage(todo, aMessage); + createTodoWithDialog(null, null, null, todo); + } +}; + +/** + * Invoke a drag session for the passed item. The passed box will be used as a + * source. + * + * @param aItem The item to drag. + * @param aXULBox The XUL box to invoke the drag session from. + */ +function invokeEventDragSession(aItem, aXULBox) { + let transfer = Components.classes["@mozilla.org/widget/transferable;1"] + .createInstance(Components.interfaces.nsITransferable); + transfer.init(null); + transfer.addDataFlavor("text/calendar"); + + let flavourProvider = { + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.nsIFlavorDataProvider]), + + item: aItem, + getFlavorData: function(aInTransferable, aInFlavor, aOutData, aOutDataLen) { + if ((aInFlavor == "application/vnd.x-moz-cal-event") || + (aInFlavor == "application/vnd.x-moz-cal-task")) { + aOutData.value = aItem; + aOutDataLen.value = 1; + } else { + ASSERT(false, "error:" + aInFlavor); + } + } + }; + + if (isEvent(aItem)) { + transfer.addDataFlavor("application/vnd.x-moz-cal-event"); + transfer.setTransferData("application/vnd.x-moz-cal-event", flavourProvider, 0); + } else if (isToDo(aItem)) { + transfer.addDataFlavor("application/vnd.x-moz-cal-task"); + transfer.setTransferData("application/vnd.x-moz-cal-task", flavourProvider, 0); + } + + // Also set some normal data-types, in case we drag into another app + let serializer = Components.classes["@mozilla.org/calendar/ics-serializer;1"] + .createInstance(Components.interfaces.calIIcsSerializer); + serializer.addItems([aItem], 1); + + let supportsString = Components.classes["@mozilla.org/supports-string;1"] + .createInstance(Components.interfaces.nsISupportsString); + supportsString.data = serializer.serializeToString(); + transfer.setTransferData("text/calendar", supportsString, supportsString.data.length * 2); + transfer.setTransferData("text/unicode", supportsString, supportsString.data.length * 2); + + let action = Components.interfaces.nsIDragService.DRAGDROP_ACTION_MOVE; + let supArray = Components.classes["@mozilla.org/supports-array;1"] + .createInstance(Components.interfaces.nsISupportsArray); + supArray.AppendElement(transfer); + aXULBox.sourceObject = aItem; + try { + cal.getDragService().invokeDragSession(aXULBox, supArray, null, action); + } catch (error) { + // Nothing done here because we only have to catch an exception that occurs when dragging + // is cancelled with ESC. This is an odd behaviour of the nativeDragService which we have + // have to cover. + // Therefore the DND API for calendar should be changed to the new DOM driven DND-API + // sometime. + } +} + +var calendarViewDNDObserver = new calViewDNDObserver(); +var calendarMailButtonDNDObserver = new calMailButtonDNDObserver(); +var calendarCalendarButtonDNDObserver = new calCalendarButtonDNDObserver(); +var calendarTaskButtonDNDObserver = new calTaskButtonDNDObserver(); diff --git a/calendar/base/content/calendar-extract.js b/calendar/base/content/calendar-extract.js new file mode 100644 index 000000000..8ceb3be7e --- /dev/null +++ b/calendar/base/content/calendar-extract.js @@ -0,0 +1,274 @@ +/* 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/. */ + +Components.utils.import("resource://calendar/modules/calExtract.jsm"); +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://gre/modules/Preferences.jsm"); + +var calendarExtract = { + onShowLocaleMenu: function(target) { + let localeList = document.getElementById(target.id); + let langs = []; + let chrome = Components.classes["@mozilla.org/chrome/chrome-registry;1"] + .getService(Components.interfaces.nsIXULChromeRegistry); + chrome.QueryInterface(Components.interfaces.nsIToolkitChromeRegistry); + let locales = chrome.getLocalesForPackage("calendar"); + let langRegex = /^(([^-]+)-*(.*))$/; + + while (locales.hasMore()) { + let localeParts = langRegex.exec(locales.getNext()); + let langName = localeParts[2]; + + try { + langName = cal.calGetString("languageNames", langName, null, "global"); + } catch (ex) { + // If no language name is found that is ok, keep the technical term + } + + let label = cal.calGetString("calendar", "extractUsing", [langName]); + if (localeParts[3] != "") { + label = cal.calGetString("calendar", "extractUsingRegion", [langName, localeParts[3]]); + } + + langs.push([label, localeParts[1]]); + } + + // sort + let pref = "calendar.patterns.last.used.languages"; + let lastUsedLangs = Preferences.get(pref, ""); + + langs.sort((a, b) => { + let idx_a = lastUsedLangs.indexOf(a[1]); + let idx_b = lastUsedLangs.indexOf(b[1]); + + if (idx_a == -1 && idx_b == -1) { + return a[0].localeCompare(b[0]); + } else if (idx_a != -1 && idx_b != -1) { + return idx_a - idx_b; + } else if (idx_a == -1) { + return 1; + } else { + return -1; + } + }); + removeChildren(localeList); + + for (let lang of langs) { + addMenuItem(localeList, lang[0], lang[1], null); + } + }, + + extractWithLocale: function(event, isEvent) { + event.stopPropagation(); + let locale = event.target.value; + this.extractFromEmail(isEvent, true, locale); + }, + + extractFromEmail: function(isEvent, fixedLang, fixedLocale) { + // TODO would be nice to handle multiple selected messages, + // though old conversion functionality didn't + let message = gFolderDisplay.selectedMessage; + let messenger = Components.classes["@mozilla.org/messenger;1"] + .createInstance(Components.interfaces.nsIMessenger); + let listener = Components.classes["@mozilla.org/network/sync-stream-listener;1"] + .createInstance(Components.interfaces.nsISyncStreamListener); + let uri = message.folder.getUriForMsg(message); + messenger.messageServiceFromURI(uri) + .streamMessage(uri, listener, null, null, false, ""); + let folder = message.folder; + let title = message.mime2DecodedSubject; + let content = folder.getMsgTextFromStream(listener.inputStream, + message.Charset, + 65536, + 32768, + false, + true, + { }); + cal.LOG("[calExtract] Original email content: \n" + title + "\r\n" + content); + let date = new Date(message.date / 1000); + let time = (new Date()).getTime(); + + let locale = Preferences.get("general.useragent.locale", "en-US"); + let dayStart = Preferences.get("calendar.view.daystarthour", 6); + let extractor; + + if (fixedLang) { + extractor = new Extractor(fixedLocale, dayStart); + } else { + extractor = new Extractor(locale, dayStart, false); + } + + let item; + item = isEvent ? cal.createEvent() : cal.createTodo(); + item.title = message.mime2DecodedSubject; + item.calendar = getSelectedCalendar(); + item.setProperty("DESCRIPTION", content); + cal.setDefaultStartEndHour(item); + cal.alarms.setDefaultValues(item); + let sel = GetMessagePaneFrame().getSelection(); + // Thunderbird Conversations might be installed + if (sel === null) { + try { + sel = document.getElementById("multimessage") + .contentDocument.querySelector(".iframe-container iframe") + .contentDocument.getSelection(); + } catch (ex) { + // If Thunderbird Conversations is not installed that is fine, + // we will just have a null selection. + } + } + let collected = extractor.extract(title, content, date, sel); + + // if we only have email date then use default start and end + if (collected.length == 1) { + cal.LOG("[calExtract] Date and time information was not found in email/selection."); + createEventWithDialog(null, null, null, null, item); + } else { + let guessed = extractor.guessStart(!isEvent); + let endGuess = extractor.guessEnd(guessed, !isEvent); + let allDay = (guessed.hour == null || guessed.minute == null) && isEvent; + + if (isEvent) { + if (guessed.year != null) { + item.startDate.year = guessed.year; + } + if (guessed.month != null) { + item.startDate.month = guessed.month - 1; + } + if (guessed.day != null) { + item.startDate.day = guessed.day; + } + if (guessed.hour != null) { + item.startDate.hour = guessed.hour; + } + if (guessed.minute != null) { + item.startDate.minute = guessed.minute; + } + + item.endDate = item.startDate.clone(); + item.endDate.minute += Preferences.get("calendar.event.defaultlength", 60); + + if (endGuess.year != null) { + item.endDate.year = endGuess.year; + } + if (endGuess.month != null) { + item.endDate.month = endGuess.month - 1; + } + if (endGuess.day != null) { + item.endDate.day = endGuess.day; + if (allDay) { + item.endDate.day++; + } + } + if (endGuess.hour != null) { + item.endDate.hour = endGuess.hour; + } + if (endGuess.minute != null) { + item.endDate.minute = endGuess.minute; + } + } else { + let dtz = cal.calendarDefaultTimezone(); + let dueDate = new Date(); + // set default + dueDate.setHours(0); + dueDate.setMinutes(0); + dueDate.setSeconds(0); + + if (endGuess.year != null) { + dueDate.setYear(endGuess.year); + } + if (endGuess.month != null) { + dueDate.setMonth(endGuess.month - 1); + } + if (endGuess.day != null) { + dueDate.setDate(endGuess.day); + } + if (endGuess.hour != null) { + dueDate.setHours(endGuess.hour); + } + if (endGuess.minute != null) { + dueDate.setMinutes(endGuess.minute); + } + + setItemProperty(item, "entryDate", cal.jsDateToDateTime(date, dtz)); + if (endGuess.year != null) { + setItemProperty(item, "dueDate", cal.jsDateToDateTime(dueDate, dtz)); + } + } + + // if time not guessed set allday for events + if (allDay) { + createEventWithDialog(null, null, null, null, item, true); + } else { + createEventWithDialog(null, null, null, null, item); + } + } + + let timeSpent = (new Date()).getTime() - time; + cal.LOG("[calExtract] Total time spent for conversion (including loading of dictionaries): " + timeSpent + "ms"); + }, + + addListeners: function() { + if (window.top.document.location == "chrome://messenger/content/messenger.xul") { + // covers initial load and folder change + let folderTree = document.getElementById("folderTree"); + folderTree.addEventListener("select", this.setState, false); + + // covers selection change in a folder + let msgTree = window.top.GetThreadTree(); + msgTree.addEventListener("select", this.setState, false); + + window.addEventListener("unload", () => { + folderTree.removeEventListener("select", this.setState, false); + msgTree.removeEventListener("select", this.setState, false); + }, false); + } + }, + + setState: function() { + let eventButton = document.getElementById("extractEventButton"); + let taskButton = document.getElementById("extractTaskButton"); + let hdrEventButton = document.getElementById("hdrExtractEventButton"); + let hdrTaskButton = document.getElementById("hdrExtractTaskButton"); + let contextMenu = document.getElementById("mailContext-calendar-convert-menu"); + let contextMenuEvent = document.getElementById("mailContext-calendar-convert-event-menuitem"); + let contextMenuTask = document.getElementById("mailContext-calendar-convert-task-menuitem"); + let eventDisabled = (gFolderDisplay.selectedCount == 0); + let taskDisabled = (gFolderDisplay.selectedCount == 0); + let contextEventDisabled = false; + let contextTaskDisabled = false; + let newEvent = document.getElementById("calendar_new_event_command"); + let newTask = document.getElementById("calendar_new_todo_command"); + + if (newEvent.getAttribute("disabled") == "true") { + eventDisabled = true; + contextEventDisabled = true; + } + + if (newTask.getAttribute("disabled") == "true") { + taskDisabled = true; + contextTaskDisabled = true; + } + + if (eventButton) { + eventButton.disabled = eventDisabled; + } + if (taskButton) { + taskButton.disabled = taskDisabled; + } + if (hdrEventButton) { + hdrEventButton.disabled = eventDisabled; + } + if (hdrTaskButton) { + hdrTaskButton.disabled = taskDisabled; + } + + contextMenuEvent.disabled = contextEventDisabled; + contextMenuTask.disabled = contextTaskDisabled; + + contextMenu.disabled = contextEventDisabled && contextTaskDisabled; + } +}; + +window.addEventListener("load", calendarExtract.addListeners.bind(calendarExtract), false); diff --git a/calendar/base/content/calendar-invitations-manager.js b/calendar/base/content/calendar-invitations-manager.js new file mode 100644 index 000000000..7c8a28ee8 --- /dev/null +++ b/calendar/base/content/calendar-invitations-manager.js @@ -0,0 +1,422 @@ +/* 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/. */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://calendar/modules/calItipUtils.jsm"); +Components.utils.import("resource://gre/modules/Preferences.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + +/* exported getInvitationsManager */ + +/** + * This object contains functions to take care of manipulating requests. + */ +var gInvitationsRequestManager = { + mRequestStatusList: {}, + + /** + * Add a request to the request manager. + * + * @param calendar The calendar to add for. + * @param op The operation to add + */ + addRequestStatus: function(calendar, operation) { + if (operation) { + this.mRequestStatusList[calendar.id] = operation; + } + }, + + /** + * Cancel all pending requests + */ + cancelPendingRequests: function() { + for (let id in this.mRequestStatusList) { + let request = this.mRequestStatusList[id]; + if (request && request.isPending) { + request.cancel(null); + } + } + this.mRequestStatusList = {}; + } +}; + +var gInvitationsManager = null; + +/** + * Return a cached instance of the invitations manager + * + * @return The invitations manager instance. + */ +function getInvitationsManager() { + if (!gInvitationsManager) { + gInvitationsManager = new InvitationsManager(); + } + return gInvitationsManager; +} + +/** + * The invitations manager class constructor + * + * XXX do we really need this to be an instance? + * + * @constructor + */ +function InvitationsManager() { + this.mItemList = []; + this.mStartDate = null; + this.mJobsPending = 0; + this.mTimer = null; + + window.addEventListener("unload", () => { + // Unload handlers get removed automatically + this.cancelInvitationsUpdate(); + }, false); +} + +InvitationsManager.prototype = { + mItemList: null, + mStartDate: null, + mJobsPending: 0, + mTimer: null, + + /** + * Schedule an update for the invitations manager asynchronously. + * + * @param firstDelay The timeout before the operation should start. + * @param operationListener The calIGenericOperationListener to notify. + */ + scheduleInvitationsUpdate: function(firstDelay, operationListener) { + this.cancelInvitationsUpdate(); + + this.mTimer = setTimeout(() => { + if (Preferences.get("calendar.invitations.autorefresh.enabled", true)) { + this.mTimer = setInterval(() => { + this.getInvitations(operationListener); + }, Preferences.get("calendar.invitations.autorefresh.timeout", 3) * 60000); + } + this.getInvitations(operationListener); + }, firstDelay); + }, + + /** + * Cancel pending any pending invitations update. + */ + cancelInvitationsUpdate: function() { + clearTimeout(this.mTimer); + }, + + /** + * Retrieve invitations from all calendars. Notify all passed + * operation listeners. + * + * @param operationListener1 The first operation listener to notify. + * @param operationListener2 (optinal) The second operation listener to + * notify. + */ + getInvitations: function(operationListener1, operationListener2) { + let listeners = []; + if (operationListener1) { + listeners.push(operationListener1); + } + if (operationListener2) { + listeners.push(operationListener2); + } + + gInvitationsRequestManager.cancelPendingRequests(); + this.updateStartDate(); + this.deleteAllItems(); + + let cals = getCalendarManager().getCalendars({}); + + let opListener = { + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calIOperationListener]), + mCount: cals.length, + mRequestManager: gInvitationsRequestManager, + mInvitationsManager: this, + mHandledItems: {}, + + // calIOperationListener + onOperationComplete: function(aCalendar, + aStatus, + aOperationType, + aId, + aDetail) { + if (--this.mCount == 0) { + this.mInvitationsManager.mItemList.sort((a, b) => { + return a.startDate.compare(b.startDate); + }); + for (let listener of listeners) { + try { + if (this.mInvitationsManager.mItemList.length) { + // Only call if there are actually items + listener.onGetResult(null, + Components.results.NS_OK, + Components.interfaces.calIItemBase, + null, + this.mInvitationsManager.mItemList.length, + this.mInvitationsManager.mItemList); + } + listener.onOperationComplete(null, + Components.results.NS_OK, + Components.interfaces.calIOperationListener.GET, + null, + null); + } catch (exc) { + ERROR(exc); + } + } + } + }, + + onGetResult: function(aCalendar, + aStatus, + aItemType, + aDetail, + aCount, + aItems) { + if (Components.isSuccessCode(aStatus)) { + for (let item of aItems) { + // we need to retrieve by occurrence to properly filter exceptions, + // should be fixed with bug 416975 + item = item.parentItem; + let hid = item.hashId; + if (!this.mHandledItems[hid]) { + this.mHandledItems[hid] = true; + this.mInvitationsManager.addItem(item); + } + } + } + } + }; + + for (let calendar of cals) { + if (!isCalendarWritable(calendar) || calendar.getProperty("disabled")) { + opListener.onOperationComplete(); + continue; + } + + // temporary hack unless calCachedCalendar supports REQUEST_NEEDS_ACTION filter: + calendar = calendar.getProperty("cache.uncachedCalendar"); + if (!calendar) { + opListener.onOperationComplete(); + continue; + } + + try { + calendar = calendar.QueryInterface(Components.interfaces.calICalendar); + let endDate = this.mStartDate.clone(); + endDate.year += 1; + let operation = calendar.getItems(Components.interfaces.calICalendar.ITEM_FILTER_REQUEST_NEEDS_ACTION | + Components.interfaces.calICalendar.ITEM_FILTER_TYPE_ALL | + // we need to retrieve by occurrence to properly filter exceptions, + // should be fixed with bug 416975 + Components.interfaces.calICalendar.ITEM_FILTER_CLASS_OCCURRENCES, + 0, this.mStartDate, + endDate /* we currently cannot pass null here, because of bug 416975 */, + opListener); + gInvitationsRequestManager.addRequestStatus(calendar, operation); + } catch (exc) { + opListener.onOperationComplete(); + ERROR(exc); + } + } + }, + + /** + * Open the invitations dialog, non-modal. + * + * XXX Passing these listeners in instead of keeping them in the window + * sounds fishy to me. Maybe there is a more encapsulated solution. + * + * @param onLoadOpListener The operation listener to notify when + * getting invitations. Should be passed + * to this.getInvitations(). + * @param finishedCallBack A callback function to call when the + * dialog has completed. + */ + openInvitationsDialog: function(onLoadOpListener, finishedCallBack) { + let args = {}; + args.onLoadOperationListener = onLoadOpListener; + args.queue = []; + args.finishedCallBack = finishedCallBack; + args.requestManager = gInvitationsRequestManager; + args.invitationsManager = this; + // the dialog will reset this to auto when it is done loading + window.setCursor("wait"); + // open the dialog + window.openDialog( + "chrome://calendar/content/calendar-invitations-dialog.xul", + "_blank", + "chrome,titlebar,resizable", + args); + }, + + /** + * Process the passed job queue. A job is an object that consists of an + * action, a newItem and and oldItem. This processor only takes "modify" + * operations into account. + * + * @param queue The array of objects to process. + * @param jobQueueFinishedCallBack A callback function called when + * job has finished. + */ + processJobQueue: function(queue, jobQueueFinishedCallBack) { + // TODO: undo/redo + function operationListener(mgr, queueCallback, oldItem_) { + this.mInvitationsManager = mgr; + this.mJobQueueFinishedCallBack = queueCallback; + this.mOldItem = oldItem_; + } + operationListener.prototype = { + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calIOperationListener]), + onOperationComplete: function(aCalendar, + aStatus, + aOperationType, + aId, + aDetail) { + if (Components.isSuccessCode(aStatus) && + aOperationType == Components.interfaces.calIOperationListener.MODIFY) { + cal.itip.checkAndSend(aOperationType, aDetail, this.mOldItem); + this.mInvitationsManager.deleteItem(aDetail); + this.mInvitationsManager.addItem(aDetail); + } + this.mInvitationsManager.mJobsPending--; + if (this.mInvitationsManager.mJobsPending == 0 && + this.mJobQueueFinishedCallBack) { + this.mJobQueueFinishedCallBack(); + } + }, + + onGetResult: function(aCalendar, + aStatus, + aItemType, + aDetail, + aCount, + aItems) { + + } + }; + + this.mJobsPending = 0; + for (let i = 0; i < queue.length; i++) { + let job = queue[i]; + let oldItem = job.oldItem; + let newItem = job.newItem; + switch (job.action) { + case "modify": + this.mJobsPending++; + newItem.calendar.modifyItem(newItem, + oldItem, + new operationListener(this, jobQueueFinishedCallBack, oldItem)); + break; + default: + break; + } + } + if (this.mJobsPending == 0 && jobQueueFinishedCallBack) { + jobQueueFinishedCallBack(); + } + }, + + /** + * Checks if the internal item list contains the given item + * XXXdbo Please document these correctly. + * + * @param item The item to look for. + * @return A boolean value indicating if the item was found. + */ + hasItem: function(item) { + let hid = item.hashId; + return this.mItemList.some(item_ => hid == item_.hashId); + }, + + /** + * Adds an item to the internal item list. + * XXXdbo Please document these correctly. + * + * @param item The item to add. + */ + addItem: function(item) { + let recInfo = item.recurrenceInfo; + if (recInfo && !cal.isOpenInvitation(item)) { + // scan exceptions: + let ids = recInfo.getExceptionIds({}); + for (let id of ids) { + let ex = recInfo.getExceptionFor(id); + if (ex && this.validateItem(ex) && !this.hasItem(ex)) { + this.mItemList.push(ex); + } + } + } else if (this.validateItem(item) && !this.hasItem(item)) { + this.mItemList.push(item); + } + }, + + /** + * Removes an item from the internal item list + * XXXdbo Please document these correctly. + * + * @param item The item to remove. + */ + deleteItem: function(item) { + let id = item.id; + this.mItemList.filter(item_ => id != item_.id); + }, + + /** + * Remove all items from the internal item list + * XXXdbo Please document these correctly. + */ + deleteAllItems: function() { + this.mItemList = []; + }, + + /** + * Helper function to create a start date to search from. This date is the + * current time with hour/minute/second set to zero. + * + * @return Potential start date. + */ + getStartDate: function() { + let date = now(); + date.second = 0; + date.minute = 0; + date.hour = 0; + return date; + }, + + /** + * Updates the start date for the invitations manager to the date returned + * from this.getStartDate(), unless the previously existing start date is + * the same or after what getStartDate() returned. + */ + updateStartDate: function() { + if (this.mStartDate) { + let startDate = this.getStartDate(); + if (startDate.compare(this.mStartDate) > 0) { + this.mStartDate = startDate; + } + } else { + this.mStartDate = this.getStartDate(); + } + }, + + /** + * Checks if the item is valid for the invitation manager. Checks if the + * item is in the range of the invitation manager and if the item is a valid + * invitation. + * + * @param item The item to check + * @return A boolean indicating if the item is a valid invitation. + */ + validateItem: function(item) { + if (item.calendar instanceof Components.interfaces.calISchedulingSupport && + !item.calendar.isInvitation(item)) { + return false; // exclude if organizer has invited himself + } + let start = item[calGetStartDateProp(item)] || item[calGetEndDateProp(item)]; + return (cal.isOpenInvitation(item) && + start.compare(this.mStartDate) >= 0); + } +}; diff --git a/calendar/base/content/calendar-item-bindings.xml b/calendar/base/content/calendar-item-bindings.xml new file mode 100644 index 000000000..58b4a1e40 --- /dev/null +++ b/calendar/base/content/calendar-item-bindings.xml @@ -0,0 +1,97 @@ +<?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/. --> + +<!DOCTYPE bindings SYSTEM "chrome://calendar/locale/calendar.dtd"> + +<bindings id="calendar-item-bindings" + xmlns="http://www.mozilla.org/xbl" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:xbl="http://www.mozilla.org/xbl"> + + <!-- Header with a line beside it, used i.e in the event dialog --> + <binding id="calendar-caption" extends="xul:hbox"> + <content align="center"> + <xul:label xbl:inherits="value=label,control" class="header"/> + <xul:separator class="groove" flex="1"/> + </content> + </binding> + + <binding id="item-date-row" extends="xul:row"> + <resources> + <stylesheet src="chrome://calendar/skin/calendar-event-dialog.css"/> + </resources> + <content xbl:inherits="mode"> + <xul:label anonid="item-datetime-label" + class="headline" + xbl:inherits="align"/> + <xul:label anonid="item-datetime-value"/> + </content> + <implementation> + <field name="mItem">null</field> + <property name="mode" + readonly="true"> + <getter><![CDATA[ + if (this.hasAttribute("mode")) { + return this.getAttribute("mode"); + } else { + return "start"; + } + ]]></getter> + </property> + <property name="Item"> + <getter><![CDATA[ + return mItem; + ]]></getter> + <setter><![CDATA[ + this.mItem = val; + let headerLabel = document.getAnonymousElementByAttribute(this, "anonid", "item-datetime-label"); + let itemDateTimeLabel = document.getAnonymousElementByAttribute(this, "anonid", "item-datetime-value"); + let date; + if (this.mode == "start") { + date = this.mItem[calGetStartDateProp(this.mItem)]; + if (date) { + let label; + if (isToDo(this.mItem)) { + label = this.getAttribute("taskStartLabel"); + } else { + label = this.getAttribute("eventStartLabel"); + } + headerLabel.value = label; + } + } else { + date = this.mItem[calGetEndDateProp(this.mItem)]; + if (date) { + let label; + if (isToDo(this.mItem)) { + label = this.getAttribute("taskDueLabel"); + } else { + label = this.getAttribute("eventEndLabel"); + } + headerLabel.value = label; + } + } + let hideLabels = (date == null); + if (hideLabels) { + this.setAttribute("hidden", "true"); + } else { + const kDefaultTimezone = cal.calendarDefaultTimezone(); + let localTime = date.getInTimezone(kDefaultTimezone); + let formatter = getDateFormatter(); + itemDateTimeLabel.value = formatter.formatDateTime(localTime); + if (!date.timezone.isFloating && date.timezone.tzid != kDefaultTimezone.tzid) { + // we additionally display the original datetime with timezone + let orgTime = cal.calGetString("calendar", + "datetimeWithTimezone", + [formatter.formatDateTime(date), + date.timezone.tzid]); + itemDateTimeLabel.value += " (" + orgTime + ")"; + } + this.removeAttribute("hidden"); + } + ]]></setter> + </property> + </implementation> + </binding> +</bindings> diff --git a/calendar/base/content/calendar-item-editing.js b/calendar/base/content/calendar-item-editing.js new file mode 100644 index 000000000..c505e8552 --- /dev/null +++ b/calendar/base/content/calendar-item-editing.js @@ -0,0 +1,742 @@ +/* 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/. */ + +Components.utils.import("resource://calendar/modules/calAlarmUtils.jsm"); +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); +Components.utils.import("resource://gre/modules/Preferences.jsm"); + +/* exported modifyEventWithDialog, undo, redo, setContextPartstat */ + +/** + * Takes a job and makes sure the dispose function on it is called. If there is + * no dispose function or the job is null, ignore it. + * + * @param job The job to dispose. + */ +function disposeJob(job) { + if (job && job.dispose) { + job.dispose(); + } +} + +/** + * Sets the default values for new items, taking values from either the passed + * parameters or the preferences + * + * @param aItem The item to set up + * @param aCalendar (optional) The calendar to apply. + * @param aStartDate (optional) The start date to set. + * @param aEndDate (optional) The end date/due date to set. + * @param aInitialDate (optional) The reference date for the date pickers + * @param aForceAllday (optional) Force the event/task to be an allday item. + */ +function setDefaultItemValues(aItem, aCalendar=null, aStartDate=null, aEndDate=null, aInitialDate=null, aForceAllday=false) { + function endOfDay(aDate) { + let eod = aDate ? aDate.clone() : cal.now(); + eod.hour = Preferences.get("calendar.view.dayendhour", 19); + eod.minute = 0; + eod.second = 0; + return eod; + } + function startOfDay(aDate) { + let sod = aDate ? aDate.clone() : cal.now(); + sod.hour = Preferences.get("calendar.view.daystarthour", 8); + sod.minute = 0; + sod.second = 0; + return sod; + } + + let initialDate = aInitialDate ? aInitialDate.clone() : cal.now(); + initialDate.isDate = true; + + if (cal.isEvent(aItem)) { + 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.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.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.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.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 += Preferences.get("calendar.event.defaultlength", 60); + } + } + + // Free/busy status is only valid for events, must not be set for tasks. + aItem.setProperty("TRANSP", cal.getEventDefaultTransparency(aForceAllday)); + } else if (cal.isToDo(aItem)) { + let now = cal.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 = Preferences.get("calendar.task.defaultstart", "none"); + if (Preferences.get("calendar.alarms.onfortodos", 0) == 1 && defaultStart == "none") { + // start date is required if we want to set an alarm + defaultStart = "offsetcurrent"; + } + + let units = Preferences.get("calendar.task.defaultstartoffsetunits", "minutes"); + if (!["days", "hours", "minutes"].includes(units)) { + units = "minutes"; + } + let startOffset = cal.createDuration(); + startOffset[units] = Preferences.get("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 = Preferences.get("calendar.task.defaultdue", "none"); + + let units = Preferences.get("calendar.task.defaultdueoffsetunits", "minutes"); + if (!["days", "hours", "minutes"].includes(units)) { + units = "minutes"; + } + let dueOffset = cal.createDuration(); + dueOffset[units] = Preferences.get("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 calendar (optional) The calendar to create the event in + * @param startDate (optional) The event's start date. + * @param endDate (optional) The event's end date. + * @param summary (optional) The event's title. + * @param event (optional) A template event to show in the dialog + * @param aForceAllDay (optional) Make sure the event shown in the dialog is an + * allday event. + */ +function createEventWithDialog(calendar, startDate, endDate, summary, event, aForceAllday) { + let onNewEvent = function(item, opcalendar, originalItem, listener) { + 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); + } else { + // Otherwise, this is an addition + doTransaction("add", item, opcalendar, null, listener); + } + }; + + 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 (aForceAllday) { + 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 = cal.createEvent(); + + let refDate = currentView().initialized && currentView().selectedDay.clone(); + setDefaultItemValues(event, calendar, startDate, endDate, refDate, aForceAllday); + 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) { + 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); + } else { + // Otherwise, this is an addition + doTransaction("add", item, opcalendar, null, listener); + } + }; + + 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 = cal.createTodo(); + setDefaultItemValues(todo, calendar, null, dueDate, initialDate); + + if (summary) { + todo.title = summary; + } + } + + openEventDialog(todo, calendar, "new", onNewItem, null, initialDate); +} + +/** + * Modifies the passed event in the event dialog. + * + * @param aItem The item to modify. + * @param job (optional) The job object that controls this + * modification. + * @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, job=null, aPromptOccurrence, initialDate=null, aCounterProposal) { + let dlg = cal.findItemWindow(aItem); + if (dlg) { + dlg.focus(); + disposeJob(job); + return; + } + + let onModifyItem = function(item, calendar, originalItem, listener) { + doTransaction("modify", item, calendar, originalItem, listener); + }; + + 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, job, initialDate, + aCounterProposal); + } else { + disposeJob(job); + } +} + +/** + * Opens the event dialog with the given item (task OR event) + * + * @param calendarItem The item to open the dialog with + * @param calendar The calendar to open the dialog with. + * @param mode The operation the dialog should do ("new", "modify") + * @param callback The callback to call when the dialog has completed. + * @param job (optional) The job object for the modification. + * @param initialDate (optional) The initial date for new task datepickers + * @param counterProposal (optional) An object representing the counterproposal - see + * description for modifyEventWithDialog() + */ +function openEventDialog(calendarItem, calendar, mode, callback, job=null, initialDate=null, counterProposal) { + let dlg = cal.findItemWindow(calendarItem); + if (dlg) { + dlg.focus(); + disposeJob(job); + return; + } + + // Set up some defaults + mode = mode || "new"; + calendar = calendar || getSelectedCalendar(); + let calendars = getCalendarManager().getCalendars({}); + calendars = calendars.filter(isCalendarWritable); + + let isItemSupported; + if (isToDo(calendarItem)) { + isItemSupported = function(aCalendar) { + return (aCalendar.getProperty("capabilities.tasks.supported") !== false); + }; + } else if (isEvent(calendarItem)) { + 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(userCanAddItemsToCalendar); + } else { /* 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 = userCanModifyItem(calendarItem); + let canMoveItems = userCanDeleteItemsFromCalendar(calendarItem.calendar) && + userCanAddItemsToCalendar(aCalendar); + + return isSameCalendar ? canModify : canMoveItems; + }); + } + + if (mode == "new" && + (!isCalendarWritable(calendar) || + !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. + disposeJob(job); + return; + } else { + // 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.job = job; + args.initialStartDateValue = initialDate || getDefaultStartDate(); + args.counterProposal = counterProposal; + args.inTab = Preferences.get("calendar.item.editInTab", false); + args.useNewItemUI = Preferences.get("calendar.item.useNewItemUI", 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 provide 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 wrappedCalendar = cal.wrapInstance(calendar, Components.interfaces.calISchedulingSupport); + let isInvitation = wrappedCalendar && wrappedCalendar.isInvitation(calendarItem); + + // open the dialog modeless + let url; + let isEditable = mode == "modify" && !isInvitation && userCanModifyItem(calendarItem); + if (isCalendarWritable(calendar) && (mode == "new" || isEditable)) { + if (args.inTab) { + url = args.useNewItemUI ? "chrome://lightning/content/html-item-editing/lightning-item-iframe.html" + : "chrome://lightning/content/lightning-item-iframe.xul"; + } else { + url = "chrome://calendar/content/calendar-event-dialog.xul"; + } + } else { + url = "chrome://calendar/content/calendar-summary-dialog.xul"; + args.inTab = false; + } + + if (args.inTab) { + // open in a tab, currently the read-only summary dialog is + // never opened in a tab + args.url = url; + let tabmail = document.getElementById("tabmail"); + let tabtype = cal.isEvent(args.calendarEvent) ? "calendarEvent" : "calendarTask"; + tabmail.openTab(tabtype, args); + } else { + // open in a window + + // reminder: event dialog should not be modal (cf bug 122671) + let features; + // keyword "dependent" should not be used (cf bug 752206) + if (Services.appinfo.OS == "WINNT") { + features = "chrome,titlebar,resizable"; + } else { + // All other targets, mostly Linux flavors using gnome. + features = "chrome,titlebar,resizable,minimizable=no,dialog=no"; + } + openDialog(url, "_blank", features, 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 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 + * @return [modifiedItem, futureItem, promptResponse] + * 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 futureItem = false; + let pastItem; + let type = CANCEL; + + // Check if this actually is an instance of a recurring event + if (aItem == aItem.parentItem) { + type = MODIFY_PARENT; + } else if (aItem.parentItem.recurrenceInfo.getExceptionFor(aItem.recurrenceId)) { + // If the user wants to edit an occurrence which is already an exception + // always edit this single item. + // XXX Why? I think its ok to ask also for exceptions. + type = MODIFY_OCCURRENCE; + } else { + // Prompt the user. Setting modal blocks the dialog until it is closed. We + // use rv to pass our return value. + let rv = { value: CANCEL, item: aItem, action: aAction }; + window.openDialog("chrome://calendar/content/calendar-occurrence-prompt.xul", + "PromptOccurrenceModification", + "centerscreen,chrome,modal,titlebar", + rv); + type = rv.value; + } + + switch (type) { + case MODIFY_PARENT: + pastItem = aItem.parentItem; + break; + case MODIFY_FOLLOWING: + // TODO tbd in a different bug + throw Components.results.NS_ERROR_NOT_IMPLEMENTED; + case MODIFY_OCCURRENCE: + pastItem = aItem; + break; + case CANCEL: + // Since we have not set past or futureItem, the return below will + // take care. + break; + } + + return [pastItem, futureItem, type]; +} + +// Undo/Redo code + +/** + * Helper to return the transaction manager service. + * + * @return The calITransactionManager service. + */ +function getTransactionMgr() { + return Components.classes["@mozilla.org/calendar/transactionmanager;1"] + .getService(Components.interfaces.calITransactionManager); +} + + +/** + * Create and commit a transaction with the given arguments to the transaction + * manager. Also updates the undo/redo menu. + * + * @see calITransactionManager + * @param aAction The action to do. + * @param aItem The new item to add/modify/delete + * @param aCalendar The calendar to do the transaction on + * @param aOldItem (optional) some actions require an old item + * @param aListener (optional) the listener to call when complete. + */ +function doTransaction(aAction, aItem, aCalendar, aOldItem, aListener) { + // This is usually a user-initiated transaction, so make sure the calendar + // this transaction is happening on is visible. + ensureCalendarVisible(aCalendar); + + // Now use the transaction manager to execute the action + getTransactionMgr().createAndCommitTxn(aAction, + aItem, + aCalendar, + aOldItem, + aListener ? aListener : null); + updateUndoRedoMenu(); +} + +/** + * Undo the last operation done through the transaction manager. + */ +function undo() { + if (canUndo()) { + getTransactionMgr().undo(); + updateUndoRedoMenu(); + } +} + +/** + * Redo the last undone operation in the transaction manager. + */ +function redo() { + if (canRedo()) { + getTransactionMgr().redo(); + updateUndoRedoMenu(); + } +} + +/** + * Start a batch transaction on the transaction manager. Can be called multiple + * times, which nests transactions. + */ +function startBatchTransaction() { + getTransactionMgr().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() { + getTransactionMgr().endBatch(); + updateUndoRedoMenu(); +} + +/** + * Checks if the last operation can be undone (or if there is a last operation + * at all). + */ +function canUndo() { + return getTransactionMgr().canUndo(); +} + +/** + * Checks if the last undone operation can be redone. + */ +function canRedo() { + return getTransactionMgr().canRedo(); +} + +/** + * Update the undo and redo commands. + */ +function updateUndoRedoMenu() { + goUpdateCommand("cmd_undo"); + goUpdateCommand("cmd_redo"); +} + +function setContextPartstat(value, scope, items) { + startBatchTransaction(); + try { + for (let oldItem of items) { + // Skip this item if its calendar is read only. + if (oldItem.calendar.readOnly) { + continue; + } + if (scope == "all-occurrences") { + oldItem = oldItem.parentItem; + } + let attendee = null; + if (cal.isInvitation(oldItem)) { + // Check for the invited attendee first, this is more important + attendee = cal.getInvitedAttendee(oldItem); + } else if (oldItem.organizer && oldItem.getAttendees({}).length) { + // Now check the organizer. This should be done last. + let calOrgId = oldItem.calendar.getProperty("organizerId"); + if (calOrgId == oldItem.organizer.id) { + attendee = oldItem.organizer; + } + } + + if (attendee) { + let newItem = oldItem.clone(); + let newAttendee = attendee.clone(); + + newAttendee.participationStatus = value; + if (newAttendee.isOrganizer) { + newItem.organizer = newAttendee; + } else { + newItem.removeAttendee(attendee); + newItem.addAttendee(newAttendee); + } + + doTransaction("modify", newItem, newItem.calendar, oldItem, null); + } + } + } catch (e) { + cal.ERROR("Error setting partstat: " + e); + } finally { + endBatchTransaction(); + } +} diff --git a/calendar/base/content/calendar-management.js b/calendar/base/content/calendar-management.js new file mode 100644 index 000000000..873fc29ab --- /dev/null +++ b/calendar/base/content/calendar-management.js @@ -0,0 +1,444 @@ +/* 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 promptDeleteCalendar, loadCalendarManager, unloadCalendarManager, + * updateSortOrderPref, calendarListTooltipShowing, + * calendarListSetupContextMenu, ensureCalendarVisible, toggleCalendarVisible, + * showAllCalendars, showOnlyCalendar, openCalendarSubscriptionsDialog, + * calendarOfflineManager + */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); +Components.utils.import("resource://gre/modules/Preferences.jsm"); + +/** + * Get this window's currently selected calendar. + * + * @return The currently selected calendar. + */ +function getSelectedCalendar() { + return getCompositeCalendar().defaultCalendar; +} + +/** + * Deletes the passed calendar, prompting the user if he really wants to do + * this. If there is only one calendar left, no calendar is removed and the user + * is not prompted. + * + * @param aCalendar The calendar to delete. + */ +function promptDeleteCalendar(aCalendar) { + const nIPS = Components.interfaces.nsIPromptService; + const cICM = Components.interfaces.calICalendarManager; + + let calMgr = cal.getCalendarManager(); + let calendars = calMgr.getCalendars({}); + if (calendars.length <= 1) { + // If this is the last calendar, don't delete it. + return; + } + + let modes = new Set(aCalendar.getProperty("capabilities.removeModes") || ["unsubscribe"]); + let title = cal.calGetString("calendar", "removeCalendarTitle"); + + let textKey, b0text, b2text; + let removeFlags = 0; + let promptFlags = (nIPS.BUTTON_POS_0 * nIPS.BUTTON_TITLE_IS_STRING) + + (nIPS.BUTTON_POS_1 * nIPS.BUTTON_TITLE_CANCEL); + + if (modes.has("delete") && !modes.has("unsubscribe")) { + textKey = "removeCalendarMessageDelete"; + promptFlags += nIPS.BUTTON_DELAY_ENABLE; + b0text = cal.calGetString("calendar", "removeCalendarButtonDelete"); + } else if (modes.has("delete")) { + textKey = "removeCalendarMessageDeleteOrUnsubscribe"; + promptFlags += (nIPS.BUTTON_POS_2 * nIPS.BUTTON_TITLE_IS_STRING); + b0text = cal.calGetString("calendar", "removeCalendarButtonUnsubscribe"); + b2text = cal.calGetString("calendar", "removeCalendarButtonDelete"); + } else if (modes.has("unsubscribe")) { + textKey = "removeCalendarMessageUnsubscribe"; + removeFlags |= cICM.REMOVE_NO_DELETE; + b0text = cal.calGetString("calendar", "removeCalendarButtonUnsubscribe"); + } else { + return; + } + + let text = cal.calGetString("calendar", textKey, [aCalendar.name]); + let res = Services.prompt.confirmEx(window, title, text, promptFlags, + b0text, null, b2text, null, {}); + + if (res != 1) { // Not canceled + if (textKey == "removeCalendarMessageDeleteOrUnsubscribe" && res == 0) { + // Both unsubscribing and deleting is possible, but unsubscribing was + // requested. Make sure no delete is executed. + removeFlags |= cICM.REMOVE_NO_DELETE; + } + + calMgr.removeCalendar(aCalendar, removeFlags); + } +} + +/** + * Called to initialize the calendar manager for a window. + */ +function loadCalendarManager() { + // Set up the composite calendar in the calendar list widget. + let tree = document.getElementById("calendar-list-tree-widget"); + let compositeCalendar = getCompositeCalendar(); + tree.compositeCalendar = compositeCalendar; + + // Initialize our composite observer + compositeCalendar.addObserver(compositeObserver); + + // Create the home calendar if no calendar exists. + let calendars = cal.getCalendarManager().getCalendars({}); + if (calendars.length) { + // migration code to make sure calendars, which do not support caching have cache enabled + // required to further clean up on top of bug 1182264 + for (let calendar of calendars) { + if (calendar.getProperty("cache.supported") === false && + calendar.getProperty("cache.enabled") === true) { + calendar.deleteProperty("cache.enabled"); + } + } + } else { + initHomeCalendar(); + } +} + +/** + * Creates the initial "Home" calendar if no calendar exists. + */ +function initHomeCalendar() { + let calMgr = cal.getCalendarManager(); + let composite = getCompositeCalendar(); + let url = cal.makeURL("moz-storage-calendar://"); + let homeCalendar = calMgr.createCalendar("storage", url); + homeCalendar.name = calGetString("calendar", "homeCalendarName"); + calMgr.registerCalendar(homeCalendar); + Preferences.set("calendar.list.sortOrder", homeCalendar.id); + composite.addCalendar(homeCalendar); + + // Wrapping this in a try/catch block, as if any of the migration code + // fails, the app may not load. + if (Preferences.get("calendar.migrator.enabled", true)) { + try { + gDataMigrator.checkAndMigrate(); + } catch (e) { + Components.utils.reportError("Migrator error: " + e); + } + } + + return homeCalendar; +} + +/** + * Called to clean up the calendar manager for a window. + */ +function unloadCalendarManager() { + let compositeCalendar = getCompositeCalendar(); + compositeCalendar.setStatusObserver(null, null); + compositeCalendar.removeObserver(compositeObserver); +} + +/** + * Updates the sort order preference based on the given event. The event is a + * "SortOrderChanged" event, emitted from the calendar-list-tree binding. You + * can also pass in an object like { sortOrder: "Space separated calendar ids" } + * + * @param event The SortOrderChanged event described above. + */ +function updateSortOrderPref(event) { + let sortOrderString = event.sortOrder.join(" "); + Preferences.set("calendar.list.sortOrder", sortOrderString); + try { + Services.prefs.savePrefFile(null); + } catch (e) { + cal.ERROR(e); + } +} + +/** + * Handler function to call when the tooltip is showing on the calendar list. + * + * @param event The DOM event provoked by the tooltip showing. + */ +function calendarListTooltipShowing(event) { + let tree = document.getElementById("calendar-list-tree-widget"); + let calendar = tree.getCalendarFromEvent(event); + let tooltipText = false; + if (calendar) { + let currentStatus = calendar.getProperty("currentStatus"); + if (!Components.isSuccessCode(currentStatus)) { + tooltipText = calGetString("calendar", "tooltipCalendarDisabled", [calendar.name]); + } else if (calendar.readOnly) { + tooltipText = calGetString("calendar", "tooltipCalendarReadOnly", [calendar.name]); + } + } + setElementValue("calendar-list-tooltip", tooltipText, "label"); + return (tooltipText != false); +} + +/** + * A handler called to set up the context menu on the calendar list. + * + * @param event The DOM event that caused the context menu to open. + * @return Returns true if the context menu should be shown. + */ +function calendarListSetupContextMenu(event) { + let col = {}; + let row = {}; + let calendar; + let calendars = getCalendarManager().getCalendars({}); + let treeNode = document.getElementById("calendar-list-tree-widget"); + let composite = getCompositeCalendar(); + + if (document.popupNode.localName == "tree") { + // Using VK_APPS to open the context menu will target the tree + // itself. In that case we won't have a client point even for + // opening the context menu. The "target" element should then be the + // selected calendar. + row.value = treeNode.tree.currentIndex; + col.value = treeNode.getColumn("calendarname-treecol"); + calendar = treeNode.getCalendar(row.value); + } else { + // Using the mouse, the context menu will open on the treechildren + // element. Here we can use client points. + calendar = treeNode.getCalendarFromEvent(event, col, row); + } + + if (col.value && + col.value.element.getAttribute("anonid") == "checkbox-treecol") { + // Don't show the context menu if the checkbox was clicked. + return false; + } + + document.getElementById("list-calendars-context-menu").contextCalendar = calendar; + + // Only enable calendar search if there's actually the chance of finding something: + let hasProviders = getCalendarSearchService().getProviders({}).length < 1 && "true"; + setElementValue("list-calendars-context-find", hasProviders, "collapsed"); + + if (calendar) { + enableElement("list-calendars-context-edit"); + enableElement("list-calendars-context-publish"); + + enableElement("list-calendars-context-togglevisible"); + setElementValue("list-calendars-context-togglevisible", false, "collapsed"); + let stringName = composite.getCalendarById(calendar.id) ? "hideCalendar" : "showCalendar"; + setElementValue("list-calendars-context-togglevisible", + cal.calGetString("calendar", stringName, [calendar.name]), + "label"); + let accessKey = document.getElementById("list-calendars-context-togglevisible") + .getAttribute(composite.getCalendarById(calendar.id) ? + "accesskeyhide" : "accesskeyshow"); + setElementValue("list-calendars-context-togglevisible", accessKey, "accesskey"); + + enableElement("list-calendars-context-showonly"); + setElementValue("list-calendars-context-showonly", false, "collapsed"); + setElementValue("list-calendars-context-showonly", + cal.calGetString("calendar", "showOnlyCalendar", [calendar.name]), + "label"); + + setupDeleteMenuitem("list-calendars-context-delete", calendar); + // Only enable the delete calendars item if there is more than one + // calendar. We don't want to have the last calendar deleted. + setElementValue("list-calendars-context-delete", calendars.length < 2 && "true", "disabled"); + } else { + disableElement("list-calendars-context-edit"); + disableElement("list-calendars-context-publish"); + disableElement("list-calendars-context-delete"); + disableElement("list-calendars-context-togglevisible"); + setElementValue("list-calendars-context-togglevisible", true, "collapsed"); + disableElement("list-calendars-context-showonly"); + setElementValue("list-calendars-context-showonly", true, "collapsed"); + setupDeleteMenuitem("list-calendars-context-delete", null); + } + return true; +} + +/** + * Changes the "delete calendar" menuitem to have the right label based on the + * removeModes. The menuitem must have the attributes "labelremove", + * "labeldelete" and "labelunsubscribe". + * + * @param aDeleteId The id of the menuitem to delete the calendar + */ +function setupDeleteMenuitem(aDeleteId, aCalendar) { + let calendar = (aCalendar === undefined ? getSelectedCalendar() : aCalendar); + let modes = new Set(calendar ? calendar.getProperty("capabilities.removeModes") || ["unsubscribe"] : []); + + let type = "remove"; + if (modes.has("delete") && !modes.has("unsubscribe")) { + type = "delete"; + } else if (modes.has("unsubscribe") && !modes.has("delete")) { + type = "unsubscribe"; + } + + let deleteItem = document.getElementById(aDeleteId); + setElementValue(deleteItem, deleteItem.getAttribute("label" + type), "label"); + setElementValue(deleteItem, deleteItem.getAttribute("accesskey" + type), "accesskey"); + setElementValue(deleteItem, modes.size == 0 && "true", "disabled"); +} + +/** + * Makes sure the passed calendar is visible to the user + * + * @param aCalendar The calendar to make visible. + */ +function ensureCalendarVisible(aCalendar) { + // We use the main window's calendar list to ensure that the calendar is visible. + // If the main window has been closed this function may still be called, + // like when an event/task window is still open and the user clicks 'save', + // thus we have the extra checks. + let list = document.getElementById("calendar-list-tree-widget"); + if (list && list.ensureCalendarVisible) { + list.ensureCalendarVisible(aCalendar); + } +} + +/** + * Hides the specified calendar if it is visible, or shows it if it is hidden. + * + * @param aCalendar The calendar to show or hide + */ +function toggleCalendarVisible(aCalendar) { + let composite = getCompositeCalendar(); + if (composite.getCalendarById(aCalendar.id)) { + composite.removeCalendar(aCalendar); + } else { + composite.addCalendar(aCalendar); + } +} + +/** + * Shows all hidden calendars. + */ +function showAllCalendars() { + let composite = getCompositeCalendar(); + let cals = cal.getCalendarManager().getCalendars({}); + + composite.startBatch(); + for (let calendar of cals) { + if (!composite.getCalendarById(calendar.id)) { + composite.addCalendar(calendar); + } + } + composite.endBatch(); +} + +/** + * Shows only the specified calendar, and hides all others. + * + * @param aCalendar The calendar to show as the only visible calendar + */ +function showOnlyCalendar(aCalendar) { + let composite = getCompositeCalendar(); + let cals = composite.getCalendars({}) || []; + + composite.startBatch(); + for (let calendar of cals) { + if (calendar.id != aCalendar.id) { + composite.removeCalendar(calendar); + } + } + composite.addCalendar(aCalendar); + composite.endBatch(); +} + +var compositeObserver = { + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calIObserver, + Components.interfaces.calICompositeObserver]), + + onStartBatch: function() {}, + onEndBatch: function() {}, + onAddItem: function() {}, + onModifyItem: function() {}, + onDeleteItem: function() {}, + onError: function() {}, + onPropertyChanged: function() {}, + onPropertyDeleting: function() {}, + + onLoad: function() { + calendarUpdateNewItemsCommand(); + document.commandDispatcher.updateCommands("calendar_commands"); + }, + + onCalendarAdded: function(aCalendar) { + // Update the calendar commands for number of remote calendars and for + // more than one calendar + document.commandDispatcher.updateCommands("calendar_commands"); + }, + + onCalendarRemoved: function(aCalendar) { + // Update commands to disallow deleting the last calendar and only + // allowing reload remote calendars when there are remote calendars. + document.commandDispatcher.updateCommands("calendar_commands"); + }, + + onDefaultCalendarChanged: function(aNewCalendar) { + // A new default calendar may mean that the new calendar has different + // ACLs. Make sure the commands are updated. + calendarUpdateNewItemsCommand(); + document.commandDispatcher.updateCommands("calendar_commands"); + } +}; + +/** + * Opens the subscriptions dialog modally. + */ +function openCalendarSubscriptionsDialog() { + // the dialog will reset this to auto when it is done loading + window.setCursor("wait"); + + // open the dialog modally + window.openDialog("chrome://calendar/content/calendar-subscriptions-dialog.xul", + "_blank", + "chrome,titlebar,modal,resizable"); +} + +/** + * Calendar Offline Manager + */ +var calendarOfflineManager = { + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.nsIObserver]), + + init: function() { + if (this.initialized) { + throw Components.results.NS_ERROR_ALREADY_INITIALIZED; + } + Services.obs.addObserver(this, "network:offline-status-changed", false); + + this.updateOfflineUI(!this.isOnline()); + this.initialized = true; + }, + + uninit: function() { + if (!this.initialized) { + throw Components.results.NS_ERROR_NOT_INITIALIZED; + } + Services.obs.removeObserver(this, "network:offline-status-changed", false); + this.initialized = false; + }, + + isOnline: function() { + return !Services.io.offline; + }, + + updateOfflineUI: function(aIsOffline) { + // Refresh the current view + currentView().goToDay(currentView().selectedDay); + + // Set up disabled locks for offline + document.commandDispatcher.updateCommands("calendar_commands"); + }, + + observe: function(aSubject, aTopic, aState) { + if (aTopic == "network:offline-status-changed") { + this.updateOfflineUI(aState == "offline"); + } + } +}; diff --git a/calendar/base/content/calendar-menus.xml b/calendar/base/content/calendar-menus.xml new file mode 100644 index 000000000..c210306fb --- /dev/null +++ b/calendar/base/content/calendar-menus.xml @@ -0,0 +1,149 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<!DOCTYPE bindings SYSTEM "chrome://calendar/locale/calendar.dtd"> + +<bindings id="calendar-menu-bindings" + xmlns="http://www.mozilla.org/xbl" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:xbl="http://www.mozilla.org/xbl"> + + <binding id="task-menupopup" extends="xul:menupopup"> + <implementation> + <field name="mType">null</field>; + <field name="mPopupHandler">null</field> + <field name="mParentMenuPopup">null</field> + + <constructor><![CDATA[ + this.mPopupHandler = () => { this.schangeMenuByPropertyName(); }; + this.mParentMenuPopup = getParentNodeOrThis(this, "menupopup"); + this.mParentMenuPopup.addEventListener("popupshowing", this.mPopupHandler, true); + ]]></constructor> + + <destructor><![CDATA[ + this.mParentMenuPopup.removeEventListener("popupshowing", this.mPopupHandler, true); + ]]></destructor> + + <!-- This method checks a command which naming follows + the notation 'calendar_' + mType + ' + '-' + propertyValue + 'command', + when its propertyValue part matches the propertyValue of the selected tasks + as long as the selected tasks share common propertyValues. --> + <method name="changeMenuByPropertyName"> + <body><![CDATA[ + let liveList = document.getAnonymousNodes(this); + for (let item of liveList) { + let commandName = item.getAttribute("command"); + let command = document.getElementById(commandName); + if (command) { + command.setAttribute("checked", "false"); + item.setAttribute("checked", "false"); + } + } + let propertyValue; + if (gTabmail && gTabmail.currentTabInfo.mode.type == "calendarTask") { + // We are in a task tab (editing a single task). + propertyValue = gConfig[this.mType]; + } else { + // We are in the Tasks tab. + let tasks = getSelectedTasks(); + let tasksSelected = (tasks != null) && (tasks.length > 0); + if (tasksSelected) { + let task = tasks[0]; + if (isPropertyValueSame(tasks, this.mType)) { + propertyValue = task[this.mType]; + } + } else { + applyAttributeToMenuChildren(this, "disabled", !tasksSelected); + } + } + if (propertyValue || propertyValue == 0) { + let command = document.getElementById("calendar_" + this.mType + "-" + propertyValue + "_command"); + if (command) { + command.setAttribute("checked", "true"); + } + } + ]]></body> + </method> + </implementation> + </binding> + + <binding id="task-progress-menupopup" extends="chrome://calendar/content/calendar-menus.xml#task-menupopup"> + <content> + <xul:menuitem anonid="percent-0-menuitem" + type="checkbox" + label="&progress.level.0;" + accesskey="&progress.level.0.accesskey;" + observes="calendar_percentComplete-0_command" + command="calendar_percentComplete-0_command"/> + <xul:menuitem anonid="percent-25-menuitem" + type="checkbox" + label="&progress.level.25;" + accesskey="&progress.level.25.accesskey;" + observes="calendar_percentComplete-25_command" + command="calendar_percentComplete-25_command"/> + <xul:menuitem anonid="percent-50-menuitem" + type="checkbox" + label="&progress.level.50;" + accesskey="&progress.level.50.accesskey;" + observes="calendar_percentComplete-50_command" + command="calendar_percentComplete-50_command"/> + <xul:menuitem anonid="percent-75-menuitem" + type="checkbox" + label="&progress.level.75;" + accesskey="&progress.level.75.accesskey;" + observes="calendar_percentComplete-75_command" + command="calendar_percentComplete-75_command"/> + <xul:menuitem anonid="percent-100-menuitem" + type="checkbox" + label="&progress.level.100;" + accesskey="&progress.level.100.accesskey;" + observes="calendar_percentComplete-100_command" + command="calendar_percentComplete-100_command"/> + <children/> + </content> + <implementation> + <constructor><![CDATA[ + this.mType = "percentComplete"; + this.changeMenuByPropertyName(); + ]]></constructor> + </implementation> + </binding> + + <binding id="task-priority-menupopup" extends="chrome://calendar/content/calendar-menus.xml#task-menupopup"> + <content> + <xul:menuitem id="priority-0-menuitem" + type="checkbox" + label="&priority.level.none;" + accesskey="&priority.level.none.accesskey;" + command="calendar_priority-0_command" + observes="calendar_priority-0_command"/> + <xul:menuitem id="priority-9-menuitem" + type="checkbox" + label="&priority.level.low;" + accesskey="&priority.level.low.accesskey;" + command="calendar_priority-9_command" + observes="calendar_priority-9_command"/> + <xul:menuitem id="priority-5-menuitem" + type="checkbox" + label="&priority.level.normal;" + accesskey="&priority.level.normal.accesskey;" + command="calendar_priority-5_command" + observes="calendar_priority-5_command"/> + <xul:menuitem id="priority-1-menuitem" + type="checkbox" + label="&priority.level.high;" + accesskey="&priority.level.high.accesskey;" + command="calendar_priority-1_command" + observes="calendar_priority-1_command"/> + <children/> + </content> + <implementation> + <constructor><![CDATA[ + this.mType = "priority"; + this.changeMenuByPropertyName(); + ]]></constructor> + </implementation> + </binding> +</bindings> diff --git a/calendar/base/content/calendar-month-view.xml b/calendar/base/content/calendar-month-view.xml new file mode 100644 index 000000000..eab5f8f91 --- /dev/null +++ b/calendar/base/content/calendar-month-view.xml @@ -0,0 +1,1137 @@ +<?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/. --> + +<!-- Note that this file depends on helper functions in calUtils.js--> + +<!DOCTYPE bindings SYSTEM "chrome://global/locale/global.dtd" > + +<bindings id="calendar-month-view-bindings" + xmlns="http://www.mozilla.org/xbl" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:xbl="http://www.mozilla.org/xbl"> + + <binding id="calendar-month-day-box-item" extends="chrome://calendar/content/calendar-view-core.xml#calendar-editable-item"> + <content mousethrough="never" tooltip="itemTooltip"> + <xul:vbox flex="1"> + <xul:hbox> + <xul:box anonid="event-container" + class="calendar-color-box" + xbl:inherits="calendar-uri,calendar-id" + flex="1"> + <xul:box class="calendar-event-selection" orient="horizontal" flex="1"> + <xul:stack anonid="eventbox" + class="calendar-event-box-container" + xbl:inherits="readonly,flashing,alarm,allday,priority,progress,status,calendar,categories" + flex="1"> + <xul:hbox anonid="event-detail-box" + class="calendar-event-details"> + <xul:vbox pack="center"> + <xul:image anonid="item-icon" + class="calendar-item-image" + xbl:inherits="progress,allday,itemType,todoType"/> + </xul:vbox> + <xul:label anonid="item-label" + class="calendar-month-day-box-item-label" + xbl:inherits="context"/> + <xul:vbox align="left" + flex="1" + xbl:inherits="context"> + <xul:label anonid="event-name" + crop="end" + flex="1" + style="margin: 0;"/> + <xul:textbox anonid="event-name-textbox" + class="plain calendar-event-name-textbox" + crop="end" + hidden="true" + wrap="true"/> + <xul:spacer flex="1"/> + </xul:vbox> + <xul:stack anonid="category-box-stack"> + <xul:calendar-category-box anonid="category-box" xbl:inherits="categories" pack="end"/> + <xul:hbox align="center"> + <xul:hbox anonid="alarm-icons-box" + class="alarm-icons-box" + pack="end" + align="top" + xbl:inherits="flashing"/> + <xul:image anonid="item-classification-box" + class="item-classification-box" + pack="end"/> + </xul:hbox> + </xul:stack> + </xul:hbox> + </xul:stack> + </xul:box> + </xul:box> + </xul:hbox> + </xul:vbox> + </content> + <implementation> + <property name="occurrence"> + <getter><![CDATA[ + return this.mOccurrence; + ]]></getter> + <setter><![CDATA[ + ASSERT(!this.mOccurrence, "Code changes needed to set the occurrence twice", true); + this.mOccurrence = val; + if (cal.isEvent(val)) { + if (!val.startDate.isDate) { + let label = document.getAnonymousElementByAttribute(this, "anonid", "item-label"); + let formatter = Components.classes["@mozilla.org/calendar/datetime-formatter;1"] + .getService(Components.interfaces.calIDateTimeFormatter); + let timezone = this.calendarView ? this.calendarView.mTimezone + : calendarDefaultTimezone(); + let parentDate = ensureDateTime(this.parentBox.date); + let startTime = val.startDate.getInTimezone(timezone); + let endTime = val.endDate.getInTimezone(timezone); + let nextDay = parentDate.clone(); + nextDay.day++; + let comp = endTime.compare(nextDay); + if (startTime.compare(parentDate) == -1) { + if (comp == 1) { + label.value = "↔"; + } else if (comp == 0) { + label.value = "↤"; + } else { + label.value = "⇥ " + formatter.formatTime(endTime); + } + } else if (comp == 1) { + label.value = "⇤ " + formatter.formatTime(startTime); + } else { + label.value = formatter.formatTime(startTime); + } + label.setAttribute("time", "true"); + } + } + + this.setEditableLabel(); + this.setCSSClasses(); + return val; + ]]></setter> + </property> + </implementation> + </binding> + + <binding id="calendar-month-day-box" extends="chrome://calendar/content/widgets/calendar-widgets.xml#dragndropContainer"> + <content orient="vertical"> + <xul:hbox anonid="monthday-labels" style="overflow: hidden;"> + <xul:label anonid="week-label" + flex="1" + crop="end" + hidden="true" + mousethrough="always" + class="calendar-month-day-box-week-label calendar-month-day-box-date-label" + xbl:inherits="relation,selected"/> + <xul:label anonid="day-label" + flex="1" + mousethrough="always" + class="calendar-month-day-box-date-label" + xbl:inherits="relation,selected,value"/> + </xul:hbox> + <xul:vbox anonid="day-items" class="calendar-month-day-box-items-box" flex="1"> + <children/> + </xul:vbox> + </content> + + <implementation> + <field name="mDate">null</field> + <field name="mItemHash">{}</field> + <field name="mShowMonthLabel">false</field> + + <property name="date" + onget="return this.mDate" + onset="this.setDate(val); return val;"/> + + <property name="selected"> + <getter><![CDATA[ + let sel = this.getAttribute("selected"); + if (sel && sel == "true") { + return true; + } + + return false; + ]]></getter> + <setter><![CDATA[ + if (val) { + this.setAttribute("selected", "true"); + } else { + this.removeAttribute("selected"); + } + return val; + ]]></setter> + </property> + + <property name="dayitems"> + <getter>return document.getAnonymousElementByAttribute(this, "anonid", "day-items");</getter> + </property> + + <property name="showMonthLabel"> + <getter><![CDATA[ + return this.mShowMonthLabel; + ]]></getter> + <setter><![CDATA[ + if (this.mShowMonthLabel == val) { + return val; + } + this.mShowMonthLabel = val; + + if (!this.mDate) { + return val; + } + if (val) { + this.setAttribute("value", getDateFormatter().formatDateWithoutYear(this.mDate)); + } else { + this.setAttribute("value", this.mDate.day); + } + return val; + ]]></setter> + </property> + + <method name="setDate"> + <parameter name="aDate"/> + <body><![CDATA[ + if (!aDate) { + throw Components.results.NS_ERROR_NULL_POINTER; + } + + // Remove all the old events + this.mItemHash = {}; + removeChildren(this); + + if (this.mDate && this.mDate.compare(aDate) == 0) { + return; + } + + this.mDate = aDate; + + // Set up DOM attributes for custom CSS coloring. + let weekTitle = cal.getWeekInfoService().getWeekTitle(aDate); + this.setAttribute("year", aDate.year); + this.setAttribute("month", aDate.month + 1); + this.setAttribute("week", weekTitle); + this.setAttribute("day", aDate.day); + + if (this.mShowMonthLabel) { + let monthName = calGetString("dateFormat", "month." + (aDate.month + 1) + ".Mmm"); + this.setAttribute("value", aDate.day + " " + monthName); + } else { + this.setAttribute("value", aDate.day); + } + ]]></body> + </method> + + <method name="addItem"> + <parameter name="aItem"/> + <body><![CDATA[ + if (aItem.hashId in this.mItemHash) { + this.deleteItem(aItem); + } + + let box = createXULElement("calendar-month-day-box-item"); + let context = this.getAttribute("item-context") || + this.getAttribute("context"); + box.setAttribute("context", context); + box.setAttribute("calendar-uri", aItem.calendar.uri.spec); + box.setAttribute("calendar-id", aItem.calendar.id); + + cal.binaryInsertNode(this, box, aItem, cal.view.compareItems, false); + + box.calendarView = this.calendarView; + box.item = aItem; + box.parentBox = this; + box.occurrence = aItem; + + this.mItemHash[aItem.hashId] = box; + return box; + ]]></body> + </method> + + <method name="selectItem"> + <parameter name="aItem"/> + <body><![CDATA[ + if (aItem.hashId in this.mItemHash) { + this.mItemHash[aItem.hashId].selected = true; + } + ]]></body> + </method> + + <method name="unselectItem"> + <parameter name="aItem"/> + <body><![CDATA[ + if (aItem.hashId in this.mItemHash) { + this.mItemHash[aItem.hashId].selected = false; + } + ]]></body> + </method> + + <method name="deleteItem"> + <parameter name="aItem"/> + <body><![CDATA[ + if (aItem.hashId in this.mItemHash) { + let node = this.mItemHash[aItem.hashId]; + node.remove(); + delete this.mItemHash[aItem.hashId]; + } + ]]></body> + </method> + + <method name="onDropItem"> + <parameter name="aItem"/> + <body><![CDATA[ + // When item's timezone is different than the default one, the + // item might get moved on a day different than the drop day. + // Changing the drop day allows to compensate a possible difference. + + // Figure out if the timezones cause a days difference. + let start = (aItem[calGetStartDateProp(aItem)] || + aItem[calGetEndDateProp(aItem)]).clone(); + let dayboxDate = this.mDate.clone(); + if (start.timezone != dayboxDate.timezone) { + let startInDefaultTz = start.clone().getInTimezone(dayboxDate.timezone); + start.isDate = true; + startInDefaultTz.isDate = true; + startInDefaultTz.timezone = start.timezone; + let dayDiff = start.subtractDate(startInDefaultTz); + // Change the day where to drop the item. + dayboxDate.addDuration(dayDiff); + } + + return cal.moveItem(aItem, dayboxDate); + ]]></body> + </method> + </implementation> + + <handlers> + <handler event="mousedown"><![CDATA[ + event.stopPropagation(); + if (this.mDate) { + this.calendarView.selectedDay = this.mDate; + } + ]]></handler> + <handler event="dblclick"><![CDATA[ + event.stopPropagation(); + this.calendarView.controller.createNewEvent(); + ]]></handler> + <handler event="click" button="0"><![CDATA[ + if (!(event.ctrlKey || event.metaKey)) { + this.calendarView.setSelectedItems(0, []); + } + ]]></handler> + <handler event="wheel"><![CDATA[ + if (getParentNodeOrThisByAttribute(event.originalTarget, "anonid", "day-label") == null) { + if (this.dayitems.scrollHeight > this.dayitems.clientHeight) { + event.stopPropagation(); + } + } + ]]></handler> + </handlers> + </binding> + + <binding id="calendar-month-base-view" extends="chrome://calendar/content/calendar-base-view.xml#calendar-base-view"> + <content style="overflow: auto;" flex="1" xbl:inherits="context,item-context"> + <xul:vbox anonid="mainbox" flex="1"> + <xul:hbox class="labeldaybox-container" + anonid="labeldaybox" + equalsize="always"/> + + <xul:grid anonid="monthgrid" flex="1"> + <xul:columns anonid="monthgridcolumns" equalsize="always"> + <xul:column flex="1" class="calendar-month-view-grid-column"/> + <xul:column flex="1" class="calendar-month-view-grid-column"/> + <xul:column flex="1" class="calendar-month-view-grid-column"/> + <xul:column flex="1" class="calendar-month-view-grid-column"/> + <xul:column flex="1" class="calendar-month-view-grid-column"/> + <xul:column flex="1" class="calendar-month-view-grid-column"/> + <xul:column flex="1" class="calendar-month-view-grid-column"/> + </xul:columns> + + <xul:rows anonid="monthgridrows" equalsize="always"> + <xul:row flex="1" class="calendar-month-view-grid-row"> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + </xul:row> + <xul:row flex="1" class="calendar-month-view-grid-row"> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + </xul:row> + <xul:row flex="1" class="calendar-month-view-grid-row"> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + </xul:row> + <xul:row flex="1" class="calendar-month-view-grid-row"> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + </xul:row> + <xul:row flex="1" class="calendar-month-view-grid-row"> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + </xul:row> + <xul:row flex="1" class="calendar-month-view-grid-row"> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + <xul:calendar-month-day-box/> + </xul:row> + </xul:rows> + </xul:grid> + </xul:vbox> + </content> + + <implementation implements="calICalendarView"> + <constructor><![CDATA[ + Components.utils.import("resource://gre/modules/Preferences.jsm"); + Components.utils.import("resource://calendar/modules/calViewUtils.jsm"); + + // Set the preference for the default start of the week + this.weekStartOffset = Preferences.get("calendar.week.start", 0); + + for (let i = 0; i < 7; i++) { + let hdr = createXULElement("calendar-day-label"); + this.labeldaybox.appendChild(hdr); + hdr.weekDay = (i + this.mWeekStartOffset) % 7; + hdr.shortWeekNames = false; + } + + // Set the preference for displaying the week number + this.mShowWeekNumber = Preferences.get("calendar.view-minimonth.showWeekNumber", true); + ]]></constructor> + + <!-- fields --> + <field name="mDateBoxes">null</field> + <field name="mSelectedDayBox">null</field> + + <field name="mShowDaysOutsideMonth">true</field> + <field name="mShowFullMonth">true</field> + <field name="mShowWeekNumber">true</field> + + <field name="mClickedTime">null</field> + + <!-- other methods --> + <method name="setAttribute"> + <parameter name="aAttr"/> + <parameter name="aVal"/> + <body><![CDATA[ + let needsrelayout = (aAttr == "context" || aAttr == "item-context"); + let ret = XULElement.prototype.setAttribute.call(this, aAttr, aVal); + + if (needsrelayout) { + this.relayout(); + } + + return ret; + ]]></body> + </method> + + <!-- calICalendarView --> + + <property name="supportsDisjointDates" readonly="true" + onget="return false;"/> + <property name="hasDisjointDates" readonly="true" + onget="return false;"/> + + <property name="startDate" readonly="true" + onget="return this.mStartDate"/> + + <property name="endDate" readonly="true" + onget="return this.mEndDate"/> + + <property name="showFullMonth"> + <getter><![CDATA[ + return this.mShowFullMonth; + ]]></getter> + <setter><![CDATA[ + this.mShowFullMonth = val; + return val; + ]]></setter> + </property> + + <!-- this property may be overridden by the + descendent classes if neeeded --> + <property name="weeksInView"> + <getter><![CDATA[ + return 0; + ]]></getter> + <setter><![CDATA[ + return val; + ]]></setter> + </property> + + <method name="handlePreference"> + <parameter name="aSubject"/> + <parameter name="aTopic"/> + <parameter name="aPreference"/> + <body><![CDATA[ + aSubject.QueryInterface(Components.interfaces.nsIPrefBranch); + + switch (aPreference) { + case "calendar.previousweeks.inview": + this.updateDaysOffPrefs(); + this.refreshView(); + break; + + case "calendar.week.start": + this.weekStartOffset = aSubject.getIntPref(aPreference); + // Refresh the view so the settings take effect + this.refreshView(); + break; + + case "calendar.weeks.inview": + this.weeksInView = aSubject.getIntPref(aPreference); + break; + + case "calendar.view-minimonth.showWeekNumber": + this.mShowWeekNumber = aSubject.getBoolPref(aPreference); + if (this.mShowWeekNumber) { + this.refreshView(); + } else { + this.hideWeekNumbers(); + } + break; + + default: + this.handleCommonPreference(aSubject, aTopic, aPreference); + break; + } + return; + ]]></body> + </method> + + <method name="getSelectedItems"> + <parameter name="aCount"/> + <body><![CDATA[ + aCount.value = this.mSelectedItems.length; + return this.mSelectedItems; + ]]></body> + </method> + + <method name="setSelectedItems"> + <parameter name="aCount"/> + <parameter name="aItems"/> + <parameter name="aSuppressEvent"/> + <body><![CDATA[ + if (this.mSelectedItems.length) { + for (let item of this.mSelectedItems) { + let oldboxes = this.findDayBoxesForItem(item); + for (let oldbox of oldboxes) { + oldbox.unselectItem(item); + } + } + } + + this.mSelectedItems = aItems || []; + + if (this.mSelectedItems.length) { + for (let item of this.mSelectedItems) { + let newboxes = this.findDayBoxesForItem(item); + for (let newbox of newboxes) { + newbox.selectItem(item); + } + } + } + + if (!aSuppressEvent) { + this.fireEvent("itemselect", this.mSelectedItems); + } + ]]></body> + </method> + + <method name="centerSelectedItems"> + <body> + </body> + </method> + + <property name="selectedDay"> + <getter><![CDATA[ + if (this.mSelectedDayBox) { + return this.mSelectedDayBox.date.clone(); + } + + return null; + ]]></getter> + <setter><![CDATA[ + if (this.mSelectedDayBox) { + this.mSelectedDayBox.selected = false; + } + + let realVal = val; + if (!realVal.isDate) { + realVal = val.clone(); + realVal.isDate = true; + } + let box = this.findDayBoxForDate(realVal); + if (box) { + box.selected = true; + this.mSelectedDayBox = box; + } + this.fireEvent("dayselect", realVal); + return val; + ]]></setter> + </property> + + <property name="selectedDateTime"> + <getter><![CDATA[ + return getDefaultStartDate(this.selectedDay); + ]]></getter> + <setter><![CDATA[ + this.mClickedTime = val; + ]]></setter> + </property> + + <method name="showDate"> + <parameter name="aDate"/> + <body><![CDATA[ + // If aDate is null it means that only a refresh is needed + // without changing the start and end of the view. + if (aDate) { + this.setDateRange(aDate.startOfMonth, aDate.endOfMonth); + this.selectedDay = aDate; + } else { + this.refresh(); + // Refresh the selected day if it doesn't appear in the view. + this.selectedDay = this.selectedDay; + } + ]]></body> + </method> + + <method name="onResize"> + <parameter name="aBinding"/> + <body><![CDATA[ + aBinding.adjustWeekdayLength(); + // Delete the timer for the time indicator in day/week view. + timeIndicator.cancel(); + ]]></body> + </method> + + <method name="setDateRange"> + <parameter name="aStartDate"/> + <parameter name="aEndDate"/> + <body><![CDATA[ + this.rangeStartDate = aStartDate; + this.rangeEndDate = aEndDate; + let viewStart = cal.getWeekInfoService().getStartOfWeek( + aStartDate.getInTimezone(this.mTimezone)); + let viewEnd = cal.getWeekInfoService().getEndOfWeek( + aEndDate.getInTimezone(this.mTimezone)); + + viewStart.isDate = true; + viewStart.makeImmutable(); + viewEnd.isDate = true; + viewEnd.makeImmutable(); + this.mStartDate = viewStart; + this.mEndDate = viewEnd; + + // check values of tasksInView, workdaysOnly, showCompleted + // see setDateRange comment in calendar-multiday-view.xml + let toggleStatus = 0; + + if (this.mTasksInView) { + toggleStatus |= this.mToggleStatusFlag.TasksInView; + } + if (this.mWorkdaysOnly) { + toggleStatus |= this.mToggleStatusFlag.WorkdaysOnly; + } + if (this.mShowCompleted) { + toggleStatus |= this.mToggleStatusFlag.ShowCompleted; + } + + // Update the navigation bar only when changes are related to the current view. + if (this.isVisible()) { + cal.navigationBar.setDateRange(aStartDate, aEndDate); + } + + // Check whether view range has been changed since last call to + // relayout() + if (!this.mViewStart || !this.mViewEnd || + this.mViewEnd.compare(viewEnd) != 0 || + this.mViewStart.compare(viewStart) != 0 || + this.mToggleStatus != toggleStatus) { + this.refresh(); + } + + ]]></body> + </method> + + <method name="getDateList"> + <parameter name="aCount"/> + <body><![CDATA[ + if (!this.mStartDate || !this.mEndDate) { + aCount.value = 0; + return []; + } + + let results = []; + let curDate = this.mStartDate.clone(); + curDate.isDate = true; + + while (curDate.compare(this.mEndDate) <= 0) { + results.push(curDate.clone()); + curDate.day += 1; + } + aCount.value = results.length; + return results; + ]]></body> + </method> + + <!-- public properties and methods --> + + <!-- whether to show days outside of the current month --> + <property name="showDaysOutsideMonth"> + <getter><![CDATA[ + return this.mShowDaysOutsideMonth; + ]]></getter> + <setter><![CDATA[ + if (this.mShowDaysOutsideMonth != val) { + this.mShowDaysOutsideMonth = val; + this.refresh(); + } + return val; + ]]></setter> + </property> + + <!-- private properties and methods --> + + <property name="monthgrid" readonly="true" + onget="return document.getAnonymousElementByAttribute(this, 'anonid', 'monthgrid');"/> + + <property name="monthgridrows" readonly="true" + onget="return document.getAnonymousElementByAttribute(this, 'anonid', 'monthgridrows');"/> + + <method name="relayout"> + <body><![CDATA[ + // Adjust headers based on the starting day of the week, if necessary + if (this.labeldaybox.firstChild.weekDay != this.weekStartOffset) { + for (let i = 0; i < this.labeldaybox.childNodes.length; i++) { + this.labeldaybox.childNodes[i].weekDay = (i + this.weekStartOffset) % 7; + } + } + + if (this.mSelectedItems.length) { + this.mSelectedItems = []; + } + + if (!this.mStartDate || !this.mEndDate) { + throw Components.results.NS_ERROR_FAILURE; + } + + // Days that are not in the main month on display are displayed with + // a gray background. Unless the month actually starts on a Sunday, + // this means that mStartDate.month is 1 month less than the main month + let mainMonth = this.mStartDate.month; + if (this.mStartDate.day != 1) { + mainMonth++; + mainMonth = mainMonth % 12; + } + + let dateBoxes = []; + let today = this.today(); + + // This gets set to true, telling us to collapse the rest of the rows + let finished = false; + let dateList = this.getDateList({}); + + // This allows to find the first column of dayboxes where to set the + // week labels taking into account whether days-off are displayed or not. + let weekLabelColumnPos = -1; + + let rows = this.monthgridrows.childNodes; + + // Iterate through each monthgridrow and set up the day-boxes that + // are its child nodes. Remember, childNodes is not a normal array, + // so don't use the in operator if you don't want extra properties + // coming out. + for (let i = 0; i < rows.length; i++) { + let row = rows[i]; + // If we've already assigned all of the day-boxes that we need, just + // collapse the rest of the rows, otherwise expand them if needed. + if (finished) { + row.setAttribute("collapsed", true); + continue; + } else { + row.removeAttribute("collapsed"); + } + for (let j = 0; j < row.childNodes.length; j++) { + let daybox = row.childNodes[j]; + let date = dateList[dateBoxes.length]; + + // Remove the attribute "relation" for all the column headers. + // Consider only the first row index otherwise it will be + // removed again afterwards the correct setting. + if (i == 0) { + this.labeldaybox.childNodes[j].removeAttribute("relation"); + } + + daybox.setAttribute("context", this.getAttribute("context")); + daybox.setAttribute("item-context", this.getAttribute("item-context") || this.getAttribute("context")); + + // Set the box-class depending on if this box displays a day in + // the month being currently shown or not. + let boxClass; + if (this.showFullMonth) { + boxClass = "calendar-month-day-box-" + + (mainMonth == date.month ? "current-month" : "other-month"); + } else { + boxClass = "calendar-month-day-box-current-month"; + } + if (this.mDaysOffArray.some(dayOffNum => dayOffNum == date.weekday)) { + boxClass = "calendar-month-day-box-day-off " + boxClass; + } + + // Set up date relations + switch (date.compare(today)) { + case -1: + daybox.setAttribute("relation", "past"); + break; + case 0: + daybox.setAttribute("relation", "today"); + this.labeldaybox.childNodes[j].setAttribute("relation", "today"); + break; + case 1: + daybox.setAttribute("relation", "future"); + break; + } + + // Set up label with the week number in the first day of the row. + if (this.mShowWeekNumber) { + let weekLabel = document.getAnonymousElementByAttribute(daybox, "anonid", "week-label"); + if (weekLabelColumnPos < 0) { + let isDayOff = this.mDaysOffArray.includes((j + this.mWeekStartOffset) % 7); + if (this.mDisplayDaysOff || !isDayOff) { + weekLabelColumnPos = j; + } + } + // Build and set the label. + if (j == weekLabelColumnPos) { + weekLabel.removeAttribute("hidden"); + let weekNumber = cal.getWeekInfoService().getWeekTitle(date); + let weekString = cal.calGetString("calendar", "abbreviationOfWeek", [weekNumber]); + weekLabel.value = weekString; + } else { + weekLabel.hidden = true; + } + } + + daybox.setAttribute("class", boxClass); + + daybox.setDate(date); + if (date.day == 1 || date.day == date.endOfMonth.day) { + daybox.showMonthLabel = true; + } else { + daybox.showMonthLabel = false; + } + daybox.calendarView = this; + daybox.date = date; + dateBoxes.push(daybox); + + // If we've now assigned all of our dates, set this to true so we + // know we can just collapse the rest of the rows. + if (dateBoxes.length == dateList.length) { + finished = true; + } + } + } + + // If we're not showing a full month, then add a few extra labels to + // help the user orient themselves in the view. + if (!this.mShowFullMonth) { + dateBoxes[0].showMonthLabel = true; + dateBoxes[dateBoxes.length - 1].showMonthLabel = true; + } + + // Store these, so that we can access them later + this.mDateBoxes = dateBoxes; + this.hideDaysOff(); + + this.adjustWeekdayLength(); + + // Store the start and end of current view. Next time when + // setDateRange is called, it will use mViewStart and mViewEnd to + // check if view range has been changed. + this.mViewStart = this.mStartDate; + this.mViewEnd = this.mEndDate; + + // Store toggle status of current view + let toggleStatus = 0; + + if (this.mTasksInView) { + toggleStatus |= this.mToggleStatusFlag.TasksInView; + } + if (this.mWorkdaysOnly) { + toggleStatus |= this.mToggleStatusFlag.WorkdaysOnly; + } + if (this.mShowCompleted) { + toggleStatus |= this.mToggleStatusFlag.ShowCompleted; + } + + this.mToggleStatus = toggleStatus; + ]]></body> + </method> + + <method name="hideWeekNumbers"> + <body><![CDATA[ + let rows = this.monthgridrows.childNodes; + for (let i = 0; i < rows.length; i++) { + let row = rows[i]; + for (let j = 0; j < row.childNodes.length; j++) { + let daybox = row.childNodes[j]; + let weekLabel = document.getAnonymousElementByAttribute(daybox, "anonid", "week-label"); + weekLabel.hidden = true; + } + } + ]]></body> + </method> + + <method name="hideDaysOff"> + <body><![CDATA[ + let columns = document.getAnonymousElementByAttribute(this, "anonid", "monthgridcolumns").childNodes; + let headerkids = document.getAnonymousElementByAttribute(this, "anonid", "labeldaybox").childNodes; + for (let i = 0; i < columns.length; i++) { + let dayForColumn = (i + this.mWeekStartOffset) % 7; + let dayOff = this.mDaysOffArray.includes(dayForColumn); + columns[i].collapsed = dayOff && !this.mDisplayDaysOff; + headerkids[i].collapsed = dayOff && !this.mDisplayDaysOff; + } + ]]></body> + </method> + + <method name="findDayBoxForDate"> + <parameter name="aDate"/> + <body><![CDATA[ + if (!this.mDateBoxes) { + return null; + } + for (let box of this.mDateBoxes) { + if (box.mDate.compare(aDate) == 0) { + return box; + } + } + return null; + ]]></body> + </method> + + <method name="findDayBoxesForItem"> + <parameter name="aItem"/> + <body><![CDATA[ + let targetDate = null; + let finishDate = null; + let boxes = []; + + // All our boxes are in default tz, so we need these times in them too. + if (cal.isEvent(aItem)) { + targetDate = aItem.startDate.getInTimezone(this.mTimezone); + finishDate = aItem.endDate.getInTimezone(this.mTimezone); + } else if (cal.isToDo(aItem)) { + // Consider tasks without entry OR due date. + if (aItem.entryDate || aItem.dueDate) { + targetDate = (aItem.entryDate || aItem.dueDate).getInTimezone(this.mTimezone); + finishDate = (aItem.dueDate || aItem.entryDate).getInTimezone(this.mTimezone); + } + } + + if (!targetDate) { + return boxes; + } + + if (!finishDate) { + let maybeBox = this.findDayBoxForDate(targetDate); + if (maybeBox) { + boxes.push(maybeBox); + } + return boxes; + } + + if (!targetDate.isDate) { + // Reset the time to 00:00, so that we really get all the boxes + targetDate.hour = 0; + targetDate.minute = 0; + targetDate.second = 0; + } + + if (targetDate.compare(finishDate) == 0) { + // We have also to handle zero length events in particular for + // tasks without entry or due date. + let box = this.findDayBoxForDate(targetDate); + if (box) { + boxes.push(box); + } + } + + while (targetDate.compare(finishDate) == -1) { + let box = this.findDayBoxForDate(targetDate); + + // This might not exist, if the event spans the view start or end + if (box) { + boxes.push(box); + } + targetDate.day += 1; + } + + return boxes; + ]]></body> + </method> + + <method name="doAddItem"> + <parameter name="aItem"/> + <body><![CDATA[ + let boxes = this.findDayBoxesForItem(aItem); + + if (!boxes.length) { + return; + } + + for (let box of boxes) { + box.addItem(aItem); + } + ]]></body> + </method> + + <method name="doDeleteItem"> + <parameter name="aItem"/> + <body><![CDATA[ + let boxes = this.findDayBoxesForItem(aItem); + + if (!boxes.length) { + return; + } + + function isNotItem(a) { + return (a.hashId != aItem.hashId); + } + let oldLength = this.mSelectedItems.length; + this.mSelectedItems = this.mSelectedItems.filter(isNotItem); + + for (let box of boxes) { + box.deleteItem(aItem); + } + + // If a deleted event was selected, we need to announce that the + // selection changed. + if (oldLength != this.mSelectedItems.length) { + this.fireEvent("itemselect", this.mSelectedItems); + } + ]]></body> + </method> + + <method name="deleteItemsFromCalendar"> + <parameter name="aCalendar"/> + <body><![CDATA[ + if (!this.mDateBoxes) { + return; + } + for (let box of this.mDateBoxes) { + for (let id in box.mItemHash) { + let node = box.mItemHash[id]; + let item = node.item; + if (item.calendar.id == aCalendar.id) { + box.deleteItem(item); + } + } + } + ]]></body> + </method> + + <method name="flashAlarm"> + <parameter name="aAlarmItem"/> + <parameter name="aStop"/> + <body><![CDATA[ + let showIndicator = Preferences.get("calendar.alarms.indicator.show", true); + let totaltime = Preferences.get("calendar.alarms.indicator.totaltime", 3600); + + if (!aStop && (!showIndicator || totaltime < 1)) { + // No need to animate if the indicator should not be shown. + return; + } + + // Make sure the flashing attribute is set or reset on all visible + // boxes. + let boxes = this.findDayBoxesForItem(aAlarmItem); + for (let box of boxes) { + for (let id in box.mItemHash) { + let itemData = box.mItemHash[id]; + if (itemData.item.hasSameIds(aAlarmItem)) { + if (aStop) { + itemData.removeAttribute("flashing"); + } else { + itemData.setAttribute("flashing", "true"); + } + } + } + } + + if (aStop) { + // We are done flashing, prevent newly created event boxes from flashing. + delete this.mFlashingEvents[aAlarmItem.hashId]; + } else { + // Set up a timer to stop the flashing after the total time. + this.mFlashingEvents[aAlarmItem.hashId] = aAlarmItem; + setTimeout(() => this.flashAlarm(aAlarmItem, true), totaltime); + } + ]]></body> + </method> + </implementation> + + <handlers> + <handler event="wheel"><![CDATA[ + const pixelThreshold = 150; + let scrollEnabled = Preferences.get("calendar.view.mousescroll", true); + if (!event.ctrlKey && !event.shiftKey && + !event.altKey && !event.metaKey && scrollEnabled) { + // In the month view, the only thing that can be scrolled + // is the month the user is in. calendar-base-view takes care + // of the shift key, so only move the view when no modifier + // is pressed. + let deltaView = 0; + if (event.deltaMode == event.DOM_DELTA_LINE) { + if (event.deltaY != 0) { + deltaView = event.deltaY < 0 ? -1 : 1; + } + } else if (event.deltaMode == event.DOM_DELTA_PIXEL) { + this.mPixelScrollDelta += event.deltaY; + if (this.mPixelScrollDelta > pixelThreshold) { + deltaView = 1; + this.mPixelScrollDelta = 0; + } else if (this.mPixelScrollDelta < -pixelThreshold) { + deltaView = -1; + this.mPixelScrollDelta = 0; + } + } + + if (deltaView != 0) { + this.moveView(deltaView); + } + } + ]]></handler> + </handlers> + </binding> +</bindings> diff --git a/calendar/base/content/calendar-multiday-view.xml b/calendar/base/content/calendar-multiday-view.xml new file mode 100644 index 000000000..1163a2e8d --- /dev/null +++ b/calendar/base/content/calendar-multiday-view.xml @@ -0,0 +1,3886 @@ +<?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/. --> + +<!-- Note that this file depends on helper functions in calUtils.js--> + +<!DOCTYPE bindings SYSTEM "chrome://global/locale/global.dtd" > + +<bindings id="calendar-multiday-view-bindings" + xmlns="http://www.mozilla.org/xbl" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:xbl="http://www.mozilla.org/xbl"> + + <!-- + - This is the time bar that displays time divisions to the side + - or top of a multiday view. + --> + <binding id="calendar-time-bar"> + <content> + <xul:stack anonid="timebarboxstack" style="display: block; position: relative" xbl:inherits="orient,width,height" flex="1"> + <xul:box anonid="topbox" xbl:inherits="orient,width,height" flex="1"/> + <xul:box anonid="timeIndicatorBoxTimeBar" class="timeIndicator-timeBar" xbl:inherits="orient" hidden="true"/> + </xul:stack> + </content> + + <implementation> + <field name="mPixPerMin">0.6</field> + <field name="mStartMin">0</field> + <field name="mEndMin">24 * 60</field> + <field name="mDayStartHour">0</field> + <field name="mDayEndHour">24</field> + + <constructor> + this.relayout(); + </constructor> + + <method name="setDayStartEndHours"> + <parameter name="aDayStartHour"/> + <parameter name="aDayEndHour"/> + <body><![CDATA[ + if (aDayStartHour * 60 < this.mStartMin || + aDayStartHour > aDayEndHour || + aDayEndHour * 60 > this.mEndMin) { + throw Components.results.NS_ERROR_INVALID_ARG; + } + if (this.mDayStartHour != aDayStartHour || + this.mDayEndHour != aDayEndHour) { + this.mDayEndHour = aDayEndHour; + this.mDayStartHour = aDayStartHour; + + let topbox = document.getAnonymousElementByAttribute(this, "anonid", "topbox"); + if (topbox.childNodes.length) { + // This only needs to be done if the initial relayout has + // already happened, otherwise it will be done then. + for (let hour = this.mStartMin / 60; hour < this.mEndMin / 60; hour++) { + if (hour < this.mDayStartHour || hour >= this.mDayEndHour) { + topbox.childNodes[hour].setAttribute("off-time", "true"); + } else { + topbox.childNodes[hour].removeAttribute("off-time"); + } + } + } + } + ]]></body> + </method> + + <method name="setAttribute"> + <parameter name="aAttr"/> + <parameter name="aVal"/> + <body><![CDATA[ + let needsrelayout = false; + if (aAttr == "orient") { + if (this.getAttribute("orient") != aVal) { + needsrelayout = true; + } + } + + // this should be done using lookupMethod(), see bug 286629 + let ret = XULElement.prototype.setAttribute.call(this, aAttr, aVal); + + if (needsrelayout) { + this.relayout(); + } + + return ret; + ]]></body> + </method> + + <property name="pixelsPerMinute" + onget="return this.mPixPerMin" + onset="if (this.mPixPerMin != val) { this.mPixPerMin = val; this.relayout(); } return val;"/> + + <method name="relayout"> + <body><![CDATA[ + let topbox = document.getAnonymousElementByAttribute(this, "anonid", "topbox"); + let orient = topbox.getAttribute("orient"); + + function makeTimeBox(timestr, size) { + let box = createXULElement("box"); + box.setAttribute("orient", orient); + + if (orient == "horizontal") { + box.setAttribute("width", size); + } else { + box.setAttribute("height", size); + } + + let label = createXULElement("label"); + label.setAttribute("class", "calendar-time-bar-label"); + label.setAttribute("value", timestr); + label.setAttribute("align", "center"); + + box.appendChild(label); + + return box; + } + + while (topbox.hasChildNodes()) { + topbox.lastChild.remove(); + } + + let formatter = Components.classes["@mozilla.org/intl/scriptabledateformat;1"] + .getService(Components.interfaces.nsIScriptableDateFormat); + let timeString; + let theMin = this.mStartMin; + let theHour = Math.floor(theMin / 60); + let durLeft = this.mEndMin - this.mStartMin; + + while (durLeft > 0) { + let dur; + if (this.mEndMin - theMin < 60) { + dur = this.mEndMin - theMin; + } else { + dur = theMin % 60; + } + theMin += dur; + if (dur == 0) { + dur = 60; + } + + // calculate duration pixel as the difference between + // start pixel and end pixel to avoid rounding errors. + let startPix = Math.round(theMin * this.mPixPerMin); + let endPix = Math.round((theMin + dur) * this.mPixPerMin); + let durPix = endPix - startPix; + let box; + if (dur == 60) { + timeString = formatter.FormatTime("", + Components.interfaces.nsIScriptableDateFormat.timeFormatNoSeconds, + theHour, 0, 0); + box = makeTimeBox(timeString, durPix); + } else { + box = makeTimeBox("", durPix); + } + + // Set up workweek hours + if (theHour < this.mDayStartHour || theHour >= this.mDayEndHour) { + box.setAttribute("off-time", "true"); + } + + box.setAttribute("class", "calendar-time-bar-box-" + (theHour % 2 == 0 ? "even" : "odd")); + topbox.appendChild(box); + + durLeft -= dur; + theMin += dur; + theHour++; + } + ]]></body> + </method> + </implementation> + </binding> + + <!-- + - A simple gripbar that is displayed at the start and end of an + - event box. Needs to handle being dragged and resizing the + - event, thus changing its start/end time. + --> + <binding id="calendar-event-gripbar"> + <content> + <xul:box anonid="thebox" + flex="1" + pack="center" + xbl:inherits="align=whichside"> + <xul:image xbl:inherits="class"/> + </xul:box> + </content> + + <implementation> + <property name="parentorient"> + <getter><![CDATA[ + return this.getAttribute("parentorient"); + ]]></getter> + <setter><![CDATA[ + let thebox = document.getAnonymousElementByAttribute(this, "anonid", "thebox"); + this.setAttribute("parentorient", val); + thebox.setAttribute("orient", getOtherOrientation(val)); + return val; + ]]></setter> + </property> + + <!-- private --> + <constructor><![CDATA[ + this.parentorient = this.getAttribute("parentorient"); + ]]></constructor> + </implementation> + + <handlers> + <handler event="mousedown" button="0"><![CDATA[ + // store the attribute 'whichside' in the event object + // but *don't* call stopPropagation(). as soon as the + // enclosing event box will receive the event it will + // make use of this information in order to invoke the + // appropriate action. + event.whichside = this.getAttribute("whichside"); + ]]></handler> + <handler event="click" button="0"><![CDATA[ + event.stopPropagation(); + ]]></handler> + </handlers> + </binding> + + <!-- + - A column for displaying event boxes in. One column per + - day; it manages the layout of the events given via add/deleteEvent. + --> + <binding id="calendar-event-column"> + <content> + <xul:stack anonid="boxstack" flex="1" class="multiday-column-box-stack" style="min-width: 1px; min-height: 1px"> + <xul:box anonid="bgbox" flex="1" class="multiday-column-bg-box" style="min-width: 1px; min-height: 1px"/> + <xul:box anonid="topbox" class="multiday-column-top-box" flex="1" style="min-width: 1px; min-height: 1px" + xbl:inherits="context" equalsize="always" mousethrough="always"/> + <xul:box anonid="timeIndicatorBox" xbl:inherits="orient" class="timeIndicator" mousethrough="always" hidden="true"/> + <xul:box anonid="fgbox" flex="1" class="fgdragcontainer" style="min-width: 1px; min-height: 1px; overflow:hidden;"> + <xul:box anonid="fgdragspacer" style="display: inherit; overflow: hidden;"> + <xul:spacer flex="1"/> + <xul:label anonid="fgdragbox-startlabel" class="fgdragbox-label"/> + </xul:box> + <xul:box anonid="fgdragbox" class="fgdragbox" /> + <xul:label anonid="fgdragbox-endlabel" class="fgdragbox-label"/> + </xul:box> + </xul:stack> + <xul:calendar-event-box anonid="config-box" hidden="true" xbl:inherits="orient"/> + </content> + + <implementation> + <constructor><![CDATA[ + this.mEventInfos = []; + this.mTimezone = UTC(); + this.mSelectedItemIds = {}; + ]]></constructor> + + <!-- fields --> + <field name="mPixPerMin">0.6</field> + <field name="mStartMin">0</field> + <field name="mEndMin">24 * 60</field> + <field name="mDayStartMin">8 * 60</field> + <field name="mDayEndMin">17 * 60</field> + <!--an array of objects that contain information about the events that are to be + displayed. The contained fields are: + event: The event that is to be displayed in a 'calendar-event-box' + layoutStart: The 'start'-datetime object of the event in the timezone of the view + layoutEnd: The 'end'-datetime object of the event in the timezone of the view. + The 'layoutEnd' may be different from the real 'end' time of the + event because it considers a certain minimum duration of the event + that is basically dependent of the font-size of the event-box label --> + <field name="mEventInfos">[]</field> + <field name="mEventMap">null</field> + <field name="mCalendarView">null</field> + <field name="mDate">null</field> + <field name="mTimezone">null</field> + <field name="mDragState">null</field> + <field name="mLayoutBatchCount">0</field> + <!-- Since we'll often be getting many events in rapid succession, this + timer helps ensure that we don't re-compute the event map too many + times in a short interval, and therefore improves performance.--> + <field name="mEventMapTimeout">null</field> + <!-- Sometimes we need to add resize handlers for columns with special + widths. When we relayout, we need to cancel those handlers --> + <field name="mHandlersToRemove">[]</field> + + <!-- Set this true so that we know in our onAddItem listener to start + - modifying an event when it comes back to us as created + --> + <field name="mCreatedNewEvent">false</field> + <field name="mEventToEdit">null</field> + <field name="mSelectedItemIds">null</field> + + <!-- properties --> + <property name="pixelsPerMinute"> + <getter><![CDATA[ + return this.mPixPerMin; + ]]></getter> + <setter><![CDATA[ + if (val <= 0.0) { + val = 0.01; + } + if (val != this.mPixPerMin) { + this.mPixPerMin = val; + this.relayout(); + } + return val; + ]]></setter> + </property> + + <field name="mSelected">false</field> + <property name="selected"> + <getter><![CDATA[ + return this.mSelected; + ]]></getter> + <setter><![CDATA[ + this.mSelected = val; + if (this.bgbox && this.bgbox.hasChildNodes()) { + let child = this.bgbox.firstChild; + while (child) { + if (val) { + child.setAttribute("selected", "true"); + } else { + child.removeAttribute("selected"); + } + child = child.nextSibling; + } + } + return val; + ]]></setter> + </property> + + <property name="date"> + <getter><![CDATA[ + return this.mDate; + ]]></getter> + <setter><![CDATA[ + this.mDate = val; + + if (!compareObjects(val.timezone, this.mTimezone)) { + this.mTimezone = val.timezone; + if (!this.mLayoutBatchCount) { + this.recalculateStartEndMinutes(); + } + } + + return val; + ]]></setter> + </property> + + <property name="calendarView" + onget="return this.mCalendarView;" + onset="return (this.mCalendarView = val);" /> + + <property name="topbox" readonly="true"> + <getter><![CDATA[ + return document.getAnonymousElementByAttribute(this, "anonid", "topbox"); + ]]></getter> + </property> + + <property name="bgbox" readonly="true"> + <getter><![CDATA[ + return document.getAnonymousElementByAttribute(this, "anonid", "bgbox"); + ]]></getter> + </property> + + <field name="mFgboxes">null</field> + <field name="mMinDuration">null</field> + <property name="fgboxes" readonly="true"> + <getter><![CDATA[ + if (this.mFgboxes == null) { + this.mFgboxes = { + box: document.getAnonymousElementByAttribute(this, "anonid", "fgbox"), + dragbox: document.getAnonymousElementByAttribute(this, "anonid", "fgdragbox"), + dragspacer: document.getAnonymousElementByAttribute(this, "anonid", "fgdragspacer"), + startlabel: document.getAnonymousElementByAttribute(this, "anonid", "fgdragbox-startlabel"), + endlabel: document.getAnonymousElementByAttribute(this, "anonid", "fgdragbox-endlabel") + }; + } + return this.mFgboxes; + ]]></getter> + </property> + + <property name="timeIndicatorBox" + readonly="true"> + <getter><![CDATA[ + return document.getAnonymousElementByAttribute(this, "anonid", "timeIndicatorBox"); + ]]></getter> + </property> + + <property name="events" readonly="true" onget="return this.methods"/> + + <field name="mDayOff">false</field> + <property name="dayOff"> + <getter><![CDATA[ + return this.mDayOff; + ]]></getter> + <setter><![CDATA[ + this.mDayOff = val; + return val; + ]]></setter> + </property> + + <!-- mEventInfos --> + <field name="mSelectedChunks">[]</field> + + <method name="selectOccurrence"> + <parameter name="aOccurrence"/> + <body><![CDATA[ + if (aOccurrence) { + this.mSelectedItemIds[aOccurrence.hashId] = true; + let chunk = this.findChunkForOccurrence(aOccurrence); + if (!chunk) { + return; + } + chunk.selected = true; + this.mSelectedChunks.push(chunk); + } + ]]></body> + </method> + + <method name="unselectOccurrence"> + <parameter name="aOccurrence"/> + <body><![CDATA[ + if (aOccurrence) { + delete this.mSelectedItemIds[aOccurrence.hashId]; + let chunk = this.findChunkForOccurrence(aOccurrence); + if (!chunk) { + return; + } + chunk.selected = false; + let index = this.mSelectedChunks.indexOf(chunk); + this.mSelectedChunks.splice(index, 1); + } + ]]></body> + </method> + + <method name="findChunkForOccurrence"> + <parameter name="aOccurrence"/> + <body><![CDATA[ + for (let chunk of this.mEventBoxes) { + if (chunk.occurrence.hashId == aOccurrence.hashId) { + return chunk; + } + } + + return null; + ]]></body> + </method> + + <method name="startLayoutBatchChange"> + <body><![CDATA[ + this.mLayoutBatchCount++; + ]]></body> + </method> + <method name="endLayoutBatchChange"> + <body><![CDATA[ + this.mLayoutBatchCount--; + if (this.mLayoutBatchCount == 0) { + this.relayout(); + } + ]]></body> + </method> + + <method name="setAttribute"> + <parameter name="aAttr"/> + <parameter name="aVal"/> + <body><![CDATA[ + // this should be done using lookupMethod(), see bug 286629 + let ret = XULElement.prototype.setAttribute.call(this, aAttr, aVal); + + if (aAttr == "orient" && this.getAttribute("orient") != aVal) { + this.relayout(); + } + + return ret; + ]]></body> + </method> + + <method name="internalDeleteEvent"> + <parameter name="aOccurrence"/> + <body><![CDATA[ + let itemIndex = -1; + let occ; + for (let i in this.mEventInfos) { + occ = this.mEventInfos[i].event; + if (occ.hashId == aOccurrence.hashId) { + itemIndex = i; + break; + } + } + + if (itemIndex == -1) { + return false; + } else { + delete this.mSelectedItemIds[occ.hashId]; + this.mSelectedChunks = this.mSelectedChunks.filter((item) => { + return !item.occurrence || (item.occurrence.hashId != aOccurrence.hashId); + }); + this.mEventInfos.splice(itemIndex, 1); + return true; + } + ]]></body> + </method> + + <method name="recalculateStartEndMinutes"> + <body><![CDATA[ + for (let chunk of this.mEventInfos) { + let mins = this.getStartEndMinutesForOccurrence(chunk.event); + chunk.startMinute = mins.start; + chunk.endMinute = mins.end; + } + + this.relayout(); + ]]></body> + </method> + + <!-- This function returns the start and end minutes of the occurrence + part in the day of this column, moreover, the real start and end + minutes of the whole occurrence (which could span multiple days) + relative to the time 0:00 of the day in this column --> + <method name="getStartEndMinutesForOccurrence"> + <parameter name="aOccurrence"/> + <body><![CDATA[ + let stdate = aOccurrence.startDate || aOccurrence.entryDate || aOccurrence.dueDate; + let enddate = aOccurrence.endDate || aOccurrence.dueDate || aOccurrence.entryDate; + + if (!compareObjects(stdate.timezone, this.mTimezone)) { + stdate = stdate.getInTimezone(this.mTimezone); + } + + if (!compareObjects(enddate.timezone, this.mTimezone)) { + enddate = enddate.getInTimezone(this.mTimezone); + } + + let startHour = stdate.hour; + let startMinute = stdate.minute; + let endHour = enddate.hour; + let endMinute = enddate.minute; + + // Handle cases where an event begins or ends on a day other than this + if (stdate.compare(this.mDate) == -1) { + startHour = 0; + startMinute = 0; + } + if (enddate.compare(this.mDate) == 1) { + endHour = 24; + endMinute = 0; + } + + // For occurrences that span multiple days, we figure out the real + // occurrence start and end minutes relative to the date of this + // column and time 0:00 + let durend = enddate.subtractDate(this.mDate); + let durstart = stdate.subtractDate(this.mDate); + // 'durend' is always positive, instead 'durstart' might be negative + // if the event starts one or more days before the date of this column + let realStart_ = (durstart.days * 24 + durstart.hours) * 60 + durstart.minutes; + realStart_ = durstart.isNegative ? -1 * realStart_ : realStart_; + let realEnd_ = (durend.days * 24 + durend.hours) * 60 + durend.minutes; + + return { + start: startHour * 60 + startMinute, + end: endHour * 60 + endMinute, + realStart: realStart_, + realEnd: realEnd_ + }; + ]]></body> + </method> + + <method name="createChunk"> + <parameter name="aOccurrence"/> + <body><![CDATA[ + let mins = this.getStartEndMinutesForOccurrence(aOccurrence); + + let chunk = { + startMinute: mins.start, + endMinute: mins.end, + event: aOccurrence + }; + return chunk; + ]]></body> + </method> + + <method name="addEvent"> + <parameter name="aOccurrence"/> + <body><![CDATA[ + this.internalDeleteEvent(aOccurrence); + + let chunk = this.createChunk(aOccurrence); + this.mEventInfos.push(chunk); + if (this.mEventMapTimeout) { + clearTimeout(this.mEventMapTimeout); + } + + if (this.mCreatedNewEvent) { + this.mEventToEdit = aOccurrence; + } + + this.mEventMapTimeout = setTimeout(() => this.relayout(), 5); + ]]></body> + </method> + + <method name="deleteEvent"> + <parameter name="aOccurrence"/> + <body><![CDATA[ + if (this.internalDeleteEvent(aOccurrence)) { + this.relayout(); + } + ]]></body> + </method> + + <method name="clear"> + <body><![CDATA[ + while (this.bgbox && this.bgbox.hasChildNodes()) { + this.bgbox.lastChild.remove(); + } + while (this.topbox && this.topbox.hasChildNodes()) { + this.topbox.lastChild.remove(); + } + for (let handler of this.mHandlersToRemove) { + this.calendarView.viewBroadcaster.removeEventListener(this.calendarView.getAttribute("type") + "viewresized", handler, true); + } + this.mHandlersToRemove = []; + this.mSelectedChunks = []; + ]]></body> + </method> + + <method name="relayout"> + <body><![CDATA[ + if (this.mLayoutBatchCount > 0) { + return; + } + this.clear(); + + let orient = this.getAttribute("orient"); + this.bgbox.setAttribute("orient", orient); + + // bgbox is used mainly for drawing the grid. at some point it may + // also be used for all-day events. + let otherorient = getOtherOrientation(orient); + let configBox = document.getAnonymousElementByAttribute(this, "anonid", "config-box"); + configBox.removeAttribute("hidden"); + let minSize = configBox.getOptimalMinSize(); + configBox.setAttribute("hidden", "true"); + this.mMinDuration = Components.classes["@mozilla.org/calendar/duration;1"] + .createInstance(Components.interfaces.calIDuration); + this.mMinDuration.minutes = Math.trunc(minSize / this.mPixPerMin); + + let theMin = this.mStartMin; + while (theMin < this.mEndMin) { + let dur = theMin % 60; + theMin += dur; + if (dur == 0) { + dur = 60; + } + + let box = createXULElement("spacer"); + // we key off this in a CSS selector + box.setAttribute("orient", orient); + box.setAttribute("class", "calendar-event-column-linebox"); + + if (this.mSelected) { + box.setAttribute("selected", "true"); + } + if (this.mDayOff) { + box.setAttribute("weekend", "true"); + } + if (theMin < this.mDayStartMin || theMin >= this.mDayEndMin) { + box.setAttribute("off-time", "true"); + } + + // Carry forth the day relation + box.setAttribute("relation", this.getAttribute("relation")); + + // calculate duration pixel as the difference between + // start pixel and end pixel to avoid rounding errors. + let startPix = Math.round(theMin * this.mPixPerMin); + let endPix = Math.round((theMin + dur) * this.mPixPerMin); + let durPix = endPix - startPix; + if (orient == "vertical") { + box.setAttribute("height", durPix); + } else { + box.setAttribute("width", durPix); + } + + box.setAttribute("style", "min-width: 1px; min-height: 1px;"); + + this.bgbox.appendChild(box); + theMin += 60; + } + + // fgbox is used for dragging events + this.fgboxes.box.setAttribute("orient", orient); + document.getAnonymousElementByAttribute(this, "anonid", "fgdragspacer").setAttribute("orient", orient); + + // this one is set to otherorient, since it will contain + // child boxes set to "orient" (one for each set of + // overlapping event areas) + this.topbox.setAttribute("orient", otherorient); + + this.mEventMap = this.computeEventMap(); + this.mEventBoxes = []; + + if (!this.mEventMap.length) { + return; + } + + // First of all we create a xul:stack which + // will hold all events for this event column. + // The stack will be grouped below .../calendar-event-column/stack/topbox. + let stack = createXULElement("stack"); + stack.setAttribute("flex", "1"); + this.topbox.appendChild(stack); + + let boxToEdit; + let columnCount = 1; + let spanTotal = 0; + + for (let layer of this.mEventMap) { + // The event-map (this.mEventMap) contains an array of layers. + // For each layer we create a box below the stack just created above. + // So each different layer lives in a box that's contained in the stack. + let xulColumn = createXULElement("box"); + xulColumn.setAttribute("orient", otherorient); + xulColumn.setAttribute("flex", "1"); + xulColumn.setAttribute("style", "min-width: 1px; min-height: 1px;"); + stack.appendChild(xulColumn); + + let numBlocksInserted = 0; + + // column count determined by layer with no special span columns + if (layer.every(e => !e.specialSpan)) { + columnCount = layer.length; + } + spanTotal = 0; + + // Each layer contains a list of the columns that + // need to be created for a span. + for (let column of layer) { + let innerColumn = createXULElement("box"); + innerColumn.setAttribute("orient", orient); + + let colFlex = column.specialSpan ? columnCount * column.specialSpan : 1; + innerColumn.setAttribute("flex", colFlex); + spanTotal += colFlex; + + innerColumn.style.minWidth = "1px"; + innerColumn.style.minHeight = "1px"; + innerColumn.style.width = colFlex + "px"; + innerColumn.style.height = colFlex + "px"; + + xulColumn.appendChild(innerColumn); + let duration; + for (let chunk of column) { + duration = chunk.duration; + if (!duration) { + continue; + } + + if (chunk.event) { + let chunkBox = createXULElement("calendar-event-box"); + let durMinutes = duration.inSeconds / 60; + let size = Math.max(durMinutes * this.mPixPerMin, minSize); + if (orient == "vertical") { + chunkBox.setAttribute("height", size); + } else { + chunkBox.setAttribute("width", size); + } + chunkBox.setAttribute("context", + this.getAttribute("item-context") || + this.getAttribute("context")); + chunkBox.setAttribute("orient", orient); + + // Set the gripBars visibility in the chunk. Keep it + // hidden for tasks with only entry date OR due date. + if ((chunk.event.entryDate || !chunk.event.dueDate) && + (!chunk.event.entryDate || chunk.event.dueDate)) { + let startGripVisible = (chunk.event.startDate || chunk.event.entryDate) + .compare(chunk.startDate) == 0; + let endGripVisible = (chunk.event.endDate || chunk.event.dueDate) + .compare(chunk.endDate) <= 0; + if (startGripVisible && endGripVisible) { + chunkBox.setAttribute("gripBars", "both"); + } else if (endGripVisible) { + chunkBox.setAttribute("gripBars", "end"); + } else if (startGripVisible) { + chunkBox.setAttribute("gripBars", "start"); + } + } + + innerColumn.appendChild(chunkBox); + chunkBox.calendarView = this.calendarView; + chunkBox.occurrence = chunk.event; + chunkBox.parentColumn = this; + if (chunk.event.hashId in this.mSelectedItemIds) { + chunkBox.selected = true; + this.mSelectedChunks.push(chunkBox); + } + + this.mEventBoxes.push(chunkBox); + + if (this.mEventToEdit && + chunkBox.occurrence.hashId == this.mEventToEdit.hashId) { + boxToEdit = chunkBox; + } + } else { + let chunkBox = createXULElement("spacer"); + chunkBox.setAttribute("context", this.getAttribute("context")); + chunkBox.setAttribute("style", "min-width: 1px; min-height: 1px;"); + chunkBox.setAttribute("orient", orient); + chunkBox.setAttribute("class", "calendar-empty-space-box"); + innerColumn.appendChild(chunkBox); + + let durMinutes = duration.inSeconds / 60; + if (orient == "vertical") { + chunkBox.setAttribute("height", durMinutes * this.mPixPerMin); + } else { + chunkBox.setAttribute("width", durMinutes * this.mPixPerMin); + } + } + } + + numBlocksInserted++; + } + + // add last empty column if necessary + if (spanTotal < columnCount) { + let lastColumn = createXULElement("box"); + lastColumn.setAttribute("orient", orient); + lastColumn.setAttribute("flex", columnCount - spanTotal); + lastColumn.style.minWidth = "1px"; + lastColumn.style.minHeight = "1px"; + lastColumn.style.width = (columnCount - spanTotal) + "px"; + lastColumn.style.height = (columnCount - spanTotal) + "px"; + + xulColumn.appendChild(lastColumn); + } + + if (boxToEdit) { + this.mCreatedNewEvent = false; + this.mEventToEdit = null; + boxToEdit.startEditing(); + } + + if (numBlocksInserted == 0) { + // if we didn't insert any blocks, then + // forget about this column + xulColumn.remove(); + } + } + ]]></body> + </method> + + <method name="computeEventMap"> + <body><![CDATA[ + /* We're going to create a series of 'blobs'. A blob is a series of + * events that create a continuous block of busy time. In other + * words, a blob ends when there is some time such that no events + * occupy that time. + * + * Each blob will be an array of objects with the following properties: + * item: the event/task + * startCol: the starting column to display the event in (0-indexed) + * colSpan: the number of columns the item spans + * + * An item with no conflicts will have startCol: 0 and colSpan: 1. + */ + let blobs = []; + let currentBlob = []; + function sortByStart(aEventInfo, bEventInfo) { + // If you pass in tasks without both entry and due dates, I will + // kill you + let startComparison = aEventInfo.layoutStart.compare(bEventInfo.layoutStart); + if (startComparison == 0) { + // If the items start at the same time, return the longer one + // first + return bEventInfo.layoutEnd.compare(aEventInfo.layoutEnd); + } else { + return startComparison; + } + } + this.mEventInfos.forEach((aEventInfo) => { + let item = aEventInfo.event.clone(); + let start = item.startDate || item.entryDate || item.dueDate; + start = start.getInTimezone(this.mTimezone); + aEventInfo.layoutStart = start; + let end = item.endDate || item.dueDate || item.entryDate; + end = end.getInTimezone(this.mTimezone); + let secEnd = start.clone(); + secEnd.addDuration(this.mMinDuration); + if (secEnd.nativeTime > end.nativeTime) { + aEventInfo.layoutEnd = secEnd; + } else { + aEventInfo.layoutEnd = end; + } + return aEventInfo; + }); + this.mEventInfos.sort(sortByStart); + + // The end time of the last ending event in the entire blob + let latestItemEnd; + + // This array keeps track of the last (latest ending) item in each of + // the columns of the current blob. We could reconstruct this data at + // any time by looking at the items in the blob, but that would hurt + // perf. + let colEndArray = []; + + /* Go through a 3 step process to try and place each item. + * Step 1: Look for an existing column with room for the item. + * Step 2: Look for a previously placed item that can be shrunk in + * width to make room for the item. + * Step 3: Give up and create a new column for the item. + * + * (The steps are explained in more detail as we come to them) + */ + for (let i in this.mEventInfos) { + let curItemInfo = { + event: this.mEventInfos[i].event, + layoutStart: this.mEventInfos[i].layoutStart, + layoutEnd: this.mEventInfos[i].layoutEnd + }; + if (!latestItemEnd) { + latestItemEnd = curItemInfo.layoutEnd; + } + if (currentBlob.length && latestItemEnd && + curItemInfo.layoutStart.compare(latestItemEnd) != -1) { + // We're done with this current blob because item starts + // after the last event in the current blob ended. + blobs.push({ blob: currentBlob, totalCols: colEndArray.length }); + + // Reset our variables + currentBlob = []; + colEndArray = []; + } + + // Place the item in its correct place in the blob + let placedItem = false; + + // Step 1 + // Look for a possible column in the blob that has been left open. This + // would happen if we already have multiple columns but some of + // the cols have events before latestItemEnd. For instance + // | | | + // |______| | + // |ev1 |______| + // | |ev2 | + // |______| | + // | | | + // |OPEN! | |<--Our item's start time might be here + // | |______| + // | | | + // + // Remember that any time we're starting a new blob, colEndArray + // will be empty, but that's ok. + for (let j = 0; j < colEndArray.length; ++j) { + let colEnd = colEndArray[j].layoutEnd; + if (colEnd.compare(curItemInfo.layoutStart) != 1) { + // Yay, we can jump into this column + colEndArray[j] = curItemInfo; + + // Check and see if there are any adjacent columns we can + // jump into as well. + let lastCol = Number(j) + 1; + while (lastCol < colEndArray.length) { + let nextColEnd = colEndArray[lastCol].layoutEnd; + // If the next column's item ends after we start, we + // can't expand any further + if (nextColEnd.compare(curItemInfo.layoutStart) == 1) { + break; + } + colEndArray[lastCol] = curItemInfo; + lastCol++; + } + // Now construct the info we need to push into the blob + currentBlob.push({ + itemInfo: curItemInfo, + startCol: j, + colSpan: lastCol - j + }); + + // Update latestItemEnd + if (latestItemEnd && + curItemInfo.layoutEnd.compare(latestItemEnd) == 1) { + latestItemEnd = curItemInfo.layoutEnd; + } + placedItem = true; + break; // Stop iterating through colEndArray + } + } + + if (placedItem) { + // Go get the next item + continue; + } + + // Step 2 + // OK, all columns (if there are any) overlap us. Look if the + // last item in any of the last items in those columns is taking + // up 2 or more cols. If so, shrink it and stick the item in the + // created space. For instance + // |______|______|______| + // |ev1 |ev3 |ev4 | + // | | | | + // | |______| | + // | | |______| + // | |_____________| + // | |ev2 | + // |______| |<--If our item's start time is + // | |_____________| here, we can shrink ev2 and jump + // | | | | in column #3 + // + for (let j = 1; j < colEndArray.length; ++j) { + if (colEndArray[j].event.hashId == colEndArray[j - 1].event.hashId) { + // Good we found a item that spanned multiple columns. + // Find it in the blob so we can modify its properties + for (let blobKey in currentBlob) { + if (currentBlob[blobKey].itemInfo.event.hashId == colEndArray[j].event.hashId) { + // Take all but the first spot that the item spanned + let spanOfShrunkItem = currentBlob[blobKey].colSpan; + currentBlob.push({ + itemInfo: curItemInfo, + startCol: Number(currentBlob[blobKey].startCol) + 1, + colSpan: spanOfShrunkItem - 1 + }); + + // Update colEndArray + for (let k = j; k < j + spanOfShrunkItem - 1; k++) { + colEndArray[k] = curItemInfo; + } + + // Modify the data on the old item + currentBlob[blobKey] = { + itemInfo: currentBlob[blobKey].itemInfo, + startCol: currentBlob[blobKey].startCol, + colSpan: 1 + }; + // Update latestItemEnd + if (latestItemEnd && + curItemInfo.layoutEnd.compare(latestItemEnd) == 1) { + latestItemEnd = curItemInfo.layoutEnd; + } + break; // Stop iterating through currentBlob + } + } + placedItem = true; + break; // Stop iterating through colEndArray + } + } + + if (placedItem) { + // Go get the next item + continue; + } + + // Step 3 + // Guess what? We still haven't placed the item. We need to + // create a new column for it. + + // All the items in the last column, except for the one* that + // conflicts with the item we're trying to place, need to have + // their span extended by 1, since we're adding the new column + // + // * Note that there can only be one, because we sorted our + // events by start time, so this event must start later than + // the start of any possible conflicts. + let lastColNum = colEndArray.length; + for (let blobKey in currentBlob) { + let blobKeyEnd = currentBlob[blobKey].itemInfo.layoutEnd; + if (currentBlob[blobKey].startCol + currentBlob[blobKey].colSpan == lastColNum && + blobKeyEnd.compare(curItemInfo.layoutStart) != 1) { + currentBlob[blobKey] = { + itemInfo: currentBlob[blobKey].itemInfo, + startCol: currentBlob[blobKey].startCol, + colSpan: currentBlob[blobKey].colSpan + 1 + }; + } + } + currentBlob.push({ + itemInfo: curItemInfo, + startCol: colEndArray.length, + colSpan: 1 + }); + colEndArray.push(curItemInfo); + + // Update latestItemEnd + if (latestItemEnd && curItemInfo.layoutEnd.compare(latestItemEnd) == 1) { + latestItemEnd = curItemInfo.layoutEnd; + } + // Go get the next item + } + // Add the last blob + blobs.push({ + blob: currentBlob, + totalCols: colEndArray.length + }); + return this.setupBoxStructure(blobs); + ]]></body> + </method> + + <method name="setupBoxStructure"> + <parameter name="aBlobs"/> + <body><![CDATA[ + // This is actually going to end up being a 3-d array + // 1st dimension: "layers", sets of columns of events that all + // should have equal width* + // 2nd dimension: "columns", individual columns of non-conflicting + // items + // 3rd dimension: "chunks", individual items or placeholders for + // the blank time in between them + // + // * Note that 'equal width' isn't strictly correct. If we're + // oriented differently, it will be height (and we'll have rows + // not columns). What's more, in the 'specialSpan' case, the + // columns won't actually have the same size, but will only all + // be multiples of a common size. See the note in the relayout + // function for more info on this (fairly rare) case. + let layers = []; + + // When we start a new blob, move to a new set of layers + let layerOffset = 0; + for (let glob of aBlobs) { + let layerArray = []; + let layerCounter = 1; + + for (let data of glob.blob) { + // from the item at hand we need to figure out on which + // layer and on which column it should go. + let layerIndex; + let specialSpan = null; + + // each blob receives its own layer, that's the first part of the story. within + // a given blob we need to distribute the items on different layers depending on + // the number of columns each item spans. if each item just spans a single column + // the blob will cover *one* layer. if the blob contains items that span more than + // a single column, this blob will cover more than one layer. the algorithm places + // the items on the first layer in the case an item covers a single column. new layers + // are introduced based on the start column and number of spanning columns of an item. + if (data.colSpan == 1) { + layerIndex = 0; + } else { + let index = glob.totalCols * data.colSpan + data.startCol; + layerIndex = layerArray[index]; + if (!layerIndex) { + layerIndex = layerCounter++; + layerArray[index] = layerIndex; + } + let offset = (glob.totalCols - data.colSpan) % glob.totalCols; + if (offset != 0) { + specialSpan = data.colSpan / glob.totalCols; + } + } + layerIndex += layerOffset; + + // Make sure there's room to insert stuff + while (layerIndex >= layers.length) { + layers.push([]); + } + + while (data.startCol >= layers[layerIndex].length) { + layers[layerIndex].push([]); + if (specialSpan) { + layers[layerIndex][layers[layerIndex].length - 1].specialSpan = 1 / glob.totalCols; + } + } + + // we now retrieve the column from 'layerIndex' and 'startCol'. + let col = layers[layerIndex][data.startCol]; + if (specialSpan) { + col.specialSpan = specialSpan; + } + + // take into account that items can span several days. + // that's why i'm clipping the start- and end-time to the + // timespan of this column. + let start = data.itemInfo.layoutStart; + let end = data.itemInfo.layoutEnd; + if (start.year != this.date.year || + start.month != this.date.month || + start.day != this.date.day) { + start = start.clone(); + start.resetTo(this.date.year, + this.date.month, + this.date.day, + 0, this.mStartMin, 0, + start.timezone); + } + if (end.year != this.date.year || + end.month != this.date.month || + end.day != this.date.day) { + end = end.clone(); + end.resetTo(this.date.year, + this.date.month, + this.date.day, + 0, this.mEndMin, 0, + end.timezone); + } + let prevEnd; + if (col.length > 0) { + // Fill in time gaps with a placeholder + prevEnd = col[col.length - 1].endDate.clone(); + } else { + // First event in the column, add a placeholder for the + // blank time from this.mStartMin to the event's start + prevEnd = start.clone(); + prevEnd.hour = 0; + prevEnd.minute = this.mStartMin; + } + prevEnd.timezone = floating(); + // the reason why we need to calculate time durations + // based on floating timezones is that we need avoid + // dst gaps in this case. converting the date/times to + // floating conveys this idea in a natural way. note that + // we explicitly don't use getInTimezone() as it would + // be slightly more expensive in terms of performance. + let floatstart = start.clone(); + floatstart.timezone = floating(); + let dur = floatstart.subtractDate(prevEnd); + if (dur.inSeconds) { + col.push({ duration: dur }); + } + let floatend = end.clone(); + floatend.timezone = floating(); + col.push({ + event: data.itemInfo.event, + endDate: end, + startDate: start, + duration: floatend.subtractDate(floatstart) + }); + } + layerOffset = layers.length; + } + return layers; + ]]></body> + </method> + + <method name="getShadowElements"> + <parameter name="aStart"/> + <parameter name="aEnd"/> + <body><![CDATA[ + // aStart and aEnd are start and end minutes of the occurrence + // from time 0:00 of the dragging column + let shadows = 1; + let offset = 0; + let startMin; + if (aStart < 0) { + shadows += Math.ceil(Math.abs(aStart) / this.mEndMin); + offset = shadows - 1; + let reminder = Math.abs(aStart) % this.mEndMin; + startMin = this.mEndMin - (reminder ? reminder : this.mEndMin); + } else { + startMin = aStart; + } + shadows += Math.floor(aEnd / this.mEndMin); + + // return values needed to build the shadows while dragging + return { + shadows: shadows, // number of shadows + offset: offset, // Offset first<->selected shadows + startMin: startMin, // First shadow start minute + endMin: aEnd % this.mEndMin // Last shadow end minute + }; + ]]></body> + </method> + + <method name="firstLastShadowColumns"> + <parameter name="aOffset"/> + <parameter name="aShadows"/> + <body><![CDATA[ + let firstCol = this; // eslint-disable-line consistent-this + let lastCol = this; // eslint-disable-line consistent-this + let firstIndex = aOffset == null ? this.mDragState.offset : aOffset; + let lastIndex = firstIndex; + while (firstCol.previousSibling && firstIndex > 0) { + firstCol = firstCol.previousSibling; + firstIndex--; + } + let lastShadow = aShadows == null ? this.mDragState.shadows : aShadows; + while (lastCol.nextSibling && lastIndex < lastShadow - 1) { + lastCol = lastCol.nextSibling; + lastIndex++; + } + + // returns first and last column with shadows that are visible in the + // week and the positions of these (visible) columns in the set of + // columns shadows of the occurrence + return { + firstCol: firstCol, + firstIndex: firstIndex, + lastCol: lastCol, + lastIndex: lastIndex + }; + ]]></body> + </method> + + <method name="updateShadowsBoxes"> + <parameter name="aStart"/> + <parameter name="aEnd"/> + <parameter name="aCurrentOffset"/> + <parameter name="aCurrentShadows"/> + <parameter name="aSizeattr"/> + <body><![CDATA[ + let lateralColumns = this.firstLastShadowColumns(aCurrentOffset, aCurrentShadows); + let firstCol = lateralColumns.firstCol; + let firstIndex = lateralColumns.firstIndex; + let lastCol = lateralColumns.lastCol; + let lastIndex = lateralColumns.lastIndex; + + // remove the first/last shadow when start/end time goes in the + // next/previous day. This happens when current offset is different + // from offset stored in mDragState + if (aCurrentOffset != null) { + if (this.mDragState.offset > aCurrentOffset && firstCol.previousSibling) { + firstCol.previousSibling.fgboxes.dragbox.removeAttribute("dragging"); + firstCol.previousSibling.fgboxes.box.removeAttribute("dragging"); + } + let currentOffsetEndSide = aCurrentShadows - 1 - aCurrentOffset; + if ((this.mDragState.shadows - 1 - this.mDragState.offset) > currentOffsetEndSide && + lastCol.nextSibling) { + lastCol.nextSibling.fgboxes.dragbox.removeAttribute("dragging"); + lastCol.nextSibling.fgboxes.box.removeAttribute("dragging"); + } + } + + // set shadow boxes size for every part of the occurrence + let firstShadowSize = (aCurrentShadows == 1 ? aEnd : this.mEndMin) - aStart; + let column = firstCol; + for (let i = firstIndex; column && i <= lastIndex; i++) { + column.fgboxes.box.setAttribute("dragging", "true"); + column.fgboxes.dragbox.setAttribute("dragging", "true"); + if (i == 0) { + // first shadow + column.fgboxes.dragspacer.setAttribute(aSizeattr, aStart * column.mPixPerMin); + column.fgboxes.dragbox.setAttribute(aSizeattr, firstShadowSize * column.mPixPerMin); + } else if (i == (aCurrentShadows - 1)) { + // last shadow + column.fgboxes.dragspacer.setAttribute(aSizeattr, 0); + column.fgboxes.dragbox.setAttribute(aSizeattr, aEnd * column.mPixPerMin); + } else { + // an intermediate shadow (full day) + column.fgboxes.dragspacer.setAttribute(aSizeattr, 0); + column.fgboxes.dragbox.setAttribute(aSizeattr, this.mEndMin * column.mPixPerMin); + } + column = column.nextSibling; + } + ]]></body> + </method> + + <method name="onEventSweepKeypress"> + <parameter name="event"/> + <body><![CDATA[ + let col = document.calendarEventColumnDragging; + if (col && event.keyCode == event.DOM_VK_ESCAPE) { + window.removeEventListener("mousemove", col.onEventSweepMouseMove, false); + window.removeEventListener("mouseup", col.onEventSweepMouseUp, false); + window.removeEventListener("keypress", col.onEventSweepKeypress, false); + + let lateralColumns = col.firstLastShadowColumns(); + let column = lateralColumns.firstCol; + let index = lateralColumns.firstIndex; + while (column && index < col.mDragState.shadows) { + column.fgboxes.dragbox.removeAttribute("dragging"); + column.fgboxes.box.removeAttribute("dragging"); + column = column.nextSibling; + index++; + } + + col.mDragState = null; + document.calendarEventColumnDragging = null; + } + ]]></body> + </method> + + <method name="clearMagicScroll"> + <body><![CDATA[ + if (this.mMagicScrollTimer) { + clearTimeout(this.mMagicScrollTimer); + this.mMagicScrollTimer = null; + } + ]]></body> + </method> + + <method name="setupMagicScroll"> + <parameter name="event"/> + <body><![CDATA[ + this.clearMagicScroll(); + + // If we are at the bottom or top of the view (or left/right when + // rotated), calculate the difference and start accelerating the + // scrollbar. + let diffStart, diffEnd; + let orient = event.target.getAttribute("orient"); + let scrollbox = document.getAnonymousElementByAttribute( + event.target, "anonid", "scrollbox"); + if (orient == "vertical") { + diffStart = event.clientY - scrollbox.boxObject.y; + diffEnd = scrollbox.boxObject.y + scrollbox.boxObject.height - event.clientY; + } else { + diffStart = event.clientX - scrollbox.boxObject.x; + diffEnd = scrollbox.boxObject.x + scrollbox.boxObject.width - event.clientX; + } + + const SCROLLZONE = 55; // Size (pixels) of the top/bottom view where the scroll starts. + const MAXTIMEOUT = 250; // Max and min time interval (ms) between + const MINTIMEOUT = 30; // two consecutive scrolls. + const SCROLLBYHOUR = 0.33; // Part of hour to move for each scroll. + let insideScrollZone = 0; + let pxPerHr = event.target.mPixPerMin * 60; + let scrollBy = Math.floor(pxPerHr * SCROLLBYHOUR); + if (diffStart < SCROLLZONE) { + insideScrollZone = SCROLLZONE - diffStart; + scrollBy *= -1; + } else if (diffEnd < SCROLLZONE) { + insideScrollZone = SCROLLZONE - diffEnd; + } + + if (insideScrollZone) { + let sbo = scrollbox.boxObject; + let timeout = MAXTIMEOUT - insideScrollZone * (MAXTIMEOUT - MINTIMEOUT) / SCROLLZONE; + this.mMagicScrollTimer = setTimeout(() => { + sbo.scrollBy(orient == "horizontal" && scrollBy, + orient == "vertical" && scrollBy); + this.onEventSweepMouseMove(event); + }, timeout); + } + ]]></body> + </method> + + <!-- + - Event sweep handlers + --> + <method name="onEventSweepMouseMove"> + <parameter name="event"/> + <body><![CDATA[ + let col = document.calendarEventColumnDragging; + if (!col) { + return; + } + + col.setupMagicScroll(event); + + let dragState = col.mDragState; + + let lateralColumns = col.firstLastShadowColumns(); + let firstCol = lateralColumns.firstCol; + let firstIndex = lateralColumns.firstIndex; + + // If we leave the view, then stop our internal sweeping and start a + // real drag session. Someday we need to fix the sweep to soely be a + // drag session, no sweeping. + if (event.clientX < (event.target.boxObject.x) || + event.clientX > (event.target.boxObject.x + event.target.boxObject.width) || + event.clientY < (event.target.boxObject.y) || + event.clientY > (event.target.boxObject.y + event.target.boxObject.height)) { + // Remove the drag state + for (let column = firstCol, i = firstIndex; + column && i < col.mDragState.shadows; + column = column.nextSibling, i++) { + column.fgboxes.dragbox.removeAttribute("dragging"); + column.fgboxes.box.removeAttribute("dragging"); + } + + window.removeEventListener("mousemove", col.onEventSweepMouseMove, false); + window.removeEventListener("mouseup", col.onEventSweepMouseUp, false); + window.removeEventListener("keypress", col.onEventSweepKeypress, false); + document.calendarEventColumnDragging = null; + col.mDragState = null; + + // the multiday view currently exhibits a less than optimal strategy + // in terms of item selection. items don't get automatically selected + // when clicked and dragged, as to differentiate inline editing from + // the act of selecting an event. but the application internal drop + // targets will ask for selected items in order to pull the data from + // the packets. that's why we need to make sure at least the currently + // dragged event is contained in the set of selected items. + let selectedItems = this.getSelectedItems({}); + if (!selectedItems.some(aItem => aItem.hashId == item.hashId)) { + col.calendarView.setSelectedItems(1, + [event.ctrlKey ? item.parentItem : item]); + } + invokeEventDragSession(dragState.dragOccurrence, col); + return; + } + + col.fgboxes.box.setAttribute("dragging", "true"); + col.fgboxes.dragbox.setAttribute("dragging", "true"); + let minutesInDay = col.mEndMin - col.mStartMin; + + // check if we need to jump a column + let jumpedColumns; + let newcol = col.calendarView.findColumnForClientPoint(event.screenX, event.screenY); + if (newcol && newcol != col) { + // Find how many columns we are jumping by subtracting the dates. + let dur = newcol.mDate.subtractDate(col.mDate); + jumpedColumns = dur.days; + jumpedColumns *= dur.isNegative ? -1 : 1; + if (dragState.dragType == "modify-start") { + // prevent dragging the start date after the end date in a new column + if ((dragState.limitEndMin - minutesInDay * jumpedColumns) < 0) { + return; + } + dragState.limitEndMin -= minutesInDay * jumpedColumns; + } else if (dragState.dragType == "modify-end") { + // prevent dragging the end date before the start date in a new column + if ((dragState.limitStartMin - minutesInDay * jumpedColumns) > minutesInDay) { + return; + } + dragState.limitStartMin -= minutesInDay * jumpedColumns; + } else if (dragState.dragType == "new") { + dragState.limitEndMin -= minutesInDay * jumpedColumns; + dragState.limitStartMin -= minutesInDay * jumpedColumns; + dragState.jumpedColumns += jumpedColumns; + } + // kill our drag state + for (let column = firstCol, i = firstIndex; + column && i < col.mDragState.shadows; + column = column.nextSibling, i++) { + column.fgboxes.dragbox.removeAttribute("dragging"); + column.fgboxes.box.removeAttribute("dragging"); + } + + // jump ship + newcol.acceptInProgressSweep(dragState); + + // restart event handling + col.onEventSweepMouseMove(event); + + return; + } + + let mousePos; + let sizeattr; + if (col.getAttribute("orient") == "vertical") { + mousePos = event.screenY - col.parentNode.boxObject.screenY; + sizeattr = "height"; + } else { + mousePos = event.screenX - col.parentNode.boxObject.screenX; + sizeattr = "width"; + } + // don't let mouse position go outside the window edges + let pos = Math.max(0, mousePos) - dragState.mouseOffset; + + // snap interval: 15 minutes or 1 minute if modifier key is pressed + let snapIntMin = (event.shiftKey && + !event.ctrlKey && + !event.altKey && + !event.metaKey) ? 1 : 15; + let interval = col.mPixPerMin * snapIntMin; + let curmin = Math.floor(pos / interval) * snapIntMin; + let deltamin = curmin - dragState.origMin; + + let shadowElements; + if (dragState.dragType == "new") { + // Extend deltamin in a linear way over the columns + deltamin += minutesInDay * dragState.jumpedColumns; + if (deltamin < 0) { + // create a new event modifying the start. End time is fixed + shadowElements = { + shadows: 1 - dragState.jumpedColumns, + offset: 0, + startMin: curmin, + endMin: dragState.origMin + }; + } else { + // create a new event modifying the end. Start time is fixed + shadowElements = { + shadows: dragState.jumpedColumns + 1, + offset: dragState.jumpedColumns, + startMin: dragState.origMin, + endMin: curmin + }; + } + dragState.startMin = shadowElements.startMin; + dragState.endMin = shadowElements.endMin; + } else if (dragState.dragType == "move") { + // if we're moving, we modify startMin and endMin of the shadow. + shadowElements = col.getShadowElements(dragState.origMinStart + deltamin, + dragState.origMinEnd + deltamin); + dragState.startMin = shadowElements.startMin; + dragState.endMin = shadowElements.endMin; + // Keep track of the last start position because it will help to + // build the event at the end of the drag session. + dragState.lastStart = dragState.origMinStart + deltamin; + } else if (dragState.dragType == "modify-start") { + // if we're modifying the start, the end time is fixed. + shadowElements = col.getShadowElements(dragState.origMin + deltamin, dragState.limitEndMin); + dragState.startMin = shadowElements.startMin; + dragState.endMin = shadowElements.endMin; + + // but we need to not go past the end; if we hit + // the end, then we'll clamp to the previous snap interval minute + if (dragState.startMin >= dragState.limitEndMin) { + dragState.startMin = Math.ceil((dragState.limitEndMin - snapIntMin) / snapIntMin) * snapIntMin; + } + } else if (dragState.dragType == "modify-end") { + // if we're modifying the end, the start time is fixed. + shadowElements = col.getShadowElements(dragState.limitStartMin, dragState.origMin + deltamin); + dragState.startMin = shadowElements.startMin; + dragState.endMin = shadowElements.endMin; + + // but we need to not go past the start; if we hit + // the start, then we'll clamp to the next snap interval minute + if (dragState.endMin <= dragState.limitStartMin) { + dragState.endMin = Math.floor((dragState.limitStartMin + snapIntMin) / snapIntMin) * snapIntMin; + } + } + let currentOffset = shadowElements.offset; + let currentShadows = shadowElements.shadows; + + // now we can update the shadow boxes position and size + col.updateShadowsBoxes(dragState.startMin, dragState.endMin, + currentOffset, currentShadows, + sizeattr); + + // update the labels + lateralColumns = col.firstLastShadowColumns(currentOffset, currentShadows); + col.updateDragLabels(lateralColumns.firstCol, lateralColumns.lastCol); + + col.mDragState.offset = currentOffset; + col.mDragState.shadows = currentShadows; + ]]></body> + </method> + + <method name="onEventSweepMouseUp"> + <parameter name="event"/> + <body><![CDATA[ + let col = document.calendarEventColumnDragging; + if (!col) { + return; + } + + let dragState = col.mDragState; + + let lateralColumns = col.firstLastShadowColumns(); + let column = lateralColumns.firstCol; + let index = lateralColumns.firstIndex; + while (column && index < dragState.shadows) { + column.fgboxes.dragbox.removeAttribute("dragging"); + column.fgboxes.box.removeAttribute("dragging"); + column = column.nextSibling; + index++; + } + + col.clearMagicScroll(); + + window.removeEventListener("mousemove", col.onEventSweepMouseMove, false); + window.removeEventListener("mouseup", col.onEventSweepMouseUp, false); + window.removeEventListener("keypress", col.onEventSweepKeypress, false); + + document.calendarEventColumnDragging = null; + + // if the user didn't sweep out at least a few pixels, ignore + // unless we're in a different column + if (dragState.origColumn == col) { + let ignore = false; + let orient = col.getAttribute("orient"); + let position = orient == "vertical" ? event.screenY : event.screenX; + if (Math.abs(position - dragState.origLoc) < 3) { + ignore = true; + } + + if (ignore) { + col.mDragState = null; + return; + } + } + + let newStart; + let newEnd; + let startTZ; + let endTZ; + let dragDay = col.mDate; + if (dragState.dragType != "new") { + let oldStart = dragState.dragOccurrence.startDate || + dragState.dragOccurrence.entryDate || + dragState.dragOccurrence.dueDate; + let oldEnd = dragState.dragOccurrence.endDate || + dragState.dragOccurrence.dueDate || + dragState.dragOccurrence.entryDate; + newStart = oldStart.clone(); + newEnd = oldEnd.clone(); + + // Our views are pegged to the default timezone. If the event + // isn't also in the timezone, we're going to need to do some + // tweaking. We could just do this for every event but + // getInTimezone is slow, so it's much better to only do this + // when the timezones actually differ from the view's. + if (col.mTimezone != newStart.timezone || + col.mTimezone != newEnd.timezone) { + startTZ = newStart.timezone; + endTZ = newEnd.timezone; + newStart = newStart.getInTimezone(col.calendarView.mTimezone); + newEnd = newEnd.getInTimezone(col.calendarView.mTimezone); + } + } + + if (dragState.dragType == "modify-start") { + newStart.resetTo(dragDay.year, dragDay.month, dragDay.day, + 0, dragState.startMin + col.mStartMin, 0, + newStart.timezone); + } else if (dragState.dragType == "modify-end") { + newEnd.resetTo(dragDay.year, dragDay.month, dragDay.day, + 0, dragState.endMin + col.mStartMin, 0, + newEnd.timezone); + } else if (dragState.dragType == "new") { + let startDay = dragState.origColumn.mDate; + let draggedForward = (dragDay.compare(startDay) > 0); + newStart = draggedForward ? startDay.clone() : dragDay.clone(); + newEnd = draggedForward ? dragDay.clone() : startDay.clone(); + newStart.isDate = false; + newEnd.isDate = false; + newStart.resetTo(newStart.year, newStart.month, newStart.day, + 0, dragState.startMin + col.mStartMin, 0, + newStart.timezone); + newEnd.resetTo(newEnd.year, newEnd.month, newEnd.day, + 0, dragState.endMin + col.mStartMin, 0, + newEnd.timezone); + + // Edit the event title on the first of the new event's occurrences + if (draggedForward) { + dragState.origColumn.mCreatedNewEvent = true; + } else { + col.mCreatedNewEvent = true; + } + } else if (dragState.dragType == "move") { + // Figure out the new date-times of the event by adding the duration + // of the total movement (days and minutes) to the old dates. + let duration = dragDay.subtractDate(dragState.origColumn.mDate); + let minutes = dragState.lastStart - dragState.realStart; + + // Since both boxDate and beginMove are dates (note datetimes), + // subtractDate will only give us a non-zero number of hours on + // DST changes. While strictly speaking, subtractDate's behavior + // is correct, we need to move the event a discrete number of + // days here. There is no need for normalization here, since + // addDuration does the job for us. Also note, the duration used + // here is only used to move over multiple days. Moving on the + // same day uses the minutes from the dragState. + if (duration.hours == 23) { + // entering DST + duration.hours++; + } else if (duration.hours == 1) { + // leaving DST + duration.hours--; + } + + if (duration.isNegative) { + // Adding negative minutes to a negative duration makes the + // duration more positive, but we want more negative, and + // vice versa. + minutes *= -1; + } + duration.minutes = minutes; + duration.normalize(); + + newStart.addDuration(duration); + newEnd.addDuration(duration); + } + + // If we tweaked tzs, put times back in their original ones + if (startTZ) { + newStart = newStart.getInTimezone(startTZ); + } + if (endTZ) { + newEnd = newEnd.getInTimezone(endTZ); + } + + if (dragState.dragType == "new") { + // We won't pass a calendar, since the display calendar is the + // composite anyway. createNewEvent() will use the selected + // calendar. + // TODO We might want to get rid of the extra displayCalendar + // member. + col.calendarView.controller.createNewEvent(null, + newStart, + newEnd); + } else if (dragState.dragType == "move" || + dragState.dragType == "modify-start" || + dragState.dragType == "modify-end") { + col.calendarView.controller.modifyOccurrence(dragState.dragOccurrence, + newStart, newEnd); + } + document.calendarEventColumnDragging = null; + col.mDragState = null; + ]]></body> + </method> + + <!-- This is called by an event box when a grippy on either side is dragged, + - or when the middle is pressed to drag the event to move it. We create + - the same type of view that we use to sweep out a new event, but we + - initialize it based on the event's values and what type of dragging + - we're doing. In addition, we constrain things like not being able to + - drag the end before the start and vice versa. + --> + <method name="startSweepingToModifyEvent"> + <parameter name="aEventBox"/> + <parameter name="aOccurrence"/> + <!-- "start", "end", "middle" --> + <parameter name="aGrabbedElement"/> + <!-- mouse screenX/screenY from the event --> + <parameter name="aMouseX"/> + <parameter name="aMouseY"/> + <parameter name="aSnapInt"/> + <body><![CDATA[ + if (!isCalendarWritable(aOccurrence.calendar) || + !userCanModifyItem(aOccurrence) || + (aOccurrence.calendar instanceof Components.interfaces.calISchedulingSupport && aOccurrence.calendar.isInvitation(aOccurrence)) || + aOccurrence.calendar.getProperty("capabilities.events.supported") === false) { + return; + } + + this.mDragState = { + origColumn: this, + dragOccurrence: aOccurrence, + mouseOffset: 0, + offset: null, + shadows: null, + limitStartMin: null, + lastStart: 0, + jumpedColumns: 0 + }; + + // snap interval: 15 minutes or 1 minute if modifier key is pressed + let snapIntMin = aSnapInt || 15; + let sizeattr; + if (this.getAttribute("orient") == "vertical") { + this.mDragState.origLoc = aMouseY; + sizeattr = "height"; + } else { + this.mDragState.origLoc = aMouseX; + sizeattr = "width"; + } + + let mins = this.getStartEndMinutesForOccurrence(aOccurrence); + + // these are only used to compute durations or to compute UI + // sizes, so offset by this.mStartMin for sanity here (at the + // expense of possible insanity later) + mins.start -= this.mStartMin; + mins.end -= this.mStartMin; + + if (aGrabbedElement == "start") { + this.mDragState.dragType = "modify-start"; + // we have to use "realEnd" as fixed end value + this.mDragState.limitEndMin = mins.realEnd; + + // snap start + this.mDragState.origMin = Math.floor(mins.start / snapIntMin) * snapIntMin; + + // show the shadows and drag labels when clicking on gripbars + let shadowElements = this.getShadowElements(this.mDragState.origMin, + this.mDragState.limitEndMin); + this.mDragState.startMin = shadowElements.startMin; + this.mDragState.endMin = shadowElements.endMin; + this.mDragState.shadows = shadowElements.shadows; + this.mDragState.offset = shadowElements.offset; + this.updateShadowsBoxes(this.mDragState.origMin, this.mDragState.endMin, + 0, this.mDragState.shadows, + sizeattr); + + // update drag labels + let lastCol = this.firstLastShadowColumns().lastCol; + this.updateDragLabels(this, lastCol); + } else if (aGrabbedElement == "end") { + this.mDragState.dragType = "modify-end"; + // we have to use "realStart" as fixed end value + this.mDragState.limitStartMin = mins.realStart; + + // snap end + this.mDragState.origMin = Math.floor(mins.end / snapIntMin) * snapIntMin; + + // show the shadows and drag labels when clicking on gripbars + let shadowElements = this.getShadowElements(this.mDragState.limitStartMin, + this.mDragState.origMin); + this.mDragState.startMin = shadowElements.startMin; + this.mDragState.endMin = shadowElements.endMin; + this.mDragState.shadows = shadowElements.shadows; + this.mDragState.offset = shadowElements.offset; + this.updateShadowsBoxes(this.mDragState.startMin, this.mDragState.endMin, + shadowElements.offset, this.mDragState.shadows, + sizeattr); + + // update drag labels + let firstCol = this.firstLastShadowColumns().firstCol; + this.updateDragLabels(firstCol, this); + } else if (aGrabbedElement == "middle") { + this.mDragState.dragType = "move"; + // in a move, origMin will be the start minute of the element where + // the drag occurs. Along with mouseOffset, it allows to track the + // shadow position. origMinStart and origMinEnd allow to figure out + // the real shadow size. + // We snap to the start and add the real duration to find the end + let limitDurationMin = mins.realEnd - mins.realStart; + this.mDragState.origMin = Math.floor(mins.start / snapIntMin) * snapIntMin; + this.mDragState.origMinStart = Math.floor(mins.realStart / snapIntMin) * snapIntMin; + this.mDragState.origMinEnd = this.mDragState.origMinStart + limitDurationMin; + // Keep also track of the real Start, it will be used at the end + // of the drag session to calculate the new start and end datetimes. + this.mDragState.realStart = mins.realStart; + + let shadowElements = this.getShadowElements(this.mDragState.origMinStart, + this.mDragState.origMinEnd); + this.mDragState.shadows = shadowElements.shadows; + this.mDragState.offset = shadowElements.offset; + // we need to set a mouse offset, since we're not dragging from + // one end of the element + if (aEventBox) { + if (this.getAttribute("orient") == "vertical") { + this.mDragState.mouseOffset = aMouseY - aEventBox.boxObject.screenY; + } else { + this.mDragState.mouseOffset = aMouseX - aEventBox.boxObject.screenX; + } + } + } else { + // Invalid grabbed element. + } + + document.calendarEventColumnDragging = this; + + window.addEventListener("mousemove", this.onEventSweepMouseMove, false); + window.addEventListener("mouseup", this.onEventSweepMouseUp, false); + window.addEventListener("keypress", this.onEventSweepKeypress, false); + ]]></body> + </method> + + <!-- called by sibling columns to tell us to take over the sweeping + - of an event. + --> + <method name="acceptInProgressSweep"> + <parameter name="aDragState"/> + <body><![CDATA[ + this.mDragState = aDragState; + document.calendarEventColumnDragging = this; + + this.fgboxes.box.setAttribute("dragging", "true"); + this.fgboxes.dragbox.setAttribute("dragging", "true"); + + // the same event handlers are still valid, + // because they use document.calendarEventColumnDragging. + // So we really don't have anything to do here. + ]]></body> + </method> + + <method name="updateDragLabels"> + <parameter name="aFirstColumn"/> + <parameter name="aLastColumn"/> + <body><![CDATA[ + if (!this.mDragState) { + return; + } + + let firstColumn = aFirstColumn || this; + let lastColumn = aLastColumn || this; + let realstartmin = this.mDragState.startMin + this.mStartMin; + let realendmin = this.mDragState.endMin + this.mStartMin; + let starthr = Math.floor(realstartmin / 60); + let startmin = realstartmin % 60; + + let endhr = Math.floor(realendmin / 60); + let endmin = realendmin % 60; + + let formatter = Components.classes["@mozilla.org/intl/scriptabledateformat;1"] + .getService(Components.interfaces.nsIScriptableDateFormat); + let startstr = formatter.FormatTime("", + Components.interfaces.nsIScriptableDateFormat.timeFormatNoSeconds, + starthr, startmin, 0); + let endstr = formatter.FormatTime("", + Components.interfaces.nsIScriptableDateFormat.timeFormatNoSeconds, + endhr, endmin, 0); + + // Tasks without Entry or Due date have a string as first label + // instead of the time. + if (cal.isToDo(this.mDragState.dragOccurrence)) { + if (!this.mDragState.dragOccurrence.dueDate) { + startstr = calGetString("calendar", "dragLabelTasksWithOnlyEntryDate"); + } else if (!this.mDragState.dragOccurrence.entryDate) { + startstr = calGetString("calendar", "dragLabelTasksWithOnlyDueDate"); + } + } + firstColumn.fgboxes.startlabel.setAttribute("value", startstr); + lastColumn.fgboxes.endlabel.setAttribute("value", endstr); + + ]]></body> + </method> + + <method name="setDayStartEndMinutes"> + <parameter name="aDayStartMin"/> + <parameter name="aDayEndMin"/> + <body><![CDATA[ + if (aDayStartMin < this.mStartMin || aDayStartMin > aDayEndMin || + aDayEndMin > this.mEndMin) { + throw Components.results.NS_ERROR_INVALID_ARG; + } + if (this.mDayStartMin != aDayStartMin || this.mDayEndMin != aDayEndMin) { + this.mDayStartMin = aDayStartMin; + this.mDayEndMin = aDayEndMin; + } + ]]></body> + </method> + + <method name="getClickedDateTime"> + <parameter name="event"/> + <body><![CDATA[ + let newStart = this.date.clone(); + newStart.isDate = false; + newStart.hour = 0; + + const ROUND_INTERVAL = 15; + + let interval = this.mPixPerMin * ROUND_INTERVAL; + let pos; + if (this.getAttribute("orient") == "vertical") { + pos = event.screenY - this.parentNode.boxObject.screenY; + } else { + pos = event.screenX - this.parentNode.boxObject.screenX; + } + newStart.minute = (Math.round(pos / interval) * ROUND_INTERVAL) + this.mStartMin; + event.stopPropagation(); + return newStart; + ]]></body> + </method> + </implementation> + + <handlers> + <handler event="dblclick" button="0"><![CDATA[ + if (this.calendarView.controller) { + let newStart = getClickedDateTime(event); + this.calendarView.controller.createNewEvent(null, newStart, null); + } + ]]></handler> + + <handler event="click" button="0"><![CDATA[ + if (!(event.ctrlKey || event.metaKey)) { + this.calendarView.setSelectedItems(0, []); + this.focus(); + } + ]]></handler> + + <handler event="click" button="2"><![CDATA[ + let newStart = getClickedDateTime(event); + this.calendarView.selectedDateTime = newStart; + ]]></handler> + + <!-- mouse down handler, in empty event column regions. Starts sweeping out a new + - event. + --> + <handler event="mousedown"><![CDATA[ + // select this column + this.calendarView.selectedDay = this.mDate; + + // If the selected calendar is readOnly, we don't want any sweeping. + let calendar = getSelectedCalendar(); + if (!isCalendarWritable(calendar) || + calendar.getProperty("capabilities.events.supported") === false) { + return; + } + + // Only start sweeping out an event if the left button was clicked + if (event.button != 0) { + return; + } + + this.mDragState = { + origColumn: this, + dragType: "new", + mouseOffset: 0, + offset: null, + shadows: null, + limitStartMin: null, + limitEndMin: null, + jumpedColumns: 0 + }; + + // snap interval: 15 minutes or 1 minute if modifier key is pressed + let snapIntMin = (event.shiftKey && + !event.ctrlKey && + !event.altKey && + !event.metaKey) ? 1 : 15; + let interval = this.mPixPerMin * snapIntMin; + + if (this.getAttribute("orient") == "vertical") { + this.mDragState.origLoc = event.screenY; + this.mDragState.origMin = Math.floor((event.screenY - this.parentNode.boxObject.screenY) / interval) * snapIntMin; + this.mDragState.limitEndMin = this.mDragState.origMin; + this.mDragState.limitStartMin = this.mDragState.origMin; + this.fgboxes.dragspacer.setAttribute("height", this.mDragState.origMin * this.mPixPerMin); + } else { + this.mDragState.origLoc = event.screenX; + this.mDragState.origMin = Math.floor((event.screenX - this.parentNode.boxObject.screenX) / interval) * snapIntMin; + this.fgboxes.dragspacer.setAttribute("width", this.mDragState.origMin * this.mPixPerMin); + } + + document.calendarEventColumnDragging = this; + + window.addEventListener("mousemove", this.onEventSweepMouseMove, false); + window.addEventListener("mouseup", this.onEventSweepMouseUp, false); + window.addEventListener("keypress", this.onEventSweepKeypress, false); + ]]></handler> + </handlers> + </binding> + + <binding id="calendar-header-container" extends="chrome://calendar/content/widgets/calendar-widgets.xml#dragndropContainer"> + <content xbl:inherits="selected" flex="1" class="calendar-event-column-header"> + <children/> + </content> + + <implementation> + <field name="mItemBoxes">null</field> + <constructor><![CDATA[ + this.mItemBoxes = []; + ]]></constructor> + + <property name="date"> + <getter><![CDATA[ + return this.mDate; + ]]></getter> + <setter><![CDATA[ + this.mDate = val; + return val; + ]]></setter> + </property> + <method name="findBoxForItem"> + <parameter name="aItem"/> + <body><![CDATA[ + for (let item of this.mItemBoxes) { + if (aItem && item.occurrence.hasSameIds(aItem)) { + // We can return directly, since there will only be one box per + // item in the header. + return item; + } + } + return null; + ]]></body> + </method> + + <method name="addEvent"> + <parameter name="aItem"/> + <body><![CDATA[ + // prevent same items being added + if (this.mItemBoxes.some(itemBox => itemBox.occurrence.hashId == aItem.hashId)) { + return; + } + + let itemBox = createXULElement("calendar-editable-item"); + this.appendChild(itemBox); + itemBox.calendarView = this.calendarView; + itemBox.occurrence = aItem; + let ctxt = this.calendarView.getAttribute("item-context") || + this.calendarView.getAttribute("context"); + itemBox.setAttribute("context", ctxt); + + if (aItem.hashId in this.calendarView.mFlashingEvents) { + itemBox.setAttribute("flashing", "true"); + } + + this.mItemBoxes.push(itemBox); + itemBox.parentBox = this; + ]]></body> + </method> + + <method name="deleteEvent"> + <parameter name="aItem"/> + <body><![CDATA[ + for (let i in this.mItemBoxes) { + if (this.mItemBoxes[i].occurrence.hashId == aItem.hashId) { + this.mItemBoxes[i].remove(); + this.mItemBoxes.splice(i, 1); + break; + } + } + ]]></body> + </method> + + <method name="onDropItem"> + <parameter name="aItem"/> + <body><![CDATA[ + let newItem = cal.moveItem(aItem, this.mDate); + newItem = cal.setItemToAllDay(newItem, true); + return newItem; + ]]></body> + </method> + + <method name="selectOccurrence"> + <parameter name="aItem"/> + <body><![CDATA[ + for (let itemBox of this.mItemBoxes) { + if (aItem && (itemBox.occurrence.hashId == aItem.hashId)) { + itemBox.selected = true; + } + } + ]]></body> + </method> + <method name="unselectOccurrence"> + <parameter name="aItem"/> + <body><![CDATA[ + for (let itemBox of this.mItemBoxes) { + if (aItem && (itemBox.occurrence.hashId == aItem.hashId)) { + itemBox.selected = false; + } + } + ]]></body> + </method> + + </implementation> + + <handlers> + <handler event="dblclick" button="0"><![CDATA[ + this.calendarView.controller.createNewEvent(null, this.mDate, null, true); + ]]></handler> + <handler event="mousedown"><![CDATA[ + this.calendarView.selectedDay = this.mDate; + ]]></handler> + <handler event="click" button="0"><![CDATA[ + if (!(event.ctrlKey || event.metaKey)) { + this.calendarView.setSelectedItems(0, []); + } + ]]></handler> + <handler event="click" button="2"><![CDATA[ + let newStart = this.calendarView.selectedDay.clone(); + newStart.isDate = true; + this.calendarView.selectedDateTime = newStart; + event.stopPropagation(); + ]]></handler> + <handler event="wheel"><![CDATA[ + if (this.getAttribute("orient") == "vertical") { + // In vertical view (normal), don't let the parent multiday view + // handle the scrolling in its bubbling phase. The default action + // will make the box scroll here. + + // TODO We could scroll by the height of exactly one event box, but + // since a normal box's boxObject doesn't implement nsIScrollBoxObject, + // there is no way to scroll by pixels. Using a xul:scrollbox has + // problems since the equalsize attribute isn't inherited by the + // inner box, and even if that is worked around, something makes the + // rotated view look bad in that case. + event.stopPropagation(); + } + ]]></handler> + </handlers> + </binding> + + <!-- + - An individual event box, to be inserted into a column. + --> + <binding id="calendar-event-box" extends="chrome://calendar/content/calendar-view-core.xml#calendar-editable-item"> + <content mousethrough="never" tooltip="itemTooltip"> + <xul:box xbl:inherits="orient,width,height" flex="1"> + <xul:box anonid="event-container" + class="calendar-color-box" + xbl:inherits="orient,readonly,flashing,alarm,allday,priority,progress, + status,calendar,categories,calendar-uri,calendar-id,todoType" + flex="1"> + <xul:box class="calendar-event-selection" orient="horizontal" flex="1"> + <xul:stack anonid="eventbox" + align="stretch" + class="calendar-event-box-container" + flex="1" + xbl:inherits="context,parentorient=orient,readonly,flashing,alarm,allday,priority,progress,status,calendar,categories"> + <xul:hbox class="calendar-event-details" + anonid="calendar-event-details" + align="start"> + <xul:image anonid="item-icon" + class="calendar-item-image" + xbl:inherits="progress,allday,itemType,todoType"/> + <xul:description anonid="event-name" class="calendar-event-details-core" flex="1"/> + <xul:textbox anonid="event-name-textbox" + class="plain calendar-event-details-core calendar-event-name-textbox" + flex="1" + hidden="true" + wrap="true"/> + </xul:hbox> + <xul:stack mousethrough="always"> + <xul:calendar-category-box anonid="category-box" xbl:inherits="categories" pack="end" /> + <xul:hbox align="right"> + <xul:hbox anonid="alarm-icons-box" + class="alarm-icons-box" + pack="end" + align="top" + xbl:inherits="flashing"/> + <xul:image anonid="item-classification-box" + class="item-classification-box" + pack="end"/> + </xul:hbox> + </xul:stack> + <xul:box xbl:inherits="orient"> + <xul:calendar-event-gripbar anonid="gripbar1" + class="calendar-event-box-grippy-top" + mousethrough="never" + whichside="start" + xbl:inherits="parentorient=orient"/> + <xul:spacer mousethrough="always" flex="1"/> + <xul:calendar-event-gripbar anonid="gripbar2" + class="calendar-event-box-grippy-bottom" + mousethrough="never" + whichside="end" + xbl:inherits="parentorient=orient"/> + </xul:box> + <!-- Do not insert anything here, otherwise the event boxes will + not be resizable using the gripbars. If you want to insert + additional elements, do so above the box with the gripbars. --> + </xul:stack> + </xul:box> + </xul:box> + </xul:box> + </content> + + <implementation> + <constructor><![CDATA[ + this.orient = this.getAttribute("orient"); + ]]></constructor> + + <!-- fields --> + <field name="mParentColumn">null</field> + + <!-- methods/properties --> + <method name="setAttribute"> + <parameter name="aAttr"/> + <parameter name="aVal"/> + <body><![CDATA[ + let needsrelayout = false; + if (aAttr == "orient") { + if (this.getAttribute("orient") != aVal) { + needsrelayout = true; + } + } + + // this should be done using lookupMethod(), see bug 286629 + let ret = XULElement.prototype.setAttribute.call(this, aAttr, aVal); + + if (needsrelayout) { + let eventbox = document.getAnonymousElementByAttribute(this, "anonid", "eventbox"); + eventbox.setAttribute("orient", val); + let gb1 = document.getAnonymousElementByAttribute(this, "anonid", "gripbar1"); + gb1.parentorient = val; + let gb2 = document.getAnonymousElementByAttribute(this, "anonid", "gripbar2"); + gb2.parentorient = val; + } + + return ret; + ]]></body> + </method> + + <method name="getOptimalMinSize"> + <body><![CDATA[ + if (this.getAttribute("orient") == "vertical") { + let minHeight = getOptimalMinimumHeight(this.eventNameLabel) + + getSummarizedStyleValues(document.getAnonymousElementByAttribute(this, "anonid", "eventbox"), ["margin-bottom", "margin-top"]) + + getSummarizedStyleValues(this, ["border-bottom-width", "border-top-width"]); + this.setAttribute("minheight", minHeight); + this.setAttribute("minwidth", "1"); + return minHeight; + } else { + this.eventNameLabel.setAttribute("style", "min-width: 2em"); + let minWidth = getOptimalMinimumWidth(this.eventNameLabel); + this.setAttribute("minwidth", minWidth); + this.setAttribute("minheight", "1"); + return minWidth; + } + ]]></body> + </method> + + <property name="parentColumn" + onget="return this.mParentColumn;" + onset="return (this.mParentColumn = val);"/> + + <property name="startMinute" readonly="true"> + <getter><![CDATA[ + if (!this.mOccurrence) { + return 0; + } + let startDate = this.mOccurrence.startDate || this.mOccurrence.entryDate; + return startDate.hour * 60 + startDate.minute; + ]]></getter> + </property> + + <property name="endMinute" readonly="true"> + <getter><![CDATA[ + if (!this.mOccurrence) { + return 0; + } + let endDate = this.mOccurrence.endDate || this.mOccurrence.dueDate; + return endDate.hour * 60 + endDate.minute; + ]]></getter> + </property> + + <method name="setEditableLabel"> + <body><![CDATA[ + let evl = this.eventNameLabel; + let item = this.mOccurrence; + + if (item.title && item.title != "") { + // Use <description> textContent so it can wrap. + evl.textContent = item.title; + } else { + evl.textContent = calGetString("calendar", "eventUntitled"); + } + + let gripbar = document.getAnonymousElementByAttribute(this, "anonid", "gripbar1").boxObject.height; + let height = document.getAnonymousElementByAttribute(this, "anonid", "eventbox").boxObject.height; + evl.setAttribute("style", "max-height: " + Math.max(0, height-gripbar * 2) + "px"); + ]]></body> + </method> + </implementation> + + <handlers> + <handler event="mousedown" button="0"><![CDATA[ + event.stopPropagation(); + + if (this.mEditing) { + return; + } + + this.parentColumn.calendarView.selectedDay = this.parentColumn.mDate; + this.mMouseX = event.screenX; + this.mMouseY = event.screenY; + + let whichside = event.whichside; + if (whichside) { + this.calendarView.setSelectedItems(1, + [event.ctrlKey ? this.mOccurrence.parentItem : this.mOccurrence]); + + let snapIntMin = (event.shiftKey && + !event.ctrlKey && + !event.altKey && + !event.metaKey) ? 1 : 15; + // start edge resize drag + this.parentColumn.startSweepingToModifyEvent(this, this.mOccurrence, whichside, + event.screenX, event.screenY, + snapIntMin); + } else { + // may be click or drag, + // so wait for mousemove (or mouseout if fast) to start item move drag + this.mInMouseDown = true; + return; + } + ]]></handler> + + <handler event="mousemove"><![CDATA[ + if (!this.mInMouseDown) { + return; + } + + let deltaX = Math.abs(event.screenX - this.mMouseX); + let deltaY = Math.abs(event.screenY - this.mMouseY); + // more than a 3 pixel move? + if ((deltaX * deltaX + deltaY * deltaY) > 9) { + if (this.parentColumn) { + if (this.editingTimer) { + clearTimeout(this.editingTimer); + this.editingTimer = null; + } + + this.calendarView.setSelectedItems(1, [this.mOccurrence]); + + this.mEditing = false; + + this.parentColumn.startSweepingToModifyEvent(this, this.mOccurrence, "middle", this.mMouseX, this.mMouseY); + this.mInMouseDown = false; + } + } + ]]></handler> + + <handler event="mouseout"><![CDATA[ + if (!this.mEditing && this.mInMouseDown && this.parentColumn) { + if (this.editingTimer) { + clearTimeout(this.editingTimer); + this.editingTimer = null; + } + + this.calendarView.setSelectedItems(1, [this.mOccurrence]); + + this.mEditing = false; + + this.parentColumn.startSweepingToModifyEvent(this, this.mOccurrence, "middle", this.mMouseX, this.mMouseY); + this.mInMouseDown = false; + } + ]]></handler> + + <handler event="mouseup"><![CDATA[ + if (this.mEditing) { + return; + } + + this.mInMouseDown = false; + ]]></handler> + + <handler event="mouseover"><![CDATA[ + if (this.calendarView && this.calendarView.controller) { + event.stopPropagation(); + onMouseOverItem(event); + } + ]]></handler> + </handlers> + </binding> + + <binding id="calendar-multiday-view" extends="chrome://calendar/content/calendar-base-view.xml#calendar-base-view"> + <content flex="1" orient="vertical" xbl:inherits="context,item-context"> + <xul:box anonid="mainbox" class="multiday-view-main-box" flex="1"> + <!-- these boxes are tricky: width or height in CSS depend on orient --> + <xul:box anonid="labelbox" class="multiday-view-label-box"> + <xul:box anonid="labeltimespacer" class="multiday-view-label-time-spacer"/> + <xul:box anonid="labeldaybox" + class="multiday-view-label-day-box" + flex="1" + equalsize="always"/> + <xul:box anonid="labelscrollbarspacer" class="multiday-labelscrollbarspacer"/> + </xul:box> + <xul:box anonid="headerbox" class="multiday-view-header-box"> + <xul:box anonid="headertimespacer" class="multiday-view-header-time-spacer"/> + <xul:box anonid="headerdaybox" class="multiday-view-header-day-box" flex="1" equalsize="always" /> + <xul:box anonid="headerscrollbarspacer" class="multiday-headerscrollbarspacer"/> + </xul:box> + <xul:scrollbox anonid="scrollbox" flex="1" + onoverflow="adjustScrollBarSpacers();" onunderflow="adjustScrollBarSpacers();"> + <!-- the orient of the calendar-time-bar needs to be the opposite of the parent --> + <xul:calendar-time-bar xbl:inherits="orient" anonid="timebar"/> + <xul:box anonid="daybox" class="multiday-view-day-box" flex="1" + equalsize="always"/> + </xul:scrollbox> + </xul:box> + </content> + + <implementation implements="calICalendarView"> + <constructor><![CDATA[ + Components.utils.import("resource://gre/modules/Preferences.jsm"); + + // get day start/end hour from prefs and set on the view + this.setDayStartEndMinutes(Preferences.get("calendar.view.daystarthour", 8) * 60, + Preferences.get("calendar.view.dayendhour", 17) * 60); + + // initially scroll to the day start hour in the view + this.scrollToMinute(this.mDayStartMin); + + // get visible hours from prefs and set on the view + let visibleMinutes = Preferences.get("calendar.view.visiblehours", 9) * 60; + this.setVisibleMinutes(visibleMinutes); + + // set the flex attribute at the scrollbox-innerbox + // (this can be removed, after Bug 343555 is fixed) + let scrollbox = document.getAnonymousElementByAttribute( + this, "anonid", "scrollbox"); + document.getAnonymousElementByAttribute( + scrollbox, "class", "box-inherit scrollbox-innerbox").flex = "1"; + + // set the time interval for the time indicator timer + this.setTimeIndicatorInterval(Preferences.get("calendar.view.timeIndicatorInterval", 15)); + this.enableTimeIndicator(); + + this.reorient(); + ]]></constructor> + + <property name="daysInView" readonly="true"> + <getter><![CDATA[ + return this.labeldaybox.childNodes && this.labeldaybox.childNodes.length; + ]]></getter> + </property> + + <property name="supportsZoom" readonly="true" + onget="return true;"/> + <property name="supportsRotation" readonly="true" + onget="return true"/> + + <method name="setTimeIndicatorInterval"> + <parameter name="aPrefInterval"/> + <body><![CDATA[ + // If the preference just edited by the user is outside the valid + // range [0, 1440], we change it into the nearest limit (0 or 1440). + let newTimeInterval = Math.max(0, Math.min(1440, aPrefInterval)); + if (newTimeInterval != aPrefInterval) { + Preferences.set("calendar.view.timeIndicatorInterval", newTimeInterval); + } + + if (newTimeInterval != this.mTimeIndicatorInterval) { + this.mTimeIndicatorInterval = newTimeInterval; + } + if (this.mTimeIndicatorInterval == 0) { + timeIndicator.cancel(); + } + ]]></body> + </method> + + <method name="enableTimeIndicator"> + <body><![CDATA[ + // Hide or show the time indicator if the preference becomes 0 or greater than 0. + let hideIndicator = this.mTimeIndicatorInterval == 0; + setBooleanAttribute(this.timeBarTimeIndicator, "hidden", hideIndicator); + let todayColumn = this.findColumnForDate(this.today()); + if (todayColumn) { + setBooleanAttribute(todayColumn.column.timeIndicatorBox, "hidden", hideIndicator); + } + // Update the timer but only under some circumstances, otherwise + // it will update the wrong view or it will start without need. + let currentMode = document.getElementById("modeBroadcaster").getAttribute("mode"); + let currView = currentView().type; + if (currentMode == "calendar" && currView == this.type && !hideIndicator && + (currView == "day" || currView == "week")) { + this.updateTimeIndicatorPosition(true); + } + ]]></body> + </method> + + <method name="updateTimeIndicatorPosition"> + <parameter name="aUpdateTheTimer"/> + <parameter name="aPpmChanged"/> + <parameter name="aViewChanged"/> + <body><![CDATA[ + let now = cal.now(); + let nowMinutes = now.hour * 60 + now.minute; + if (aUpdateTheTimer) { + let prefInt = this.mTimeIndicatorInterval; + if (prefInt == 0) { + timeIndicator.cancel(); + return; + } + + // Increase the update interval if pixels per minute is small. + let oldPrefInt = prefInt; + if (aPpmChanged && this.mPixPerMin < 0.6) { + prefInt = Math.round(prefInt / this.mPixPerMin); + } + if (!aPpmChanged || aViewChanged || oldPrefInt != prefInt) { + // Synchronize the timer with a multiple of the interval. + let firstInterval = (prefInt - nowMinutes % prefInt) * 60 - now.second; + if (timeIndicator.timer) { + timeIndicator.cancel(); + } + timeIndicator.lastView = this.id; + timeIndicator.timer = setTimeout(() => { + this.updateTimeIndicatorPosition(false); + timeIndicator.start(prefInt * 60, this); + }, firstInterval * 1000); + + // Set the time for the first positioning of the indicator. + let time = Math.floor(nowMinutes / prefInt) * prefInt; + document.getElementById("day-view").mTimeIndicatorMinutes = time; + document.getElementById("week-view").mTimeIndicatorMinutes = time; + } + } else if (aUpdateTheTimer === false) { + // Set the time for every positioning after the first + document.getElementById("day-view").mTimeIndicatorMinutes = nowMinutes; + document.getElementById("week-view").mTimeIndicatorMinutes = nowMinutes; + } + // Update the position of the indicator. + let position = Math.round(this.mPixPerMin * this.mTimeIndicatorMinutes) - 1; + let posAttr = (this.orient == "vertical" ? "top: " : "left: "); + this.timeBarTimeIndicator.setAttribute("style", posAttr + position + "px;"); + let todayColumn = this.findColumnForDate(this.today()); + if (todayColumn) { + todayColumn.column.timeIndicatorBox.setAttribute("style", "margin-" + posAttr + position + "px;"); + } + ]]></body> + </method> + + <method name="handlePreference"> + <parameter name="aSubject"/> + <parameter name="aTopic"/> + <parameter name="aPreference"/> + <body><![CDATA[ + aSubject.QueryInterface(Components.interfaces.nsIPrefBranch); + switch (aPreference) { + + case "calendar.view.daystarthour": + this.setDayStartEndMinutes(aSubject.getIntPref(aPreference) * 60, + this.mDayEndMin); + this.refreshView(); + break; + + case "calendar.view.dayendhour": + this.setDayStartEndMinutes(this.mDayStartMin, + aSubject.getIntPref(aPreference) * 60); + this.refreshView(); + break; + + case "calendar.view.visiblehours": + this.setVisibleMinutes(aSubject.getIntPref(aPreference) * 60); + this.refreshView(); + break; + + case "calendar.view.timeIndicatorInterval": + this.setTimeIndicatorInterval(aSubject.getIntPref(aPreference)); + this.enableTimeIndicator(); + break; + + default: + this.handleCommonPreference(aSubject, aTopic, aPreference); + break; + } + return; + ]]></body> + </method> + + <method name="onResize"> + <parameter name="aRealSelf"/> + <body><![CDATA[ + let self = aRealSelf || this; // eslint-disable-line consistent-this + let isARelayout = !aRealSelf; + let scrollbox = document.getAnonymousElementByAttribute(self, "anonid", "scrollbox"); + let size; + if (self.orient == "horizontal") { + size = scrollbox.boxObject.width; + } else { + size = scrollbox.boxObject.height; + } + let ppm = size / self.mVisibleMinutes; + ppm = Math.floor(ppm * 1000) / 1000; + if (ppm < self.mMinPixelsPerMinute) { + ppm = self.mMinPixelsPerMinute; + } + let ppmChanged = (self.pixelsPerMinute != ppm); + self.pixelsPerMinute = ppm; + setTimeout(() => self.scrollToMinute(self.mFirstVisibleMinute), 0); + + // Fit the weekday labels while scrolling. + self.adjustWeekdayLength(self.getAttribute("orient") == "horizontal"); + + // Adjust the time indicator position and the related timer. + if (this.mTimeIndicatorInterval != 0) { + let viewChanged = isARelayout && (timeIndicator.lastView != this.id); + let currentMode = document.getElementById("modeBroadcaster").getAttribute("mode"); + if (currentMode == "calendar" && (!timeIndicator.timer || ppmChanged || viewChanged)) { + self.updateTimeIndicatorPosition(true, ppmChanged, viewChanged); + } + } + ]]></body> + </method> + + <!-- mDateList will always be sorted before being set --> + <field name="mDateList">null</field> + <!-- array of { date: calIDatetime, column: colbox, header: hdrbox } --> + <field name="mDateColumns">null</field> + <field name="mPixPerMin">0.6</field> + <field name="mMinPixelsPerMinute">0.1</field> + <field name="mSelectedDayCol">null</field> + <field name="mSelectedDay">null</field> + <field name="mStartMin">0</field> + <field name="mEndMin">24 * 60</field> + <field name="mDayStartMin">0</field> + <field name="mDayEndMin">0</field> + <field name="mVisibleMinutes">9 * 60</field> + <field name="mClickedTime">null</field> + <field name="mTimeIndicatorInterval">15</field> + <field name="mModeHandler">null</field> + <field name="mTimeIndicatorMinutes">0</field> + + <method name="flashAlarm"> + <parameter name="aAlarmItem"/> + <parameter name="aStop"/> + <body><![CDATA[ + // Helper function to save some duplicate code + function setFlashingAttribute(aBox) { + if (aStop) { + aBox.removeAttribute("flashing"); + } else { + aBox.setAttribute("flashing", "true"); + } + } + + let showIndicator = Preferences.get("calendar.alarms.indicator.show", true); + let totaltime = Preferences.get("calendar.alarms.indicator.totaltime", 3600); + + if (!aStop && (!showIndicator || totaltime < 1)) { + // No need to animate if the indicator should not be shown. + return; + } + + + // Make sure the flashing attribute is set or reset on all visible + // boxes. + let columns = this.findColumnsForItem(aAlarmItem); + for (let col of columns) { + let box = col.column.findChunkForOccurrence(aAlarmItem); + if (box && box.eventbox) { + setFlashingAttribute(box.eventbox); + } + box = col.header.findBoxForItem(aAlarmItem); + if (box) { + setFlashingAttribute(box); + } + } + + if (aStop) { + // We are done flashing, prevent newly created event boxes from flashing. + delete this.mFlashingEvents[aAlarmItem.hashId]; + } else { + // Set up a timer to stop the flashing after the total time. + this.mFlashingEvents[aAlarmItem.hashId] = aAlarmItem; + setTimeout(() => this.flashAlarm(aAlarmItem, true), totaltime); + } + ]]></body> + </method> + + <!-- calICalendarView --> + <property name="supportsDisjointDates" + onget="return true"/> + <property name="hasDisjointDates" + onget="return (this.mDateList != null);"/> + + <property name="startDate"> + <getter><![CDATA[ + if (this.mStartDate) { + return this.mStartDate; + } else if (this.mDateList && this.mDateList.length > 0) { + return this.mDateList[0]; + } else { + return null; + } + ]]></getter> + </property> + + <property name="endDate"> + <getter><![CDATA[ + if (this.mEndDate) { + return this.mEndDate; + } else if (this.mDateList && this.mDateList.length > 0) { + return this.mDateList[this.mDateList.length - 1]; + } else { + return null; + } + ]]></getter> + </property> + + <method name="showDate"> + <parameter name="aDate"/> + <body><![CDATA[ + let targetDate = aDate.getInTimezone(this.mTimezone); + targetDate.isDate = true; + + if (this.mStartDate && this.mEndDate) { + if (this.mStartDate.compare(targetDate) <= 0 && + this.mEndDate.compare(targetDate) >= 0) { + return; + } + } else if (this.mDateList) { + for (let date of this.mDateList) { + // if date is already visible, nothing to do + if (date.compare(targetDate) == 0) { + return; + } + } + } + + // if we're only showing one date, then continue + // to only show one date; otherwise, show the week. + if (this.numVisibleDates == 1) { + this.setDateRange(aDate, aDate); + } else { + this.setDateRange(aDate.startOfWeek, aDate.endOfWeek); + } + + this.selectedDay = targetDate; + ]]></body> + </method> + + <method name="setDateRange"> + <parameter name="aStartDate"/> + <parameter name="aEndDate"/> + <body><![CDATA[ + this.rangeStartDate = aStartDate; + this.rangeEndDate = aEndDate; + + let viewStart = aStartDate.getInTimezone(this.mTimezone); + let viewEnd = aEndDate.getInTimezone(this.mTimezone); + + viewStart.isDate = true; + viewStart.makeImmutable(); + viewEnd.isDate = true; + viewEnd.makeImmutable(); + this.mStartDate = viewStart; + this.mEndDate = viewEnd; + + // goToDay are called when toggle the values below. The attempt to fix + // Bug 872063 has modified the behavior of setDateRange, which doesn't + // always refresh the view anymore. That is not the expected behavior + // by goToDay. Add checks here to determine if the view need to be + // refreshed. + + // First, check values of tasksInView, workdaysOnly, showCompleted. + // Their status will determine the value of toggleStatus, which is + // saved to this.mToggleStatus during last call to relayout() + let toggleStatus = 0; + + if (this.mTasksInView) { + toggleStatus |= this.mToggleStatusFlag.TasksInView; + } + if (this.mWorkdaysOnly) { + toggleStatus |= this.mToggleStatusFlag.WorkdaysOnly; + } + if (this.mShowCompleted) { + toggleStatus |= this.mToggleStatusFlag.ShowCompleted; + } + + // Update the navigation bar only when changes are related to the current view. + if (this.isVisible()) { + cal.navigationBar.setDateRange(viewStart, viewEnd); + } + + // Check whether view range has been changed since last call to + // relayout() + if (!this.mViewStart || !this.mViewEnd || + this.mViewEnd.compare(viewEnd) != 0 || + this.mViewStart.compare(viewStart) != 0 || + this.mToggleStatus != toggleStatus) { + this.refresh(); + } + ]]></body> + </method> + + <method name="getDateList"> + <parameter name="aCount"/> + <body><![CDATA[ + let dates = []; + if (this.mStartDate && this.mEndDate) { + let date = this.mStartDate.clone(); + while (date.compare(this.mEndDate) <= 0) { + dates.push(d.clone()); + date.day += 1; + } + } else if (this.mDateList) { + for (let date of this.mDateList) { + dates.push(date.clone()); + } + } + + aCount.value = dates.length; + return dates; + ]]></body> + </method> + + <property name="selectedDateTime"> + <getter><![CDATA[ + return this.mClickedTime; + ]]></getter> + <setter><![CDATA[ + this.mClickedTime = val; + ]]></setter> + </property> + + <property name="selectedDay"> + <getter><![CDATA[ + let selected; + if (this.numVisibleDates == 1) { + selected = this.mDateColumns[0].date; + } else if (this.mSelectedDay) { + selected = this.mSelectedDay; + } else if (this.mSelectedDayCol) { + selected = this.mSelectedDayCol.date; + } + + // TODO Make sure the selected day is valid + // TODO select now if it is in the range? + return selected; + ]]></getter> + <setter><![CDATA[ + // ignore if just 1 visible, it's always selected, + // but we don't indicate it + if (this.numVisibleDates == 1) { + this.fireEvent("dayselect", val); + return val; + } + + if (this.mSelectedDayCol) { + this.mSelectedDayCol.column.selected = false; + this.mSelectedDayCol.header.removeAttribute("selected"); + } + + if (val) { + this.mSelectedDayCol = this.findColumnForDate(val); + if (this.mSelectedDayCol) { + this.mSelectedDay = this.mSelectedDayCol.date; + this.mSelectedDayCol.column.selected = true; + this.mSelectedDayCol.header.setAttribute("selected", "true"); + } else { + this.mSelectedDay = val; + } + } + this.fireEvent("dayselect", val); + return val; + ]]></setter> + </property> + + <method name="getSelectedItems"> + <parameter name="aCount"/> + <body><![CDATA[ + aCount.value = this.mSelectedItems.length; + return this.mSelectedItems; + ]]></body> + </method> + <method name="setSelectedItems"> + <parameter name="aCount"/> + <parameter name="aItems"/> + <parameter name="aSuppressEvent"/> + <body><![CDATA[ + if (this.mSelectedItems) { + for (let item of this.mSelectedItems) { + for (let occ of this.getItemOccurrencesInView(item)) { + let cols = this.findColumnsForItem(occ); + for (let col of cols) { + col.header.unselectOccurrence(occ); + col.column.unselectOccurrence(occ); + } + } + } + } + this.mSelectedItems = aItems || []; + + for (let item of this.mSelectedItems) { + for (let occ of this.getItemOccurrencesInView(item)) { + let cols = this.findColumnsForItem(occ); + if (cols.length > 0) { + let start = item.startDate || item.entryDate || item.dueDate; + for (let col of cols) { + if (start.isDate) { + col.header.selectOccurrence(occ); + } else { + col.column.selectOccurrence(occ); + } + } + } + } + } + + if (!aSuppressEvent) { + this.fireEvent("itemselect", this.mSelectedItems); + } + ]]></body> + </method> + + <method name="getItemOccurrencesInView"> + <parameter name="aItem"/> + <body><![CDATA[ + if (aItem.recurrenceInfo && aItem.recurrenceStartDate) { + // if selected a parent item, show occurrence(s) in view range + return aItem.getOccurrencesBetween(this.startDate, this.queryEndDate, {}, 0); + } else if (aItem.recurrenceStartDate) { + return [aItem]; + } else { // undated todo + return []; + } + ]]></body> + </method> + + <method name="centerSelectedItems"> + <body><![CDATA[ + let displayTZ = calendarDefaultTimezone(); + let lowMinute = 24 * 60; + let highMinute = 0; + + for (let item of this.mSelectedItems) { + let startDateProperty = calGetStartDateProp(item); + let endDateProperty = calGetEndDateProp(item); + + let occs = []; + if (item.recurrenceInfo) { + // if selected a parent item, show occurrence(s) in view range + occs = item.getOccurrencesBetween(this.startDate, this.queryEndDate, {}, 0); + } else { + occs = [item]; + } + + for (let occ of occs) { + let occStart = occ[startDateProperty]; + let occEnd = occ[endDateProperty]; + // must have at least one of start or end + if (!occStart && !occEnd) { + continue; // task with no dates + } + + // if just has single datetime, treat as zero duration item + // (such as task with due datetime or start datetime only) + occStart = occStart || occEnd; + occEnd = occEnd || occStart; + // Now both occStart and occEnd are datetimes. + + // skip occurrence if all-day: it won't show in time view. + if (occStart.isDate || occEnd.isDate) { + continue; + } + + // Trim dates to view. (Not mutated so just reuse view dates) + if (this.startDate.compare(occStart) > 0) { + occStart = this.startDate; + } + if (this.queryEndDate.compare(occEnd) < 0) { + occEnd = this.queryEndDate; + } + + // Convert to display timezone if different + if (occStart.timezone != displayTZ) { + occStart = occStart.getInTimezone(displayTZ); + } + if (occEnd.timezone != displayTZ) { + occEnd = occEnd.getInTimezone(displayTZ); + } + // If crosses midnite in current TZ, set end just + // before midnite after start so start/title usually visible. + if (!sameDay(occStart, occEnd)) { + occEnd = occStart.clone(); + occEnd.day = occStart.day; + occEnd.hour = 23; + occEnd.minute = 59; + } + + // Ensure range shows occ + lowMinute = Math.min(occStart.hour * 60 + occStart.minute, + lowMinute); + highMinute = Math.max(occEnd.hour * 60 + occEnd.minute, + highMinute); + } + } + + let displayDuration = highMinute - lowMinute; + if (this.mSelectedItems.length && + displayDuration >= 0) { + let minute; + if (displayDuration <= this.mVisibleMinutes) { + minute = lowMinute + (displayDuration - this.mVisibleMinutes) / 2; + } else if (this.mSelectedItems.length == 1) { + // If the displayDuration doesn't fit into the visible + // minutes, but only one event is selected, then go ahead and + // center the event start. + + minute = Math.max(0, lowMinute - (this.mVisibleMinutes / 2)); + } + this.scrollToMinute(minute); + } + ]]></body> + </method> + + <property name="pixelsPerMinute"> + <getter><![CDATA[ + return this.mPixPerMin; + ]]></getter> + <setter><![CDATA[ + this.mPixPerMin = val; + + let timebar = document.getAnonymousElementByAttribute(this, "anonid", "timebar"); + timebar.pixelsPerMinute = val; + + if (!this.mDateColumns) { + return val; + } + for (let col of this.mDateColumns) { + col.column.pixelsPerMinute = val; + } + return val; + ]]></setter> + </property> + + <!-- private --> + + <property name="numVisibleDates" readonly="true"> + <getter><![CDATA[ + if (this.mDateList) { + return this.mDateList.length; + } + + let count = 0; + + if (!this.mStartDate || !this.mEndDate) { + // The view has not been initialized, so there are 0 visible dates. + return count; + } + + let date = this.mStartDate.clone(); + while (date.compare(this.mEndDate) <= 0) { + count++; + date.day += 1; + } + + return count; + ]]></getter> + </property> + + <property name="orient"> + <getter><![CDATA[ + return this.getAttribute("orient") || "vertical"; + ]]></getter> + <setter><![CDATA[ + this.setAttribute("orient", val); + return val; + ]]></setter> + </property> + + <property name="timeBarTimeIndicator" readonly="true"> + <getter><![CDATA[ + let timebar = document.getAnonymousElementByAttribute(this, "anonid", "timebar"); + return document.getAnonymousElementByAttribute(timebar, "anonid", "timeIndicatorBoxTimeBar"); + ]]></getter> + </property> + + <method name="setAttribute"> + <parameter name="aAttr"/> + <parameter name="aVal"/> + <body><![CDATA[ + let needsreorient = false; + let needsrelayout = false; + if (aAttr == "orient") { + if (this.getAttribute("orient") != aVal) { + needsreorient = true; + } + } + + if (aAttr == "context" || aAttr == "item-context") { + needsrelayout = true; + } + + // this should be done using lookupMethod(), see bug 286629 + let ret = XULElement.prototype.setAttribute.call(this, aAttr, aVal); + + if (needsrelayout && !needsreorient) { + this.relayout(); + } + + if (needsreorient) { + this.reorient(); + } + + return ret; + ]]></body> + </method> + + <method name="reorient"> + <body><![CDATA[ + let orient = this.getAttribute("orient") || "horizontal"; + let otherorient = (orient == "vertical" ? "horizontal" : "vertical"); + + if (orient == "horizontal") { + this.pixelsPerMinute = 1.5; + } else { + this.pixelsPerMinute = 0.6; + } + + let normalelems = ["mainbox", "timebar"]; + let otherelems = ["labelbox", "labeldaybox", "headertimespacer", + "headerbox", "headerdaybox", "scrollbox", "daybox"]; + + for (let id of normalelems) { + document.getAnonymousElementByAttribute(this, "anonid", id).setAttribute("orient", orient); + } + for (let id of otherelems) { + document.getAnonymousElementByAttribute(this, "anonid", id).setAttribute("orient", otherorient); + } + + let scrollbox = document.getAnonymousElementByAttribute( + this, "anonid", "scrollbox"); + let mainbox = document.getAnonymousElementByAttribute( + this, "anonid", "mainbox"); + + if (orient == "vertical") { + scrollbox.setAttribute( + "style", "overflow-x: hidden; overflow-y: auto;"); + mainbox.setAttribute( + "style", "overflow-x: auto; overflow-y: hidden;"); + } else { + scrollbox.setAttribute( + "style", "overflow-x: auto; overflow-y: hidden;"); + mainbox.setAttribute( + "style", "overflow-x: hidden; overflow-y: auto;"); + } + + let boxes = ["daybox", "headerdaybox"]; + for (let boxname of boxes) { + let box = document.getAnonymousElementByAttribute(this, "anonid", boxname); + setAttributeToChildren(box, "orient", orient); + } + + setAttributeToChildren(this.labeldaybox, "orient", otherorient); + + // Refresh + this.refresh(); + ]]></body> + </method> + + <method name="relayout"> + <body><![CDATA[ + if (!this.mStartDate || !this.mEndDate) { + return; + } + + let orient = this.getAttribute("orient") || "horizontal"; + let otherorient = getOtherOrientation(orient); + + let computedDateList = []; + let startDate = this.mStartDate.clone(); + while (startDate.compare(this.mEndDate) <= 0) { + let workday = startDate.clone(); + workday.makeImmutable(); + if (this.mDisplayDaysOff) { + computedDateList.push(workday); + } else if (!this.mDaysOffArray.includes(startDate.weekday)) { + computedDateList.push(workday); + } + startDate.day += 1; + } + this.mDateList = computedDateList; + + // unselect previous selected event upon switch views, otherwise those + // events will stay selected forever, if select other events after + // change week view. + this.setSelectedItems(0, [], true); + + let daybox = document.getAnonymousElementByAttribute(this, "anonid", "daybox"); + let headerdaybox = document.getAnonymousElementByAttribute(this, "anonid", "headerdaybox"); + + let dayStartMin = this.mDayStartMin; + let dayEndMin = this.mDayEndMin; + let setUpDayEventsBox = (aDayBox, date) => { + aDayBox.setAttribute("class", "calendar-event-column-" + (counter % 2 == 0 ? "even" : "odd")); + aDayBox.setAttribute("context", this.getAttribute("context")); + aDayBox.setAttribute("item-context", this.getAttribute("item-context") || this.getAttribute("context")); + aDayBox.startLayoutBatchChange(); + aDayBox.date = date; + aDayBox.setAttribute("orient", orient); + aDayBox.calendarView = this; + aDayBox.setDayStartEndMinutes(dayStartMin, dayEndMin); + }; + + let setUpDayHeaderBox = (aDayBox, date) => { + aDayBox.date = date; + aDayBox.calendarView = this; + aDayBox.setAttribute("orient", "vertical"); + // Since the calendar-header-container boxes have the same vertical + // orientation for normal and rotated views, it needs an attribute + // "rotated" in order to have different css rules. + setBooleanAttribute(aDayBox, "rotated", orient == "horizontal"); + }; + + this.mDateColumns = []; + + + // get today's date + let today = this.today(); + let counter = 0; + let dayboxkids = daybox.childNodes; + let headerboxkids = headerdaybox.childNodes; + let labelboxkids = this.labeldaybox.childNodes; + let updateTimeIndicator = false; + + for (let date of computedDateList) { + let dayEventsBox; + if (counter < dayboxkids.length) { + dayEventsBox = dayboxkids[counter]; + dayEventsBox.removeAttribute("relation"); + dayEventsBox.mEventInfos = []; + } else { + dayEventsBox = createXULElement("calendar-event-column"); + dayEventsBox.setAttribute("flex", "1"); + daybox.appendChild(dayEventsBox); + } + setUpDayEventsBox(dayEventsBox, date); + + let dayHeaderBox; + if (counter < headerboxkids.length) { + dayHeaderBox = headerboxkids[counter]; + // Delete backwards to make sure we get them all + // and delete until no more elements are left. + while (dayHeaderBox.mItemBoxes.length != 0) { + let num = dayHeaderBox.mItemBoxes.length; + dayHeaderBox.deleteEvent(dayHeaderBox.mItemBoxes[num-1].occurrence); + } + } else { + dayHeaderBox = createXULElement("calendar-header-container"); + dayHeaderBox.setAttribute("flex", "1"); + headerdaybox.appendChild(dayHeaderBox); + } + setUpDayHeaderBox(dayHeaderBox, date); + + if (this.mDaysOffArray.indexOf(date.weekday) >= 0) { + dayEventsBox.dayOff = true; + dayHeaderBox.setAttribute("weekend", "true"); + } else { + dayEventsBox.dayOff = false; + dayHeaderBox.removeAttribute("weekend"); + } + let labelbox; + if (counter < labelboxkids.length) { + labelbox = labelboxkids[counter]; + labelbox.date = date; + } else { + labelbox = createXULElement("calendar-day-label"); + labelbox.setAttribute("orient", otherorient); + this.labeldaybox.appendChild(labelbox); + labelbox.date = date; + } + // Set attributes for date relations and for the time indicator. + let headerDayBox = document.getAnonymousElementByAttribute( + this, "anonid", "headerdaybox"); + headerDayBox.removeAttribute("todaylastinview"); + dayEventsBox.timeIndicatorBox.setAttribute("hidden", "true"); + switch (date.compare(today)) { + case -1: { + dayHeaderBox.setAttribute("relation", "past"); + dayEventsBox.setAttribute("relation", "past"); + labelbox.setAttribute("relation", "past"); + break; + } + case 0: { + let relation_ = this.numVisibleDates == 1 ? "today1day" : "today"; + dayHeaderBox.setAttribute("relation", relation_); + dayEventsBox.setAttribute("relation", relation_); + labelbox.setAttribute("relation", relation_); + setBooleanAttribute(dayEventsBox.timeIndicatorBox, "hidden", this.mTimeIndicatorInterval == 0); + updateTimeIndicator = true; + + // Due to equalsize=always being set on the dayboxes + // parent, there are a few issues showing the border of + // the last daybox correctly. To work around this, we're + // setting an attribute we can use in CSS. For more + // information about this hack, see bug 455045 + if (dayHeaderBox == headerdaybox.childNodes[headerdaybox.childNodes.length - 1] && + this.numVisibleDates > 1) { + headerDayBox.setAttribute("todaylastinview", "true"); + } + break; + } + case 1: { + dayHeaderBox.setAttribute("relation", "future"); + dayEventsBox.setAttribute("relation", "future"); + labelbox.setAttribute("relation", "future"); + break; + } + } + // We don't want to actually mess with our original dates, plus + // they're likely to be immutable. + let date2 = date.clone(); + date2.isDate = true; + date2.makeImmutable(); + this.mDateColumns.push({ date: date2, column: dayEventsBox, header: dayHeaderBox }); + counter++; + } + + // Remove any extra columns that may have been hanging around + function removeExtraKids(elem) { + while (counter < elem.childNodes.length) { + elem.childNodes[counter].remove(); + } + } + removeExtraKids(daybox); + removeExtraKids(headerdaybox); + removeExtraKids(this.labeldaybox); + + if (updateTimeIndicator) { + this.updateTimeIndicatorPosition(); + } + + // fix pixels-per-minute + this.onResize(); + if (this.mDateColumns) { + for (let col of this.mDateColumns) { + col.column.endLayoutBatchChange(); + } + } + + // Adjust scrollbar spacers + this.adjustScrollBarSpacers(); + + // Store the start and end of current view. Next time when + // setDateRange is called, it will use mViewStart and mViewEnd to + // check if view range has been changed. + this.mViewStart = this.mStartDate; + this.mViewEnd = this.mEndDate; + + let toggleStatus = 0; + + if (this.mTasksInView) { + toggleStatus |= this.mToggleStatusFlag.TasksInView; + } + if (this.mWorkdaysOnly) { + toggleStatus |= this.mToggleStatusFlag.WorkdaysOnly; + } + if (this.mShowCompleted) { + toggleStatus |= this.mToggleStatusFlag.ShowCompleted; + } + + this.mToggleStatus = toggleStatus; + ]]></body> + </method> + + <method name="findColumnForDate"> + <parameter name="aDate"/> + <body><![CDATA[ + if (!this.mDateColumns) { + return null; + } + for (let col of this.mDateColumns) { + if (col.date.compare(aDate) == 0) { + return col; + } + } + return null; + ]]></body> + </method> + + <method name="findDayBoxForDate"> + <parameter name="aDate"/> + <body><![CDATA[ + let col = this.findColumnForDate(aDate); + return (col && col.header); + ]]></body> + </method> + + <method name="selectColumnHeader"> + <parameter name="aDate"/> + <body><![CDATA[ + let child = this.labeldaybox.firstChild; + while (child) { + if (child.date.compare(aDate) == 0) { + child.setAttribute("selected", "true"); + } else { + child.removeAttribute("selected"); + } + child = child.nextSibling; + } + ]]></body> + </method> + + <method name="findColumnsForOccurrences"> + <parameter name="aOccurrences"/> + <body><![CDATA[ + if (!this.mDateColumns || !this.mDateColumns.length) { + return []; + } + + let occMap = {}; + for (let occ of aOccurrences) { + let startDate = occ[calGetStartDateProp(occ)] + .getInTimezone(this.mStartDate.timezone); + let endDate = occ[calGetEndDateProp(occ)] + .getInTimezone(this.mEndDate.timezone) || startDate; + if (startDate.compare(this.mStartDate) >= 0 && + endDate.compare(this.mEndDate) <= 0) { + for (let i = startDate.day; i <= endDate.day; i++) { + occMap[i] = true; + } + } + } + + return this.mDateColumns.filter(col => col.date.day in occMap); + ]]></body> + </method> + + <method name="findColumnsForItem"> + <parameter name="aItem"/> + <body><![CDATA[ + let columns = []; + + if (!this.mDateColumns) { + return columns; + } + + // Note that these may be dates or datetimes + let startDate = aItem.startDate || aItem.entryDate || aItem.dueDate; + if (!startDate) { + return columns; + } + let timezone = this.mDateColumns[0].date.timezone; + let targetDate = startDate.getInTimezone(timezone); + let finishDate = (aItem.endDate || aItem.dueDate || aItem.entryDate || startDate) + .getInTimezone(timezone); + + if (!targetDate.isDate) { + // Set the time to 00:00 so that we get all the boxes + targetDate.hour = 0; + targetDate.minute = 0; + targetDate.second = 0; + } + + if (targetDate.compare(finishDate) == 0) { + // We have also to handle zero length events in particular for + // tasks without entry or due date. + let col = this.findColumnForDate(targetDate); + if (col) { + columns.push(col); + } + } + + while (targetDate.compare(finishDate) == -1) { + let col = this.findColumnForDate(targetDate); + + // This might not exist if the event spans the view start or end + if (col) { + columns.push(col); + } + targetDate.day += 1; + } + + return columns; + ]]></body> + </method> + + <!-- for the given client-coord-system point, return + - the calendar-event-column that contains it. If + - no column contains it, return null. + --> + <method name="findColumnForClientPoint"> + <parameter name="aClientX"/> + <parameter name="aClientY"/> + <body><![CDATA[ + if (!this.mDateColumns) { + return null; + } + for (let col of this.mDateColumns) { + let boxObject = document.getAnonymousElementByAttribute(col.column, "anonid", "boxstack").boxObject; + if (aClientX >= boxObject.screenX && + aClientX <= (boxObject.screenX + boxObject.width) && + aClientY >= boxObject.screenY && + aClientY <= (boxObject.screenY + boxObject.height)) { + return col.column; + } + } + return null; + ]]></body> + </method> + + <method name="adjustScrollbarSpacersForAlldayEvents"> + <parameter name="aEvent"/> + <body><![CDATA[ + let startDate = aEvent[calGetStartDateProp(aEvent)]; + let endDate = aEvent[calGetEndDateProp(aEvent)]; + if ((startDate && startDate.isDate) || + (endDate && endDate.isDate)) { + // If this is an all day event, then the header with allday + // events could possibly get a scrollbar. Readjust them. + this.adjustScrollBarSpacers(); + } + ]]></body> + </method> + + <method name="doAddItem"> + <parameter name="aEvent"/> + <body><![CDATA[ + let cols = this.findColumnsForItem(aEvent); + if (!cols.length) { + return; + } + + for (let col of cols) { + let column = col.column; + let header = col.header; + + let estart = aEvent.startDate || aEvent.entryDate || aEvent.dueDate; + if (estart.isDate) { + header.addEvent(aEvent); + } else { + column.addEvent(aEvent); + } + } + + this.adjustScrollbarSpacersForAlldayEvents(aEvent); + ]]></body> + </method> + + <method name="doDeleteItem"> + <parameter name="aEvent"/> + <body><![CDATA[ + let cols = this.findColumnsForItem(aEvent); + if (!cols.length) { + return; + } + + let oldLength = this.mSelectedItems.length; + this.mSelectedItems = this.mSelectedItems.filter((item) => { + return item.hashId != aEvent.hashId; + }); + + for (let col of cols) { + let column = col.column; + let header = col.header; + + let estart = aEvent.startDate || aEvent.entryDate || aEvent.dueDate; + if (estart.isDate) { + header.deleteEvent(aEvent); + } else { + column.deleteEvent(aEvent); + } + } + + // If a deleted event was selected, we need to announce that the + // selection changed. + if (oldLength != this.mSelectedItems.length) { + this.fireEvent("itemselect", this.mSelectedItems); + } + + this.adjustScrollbarSpacersForAlldayEvents(aEvent); + ]]></body> + </method> + + <method name="deleteItemsFromCalendar"> + <parameter name="aCalendar"/> + <body><![CDATA[ + if (!this.mDateColumns) { + return; + } + for (let col of this.mDateColumns) { + // get all-day events in column header and events within the column + let colEvents = col.header.mItemBoxes.map(box => box.occurrence) + .concat(col.column.mEventInfos.map(info => info.event)); + + for (let event of colEvents) { + if (event.calendar.id == aCalendar.id) { + this.doDeleteItem(event); + } + } + } + ]]></body> + </method> + + <method name="adjustScrollBarSpacers"> + <body><![CDATA[ + // get the view's orientation + let propertyName; + if (this.getAttribute("orient") == "vertical") { + propertyName = "width"; + } else { + propertyName = "height"; + } + + // get the width/height of the scrollbox scrollbar + let scrollbox = document.getAnonymousElementByAttribute( + this, "anonid", "scrollbox"); + let propertyValue = scrollbox.boxObject.firstChild.boxObject[propertyName]; + // Check if we need to show the headerScrollbarSpacer at all + let headerPropertyValue = propertyValue; + let headerDayBox = document.getAnonymousElementByAttribute( + this, "anonid", "headerdaybox"); + if (headerDayBox) { + // Only do this when there are multiple days + let headerDayBoxMaxHeight = parseInt(document.defaultView.getComputedStyle(headerDayBox, null) + .getPropertyValue("max-height"), 10); + if (this.getAttribute("orient") == "vertical" && + headerDayBox.boxObject.height >= headerDayBoxMaxHeight) { + // If the headerDayBox is just as high as the max-height, then + // there is already a scrollbar and we don't need to show the + // headerScrollbarSpacer. This is only valid for the non-rotated + // view. + headerPropertyValue = 0; + } + } + + // set the same width/height for the label and header box spacers + let headerScrollBarSpacer = document.getAnonymousElementByAttribute( + this, "anonid", "headerscrollbarspacer"); + headerScrollBarSpacer.setAttribute(propertyName, headerPropertyValue); + let labelScrollBarSpacer = document.getAnonymousElementByAttribute( + this, "anonid", "labelscrollbarspacer"); + labelScrollBarSpacer.setAttribute(propertyName, propertyValue); + ]]></body> + </method> + + <field name="mFirstVisibleMinute">0</field> + <method name="scrollToMinute"> + <parameter name="aMinute"/> + <body><![CDATA[ + let scrollbox = document.getAnonymousElementByAttribute(this, "anonid", "scrollbox"); + let scrollBoxObject = scrollbox.boxObject; + // 'aMinute' will be the first minute showed in the view, so it must + // belong to the range 0 <-> (24*60 - minutes_showed_in_the_view) but + // we consider 25 hours instead of 24 to let the view scroll until + // showing events that start just before 0.00 + let maxFirstMin = 25 * 60 - Math.round(scrollBoxObject.height / this.mPixPerMin); + aMinute = Math.min(maxFirstMin, Math.max(0, aMinute)); + + if (scrollBoxObject && scrollbox.scrollHeight > 0) { + let x = {}, y = {}; + scrollBoxObject.getPosition(x, y); + let pos = Math.round(aMinute * this.mPixPerMin); + if (scrollbox.getAttribute("orient") == "horizontal") { + scrollBoxObject.scrollTo(x.value, pos); + } else { + scrollBoxObject.scrollTo(pos, y.value); + } + } + + // Set the first visible minute in any case, we want to move to the + // right minute as soon as possible if we couldn't do so above. + this.mFirstVisibleMinute = aMinute; + ]]></body> + </method> + + <method name="setDayStartEndMinutes"> + <parameter name="aDayStartMin"/> + <parameter name="aDayEndMin"/> + <body><![CDATA[ + if (aDayStartMin < this.mStartMin || aDayStartMin > aDayEndMin || + aDayEndMin > this.mEndMin) { + throw Components.results.NS_ERROR_INVALID_ARG; + } + if (this.mDayStartMin != aDayStartMin || + this.mDayEndMin != aDayEndMin) { + this.mDayStartMin = aDayStartMin; + this.mDayEndMin = aDayEndMin; + + // Also update on the time-bar + document.getAnonymousElementByAttribute(this, "anonid", "timebar") + .setDayStartEndHours(this.mDayStartMin / 60, + this.mDayEndMin / 60); + } + + ]]></body> + </method> + + <method name="setVisibleMinutes"> + <parameter name="aVisibleMinutes"/> + <body><![CDATA[ + if (aVisibleMinutes <= 0 || + aVisibleMinutes > (this.mEndMin - this.mStartMin)) { + throw Components.results.NS_ERROR_INVALID_ARG; + } + if (this.mVisibleMinutes != aVisibleMinutes) { + this.mVisibleMinutes = aVisibleMinutes; + } + return this.mVisibleMinutes; + ]]></body> + </method> + + <method name="zoomIn"> + <parameter name="aLevel"/> + <body><![CDATA[ + let visibleHours = Preferences.get("calendar.view.visiblehours", 9); + visibleHours += (aLevel || 1); + + Preferences.set("calendar.view.visiblehours", Math.min(visibleHours, 24)); + ]]></body> + </method> + <method name="zoomOut"> + <parameter name="aLevel"/> + <body><![CDATA[ + let visibleHours = Preferences.get("calendar.view.visiblehours", 9); + visibleHours -= (aLevel || 1); + + Preferences.set("calendar.view.visiblehours", Math.max(1, visibleHours)); + ]]></body> + </method> + <method name="zoomReset"> + <body><![CDATA[ + Preferences.set("calendar.view.visiblehours", 9); + ]]></body> + </method> + </implementation> + + <handlers> + <handler event="click" button="2"><![CDATA[ + this.selectedDateTime = null; + ]]></handler> + <handler event="wheel" phase="bubbling"><![CDATA[ + if (!event.ctrlKey && !event.shiftKey && + !event.altKey && !event.metaKey) { + // Only shift hours if no modifier is pressed. + + let minute = this.mFirstVisibleMinute; + if (event.deltaMode == event.DOM_DELTA_LINE) { + if (this.rotated && event.deltaX != 0) { + minute += event.deltaX < 0 ? -60 : 60; + } else if (!this.rotated && event.deltaY != 0) { + minute += event.deltaY < 0 ? -60 : 60; + } + } else if (event.deltaMode == event.DOM_DELTA_PIXEL) { + if (this.rotated && event.deltaX != 0) { + minute += Math.ceil(event.deltaX / this.mPixPerMin); + } else if (!this.rotated && event.deltaY != 0) { + minute += Math.ceil(event.deltaY / this.mPixPerMin); + } + } + this.scrollToMinute(minute); + } + + // We are taking care of scrolling, so prevent the default + // action in any case. + event.preventDefault(); + ]]></handler> + + <handler event="scroll" phase="bubbling"><![CDATA[ + let scrollbox = document.getAnonymousElementByAttribute(this, "anonid", "scrollbox"); + let scrollBoxObject = scrollbox.boxObject; + if (scrollBoxObject && scrollbox.scrollHeight > 0) { + // We need to update the first visible minute, but only if the + // scrollbox has been sized. + let x = {}, y = {}; + scrollBoxObject.getPosition(x, y); + if (scrollbox.getAttribute("orient") == "horizontal") { + this.mFirstVisibleMinute = Math.round(y.value / this.mPixPerMin); + } else { + this.mFirstVisibleMinute = Math.round(x.value / this.mPixPerMin); + } + } + ]]></handler> + </handlers> + </binding> +</bindings> diff --git a/calendar/base/content/calendar-statusbar.js b/calendar/base/content/calendar-statusbar.js new file mode 100644 index 000000000..360afc5cb --- /dev/null +++ b/calendar/base/content/calendar-statusbar.js @@ -0,0 +1,111 @@ +/* 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/. */ + +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + +/* exported gCalendarStatusFeedback */ + +/** + * This code might change soon if we support Thunderbird's activity manager. + * NOTE: The naming "Meteors" is historical. + */ +var gCalendarStatusFeedback = { + mCalendarStep: 0, + mCalendarCount: 0, + mWindow: null, + mStatusText: null, + mStatusBar: null, + mStatusProgressPanel: null, + mThrobber: null, + mProgressMode: Components.interfaces.calIStatusObserver.NO_PROGRESS, + mCurIndex: 0, + mInitialized: false, + mCalendars: {}, + + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calIStatusObserver]), + + initialize: function(aWindow) { + if (!this.mInitialized) { + this.mWindow = aWindow; + this.mStatusText = this.mWindow.document.getElementById("statusText"); + this.mStatusBar = this.mWindow.document.getElementById("statusbar-icon"); + this.mStatusProgressPanel = this.mWindow.document.getElementById("statusbar-progresspanel"); + this.mThrobber = this.mWindow.document.getElementById("navigator-throbber"); + this.mInitialized = true; + } + }, + + showStatusString: function(status) { + this.mStatusText.setAttribute("label", status); + }, + + get spinning() { + return this.mProgressMode; + }, + + startMeteors: function(aProgressMode, aCalendarCount) { + if (aProgressMode != Components.interfaces.calIStatusObserver.NO_PROGRESS) { + if (!this.mInitialized) { + Components.utils.reportError("StatusObserver has not been initialized!"); + return; + } + this.mCalendars = {}; + this.mCurIndex = 0; + if (aCalendarCount) { + this.mCalendarCount = this.mCalendarCount + aCalendarCount; + this.mCalendarStep = Math.trunc(100 / this.mCalendarCount); + } + this.mProgressMode = aProgressMode; + this.mStatusProgressPanel.removeAttribute("collapsed"); + if (this.mProgressMode == Components.interfaces.calIStatusObserver.DETERMINED_PROGRESS) { + this.mStatusBar.removeAttribute("collapsed"); + this.mStatusBar.setAttribute("mode", "determined"); + this.mStatusBar.value = 0; + let commonStatus = calGetString("calendar", "gettingCalendarInfoCommon"); + this.showStatusString(commonStatus); + } + if (this.mThrobber) { + this.mThrobber.setAttribute("busy", true); + } + } + }, + + stopMeteors: function() { + if (!this.mInitialized) { + return; + } + if (this.spinning != Components.interfaces.calIStatusObserver.NO_PROGRESS) { + this.mProgressMode = Components.interfaces.calIStatusObserver.NO_PROGRESS; + this.mStatusProgressPanel.collapsed = true; + this.mStatusBar.setAttribute("mode", "normal"); + this.mStatusBar.value = 0; + this.mCalendarCount = 0; + this.showStatusString(""); + if (this.mThrobber) { + this.mThrobber.setAttribute("busy", false); + } + } + }, + + calendarCompleted: function(aCalendar) { + if (!this.mInitialized) { + return; + } + if (this.spinning != Components.interfaces.calIStatusObserver.NO_PROGRESS) { + if (this.spinning == Components.interfaces.calIStatusObserver.DETERMINED_PROGRESS) { + if (!this.mCalendars[aCalendar.id] || this.mCalendars[aCalendar.id] === undefined) { + this.mCalendars[aCalendar.id] = true; + this.mStatusBar.value = parseInt(this.mStatusBar.value, 10) + this.mCalendarStep; + this.mCurIndex++; + let curStatus = calGetString("calendar", "gettingCalendarInfoDetail", + [this.mCurIndex, this.mCalendarCount]); + this.showStatusString(curStatus); + } + } + if (this.mThrobber) { + this.mThrobber.setAttribute("busy", true); + } + } + } +}; diff --git a/calendar/base/content/calendar-task-editing.js b/calendar/base/content/calendar-task-editing.js new file mode 100644 index 000000000..a2e84cb62 --- /dev/null +++ b/calendar/base/content/calendar-task-editing.js @@ -0,0 +1,250 @@ +/* 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/. */ + +Components.utils.import("resource://calendar/modules/calAlarmUtils.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.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 = { + /** + * Get the currently observed calendar. + */ + mObservedCalendar: null, + get observedCalendar() { + return this.mObservedCalendar; + }, + + /** + * Set the currently observed calendar, removing listeners to any old + * calendar set and adding listeners to the new one. + */ + set observedCalendar(aCalendar) { + if (this.mObservedCalendar) { + this.mObservedCalendar.removeObserver(this.calendarObserver); + } + + this.mObservedCalendar = aCalendar; + + if (this.mObservedCalendar) { + this.mObservedCalendar.addObserver(this.calendarObserver); + } + return this.mObservedCalendar; + }, + + /** + * 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: function(aTarget, aDisable, aValue) { + aTarget.value = aValue; + setElementValue(aTarget, aDisable && "true", "readonly"); + setElementValue(aTarget, aDisable && "true", "aria-disabled"); + }, + + /** + * Handler function to call when the quick-add textbox gains focus. + * + * @param aEvent The DOM focus event + */ + onFocus: function(aEvent) { + let edit = aEvent.target; + if (edit.localName == "input") { + // For some reason, we only receive an onfocus event for the textbox + // when debugging with venkman. + edit = edit.parentNode.parentNode; + } + + let calendar = getSelectedCalendar(); + edit.showsInstructions = true; + + if (calendar.getProperty("capabilities.tasks.supported") === false) { + taskEdit.setupTaskField(edit, + true, + calGetString("calendar", "taskEditInstructionsCapability")); + } else if (isCalendarWritable(calendar)) { + edit.showsInstructions = false; + taskEdit.setupTaskField(edit, false, edit.savedValue || ""); + } else { + taskEdit.setupTaskField(edit, + true, + calGetString("calendar", "taskEditInstructionsReadonly")); + } + }, + + /** + * Handler function to call when the quick-add textbox loses focus. + * + * @param aEvent The DOM blur event + */ + onBlur: function(aEvent) { + let edit = aEvent.target; + if (edit.localName == "input") { + // For some reason, we only receive the blur event for the input + // element. There are no targets that point to the textbox. Go up + // the parent chain until we reach the textbox. + edit = edit.parentNode.parentNode; + } + + 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, + calGetString("calendar", "taskEditInstructionsCapability")); + } else if (isCalendarWritable(calendar)) { + if (!edit.showsInstructions) { + edit.savedValue = edit.value || ""; + } + taskEdit.setupTaskField(edit, + false, + calGetString("calendar", "taskEditInstructions")); + } else { + taskEdit.setupTaskField(edit, + true, + calGetString("calendar", "taskEditInstructionsReadonly")); + } + edit.showsInstructions = true; + }, + + /** + * Handler function to call on keypress for the quick-add textbox. + * + * @param aEvent The DOM keypress event + */ + onKeyPress: function(aEvent) { + if (aEvent.keyCode == Components.interfaces.nsIDOMKeyEvent.DOM_VK_RETURN) { + let edit = aEvent.target; + if (edit.value && edit.value.length > 0) { + let item = cal.createTodo(); + setDefaultItemValues(item); + item.title = edit.value; + + edit.value = ""; + doTransaction("add", item, item.calendar, null, null); + } + } + }, + + /** + * Window load function to set up all quick-add textboxes. The texbox must + * have the class "task-edit-field". + */ + onLoad: function(aEvent) { + window.removeEventListener("load", taskEdit.onLoad, false); + // TODO use getElementsByClassName + let taskEditFields = document.getElementsByAttribute("class", "task-edit-field"); + for (let i = 0; i < taskEditFields.length; i++) { + taskEdit.onBlur({ target: taskEditFields[i] }); + } + + getCompositeCalendar().addObserver(taskEdit.compositeObserver); + taskEdit.observedCalendar = getSelectedCalendar(); + }, + + /** + * Window load function to clean up all quick-add fields. + */ + onUnload: function() { + getCompositeCalendar().removeObserver(taskEdit.compositeObserver); + taskEdit.observedCalendar = null; + }, + + /** + * Observer to watch for readonly, disabled and capability changes of the + * observed calendar. + * + * @see calIObserver + */ + calendarObserver: { + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calIObserver]), + + // calIObserver: + onStartBatch: function() {}, + onEndBatch: function() {}, + onLoad: function(aCalendar) {}, + onAddItem: function(aItem) {}, + onModifyItem: function(aNewItem, aOldItem) {}, + onDeleteItem: function(aDeletedItem) {}, + onError: function(aCalendar, aErrNo, aMessage) {}, + + onPropertyChanged: function(aCalendar, aName, aValue, aOldValue) { + if (aCalendar.id != getSelectedCalendar().id) { + // Optimization: if the given calendar isn't the default calendar, + // then we don't need to change any readonly/disabled states. + return; + } + switch (aName) { + case "readOnly": + case "disabled": { + let taskEditFields = document.getElementsByAttribute("class", "task-edit-field"); + for (let i = 0; i < taskEditFields.length; i++) { + taskEdit.onBlur({ target: taskEditFields[i] }); + } + break; + } + } + }, + + onPropertyDeleting: function(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); + } + }, + + /** + * Observer to watch for changes to the selected calendar. + * + * XXX I think we don't need to implement calIObserver here. + * + * @see calICompositeObserver + */ + compositeObserver: { + QueryInterface: XPCOMUtils.generateQI([ + Components.interfaces.calIObserver, + Components.interfaces.calICompositeObserver + ]), + + // calIObserver: + onStartBatch: function() {}, + onEndBatch: function() {}, + onLoad: function(aCalendar) {}, + onAddItem: function(aItem) {}, + onModifyItem: function(aNewItem, aOldItem) {}, + onDeleteItem: function(aDeletedItem) {}, + onError: function(aCalendar, aErrNo, aMessage) {}, + onPropertyChanged: function(aCalendar, aName, aValue, aOldValue) {}, + onPropertyDeleting: function(aCalendar, aName) {}, + + // calICompositeObserver: + onCalendarAdded: function(aCalendar) {}, + onCalendarRemoved: function(aCalendar) {}, + onDefaultCalendarChanged: function(aNewDefault) { + let taskEditFields = document.getElementsByAttribute("class", "task-edit-field"); + for (let i = 0; i < taskEditFields.length; i++) { + taskEdit.onBlur({ target: taskEditFields[i] }); + } + taskEdit.observedCalendar = aNewDefault; + } + } +}; + +window.addEventListener("load", taskEdit.onLoad, false); +window.addEventListener("unload", taskEdit.onUnload, false); diff --git a/calendar/base/content/calendar-task-tree.js b/calendar/base/content/calendar-task-tree.js new file mode 100644 index 000000000..fbfc89d7b --- /dev/null +++ b/calendar/base/content/calendar-task-tree.js @@ -0,0 +1,312 @@ +/* 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 addCalendarNames, calendars, changeContextMenuForTask, + * contextChangeTaskCalendar, contextChangeTaskPriority, + * contextPostponeTask, modifyTaskFromContext, deleteToDoCommand, + * tasksToMail, tasksToEvents, toggleCompleted, + */ + +/** + * Add registered calendars to the given menupopup. Removes all previous + * children. + * + * XXX Either replace the existing items using replaceNode, or use helper + * functions (cal.removeChildren). + * + * @param aEvent The popupshowing event of the opening menu + */ +function addCalendarNames(aEvent) { + let calendarMenuPopup = aEvent.target; + while (calendarMenuPopup.hasChildNodes()) { + calendarMenuPopup.lastChild.remove(); + } + let tasks = getSelectedTasks(aEvent); + let tasksSelected = (tasks.length > 0); + if (tasksSelected) { + let selIndex = appendCalendarItems(tasks[0], calendarMenuPopup, null, "contextChangeTaskCalendar(event);"); + if (isPropertyValueSame(tasks, "calendar") && (selIndex > -1)) { + calendarMenuPopup.childNodes[selIndex].setAttribute("checked", "true"); + } + } +} + +/** + * Change the opening context menu for the selected tasks. + * + * @param aEvent The popupshowing event of the opening menu. + */ +function changeContextMenuForTask(aEvent) { + handleTaskContextMenuStateChange(aEvent); + + let idnode = document.popupNode.id; + let items = getSelectedTasks(aEvent); + document.getElementById("task-context-menu-new").hidden = + (idnode == "unifinder-todo-tree"); + document.getElementById("task-context-menu-modify").hidden = + (idnode == "unifinder-todo-tree"); + document.getElementById("task-context-menu-new-todaypane").hidden = + (idnode == "calendar-task-tree"); + document.getElementById("task-context-menu-modify-todaypane").hidden = + (idnode == "calendar-task-tree"); + document.getElementById("task-context-menu-filter-todaypane").hidden = + (idnode == "calendar-task-tree"); + document.getElementById("task-context-menu-separator-filter").hidden = + (idnode == "calendar-task-tree"); + + let tasksSelected = (items.length > 0); + applyAttributeToMenuChildren(aEvent.target, "disabled", (!tasksSelected)); + if (calendarController.isCommandEnabled("calendar_new_todo_command") && + calendarController.isCommandEnabled("calendar_new_todo_todaypane_command")) { + document.getElementById("calendar_new_todo_command").removeAttribute("disabled"); + document.getElementById("calendar_new_todo_todaypane_command").removeAttribute("disabled"); + } else { + document.getElementById("calendar_new_todo_command").setAttribute("disabled", "true"); + document.getElementById("calendar_new_todo_todaypane_command").setAttribute("disabled", "true"); + } + + // make sure the "Paste" and "Cut" menu items are enabled + goUpdateCommand("cmd_paste"); + goUpdateCommand("cmd_cut"); + + // make sure the filter menu is enabled + document.getElementById("task-context-menu-filter-todaypane").removeAttribute("disabled"); + applyAttributeToMenuChildren(document.getElementById("task-context-menu-filter-todaypane-popup"), + "disabled", false); + + changeMenuForTask(aEvent); + + let menu = document.getElementById("task-context-menu-attendance-menu"); + setupAttendanceMenu(menu, items); +} + +/** + * Notify the task tree that the context menu open state has changed. + * + * @param aEvent The popupshowing or popuphiding event of the menu. + */ +function handleTaskContextMenuStateChange(aEvent) { + let tree = document.popupNode; + + if (tree) { + tree.updateFocus(); + } +} + +/** + * Change the opening menu for the selected tasks. + * + * @param aEvent The popupshowing event of the opening menu. + */ +function changeMenuForTask(aEvent) { + // Make sure to update the status of some commands. + ["calendar_delete_todo_command", + "calendar_toggle_completed_command", + "calendar_general-progress_command", + "calendar_general-priority_command", + "calendar_general-postpone_command"].forEach(goUpdateCommand); + + let tasks = getSelectedTasks(aEvent); + let tasksSelected = (tasks.length > 0); + if (tasksSelected) { + let cmd = document.getElementById("calendar_toggle_completed_command"); + if (isPropertyValueSame(tasks, "isCompleted")) { + setBooleanAttribute(cmd, "checked", tasks[0].isCompleted); + } else { + setBooleanAttribute(cmd, "checked", false); + } + } +} + +/** + * Handler function to change the progress of all selected tasks, or of + * the task loaded in the current tab. + * + * @param {XULCommandEvent} aEvent The DOM event that triggered this command + * @param {short} aProgress The new progress percentage + */ +function contextChangeTaskProgress(aEvent, aProgress) { + if (gTabmail && gTabmail.currentTabInfo.mode.type == "calendarTask") { + editToDoStatus(aProgress); + } else { + startBatchTransaction(); + let tasks = getSelectedTasks(aEvent); + for (let task of tasks) { + let newTask = task.clone().QueryInterface(Components.interfaces.calITodo); + newTask.percentComplete = aProgress; + switch (aProgress) { + case 0: + newTask.isCompleted = false; + break; + case 100: + newTask.isCompleted = true; + break; + default: + newTask.status = "IN-PROCESS"; + newTask.completedDate = null; + break; + } + doTransaction("modify", newTask, newTask.calendar, task, null); + } + endBatchTransaction(); + } +} + +/** + * Handler function to change the calendar of the selected tasks. The targeted + * menuitem must have "calendar" property that implements calICalendar. + * + * @param aEvent The DOM event that triggered this command. + */ +function contextChangeTaskCalendar(aEvent) { + startBatchTransaction(); + let tasks = getSelectedTasks(aEvent); + for (let task of tasks) { + let newTask = task.clone(); + newTask.calendar = aEvent.target.calendar; + doTransaction("modify", newTask, newTask.calendar, task, null); + } + endBatchTransaction(); +} + +/** + * Handler function to change the priority of the selected tasks, or of + * the task loaded in the current tab. + * + * @param {XULCommandEvent} aEvent The DOM event that triggered this command + * @param {short} aPriority The priority to set on the task(s) + */ +function contextChangeTaskPriority(aEvent, aPriority) { + let tabType = gTabmail && gTabmail.currentTabInfo.mode.type; + if (tabType == "calendarTask" || tabType == "calendarEvent") { + editConfigState({ priority: aPriority }); + } else { + startBatchTransaction(); + let tasks = getSelectedTasks(aEvent); + for (let task of tasks) { + let newTask = task.clone().QueryInterface(Components.interfaces.calITodo); + newTask.priority = aPriority; + doTransaction("modify", newTask, newTask.calendar, task, null); + } + endBatchTransaction(); + } +} + +/** + * Handler function to postpone the start and due dates of the selected + * tasks, or of the task loaded in the current tab. 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 {XULCommandEvent} aEvent The DOM event that triggered this command + * @param {string} aDuration The duration to postpone in ISO 8601 format + */ +function contextPostponeTask(aEvent, aDuration) { + let duration = cal.createDuration(aDuration); + if (!duration) { + cal.LOG("[calendar-task-tree] Postpone Task - Invalid duration " + aDuration); + return; + } + + if (gTabmail && gTabmail.currentTabInfo.mode.type == "calendarTask") { + postponeTask(aDuration); + } else { + startBatchTransaction(); + let tasks = getSelectedTasks(aEvent); + + tasks.forEach((task) => { + if (task.entryDate || task.dueDate) { + let newTask = task.clone(); + cal.shiftItem(newTask, duration); + doTransaction("modify", newTask, newTask.calendar, task, null); + } + }); + + endBatchTransaction(); + } +} + +/** + * Modifies the selected tasks with the event dialog + * + * @param aEvent The DOM event that triggered this command. + * @param initialDate (optional) The initial date for new task datepickers + */ +function modifyTaskFromContext(aEvent, initialDate) { + let tasks = getSelectedTasks(aEvent); + for (let task of tasks) { + modifyEventWithDialog(task, null, true, initialDate); + } +} + +/** + * Delete the current selected item with focus from the task tree + * + * @param aEvent The DOM event that triggered this command. + * @param aDoNotConfirm If true, the user will not be asked to delete. + */ +function deleteToDoCommand(aEvent, aDoNotConfirm) { + let tasks = getSelectedTasks(aEvent); + calendarViewController.deleteOccurrences(tasks.length, + tasks, + false, + aDoNotConfirm); +} + +/** + * Gets the currently visible task tree + * + * @return The XUL task tree element. + */ +function getTaskTree() { + let currentMode = document.getElementById("modeBroadcaster").getAttribute("mode"); + if (currentMode == "task") { + return document.getElementById("calendar-task-tree"); + } else { + return document.getElementById("unifinder-todo-tree"); + } +} + +/** + * Gets the tasks selected in the currently visible task tree. + * + * XXX Parameter aEvent is unused, needs to be removed here and in calling + * functions. + * + * @param aEvent Unused + */ +function getSelectedTasks(aEvent) { + let taskTree = getTaskTree(); + return taskTree ? taskTree.selectedTasks : []; +} + +/** + * Convert selected tasks to emails. + */ +function tasksToMail(aEvent) { + let tasks = getSelectedTasks(aEvent); + calendarMailButtonDNDObserver.onDropItems(tasks); +} + +/** + * Convert selected tasks to events. + */ +function tasksToEvents(aEvent) { + let tasks = getSelectedTasks(aEvent); + calendarCalendarButtonDNDObserver.onDropItems(tasks); +} + +/** + * Toggle the completed state on selected tasks. + * + * @param aEvent The originating event, can be null. + */ +function toggleCompleted(aEvent) { + if (aEvent.target.getAttribute("checked") == "true") { + contextChangeTaskProgress(aEvent, 0); + } else { + contextChangeTaskProgress(aEvent, 100); + } +} diff --git a/calendar/base/content/calendar-task-tree.xml b/calendar/base/content/calendar-task-tree.xml new file mode 100644 index 000000000..cb879b422 --- /dev/null +++ b/calendar/base/content/calendar-task-tree.xml @@ -0,0 +1,1195 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<!DOCTYPE dialog [ + <!ENTITY % dtd1 SYSTEM "chrome://calendar/locale/global.dtd" > %dtd1; + <!ENTITY % dtd2 SYSTEM "chrome://calendar/locale/calendar.dtd" > %dtd2; +]> + +<bindings id="calendar-task-tree-bindings" + xmlns="http://www.mozilla.org/xbl" + xmlns:xbl="http://www.mozilla.org/xbl" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <binding id="calendar-task-tree"> + <resources> + <stylesheet src="chrome://calendar/skin/calendar-task-tree.css"/> + </resources> + <content> + <xul:tree anonid="calendar-task-tree" + class="calendar-task-tree" + flex="1" + enableColumnDrag="false" + keepcurrentinview="true"> + <xul:treecols anonid="calendar-task-tree-cols"> + <xul:treecol anonid="calendar-task-tree-col-completed" + class="calendar-task-tree-col-completed" + minwidth="19" + fixed="true" + cycler="true" + sortKey="completedDate" + itemproperty="completed" + label="&calendar.unifinder.tree.done.label;" + tooltiptext="&calendar.unifinder.tree.done.tooltip2;"> + <xul:image anonid="checkboximg" /> + </xul:treecol> + <xul:splitter class="tree-splitter" ordinal="2"/> + <xul:treecol anonid="calendar-task-tree-col-priority" + class="calendar-task-tree-col-priority" + minwidth="17" + fixed="true" + itemproperty="priority" + label="&calendar.unifinder.tree.priority.label;" + tooltiptext="&calendar.unifinder.tree.priority.tooltip2;"> + <xul:image anonid="priorityimg"/> + </xul:treecol> + <xul:splitter class="tree-splitter" ordinal="4"/> + <xul:treecol anonid="calendar-task-tree-col-title" + flex="1" + itemproperty="title" + label="&calendar.unifinder.tree.title.label;" + tooltiptext="&calendar.unifinder.tree.title.tooltip2;"/> + <xul:splitter class="tree-splitter" ordinal="6"/> + <xul:treecol anonid="calendar-task-tree-col-entrydate" + itemproperty="entryDate" + flex="1" + label="&calendar.unifinder.tree.startdate.label;" + tooltiptext="&calendar.unifinder.tree.startdate.tooltip2;"/> + <xul:splitter class="tree-splitter" ordinal="8"/> + <xul:treecol anonid="calendar-task-tree-col-duedate" + itemproperty="dueDate" + flex="1" + label="&calendar.unifinder.tree.duedate.label;" + tooltiptext="&calendar.unifinder.tree.duedate.tooltip2;"/> + <xul:splitter class="tree-splitter" ordinal="10"/> + <xul:treecol anonid="calendar-task-tree-col-duration" + sortKey="dueDate" + itemproperty="duration" + flex="1" + label="&calendar.unifinder.tree.duration.label;" + tooltiptext="&calendar.unifinder.tree.duration.tooltip2;"/> + <xul:splitter class="tree-splitter" ordinal="12"/> + <xul:treecol anonid="calendar-task-tree-col-completeddate" + itemproperty="completedDate" + flex="1" + label="&calendar.unifinder.tree.completeddate.label;" + tooltiptext="&calendar.unifinder.tree.completeddate.tooltip2;"/> + <xul:splitter class="tree-splitter" ordinal="14"/> + <xul:treecol anonid="calendar-task-tree-col-percentcomplete" + flex="1" + type="progressmeter" + minwidth="19" + itemproperty="percentComplete" + label="&calendar.unifinder.tree.percentcomplete.label;" + tooltiptext="&calendar.unifinder.tree.percentcomplete.tooltip2;"/> + <xul:splitter class="tree-splitter" ordinal="16"/> + <xul:treecol anonid="calendar-task-tree-col-categories" + itemproperty="categories" + flex="1" + label="&calendar.unifinder.tree.categories.label;" + tooltiptext="&calendar.unifinder.tree.categories.tooltip2;"/> + <xul:splitter class="tree-splitter" ordinal="18"/> + <xul:treecol anonid="calendar-task-tree-col-location" + itemproperty="location" + label="&calendar.unifinder.tree.location.label;" + tooltiptext="&calendar.unifinder.tree.location.tooltip2;"/> + <xul:splitter class="tree-splitter" ordinal="20"/> + <xul:treecol anonid="calendar-task-tree-col-status" + flex="1" + itemproperty="status" + label="&calendar.unifinder.tree.status.label;" + tooltiptext="&calendar.unifinder.tree.status.tooltip2;"/> + <xul:splitter class="tree-splitter" ordinal="22"/> + <xul:treecol anonid="calendar-task-tree-col-calendarname" + flex="1" + itemproperty="calendar" + label="&calendar.unifinder.tree.calendarname.label;" + tooltiptext="&calendar.unifinder.tree.calendarname.tooltip2;"/> + </xul:treecols> + <xul:treechildren tooltip="taskTreeTooltip" ondblclick="mTreeView.onDoubleClick(event)"/> + </xul:tree> + </content> + + <implementation implements="nsIObserver"> + <constructor><![CDATA[ + Components.utils.import("resource://gre/modules/PluralForm.jsm"); + Components.utils.import("resource://gre/modules/Services.jsm"); + Components.utils.import("resource://calendar/modules/calItemUtils.jsm"); + Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + + // set up the tree filter + this.mFilter = new calFilter(); + + // set up the custom tree view + let tree = document.getAnonymousElementByAttribute(this, "anonid", "calendar-task-tree"); + this.mTreeView.tree = tree; + tree.view = this.mTreeView; + + // set up our calendar event observer + let composite = getCompositeCalendar(); + composite.addObserver(this.mTaskTreeObserver); + + // set up the preference observer + let branch = Services.prefs.getBranch(""); + branch.addObserver("calendar.", this, false); + + + // we want to make several attributes on the column + // elements persistent, but unfortunately there's no + // relyable way with the 'persist' feature. + // that's why we need to store the necessary bits and + // pieces at the element this binding is attached to. + let names = this.getAttribute("visible-columns").split(" "); + let ordinals = this.getAttribute("ordinals").split(" "); + let widths = this.getAttribute("widths").split(" "); + let sorted = this.getAttribute("sort-active"); + let sortDirection = this.getAttribute("sort-direction") || "ascending"; + tree = document.getAnonymousNodes(this)[0]; + let treecols = tree.getElementsByTagNameNS(tree.namespaceURI, "treecol"); + for (let i = 0; i < treecols.length; i++) { + let content = treecols[i].getAttribute("itemproperty"); + if (names.some(element => element == content)) { + treecols[i].removeAttribute("hidden"); + } else { + treecols[i].setAttribute("hidden", "true"); + } + if (ordinals && ordinals.length > 0) { + treecols[i].ordinal = Number(ordinals.shift()); + } + if (widths && widths.length > 0) { + treecols[i].width = Number(widths.shift()); + } + if (sorted && sorted.length > 0) { + if (sorted == content) { + this.mTreeView.sortDirection = sortDirection; + this.mTreeView.selectedColumn = treecols[i]; + } + } + } + ]]></constructor> + <destructor><![CDATA[ + Components.utils.import("resource://gre/modules/Services.jsm"); + + // remove composite calendar observer + let composite = getCompositeCalendar(); + composite.removeObserver(this.mTaskTreeObserver); + + // remove the preference observer + let branch = Services.prefs.getBranch(""); + branch.removeObserver("calendar.", this, false); + + let widths = ""; + let ordinals = ""; + let visible = ""; + let sorted = this.mTreeView.selectedColumn; + let tree = document.getAnonymousNodes(this)[0]; + let treecols = tree.getElementsByTagNameNS(tree.namespaceURI, "treecol"); + for (let i = 0; i < treecols.length; i++) { + if (treecols[i].getAttribute("hidden") != "true") { + let content = treecols[i].getAttribute("itemproperty"); + visible += visible.length > 0 ? " " + content : content; + } + if (ordinals.length > 0) { + ordinals += " "; + } + ordinals += treecols[i].ordinal; + if (widths.length > 0) { + widths += " "; + } + widths += treecols[i].width || 0; + } + this.setAttribute("visible-columns", visible); + this.setAttribute("ordinals", ordinals); + this.setAttribute("widths", widths); + if (sorted) { + this.setAttribute("sort-active", sorted.getAttribute("itemproperty")); + this.setAttribute("sort-direction", this.mTreeView.sortDirection); + } else { + this.removeAttribute("sort-active"); + this.removeAttribute("sort-direction"); + } + ]]></destructor> + + <field name="mTaskArray">[]</field> + <field name="mHash2Index"><![CDATA[({})]]></field> + <field name="mPendingRefreshJobs"><![CDATA[({})]]></field> + <field name="mShowCompletedTasks">true</field> + <field name="mFilter">null</field> + <field name="mStartDate">null</field> + <field name="mEndDate">null</field> + <field name="mDateRangeFilter">null</field> + <field name="mTextFilterField">null</field> + + <property name="currentIndex"> + <getter><![CDATA[ + let tree = document.getAnonymousElementByAttribute( + this, "anonid", "calendar-task-tree"); + return tree.currentIndex; + ]]></getter> + </property> + + <property name="currentTask"> + <getter><![CDATA[ + let tree = document.getAnonymousElementByAttribute( + this, "anonid", "calendar-task-tree"); + let index = tree.currentIndex; + if (tree.view && tree.view.selection) { + // If the current index is not selected, then ignore + index = (tree.view.selection.isSelected(index) ? index : -1); + } + return index < 0 ? null : this.mTaskArray[index]; + ]]></getter> + </property> + + <property name="selectedTasks" readonly="true"> + <getter><![CDATA[ + let tasks = []; + let start = {}; + let end = {}; + if (!this.mTreeView.selection) { + return tasks; + } + + let rangeCount = this.mTreeView.selection.getRangeCount(); + for (let range = 0; range < rangeCount; range++) { + this.mTreeView.selection.getRangeAt(range, start, end); + for (let i = start.value; i <= end.value; i++) { + let task = this.getTaskAtRow(i); + if (task) { + tasks.push(this.getTaskAtRow(i)); + } + } + } + return tasks; + ]]></getter> + </property> + + <property name="showCompleted"> + <getter><![CDATA[ + return this.mShowCompletedTasks; + ]]></getter> + <setter><![CDATA[ + this.mShowCompletedTasks = val; + return val; + ]]></setter> + </property> + + <property name="textFilterField"> + <getter><![CDATA[ + return this.mTextFilterField; + ]]></getter> + <setter><![CDATA[ + this.mTextFilterField = val; + return val; + ]]></setter> + </property> + + <method name="duration"> + <parameter name="aTask"/> + <body><![CDATA[ + if (aTask && aTask.dueDate && aTask.dueDate.isValid) { + let dur = aTask.dueDate.subtractDate(cal.now()); + if (!dur.isNegative) { + let minutes = Math.ceil(dur.inSeconds / 60); + if (minutes >= 1440) { // 1 day or more + let dueIn = PluralForm.get(dur.days, calGetString("calendar", "dueInDays")); + return dueIn.replace("#1", dur.days); + } else if (minutes >= 60) { // 1 hour or more + let dueIn = PluralForm.get(dur.hours, calGetString("calendar", "dueInHours")); + return dueIn.replace("#1", dur.hours); + } else { + // Less than one hour + return calGetString("calendar", "dueInLessThanOneHour"); + } + } else if (!aTask.completedDate || !aTask.completedDate.isValid) { + // Overdue task + let minutes = Math.ceil(-dur.inSeconds / 60); + if (minutes >= 1440) { // 1 day or more + let dueIn = PluralForm.get(dur.days, calGetString("calendar", "dueInDays")); + return "-" + dueIn.replace("#1", dur.days); + } else if (minutes >= 60) { // 1 hour or more + let dueIn = PluralForm.get(dur.hours, calGetString("calendar", "dueInHours")); + return "-" + dueIn.replace("#1", dur.hours); + } else { + // Less than one hour + return calGetString("calendar", "dueInLessThanOneHour"); + } + } + } + // No due date specified + return null; + ]]></body> + </method> + <method name="getTaskAtRow"> + <parameter name="aRow"/> + <body><![CDATA[ + return (aRow > -1 ? this.mTaskArray[aRow] : null); + ]]></body> + </method> + + <method name="getTaskFromEvent"> + <parameter name="aEvent"/> + <body><![CDATA[ + return this.mTreeView._getItemFromEvent(aEvent); + ]]></body> + </method> + + <field name="mTreeView"><![CDATA[ + ({ + /** + * Attributes + */ + + // back reference to the binding + binding: this, + tree: null, + treebox: null, + mSelectedColumn: null, + sortDirection: null, + + get selectedColumn() { + return this.mSelectedColumn; + }, + + set selectedColumn(aCol) { + let tree = document.getAnonymousNodes(this.binding)[0]; + let treecols = tree.getElementsByTagNameNS(tree.namespaceURI, "treecol"); + for (let i = 0; i < treecols.length; i++) { + let col = treecols[i]; + if (col.getAttribute("sortActive")) { + col.removeAttribute("sortActive"); + col.removeAttribute("sortDirection"); + } + if (aCol.getAttribute("itemproperty") == col.getAttribute("itemproperty")) { + col.setAttribute("sortActive", "true"); + col.setAttribute("sortDirection", this.sortDirection); + } + } + return (this.mSelectedColumn = aCol); + }, + + /** + * High-level task tree manipulation + */ + + // Adds an array of items to the list if they match the currently applied filter. + addItems: function(aItems, aDontSort) { + this.modifyItems(aItems, [], aDontSort, true); + }, + + // Removes an array of items from the list. + removeItems: function(aItems) { + this.modifyItems([], aItems, true, false); + }, + + // Removes an array of old items from the list, and adds an array of new items if + // they match the currently applied filter. + modifyItems: function(aNewItems, aOldItems, aDontSort, aSelectNew) { + let selItem = this.binding.currentTask; + let selIndex = this.tree.currentIndex; + let firstHash = null; + let remIndexes = []; + aNewItems = aNewItems || []; + aOldItems = aOldItems || []; + + this.treebox.beginUpdateBatch(); + + let idiff = new itemDiff(); + idiff.load(aOldItems); + idiff.difference(aNewItems); + idiff.complete(); + let delItems = idiff.deletedItems; + let addItems = idiff.addedItems; + let modItems = idiff.modifiedItems; + + // find the indexes of the old items that need to be removed + for (let item of delItems.mArray) { + if (item.hashId in this.binding.mHash2Index) { + // the old item needs to be removed + remIndexes.push(this.binding.mHash2Index[item.hashId]); + delete this.binding.mHash2Index[item.hashId]; + } + } + + // modified items need to be updated + for (let item of modItems.mArray) { + if (item.hashId in this.binding.mHash2Index) { + // make sure we're using the new version of a modified item + this.binding.mTaskArray[this.binding.mHash2Index[item.hashId]] = item; + } + } + + // remove the old items working backward from the end so the indexes stay valid + remIndexes.sort((a, b) => b - a).forEach((index) => { + this.binding.mTaskArray.splice(index, 1); + this.treebox.rowCountChanged(index, -1); + }); + + // add the new items + for (let item of addItems.mArray) { + if (!(item.hashId in this.binding.mHash2Index)) { + let index = this.binding.mTaskArray.length; + this.binding.mTaskArray.push(item); + this.binding.mHash2Index[item.hashId] = index; + this.treebox.rowCountChanged(index, 1); + firstHash = firstHash || item.hashId; + } + } + + if (aDontSort) { + this.binding.recreateHashTable(); + } else { + this.binding.sortItems(); + } + + if (aSelectNew && firstHash && firstHash in this.binding.mHash2Index) { + // select the first item added into the list + selIndex = this.binding.mHash2Index[firstHash]; + } else if (selItem && selItem.hashId in this.binding.mHash2Index) { + // select the previously selected item + selIndex = this.binding.mHash2Index[selItem.hashId]; + } else if (selIndex >= this.binding.mTaskArray.length) { + // make sure the previously selected index is valid + selIndex = this.binding.mTaskArray.length - 1; + } + + if (selIndex > -1) { + this.tree.view.selection.select(selIndex); + this.treebox.ensureRowIsVisible(selIndex); + } + + this.treebox.endUpdateBatch(); + }, + + clear: function() { + let count = this.binding.mTaskArray.length; + if (count > 0) { + this.binding.mTaskArray = []; + this.binding.mHash2Index = {}; + this.treebox.rowCountChanged(0, -count); + this.tree.view.selection.clearSelection(); + } + }, + + updateItem: function(aItem) { + let index = this.binding.mHash2Index[aItem.hashId]; + if (index) { + this.treebox.invalidateRow(index); + } + }, + + /** + * nsITreeView methods and properties + */ + + get rowCount() { + return this.binding.mTaskArray.length; + }, + + // TODO this code is currently identical to the unifinder. We should + // create an itemTreeView that these tree views can inherit, that + // contains this code, and possibly other code related to sorting and + // storing items. See bug 432582 for more details. + getCellProperties: function(aRow, aCol) { + let rowProps = this.getRowProperties(aRow); + let colProps = this.getColumnProperties(aCol); + return rowProps + (rowProps && colProps ? " " : "") + colProps; + }, + + // Called to get properties to paint a column background. + // For shading the sort column, etc. + getColumnProperties: function(aCol) { + return aCol.element.getAttribute("anonid") || ""; + }, + + getRowProperties: function(aRow) { + let properties = []; + let item = this.binding.mTaskArray[aRow]; + if (item.priority > 0 && item.priority < 5) { + properties.push("highpriority"); + } else if (item.priority > 5 && item.priority < 10) { + properties.push("lowpriority"); + } + properties.push(getProgressAtom(item)); + + // Add calendar name and id atom + properties.push("calendar-" + formatStringForCSSRule(item.calendar.name)); + properties.push("calendarid-" + formatStringForCSSRule(item.calendar.id)); + + // Add item status atom + if (item.status) { + properties.push("status-" + item.status.toLowerCase()); + } + + // Alarm status atom + if (item.getAlarms({}).length) { + properties.push("alarm"); + } + + // Task categories + properties = properties.concat(item.getCategories({}) + .map(formatStringForCSSRule)); + + return properties.join(" "); + }, + + // Called on the view when a cell in a non-selectable cycling + // column (e.g., unread/flag/etc.) is clicked. + cycleCell: function(aRow, aCol) { + let task = this.binding.mTaskArray[aRow]; + + // prevent toggling completed status for parent items of + // repeating tasks or when the calendar is read-only. + if (!task || task.recurrenceInfo || task.calendar.readOnly) { + return; + } + if (aCol != null) { + let content = aCol.element.getAttribute("itemproperty"); + if (content == "completed") { + let newTask = task.clone().QueryInterface(Components.interfaces.calITodo); + newTask.isCompleted = !task.completedDate; + doTransaction("modify", newTask, newTask.calendar, task, null); + } + } + }, + + // Called on the view when a header is clicked. + cycleHeader: function(aCol) { + if (!this.selectedColumn) { + this.sortDirection = "ascending"; + } else if (!this.sortDirection || this.sortDirection == "descending") { + this.sortDirection = "ascending"; + } else { + this.sortDirection = "descending"; + } + this.selectedColumn = aCol.element; + let selectedItems = this.binding.selectedTasks; + this.binding.sortItems(); + if (selectedItems != undefined) { + this.tree.view.selection.clearSelection(); + for (let item of selectedItems) { + let index = this.binding.mHash2Index[item.hashId]; + this.tree.view.selection.toggleSelect(index); + } + } + }, + + // The text for a given cell. If a column consists only of an + // image, then the empty string is returned. + getCellText: function(aRow, aCol) { + let task = this.binding.mTaskArray[aRow]; + if (!task) { + return false; + } + + switch (aCol.element.getAttribute("itemproperty")) { + case "title": + // return title, or "Untitled" if empty/null + return (task.title ? task.title.replace(/\n/g, " ") : calGetString("calendar", "eventUntitled")); + case "entryDate": + return task.recurrenceInfo ? calGetString("dateFormat", "Repeating") : this._formatDateTime(task.entryDate); + case "dueDate": + return task.recurrenceInfo ? calGetString("dateFormat", "Repeating") : this._formatDateTime(task.dueDate); + case "completedDate": + return task.recurrenceInfo ? calGetString("dateFormat", "Repeating") : this._formatDateTime(task.completedDate); + case "percentComplete": + return (task.percentComplete > 0 ? task.percentComplete + "%" : ""); + case "categories": + return task.getCategories({}).join(", "); // TODO l10n-unfriendly + case "location": + return task.getProperty("LOCATION"); + case "status": + return getToDoStatusString(task); + case "calendar": + return task.calendar.name; + case "duration": + return this.binding.duration(task); + case "completed": + case "priority": + default: + return ""; + } + }, + + // This method is only called for columns of type other than text. + getCellValue: function(aRow, aCol) { + let task = this.binding.mTaskArray[aRow]; + if (!task) { + return null; + } + switch (aCol.element.getAttribute("itemproperty")) { + case "percentComplete": + return task.percentComplete; + } + return null; + }, + + // SetCellValue is called when the value of the cell has been set by the user. + // This method is only called for columns of type other than text. + setCellValue: function(aRow, aCol, aValue) { + return null; + }, + + // The image path for a given cell. For defining an icon for a cell. + // If the empty string is returned, the :moz-tree-image pseudoelement will be used. + getImageSrc: function(aRow, aCol) { + // Return the empty string in order + // to use moz-tree-image pseudoelement : + // it is mandatory to return "" and not false :-( + return ""; + }, + + // IsEditable is called to ask the view if the cell contents are editable. + // A value of true will result in the tree popping up a text field when the user + // tries to inline edit the cell. + isEditable: function(aRow, aCol) { + return true; + }, + + // Called during initialization to link the view to the front end box object. + setTree: function(aTreeBox) { + this.treebox = aTreeBox; + }, + + // Methods that can be used to test whether or not a twisty should + // be drawn, and if so, whether an open or closed twisty should be used. + isContainer: function(aRow) { + return false; + }, + isContainerOpen: function(aRow) { + return false; + }, + isContainerEmpty: function(aRow) { + return false; + }, + + // IsSeparator is used to determine if the row at index is a separator. + // A value of true will result in the tree drawing a horizontal separator. + // The tree uses the ::moz-tree-separator pseudoclass to draw the separator. + isSeparator: function(aRow) { + return false; + }, + + // Specifies if there is currently a sort on any column. + // Used mostly by drag'n'drop to affect drop feedback. + isSorted: function(aRow) { + return false; + }, + + canDrop: function() { return false; }, + + drop: function(aRow, aOrientation) {}, + + getParentIndex: function(aRow) { + return -1; + }, + + // The level is an integer value that represents the level of indentation. + // It is multiplied by the width specified in the :moz-tree-indentation + // pseudoelement to compute the exact indendation. + getLevel: function(aRow) { + return 0; + }, + + // The image path for a given cell. For defining an icon for a cell. + // If the empty string is returned, the :moz-tree-image pseudoelement + // will be used. + getImgSrc: function(aRow, aCol) { + return null; + }, + + // The progress mode for a given cell. This method is only called for + // columns of type |progressmeter|. + getProgressMode: function(aRow, aCol) { + switch (aCol.element.getAttribute("itemproperty")) { + case "percentComplete": { + let task = this.binding.mTaskArray[aRow]; + if (aCol.element.boxObject.width > 75 && + task.percentComplete > 0) { + // XXX Would be nice if we could use relative widths, + // i.e "15ex", but there is no scriptable interface. + return Components.interfaces.nsITreeView.PROGRESS_NORMAL; + } + break; + } + } + + return Components.interfaces.nsITreeView.PROGRESS_NONE; + }, + + /** + * Task Tree Events + */ + onSelect: function(event) {}, + + onDoubleClick: function(event) { + if (event.button == 0) { + let initialDate = getDefaultStartDate(this.binding.getInitialDate()); + let col = {}; + let item = this._getItemFromEvent(event, col); + if (item) { + let colAnonId = col.value.element.getAttribute("itemproperty"); + if (colAnonId == "completed") { + // item holds checkbox state toggled by first click, + // so don't call modifyEventWithDialog + // to make sure user notices state changed. + } else { + modifyEventWithDialog(item, null, true, initialDate); + } + } else { + createTodoWithDialog(null, null, null, null, initialDate); + } + } + }, + + onKeyPress: function(event) { + switch (event.key) { + case "Delete": { + document.popupNode = this.binding; + document.getElementById("calendar_delete_todo_command").doCommand(); + event.preventDefault(); + event.stopPropagation(); + break; + } + case " ": { + if (this.tree.currentIndex > -1) { + let col = document.getAnonymousElementByAttribute( + this.binding, "itemproperty", "completed"); + this.cycleCell( + this.tree.currentIndex, + { element: col }); + } + break; + } + case "Enter": { + let index = this.tree.currentIndex; + if (index > -1) { + modifyEventWithDialog(this.binding.mTaskArray[index]); + } + break; + } + } + }, + + // Set the context menu on mousedown to change it before it is opened + onMouseDown: function(event) { + let tree = document.getAnonymousElementByAttribute(this.binding, + "anonid", + "calendar-task-tree"); + + if (!this._getItemFromEvent(event)) { + tree.view.selection.invalidateSelection(); + } + }, + + /** + * Private methods and attributes + */ + + _getItemFromEvent: function(event, aCol, aRow) { + aRow = aRow || {}; + let childElt = {}; + this.treebox.getCellAt(event.clientX, event.clientY, aRow, aCol || {}, childElt); + if (!childElt.value) { + return false; + } + return aRow && aRow.value > -1 && this.binding.mTaskArray[aRow.value]; + }, + + // Helper function to display datetimes + _formatDateTime: function(aDateTime) { + let dateFormatter = Components.classes["@mozilla.org/calendar/datetime-formatter;1"] + .getService(Components.interfaces.calIDateTimeFormatter); + + // datetime is from todo object, it is not a javascript date + if (aDateTime && aDateTime.isValid) { + let dateTime = aDateTime.getInTimezone(calendarDefaultTimezone()); + return dateFormatter.formatDateTime(dateTime); + } + return ""; + } + }) + ]]></field> + + <!-- + Observer for the calendar event data source. This keeps the unifinder + display up to date when the calendar event data is changed + --> + <field name="mTaskTreeObserver"><![CDATA[ + ({ + binding: this, + + QueryInterface: XPCOMUtils.generateQI([ + Components.interfaces.calICompositeObserver, + Components.interfaces.calIObserver + ]), + + /** + * calIObserver methods and properties + */ + onStartBatch: function() { + }, + + onEndBatch: function() { + }, + + onLoad: function() { + this.binding.refresh(); + }, + + onAddItem: function(aItem) { + if (cal.isToDo(aItem)) { + this.binding.mTreeView.addItems(this.binding.mFilter.getOccurrences(aItem)); + } + }, + + onModifyItem: function(aNewItem, aOldItem) { + if (cal.isToDo(aNewItem) || cal.isToDo(aOldItem)) { + this.binding.mTreeView.modifyItems(this.binding.mFilter.getOccurrences(aNewItem), + this.binding.mFilter.getOccurrences(aOldItem)); + + // we also need to notify potential listeners. + let event = document.createEvent("Events"); + event.initEvent("select", true, false); + this.binding.dispatchEvent(event); + } + }, + + onDeleteItem: function(aDeletedItem) { + if (cal.isToDo(aDeletedItem)) { + this.binding.mTreeView.removeItems(this.binding.mFilter.getOccurrences(aDeletedItem)); + } + }, + + onError: function(aCalendar, aErrNo, aMessage) {}, + onPropertyChanged: function(aCalendar, aName, aValue, aOldValue) { + switch (aName) { + case "disabled": + if (aValue) { + this.binding.onCalendarRemoved(aCalendar); + } else { + this.binding.onCalendarAdded(aCalendar); + } + break; + } + }, + + onPropertyDeleting: function(aCalendar, aName) { + this.onPropertyChanged(aCalendar, aName, null, null); + }, + + /** + * calICompositeObserver methods and properties + */ + onCalendarAdded: function(aCalendar) { + if (!aCalendar.getProperty("disabled")) { + this.binding.onCalendarAdded(aCalendar); + } + }, + + onCalendarRemoved: function(aCalendar) { + this.binding.onCalendarRemoved(aCalendar); + }, + + onDefaultCalendarChanged: function(aNewDefaultCalendar) {} + }) + ]]></field> + + <method name="observe"> + <parameter name="aSubject"/> + <parameter name="aTopic"/> + <parameter name="aPrefName"/> + <body><![CDATA[ + switch (aPrefName) { + case "calendar.date.format": + case "calendar.timezone.local": + this.refresh(); + break; + } + + ]]></body> + </method> + + <method name="refreshFromCalendar"> + <parameter name="aCalendar"/> + <body><![CDATA[ + let refreshJob = { + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calIOperationListener]), + binding: this, + calendar: null, + items: null, + operation: null, + + onOperationComplete: function(aOpCalendar, aStatus, aOperationType, aId, aDateTime) { + if (aOpCalendar.id in this.binding.mPendingRefreshJobs) { + delete this.binding.mPendingRefreshJobs[aOpCalendar.id]; + } + + let oldItems = this.binding.mTaskArray.filter(item => item.calendar.id == aOpCalendar.id); + this.binding.mTreeView.modifyItems(this.items, oldItems); + }, + + onGetResult: function(aOpCalendar, aStatus, aItemType, aDetail, aCount, aItems) { + this.items = this.items.concat(aItems); + }, + + cancel: function() { + if (this.operation && this.operation.isPending) { + this.operation.cancel(); + this.operation = null; + this.items = []; + } + }, + + execute: function() { + if (aCalendar.id in this.binding.mPendingRefreshJobs) { + this.binding.mPendingRefreshJobs[aCalendar.id].cancel(); + } + this.calendar = aCalendar; + this.items = []; + + let operation = this.binding.mFilter.getItems(aCalendar, + aCalendar.ITEM_FILTER_TYPE_TODO, + this); + if (operation && operation.isPending) { + this.operation = operation; + this.binding.mPendingRefreshJobs[aCalendar.id] = this; + } + } + }; + + refreshJob.execute(); + ]]></body> + </method> + + <method name="selectAll"> + <body><![CDATA[ + if (this.mTreeView.selection) { + this.mTreeView.selection.selectAll(); + } + ]]></body> + </method> + + <!-- Called by event observers to update the display --> + <method name="refresh"> + <parameter name="aFilter"/> + <body><![CDATA[ + let cals = getCompositeCalendar().getCalendars({}) || []; + for (let calendar of cals) { + if (!calendar.getProperty("disabled")) { + this.refreshFromCalendar(calendar, aFilter); + } + } + ]]></body> + </method> + + <method name="onCalendarAdded"> + <parameter name="aCalendar"/> + <parameter name="aFilter"/> + <body><![CDATA[ + if (!aCalendar.getProperty("disabled")) { + this.refreshFromCalendar(aCalendar, aFilter); + } + ]]></body> + </method> + + <method name="onCalendarRemoved"> + <parameter name="aCalendar"/> + <body><![CDATA[ + let tasks = this.mTaskArray.filter(task => task.calendar.id == aCalendar.id); + this.mTreeView.removeItems(tasks); + ]]></body> + </method> + + <method name="sortItems"> + <body><![CDATA[ + if (this.mTreeView.selectedColumn) { + let modifier = (this.mTreeView.sortDirection == "descending" ? -1 : 1); + let column = this.mTreeView.selectedColumn; + cal.sortEntry.mSortKey = column.getAttribute("sortKey") + ? column.getAttribute("sortKey") + : column.getAttribute("itemproperty"); + let sortType = cal.getSortTypeForSortKey(cal.sortEntry.mSortKey); + + // sort (key,item) entries + cal.sortEntry.mSortStartedDate = now(); + let entries = this.mTaskArray.map(cal.sortEntry, cal.sortEntry); + entries.sort(cal.sortEntryComparer(sortType, modifier)); + this.mTaskArray = entries.map(cal.sortEntryItem); + } + + this.recreateHashTable(); + ]]></body> + </method> + + <method name="recreateHashTable"> + <body><![CDATA[ + this.mHash2Index = {}; + for (let i = 0; i < this.mTaskArray.length; i++) { + let item = this.mTaskArray[i]; + this.mHash2Index[item.hashId] = i; + } + if (this.mTreeView.treebox) { + this.mTreeView.treebox.invalidate(); + } + ]]></body> + </method> + + <method name="getInitialDate"> + <body><![CDATA[ + let initialDate = currentView().selectedDay; + return initialDate ? initialDate : now(); + ]]></body> + </method> + + <method name="doUpdateFilter"> + <parameter name="aFilter"/> + <body><![CDATA[ + let needsRefresh = false; + let oldStart = this.mFilter.mStartDate; + let oldEnd = this.mFilter.mEndDate; + let filterText = this.mFilter.filterText || ""; + + if (aFilter) { + let props = this.mFilter.filterProperties; + this.mFilter.applyFilter(aFilter); + needsRefresh = !props || !props.equals(this.mFilter.filterProperties); + } else { + this.mFilter.updateFilterDates(); + } + + if (this.mTextFilterField) { + let field = document.getElementById(this.mTextFilterField); + if (field) { + this.mFilter.filterText = field.value; + needsRefresh = needsRefresh || filterText.toLowerCase() != this.mFilter.filterText.toLowerCase(); + } + } + + // we only need to refresh the tree if the filter properties or date range changed + if (needsRefresh || + !((!oldStart && !this.mFilter.mStartDate) || + (oldStart && this.mFilter.mStartDate && oldStart.compare(this.mFilter.mStartDate) == 0)) || + !((!oldEnd && !this.mFilter.mEndDate) || + (oldEnd && this.mFilter.mEndDate && oldEnd.compare(this.mFilter.mEndDate) == 0))) { + this.refresh(); + } + ]]></body> + </method> + + <method name="updateFilter"> + <parameter name="aFilter"/> + <body><![CDATA[ + this.doUpdateFilter(aFilter); + ]]></body> + </method> + + <method name="updateFocus"> + <body><![CDATA[ + let tree = document.getAnonymousElementByAttribute(this, "anonid", "calendar-task-tree"); + let menuOpen = false; + + // we need to consider the tree focused if the context menu is open. + if (this.hasAttribute("context")) { + let context = document.getElementById(this.getAttribute("context")); + if (context && context.state) { + menuOpen = (context.state == "open") || (context.state == "showing"); + } + } + + let focused = (document.activeElement == tree) || menuOpen; + + calendarController.onSelectionChanged({ detail: focused ? this.selectedTasks : [] }); + calendarController.todo_tasktree_focused = focused; + ]]></body> + </method> + </implementation> + + <handlers> + <handler event="select"><![CDATA[ + this.mTreeView.onSelect(event); + if (calendarController.todo_tasktree_focused) { + calendarController.onSelectionChanged({ detail: this.selectedTasks }); + } + ]]></handler> + <handler event="focus"><![CDATA[ + this.updateFocus(); + ]]></handler> + <handler event="blur"><![CDATA[ + this.updateFocus(); + ]]></handler> + <handler event="keypress"><![CDATA[ + this.mTreeView.onKeyPress(event); + ]]></handler> + <handler event="mousedown"><![CDATA[ + this.mTreeView.onMouseDown(event); + ]]></handler> + <handler event="dragstart"><![CDATA[ + if (event.originalTarget.localName != "treechildren") { + // We should only drag treechildren, not for example the scrollbar. + return; + } + let item = this.mTreeView._getItemFromEvent(event); + if (!item || item.calendar.readOnly) { + return; + } + + let tree = document.getAnonymousElementByAttribute(this, "anonid", "calendar-task-tree"); + + // let's build the drag region + let region = null; + try { + region = Components.classes["@mozilla.org/gfx/region;1"].createInstance(Components.interfaces.nsIScriptableRegion); + region.init(); + let treeBox = tree.treeBox; + let bodyBox = treeBox.treeBody.boxObject; + let sel = tree.view.selection; + + let rowX = bodyBox.x; + let rowY = bodyBox.y; + let rowHeight = treeBox.rowHeight; + let rowWidth = bodyBox.width; + + // add a rectangle for each visible selected row + for (let i = treeBox.getFirstVisibleRow(); i <= treeBox.getLastVisibleRow(); i++) { + if (sel.isSelected(i)) { + region.unionRect(rowX, rowY, rowWidth, rowHeight); + } + rowY = rowY + rowHeight; + } + + // and finally, clip the result to be sure we don't spill over... + if (!region.isEmpty()) { + region.intersectRect(bodyBox.x, bodyBox.y, bodyBox.width, bodyBox.height); + } + } catch (ex) { + ASSERT(false, "Error while building selection region: " + ex + "\n"); + region = null; + } + invokeEventDragSession(item, event.target); + ]]></handler> + </handlers> + + </binding> + + <binding id="calendar-task-tree-todaypane" extends="chrome://calendar/content/calendar-task-tree.xml#calendar-task-tree"> + <implementation> + <method name="getInitialDate"> + <body><![CDATA[ + let initialDate = agendaListbox.today ? agendaListbox.today.start : now(); + return initialDate ? initialDate : now(); + ]]></body> + </method> + <method name="updateFilter"> + <parameter name="aFilter"/> + <body><![CDATA[ + this.mFilter.selectedDate = agendaListbox.today && agendaListbox.today.start ? + agendaListbox.today.start : now(); + this.doUpdateFilter(aFilter); + ]]></body> + </method> + </implementation> + </binding> +</bindings> diff --git a/calendar/base/content/calendar-task-view.js b/calendar/base/content/calendar-task-view.js new file mode 100644 index 000000000..988ba043a --- /dev/null +++ b/calendar/base/content/calendar-task-view.js @@ -0,0 +1,300 @@ +/* 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 taskDetailsView, sendMailToOrganizer, taskViewCopyLink */ + +Components.utils.import("resource://calendar/modules/calRecurrenceUtils.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); +Components.utils.import("resource://gre/modules/AppConstants.jsm"); + +var taskDetailsView = { + + /** + * Task Details Events + * + * XXXberend Please document this function, possibly also consolidate since + * its the only function in taskDetailsView. + */ + onSelect: function(event) { + function displayElement(id, flag) { + setBooleanAttribute(id, "hidden", !flag); + return flag; + } + + let dateFormatter = + Components.classes["@mozilla.org/calendar/datetime-formatter;1"] + .getService(Components.interfaces.calIDateTimeFormatter); + + let item = document.getElementById("calendar-task-tree").currentTask; + if (displayElement("calendar-task-details-container", item != null) && + displayElement("calendar-task-view-splitter", item != null)) { + displayElement("calendar-task-details-title-row", true); + document.getElementById("calendar-task-details-title").textContent = + (item.title ? item.title.replace(/\n/g, " ") : ""); + + let organizer = item.organizer; + if (displayElement("calendar-task-details-organizer-row", organizer != null)) { + let name = organizer.commonName; + if (!name || name.length <= 0) { + if (organizer.id && organizer.id.length) { + name = organizer.id; + let re = new RegExp("^mailto:(.*)", "i"); + let matches = re.exec(name); + if (matches) { + name = matches[1]; + } + } + } + if (displayElement("calendar-task-details-organizer-row", name && name.length)) { + document.getElementById("calendar-task-details-organizer").value = name; + } + } + + let priority = 0; + if (item.calendar.getProperty("capabilities.priority.supported") != false) { + priority = parseInt(item.priority, 10); + } + displayElement("calendar-task-details-priority-label", priority > 0); + displayElement("calendar-task-details-priority-low", priority >= 6 && priority <= 9); + displayElement("calendar-task-details-priority-normal", priority == 5); + displayElement("calendar-task-details-priority-high", priority >= 1 && priority <= 4); + + let status = item.getProperty("STATUS"); + if (displayElement("calendar-task-details-status-row", status && status.length > 0)) { + let statusDetails = document.getElementById("calendar-task-details-status"); + switch (status) { + case "NEEDS-ACTION": { + statusDetails.value = calGetString( + "calendar", + "taskDetailsStatusNeedsAction"); + break; + } + case "IN-PROCESS": { + let percent = 0; + let property = item.getProperty("PERCENT-COMPLETE"); + if (property != null) { + percent = parseInt(property, 10); + } + statusDetails.value = calGetString( + "calendar", + "taskDetailsStatusInProgress", [percent]); + break; + } + case "COMPLETED": { + if (item.completedDate) { + let completedDate = item.completedDate.getInTimezone( + calendarDefaultTimezone()); + statusDetails.value = calGetString( + "calendar", + "taskDetailsStatusCompletedOn", + [dateFormatter.formatDateTime(completedDate)]); + } + break; + } + case "CANCELLED": { + statusDetails.value = calGetString( + "calendar", + "taskDetailsStatusCancelled"); + break; + } + default: { + displayElement("calendar-task-details-status-row", false); + break; + } + } + } + let categories = item.getCategories({}); + if (displayElement("calendar-task-details-category-row", categories.length > 0)) { + document.getElementById("calendar-task-details-category").value = categories.join(", "); + } + document.getElementById("task-start-row").Item = item; + document.getElementById("task-due-row").Item = item; + let parentItem = item; + if (parentItem.parentItem != parentItem) { + // XXXdbo Didn't we want to get rid of these checks? + parentItem = parentItem.parentItem; + } + let recurrenceInfo = parentItem.recurrenceInfo; + let recurStart = parentItem.recurrenceStartDate; + if (displayElement("calendar-task-details-repeat-row", recurrenceInfo && recurStart)) { + let kDefaultTimezone = calendarDefaultTimezone(); + let startDate = recurStart.getInTimezone(kDefaultTimezone); + let endDate = item.dueDate ? item.dueDate.getInTimezone(kDefaultTimezone) : null; + let detailsString = recurrenceRule2String(recurrenceInfo, startDate, endDate, startDate.isDate); + if (detailsString) { + let rpv = document.getElementById("calendar-task-details-repeat"); + rpv.value = detailsString.split("\n").join(" "); + } + } + let textbox = document.getElementById("calendar-task-details-description"); + let description = item.hasProperty("DESCRIPTION") ? item.getProperty("DESCRIPTION") : null; + textbox.value = description; + textbox.inputField.readOnly = true; + let attachmentRows = document.getElementById("calendar-task-details-attachment-rows"); + removeChildren(attachmentRows); + let attachments = item.getAttachments({}); + if (displayElement("calendar-task-details-attachment-row", attachments.length > 0)) { + displayElement("calendar-task-details-attachment-rows", true); + for (let attachment of attachments) { + let url = attachment.calIAttachment.uri.spec; + let urlLabel = createXULElement("label"); + urlLabel.setAttribute("value", url); + urlLabel.setAttribute("tooltiptext", url); + urlLabel.setAttribute("class", "text-link"); + urlLabel.setAttribute("crop", "end"); + urlLabel.setAttribute("onclick", + "if (event.button != 2) launchBrowser(this.value);"); + urlLabel.setAttribute("context", "taskview-link-context-menu"); + attachmentRows.appendChild(urlLabel); + } + } + } + }, + + loadCategories: function(event) { + let panel = event.target; + let item = document.getElementById("calendar-task-tree").currentTask; + panel.loadItem(item); + }, + + saveCategories: function(event) { + let panel = event.target; + let item = document.getElementById("calendar-task-tree").currentTask; + let categoriesMap = {}; + + for (let cat of item.getCategories({})) { + categoriesMap[cat] = true; + } + + for (let cat of panel.categories) { + if (cat in categoriesMap) { + delete categoriesMap[cat]; + } else { + categoriesMap[cat] = false; + } + } + + if (categoriesMap.toSource() != "({})") { + let newItem = item.clone(); + newItem.setCategories(panel.categories.length, panel.categories); + + doTransaction("modify", newItem, newItem.calendar, item, null); + } + } +}; + + +/** + * Updates the currently applied filter for the task view and refreshes the task + * tree. + * + * @param aFilter The filter name to set. + */ +function taskViewUpdate(aFilter) { + let tree = document.getElementById("calendar-task-tree"); + let broadcaster = document.getElementById("filterBroadcaster"); + let oldFilter = broadcaster.getAttribute("value"); + let filter = oldFilter; + + if (aFilter && !(aFilter instanceof Event)) { + filter = aFilter; + } + + if (filter && (filter != oldFilter)) { + broadcaster.setAttribute("value", filter); + } + + // update the filter + tree.updateFilter(filter || "all"); +} + +/** + * Prepares a dialog to send an email to the organizer of the currently selected + * task in the task view. + * + * XXX We already have a function with this name in the event dialog. Either + * consolidate or make name more clear. + */ +function sendMailToOrganizer() { + let item = document.getElementById("calendar-task-tree").currentTask; + if (item != null) { + let organizer = item.organizer; + let email = cal.getAttendeeEmail(organizer, true); + let emailSubject = cal.calGetString("calendar-event-dialog", "emailSubjectReply", [item.title]); + let identity = item.calendar.getProperty("imip.identity"); + sendMailTo(email, emailSubject, null, identity); + } +} + +/** + * Handler function to observe changing of the calendar display deck. Updates + * the task tree if the task view was selected. + * + * TODO Consolidate this function and anything connected, its still from times + * before we had view tabs. + */ +function taskViewObserveDisplayDeckChange(event) { + let deck = event.target; + + // Bug 309505: The 'select' event also fires when we change the selected + // panel of calendar-view-box. Workaround with this check. + if (deck.id != "calendarDisplayDeck") { + return; + } + + // In case we find that the task view has been made visible, we refresh the view. + if (deck.selectedPanel && deck.selectedPanel.id == "calendar-task-box") { + let taskFilterGroup = document.getElementById("task-tree-filtergroup"); + taskViewUpdate(taskFilterGroup.value || "all"); + } +} + +// Install event listeners for the display deck change and connect task tree to filter field +function taskViewOnLoad() { + let deck = document.getElementById("calendarDisplayDeck"); + let tree = document.getElementById("calendar-task-tree"); + + if (deck && tree) { + deck.addEventListener("select", taskViewObserveDisplayDeckChange, true); + tree.textFilterField = "task-text-filter-field"; + + // setup the platform-dependent placeholder for the text filter field + let textFilter = document.getElementById("task-text-filter-field"); + if (textFilter) { + let base = textFilter.getAttribute("emptytextbase"); + let keyLabel = textFilter.getAttribute(AppConstants.platform == "macosx" ? + "keyLabelMac" : "keyLabelNonMac"); + + textFilter.setAttribute("placeholder", base.replace("#1", keyLabel)); + textFilter.value = ""; + } + } + + // Setup customizeDone handler for the task action toolbox. + let toolbox = document.getElementById("task-actions-toolbox"); + toolbox.customizeDone = function(aEvent) { + MailToolboxCustomizeDone(aEvent, "CustomizeTaskActionsToolbar"); + }; + + let toolbarset = document.getElementById("customToolbars"); + toolbox.toolbarset = toolbarset; + + Services.obs.notifyObservers(window, "calendar-taskview-startup-done", false); +} + +/** + * Copy the value of the given link node to the clipboard + * + * @param linkNode The node containing the value to copy to the clipboard + */ +function taskViewCopyLink(linkNode) { + if (linkNode) { + let linkAddress = linkNode.value; + let clipboard = Components.classes["@mozilla.org/widget/clipboardhelper;1"] + .getService(Components.interfaces.nsIClipboardHelper); + clipboard.copyString(linkAddress); + } +} + +window.addEventListener("load", taskViewOnLoad, false); diff --git a/calendar/base/content/calendar-task-view.xul b/calendar/base/content/calendar-task-view.xul new file mode 100644 index 000000000..7aacd1d28 --- /dev/null +++ b/calendar/base/content/calendar-task-view.xul @@ -0,0 +1,244 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/calendar-task-view.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/content/calendar-bindings.css"?> + +<!DOCTYPE overlay [ + <!ENTITY % dtd1 SYSTEM "chrome://calendar/locale/calendar.dtd" > %dtd1; + <!ENTITY % dtd2 SYSTEM "chrome://calendar/locale/calendar-event-dialog.dtd" > %dtd2; + <!ENTITY % dtd3 SYSTEM "chrome://calendar/locale/menuOverlay.dtd" > %dtd3; +]> + +<overlay xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <script type="application/javascript" src="chrome://calendar/content/calendar-task-tree.js"/> + <script type="application/javascript" src="chrome://calendar/content/calendar-task-view.js"/> + <script type="application/javascript" src="chrome://calendar/content/calendar-dialog-utils.js"/> + <script type="application/javascript" src="chrome://calendar/content/calApplicationUtils.js"/> + <script type="application/javascript" src="chrome://calendar/content/calFilter.js"/> + <script type="application/javascript" src="chrome://global/content/globalOverlay.js"/> + + <vbox id="calendarDisplayDeck"> + <vbox id="calendar-task-box" flex="1" + onselect="taskDetailsView.onSelect(event);"> + <hbox id="task-addition-box" align="center"> + <box align="center" flex="1"> + <toolbarbutton id="calendar-add-task-button" + label="&calendar.newtask.button.label;" + tooltiptext="&calendar.newtask.button.tooltip;" + observes="calendar_new_todo_command"/> + <textbox id="view-task-edit-field" + flex="1" + class="task-edit-field" + onfocus="taskEdit.onFocus(event)" + onblur="taskEdit.onBlur(event)" + onkeypress="taskEdit.onKeyPress(event)"/> + </box> + <box align="center" flex="1"> + <textbox id="task-text-filter-field" + class="searchBox" + type="search" + flex="1" + placeholder="" + emptytextbase="&calendar.task.text-filter.textbox.emptytext.base1;" + keyLabelNonMac="&calendar.task.text-filter.textbox.emptytext.keylabel.nonmac;" + keyLabelMac="&calendar.task.text-filter.textbox.emptytext.keylabel.mac;" + oncommand="taskViewUpdate();"/> + </box> + </hbox> + <vbox flex="1"> + <calendar-task-tree id="calendar-task-tree" flex="1" + visible-columns="completed priority title startdate duedate" + persist="visible-columns ordinals widths sort-active sort-direction height" + context="taskitem-context-menu"/> + <splitter id="calendar-task-view-splitter" collapse="none" persist="state" class="calendar-splitter"/> + <vbox id="calendar-task-details-container" + class="main-header-area" + flex="1" + persist="height" + context="task-actions-toolbar-context-menu" + hidden="true"> + <hbox id="calendar-task-details"> + <grid id="calendar-task-details-grid" flex="1"> + <columns id="calendar-task-details-columns"> + <column id="calendar-header-name-column"/> + <column id="calendar-header-value-column" flex="1"/> + </columns> + + <rows id="calendar-task-details-rows"> + <row id="calendar-task-details-priority-row" + align="end"> + <hbox pack="end"> + <label id="calendar-task-details-priority-label" + value="&calendar.task-details.priority.label;" + class="task-details-name" + hidden="true"/> + </hbox> + <hbox flex="1" align="end" > + <label id="calendar-task-details-priority-low" + value="&calendar.task-details.priority.low.label;" + class="task-details-value" + crop="end" + flex="1" + hidden="true"/> + <label id="calendar-task-details-priority-normal" + value="&calendar.task-details.priority.normal.label;" + class="task-details-value" + crop="end" + flex="1" + hidden="true"/> + <label id="calendar-task-details-priority-high" + value="&calendar.task-details.priority.high.label;" + class="task-details-value" + crop="end" + flex="1" + hidden="true"/> + <hbox id="other-actions-box" align="end" flex="1" pack="end"> + <menupopup id="task-actions-toolbar-context-menu"> + <menuitem id="CustomizeTaskActionsToolbar" + oncommand="CustomizeMailToolbar('task-actions-toolbox', 'CustomizeTaskActionsToolbar')" + label="&calendar.menu.customize.label;" + accesskey="&calendar.menu.customize.accesskey;"/> + </menupopup> + + <toolbox id="task-actions-toolbox" + minwidth="50px" + mode="full" + iconsize="small" + labelalign="end" + defaultmode="full" + defaulticonsize="small" + defaultlabelalign="end"> + <toolbarpalette id="task-actions-toolbar-palette"> + <toolbarbutton id="task-actions-category" + type="menu" + label="&calendar.unifinder.tree.categories.label;" + tooltiptext="&calendar.task.category.button.tooltip;" + command="calendar_task_category_command" + observes="calendar_task_category_command" + class="toolbarbutton-1 msgHeaderView-button"> + <panel id="task-actions-category-panel" + type="category-panel" + onpopupshowing="taskDetailsView.loadCategories(event)" + onpopuphiding="taskDetailsView.saveCategories(event)"/> + </toolbarbutton> + <toolbarbutton id="task-actions-markcompleted" + type="menu-button" + label="&calendar.context.markcompleted.label;" + tooltiptext="&calendar.task.complete.button.tooltip;" + command="calendar_toggle_completed_command" + observes="calendar_toggle_completed_command" + class="toolbarbutton-1 msgHeaderView-button"> + <menupopup id="task-actions-markcompleted-menupopup" type="task-progress"/> + </toolbarbutton> + <toolbarbutton id="task-actions-priority" + type="menu" + label="&calendar.context.priority.label;" + tooltiptext="&calendar.task.priority.button.tooltip;" + command="calendar_general-priority_command" + observes="calendar_general-priority_command" + class="toolbarbutton-1 msgHeaderView-button"> + <menupopup id="task-actions-priority-menupopup" type="task-priority"/> + </toolbarbutton> + <toolbarbutton id="calendar-delete-task-button" + class="toolbarbutton-1 msgHeaderView-button" + label="&calendar.taskview.delete.label;" + tooltiptext="&calendar.context.deletetask.label;" + observes="calendar_delete_todo_command"/> + </toolbarpalette> + + <toolbar id="task-actions-toolbar" align="start" + class="inline-toolbar" + customizable="true" + mode="full" + iconsize="small" + labelalign="end" + defaultmode="full" + defaulticonsize="small" + context="task-actions-toolbar-context-menu" + defaultset="task-actions-category,task-actions-markcompleted,task-actions-priority,calendar-delete-task-button"/> + </toolbox> + </hbox> + </hbox> + </row> + <row id="calendar-task-details-title-row" + align="top" + hidden="true"> + <label value="&calendar.task-details.title.label;" + class="task-details-name"/> + <label id="calendar-task-details-title" + class="task-details-value"/> + </row> + <row id="calendar-task-details-organizer-row" + align="top" + hidden="true"> + <label value="&calendar.task-details.organizer.label;" + class="task-details-name"/> + <label id="calendar-task-details-organizer" + class="task-details-value text-link" + crop="end" + onclick="sendMailToOrganizer()"/> + </row> + <row id="calendar-task-details-status-row" + align="top" + hidden="true"> + <label value="&calendar.task-details.status.label;" + class="task-details-name"/> + <label id="calendar-task-details-status" + crop="end" + class="task-details-value"/> + </row> + <row id="calendar-task-details-category-row" + align="top" + hidden="true"> + <label value="&calendar.task-details.category.label;" + class="task-details-name"/> + <label id="calendar-task-details-category" + crop="end" + class="task-details-value"/> + </row> + <row class="item-date-row" + id="task-start-row" + mode="start" + taskStartLabel="&calendar.task-details.start.label;" + align="end"/> + <row class="item-date-row" + id="task-due-row" + mode="end" + taskDueLabel="&calendar.task-details.due.label;" + align="end"/> + <row id="calendar-task-details-repeat-row" + align="top" + hidden="true"> + <label value="&calendar.task-details.repeat.label;" + class="task-details-name"/> + <label id="calendar-task-details-repeat" + crop="end" + class="task-details-value"/> + </row> + </rows> + </grid> + </hbox> + <textbox id="calendar-task-details-description" multiline="true" flex="1"/> + <hbox id="calendar-task-details-attachment-row" + align="top" + hidden="true"> + <hbox pack="end"> + <label value="&calendar.task-details.attachments.label;" + class="task-details-name"/> + </hbox> + <vbox id="calendar-task-details-attachment-rows" + align="top" + flex="1" + style="overflow: auto;" + hidden="true"> + </vbox> + </hbox> + </vbox> + </vbox> + </vbox> + </vbox> + +</overlay> diff --git a/calendar/base/content/calendar-ui-utils.js b/calendar/base/content/calendar-ui-utils.js new file mode 100644 index 000000000..3cc85d25e --- /dev/null +++ b/calendar/base/content/calendar-ui-utils.js @@ -0,0 +1,654 @@ +/* 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 getElementValue, setBooleanAttribute, showElement, hideElement, + * uncollapseElement, collapseElement, disableElementWithLock, + * enableElementWithLock, uncheckChildNodes, removeChildren, + * appendCalendarItems, setAttributeToChildren, checkRadioControl, + * processEnableCheckbox, updateListboxDeleteButton, + * updateUnitLabelPlural, updateMenuLabelsPlural, menuListSelectItem, + * getOptimalMinimumWidth, getOptimalMinimumHeight, + * getOtherOrientation, updateSelectedLabel, setupAttendanceMenu + */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://gre/modules/Preferences.jsm"); +Components.utils.import("resource://gre/modules/PluralForm.jsm"); + +/** + * Helper function for filling the form, + * Set the value of a property of a XUL element + * + * @param aElement ID of XUL element to set, or the element node itself + * @param aNewValue value to set property to ( if undefined no change is made ) + * @param aPropertyName OPTIONAL name of property to set, default is "value", + * use "checked" for radios & checkboxes, "data" for + * drop-downs + */ +function setElementValue(aElement, aNewValue, aPropertyName) { + cal.ASSERT(aElement, "aElement"); + + if (aNewValue !== undefined) { + if (typeof aElement == "string") { + aElement = document.getElementById(aElement); + cal.ASSERT(aElement, "aElement"); + } + + if (!aElement) { return; } + + if (aNewValue === false) { + try { + aElement.removeAttribute(aPropertyName); + } catch (e) { + cal.ERROR("setElementValue: aElement.removeAttribute couldn't remove " + + aPropertyName + " from " + (aElement && aElement.localName) + " e: " + e + "\n"); + } + } else if (aPropertyName) { + try { + aElement.setAttribute(aPropertyName, aNewValue); + } catch (e) { + cal.ERROR("setElementValue: aElement.setAttribute couldn't set " + + aPropertyName + " from " + (aElement && aElement.localName) + " to " + aNewValue + + " e: " + e + "\n"); + } + } else { + aElement.value = aNewValue; + } + } +} + +/** + * Helper function for getting data from the form, + * Get the value of a property of a XUL element + * + * @param aElement ID of XUL element to set, or the element node itself + * @param propertyName OPTIONAL name of property to set, default is "value", + * use "checked" for radios & checkboxes, "data" for + * drop-downs + * @return newValue Value of property + * + */ +function getElementValue(aElement, aPropertyName) { + if (typeof aElement == "string") { + aElement = document.getElementById(aElement); + } + return aElement[aPropertyName || "value"]; +} + +/** + * Sets the value of a boolean attribute by either setting the value or + * removing the attribute + * + * @param aXulElement The XUL element/string ID the attribute is applied to. + * @param aAttribute The name of the attribute + * @param aValue The boolean value + * @return Returns aValue (for chaining) + */ +function setBooleanAttribute(aXulElement, aAttribute, aValue) { + setElementValue(aXulElement, aValue ? "true" : false, aAttribute); + return aValue; +} + +/** + * Unconditionally show the element (hidden attribute) + * + * @param aElement ID of XUL element to set, or the element node itself + */ +function showElement(aElement) { + setElementValue(aElement, false, "hidden"); +} + +/** + * Unconditionally hide the element (hidden attribute) + * + * @param aElement ID of XUL element to set, or the element node itself + */ +function hideElement(aElement) { + setElementValue(aElement, "true", "hidden"); +} + +/** + * Unconditionally show the element (collapsed attribute) + * + * @param aElement ID of XUL element to set, or the element node itself + */ +function uncollapseElement(aElement) { + setElementValue(aElement, false, "collapsed"); +} + +/** + * Unconditionally hide the element (collapsed attribute) + * + * @param aElement ID of XUL element to set, or the element node itself + */ +function collapseElement(aElement) { + setElementValue(aElement, "true", "collapsed"); +} + +/** + * Unconditionally enable the element (hidden attribute) + * + * @param aElement ID of XUL element to set, or the element node itself + */ +function enableElement(aElement) { + setElementValue(aElement, false, "disabled"); +} + +/** + * Unconditionally disable the element (hidden attribute) + * + * @param aElement ID of XUL element to set, or the element node itself + */ +function disableElement(aElement) { + setElementValue(aElement, "true", "disabled"); +} + +/** + * This function unconditionally disables the element for + * which the id has been passed as argument. Furthermore, it + * remembers who was responsible for this action by using + * the given key (lockId). In case the control should be + * enabled again the lock gets removed, but the control only + * gets enabled if *all* possibly held locks have been removed. + * + * @param elementId The element ID of the element to disable. + * @param lockId The ID of the lock to set. + */ +function disableElementWithLock(elementId, lockId) { + // unconditionally disable the element. + disableElement(elementId); + + // remember that this element has been locked with + // the key passed as argument. we keep a primitive + // form of ref-count in the attribute 'lock'. + let element = document.getElementById(elementId); + if (element) { + if (!element.hasAttribute(lockId)) { + element.setAttribute(lockId, "true"); + let n = parseInt(element.getAttribute("lock") || 0, 10); + element.setAttribute("lock", n + 1); + } + } +} + +/** + * This function is intended to be used in tandem with the + * above defined function 'disableElementWithLock()'. + * See the respective comment for further details. + * + * @see disableElementWithLock + * @param elementId The element ID of the element to enable. + * @param lockId The ID of the lock to set. + */ +function enableElementWithLock(elementId, lockId) { + let element = document.getElementById(elementId); + if (!element) { + dump("unable to find " + elementId + "\n"); + return; + } + + if (element.hasAttribute(lockId)) { + element.removeAttribute(lockId); + let n = parseInt(element.getAttribute("lock") || 0, 10) - 1; + if (n > 0) { + element.setAttribute("lock", n); + } else { + element.removeAttribute("lock"); + } + if (n <= 0) { + enableElement(elementId); + } + } +} + +/** + * Unchecks the commands of the child elements of a DOM-tree-node i.e of a menu + * + * @param aEvent The event from which the target is taken to retrieve the + * child elements + */ +function uncheckChildNodes(aEvent) { + let liveList = aEvent.target.getElementsByAttribute("checked", "true"); + for (let i = liveList.length - 1; i >= 0; i--) { + let commandName = liveList.item(i).getAttribute("command"); + let command = document.getElementById(commandName); + if (command) { + command.setAttribute("checked", "false"); + } + } +} + +/** + * Removes all child nodes of the given node + * + * @param aElement The Node (or its id) to remove children from + */ +function removeChildren(aElement) { + if (typeof aElement == "string") { + aElement = document.getElementById(aElement); + } + + while (aElement.firstChild) { + aElement.lastChild.remove(); + } +} + +/** + * Sorts a sorted array of calendars by pref |calendar.list.sortOrder|. + * Repairs that pref if dangling entries exist. + * + * @param calendars An array of calendars to sort. + */ +function sortCalendarArray(calendars) { + let ret = calendars.concat([]); + let sortOrder = {}; + let sortOrderPref = Preferences.get("calendar.list.sortOrder", "").split(" "); + for (let i = 0; i < sortOrderPref.length; ++i) { + sortOrder[sortOrderPref[i]] = i; + } + function sortFunc(cal1, cal2) { + let orderIdx1 = sortOrder[cal1.id] || -1; + let orderIdx2 = sortOrder[cal2.id] || -1; + if (orderIdx1 < orderIdx2) { + return -1; + } + if (orderIdx1 > orderIdx2) { + return 1; + } + return 0; + } + ret.sort(sortFunc); + + // check and repair pref: + let sortOrderString = Preferences.get("calendar.list.sortOrder", ""); + let wantedOrderString = ret.map(calendar => calendar.id).join(" "); + if (wantedOrderString != sortOrderString) { + Preferences.set("calendar.list.sortOrder", wantedOrderString); + } + + return ret; +} + +/** +* Fills up a menu - either a menupopup or a menulist - with menuitems that refer +* to calendars. +* +* @param aItem The event or task +* @param aCalendarMenuParent The direct parent of the menuitems - either a +* menupopup or a menulist +* @param aCalendarToUse The default-calendar +* @param aOnCommand A string that is applied to the "oncommand" +* attribute of each menuitem +* @return The index of the calendar that matches the +* default-calendar. By default 0 is returned. +*/ +function appendCalendarItems(aItem, aCalendarMenuParent, aCalendarToUse, aOnCommand) { + let calendarToUse = aCalendarToUse || aItem.calendar; + let calendars = sortCalendarArray(cal.getCalendarManager().getCalendars({})); + let indexToSelect = 0; + let index = -1; + for (let i = 0; i < calendars.length; ++i) { + let calendar = calendars[i]; + if (calendar.id == calendarToUse.id || + (calendar && + isCalendarWritable(calendar) && + (userCanAddItemsToCalendar(calendar) || + (calendar == aItem.calendar && userCanModifyItem(aItem))) && + isItemSupported(aItem, calendar))) { + let menuitem = addMenuItem(aCalendarMenuParent, calendar.name, calendar.name); + menuitem.calendar = calendar; + index++; + if (aOnCommand) { + menuitem.setAttribute("oncommand", aOnCommand); + } + if (aCalendarMenuParent.localName == "menupopup") { + menuitem.setAttribute("type", "checkbox"); + } + if (calendarToUse && calendarToUse.id == calendar.id) { + indexToSelect = index; + } + } + } + return indexToSelect; +} + +/** + * Helper function to add a menuitem to a menulist or similar. + * + * @param aParent The XUL node to add the menuitem to. + * @param aLabel The label string of the menuitem. + * @param aValue The value attribute of the menuitem. + * @param aCommand The oncommand attribute of the menuitem. + * @return The newly created menuitem + */ +function addMenuItem(aParent, aLabel, aValue, aCommand) { + let item = null; + if (aParent.localName == "menupopup") { + item = createXULElement("menuitem"); + item.setAttribute("label", aLabel); + if (aValue) { + item.setAttribute("value", aValue); + } + if (aCommand) { + item.command = aCommand; + } + aParent.appendChild(item); + } else if (aParent.localName == "menulist") { + item = aParent.appendItem(aLabel, aValue); + } + return item; +} + +/** + * Sets a given attribute value on the children of a passed node + * + * @param aParent The parent node. + * @param aAttribute The name of the attribute to be set. + * @param aValue The value of the attribute. + * @param aFilterAttribute (optional) The name of an attribute that the child nodes carry + * and that is used to filter the childnodes. + * @param aFilterValue (optional) The value of the filterattribute. If set only those + * childnodes are modified that have an attribute + * 'aFilterAttribute' with the given value + * 'aFilterValue' set. + */ +function setAttributeToChildren(aParent, aAttribute, aValue, aFilterAttribute, aFilterValue) { + for (let i = 0; i < aParent.childNodes.length; i++) { + let element = aParent.childNodes[i]; + if (aFilterAttribute == null) { + setElementValue(element, aValue, aAttribute); + } else if (element.hasAttribute(aFilterAttribute)) { + let compValue = element.getAttribute(aFilterAttribute); + if (compValue === aFilterValue) { + setElementValue(element, aValue, aAttribute); + } + } + } +} + +/** + * Checks a radio control or a radio-menuitem. + * + * @param aParent The parent node of the 'radio controls', either radios + * or menuitems of the type 'radio'. + * @param avalue The value of the radio control bound to be checked. + * @return True or false depending on if the a 'radio control' with the + * given value could be checked. + */ +function checkRadioControl(aParent, aValue) { + for (let i = 0; i < aParent.childNodes.length; i++) { + let element = aParent.childNodes[i]; + if (element.hasAttribute("value")) { + let compValue = element.getAttribute("value"); + if (compValue == aValue) { + if (element.localName == "menuitem") { + if (element.getAttribute("type") == "radio") { + element.setAttribute("checked", "true"); + return true; + } + } else if (element.localName == "radio") { + element.radioGroup.selectedItem = element; + return true; + } + } + } + } + return false; +} + +/** + * Enables or disables the given element depending on the checkbox state. + * + * @param checkboxId The ID of the XUL checkbox element. + * @param elementId The element to change the disabled state on. + */ +function processEnableCheckbox(checkboxId, elementId) { + let checked = document.getElementById(checkboxId).checked; + setElementValue(elementId, !checked && "true", "disabled"); +} + +/** + * Enable/disable button if there are children in a listbox + * + * XXX This function needs renaming, it can do more than just buttons. + * + * @param listboxId The ID of the listbox to check. + * @param buttonId The element to change the disabled state on. + */ +function updateListboxDeleteButton(listboxId, buttonId) { + let rowCount = document.getElementById(listboxId).getRowCount(); + setElementValue(buttonId, rowCount < 1 && "true", "disabled"); +} + +/** + * Gets the correct plural form of a given unit. + * + * @param aLength The number to use to determine the plural form + * @param aUnit The unit to find the plural form of + * @param aIncludeLength (optional) If true, the length will be included in the + * result. If false, only the pluralized unit is returned. + * @return A string containg the pluralized version of the unit + */ +function unitPluralForm(aLength, aUnit, aIncludeLength=true) { + let unitProp = { + minutes: "unitMinutes", + hours: "unitHours", + days: "unitDays", + weeks: "unitWeeks" + }[aUnit] || "unitMinutes"; + + return PluralForm.get(aLength, cal.calGetString("calendar", unitProp)) + .replace("#1", aIncludeLength ? aLength : "").trim(); +} + +/** + * Update the given unit label to show the correct plural form. + * + * @param aLengthFieldId The ID of the element containing the number + * @param aLabelId The ID of the label to update. + * @param aUnit The unit to use for the label. + */ +function updateUnitLabelPlural(aLengthFieldId, aLabelId, aUnit) { + let label = document.getElementById(aLabelId); + let length = Number(document.getElementById(aLengthFieldId).value); + + label.value = unitPluralForm(length, aUnit, false); +} + +/** + * Update the given menu to show the correct plural form in the list. + * + * @param aLengthFieldId The ID of the element containing the number + * @param aMenuId The menu to update labels in. + */ +function updateMenuLabelsPlural(aLengthFieldId, aMenuId) { + let menu = document.getElementById(aMenuId); + let length = Number(document.getElementById(aLengthFieldId).value); + + // update the menu items + let items = menu.getElementsByTagName("menuitem"); + for (let menuItem of items) { + menuItem.label = unitPluralForm(length, menuItem.value, false); + } + + // force the menu selection to redraw + let saveSelectedIndex = menu.selectedIndex; + menu.selectedIndex = -1; + menu.selectedIndex = saveSelectedIndex; +} + +/** + * Select value in menuList. Throws string if no such value. + * + * XXX Isn't it enough to just do menuList.value = value ? + * + * @param menuListId The ID of the menulist to check. + * @param value The value to set. + * @throws String error if value not found. + */ +function menuListSelectItem(menuListId, value) { + let menuList = document.getElementById(menuListId); + let index = menuListIndexOf(menuList, value); + if (index == -1) { + throw "menuListSelectItem: No such Element: " + value; + } else { + menuList.selectedIndex = index; + } +} + +/** + * Find index of menuitem with the given value, or return -1 if not found. + * + * @param menuListId The XUL menulist node to check. + * @param value The value to look for. + * @return The child index of the node that matches, or -1. + */ +function menuListIndexOf(menuList, value) { + let items = menuList.menupopup.childNodes; + let index = -1; + for (let i = 0; i < items.length; i++) { + let element = items[i]; + if (element.nodeName == "menuitem") { + index++; + } + if (element.getAttribute("value") == value) { + return index; + } + } + return -1; // not found +} + +/** + * Creates the given element in the XUL namespace. + * + * @param elem The local name of the element to create. + * @return The XUL element requested. + */ +function createXULElement(elem) { + return document.createElementNS("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul", elem); +} + +/** + * A helper function to calculate and add up certain css-values of a box. + * It is required, that all css values can be converted to integers + * see also + * http://www.w3.org/TR/DOM-Level-2-Style/css.html#CSS-CSSview-getComputedStyle + * @param aXULElement The xul element to be inspected. + * @param aStyleProps The css style properties for which values are to be retrieved + * e.g. 'font-size', 'min-width" etc. + * @return An integer value denoting the optimal minimum width + */ +function getSummarizedStyleValues(aXULElement, aStyleProps) { + let retValue = 0; + let cssStyleDeclares = document.defaultView.getComputedStyle(aXULElement, null); + for (let prop of aStyleProps) { + retValue += parseInt(cssStyleDeclares.getPropertyValue(prop), 10); + } + return retValue; +} + +/** + * Calculates the optimal minimum width based on the set css style-rules + * by considering the css rules for the min-width, padding, border, margin + * and border of the box. + * + * @param aXULElement The xul element to be inspected. + * @return An integer value denoting the optimal minimum width + */ +function getOptimalMinimumWidth(aXULElement) { + return getSummarizedStyleValues(aXULElement, ["min-width", + "padding-left", "padding-right", + "margin-left", "margin-top", + "border-left-width", "border-right-width"]); +} + +/** + * Calculates the optimal minimum height based on the set css style-rules + * by considering the css rules for the font-size, padding, border, margin + * and border of the box. In its current state the line-height is considered + * by assuming that it's size is about one third of the size of the font-size + * + * @param aXULElement The xul-element to be inspected. + * @return An integer value denoting the optimal minimum height + */ +function getOptimalMinimumHeight(aXULElement) { + // the following line of code presumes that the line-height is set to "normal" + // which is supposed to be a "reasonable distance" between the lines + let firstEntity = parseInt(1.35 * getSummarizedStyleValues(aXULElement, ["font-size"]), 10); + let secondEntity = getSummarizedStyleValues(aXULElement, + ["padding-bottom", "padding-top", + "margin-bottom", "margin-top", + "border-bottom-width", "border-top-width"]); + return (firstEntity + secondEntity); +} + +/** + * Gets the "other" orientation value, i.e if "horizontal" is passed, "vertical" + * is returned and vice versa. + * + * @param aOrientation The orientation value to turn around. + * @return The opposite orientation value. + */ +function getOtherOrientation(aOrientation) { + return (aOrientation == "horizontal" ? "vertical" : "horizontal"); +} + +/** + * Setting labels on a menuitem doesn't update the label that is shown when the + * menuitem is selected. This function takes care by reselecting the item + * + * @param aElement The element to update, or its id as a string + */ +function updateSelectedLabel(aElement) { + if (typeof aElement == "string") { + aElement = document.getElementById(aElement); + } + let selectedIndex = aElement.selectedIndex; + aElement.selectedIndex = -1; + aElement.selectedIndex = selectedIndex; +} + +/** + * Sets up the attendance context menu, based on the given items + * + * @param aMenu The DOM Node of the menupopup to set up + * @param aItems The array of items to consider + */ +function setupAttendanceMenu(aMenu, aItems) { + function getInvStat(item) { + let attendee = null; + if (cal.isInvitation(item)) { + attendee = cal.getInvitedAttendee(item); + } else if (item.organizer) { + let calOrgId = item.calendar.getProperty("organizerId"); + if (calOrgId == item.organizer.id && item.getAttendees({}).length) { + attendee = item.organizer; + } + } + return attendee && attendee.participationStatus; + } + + goUpdateCommand("calendar_attendance_command"); + + let allSingle = aItems.every(x => !x.recurrenceId); + setElementValue(aMenu, allSingle ? "single" : "recurring", "itemType"); + + let firstStatusOccurrences = aItems.length && getInvStat(aItems[0]); + let firstStatusParents = aItems.length && getInvStat(aItems[0].parentItem); + let sameStatusOccurrences = aItems.every(x => getInvStat(x) == firstStatusOccurrences); + let sameStatusParents = aItems.every(x => getInvStat(x.parentItem) == firstStatusParents); + + let occurrenceChildren = aMenu.getElementsByAttribute("value", firstStatusOccurrences); + let parentsChildren = aMenu.getElementsByAttribute("value", firstStatusParents); + + if (sameStatusOccurrences && occurrenceChildren[0]) { + occurrenceChildren[0].setAttribute("checked", "true"); + } + + if (sameStatusParents && parentsChildren[1]) { + parentsChildren[1].setAttribute("checked", "true"); + } + + return true; +} diff --git a/calendar/base/content/calendar-unifinder-todo.js b/calendar/base/content/calendar-unifinder-todo.js new file mode 100644 index 000000000..9c90c5cc6 --- /dev/null +++ b/calendar/base/content/calendar-unifinder-todo.js @@ -0,0 +1,60 @@ +/* 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 prepareCalendarToDoUnifinder, finishCalendarToDoUnifinder */ + +/** + * Called when the window is loaded to set up the unifinder-todo. + */ +function prepareCalendarToDoUnifinder() { + // add listener to update the date filters + getViewDeck().addEventListener("dayselect", updateCalendarToDoUnifinder, false); + + updateCalendarToDoUnifinder(); +} + +/** + * Updates the applied filter and show completed view of the unifinder todo. + * + * @param aFilter The filter name to set. + */ +function updateCalendarToDoUnifinder(aFilter) { + // Set up hiding completed tasks for the unifinder-todo tree + let showCompleted = document.getElementById("show-completed-checkbox").checked; + let tree = document.getElementById("unifinder-todo-tree"); + let oldFilter = document.getElementById("unifinder-todo-filter-broadcaster").getAttribute("value"); + let filter = oldFilter; + + // This function acts as an event listener, in which case we get the Event as the + // parameter instead of a filter. + if (aFilter && !(aFilter instanceof Event)) { + filter = aFilter; + } + + if (filter && (filter != oldFilter)) { + document.getElementById("unifinder-todo-filter-broadcaster").setAttribute("value", aFilter); + } + + if (filter && !showCompleted) { + let filterProps = tree.mFilter.getDefinedFilterProperties(filter); + if (filterProps) { + filterProps.status = (filterProps.status || filterProps.FILTER_STATUS_ALL) & + (filterProps.FILTER_STATUS_INCOMPLETE | + filterProps.FILTER_STATUS_IN_PROGRESS); + filter = filterProps; + } + } + + // update the filter + tree.showCompleted = showCompleted; + tree.updateFilter(filter); +} + +/** + * Called when the window is unloaded to clean up the unifinder-todo. + */ +function finishCalendarToDoUnifinder() { + // remove listeners + getViewDeck().removeEventListener("dayselect", updateCalendarToDoUnifinder, false); +} diff --git a/calendar/base/content/calendar-unifinder-todo.xul b/calendar/base/content/calendar-unifinder-todo.xul new file mode 100644 index 000000000..b01ebe667 --- /dev/null +++ b/calendar/base/content/calendar-unifinder-todo.xul @@ -0,0 +1,43 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<!DOCTYPE overlay [ + <!ENTITY % dtd2 SYSTEM "chrome://calendar/locale/calendar.dtd" > %dtd2; + <!ENTITY % dtd3 SYSTEM "chrome://calendar/locale/calendar-event-dialog.dtd"> %dtd3; +]> + +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/calendar-task-view.css"?> + +<overlay xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script type="application/javascript" src="chrome://calendar/content/calendar-task-tree.js"/> + <script type="application/javascript" src="chrome://calendar/content/calFilter.js"/> <script type="application/javascript" src="chrome://calendar/content/calendar-unifinder-todo.js"/> + <script type="application/javascript" src="chrome://global/content/globalOverlay.js"/> + + <vbox id="todo-tab-panel" persist="height,collapsed" flex="1"> + <box id="todo-label" align="left" collapsed="true"> + <label flex="1" crop="end" style="font-weight: bold" value="&calendar.unifinder.todoitems.label;" control="unifinder-todo-tree"/> + </box> + <box align="center"> + <checkbox id="show-completed-checkbox" + label="&calendar.unifinder.showcompletedtodos.label;" + flex="1" + crop="end" + oncommand="updateCalendarToDoUnifinder()" + persist="checked"/> + </box> + <vbox id="calendar-task-tree-detail" flex="1"> + <calendar-task-tree id="unifinder-todo-tree" flex="1" + visible-columns="completed priority title" + persist="visible-columns ordinals widths sort-active sort-direction" + context="taskitem-context-menu"/> + <textbox id="unifinder-task-edit-field" + class="task-edit-field" + onfocus="taskEdit.onFocus(event)" + onblur="taskEdit.onBlur(event)" + onkeypress="taskEdit.onKeyPress(event)"/> + </vbox> + </vbox> +</overlay> diff --git a/calendar/base/content/calendar-unifinder.js b/calendar/base/content/calendar-unifinder.js new file mode 100644 index 000000000..45e250a1c --- /dev/null +++ b/calendar/base/content/calendar-unifinder.js @@ -0,0 +1,959 @@ +/* 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 gCalendarEventTreeClicked, unifinderDoubleClick, unifinderKeyPress, + * focusSearch, toggleUnifinder + */ + +/** + * U N I F I N D E R + * + * This is a hacked in interface to the unifinder. We will need to + * improve this to make it usable in general. + * + * NOTE: Including this file will cause a load handler to be added to the + * window. + */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + +// Set this to true when the calendar event tree is clicked to allow for +// multiple selection +var gCalendarEventTreeClicked = false; + +// Store the start and enddate, because the providers can't be trusted when +// dealing with all-day events. So we need to filter later. See bug 306157 + +var kDefaultTimezone; +var gUnifinderNeedsRefresh = true; + +/** + * Checks if the unifinder is hidden + * + * @return Returns true if the unifinder is hidden. + */ +function isUnifinderHidden() { + return document.getElementById("bottom-events-box").hidden; +} + +/** + * Returns the current filter applied to the unifinder. + * + * @return The string name of the applied filter. + */ +function getCurrentUnifinderFilter() { + return document.getElementById("event-filter-menulist").selectedItem.value; +} + +/** + * Observer for the calendar event data source. This keeps the unifinder + * display up to date when the calendar event data is changed + * + * @see calIObserver + * @see calICompositeObserver + */ +var unifinderObserver = { + QueryInterface: XPCOMUtils.generateQI([ + Components.interfaces.calICompositeObserver, + Components.interfaces.nsIObserver, + Components.interfaces.calIObserver + ]), + + // calIObserver: + onStartBatch: function() { + }, + + onEndBatch: function() { + refreshEventTree(); + }, + + onLoad: function() { + if (isUnifinderHidden() && !gUnifinderNeedsRefresh) { + // If the unifinder is hidden, all further item operations might + // produce invalid entries in the unifinder. From now on, ignore + // those operations and refresh as soon as the unifinder is shown + // again. + gUnifinderNeedsRefresh = true; + unifinderTreeView.clearItems(); + } + }, + + onAddItem: function(aItem) { + if (isEvent(aItem) && + !gUnifinderNeedsRefresh && + unifinderTreeView.mFilter.isItemInFilters(aItem) + ) { + this.addItemToTree(aItem); + } + }, + + onModifyItem: function(aNewItem, aOldItem) { + this.onDeleteItem(aOldItem); + this.onAddItem(aNewItem); + }, + + onDeleteItem: function(aDeletedItem) { + if (isEvent(aDeletedItem) && !gUnifinderNeedsRefresh) { + this.removeItemFromTree(aDeletedItem); + } + }, + + onError: function(aCalendar, aErrNo, aMessage) {}, + + onPropertyChanged: function(aCalendar, aName, aValue, aOldValue) { + switch (aName) { + case "disabled": + refreshEventTree(); + break; + } + }, + + onPropertyDeleting: function(aCalendar, aName) { + this.onPropertyChanged(aCalendar, aName, null, null); + }, + + // calICompositeObserver: + onCalendarAdded: function(aAddedCalendar) { + if (!aAddedCalendar.getProperty("disabled")) { + addItemsFromCalendar(aAddedCalendar, + addItemsFromSingleCalendarInternal); + } + }, + + onCalendarRemoved: function(aDeletedCalendar) { + if (!aDeletedCalendar.getProperty("disabled")) { + deleteItemsFromCalendar(aDeletedCalendar); + } + }, + + onDefaultCalendarChanged: function(aNewDefaultCalendar) {}, + + /** + * Add an unifinder item to the tree. It is safe to call these for any + * event. The functions will determine whether or not anything actually + * needs to be done to the tree. + * + * @return aItem The item to add to the tree. + */ + addItemToTree: function(aItem) { + let items; + let filter = unifinderTreeView.mFilter; + + if (filter.startDate && filter.endDate) { + items = aItem.getOccurrencesBetween(filter.startDate, filter.endDate, {}); + } else { + items = [aItem]; + } + unifinderTreeView.addItems(items.filter(filter.isItemInFilters, filter)); + }, + + /** + * Remove an item from the unifinder tree. It is safe to call these for any + * event. The functions will determine whether or not anything actually + * needs to be done to the tree. + * + * @return aItem The item to remove from the tree. + */ + removeItemFromTree: function(aItem) { + let items; + let filter = unifinderTreeView.mFilter; + if (filter.startDate && filter.endDate && (aItem.parentItem == aItem)) { + items = aItem.getOccurrencesBetween(filter.startDate, filter.endDate, {}); + } else { + items = [aItem]; + } + // XXX: do we really still need this, we are always checking it in the refreshInternal + unifinderTreeView.removeItems(items.filter(filter.isItemInFilters, filter)); + }, + + observe: function(aSubject, aTopic, aPrefName) { + switch (aPrefName) { + case "calendar.date.format": + case "calendar.timezone.local": + refreshEventTree(); + break; + } + } +}; + +/** + * Called when the window is loaded to prepare the unifinder. This function is + * used to add observers, event listeners, etc. + */ +function prepareCalendarUnifinder() { + // Only load once + window.removeEventListener("load", prepareCalendarUnifinder, false); + let unifinderTree = document.getElementById("unifinder-search-results-tree"); + + // Add pref observer + let branch = Services.prefs.getBranch(""); + branch.addObserver("calendar.", unifinderObserver, false); + + // Check if this is not the hidden window, which has no UI elements + if (unifinderTree) { + // set up our calendar event observer + let ccalendar = getCompositeCalendar(); + ccalendar.addObserver(unifinderObserver); + + kDefaultTimezone = calendarDefaultTimezone(); + + // Set up the filter + unifinderTreeView.mFilter = new calFilter(); + + // Set up the unifinder views. + unifinderTreeView.treeElement = unifinderTree; + unifinderTree.view = unifinderTreeView; + + // Listen for changes in the selected day, so we can update if need be + let viewDeck = getViewDeck(); + viewDeck.addEventListener("dayselect", unifinderDaySelect, false); + viewDeck.addEventListener("itemselect", unifinderItemSelect, true); + + // Set up sortDirection and sortActive, in case it persisted + let sorted = unifinderTree.getAttribute("sort-active"); + let sortDirection = unifinderTree.getAttribute("sort-direction"); + if (!sortDirection || sortDirection == "undefined") { + sortDirection = "ascending"; + } + let tree = document.getElementById("unifinder-search-results-tree"); + let treecols = tree.getElementsByTagName("treecol"); + for (let i = 0; i < treecols.length; i++) { + let col = treecols[i]; + let content = col.getAttribute("itemproperty"); + if (sorted && sorted.length > 0) { + if (sorted == content) { + unifinderTreeView.sortDirection = sortDirection; + unifinderTreeView.selectedColumn = col; + } + } + } + // Display something upon first load. onLoad doesn't work properly for + // observers + if (!isUnifinderHidden()) { + gUnifinderNeedsRefresh = false; + refreshEventTree(); + } + } +} + +/** + * Called when the window is unloaded to clean up any observers and listeners + * added. + */ +function finishCalendarUnifinder() { + let ccalendar = getCompositeCalendar(); + ccalendar.removeObserver(unifinderObserver); + + // Remove pref observer + let branch = Services.prefs.getBranch(""); + branch.removeObserver("calendar.", unifinderObserver, false); + + let viewDeck = getViewDeck(); + if (viewDeck) { + viewDeck.removeEventListener("dayselect", unifinderDaySelect, false); + viewDeck.removeEventListener("itemselect", unifinderItemSelect, true); + } + + // Persist the sort + let unifinderTree = document.getElementById("unifinder-search-results-tree"); + let sorted = unifinderTreeView.selectedColumn; + if (sorted) { + unifinderTree.setAttribute("sort-active", sorted.getAttribute("itemproperty")); + unifinderTree.setAttribute("sort-direction", unifinderTreeView.sortDirection); + } else { + unifinderTree.removeAttribute("sort-active"); + unifinderTree.removeAttribute("sort-direction"); + } +} + +/** + * Event listener for the view deck's dayselect event. + */ +function unifinderDaySelect() { + let filter = getCurrentUnifinderFilter(); + if (filter == "current" || filter == "currentview") { + refreshEventTree(); + } +} + +/** + * Event listener for the view deck's itemselect event. + */ +function unifinderItemSelect(aEvent) { + unifinderTreeView.setSelectedItems(aEvent.detail); +} + +/** + * Helper function to display event datetimes in the unifinder. + * + * @param aDatetime A calIDateTime object to format. + * @return The passed date's formatted in the default timezone. + */ +function formatUnifinderEventDateTime(aDatetime) { + return cal.getDateFormatter().formatDateTime(aDatetime.getInTimezone(kDefaultTimezone)); +} + +/** + * Handler function for double clicking the unifinder. + * + * @param event The DOM doubleclick event. + */ +function unifinderDoubleClick(event) { + // We only care about button 0 (left click) events + if (event.button != 0) { + return; + } + + // find event by id + let calendarEvent = unifinderTreeView.getItemFromEvent(event); + + if (calendarEvent) { + modifyEventWithDialog(calendarEvent, null, true); + } else { + createEventWithDialog(); + } +} + +/** + * Handler function for selection in the unifinder. + * + * @param event The DOM selection event. + */ +function unifinderSelect(event) { + let tree = unifinderTreeView.treeElement; + if (!tree.view.selection || tree.view.selection.getRangeCount() == 0) { + return; + } + + let selectedItems = []; + gCalendarEventTreeClicked = true; + + // Get the selected events from the tree + let start = {}; + let end = {}; + let numRanges = tree.view.selection.getRangeCount(); + + for (let range = 0; range < numRanges; range++) { + tree.view.selection.getRangeAt(range, start, end); + + for (let i = start.value; i <= end.value; i++) { + try { + selectedItems.push(unifinderTreeView.getItemAt(i)); + } catch (e) { + WARN("Error getting Event from row: " + e + "\n"); + } + } + } + + if (selectedItems.length == 1) { + // Go to the day of the selected item in the current view. + currentView().goToDay(selectedItems[0].startDate); + } + + // Set up the selected items in the view. Pass in true, so we don't end + // up in a circular loop + currentView().setSelectedItems(selectedItems.length, selectedItems, true); + currentView().centerSelectedItems(); + calendarController.onSelectionChanged({ detail: selectedItems }); + document.getElementById("unifinder-search-results-tree").focus(); +} + +/** + * Handler function for keypress in the unifinder. + * + * @param aEvent The DOM Key event. + */ +function unifinderKeyPress(aEvent) { + const kKE = Components.interfaces.nsIDOMKeyEvent; + switch (aEvent.keyCode) { + case 13: + // Enter, edit the event + editSelectedEvents(); + aEvent.stopPropagation(); + aEvent.preventDefault(); + break; + case kKE.DOM_VK_BACK_SPACE: + case kKE.DOM_VK_DELETE: + deleteSelectedEvents(); + aEvent.stopPropagation(); + aEvent.preventDefault(); + break; + } +} + +/** + * Tree controller for unifinder search results + */ +var unifinderTreeView = { + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.nsITreeView]), + + // Provide a default tree that holds all the functions used here to avoid + // cludgy if (this.tree) { this.tree.rowCountChanged(...); } constructs. + tree: { + rowCountChanged: function() {}, + beginUpdateBatch: function() {}, + endUpdateBatch: function() {}, + invalidate: function() {} + }, + + treeElement: null, + doingSelection: false, + mFilter: null, + mSelectedColumn: null, + sortDirection: null, + + /** + * Returns the currently selected column in the unifinder (used for sorting). + */ + get selectedColumn() { + return this.mSelectedColumn; + }, + + /** + * Sets the currently selected column in the unifinder (used for sorting). + */ + set selectedColumn(aCol) { + let tree = document.getElementById("unifinder-search-results-tree"); + let treecols = tree.getElementsByTagName("treecol"); + for (let col of treecols) { + if (col.getAttribute("sortActive")) { + col.removeAttribute("sortActive"); + col.removeAttribute("sortDirection"); + } + if (aCol.getAttribute("itemproperty") == col.getAttribute("itemproperty")) { + col.setAttribute("sortActive", "true"); + col.setAttribute("sortDirection", this.sortDirection); + } + } + return (this.mSelectedColumn = aCol); + }, + + /** + * Event functions + */ + + eventArray: [], + eventIndexMap: {}, + + /** + * Add an item to the unifinder tree. + * + * @param aItemArray An array of items to add. + * @param aDontSort If true, the items will only be appended. + */ + addItems: function(aItemArray, aDontSort) { + this.eventArray = this.eventArray.concat(aItemArray); + let newCount = (this.eventArray.length - aItemArray.length - 1); + this.tree.rowCountChanged(newCount, aItemArray.length); + + if (aDontSort) { + this.calculateIndexMap(); + } else { + this.sortItems(); + } + }, + + /** + * Remove items from the unifinder tree. + * + * @param aItemArray An array of items to remove. + */ + removeItems: function(aItemArray) { + let indexesToRemove = []; + // Removing items is a bit tricky. Our getItemRow function takes the + // index from a cached map, so removing an item from the array will + // remove the wrong indexes. We don't want to just invalidate the map, + // since this will cause O(n^2) behavior. Instead, we keep a sorted + // array of the indexes to remove: + for (let item of aItemArray) { + let row = this.getItemRow(item); + if (row > -1) { + if (!indexesToRemove.length || row <= indexesToRemove[0]) { + indexesToRemove.unshift(row); + } else { + indexesToRemove.push(row); + } + } + } + + // Then we go through the indexes to remove, and remove then from the + // array. We subtract one delta for each removed index to make sure the + // correct element is removed from the array and the correct + // notification is sent. + this.tree.beginUpdateBatch(); + for (let delta = 0; delta < indexesToRemove.length; delta++) { + let index = indexesToRemove[delta]; + this.eventArray.splice(index - delta, 1); + this.tree.rowCountChanged(index - delta, -1); + } + this.tree.endUpdateBatch(); + + // Finally, we recalculate the index map once. This way we end up with + // (given that Array.unshift doesn't loop but just prepends or maps + // memory smartly) O(3n) behavior. Lets hope its worth it. + this.calculateIndexMap(true); + }, + + /** + * Clear all items from the unifinder. + */ + clearItems: function() { + let oldCount = this.eventArray.length; + this.eventArray = []; + this.tree.rowCountChanged(0, -oldCount); + this.calculateIndexMap(); + }, + + /** + * Sets the items that should be in the unifinder. This removes all items + * that were previously in the unifinder. + */ + setItems: function(aItemArray, aDontSort) { + let oldCount = this.eventArray.length; + this.eventArray = aItemArray.slice(0); + this.tree.rowCountChanged(oldCount - 1, this.eventArray.length - oldCount); + + if (aDontSort) { + this.calculateIndexMap(); + } else { + this.sortItems(); + } + }, + + /** + * Recalculate the index map that improves performance when accessing + * unifinder items. This is usually done automatically when adding/removing + * items. + * + * @param aDontInvalidate (optional) Don't invalidate the tree, i.e if + * you correctly issued rowCountChanged + * notices. + */ + calculateIndexMap: function(aDontInvalidate) { + this.eventIndexMap = {}; + for (let i = 0; i < this.eventArray.length; i++) { + this.eventIndexMap[this.eventArray[i].hashId] = i; + } + + if (!aDontInvalidate) { + this.tree.invalidate(); + } + }, + + /** + * Sort the items in the unifinder by the currently selected column. + */ + sortItems: function() { + if (this.selectedColumn) { + let modifier = (this.sortDirection == "descending" ? -1 : 1); + let sortKey = unifinderTreeView.selectedColumn.getAttribute("itemproperty"); + let sortType = cal.getSortTypeForSortKey(sortKey); + // sort (key,item) entries + cal.sortEntry.mSortKey = sortKey; + cal.sortEntry.mSortStartedDate = now(); + let entries = this.eventArray.map(cal.sortEntry, cal.sortEntry); + entries.sort(cal.sortEntryComparer(sortType, modifier)); + this.eventArray = entries.map(cal.sortEntryItem); + } + this.calculateIndexMap(); + }, + + /** + * Get the index of the row associated with the passed item. + * + * @param item The item to search for. + * @return The row index of the passed item. + */ + getItemRow: function(item) { + if (this.eventIndexMap[item.hashId] === undefined) { + return -1; + } + return this.eventIndexMap[item.hashId]; + }, + + /** + * Get the item at the given row index. + * + * @param item The row index to get the item for. + * @return The item at the given row. + */ + getItemAt: function(aRow) { + return this.eventArray[aRow]; + }, + + /** + * Get the calendar item from the given DOM event + * + * @param event The DOM mouse event to get the item for. + * @return The item under the mouse position. + */ + getItemFromEvent: function(event) { + let row = this.tree.getRowAt(event.clientX, event.clientY); + + if (row > -1) { + return this.getItemAt(row); + } + return null; + }, + + /** + * Change the selection in the unifinder. + * + * @param aItemArray An array of items to select. + */ + setSelectedItems: function(aItemArray) { + if (this.doingSelection || !this.tree || !this.tree.view) { + return; + } + + this.doingSelection = true; + + // If no items were passed, get the selected items from the view. + aItemArray = aItemArray || currentView().getSelectedItems({}); + + calendarUpdateDeleteCommand(aItemArray); + + /** + * The following is a brutal hack, caused by + * http://lxr.mozilla.org/mozilla1.0/source/layout/xul/base/src/tree/src/nsTreeSelection.cpp#555 + * and described in bug 168211 + * http://bugzilla.mozilla.org/show_bug.cgi?id=168211 + * Do NOT remove anything in the next 3 lines, or the selection in the tree will not work. + */ + this.treeElement.onselect = null; + this.treeElement.removeEventListener("select", unifinderSelect, true); + this.tree.view.selection.selectEventsSuppressed = true; + this.tree.view.selection.clearSelection(); + + if (aItemArray && aItemArray.length == 1) { + // If only one item is selected, scroll to it + let rowToScrollTo = this.getItemRow(aItemArray[0]); + if (rowToScrollTo > -1) { + this.tree.ensureRowIsVisible(rowToScrollTo); + this.tree.view.selection.select(rowToScrollTo); + } + } else if (aItemArray && aItemArray.length > 1) { + // If there is more than one item, just select them all. + for (let item of aItemArray) { + let row = this.getItemRow(item); + this.tree.view.selection.rangedSelect(row, row, true); + } + } + + // This needs to be in a setTimeout + setTimeout(() => unifinderTreeView.resetAllowSelection(), 1); + }, + + /** + * Due to a selection issue described in bug 168211 this method is needed to + * re-add the selection listeners selection listeners. + */ + resetAllowSelection: function() { + if (!this.tree) { + return; + } + /** + * Do not change anything in the following lines, they are needed as + * described in the selection observer above + */ + this.doingSelection = false; + + this.tree.view.selection.selectEventsSuppressed = false; + this.treeElement.addEventListener("select", unifinderSelect, true); + }, + + /** + * Tree View Implementation + * @see nsITreeView + */ + get rowCount() { + return this.eventArray.length; + }, + + + // TODO this code is currently identical to the task tree. We should create + // an itemTreeView that these tree views can inherit, that contains this + // code, and possibly other code related to sorting and storing items. See + // bug 432582 for more details. + getCellProperties: function(aRow, aCol) { + let rowProps = this.getRowProperties(aRow); + let colProps = this.getColumnProperties(aCol); + return rowProps + (rowProps && colProps ? " " : "") + colProps; + }, + getRowProperties: function(aRow) { + let properties = []; + let item = this.eventArray[aRow]; + if (item.priority > 0 && item.priority < 5) { + properties.push("highpriority"); + } else if (item.priority > 5 && item.priority < 10) { + properties.push("lowpriority"); + } + + // Add calendar name atom + properties.push("calendar-" + formatStringForCSSRule(item.calendar.name)); + + // Add item status atom + if (item.status) { + properties.push("status-" + item.status.toLowerCase()); + } + + // Alarm status atom + if (item.getAlarms({}).length) { + properties.push("alarm"); + } + + // Task categories + properties = properties.concat(item.getCategories({}) + .map(formatStringForCSSRule)); + + return properties.join(" "); + }, + getColumnProperties: function(aCol) { return ""; }, + + isContainer: function() { + return false; + }, + + isContainerOpen: function(aRow) { + return false; + }, + + isContainerEmpty: function(aRow) { + return false; + }, + + isSeparator: function(aRow) { + return false; + }, + + isSorted: function(aRow) { + return false; + }, + + canDrop: function(aRow, aOrientation) { + return false; + }, + + drop: function(aRow, aOrientation) {}, + + getParentIndex: function(aRow) { + return -1; + }, + + hasNextSibling: function(aRow, aAfterIndex) {}, + + getLevel: function(aRow) { + return 0; + }, + + getImageSrc: function(aRow, aOrientation) {}, + + getProgressMode: function(aRow, aCol) {}, + + getCellValue: function(aRow, aCol) { + return null; + }, + + getCellText: function(row, column) { + let calendarEvent = this.eventArray[row]; + + switch (column.element.getAttribute("itemproperty")) { + case "title": { + return (calendarEvent.title ? calendarEvent.title.replace(/\n/g, " ") : ""); + } + case "startDate": { + return formatUnifinderEventDateTime(calendarEvent.startDate); + } + case "endDate": { + let eventEndDate = calendarEvent.endDate.clone(); + // XXX reimplement + // let eventEndDate = getCurrentNextOrPreviousRecurrence(calendarEvent); + if (calendarEvent.startDate.isDate) { + // display enddate is ical enddate - 1 + eventEndDate.day = eventEndDate.day - 1; + } + return formatUnifinderEventDateTime(eventEndDate); + } + case "categories": { + return calendarEvent.getCategories({}).join(", "); + } + case "location": { + return calendarEvent.getProperty("LOCATION"); + } + case "status": { + return getEventStatusString(calendarEvent); + } + case "calendar": { + return calendarEvent.calendar.name; + } + default: { + return false; + } + } + }, + + setTree: function(tree) { + this.tree = tree; + }, + + toggleOpenState: function(aRow) {}, + + cycleHeader: function(col) { + if (!this.selectedColumn) { + this.sortDirection = "ascending"; + } else if (!this.sortDirection || this.sortDirection == "descending") { + this.sortDirection = "ascending"; + } else { + this.sortDirection = "descending"; + } + this.selectedColumn = col.element; + this.sortItems(); + }, + + isEditable: function(aRow, aCol) { + return false; + }, + + setCellValue: function(aRow, aCol, aValue) {}, + setCellText: function(aRow, aCol, aValue) {}, + + performAction: function(aAction) {}, + + performActionOnRow: function(aAction, aRow) {}, + + performActionOnCell: function(aAction, aRow, aCol) {}, + + outParameter: {} // used to obtain dates during sort +}; + +/** + * Refresh the unifinder tree by getting items from the composite calendar and + * applying the current filter. + */ +function refreshEventTree() { + let field = document.getElementById("unifinder-search-field"); + if (field) { + unifinderTreeView.mFilter.filterText = field.value; + } + + addItemsFromCalendar(getCompositeCalendar(), + addItemsFromCompositeCalendarInternal); +} + +/** + * EXTENSION_POINTS + * Filters the passed event array according to the currently applied filter. + * Afterwards, applies the items to the unifinder view. + * + * If you are implementing a new filter, you can overwrite this function and + * filter the items accordingly and afterwards call this function with the + * result. + * + * @param eventArray The array of items to be set in the unifinder. + */ +function addItemsFromCompositeCalendarInternal(eventArray) { + let newItems = eventArray.filter(unifinderTreeView.mFilter.isItemInFilters, + unifinderTreeView.mFilter); + unifinderTreeView.setItems(newItems); + + // Select selected events in the tree. Not passing the argument gets the + // items from the view. + unifinderTreeView.setSelectedItems(); +} + +function addItemsFromSingleCalendarInternal(eventArray) { + let newItems = eventArray.filter(unifinderTreeView.mFilter.isItemInFilters, + unifinderTreeView.mFilter); + unifinderTreeView.setItems(unifinderTreeView.eventArray.concat(newItems)); + + // Select selected events in the tree. Not passing the argument gets the + // items from the view. + unifinderTreeView.setSelectedItems(); +} + +function addItemsFromCalendar(aCalendar, aAddItemsInternalFunc) { + if (isUnifinderHidden()) { + // If the unifinder is hidden, don't refresh the events to reduce needed + // getItems calls. + return; + } + let refreshListener = { + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calIOperationListener]), + mEventArray: [], + + onOperationComplete: function(aOpCalendar, aStatus, aOperationType, aId, aDateTime) { + let refreshTreeInternalFunc = function() { + aAddItemsInternalFunc(refreshListener.mEventArray); + }; + setTimeout(refreshTreeInternalFunc, 0); + }, + + onGetResult: function(aOpCalendar, aStatus, aItemType, aDetail, aCount, aItems) { + refreshListener.mEventArray = refreshListener.mEventArray.concat(aItems); + } + }; + + let filter = 0; + + filter |= aCalendar.ITEM_FILTER_TYPE_EVENT; + + // Not all xul might be there yet... + if (!document.getElementById("unifinder-search-field")) { + return; + } + unifinderTreeView.mFilter.applyFilter(getCurrentUnifinderFilter()); + + if (unifinderTreeView.mFilter.startDate && unifinderTreeView.mFilter.endDate) { + filter |= aCalendar.ITEM_FILTER_CLASS_OCCURRENCES; + } + + aCalendar.getItems(filter, + 0, + unifinderTreeView.mFilter.startDate, + unifinderTreeView.mFilter.endDate, + refreshListener); +} + +function deleteItemsFromCalendar(aCalendar) { + let filter = unifinderTreeView.mFilter; + let items = unifinderTreeView.eventArray.filter(item => item.calendar.id == aCalendar.id); + + unifinderTreeView.removeItems(items.filter(filter.isItemInFilters, filter)); +} + +/** + * Focuses the unifinder search field + */ +function focusSearch() { + document.getElementById("unifinder-search-field").focus(); +} + +/** + * Toggles the hidden state of the unifinder. + */ +function toggleUnifinder() { + // Toggle the elements + goToggleToolbar("bottom-events-box", "calendar_show_unifinder_command"); + goToggleToolbar("calendar-view-splitter"); + + unifinderTreeView.treeElement.view = unifinderTreeView; + + // When the unifinder is hidden, refreshEventTree is not called. Make sure + // the event tree is refreshed now. + if (!isUnifinderHidden() && gUnifinderNeedsRefresh) { + gUnifinderNeedsRefresh = false; + refreshEventTree(); + } + + // Make sure the selection is correct + if (unifinderTreeView.doingSelection) { + unifinderTreeView.resetAllowSelection(); + } + unifinderTreeView.setSelectedItems(); +} + +window.addEventListener("load", prepareCalendarUnifinder, false); +window.addEventListener("unload", finishCalendarUnifinder, false); diff --git a/calendar/base/content/calendar-unifinder.xul b/calendar/base/content/calendar-unifinder.xul new file mode 100644 index 000000000..e8756aadb --- /dev/null +++ b/calendar/base/content/calendar-unifinder.xul @@ -0,0 +1,140 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<?xml-stylesheet href="chrome://calendar/skin/calendar-unifinder.css" type="text/css"?> + +<!DOCTYPE overlay SYSTEM "chrome://calendar/locale/calendar.dtd"> + +<overlay xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <script type="application/javascript" src="chrome://calendar/content/calendar-unifinder.js"/> + <script type="application/javascript" src="chrome://global/content/globalOverlay.js"/> + + <vbox id="calendar-view-box"> + <vbox id="bottom-events-box" insertbefore="calendar-nav-control" persist="height"> + <hbox id="unifinder-searchBox" persist="collapsed"> + <box align="center"> + <menulist id="event-filter-menulist" value="P7D" persist="value"> + <menupopup id="event-filter-menupopup" oncommand="refreshEventTree()"> + <menuitem id="event-filter-all" + label="&calendar.events.filter.all.label;" + value="all"/> + <menuitem id="event-filter-today" + label="&calendar.events.filter.today.label;" + value="today"/> + <menuitem id="event-filter-next7days" + label="&calendar.events.filter.next7Days.label;" + value="P7D"/> + <menuitem id="event-filter-next14Days" + label="&calendar.events.filter.next14Days.label;" + value="P14D"/> + <menuitem id="event-filter-next31Days" + label="&calendar.events.filter.next31Days.label;" + value="P31D"/> + <menuitem id="event-filter-thisCalendarMonth" + label="&calendar.events.filter.thisCalendarMonth.label;" + value="thisCalendarMonth"/> + <menuitem id="event-filter-future" + label="&calendar.events.filter.future.label;" + value="future"/> + <menuitem id="event-filter-current" + label="&calendar.events.filter.current.label;" + value="current"/> + <menuitem id="event-filter-currentview" + label="&calendar.events.filter.currentview.label;" + value="currentview"/> + </menupopup> + </menulist> + </box> + <box align="center" flex="1"> + <label control="unifinder-search-field" value="&calendar.search.options.searchfor;"/> + <textbox id="unifinder-search-field" + class="searchBox" + type="search" + oncommand="refreshEventTree();" + flex="1"/> + </box> + <toolbarbutton id="unifinder-closer" + class="unifinder-closebutton" + command="calendar_show_unifinder_command" + tooltiptext="&calendar.unifinder.close.tooltip;"/> + </hbox> + <tree id="unifinder-search-results-tree" flex="1" + onselect="unifinderSelect(event); calendarController.onSelectionChanged()" + onkeypress="unifinderKeyPress(event)" + _selectDelay="500" + persist="sort-active sort-direction" + enableColumnDrag="true"> + <treecols id="unifinder-search-results-tree-cols"> + <treecol id="unifinder-search-results-tree-col-title" + persist="hidden ordinal width" + flex="1" + itemproperty="title" + label="&calendar.unifinder.tree.title.label;" + tooltiptext="&calendar.unifinder.tree.title.tooltip2;"/> + <splitter class="tree-splitter"/> + <treecol id="unifinder-search-results-tree-col-startdate" + persist="hidden ordinal width" + flex="1" + itemproperty="startDate" + label="&calendar.unifinder.tree.startdate.label;" + tooltiptext="&calendar.unifinder.tree.startdate.tooltip2;"/> + <splitter class="tree-splitter"/> + <treecol id="unifinder-search-results-tree-col-enddate" + persist="hidden ordinal width" + flex="1" + itemproperty="endDate" + label="&calendar.unifinder.tree.enddate.label;" + tooltiptext="&calendar.unifinder.tree.enddate.tooltip2;"/> + <splitter class="tree-splitter"/> + <treecol id="unifinder-search-results-tree-col-categories" + persist="hidden ordinal width" + flex="1" + itemproperty="categories" + label="&calendar.unifinder.tree.categories.label;" + tooltiptext="&calendar.unifinder.tree.categories.tooltip2;"/> + <splitter class="tree-splitter"/> + <treecol id="unifinder-search-results-tree-col-location" + persist="hidden ordinal width" + flex="1" + hidden="true" + itemproperty="location" + label="&calendar.unifinder.tree.location.label;" + tooltiptext="&calendar.unifinder.tree.location.tooltip2;"/> + <splitter class="tree-splitter"/> + <treecol id="unifinder-search-results-tree-col-status" + persist="hidden ordinal width" + flex="1" + hidden="true" + itemproperty="status" + label="&calendar.unifinder.tree.status.label;" + tooltiptext="&calendar.unifinder.tree.status.tooltip2;"/> + <treecol id="unifinder-search-results-tree-col-calendarname" + persist="hidden ordinal width" + flex="1" + hidden="true" + itemproperty="calendar" + label="&calendar.unifinder.tree.calendarname.label;" + tooltiptext="&calendar.unifinder.tree.calendarname.tooltip2;"/> + </treecols> + + <!-- on mousedown here happens before onclick above --> + <treechildren tooltip="eventTreeTooltip" + context="calendar-item-context-menu" + onkeypress="if (event.keyCode == 13) unifinderEditCommand();" + ondragenter="return false;" + ondblclick="unifinderDoubleClick(event)" + onfocus="focusFirstItemIfNoSelection( );"/> + </tree> + </vbox> + <splitter id="calendar-view-splitter" + insertbefore="calendar-nav-control" + resizebefore="closest" + resizeafter="farthest" + persist="state" + class="chromeclass-extrachrome sidebar-splitter calendar-splitter" + orient="vertical" + onmouseup="setTimeout(refreshEventTree, 10);"/> + </vbox> +</overlay> diff --git a/calendar/base/content/calendar-view-bindings.css b/calendar/base/content/calendar-view-bindings.css new file mode 100644 index 000000000..7cffd605d --- /dev/null +++ b/calendar/base/content/calendar-view-bindings.css @@ -0,0 +1,72 @@ +/* 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/. */ + +@namespace url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"); + +calendar-event-column { + -moz-binding: url(chrome://calendar/content/calendar-multiday-view.xml#calendar-event-column); +} + +calendar-event-box { + -moz-binding: url(chrome://calendar/content/calendar-multiday-view.xml#calendar-event-box); +} + +calendar-event-gripbar { + -moz-binding: url(chrome://calendar/content/calendar-multiday-view.xml#calendar-event-gripbar); +} + +calendar-time-bar { + -moz-binding: url(chrome://calendar/content/calendar-multiday-view.xml#calendar-time-bar); +} + +calendar-multiday-view { + -moz-binding: url(chrome://calendar/content/calendar-multiday-view.xml#calendar-multiday-view); +} + +calendar-day-label { + -moz-binding: url(chrome://calendar/content/calendar-base-view.xml#calendar-day-label); +} + +nav-day-label { + -moz-binding: url(chrome://calendar/content/calendar-base-view.xml#nav-day-label); +} + +calendar-base-view { + -moz-binding: url(chrome://calendar/content/calendar-base-view.xml#calendar-base-view); +} + +calendar-header-container { + -moz-binding: url(chrome://calendar/content/calendar-multiday-view.xml#calendar-header-container); +} + +/* Month View */ +calendar-month-base-view { + -moz-binding: url(chrome://calendar/content/calendar-month-view.xml#calendar-month-base-view); +} + +calendar-base-view { + -moz-binding: url(chrome://calendar/content/calendar-base-view.xml#calendar-base-view); + -moz-user-focus: normal; +} + +calendar-month-day-box { + -moz-binding: url(chrome://calendar/content/calendar-month-view.xml#calendar-month-day-box); +} + +calendar-month-day-box-item { + -moz-binding: url(chrome://calendar/content/calendar-month-view.xml#calendar-month-day-box-item); +} + +/* View core */ +calendar-editable-item { + -moz-binding: url(chrome://calendar/content/calendar-view-core.xml#calendar-editable-item); +} + +calendar-category-box { + -moz-binding: url(chrome://calendar/content/calendar-view-core.xml#calendar-category-box); +} + + calendar-shadow-box { + -moz-binding: url(chrome://calendar/content/calendar-view-core.xml#calendar-shadow-box); + } diff --git a/calendar/base/content/calendar-view-core.xml b/calendar/base/content/calendar-view-core.xml new file mode 100644 index 000000000..35f70c676 --- /dev/null +++ b/calendar/base/content/calendar-view-core.xml @@ -0,0 +1,389 @@ +<?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/. --> + +<!-- Note that this file depends on helper functions in calUtils.js--> + +<bindings id="calendar-core-view-bindings" + xmlns="http://www.mozilla.org/xbl" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:xbl="http://www.mozilla.org/xbl"> + + <binding id="calendar-editable-item"> + <content mousethrough="never" + tooltip="itemTooltip" + tabindex="-1"> + <xul:vbox flex="1"> + <xul:hbox> + <xul:box anonid="event-container" + class="calendar-color-box" + xbl:inherits="calendar-uri,calendar-id" + flex="1"> + <xul:box class="calendar-event-selection" orient="horizontal" flex="1"> + <xul:stack anonid="eventbox" + class="calendar-event-box-container" + flex="1" + xbl:inherits="readonly,flashing,alarm,allday,priority,progress,status,calendar,categories"> + <xul:hbox class="calendar-event-details"> + <xul:vbox align="left" + flex="1" + xbl:inherits="context"> + <xul:label anonid="event-name" + crop="end" + flex="1" + style="margin: 0;"/> + <xul:textbox anonid="event-name-textbox" + class="plain" + crop="end" + hidden="true" + style="background: transparent !important;" + wrap="true"/> + <xul:spacer flex="1"/> + </xul:vbox> + <xul:stack> + <xul:calendar-category-box anonid="category-box" + xbl:inherits="categories" + pack="end"/> + <xul:hbox align="center"> + <xul:hbox anonid="alarm-icons-box" + class="alarm-icons-box" + align="center" + xbl:inherits="flashing"/> + <xul:image anonid="item-classification-box" + class="item-classification-box" + pack="end"/> + </xul:hbox> + </xul:stack> + </xul:hbox> + </xul:stack> + </xul:box> + </xul:box> + </xul:hbox> + </xul:vbox> + </content> + + <implementation> + <constructor><![CDATA[ + Components.utils.import("resource://calendar/modules/calAlarmUtils.jsm"); + Components.utils.import("resource://calendar/modules/calUtils.jsm"); + Components.utils.import("resource://gre/modules/Preferences.jsm"); + + this.eventNameTextbox.onblur = () => { + this.stopEditing(true); + }; + this.eventNameTextbox.onkeypress = (event) => { + // save on enter + if (event.keyCode == 13) { + this.stopEditing(true); + // abort on escape + } else if (event.keyCode == 27) { + this.stopEditing(false); + } + }; + let stopPropagationIfEditing = (event) => { + if (this.mEditing) { + event.stopPropagation(); + } + }; + // while editing, single click positions cursor, so don't propagate. + this.eventNameTextbox.onclick = stopPropagationIfEditing; + // while editing, double click selects words, so don't propagate. + this.eventNameTextbox.ondblclick = stopPropagationIfEditing; + // while editing, don't propagate mousedown/up (selects calEvent). + this.eventNameTextbox.onmousedown = stopPropagationIfEditing; + this.eventNameTextbox.onmouseup = stopPropagationIfEditing; + ]]></constructor> + + <field name="mOccurrence">null</field> + <field name="mSelected">false</field> + <field name="mCalendarView">null</field> + + <property name="parentBox" + onget="return this.mParentBox;" + onset="this.mParentBox = val;"/> + + <property name="selected"> + <getter><![CDATA[ + return this.mSelected; + ]]></getter> + <setter><![CDATA[ + if (val && !this.mSelected) { + this.mSelected = true; + this.setAttribute("selected", "true"); + } else if (!val && this.mSelected) { + this.mSelected = false; + this.removeAttribute("selected"); + } + return val; + ]]></setter> + </property> + <property name="calendarView"> + <getter><![CDATA[ + return this.mCalendarView; + ]]></getter> + <setter><![CDATA[ + this.mCalendarView = val; + return val; + ]]></setter> + </property> + + <property name="occurrence"> + <getter><![CDATA[ + return this.mOccurrence; + ]]></getter> + <setter><![CDATA[ + this.mOccurrence = val; + this.setEditableLabel(); + this.setCSSClasses(); + return val; + ]]></setter> + </property> + + <property name="eventNameLabel" readonly="true" + onget="return document.getAnonymousElementByAttribute(this, 'anonid', 'event-name');"/> + <property name="eventNameTextbox" readonly="true" + onget="return document.getAnonymousElementByAttribute(this, 'anonid', 'event-name-textbox');"/> + + <method name="setEditableLabel"> + <body><![CDATA[ + let evl = this.eventNameLabel; + let item = this.mOccurrence; + evl.value = (item.title ? item.title.replace(/\n/g, " ") + : cal.calGetString("calendar", "eventUntitled")); + ]]></body> + </method> + + <method name="setCSSClasses"> + <body><![CDATA[ + let item = this.mOccurrence; + this.setAttribute("calendar-uri", item.calendar.uri.spec); + this.setAttribute("calendar-id", item.calendar.id); + let categoriesArray = item.getCategories({}); + if (categoriesArray.length > 0) { + let cssClassesArray = categoriesArray.map(formatStringForCSSRule); + this.setAttribute("categories", cssClassesArray.join(" ")); + } + + // Add alarm icons as needed. + let alarms = item.getAlarms({}); + if (alarms.length && Preferences.get("calendar.alarms.indicator.show", true)) { + let iconsBox = document.getAnonymousElementByAttribute(this, "anonid", "alarm-icons-box"); + cal.alarms.addReminderImages(iconsBox, alarms); + + // Set suppressed status on the icons box + setElementValue(iconsBox, + item.calendar.getProperty("suppressAlarms") || false, + "suppressed"); + } + + // Item classification / privacy + let classificationBox = document.getAnonymousElementByAttribute(this, "anonid", "item-classification-box"); + if (classificationBox) { + classificationBox.setAttribute("classification", item.privacy || "PUBLIC"); + } + + // Set up event box attributes for use in css selectors. Note if + // something is added here, it should also be xbl:inherited correctly + // in the <content> section of this binding, and all that inherit it. + + // Event type specific properties + if (cal.isEvent(item)) { + if (item.startDate.isDate) { + this.setAttribute("allday", "true"); + } + this.setAttribute("itemType", "event"); + } else if (cal.isToDo(item)) { + // progress attribute + this.setAttribute("progress", getProgressAtom(item)); + // Attribute for tasks and tasks image. + this.setAttribute("itemType", "todo"); + if (item.entryDate && !item.dueDate) { + this.setAttribute("todoType", "start"); + } else if (!item.entryDate && item.dueDate) { + this.setAttribute("todoType", "end"); + } + } + + if (this.calendarView && + item.hashId in this.calendarView.mFlashingEvents) { + this.setAttribute("flashing", "true"); + } + + if (alarms.length) { + this.setAttribute("alarm", "true"); + } + + // priority + if (item.priority > 0 && item.priority < 5) { + this.setAttribute("priority", "high"); + } else if (item.priority > 5 && item.priority < 10) { + this.setAttribute("priority", "low"); + } + + // status attribute + if (item.status) { + this.setAttribute("status", item.status.toUpperCase()); + } + + // item class + if (item.hasProperty("CLASS")) { + this.setAttribute("itemclass", item.getProperty("CLASS")); + } + + // calendar name + this.setAttribute("calendar", item.calendar.name.toLowerCase()); + + // Invitation + if (cal.isInvitation(item)) { + this.setAttribute("invitation-status", cal.getInvitedAttendee(item).participationStatus); + this.setAttribute("readonly", "true"); + } else if (!isCalendarWritable(item.calendar)) { + this.setAttribute("readonly", "true"); + } + ]]></body> + </method> + + <method name="startEditing"> + <body><![CDATA[ + this.editingTimer = null; + this.mOriginalTextLabel = this.mOccurrence.title; + + this.eventNameLabel.setAttribute("hidden", "true"); + + this.mEditing = true; + + this.eventNameTextbox.value = this.mOriginalTextLabel; + this.eventNameTextbox.removeAttribute("hidden"); + this.eventNameTextbox.select(); + ]]></body> + </method> + <method name="select"> + <parameter name="event"/> + <body><![CDATA[ + if (!this.calendarView) { + return; + } + let items = this.calendarView.mSelectedItems.slice(); + if (event.ctrlKey || event.metaKey) { + if (this.selected) { + let pos = items.indexOf(this.mOccurrence); + items.splice(pos, 1); + } else { + items.push(this.mOccurrence); + } + } else { + items = [this.mOccurrence]; + } + this.calendarView.setSelectedItems(items.length, items); + ]]></body> + </method> + <method name="stopEditing"> + <parameter name="saveChanges"/> + <body><![CDATA[ + if (!this.mEditing) { + return; + } + + this.mEditing = false; + + if (saveChanges && (this.eventNameTextbox.value != this.mOriginalTextLabel)) { + this.calendarView.controller.modifyOccurrence(this.mOccurrence, + null, null, + this.eventNameTextbox.value); + + // Note that as soon as we do the modifyItem, this element ceases to exist, + // so don't bother trying to modify anything further here! ('this' exists, + // because it's being kept alive, but our child content etc. is all gone) + return; + } + + this.eventNameTextbox.setAttribute("hidden", "true"); + this.eventNameLabel.removeAttribute("hidden"); + return; + ]]></body> + </method> + </implementation> + + <handlers> + <handler event="contextmenu" phase="capturing"><![CDATA[ + // If the middle/right button was used for click just select the item. + if (!this.selected) { + this.select(event); + } + ]]></handler> + <handler event="click" button="0"><![CDATA[ + if (this.mEditing) { + return; + } + + // If the left button was used and the item is already selected + // and there are no multiple items selected start + // the 'single click edit' timeout. Otherwise select the item too. + // Also, check if the calendar is readOnly or we are offline. + + if (this.selected && !(event.ctrlKey || event.metaKey) && + isCalendarWritable(this.mOccurrence.calendar)) { + if (this.editingTimer) { + clearTimeout(this.editingTimer); + } + this.editingTimer = setTimeout(() => this.startEditing(), 350); + } else { + this.select(event); + event.stopPropagation(); + } + ]]></handler> + + <handler event="dblclick" button="0"><![CDATA[ + event.stopPropagation(); + + // stop 'single click edit' timeout (if started) + if (this.editingTimer) { + clearTimeout(this.editingTimer); + this.editingTimer = null; + } + + if (this.calendarView && this.calendarView.controller) { + let item = event.ctrlKey ? this.mOccurrence.parentItem : this.mOccurrence; + this.calendarView.controller.modifyOccurrence(item); + } + ]]></handler> + <handler event="mouseover"><![CDATA[ + if (this.calendarView && this.calendarView.controller) { + event.stopPropagation(); + onMouseOverItem(event); + } + ]]></handler> + <handler event="dragstart"><![CDATA[ + if (event.target.localName == "calendar-event-box") { + return; + } + let item = this.occurrence; + let isInvitation = item.calendar instanceof Components.interfaces.calISchedulingSupport && item.calendar.isInvitation(item); + if (!isCalendarWritable(item.calendar) || !userCanModifyItem(item) || isInvitation) { + return; + } + if (!this.selected) { + this.select(event); + } + invokeEventDragSession(item, this); + ]]></handler> + </handlers> + </binding> + + <binding id="calendar-category-box"> + <!-- calendar-views.css makes this binding hide if the categories attribute + is not specified --> + <content> + <xul:vbox anonid="category-box" + class="category-color-box calendar-event-selection" + xbl:inherits="categories"> + <xul:hbox flex="1"> + <xul:image class="calendar-category-box-gradient" height="1"/> + </xul:hbox> + <xul:hbox height="1"/> + </xul:vbox> + </content> + </binding> +</bindings> diff --git a/calendar/base/content/calendar-views.js b/calendar/base/content/calendar-views.js new file mode 100644 index 000000000..337fd673e --- /dev/null +++ b/calendar/base/content/calendar-views.js @@ -0,0 +1,723 @@ +/* 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 switchToView, getSelectedDay, scheduleMidnightUpdate, + * updateStyleSheetForViews, observeViewDaySelect, toggleOrientation, + * toggleWorkdaysOnly, toggleTasksInView, toggleShowCompletedInView, + * goToDate, getLastCalendarView, deleteSelectedEvents, + * editSelectedEvents, selectAllEvents + */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://calendar/modules/calAlarmUtils.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); +Components.utils.import("resource://gre/modules/Preferences.jsm"); + +/** + * Controller for the views + * @see calIcalendarViewController + */ +var calendarViewController = { + QueryInterface: function(aIID) { + if (!aIID.equals(Components.interfaces.calICalendarViewController) && + !aIID.equals(Components.interfaces.nsISupports)) { + throw Components.results.NS_ERROR_NO_INTERFACE; + } + + return this; + }, + + /** + * Creates a new event + * @see calICalendarViewController + */ + createNewEvent: function(aCalendar, aStartTime, aEndTime, aForceAllday) { + // if we're given both times, skip the dialog + if (aStartTime && aEndTime && !aStartTime.isDate && !aEndTime.isDate) { + let item = cal.createEvent(); + setDefaultItemValues(item, aCalendar, aStartTime, aEndTime); + item.title = calGetString("calendar", "newEvent"); + doTransaction("add", item, item.calendar, null, null); + } else { + createEventWithDialog(aCalendar, aStartTime, null, null, null, aForceAllday); + } + }, + + /** + * Modifies the given occurrence + * @see calICalendarViewController + */ + modifyOccurrence: function(aOccurrence, aNewStartTime, aNewEndTime, aNewTitle) { + // if modifying this item directly (e.g. just dragged to new time), + // then do so; otherwise pop up the dialog + if (aNewStartTime || aNewEndTime || aNewTitle) { + let instance = aOccurrence.clone(); + + if (aNewTitle) { + instance.title = aNewTitle; + } + + // When we made the executive decision (in bug 352862) that + // dragging an occurrence of a recurring event would _only_ act + // upon _that_ occurrence, we removed a bunch of code from this + // function. If we ever revert that decision, check CVS history + // here to get that code back. + + if (aNewStartTime || aNewEndTime) { + // Yay for variable names that make this next line look silly + if (isEvent(instance)) { + if (aNewStartTime && instance.startDate) { + instance.startDate = aNewStartTime; + } + if (aNewEndTime && instance.endDate) { + instance.endDate = aNewEndTime; + } + } else { + if (aNewStartTime && instance.entryDate) { + instance.entryDate = aNewStartTime; + } + if (aNewEndTime && instance.dueDate) { + instance.dueDate = aNewEndTime; + } + } + } + + doTransaction("modify", instance, instance.calendar, aOccurrence, null); + } else { + modifyEventWithDialog(aOccurrence, null, true); + } + }, + + /** + * Deletes the given occurrences + * @see calICalendarViewController + */ + deleteOccurrences: function(aCount, + aOccurrences, + aUseParentItems, + aDoNotConfirm) { + startBatchTransaction(); + let recurringItems = {}; + + let getSavedItem = function(aItemToDelete) { + // Get the parent item, saving it in our recurringItems object for + // later use. + let hashVal = aItemToDelete.parentItem.hashId; + if (!recurringItems[hashVal]) { + recurringItems[hashVal] = { + oldItem: aItemToDelete.parentItem, + newItem: aItemToDelete.parentItem.clone() + }; + } + return recurringItems[hashVal]; + }; + + // Make sure we are modifying a copy of aOccurrences, otherwise we will + // run into race conditions when the view's doDeleteItem removes the + // array elements while we are iterating through them. While we are at + // it, filter out any items that have readonly calendars, so that + // checking for one total item below also works out if all but one item + // are readonly. + let occurrences = aOccurrences.filter(item => isCalendarWritable(item.calendar)); + + for (let itemToDelete of occurrences) { + if (aUseParentItems) { + // Usually happens when ctrl-click is used. In that case we + // don't need to ask the user if he wants to delete an + // occurrence or not. + itemToDelete = itemToDelete.parentItem; + } else if (!aDoNotConfirm && occurrences.length == 1) { + // Only give the user the selection if only one occurrence is + // selected. Otherwise he will get a dialog for each occurrence + // he deletes. + let [targetItem, , response] = promptOccurrenceModification(itemToDelete, false, "delete"); + if (!response) { + // The user canceled the dialog, bail out + break; + } + + itemToDelete = targetItem; + } + + // Now some dirty work: Make sure more than one occurrence can be + // deleted by saving the recurring items and removing occurrences as + // they come in. If this is not an occurrence, we can go ahead and + // delete the whole item. + if (itemToDelete.parentItem.hashId == itemToDelete.hashId) { + doTransaction("delete", itemToDelete, itemToDelete.calendar, null, null); + } else { + let savedItem = getSavedItem(itemToDelete); + savedItem.newItem.recurrenceInfo + .removeOccurrenceAt(itemToDelete.recurrenceId); + // Dont start the transaction yet. Do so later, in case the + // parent item gets modified more than once. + } + } + + // Now handle recurring events. This makes sure that all occurrences + // that have been passed are deleted. + for (let hashVal in recurringItems) { + let ritem = recurringItems[hashVal]; + doTransaction("modify", + ritem.newItem, + ritem.newItem.calendar, + ritem.oldItem, + null); + } + endBatchTransaction(); + } +}; + +/** + * This function does the common steps to switch between views. Should be called + * from app-specific view switching functions + * + * @param aViewType The type of view to select. + */ +function switchToView(aViewType) { + let viewDeck = getViewDeck(); + let selectedDay; + let currentSelection = []; + + // Set up the view commands + let views = viewDeck.childNodes; + for (let i = 0; i < views.length; i++) { + let view = views[i]; + let commandId = "calendar_" + view.id + "_command"; + let command = document.getElementById(commandId); + if (view.id == aViewType + "-view") { + command.setAttribute("checked", "true"); + } else { + command.removeAttribute("checked"); + } + } + + /** + * Sets up a node to use view specific attributes. If there is no view + * specific attribute, then <attr>-all is used instead. + * + * @param id The id of the node to set up. + * @param attr The view specific attribute to modify. + */ + function setupViewNode(id, attr) { + let node = document.getElementById(id); + if (node) { + if (node.hasAttribute(attr + "-" + aViewType)) { + node.setAttribute(attr, node.getAttribute(attr + "-" + aViewType)); + } else { + node.setAttribute(attr, node.getAttribute(attr + "-all")); + } + } + } + + // Set up the labels and accesskeys for the context menu + ["calendar-view-context-menu-next", + "calendar-view-context-menu-previous", + "calendar-go-menu-next", + "calendar-go-menu-previous", + "appmenu_calendar-go-menu-next", + "appmenu_calendar-go-menu-previous"].forEach((x) => { + setupViewNode(x, "label"); + setupViewNode(x, "accesskey"); + }); + + // Set up the labels for the view navigation + ["previous-view-button", + "today-view-button", + "next-view-button"].forEach(x => setupViewNode(x, "tooltiptext")); + + try { + selectedDay = viewDeck.selectedPanel.selectedDay; + currentSelection = viewDeck.selectedPanel.getSelectedItems({}); + } catch (ex) { + // This dies if no view has even been chosen this session, but that's + // ok because we'll just use now() below. + } + + if (!selectedDay) { + selectedDay = now(); + } + + // Anyone wanting to plug in a view needs to follow this naming scheme + let view = document.getElementById(aViewType + "-view"); + viewDeck.selectedPanel = view; + + // Select the corresponding tab + let viewTabs = document.getElementById("view-tabs"); + viewTabs.selectedIndex = getViewDeck().selectedIndex; + + let compositeCal = getCompositeCalendar(); + if (view.displayCalendar != compositeCal) { + view.displayCalendar = compositeCal; + view.timezone = calendarDefaultTimezone(); + view.controller = calendarViewController; + } + + view.goToDay(selectedDay); + view.setSelectedItems(currentSelection.length, currentSelection); + + onCalendarViewResize(); +} + +/** + * Returns the calendar view deck XUL element. + * + * @return The view-deck element. + */ +function getViewDeck() { + return document.getElementById("view-deck"); +} + +/** + * Returns the currently selected calendar view. + * + * @return The selected calendar view + */ +function currentView() { + return getViewDeck().selectedPanel; +} + +/** + * Returns the selected day in the current view. + * + * @return The selected day + */ +function getSelectedDay() { + return currentView().selectedDay; +} + +var gMidnightTimer; + +/** + * Creates a timer that will fire after midnight. Pass in a function as + * aRefreshCallback that should be called at that time. + * + * XXX This function is not very usable, since there is only one midnight timer. + * Better would be a function that uses the observer service to notify at + * midnight. + * + * @param aRefreshCallback A callback to be called at midnight. + */ +function scheduleMidnightUpdate(aRefreshCallback) { + let jsNow = new Date(); + let tomorrow = new Date(jsNow.getFullYear(), jsNow.getMonth(), jsNow.getDate() + 1); + let msUntilTomorrow = tomorrow.getTime() - jsNow.getTime(); + + // Is an nsITimer/callback extreme overkill here? Yes, but it's necessary to + // workaround bug 291386. If we don't, we stand a decent chance of getting + // stuck in an infinite loop. + let udCallback = { + notify: function(timer) { + aRefreshCallback(); + } + }; + + if (gMidnightTimer) { + gMidnightTimer.cancel(); + } else { + // Observer for wake after sleep/hibernate/standby to create new timers and refresh UI + let wakeObserver = { + observe: function(aSubject, aTopic, aData) { + if (aTopic == "wake_notification") { + // postpone refresh for another couple of seconds to get netwerk ready: + if (this.mTimer) { + this.mTimer.cancel(); + } else { + this.mTimer = Components.classes["@mozilla.org/timer;1"] + .createInstance(Components.interfaces.nsITimer); + } + this.mTimer.initWithCallback(udCallback, 10 * 1000, + Components.interfaces.nsITimer.TYPE_ONE_SHOT); + } + } + }; + + // Add observer + Services.obs.addObserver(wakeObserver, "wake_notification", false); + + // Remove observer on unload + window.addEventListener("unload", () => { + Services.obs.removeObserver(wakeObserver, "wake_notification"); + }, false); + gMidnightTimer = Components.classes["@mozilla.org/timer;1"] + .createInstance(Components.interfaces.nsITimer); + } + gMidnightTimer.initWithCallback(udCallback, msUntilTomorrow, gMidnightTimer.TYPE_ONE_SHOT); +} + +/** + * Retuns a cached copy of the view stylesheet. + * + * @return The view stylesheet object. + */ +function getViewStyleSheet() { + if (!getViewStyleSheet.sheet) { + const cssUri = "chrome://calendar/content/calendar-view-bindings.css"; + for (let sheet of document.styleSheets) { + if (sheet.href == cssUri) { + getViewStyleSheet.sheet = sheet; + break; + } + } + } + return getViewStyleSheet.sheet; +} + +/** + * Updates the view stylesheet to contain rules that give all boxes with class + * .calendar-color-box and an attribute calendar-id="<id of the calendar>" the + * background color of the specified calendar. + * + * @param aCalendar The calendar to update the stylesheet for. + */ +function updateStyleSheetForViews(aCalendar) { + if (!updateStyleSheetForViews.ruleCache) { + updateStyleSheetForViews.ruleCache = {}; + } + let ruleCache = updateStyleSheetForViews.ruleCache; + + if (!(aCalendar.id in ruleCache)) { + // We haven't create a rule for this calendar yet, do so now. + let sheet = getViewStyleSheet(); + let ruleString = '.calendar-color-box[calendar-id="' + aCalendar.id + '"] {} '; + let ruleIndex = sheet.insertRule(ruleString, sheet.cssRules.length); + + ruleCache[aCalendar.id] = sheet.cssRules[ruleIndex]; + } + + let color = aCalendar.getProperty("color") || "#A8C2E1"; + ruleCache[aCalendar.id].style.backgroundColor = color; + ruleCache[aCalendar.id].style.color = cal.getContrastingTextColor(color); +} + +/** + * Category preferences observer. Used to update the stylesheets for category + * colors. + * + * Note we need to keep the categoryPrefBranch variable outside of + * initCategories since branch observers only live as long as the branch object + * is alive, and out of categoryManagement to avoid cyclic references. + */ +var categoryPrefBranch; +var categoryManagement = { + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.nsIObserver]), + + initCategories: function() { + categoryPrefBranch = Services.prefs.getBranch("calendar.category.color."); + let categories = categoryPrefBranch.getChildList(""); + + // Fix illegally formatted category prefs. + for (let i in categories) { + let category = categories[i]; + if (category.search(/[^_0-9a-z-]/) != -1) { + let categoryFix = formatStringForCSSRule(category); + if (categoryPrefBranch.prefHasUserValue(categoryFix)) { + categories.splice(i, 1); // remove illegal name + } else { + let color = categoryPrefBranch.getCharPref(category); + categoryPrefBranch.setCharPref(categoryFix, color); + categoryPrefBranch.clearUserPref(category); // not usable + categories[i] = categoryFix; // replace illegal name + } + } + } + + // Add color information to the stylesheets. + categories.forEach(categoryManagement.updateStyleSheetForCategory, + categoryManagement); + categoryPrefBranch.addObserver("", categoryManagement, false); + }, + + cleanupCategories: function() { + categoryPrefBranch = Services.prefs.getBranch("calendar.category.color."); + categoryPrefBranch.removeObserver("", categoryManagement); + }, + + observe: function(aSubject, aTopic, aPrefName) { + this.updateStyleSheetForCategory(aPrefName); + // TODO Currently, the only way to find out if categories are removed is + // to initially grab the calendar.categories.names preference and then + // observe changes to it. it would be better if we had hooks for this, + // so we could delete the rule from our style cache and also remove its + // color preference. + }, + + categoryStyleCache: {}, + + updateStyleSheetForCategory: function(aCatName) { + if (!(aCatName in this.categoryStyleCache)) { + // We haven't created a rule for this category yet, do so now. + let sheet = getViewStyleSheet(); + let ruleString = '.category-color-box[categories~="' + aCatName + '"] {} '; + let ruleIndex = sheet.insertRule(ruleString, sheet.cssRules.length); + + this.categoryStyleCache[aCatName] = sheet.cssRules[ruleIndex]; + } + + let color = Preferences.get("calendar.category.color." + aCatName) || ""; + this.categoryStyleCache[aCatName].style.backgroundColor = color; + } +}; + +/** + * Handler function to set the selected day in the minimonth to the currently + * selected day in the current view. + * + * @param event The "dayselect" event emitted from the views. + * + */ +function observeViewDaySelect(event) { + let date = event.detail; + let jsDate = new Date(date.year, date.month, date.day); + + // for the month and multiweek view find the main month, + // which is the month with the most visible days in the view; + // note, that the main date is the first day of the main month + let jsMainDate; + if (!event.originalTarget.supportsDisjointDates) { + let mainDate = null; + let maxVisibleDays = 0; + let startDay = currentView().startDay; + let endDay = currentView().endDay; + let firstMonth = startDay.startOfMonth; + let lastMonth = endDay.startOfMonth; + for (let month = firstMonth.clone(); month.compare(lastMonth) <= 0; month.month += 1) { + let visibleDays = 0; + if (month.compare(firstMonth) == 0) { + visibleDays = startDay.endOfMonth.day - startDay.day + 1; + } else if (month.compare(lastMonth) == 0) { + visibleDays = endDay.day; + } else { + visibleDays = month.endOfMonth.day; + } + if (visibleDays > maxVisibleDays) { + mainDate = month.clone(); + maxVisibleDays = visibleDays; + } + } + jsMainDate = new Date(mainDate.year, mainDate.month, mainDate.day); + } + + getMinimonth().selectDate(jsDate, jsMainDate); + currentView().focus(); +} + +/** + * Provides a neutral way to get the minimonth, regardless of whether we're in + * Sunbird or Lightning. + * + * @return The XUL minimonth element. + */ +function getMinimonth() { + return document.getElementById("calMinimonth"); +} + +/** + * Update the view orientation based on the checked state of the command + */ +function toggleOrientation() { + let cmd = document.getElementById("calendar_toggle_orientation_command"); + let newValue = (cmd.getAttribute("checked") == "true" ? "false" : "true"); + cmd.setAttribute("checked", newValue); + + let deck = getViewDeck(); + for (let view of deck.childNodes) { + view.rotated = (newValue == "true"); + } + + // orientation refreshes automatically +} + +/** + * Toggle the workdays only checkbox and refresh the current view + * + * XXX We shouldn't need to refresh the view just to toggle the workdays. This + * should happen automatically. + */ +function toggleWorkdaysOnly() { + let cmd = document.getElementById("calendar_toggle_workdays_only_command"); + let newValue = (cmd.getAttribute("checked") == "true" ? "false" : "true"); + cmd.setAttribute("checked", newValue); + + let deck = getViewDeck(); + for (let view of deck.childNodes) { + view.workdaysOnly = (newValue == "true"); + } + + // Refresh the current view + currentView().goToDay(); +} + +/** + * Toggle the tasks in view checkbox and refresh the current view + */ +function toggleTasksInView() { + let cmd = document.getElementById("calendar_toggle_tasks_in_view_command"); + let newValue = (cmd.getAttribute("checked") == "true" ? "false" : "true"); + cmd.setAttribute("checked", newValue); + + let deck = getViewDeck(); + for (let view of deck.childNodes) { + view.tasksInView = (newValue == "true"); + } + + // Refresh the current view + currentView().goToDay(); +} + +/** + * Toggle the show completed in view checkbox and refresh the current view + */ +function toggleShowCompletedInView() { + let cmd = document.getElementById("calendar_toggle_show_completed_in_view_command"); + let newValue = (cmd.getAttribute("checked") == "true" ? "false" : "true"); + cmd.setAttribute("checked", newValue); + + let deck = getViewDeck(); + for (let view of deck.childNodes) { + view.showCompleted = (newValue == "true"); + } + + // Refresh the current view + currentView().goToDay(); +} + +/** + * Provides a neutral way to go to the current day in the views and minimonth. + * + * @param aDate The date to go. + */ +function goToDate(aDate) { + getMinimonth().value = cal.dateTimeToJsDate(aDate); + currentView().goToDay(aDate); +} + +/** + * Returns the calendar view that was selected before restart, or the current + * calendar view if it has already been set in this session + * + * @return The last calendar view. + */ +function getLastCalendarView() { + let deck = getViewDeck(); + if (deck.selectedIndex > -1) { + let viewNode = deck.childNodes[deck.selectedIndex]; + return viewNode.id.replace(/-view/, ""); + } + + // No deck item was selected beforehand, default to week view. + return "week"; +} + +/** + * Deletes items currently selected in the view and clears selection. + */ +function deleteSelectedEvents() { + let selectedItems = currentView().getSelectedItems({}); + calendarViewController.deleteOccurrences(selectedItems.length, + selectedItems, + false, + false); + // clear selection + currentView().setSelectedItems(0, [], true); +} + +/** + * Edit the items currently selected in the view with the event dialog. + */ +function editSelectedEvents() { + let selectedItems = currentView().getSelectedItems({}); + if (selectedItems && selectedItems.length >= 1) { + modifyEventWithDialog(selectedItems[0], null, true); + } +} + +/** + * Select all events from all calendars. Use with care. + */ +function selectAllEvents() { + let items = []; + let listener = { + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calIOperationListener]), + onOperationComplete: function(aCalendar, aStatus, aOperationType, aId, aDetail) { + currentView().setSelectedItems(items.length, items, false); + }, + onGetResult: function(aCalendar, aStatus, aItemType, aDetail, aCount, aItems) { + for (let item of aItems) { + items.push(item); + } + } + }; + + let composite = getCompositeCalendar(); + let filter = composite.ITEM_FILTER_CLASS_OCCURRENCES; + + if (currentView().tasksInView) { + filter |= composite.ITEM_FILTER_TYPE_ALL; + } else { + filter |= composite.ITEM_FILTER_TYPE_EVENT; + } + if (currentView().showCompleted) { + filter |= composite.ITEM_FILTER_COMPLETED_ALL; + } else { + filter |= composite.ITEM_FILTER_COMPLETED_NO; + } + + // Need to move one day out to get all events + let end = currentView().endDay.clone(); + end.day += 1; + + composite.getItems(filter, 0, currentView().startDay, end, listener); +} + +var cal = cal || {}; +cal.navigationBar = { + setDateRange: function(aStartDate, aEndDate) { + let docTitle = ""; + if (aStartDate) { + let intervalLabel = document.getElementById("intervalDescription"); + let firstWeekNo = getWeekInfoService().getWeekTitle(aStartDate); + let secondWeekNo = firstWeekNo; + let weekLabel = document.getElementById("calendarWeek"); + if (aStartDate.nativeTime == aEndDate.nativeTime) { + intervalLabel.value = getDateFormatter().formatDate(aStartDate); + } else { + intervalLabel.value = currentView().getRangeDescription(); + secondWeekNo = getWeekInfoService().getWeekTitle(aEndDate); + } + if (secondWeekNo == firstWeekNo) { + weekLabel.value = calGetString("calendar", "singleShortCalendarWeek", [firstWeekNo]); + weekLabel.tooltipText = calGetString("calendar", "singleLongCalendarWeek", [firstWeekNo]); + } else { + weekLabel.value = calGetString("calendar", "severalShortCalendarWeeks", [firstWeekNo, secondWeekNo]); + weekLabel.tooltipText = calGetString("calendar", "severalLongCalendarWeeks", [firstWeekNo, secondWeekNo]); + } + docTitle = intervalLabel.value; + } + if (document.getElementById("modeBroadcaster").getAttribute("mode") == "calendar") { + document.title = (docTitle ? docTitle + " - " : "") + + calGetString("brand", "brandFullName", null, "branding"); + } + let viewTabs = document.getElementById("view-tabs"); + viewTabs.selectedIndex = getViewDeck().selectedIndex; + } +}; + +/* + * Timer for the time indicator in day and week view. + */ +var timeIndicator = { + timer: null, + start: function(aInterval, aThis) { + timeIndicator.timer = setInterval(() => aThis.updateTimeIndicatorPosition(false), aInterval * 1000); + }, + cancel: function() { + if (timeIndicator.timer) { + clearTimeout(timeIndicator.timer); + timeIndicator.timer = null; + } + }, + lastView: null +}; diff --git a/calendar/base/content/calendar-views.xml b/calendar/base/content/calendar-views.xml new file mode 100644 index 000000000..44e756ad9 --- /dev/null +++ b/calendar/base/content/calendar-views.xml @@ -0,0 +1,289 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<bindings id="calendar-specific-view-bindings" + xmlns="http://www.mozilla.org/xbl" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:xbl="http://www.mozilla.org/xbl"> + + <binding id="calendar-day-view" + extends="chrome://calendar/content/calendar-multiday-view.xml#calendar-multiday-view"> + <implementation implements="calICalendarView"> + <property name="observerID"> + <getter><![CDATA[ + return "day-view-observer"; + ]]></getter> + </property> + <property name="supportsWorkdaysOnly" + readonly="true" + onget="return false;"/> + + <!-- Public methods --> + <method name="goToDay"> + <parameter name="aDate"/> + <body><![CDATA[ + if (!aDate) { + this.refresh(); + return; + } + aDate = aDate.getInTimezone(this.timezone); + this.setDateRange(aDate, aDate); + this.selectedDay = aDate; + ]]></body> + </method> + <method name="moveView"> + <parameter name="aNumber"/> + <body><![CDATA[ + if (aNumber) { + let currentDay = this.startDay.clone(); + currentDay.day += aNumber; + this.goToDay(currentDay); + } else { + this.goToDay(now()); + } + ]]></body> + </method> + </implementation> + </binding> + + <binding id="calendar-week-view" + extends="chrome://calendar/content/calendar-multiday-view.xml#calendar-multiday-view"> + <implementation implements="calICalendarView"> + <constructor><![CDATA[ + // add a listener for the mode change + this.mModeHandler = (event) => { + if (event.attrName == "mode") { + this.onModeChanged(event); + } + }; + document.getElementById("modeBroadcaster").addEventListener("DOMAttrModified", this.mModeHandler, true); + ]]></constructor> + <destructor><![CDATA[ + document.getElementById("modeBroadcaster").removeEventListener("DOMAttrModified", this.mModeHandler, true); + ]]></destructor> + + <property name="observerID"> + <getter><![CDATA[ + return "week-view-observer"; + ]]></getter> + </property> + + <method name="onModeChanged"> + <parameter name="aEvent"/> + <body><![CDATA[ + let currentMode = document.getElementById("modeBroadcaster").getAttribute("mode"); + if (currentMode != "calendar") { + timeIndicator.cancel(); + } + ]]></body> + </method> + + <!--Public methods--> + <method name="goToDay"> + <parameter name="aDate"/> + <body><![CDATA[ + this.displayDaysOff = !this.mWorkdaysOnly; + + if (!aDate) { + this.refresh(); + return; + } + aDate = aDate.getInTimezone(this.timezone); + let weekStart = cal.getWeekInfoService().getStartOfWeek(aDate); + let weekEnd = weekStart.clone(); + weekEnd.day += 6; + this.setDateRange(weekStart, weekEnd); + this.selectedDay = aDate; + ]]></body> + </method> + <method name="moveView"> + <parameter name="aNumber"/> + <body><![CDATA[ + if (aNumber) { + let date = this.selectedDay.clone(); + date.day += 7 * aNumber; + this.goToDay(date); + } else { + this.goToDay(now()); + } + ]]></body> + </method> + </implementation> + </binding> + + <binding id="calendar-multiweek-view" extends="chrome://calendar/content/calendar-month-view.xml#calendar-month-base-view"> + <implementation implements="calICalendarView"> + <constructor><![CDATA[ + Components.utils.import("resource://gre/modules/Preferences.jsm"); + this.mWeeksInView = Preferences.get("calendar.weeks.inview", 4); + ]]></constructor> + + <field name="mWeeksInView">4</field> + + <property name="weeksInView"> + <getter><![CDATA[ + return this.mWeeksInView; + ]]></getter> + <setter><![CDATA[ + this.mWeeksInView = val; + Preferences.set("calendar.weeks.inview", Number(val)); + this.refreshView(); + return val; + ]]></setter> + </property> + + <property name="supportsZoom" readonly="true" + onget="return true;"/> + + <method name="zoomIn"> + <parameter name="aLevel"/> + <body><![CDATA[ + let visibleWeeks = Preferences.get("calendar.weeks.inview", 4); + visibleWeeks += (aLevel || 1); + + Preferences.set("calendar.weeks.inview", Math.min(visibleWeeks, 6)); + ]]></body> + </method> + + <method name="zoomOut"> + <parameter name="aLevel"/> + <body><![CDATA[ + let visibleWeeks = Preferences.get("calendar.weeks.inview", 4); + visibleWeeks -= aLevel || 1; + + Preferences.set("calendar.weeks.inview", Math.max(visibleWeeks, 2)); + ]]></body> + </method> + + <method name="zoomReset"> + <body><![CDATA[ + Preferences.set("calendar.view.visiblehours", 4); + ]]></body> + </method> + + <property name="observerID"> + <getter><![CDATA[ + return "multiweek-view-observer"; + ]]></getter> + </property> + + <!--Public methods--> + <method name="goToDay"> + <parameter name="aDate"/> + <body><![CDATA[ + this.showFullMonth = false; + this.displayDaysOff = !this.mWorkdaysOnly; + + // If aDate is null it means that only a refresh is needed + // without changing the start and end of the view. + if (aDate) { + aDate = aDate.getInTimezone(this.timezone); + // Get the first date that should be shown. This is the + // start of the week of the day that we're centering around + // adjusted for the day the week starts on and the number + // of previous weeks we're supposed to display. + let dayStart = cal.getWeekInfoService().getStartOfWeek(aDate); + dayStart.day -= 7 * Preferences.get("calendar.previousweeks.inview", 0); + // The last day we're supposed to show + let dayEnd = dayStart.clone(); + dayEnd.day += (7 * this.mWeeksInView) - 1; + this.setDateRange(dayStart, dayEnd); + this.selectedDay = aDate; + } else { + this.refresh(); + } + ]]></body> + </method> + + <method name="moveView"> + <parameter name="aNumber"/> + <body><![CDATA[ + if (aNumber) { + let date = this.startDay.clone(); + let savedSelectedDay = this.selectedDay.clone(); + // aNumber only corresponds to the number of weeks to move + // make sure to compensate for previous weeks in view too + date.day += 7 * (aNumber + Preferences.get("calendar.previousweeks.inview", 4)); + this.goToDay(date); + savedSelectedDay.day += 7 * aNumber; + this.selectedDay = savedSelectedDay; + } else { + let date = now(); + this.goToDay(date); + this.selectedDay = date; + } + ]]></body> + </method> + </implementation> + </binding> + + <binding id="calendar-month-view" extends="chrome://calendar/content/calendar-month-view.xml#calendar-month-base-view"> + <implementation implements="calICalendarView"> + <property name="observerID"> + <getter><![CDATA[ + return "month-view-observer"; + ]]></getter> + </property> + + <!--Public methods--> + <method name="goToDay"> + <parameter name="aDate"/> + <body><![CDATA[ + this.displayDaysOff = !this.mWorkdaysOnly; + + if (aDate) { + aDate = aDate.getInTimezone(this.timezone); + } + this.showDate(aDate); + ]]></body> + </method> + <method name="getRangeDescription"> + <body><![CDATA[ + let monthName = cal.formatMonth(this.rangeStartDate.month + 1, + "calendar", "monthInYear"); + return calGetString("calendar", "monthInYear", [monthName, this.rangeStartDate.year]); + ]]></body> + </method> + <method name="moveView"> + <parameter name="aNumber"/> + <body><![CDATA[ + let dates = this.getDateList({}); + this.displayDaysOff = !this.mWorkdaysOnly; + + if (aNumber) { + // The first few dates in this list are likely in the month + // prior to the one actually being shown (since the month + // probably doesn't start on a Sunday). The 7th item must + // be in correct month though. + let date = dates[6].clone(); + + date.month += aNumber; + // Need to store this before we move + let oldSelectedDay = this.selectedDay; + + this.goToDay(date); + + // Most of the time we want to select the date with the + // same day number in the next month + let newSelectedDay = oldSelectedDay.clone(); + newSelectedDay.month += aNumber; + // correct for accidental rollover into the next month + if ((newSelectedDay.month - aNumber + 12) % 12 != oldSelectedDay.month) { + newSelectedDay.month -= 1; + newSelectedDay.day = newSelectedDay.endOfMonth.day; + } + + this.selectedDay = newSelectedDay; + } else { + let date = now(); + this.goToDay(date); + this.selectedDay = date; + } + ]]></body> + </method> + </implementation> + </binding> +</bindings> diff --git a/calendar/base/content/calendar-views.xul b/calendar/base/content/calendar-views.xul new file mode 100644 index 000000000..76eeebff9 --- /dev/null +++ b/calendar/base/content/calendar-views.xul @@ -0,0 +1,115 @@ +<?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/. --> + +<?xml-stylesheet href="chrome://calendar/skin/calendar-views.css" type="text/css"?> +<?xml-stylesheet href="chrome://calendar/skin/widgets/calendar-widgets.css" type="text/css"?> + +<!DOCTYPE overlay +[ + <!ENTITY % dtd1 SYSTEM "chrome://calendar/locale/calendar.dtd" > %dtd1; + <!ENTITY % dtd2 SYSTEM "chrome://global/locale/global.dtd" > %dtd2; +]> + + +<overlay id="calendar-views-overlay" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <vbox id="calendar-view-box" context="calendar-view-context-menu"> + <hbox id="calendar-nav-control"> + <vbox flex="1"> + <hbox flex="1" class="navigation-inner-box" align="center"> + <!-- If you are extending a view, add attributes to these + nodes for your view. i.e if your view has the id + "foobar-view", then you need to add the attribute + tooltiptext-foobar="..." --> + <hbox pack="center"> + <toolbarbutton id="previous-view-button" + class="view-navigation-button" + type="prev" + tooltiptext-day="&calendar.navigation.prevday.tooltip;" + tooltiptext-week="&calendar.navigation.prevweek.tooltip;" + tooltiptext-multiweek="&calendar.navigation.prevweek.tooltip;" + tooltiptext-month="&calendar.navigation.prevmonth.tooltip;" + command="calendar_view_prev_command"/> + <toolbarbutton id="today-view-button" + class="today-navigation-button" + label="&calendar.today.button.label;" + tooltiptext-all="&calendar.today.button.tooltip;" + command="calendar_view_today_command"/> + <toolbarbutton id="next-view-button" + class="view-navigation-button" + type="next" + tooltiptext-day="&calendar.navigation.nextday.tooltip;" + tooltiptext-week="&calendar.navigation.nextweek.tooltip;" + tooltiptext-multiweek="&calendar.navigation.nextweek.tooltip;" + tooltiptext-month="&calendar.navigation.nextmonth.tooltip;" + command="calendar_view_next_command"/> + </hbox> + <label id="intervalDescription" + class="view-header" + crop="end" + flex="1" + pack="start"/> + <spacer flex="1"/> + <label id="calendarWeek" + class="view-header" + type="end" + crop="start"/> + </hbox> + <hbox flex="1" class="navigation-bottombox"/> + </vbox> + <tabbox id="view-tabbox" pack="end"> + <tabs id="view-tabs" + class="calview-tabs" + setfocus="true"> + <tab id="calendar-day-view-button" + label="&calendar.day.button.label;" + tooltiptext="&calendar.day.button.tooltip;" + calview="day" + observes="calendar_day-view_command"/> + <tab id="calendar-week-view-button" + label="&calendar.week.button.label;" + tooltiptext="&calendar.week.button.tooltip;" + calview="week" + observes="calendar_week-view_command"/> + <tab id="calendar-multiweek-view-button" + label="&calendar.multiweek.button.label;" + tooltiptext="&calendar.multiweek.button.tooltip;" + calview="multiweek" + observes="calendar_multiweek-view_command"/> + <tab id="calendar-month-view-button" + label="&calendar.month.button.label;" + tooltiptext="&calendar.month.button.tooltip;" + calview="month" + observes="calendar_month-view_command"/> + </tabs> + <box class="navigation-bottombox"/> + </tabbox> + <vbox> + <vbox flex="1" class="navigation-spacer-box"/> + <hbox class="navigation-bottombox"/> + </vbox> + </hbox> + <deck flex="1" + id="view-deck" + persist="selectedIndex"> + <!-- Note: the "id" attributes of the calendar panes **must** follow the + notation 'type + "-" + "view"', where "type" should refer to the + displayed time period as described in base/public/calICalendarView.idl --> + <calendar-day-view id="day-view" flex="1" + context="calendar-view-context-menu" + item-context="calendar-item-context-menu"/> + <calendar-week-view id="week-view" flex="1" + context="calendar-view-context-menu" + item-context="calendar-item-context-menu"/> + <calendar-multiweek-view id="multiweek-view" flex="1" + context="calendar-view-context-menu" + item-context="calendar-item-context-menu"/> + <calendar-month-view id="month-view" flex="1" + context="calendar-view-context-menu" + item-context="calendar-item-context-menu"/> + </deck> + </vbox> +</overlay> diff --git a/calendar/base/content/dialogs/calendar-alarm-dialog.js b/calendar/base/content/dialogs/calendar-alarm-dialog.js new file mode 100644 index 000000000..d9911bbea --- /dev/null +++ b/calendar/base/content/dialogs/calendar-alarm-dialog.js @@ -0,0 +1,359 @@ +/* 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 onDismissAllAlarms, setupWindow, finishWindow, addWidgetFor, + * removeWidgetFor, onSelectAlarm, ensureCalendarVisible + */ + +Components.utils.import("resource://gre/modules/PluralForm.jsm"); +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://gre/modules/Preferences.jsm"); + +/** + * Helper function to get the alarm service and cache it. + * + * @return The alarm service component + */ +function getAlarmService() { + if (!("mAlarmService" in window)) { + window.mAlarmService = Components.classes["@mozilla.org/calendar/alarm-service;1"] + .getService(Components.interfaces.calIAlarmService); + } + return window.mAlarmService; +} + +/** + * Event handler for the 'snooze' event. Snoozes the given alarm by the given + * number of minutes using the alarm service. + * + * @param event The snooze event + */ +function onSnoozeAlarm(event) { + // reschedule alarm: + let duration = getDuration(event.detail); + if (aboveSnoozeLimit(duration)) { + // we prevent snoozing too far if the alarm wouldn't be displayed + return; + } + getAlarmService().snoozeAlarm(event.target.item, event.target.alarm, duration); +} + +/** + * Event handler for the 'dismiss' event. Dismisses the given alarm using the + * alarm service. + * + * @param event The snooze event + */ +function onDismissAlarm(event) { + getAlarmService().dismissAlarm(event.target.item, event.target.alarm); +} + +/** + * Called to dismiss all alarms in the alarm window. + */ +function onDismissAllAlarms() { + // removes widgets on the fly: + let alarmRichlist = document.getElementById("alarm-richlist"); + let parentItems = {}; + + // Make a copy of the child nodes as they get modified live + for (let node of alarmRichlist.childNodes) { + // Check if the node is a valid alarm and is still part of DOM + if (node.parentNode && node.item && node.alarm && + !(node.item.parentItem.hashId in parentItems)) { + // We only need to acknowledge one occurrence for repeating items + parentItems[node.item.parentItem.hashId] = node.item.parentItem; + getAlarmService().dismissAlarm(node.item, node.alarm); + } + } +} + +/** + * Event handler fired when the alarm widget's "Details..." label was clicked. + * Open the event dialog in the most recent Thunderbird window. + * + * @param event The itemdetails event. + */ +function onItemDetails(event) { + // We want this to happen in a calendar window if possible. Otherwise open + // it using our window. + let calWindow = cal.getCalendarWindow(); + if (calWindow) { + calWindow.modifyEventWithDialog(event.target.item, null, true); + } else { + modifyEventWithDialog(event.target.item, null, true); + } +} + +/** + * Sets up the alarm dialog, initializing the default snooze length and setting + * up the relative date update timer. + */ +var gRelativeDateUpdateTimer; +function setupWindow() { + // We want to update when we are at 0 seconds past the minute. To do so, use + // setTimeout to wait until we are there, then setInterval to execute every + // minute. Since setInterval is not totally exact, we may run into problems + // here. I hope not! + let current = new Date(); + + let timeout = (60 - current.getSeconds()) * 1000; + gRelativeDateUpdateTimer = setTimeout(() => { + updateRelativeDates(); + gRelativeDateUpdateTimer = setInterval(updateRelativeDates, 60 * 1000); + }, timeout); + + // Give focus to the alarm richlist after onload completes. See bug 103197 + setTimeout(onFocusWindow, 0); +} + +/** + * Unload function for the alarm dialog. If applicable, snooze the remaining + * alarms and clean up the relative date update timer. + */ +function finishWindow() { + let alarmRichlist = document.getElementById("alarm-richlist"); + + if (alarmRichlist.childNodes.length > 0) { + // If there are still items, the window wasn't closed using dismiss + // all/snooze all. This can happen when the closer is clicked or escape + // is pressed. Snooze all remaining items using the default snooze + // property. + let snoozePref = Preferences.get("calendar.alarms.defaultsnoozelength", 0); + if (snoozePref <= 0) { + snoozePref = 5; + } + snoozeAllItems(snoozePref); + } + + // Stop updating the relative time + clearTimeout(gRelativeDateUpdateTimer); +} + +/** + * Set up the focused element. If no element is focused, then switch to the + * richlist. + */ +function onFocusWindow() { + if (!document.commandDispatcher.focusedElement) { + document.getElementById("alarm-richlist").focus(); + } +} + +/** + * Timer callback to update all relative date labels + */ +function updateRelativeDates() { + let alarmRichlist = document.getElementById("alarm-richlist"); + for (let node of alarmRichlist.childNodes) { + if (node.item && node.alarm) { + node.updateRelativeDateLabel(); + } + } +} + +/** + * Function to snooze all alarms the given number of minutes. + * + * @param aDurationMinutes The duration in minutes + */ +function snoozeAllItems(aDurationMinutes) { + let duration = getDuration(aDurationMinutes); + if (aboveSnoozeLimit(duration)) { + // we prevent snoozing too far if the alarm wouldn't be displayed + return; + } + + let alarmRichlist = document.getElementById("alarm-richlist"); + let parentItems = {}; + + // Make a copy of the child nodes as they get modified live + for (let node of alarmRichlist.childNodes) { + // Check if the node is a valid alarm and is still part of DOM + if (node.parentNode && node.item && node.alarm && + !(node.item.parentItem.hashId in parentItems)) { + // We only need to acknowledge one occurrence for repeating items + parentItems[node.item.parentItem.hashId] = node.item.parentItem; + getAlarmService().snoozeAlarm(node.item, node.alarm, duration); + } + } +} + +/** + * Receive a calIDuration object for a given number of minutes + * + * @param {long} aMinutes The number of minutes + * @return {calIDuration} + */ +function getDuration(aMinutes) { + const MINUTESINWEEK = 7 * 24 * 60; + + // converting to weeks if any is required to avoid an integer overflow of duration.minutes as + // this is of type short + let weeks = Math.floor(aMinutes / MINUTESINWEEK); + aMinutes -= weeks * MINUTESINWEEK; + + let duration = cal.createDuration(); + duration.minutes = aMinutes; + duration.weeks = weeks; + duration.normalize(); + return duration; +} + +/** + * Check whether the snooze period exceeds the current limitation of the AlarmService and prompt + * the user with a message if so + * @param {calIDuration} aDuration The duration to snooze + * @returns {Boolean} + */ +function aboveSnoozeLimit(aDuration) { + const LIMIT = Components.interfaces.calIAlarmService.MAX_SNOOZE_MONTHS; + + let currentTime = cal.now().getInTimezone(cal.UTC()); + let limitTime = currentTime.clone(); + limitTime.month += LIMIT; + + let durationUntilLimit = limitTime.subtractDate(currentTime); + if (aDuration.compare(durationUntilLimit) > 0) { + let msg = PluralForm.get(LIMIT, cal.calGetString("calendar", "alarmSnoozeLimitExceeded")); + showError(msg.replace("#1", LIMIT)); + return true; + } + return false; +} + +/** + * Sets up the window title, counting the number of alarms in the window. + */ +function setupTitle() { + let alarmRichlist = document.getElementById("alarm-richlist"); + let reminders = alarmRichlist.childNodes.length; + + let title = PluralForm.get(reminders, calGetString("calendar", "alarmWindowTitle.label")); + document.title = title.replace("#1", reminders); +} + +/** + * Comparison function for the start date of a calendar item and + * the start date of a calendar-alarm-widget. + * + * @param aItem A calendar item for the comparison of the start date property + * @param calAlarmWidget The alarm widget item for the start date comparison with the given calendar item + * @return 1 - if the calendar item starts before the calendar-alarm-widget + * -1 - if the calendar-alarm-widget starts before the calendar item + * 0 - otherwise + */ +function widgetAlarmComptor(aItem, aWidgetItem) { + if (aItem == null || aWidgetItem == null) { + return -1; + } + + // Get the dates to compare + let aDate = aItem[calGetStartDateProp(aItem)]; + let bDate = aWidgetItem[calGetStartDateProp(aWidgetItem)]; + + return aDate.compare(bDate); +} + +/** + * Add an alarm widget for the passed alarm and item. + * + * @param aItem The calendar item to add a widget for. + * @param aAlarm The alarm to add a widget for. + */ +function addWidgetFor(aItem, aAlarm) { + let widget = document.createElement("calendar-alarm-widget"); + let alarmRichlist = document.getElementById("alarm-richlist"); + + // Add widgets sorted by start date ascending + cal.binaryInsertNode(alarmRichlist, widget, aItem, widgetAlarmComptor, false); + + widget.item = aItem; + widget.alarm = aAlarm; + widget.addEventListener("snooze", onSnoozeAlarm, false); + widget.addEventListener("dismiss", onDismissAlarm, false); + widget.addEventListener("itemdetails", onItemDetails, false); + + setupTitle(); + + if (!alarmRichlist.userSelectedWidget) { + // Always select first widget of the list. + // Since the onselect event causes scrolling, + // we don't want to process the event when adding widgets. + alarmRichlist.suppressOnSelect = true; + alarmRichlist.selectedIndex = 0; + alarmRichlist.suppressOnSelect = false; + } + + window.focus(); + window.getAttention(); +} + +/** + * Remove the alarm widget for the passed alarm and item. + * + * @param aItem The calendar item to remove the alarm widget for. + * @param aAlarm The alarm to remove the widget for. + */ +function removeWidgetFor(aItem, aAlarm) { + let hashId = aItem.hashId; + let alarmRichlist = document.getElementById("alarm-richlist"); + let nodes = alarmRichlist.childNodes; + let notfound = true; + for (let i = nodes.length - 1; notfound && i >= 0; --i) { + let widget = nodes[i]; + if (widget.item && widget.item.hashId == hashId && + widget.alarm && widget.alarm.icalString == aAlarm.icalString) { + if (widget.selected) { + // Advance selection if needed + widget.control.selectedItem = widget.previousSibling || + widget.nextSibling; + } + + widget.removeEventListener("snooze", onSnoozeAlarm, false); + widget.removeEventListener("dismiss", onDismissAlarm, false); + widget.removeEventListener("itemdetails", onItemDetails, false); + + widget.remove(); + closeIfEmpty(); + notfound = false; + } + } + + // Update the title + setupTitle(); +} + +/** + * Close the alarm dialog if there are no further alarm widgets + */ +function closeIfEmpty() { + let alarmRichlist = document.getElementById("alarm-richlist"); + + // we don't want to close if the alarm service is still loading, as the + // removed alarms may be immediately added again. + if (!alarmRichlist.hasChildNodes() && !getAlarmService().isLoading) { + window.close(); + } +} + +/** + * Handler function called when an alarm entry in the richlistbox is selected + * + * @param event The DOM event from the click action + */ +function onSelectAlarm(event) { + let richList = document.getElementById("alarm-richlist"); + if (richList == event.target) { + richList.ensureElementIsVisible(richList.getSelectedItem(0)); + richList.userSelectedWidget = true; + } +} + +function ensureCalendarVisible(aCalendar) { + // This function is called on the alarm dialog from calendar-item-editing.js. + // Normally, it makes sure that the calendar being edited is made visible, + // but the alarm dialog is too far away from the calendar views that it + // makes sense to force visiblity for the calendar. Therefore, do nothing. +} diff --git a/calendar/base/content/dialogs/calendar-alarm-dialog.xul b/calendar/base/content/dialogs/calendar-alarm-dialog.xul new file mode 100644 index 000000000..35aaca268 --- /dev/null +++ b/calendar/base/content/dialogs/calendar-alarm-dialog.xul @@ -0,0 +1,49 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> +<?xml-stylesheet href="chrome://calendar/skin/calendar-alarm-dialog.css" type="text/css"?> + +<!-- used for button-text and button-menu-dropmarker classes --> +<?xml-stylesheet href="chrome://global/skin/button.css" type="text/css"?> + +<!-- used for textbox in the menupopup "snooze-menupopup" --> +<?xml-stylesheet href="chrome://global/skin/spinbuttons.css" type="text/css"?> + +<!-- DTD File with all strings specific to the calendar --> +<!DOCTYPE dialog SYSTEM "chrome://calendar/locale/calendar.dtd"> + +<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + id="calendar-alarm-dialog" + title="&calendar.alarm.title.label;" + windowtype="Calendar:AlarmWindow" + persist="screenX screenY width height" + onload="setupWindow(); window.arguments[0].wrappedJSObject.window_onLoad();" + onunload="finishWindow();" + onfocus="onFocusWindow();" + onkeypress="if (event.keyCode == event.DOM_VK_ESCAPE) { window.close(); }" + width="600" + height="300"> + <script type="application/javascript" src="chrome://calendar/content/calendar-alarm-dialog.js"/> + <script type="application/javascript" src="chrome://calendar/content/calendar-item-editing.js"/> + <script type="application/javascript" src="chrome://calendar/content/calUtils.js"/> + + <richlistbox id="alarm-richlist" flex="1" onselect="onSelectAlarm(event)"/> + + <hbox pack="end" id="alarm-actionbar" align="center"> + <button id="alarm-snooze-all-button" + type="menu" + label="&calendar.alarm.snoozeallfor.label;"> + <menupopup type="snooze-menupopup" + ignorekeys="true" + onsnooze="snoozeAllItems(event.detail)"/> + </button> + <button label="&calendar.alarm.dismissall.label;" + oncommand="onDismissAllAlarms();"/> + </hbox> + <hbox pack="end" class="resizer-box"> + <resizer dir="bottomright"/> + </hbox> +</window> diff --git a/calendar/base/content/dialogs/calendar-conflicts-dialog.xul b/calendar/base/content/dialogs/calendar-conflicts-dialog.xul new file mode 100644 index 000000000..4e23a3883 --- /dev/null +++ b/calendar/base/content/dialogs/calendar-conflicts-dialog.xul @@ -0,0 +1,59 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<?xml-stylesheet type="text/css" href="chrome://global/skin/global.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/calendar-views.css"?> + +<dialog id="calendar-conflicts-dialog" + windowtype="Calendar:Conflicts" + onload="onLoad()" + ondialogaccept="return onAccept();" + ondialogcancel="return onCancel();" + persist="screenX screenY" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <script type="application/javascript" src="chrome://calendar/content/mouseoverPreviews.js"/> + <script type="application/javascript" src="chrome://calendar/content/calUtils.js"/> + <script type="application/javascript"><![CDATA[ + Components.utils.import("resource://calendar/modules/calUtils.jsm"); + function onLoad() { + let docEl = document.documentElement; + let item = window.arguments[0].item; + let vbox = getPreviewForEvent(item); + let descr = document.getElementById("conflicts-description"); + descr.parentNode.insertBefore(vbox, descr); + + // TODO These strings should move to DTD files, but we don't want to + // disrupt string freeze right now. For that matter, this dialog + // should be reworked! + docEl.title = cal.calGetString("calendar", "itemModifiedOnServerTitle"); + descr.textContent = cal.calGetString("calendar", "itemModifiedOnServer"); + + if (window.arguments[0].mode == "modify") { + descr.textContent += cal.calGetString("calendar", "modifyWillLoseData"); + docEl.getButton("accept").setAttribute("label", cal.calGetString("calendar", "proceedModify")); + } else { + descr.textContent += cal.calGetString("calendar", "deleteWillLoseData"); + docEl.getButton("accept").setAttribute("label", cal.calGetString("calendar", "proceedDelete")); + } + + docEl.getButton("cancel").setAttribute("label", cal.calGetString("calendar", "updateFromServer")); + + window.sizeToContent(); + } + + function onAccept() { + window.arguments[0].overwrite = true; + } + + function onCancel() { + window.arguments[0].overwrite = false; + } + ]]></script> + + <vbox id="conflicts-vbox"> + <description id="conflicts-description" + style="max-width: 40em; margin-top: 1ex"/> + </vbox> +</dialog> diff --git a/calendar/base/content/dialogs/calendar-creation.js b/calendar/base/content/dialogs/calendar-creation.js new file mode 100644 index 000000000..a083c81c8 --- /dev/null +++ b/calendar/base/content/dialogs/calendar-creation.js @@ -0,0 +1,49 @@ +/* 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 openLocalCalendar */ + +/** + * Shows the filepicker and creates a new calendar with a local file using the ICS + * provider. + */ +function openLocalCalendar() { + const nsIFilePicker = Components.interfaces.nsIFilePicker; + let picker = Components.classes["@mozilla.org/filepicker;1"].createInstance(nsIFilePicker); + picker.init(window, calGetString("calendar", "Open"), nsIFilePicker.modeOpen); + let wildmat = "*.ics"; + let description = calGetString("calendar", "filterIcs", [wildmat]); + picker.appendFilter(description, wildmat); + picker.appendFilters(nsIFilePicker.filterAll); + + if (picker.show() != nsIFilePicker.returnOK) { + return; + } + + let calMgr = getCalendarManager(); + let calendars = calMgr.getCalendars({}); + if (calendars.some(x => x.uri == picker.fileURL)) { + // The calendar already exists, select it and return. + document.getElementById("calendar-list-tree-widget") + .tree.view.selection.select(index); + return; + } + + let openCalendar = calMgr.createCalendar("ics", picker.fileURL); + + // Strip ".ics" from filename for use as calendar name, taken from + // calendarCreation.js + let fullPathRegex = new RegExp("([^/:]+)[.]ics$"); + let prettyName = picker.fileURL.spec.match(fullPathRegex); + let name; + + if (prettyName && prettyName.length >= 1) { + name = decodeURIComponent(prettyName[1]); + } else { + name = calGetString("calendar", "untitledCalendarName"); + } + openCalendar.name = name; + + calMgr.registerCalendar(openCalendar); +} diff --git a/calendar/base/content/dialogs/calendar-dialog-utils.js b/calendar/base/content/dialogs/calendar-dialog-utils.js new file mode 100644 index 000000000..19af89c27 --- /dev/null +++ b/calendar/base/content/dialogs/calendar-dialog-utils.js @@ -0,0 +1,693 @@ +/* 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 gInTab, gMainWindow, gTabmail, intializeTabOrWindowVariables, + * dispose, setDialogId, loadReminders, saveReminder, + * commonUpdateReminder, updateLink, rearrangeAttendees + */ + +Components.utils.import("resource://gre/modules/PluralForm.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); +Components.utils.import("resource://gre/modules/iteratorUtils.jsm"); + +Components.utils.import("resource://calendar/modules/calAlarmUtils.jsm"); +Components.utils.import("resource://calendar/modules/calIteratorUtils.jsm"); +Components.utils.import("resource://calendar/modules/calRecurrenceUtils.jsm"); + +// Variables related to whether we are in a tab or a window dialog. +var gInTab = false; +var gMainWindow = null; +var gTabmail = null; + +/** + * Initialize variables for tab vs window. + */ +function intializeTabOrWindowVariables() { + let args = window.arguments[0]; + gInTab = args.inTab; + if (gInTab) { + gTabmail = parent.document.getElementById("tabmail"); + gMainWindow = parent; + } else { + gMainWindow = parent.opener; + } +} + +/** + * Dispose of controlling operations of this event dialog. Uses + * window.arguments[0].job.dispose() + */ +function dispose() { + let args = window.arguments[0]; + if (args.job && args.job.dispose) { + args.job.dispose(); + } + resetDialogId(document.documentElement); +} + +/** + * Sets the id of a Dialog to another value to allow different window-icons to be displayed. + * The original name is stored as new Attribute of the Dialog to set it back later. + * + * @param aDialog The Dialog to be changed. + * @param aNewId The new ID as String. + */ +function setDialogId(aDialog, aNewId) { + aDialog.setAttribute("originalId", aDialog.getAttribute("id")); + aDialog.setAttribute("id", aNewId); + applyPersitedProperties(aDialog); +} + +/** + * Sets the Dialog id back to previously stored one, + * so that the persisted values are correctly saved. + * + * @param aDialog The Dialog which is to be restored. + */ +function resetDialogId(aDialog) { + let id = aDialog.getAttribute("originalId"); + if (id != "") { + aDialog.setAttribute("id", id); + } + aDialog.removeAttribute("originalId"); +} + +/** + * Apply the persisted properties from xulstore.json on a dialog based on the current dialog id. + * This needs to be invoked after changing a dialog id while loading to apply the values for the + * new dialog id. + * + * @param aDialog The Dialog to apply the property values for + */ +function applyPersitedProperties(aDialog) { + let xulStore = Components.classes["@mozilla.org/xul/xulstore;1"] + .getService(Components.interfaces.nsIXULStore); + // first we need to detect which properties are persisted + let persistedProps = aDialog.getAttribute("persist") || ""; + if (persistedProps == "") { + return; + } + let propNames = persistedProps.split(" "); + // now let's apply persisted values if applicable + for (let propName of propNames) { + if (xulStore.hasValue(aDialog.baseURI, aDialog.id, propName)) { + aDialog.setAttribute(propName, xulStore.getValue(aDialog.baseURI, aDialog.id, propName)); + } + } +} + +/** + * Create a calIAlarm from the given menuitem. The menuitem must have the + * following attributes: unit, length, origin, relation. + * + * @param menuitem The menuitem to create the alarm from. + * @return The calIAlarm with information from the menuitem. + */ +function createReminderFromMenuitem(aMenuitem) { + let reminder = aMenuitem.reminder || cal.createAlarm(); + // clone immutable reminders if necessary to set default values + let isImmutable = !reminder.isMutable; + if (isImmutable) { + reminder = reminder.clone(); + } + let offset = cal.createDuration(); + offset[aMenuitem.getAttribute("unit")] = aMenuitem.getAttribute("length"); + offset.normalize(); + offset.isNegative = (aMenuitem.getAttribute("origin") == "before"); + reminder.related = (aMenuitem.getAttribute("relation") == "START" ? + reminder.ALARM_RELATED_START : reminder.ALARM_RELATED_END); + reminder.offset = offset; + reminder.action = getDefaultAlarmType(); + // make reminder immutable in case it was before + if (isImmutable) { + reminder.makeImmutable(); + } + return reminder; +} + +/** + * This function opens the needed dialogs to edit the reminder. Note however + * that calling this function from an extension is not recommended. To allow an + * extension to open the reminder dialog, set the menulist "item-alarm" to the + * custom menuitem and call updateReminder(). + */ +function editReminder() { + let customItem = document.getElementById("reminder-custom-menuitem"); + let args = {}; + args.reminders = customItem.reminders; + args.item = window.calendarItem; + args.timezone = window.gStartTimezone || + window.gEndTimezone || + calendarDefaultTimezone(); + + args.calendar = getCurrentCalendar(); + + // While these are "just" callbacks, the dialog is opened modally, so aside + // from whats needed to set up the reminders, nothing else needs to be done. + args.onOk = function(reminders) { + customItem.reminders = reminders; + }; + args.onCancel = function() { + document.getElementById("item-alarm").selectedIndex = gLastAlarmSelection; + }; + + window.setCursor("wait"); + + // open the dialog modally + openDialog( + "chrome://calendar/content/calendar-event-dialog-reminder.xul", + "_blank", + "chrome,titlebar,modal,resizable", + args); +} + +/** + * Update the reminder details from the selected alarm. This shows a string + * describing the reminder set, or nothing in case a preselected reminder was + * chosen. + */ +function updateReminderDetails() { + // find relevant elements in the document + let reminderList = document.getElementById("item-alarm"); + let reminderMultipleLabel = document.getElementById("reminder-multiple-alarms-label"); + let iconBox = document.getElementById("reminder-icon-box"); + let reminderSingleLabel = document.getElementById("reminder-single-alarms-label"); + let reminders = document.getElementById("reminder-custom-menuitem").reminders || []; + let calendar = getCurrentCalendar(); + let actionValues = calendar.getProperty("capabilities.alarms.actionValues") || ["DISPLAY"]; + let actionMap = {}; + for (let action of actionValues) { + actionMap[action] = true; + } + + // Filter out any unsupported action types. + reminders = reminders.filter(x => x.action in actionMap); + + if (reminderList.value == "custom") { + // Depending on how many alarms we have, show either the "Multiple Alarms" + // label or the single reminder label. + setElementValue(reminderMultipleLabel, + reminders.length < 2 && "true", + "hidden"); + setElementValue(reminderSingleLabel, + reminders.length > 1 && "true", + "hidden"); + + cal.alarms.addReminderImages(iconBox, reminders); + + // If there is only one reminder, display the reminder string + if (reminders.length == 1) { + setElementValue(reminderSingleLabel, + reminders[0].toString(window.calendarItem)); + } + } else { + hideElement(reminderMultipleLabel); + hideElement(reminderSingleLabel); + if (reminderList.value == "none") { + // No reminder selected means show no icons. + removeChildren(iconBox); + } else { + // This is one of the predefined dropdown items. We should show a + // single icon in the icons box to tell the user what kind of alarm + // this will be. + let mockAlarm = cal.createAlarm(); + mockAlarm.action = getDefaultAlarmType(); + cal.alarms.addReminderImages(iconBox, [mockAlarm]); + } + } +} + +var gLastAlarmSelection = 0; + +function matchCustomReminderToMenuitem(reminder) { + let defaultAlarmType = getDefaultAlarmType(); + let reminderList = document.getElementById("item-alarm"); + let reminderPopup = reminderList.firstChild; + if (reminder.related != Components.interfaces.calIAlarm.ALARM_RELATED_ABSOLUTE && + reminder.offset && + reminder.action == defaultAlarmType) { + // Exactly one reminder thats not absolute, we may be able to match up + // popup items. + let relation = (reminder.related == reminder.ALARM_RELATED_START ? "START" : "END"); + let origin; + + // If the time duration for offset is 0, means the reminder is '0 minutes before' + if (reminder.offset.inSeconds == 0 || reminder.offset.isNegative) { + origin = "before"; + } else { + origin = "after"; + } + + let unitMap = { + days: 86400, + hours: 3600, + minutes: 60 + }; + + for (let menuitem of reminderPopup.childNodes) { + if (menuitem.localName == "menuitem" && + menuitem.hasAttribute("length") && + menuitem.getAttribute("origin") == origin && + menuitem.getAttribute("relation") == relation) { + let unitMult = unitMap[menuitem.getAttribute("unit")] || 1; + let length = menuitem.getAttribute("length") * unitMult; + + if (Math.abs(reminder.offset.inSeconds) == length) { + menuitem.reminder = reminder.clone(); + reminderList.selectedItem = menuitem; + // We've selected an item, so we are done here. + return true; + } + } + } + } + + return false; +} +/** + * Load an item's reminders into the dialog + * + * @param reminders An array of calIAlarms to load. + */ +function loadReminders(reminders) { + // select 'no reminder' by default + let reminderList = document.getElementById("item-alarm"); + let customItem = document.getElementById("reminder-custom-menuitem"); + reminderList.selectedIndex = 0; + gLastAlarmSelection = 0; + + if (!reminders || !reminders.length) { + // No reminders selected, we are done + return; + } + + if (reminders.length > 1 || + !matchCustomReminderToMenuitem(reminders[0])) { + // If more than one alarm is selected, or we didn't find a matching item + // above, then select the "custom" item and attach the item's reminders to + // it. + reminderList.value = "custom"; + customItem.reminders = reminders; + } + + // remember the selected index + gLastAlarmSelection = reminderList.selectedIndex; +} + +/** + * Save the selected reminder into the passed item. + * + * @param item The item save the reminder into. + */ +function saveReminder(item) { + // We want to compare the old alarms with the new ones. If these are not + // the same, then clear the snooze/dismiss times + let oldAlarmMap = {}; + for (let alarm of item.getAlarms({})) { + oldAlarmMap[alarm.icalString] = true; + } + + // Clear the alarms so we can add our new ones. + item.clearAlarms(); + + let reminderList = document.getElementById("item-alarm"); + if (reminderList.value != "none") { + let menuitem = reminderList.selectedItem; + let reminders; + + if (menuitem.reminders) { + // Custom reminder entries carry their own reminder object with + // them. Make sure to clone in case these are the original item's + // reminders. + + // XXX do we need to clone here? + reminders = menuitem.reminders.map(x => x.clone()); + } else { + // Pre-defined entries specify the necessary information + // as attributes attached to the menuitem elements. + reminders = [createReminderFromMenuitem(menuitem)]; + } + + let alarmCaps = item.calendar.getProperty("capabilities.alarms.actionValues") || + ["DISPLAY"]; + let alarmActions = {}; + for (let action of alarmCaps) { + alarmActions[action] = true; + } + + // Make sure only alarms are saved that work in the given calendar. + reminders.filter(x => x.action in alarmActions) + .forEach(item.addAlarm, item); + } + + // Compare alarms to see if something changed. + for (let alarm of item.getAlarms({})) { + let ics = alarm.icalString; + if (ics in oldAlarmMap) { + // The new alarm is also in the old set, remember this + delete oldAlarmMap[ics]; + } else { + // The new alarm is not in the old set, this means the alarms + // differ and we can break out. + oldAlarmMap[ics] = true; + break; + } + } + + // If the alarms differ, clear the snooze/dismiss properties + if (Object.keys(oldAlarmMap).length > 0) { + let cmp = "X-MOZ-SNOOZE-TIME"; + + // Recurring item alarms potentially have more snooze props, remove them + // all. + let propIterator = fixIterator(item.propertyEnumerator, Components.interfaces.nsIProperty); + let propsToDelete = []; + for (let prop in propIterator) { + if (prop.name.startsWith(cmp)) { + propsToDelete.push(prop.name); + } + } + + item.alarmLastAck = null; + propsToDelete.forEach(item.deleteProperty, item); + } +} + +/** + * Get the default alarm type for the currently selected calendar. If the + * calendar supports DISPLAY alarms, this is the default. Otherwise it is the + * first alarm action the calendar supports. + * + * @return The default alarm type. + */ +function getDefaultAlarmType() { + let calendar = getCurrentCalendar(); + let alarmCaps = calendar.getProperty("capabilities.alarms.actionValues") || + ["DISPLAY"]; + return (alarmCaps.includes("DISPLAY") ? "DISPLAY" : alarmCaps[0]); +} + +/** + * Get the currently selected calendar. For dialogs with a menulist of + * calendars, this is the currently chosen calendar, otherwise its the fixed + * calendar from the window's item. + * + * @return The currently selected calendar. + */ +function getCurrentCalendar() { + let calendarNode = document.getElementById("item-calendar"); + return (calendarNode && calendarNode.selectedItem + ? calendarNode.selectedItem.calendar + : window.calendarItem.calendar); +} + +/** + * Common update functions for both event dialogs. Called when a reminder has + * been selected from the menulist. + * + * @param aSuppressDialogs If true, controls are updated without prompting + * for changes with the dialog + */ +function commonUpdateReminder(aSuppressDialogs) { + // if a custom reminder has been selected, we show the appropriate + // dialog in order to allow the user to specify the details. + // the result will be placed in the 'reminder-custom-menuitem' tag. + let reminderList = document.getElementById("item-alarm"); + if (reminderList.value == "custom") { + // Clear the reminder icons first, this will make sure that while the + // dialog is open the default reminder image is not shown which may + // confuse users. + removeChildren("reminder-icon-box"); + + // show the dialog. This call blocks until the dialog is closed. Don't + // pop up the dialog if aSuppressDialogs was specified or if this + // happens during initialization of the dialog + if (!aSuppressDialogs && reminderList.hasAttribute("last-value")) { + editReminder(); + } + + if (reminderList.value == "custom") { + // Only do this if the 'custom' item is still selected. If the edit + // reminder dialog was canceled then the previously selected + // menuitem is selected, which may not be the custom menuitem. + + // If one or no reminders were selected, we have a chance of mapping + // them to the existing elements in the dropdown. + let customItem = reminderList.selectedItem; + if (customItem.reminders.length == 0) { + // No reminder was selected + reminderList.value = "none"; + } else if (customItem.reminders.length == 1) { + // We might be able to match the custom reminder with one of the + // default menu items. + matchCustomReminderToMenuitem(customItem.reminders[0]); + } + } + } + + // remember the current reminder drop down selection index. + gLastAlarmSelection = reminderList.selectedIndex; + reminderList.setAttribute("last-value", reminderList.value); + + // possibly the selected reminder conflicts with the item. + // for example an end-relation combined with a task without duedate + // is an invalid state we need to take care of. we take the same + // approach as with recurring tasks. in case the reminder is related + // to the entry date we check the entry date automatically and disable + // the checkbox. the same goes for end related reminder and the due date. + if (isToDo(window.calendarItem)) { + // In general, (re-)enable the due/entry checkboxes. This will be + // changed in case the alarms are related to START/END below. + enableElementWithLock("todo-has-duedate", "reminder-lock"); + enableElementWithLock("todo-has-entrydate", "reminder-lock"); + + let menuitem = reminderList.selectedItem; + if (menuitem.value != "none") { + // In case a reminder is selected, retrieve the array of alarms from + // it, or create one from the currently selected menuitem. + let reminders = menuitem.reminders || [createReminderFromMenuitem(menuitem)]; + + // If a reminder is related to the entry date... + if (reminders.some(x => x.related == x.ALARM_RELATED_START)) { + // ...automatically check 'has entrydate'. + if (!getElementValue("todo-has-entrydate", "checked")) { + setElementValue("todo-has-entrydate", "true", "checked"); + + // Make sure gStartTime is properly initialized + updateEntryDate(); + } + + // Disable the checkbox to indicate that we need the entry-date. + disableElementWithLock("todo-has-entrydate", "reminder-lock"); + } + + // If a reminder is related to the due date... + if (reminders.some(x => x.related == x.ALARM_RELATED_END)) { + // ...automatically check 'has duedate'. + if (!getElementValue("todo-has-duedate", "checked")) { + setElementValue("todo-has-duedate", "true", "checked"); + + // Make sure gStartTime is properly initialized + updateDueDate(); + } + + // Disable the checkbox to indicate that we need the entry-date. + disableElementWithLock("todo-has-duedate", "reminder-lock"); + } + } + } + updateReminderDetails(); +} + +/** + * Updates the related link on the dialog. Currently only used by the + * read-only summary dialog. + */ +function updateLink() { + function hideOrShow(aBool) { + setElementValue("event-grid-link-row", !aBool && "true", "hidden"); + let separator = document.getElementById("event-grid-link-separator"); + if (separator) { + // The separator is not there in the summary dialog + setElementValue("event-grid-link-separator", !aBool && "true", "hidden"); + } + } + + let itemUrlString = window.calendarItem.getProperty("URL") || ""; + let linkCommand = document.getElementById("cmd_toggle_link"); + + + if (linkCommand) { + // Disable if there is no url + setElementValue(linkCommand, + !itemUrlString.length && "true", + "disabled"); + } + + if ((linkCommand && linkCommand.getAttribute("checked") != "true") || + !itemUrlString.length) { + // Hide if there is no url, or the menuitem was chosen so that the url + // should be hidden + hideOrShow(false); + } else { + let handler, uri; + try { + uri = makeURL(itemUrlString); + handler = Services.io.getProtocolHandler(uri.scheme); + } catch (e) { + // No protocol handler for the given protocol, or invalid uri + hideOrShow(false); + return; + } + + // Only show if its either an internal protcol handler, or its external + // and there is an external app for the scheme + handler = cal.wrapInstance(handler, Components.interfaces.nsIExternalProtocolHandler); + hideOrShow(!handler || handler.externalAppExistsForScheme(uri.scheme)); + + setTimeout(() => { + // HACK the url-link doesn't crop when setting the value in onLoad + setElementValue("url-link", itemUrlString); + setElementValue("url-link", itemUrlString, "href"); + }, 0); + } +} + +/* + * setup attendees in event and summary dialog + */ +function setupAttendees() { + let attBox = document.getElementById("item-attendees-box"); + let attBoxRows = attBox.getElementsByClassName("item-attendees-row"); + + if (window.attendees && window.attendees.length > 0) { + // cloning of the template nodes + let selector = "#item-attendees-box-template .item-attendees-row"; + let clonedRow = document.querySelector(selector).cloneNode(false); + selector = "#item-attendees-box-template .item-attendees-row box:nth-of-type(1)"; + let clonedCell = document.querySelector(selector).cloneNode(true); + selector = "#item-attendees-box-template .item-attendees-row box:nth-of-type(2)"; + let clonedSpacer = document.querySelector(selector).cloneNode(false); + + // determining of attendee box setup + let inRow = window.attendeesInRow || -1; + if (inRow == -1) { + inRow = determineAttendeesInRow(); + window.attendeesInRow = inRow; + } else { + while (attBoxRows.length > 0) { + attBox.removeChild(attBoxRows[0]); + } + } + + // set up of the required nodes + let maxRows = Math.ceil(window.attendees.length / inRow); + let inLastRow = window.attendees.length - ((maxRows - 1) * inRow); + let attCount = 0; + while (attBox.getElementsByClassName("item-attendees-row").length < maxRows) { + let newRow = clonedRow.cloneNode(false); + let row = attBox.appendChild(newRow); + row.removeAttribute("hidden"); + let rowCount = attBox.getElementsByClassName("item-attendees-row").length; + let reqAtt = rowCount == maxRows ? inLastRow : inRow; + // we add as many attendee cells as required + while (row.childNodes.length < reqAtt) { + let newCell = clonedCell.cloneNode(true); + let cell = row.appendChild(newCell); + let icon = cell.getElementsByTagName("img")[0]; + let text = cell.getElementsByTagName("label")[0]; + let attendee = window.attendees[attCount]; + + let label = (attendee.commonName && attendee.commonName.length) + ? attendee.commonName : attendee.toString(); + let userType = attendee.userType || "INDIVIDUAL"; + let role = attendee.role || "REQ-PARTICIPANT"; + let partstat = attendee.participationStatus || "NEEDS-ACTION"; + + icon.setAttribute("partstat", partstat); + icon.setAttribute("usertype", userType); + icon.setAttribute("role", role); + cell.setAttribute("attendeeid", attendee.id); + cell.removeAttribute("hidden"); + + let userTypeString = cal.calGetString("calendar", "dialog.tooltip.attendeeUserType2." + userType, + [attendee.toString()]); + let roleString = cal.calGetString("calendar", "dialog.tooltip.attendeeRole2." + role, + [userTypeString]); + let partstatString = cal.calGetString("calendar", "dialog.tooltip.attendeePartStat2." + partstat, + [label]); + let tooltip = cal.calGetString("calendar", "dialog.tooltip.attendee.combined", + [roleString, partstatString]); + + let del = cal.resolveDelegation(attendee, window.attendees); + if (del.delegators != "") { + del.delegators = cal.calGetString("calendar", + "dialog.attendee.append.delegatedFrom", + [del.delegators]); + label += " " + del.delegators; + tooltip += " " + del.delegators; + } + if (del.delegatees != "") { + del.delegatees = cal.calGetString("calendar", + "dialog.attendee.append.delegatedTo", + [del.delegatees]); + tooltip += " " + del.delegatees; + } + + text.setAttribute("value", label); + cell.setAttribute("tooltiptext", tooltip); + attCount++; + } + // we fill the row with placeholders if required + if (attBox.getElementsByClassName("item-attendees-row").length > 1 && inRow > 1) { + while (row.childNodes.length < inRow) { + let newSpacer = clonedSpacer.cloneNode(true); + newSpacer.removeAttribute("hidden"); + row.appendChild(newSpacer); + } + } + } + + // determining of the max width of an attendee label - this needs to + // be done only once and is obsolete in case of resizing + if (!window.maxLabelWidth) { + let maxWidth = 0; + for (let cell of attBox.getElementsByClassName("item-attendees-cell")) { + cell = cell.cloneNode(true); + cell.removeAttribute("flex"); + cell.getElementsByTagName("label")[0].removeAttribute("flex"); + maxWidth = cell.clientWidth > maxWidth ? cell.clientWidth : maxWidth; + } + window.maxLabelWidth = maxWidth; + } + } else { + while (attBoxRows.length > 0) { + attBox.removeChild(attBoxRows[0]); + } + } +} + +/** + * Re-arranges the attendees on dialog resizing in event and summary dialog + */ +function rearrangeAttendees() { + if (window.attendees && window.attendees.length > 0 && window.attendeesInRow) { + let inRow = determineAttendeesInRow(); + if (inRow != window.attendeesInRow) { + window.attendeesInRow = inRow; + setupAttendees(); + } + } +} + +/** + * Calculates the number of columns to distribute attendees for event and summary dialog + */ +function determineAttendeesInRow() { + // as default value a reasonable high value is appropriate + // it will be recalculated anyway. + let minWidth = window.maxLabelWidth || 200; + let inRow = Math.floor(document.width / minWidth); + return inRow > 1 ? inRow : 1; +} diff --git a/calendar/base/content/dialogs/calendar-error-prompt.xul b/calendar/base/content/dialogs/calendar-error-prompt.xul new file mode 100644 index 000000000..5aef370a9 --- /dev/null +++ b/calendar/base/content/dialogs/calendar-error-prompt.xul @@ -0,0 +1,67 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> + +<!DOCTYPE dialog +[ + <!ENTITY % dtd1 SYSTEM "chrome://calendar/locale/global.dtd" > %dtd1; + <!ENTITY % dtd2 SYSTEM "chrome://calendar/locale/calendar.dtd" > %dtd2; +]> + + +<dialog id="calendar-error-prompt" + title="&calendar.error.title;" + windowtype="Calendar:ErrorPrompt" + buttons="accept" + onload="loadErrorPrompt()" + persist="screenX screenY" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + width="500" + xmlns:nc="http://home.netscape.com/NC-rdf#"> + + <script type="application/javascript"><![CDATA[ + function loadErrorPrompt() { + var args = window.arguments[0].QueryInterface(Components.interfaces.nsIDialogParamBlock); + document.getElementById("general-text").value = args.GetString(0); + document.getElementById("error-code").value = args.GetString(1); + document.getElementById("error-description").value = args.GetString(2); + this.sizeToContent(); + } + function toggleDetails() { + var grid = document.getElementById("details-grid"); + if (grid.collapsed) + grid.collapsed = false; + else + grid.collapsed = true; + this.sizeToContent(); + } + ]]></script> + <vbox> + <textbox id="general-text" class="plain" readonly="true" + multiline="true" rows="3"/> + <hbox> + <button id="details-button" label="&calendar.error.detail;" oncommand="toggleDetails()"/> + <spacer flex="1"/> + </hbox> + <grid id="details-grid" collapsed="true" persist="collapsed"> + <columns> + <column/> + <column flex="1"/> + </columns> + <rows> + <row> + <label value="&calendar.error.code;"/> + <label id="error-code" value=""/> + </row> + <row flex="1"> + <label value="&calendar.error.description;" control="error-description"/> + <textbox id="error-description" class="plain" + readonly="true" multiline="true" rows="5"/> + </row> + </rows> + </grid> + </vbox> +</dialog> diff --git a/calendar/base/content/dialogs/calendar-event-dialog-attendees.js b/calendar/base/content/dialogs/calendar-event-dialog-attendees.js new file mode 100644 index 000000000..f523bf1b1 --- /dev/null +++ b/calendar/base/content/dialogs/calendar-event-dialog-attendees.js @@ -0,0 +1,1004 @@ +/* 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 onLoad, onAccept, onCancel, zoomWithButtons, updateStartTime, + * endWidget, updateEndTime, editStartTimezone, editEndTimezone, + * changeAllDay, onNextSlot, onPreviousSlot + */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); +Components.utils.import("resource://gre/modules/Preferences.jsm"); + +var gStartDate = null; +var gEndDate = null; +var gStartTimezone = null; +var gEndTimezone = null; +var gDuration = null; +var gStartHour = 0; +var gEndHour = 24; +var gIsReadOnly = false; +var gIsInvitation = false; +var gIgnoreUpdate = false; +var gDisplayTimezone = true; +var gUndoStack = []; +var gForce24Hours = false; +var gZoomFactor = 100; + +/** + * Sets up the attendee dialog + */ +function onLoad() { + // first of all, attach all event handlers + window.addEventListener("resize", onResize, true); + window.addEventListener("modify", onModify, true); + window.addEventListener("rowchange", onRowChange, true); + window.addEventListener("DOMAttrModified", onAttrModified, true); + window.addEventListener("timebar", onTimebar, true); + window.addEventListener("timechange", onTimeChange, true); + + // As long as DOMMouseScroll is still implemented, we need to keep it + // around to make sure scrolling is blocked. + window.addEventListener("wheel", onMouseScroll, true); + window.addEventListener("DOMMouseScroll", onMouseScroll, true); + + let args = window.arguments[0]; + let startTime = args.startTime; + let endTime = args.endTime; + let calendar = args.calendar; + + gDisplayTimezone = args.displayTimezone; + + onChangeCalendar(calendar); + + + let zoom = document.getElementById("zoom-menulist"); + let zoomOut = document.getElementById("zoom-out-button"); + let zoomIn = document.getElementById("zoom-in-button"); + + // Make sure zoom factor is set up correctly (from persisted value) + setZoomFactor(zoom.value); + if (gZoomFactor == 100) { + // if zoom factor was not changed, make sure it is applied at least once + applyCurrentZoomFactor(); + } + + initTimeRange(); + + // Check if an all-day event has been passed in (to adapt endDate). + if (startTime.isDate) { + startTime = startTime.clone(); + endTime = endTime.clone(); + + endTime.day--; + + // for all-day events we expand to 24hrs, set zoom-factor to 25% + // and disable the zoom-control. + setForce24Hours(true); + zoom.value = "400"; + zoom.setAttribute("disabled", "true"); + zoomOut.setAttribute("disabled", "true"); + zoomIn.setAttribute("disabled", "true"); + setZoomFactor(zoom.value); + } + + loadDateTime(startTime, endTime); + propagateDateTime(); + // Set the scroll bar at where the event is + scrollToCurrentTime(); + updateButtons(); + + // we need to enforce several layout constraints which can't be modelled + // with plain xul and css, at least as far as i know. + const kStylesheet = "chrome://calendar/skin/calendar-event-dialog.css"; + for (let stylesheet of document.styleSheets) { + if (stylesheet.href == kStylesheet) { + // make the dummy-spacer #1 [top] the same height as the timebar + let timebar = document.getElementById("timebar"); + stylesheet.insertRule(".attendee-spacer-top { height: " + + timebar.boxObject.height + "px; }", 0); + // make the dummy-spacer #2 [bottom] the same height as the scrollbar + let scrollbar = document.getElementById("horizontal-scrollbar"); + stylesheet.insertRule(".attendee-spacer-bottom { height: " + + scrollbar.boxObject.height + "px; }", 0); + break; + } + } + + // attach an observer to get notified of changes + // that are relevant to this dialog. + let prefObserver = { + observe: function(aSubject, aTopic, aPrefName) { + switch (aPrefName) { + case "calendar.view.daystarthour": + case "calendar.view.dayendhour": + initTimeRange(); + propagateDateTime(); + break; + } + } + }; + Services.prefs.addObserver("calendar.", prefObserver, false); + window.addEventListener("unload", () => { + Services.prefs.removeObserver("calendar.", prefObserver); + }, false); + + opener.setCursor("auto"); + self.focus(); +} + +/** + * This function should be called when the accept button was pressed on the + * attendee dialog. Calls the accept function specified in the window arguments. + * + * @return Returns true, if the dialog should be closed. + */ +function onAccept() { + let attendees = document.getElementById("attendees-list"); + window.arguments[0].onOk( + attendees.attendees, + attendees.organizer, + gStartDate.getInTimezone(gStartTimezone), + gEndDate.getInTimezone(gEndTimezone)); + return true; +} + +/** + * This function should be called when the cancel button was pressed on the + * attendee dialog. + * + * @return Returns true, if the dialog should be closed. + */ +function onCancel() { + return true; +} + +/** + * Function called when zoom buttons (+/-) are clicked. + * + * @param aZoomOut true -> zoom out; false -> zoom in. + */ +function zoomWithButtons(aZoomOut) { + let zoom = document.getElementById("zoom-menulist"); + if (aZoomOut && zoom.selectedIndex < 4) { + zoom.selectedIndex++; + } else if (!aZoomOut && zoom.selectedIndex > 0) { + zoom.selectedIndex--; + } + setZoomFactor(zoom.value); +} + +/** + * Loads the passed start and end dates, fills global variables that give + * information about the state of the dialog. + * + * @param aStartDate The date/time the grid should start at. + * @param aEndDate The date/time the grid should end at. + */ +function loadDateTime(aStartDate, aEndDate) { + gDuration = aEndDate.subtractDate(aStartDate); + let kDefaultTimezone = calendarDefaultTimezone(); + gStartTimezone = aStartDate.timezone; + gEndTimezone = aEndDate.timezone; + gStartDate = aStartDate.getInTimezone(kDefaultTimezone); + gEndDate = aEndDate.getInTimezone(kDefaultTimezone); + gStartDate.makeImmutable(); + gEndDate.makeImmutable(); +} + +/** + * Sets up the time grid using the global start and end dates. + */ +function propagateDateTime() { + // Fill the controls + updateDateTime(); + + // Tell the timebar about the new start/enddate + let timebar = document.getElementById("timebar"); + timebar.startDate = gStartDate; + timebar.endDate = gEndDate; + timebar.refresh(); + + // Tell the selection-bar about the new start/enddate + let selectionbar = document.getElementById("selection-bar"); + selectionbar.startDate = gStartDate; + selectionbar.endDate = gEndDate; + selectionbar.update(); + + // Tell the freebusy grid about the new start/enddate + let grid = document.getElementById("freebusy-grid"); + + let refresh = (grid.startDate == null) || + (grid.startDate.compare(gStartDate) != 0) || + (grid.endDate == null) || + (grid.endDate.compare(gEndDate) != 0); + grid.startDate = gStartDate; + grid.endDate = gEndDate; + if (refresh) { + grid.forceRefresh(); + } + + // Expand to 24hrs if the new range is outside of the default range. + let kDefaultTimezone = calendarDefaultTimezone(); + let startTime = gStartDate.getInTimezone(kDefaultTimezone); + let endTime = gEndDate.getInTimezone(kDefaultTimezone); + if ((startTime.hour < gStartHour) || + (startTime.hour >= gEndHour) || + (endTime.hour >= gEndHour) || + (startTime.day != endTime.day) || + (startTime.isDate)) { + setForce24Hours(true); + } +} + +/** + * This function requires gStartDate and gEndDate and the respective timezone + * variables to be initialized. It updates the date/time information displayed in + * the dialog from the above noted variables. + */ +function updateDateTime() { + // 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 (gDisplayTimezone) { + let startTime = gStartDate.getInTimezone(gStartTimezone); + let endTime = gEndDate.getInTimezone(gEndTimezone); + + if (startTime.isDate) { + document.getElementById("all-day") + .setAttribute("checked", "true"); + } + + // 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 (!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 = floating(); + endTime.timezone = floating(); + + document.getElementById("event-starttime").value = cal.dateTimeToJsDate(startTime); + document.getElementById("event-endtime").value = cal.dateTimeToJsDate(endTime); + } else { + let kDefaultTimezone = calendarDefaultTimezone(); + + let startTime = gStartDate.getInTimezone(kDefaultTimezone); + let endTime = gEndDate.getInTimezone(kDefaultTimezone); + + if (startTime.isDate) { + document.getElementById("all-day") + .setAttribute("checked", "true"); + } + + // 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 = floating(); + endTime.timezone = floating(); + + document.getElementById("event-starttime").value = cal.dateTimeToJsDate(startTime); + document.getElementById("event-endtime").value = cal.dateTimeToJsDate(endTime); + } + + updateTimezone(); + updateAllDay(); +} + +/** + * This function requires gStartDate and gEndDate and the respective timezone + * variables to be initialized. It updates the timezone information displayed in + * the dialog from the above noted variables. + */ +function updateTimezone() { + gIgnoreUpdate = true; + + if (gDisplayTimezone) { + let startTimezone = gStartTimezone; + let endTimezone = gEndTimezone; + let equalTimezones = false; + if (startTimezone && endTimezone && + (compareObjects(startTimezone, endTimezone) || endTimezone.isUTC)) { + equalTimezones = true; + } + + let tzStart = document.getElementById("timezone-starttime"); + let tzEnd = document.getElementById("timezone-endtime"); + if (startTimezone) { + tzStart.removeAttribute("collapsed"); + tzStart.value = startTimezone.displayName || startTimezone.tzid; + } else { + tzStart.setAttribute("collapsed", "true"); + } + + // we never display the second timezone if both are equal + if (endTimezone != null && !equalTimezones) { + tzEnd.removeAttribute("collapsed"); + tzEnd.value = endTimezone.displayName || endTimezone.tzid; + } else { + tzEnd.setAttribute("collapsed", "true"); + } + } else { + document.getElementById("timezone-starttime") + .setAttribute("collapsed", "true"); + document.getElementById("timezone-endtime") + .setAttribute("collapsed", "true"); + } + + gIgnoreUpdate = false; +} + +/** + * Updates gStartDate from the start time picker "event-starttime" + */ +function updateStartTime() { + if (gIgnoreUpdate) { + return; + } + + let startWidgetId = "event-starttime"; + + let startWidget = document.getElementById(startWidgetId); + + // jsDate is always in OS timezone, thus we create a calIDateTime + // object from the jsDate representation and simply set the new + // timezone instead of converting. + let timezone = gDisplayTimezone ? gStartTimezone : calendarDefaultTimezone(); + let start = cal.jsDateToDateTime(startWidget.value, timezone); + + gStartDate = start.clone(); + start.addDuration(gDuration); + gEndDate = start.getInTimezone(gEndTimezone); + + let allDayElement = document.getElementById("all-day"); + let allDay = allDayElement.getAttribute("checked") == "true"; + if (allDay) { + gStartDate.isDate = true; + gEndDate.isDate = true; + } + + propagateDateTime(); +} + +/** + * Updates gEndDate from the end time picker "event-endtime" + */ +function updateEndTime() { + if (gIgnoreUpdate) { + return; + } + + let startWidgetId = "event-starttime"; + let endWidgetId = "event-endtime"; + + let startWidget = document.getElementById(startWidgetId); + let endWidget = document.getElementById(endWidgetId); + + let saveStartTime = gStartDate; + let saveEndTime = gEndDate; + let kDefaultTimezone = calendarDefaultTimezone(); + + gStartDate = cal.jsDateToDateTime(startWidget.value, + gDisplayTimezone ? gStartTimezone : calendarDefaultTimezone()); + + let timezone = gEndTimezone; + if (timezone.isUTC && + gStartDate && + !compareObjects(gStartTimezone, gEndTimezone)) { + timezone = gStartTimezone; + } + gEndDate = cal.jsDateToDateTime(endWidget.value, + gDisplayTimezone ? timezone : kDefaultTimezone); + + let allDayElement = document.getElementById("all-day"); + let allDay = allDayElement.getAttribute("checked") == "true"; + if (allDay) { + gStartDate.isDate = true; + gEndDate.isDate = true; + } + + // Calculate the new duration of start/end-time. + // don't allow for negative durations. + let warning = false; + if (gEndDate.compare(gStartDate) >= 0) { + gDuration = gEndDate.subtractDate(gStartDate); + } else { + gStartDate = saveStartTime; + gEndDate = saveEndTime; + warning = true; + } + + propagateDateTime(); + + if (warning) { + let callback = function() { + Services.prompt.alert( + null, + document.title, + calGetString("calendar", "warningEndBeforeStart")); + }; + setTimeout(callback, 1); + } +} + +/** + * Prompts the user to pick a new timezone for the starttime. The dialog is + * opened modally. + */ +function editStartTimezone() { + let tzStart = document.getElementById("timezone-starttime"); + if (tzStart.hasAttribute("disabled")) { + return; + } + + let self = this; + let args = {}; + args.calendar = window.arguments[0].calendar; + args.time = gStartDate.getInTimezone(gStartTimezone); + args.onOk = function(datetime) { + let equalTimezones = false; + if (gStartTimezone && gEndTimezone && + compareObjects(gStartTimezone, gEndTimezone)) { + equalTimezones = true; + } + gStartTimezone = datetime.timezone; + if (equalTimezones) { + gEndTimezone = datetime.timezone; + } + self.propagateDateTime(); + }; + + // Open the dialog modally + openDialog( + "chrome://calendar/content/calendar-event-dialog-timezone.xul", + "_blank", + "chrome,titlebar,modal,resizable", + args); +} + +/** + * Prompts the user to pick a new timezone for the endtime. The dialog is + * opened modally. + */ +function editEndTimezone() { + let tzStart = document.getElementById("timezone-endtime"); + if (tzStart.hasAttribute("disabled")) { + return; + } + + let self = this; + let args = {}; + args.calendar = window.arguments[0].calendar; + args.time = gEndTime.getInTimezone(gEndTimezone); + args.onOk = function(datetime) { + if (gStartTimezone && gEndTimezone && + compareObjects(gStartTimezone, gEndTimezone)) { + gStartTimezone = datetime.timezone; + } + gEndTimezone = datetime.timezone; + self.propagateDateTime(); + }; + + // Open the dialog modally + openDialog( + "chrome://calendar/content/calendar-event-dialog-timezone.xul", + "_blank", + "chrome,titlebar,modal,resizable", + args); +} + +/** + * Updates the dialog controls in case the window's event is an allday event, or + * was set to one in the attendee dialog. + * + * This for example disables the timepicker since its not needed. + */ +function updateAllDay() { + if (gIgnoreUpdate) { + return; + } + + let allDayElement = document.getElementById("all-day"); + let allDay = (allDayElement.getAttribute("checked") == "true"); + let startpicker = document.getElementById("event-starttime"); + let endpicker = document.getElementById("event-endtime"); + + let tzStart = document.getElementById("timezone-starttime"); + let tzEnd = document.getElementById("timezone-endtime"); + + // Disable the timezone links if 'allday' is checked OR the + // calendar of this item is read-only. In any other case we + // enable the links. + if (allDay) { + startpicker.setAttribute("timepickerdisabled", "true"); + endpicker.setAttribute("timepickerdisabled", "true"); + + tzStart.setAttribute("disabled", "true"); + tzEnd.setAttribute("disabled", "true"); + tzStart.removeAttribute("class"); + tzEnd.removeAttribute("class"); + } else { + startpicker.removeAttribute("timepickerdisabled"); + endpicker.removeAttribute("timepickerdisabled"); + + tzStart.removeAttribute("disabled"); + tzEnd.removeAttribute("disabled"); + tzStart.setAttribute("class", "text-link"); + tzEnd.setAttribute("class", "text-link"); + } +} + +/** + * Changes the global variables to adapt for the change of the allday checkbox. + * + * XXX Function names are all very similar here. This needs some consistency! + */ +function changeAllDay() { + let allDayElement = document.getElementById("all-day"); + let allDay = (allDayElement.getAttribute("checked") == "true"); + + gStartDate = gStartDate.clone(); + gEndDate = gEndDate.clone(); + + gStartDate.isDate = allDay; + gEndDate.isDate = allDay; + + propagateDateTime(); + + // After propagating the modified times we enforce some constraints + // on the zoom-factor. In case this events is now said to be all-day, + // we automatically enforce a 25% zoom-factor and disable the control. + let zoom = document.getElementById("zoom-menulist"); + let zoomOut = document.getElementById("zoom-out-button"); + let zoomIn = document.getElementById("zoom-in-button"); + if (allDay) { + zoom.value = "400"; + zoom.setAttribute("disabled", "true"); + zoomOut.setAttribute("disabled", "true"); + zoomIn.setAttribute("disabled", "true"); + setZoomFactor(zoom.value); + setForce24Hours(true); + } else { + zoom.removeAttribute("disabled"); + zoomOut.removeAttribute("disabled"); + zoomIn.removeAttribute("disabled"); + } +} + +/** + * Handler function used when the window is resized. + */ +function onResize() { + // Don't do anything if we haven't been initialized. + if (!gStartDate || !gEndDate) { + return; + } + + let grid = document.getElementById("freebusy-grid"); + let gridScrollbar = document.getElementById("horizontal-scrollbar"); + grid.fitDummyRows(); + let gridRatio = grid.boxObject.width / grid.documentSize; + let gridMaxpos = gridScrollbar.getAttribute("maxpos"); + let gridInc = gridMaxpos * gridRatio / (1 - gridRatio); + gridScrollbar.setAttribute("pageincrement", gridInc); + + let attendees = document.getElementById("attendees-list"); + let attendeesScrollbar = document.getElementById("vertical-scrollbar"); + let box = document.getElementById("vertical-scrollbar-box"); + attendees.fitDummyRows(); + let attRatio = attendees.boxObject.height / attendees.documentSize; + let attMaxpos = attendeesScrollbar.getAttribute("maxpos"); + if (attRatio < 1) { + box.removeAttribute("collapsed"); + let attInc = attMaxpos * attRatio / (1 - attRatio); + attendeesScrollbar.setAttribute("pageincrement", attInc); + } else { + box.setAttribute("collapsed", "true"); + } +} + +/** + * Handler function to call when changing the calendar used in this dialog. + * + * @param calendar The calendar to change to. + */ +function onChangeCalendar(calendar) { + let args = window.arguments[0]; + + // set 'mIsReadOnly' if the calendar is read-only + if (calendar && calendar.readOnly) { + gIsReadOnly = true; + } + + // assume we're the organizer [in case that the calendar + // does not support the concept of identities]. + gIsInvitation = false; + calendar = cal.wrapInstance(args.item.calendar, Components.interfaces.calISchedulingSupport); + if (calendar) { + gIsInvitation = calendar.isInvitation(args.item); + } + + if (gIsReadOnly || gIsInvitation) { + document.getElementById("next-slot") + .setAttribute("disabled", "true"); + document.getElementById("previous-slot") + .setAttribute("disabled", "true"); + } + + let freebusy = document.getElementById("freebusy-grid"); + freebusy.onChangeCalendar(calendar); +} + +/** + * Updates the slot buttons. + */ +function updateButtons() { + let previousButton = document.getElementById("previous-slot"); + if (gUndoStack.length > 0) { + previousButton.removeAttribute("disabled"); + } else { + previousButton.setAttribute("disabled", "true"); + } +} + +/** + * Handler function called to advance to the next slot. + */ +function onNextSlot() { + // Store the current setting in the undo-stack. + let currentSlot = {}; + currentSlot.startTime = gStartDate; + currentSlot.endTime = gEndDate; + gUndoStack.push(currentSlot); + + // Ask the grid for the next possible timeslot. + let grid = document.getElementById("freebusy-grid"); + let duration = gEndDate.subtractDate(gStartDate); + let start = grid.nextSlot(); + let end = start.clone(); + end.addDuration(duration); + if (start.isDate) { + end.day++; + } + gStartDate = start.clone(); + gEndDate = end.clone(); + let endDate = gEndDate.clone(); + + // Check if an all-day event has been passed in (to adapt endDate). + if (gStartDate.isDate) { + gEndDate.day--; + } + gStartDate.makeImmutable(); + gEndDate.makeImmutable(); + endDate.makeImmutable(); + + propagateDateTime(); + + // Scroll the grid/timebar such that the current time is visible + scrollToCurrentTime(); + + updateButtons(); +} + +/** + * Handler function called to advance to the previous slot. + */ +function onPreviousSlot() { + let previousSlot = gUndoStack.pop(); + if (!previousSlot) { + return; + } + + // In case the new starttime happens to be scheduled + // on a different day, we also need to update the + // complete freebusy informations and appropriate + // underlying arrays holding the information. + let refresh = previousSlot.startTime.day != gStartDate.day; + + gStartDate = previousSlot.startTime.clone(); + gEndDate = previousSlot.endTime.clone(); + + propagateDateTime(); + + // scroll the grid/timebar such that the current time is visible + scrollToCurrentTime(); + + updateButtons(); + + if (refresh) { + let grid = document.getElementById("freebusy-grid"); + grid.forceRefresh(); + } +} + +/** + * Scrolls the time grid to a position where the time of the item in question is + * visible. + */ +function scrollToCurrentTime() { + let timebar = document.getElementById("timebar"); + let ratio = (gStartDate.hour - gStartHour - 1) * timebar.step; + if (ratio <= 0.0) { + ratio = 0.0; + } + if (ratio >= 1.0) { + ratio = 1.0; + } + let scrollbar = document.getElementById("horizontal-scrollbar"); + let maxpos = scrollbar.getAttribute("maxpos"); + scrollbar.setAttribute("curpos", ratio * maxpos); +} + + +/** + * Sets the zoom factor for the time grid + * + * @param aValue The zoom factor to set. + * @return aValue (for chaining) + */ +function setZoomFactor(aValue) { + // Correct zoom factor, if needed + aValue = parseInt(aValue, 10) || 100; + + if (gZoomFactor == aValue) { + return aValue; + } + + gZoomFactor = aValue; + applyCurrentZoomFactor(); + return aValue; +} + +/** + * applies the current zoom factor for the time grid + */ +function applyCurrentZoomFactor() { + let timebar = document.getElementById("timebar"); + timebar.zoomFactor = gZoomFactor; + let selectionbar = document.getElementById("selection-bar"); + selectionbar.zoomFactor = gZoomFactor; + let grid = document.getElementById("freebusy-grid"); + grid.zoomFactor = gZoomFactor; + + // Calling onResize() will update the scrollbars and everything else + // that needs to adopt the previously made changes. We need to call + // this after the changes have actually been made... + onResize(); + + let scrollbar = document.getElementById("horizontal-scrollbar"); + if (scrollbar.hasAttribute("maxpos")) { + let curpos = scrollbar.getAttribute("curpos"); + let maxpos = scrollbar.getAttribute("maxpos"); + let ratio = curpos / maxpos; + timebar.scroll = ratio; + grid.scroll = ratio; + selectionbar.ratio = ratio; + } +} + +/** + * Force the time grid to show 24 hours. + * + * @param aValue If true, the view will be forced to 24 hours. + * @return aValue (for chaining) + */ +function setForce24Hours(aValue) { + if (gForce24Hours == aValue) { + return aValue; + } + + gForce24Hours = aValue; + initTimeRange(); + let timebar = document.getElementById("timebar"); + timebar.force24Hours = gForce24Hours; + let selectionbar = document.getElementById("selection-bar"); + selectionbar.force24Hours = gForce24Hours; + let grid = document.getElementById("freebusy-grid"); + grid.force24Hours = gForce24Hours; + + // Calling onResize() will update the scrollbars and everything else + // that needs to adopt the previously made changes. We need to call + // this after the changes have actually been made... + onResize(); + + let scrollbar = document.getElementById("horizontal-scrollbar"); + if (!scrollbar.hasAttribute("maxpos")) { + return aValue; + } + let curpos = scrollbar.getAttribute("curpos"); + let maxpos = scrollbar.getAttribute("maxpos"); + let ratio = curpos / maxpos; + timebar.scroll = ratio; + grid.scroll = ratio; + selectionbar.ratio = ratio; + + return aValue; +} + +/** + * Initialize the time range, setting the start and end hours from the prefs, or + * to 24 hrs if gForce24Hours is set. + */ +function initTimeRange() { + if (gForce24Hours) { + gStartHour = 0; + gEndHour = 24; + } else { + gStartHour = Preferences.get("calendar.view.daystarthour", 8); + gEndHour = Preferences.get("calendar.view.dayendhour", 19); + } +} + +/** + * Handler function for the "modify" event, emitted from the attendees-list + * binding. event.details is an array of objects containing the user's email + * (calid) and a flag that tells if the user has entered text before the last + * onModify was called (dirty). + * + * @param event The DOM event that caused the modification. + */ +function onModify(event) { + onResize(); + document.getElementById("freebusy-grid").onModify(event); +} + +/** + * Handler function for the "rowchange" event, emitted from the attendees-list + * binding. event.details is the row that was changed to. + * + * @param event The DOM event caused by the row change. + */ +function onRowChange(event) { + let scrollbar = document.getElementById("vertical-scrollbar"); + let attendees = document.getElementById("attendees-list"); + let maxpos = scrollbar.getAttribute("maxpos"); + scrollbar.setAttribute( + "curpos", + event.details / attendees.mMaxAttendees * maxpos); +} + +/** + * Handler function to take care of mouse scrolling on the window + * + * @param event The wheel event caused by scrolling. + */ +function onMouseScroll(event) { + // ignore mouse scrolling for now... + event.stopPropagation(); +} + +/** + * Hanlder function to take care of attribute changes on the window + * + * @param event The DOMAttrModified event caused by this change. + */ +function onAttrModified(event) { + if (event.attrName == "width") { + let selectionbar = document.getElementById("selection-bar"); + selectionbar.setWidth(selectionbar.boxObject.width); + return; + } + + // Synchronize grid and attendee list + let target = event.originalTarget; + if (target.hasAttribute("anonid") && + target.getAttribute("anonid") == "input" && + event.attrName == "focused") { + let attendees = document.getElementById("attendees-list"); + if (event.newValue == "true") { + let grid = document.getElementById("freebusy-grid"); + if (grid.firstVisibleRow != attendees.firstVisibleRow) { + grid.firstVisibleRow = attendees.firstVisibleRow; + } + } + if (!target.lastListCheckedValue || + target.lastListCheckedValue != target.value) { + attendees.resolvePotentialList(target); + target.lastListCheckedValue = target.value; + } + } + + if (event.originalTarget.localName == "scrollbar") { + let scrollbar = event.originalTarget; + if (scrollbar.hasAttribute("maxpos")) { + if (scrollbar.getAttribute("id") == "vertical-scrollbar") { + let attendees = document.getElementById("attendees-list"); + let grid = document.getElementById("freebusy-grid"); + if (event.attrName == "curpos") { + let maxpos = scrollbar.getAttribute("maxpos"); + attendees.ratio = event.newValue / maxpos; + } + grid.firstVisibleRow = attendees.firstVisibleRow; + } else if (scrollbar.getAttribute("id") == "horizontal-scrollbar") { + if (event.attrName == "curpos") { + let maxpos = scrollbar.getAttribute("maxpos"); + let ratio = event.newValue / maxpos; + let timebar = document.getElementById("timebar"); + let grid = document.getElementById("freebusy-grid"); + let selectionbar = document.getElementById("selection-bar"); + timebar.scroll = ratio; + grid.scroll = ratio; + selectionbar.ratio = ratio; + } + } + } + } +} + +/** + * Handler function for initializing the selection bar, event usually emitted + * from the freebusy-timebar binding. + * + * @param event The "timebar" event with details and height property. + */ +function onTimebar(event) { + document.getElementById( + "selection-bar") + .init(event.details, event.height); +} + +/** + * Handler function to update controls when the time has changed on the + * selection bar. + * + * @param event The "timechange" event with startDate and endDate + * properties. + */ +function onTimeChange(event) { + let start = event.startDate.getInTimezone(gStartTimezone); + let end = event.endDate.getInTimezone(gEndTimezone); + + loadDateTime(start, end); + + // fill the controls + updateDateTime(); + + // tell the timebar about the new start/enddate + let timebar = document.getElementById("timebar"); + timebar.startDate = gStartDate; + timebar.endDate = gEndDate; + timebar.refresh(); + + // tell the freebusy grid about the new start/enddate + let grid = document.getElementById("freebusy-grid"); + + let refresh = (grid.startDate == null) || + (grid.startDate.compare(gStartDate) != 0) || + (grid.endDate == null) || + (grid.endDate.compare(gEndDate) != 0); + grid.startDate = gStartDate; + grid.endDate = gEndDate; + if (refresh) { + grid.forceRefresh(); + } +} + +/** + * This listener is used in calendar-event-dialog-freebusy.xml inside the + * binding. It has been taken out of the binding to prevent leaks. + */ +function calFreeBusyListener(aFbElement, aBinding) { + this.mFbElement = aFbElement; + this.mBinding = aBinding; +} + +calFreeBusyListener.prototype = { + onResult: function(aRequest, aEntries) { + if (aRequest && !aRequest.isPending) { + // Find request in list of pending requests and remove from queue: + this.mBinding.mPendingRequests = this.mBinding.mPendingRequests.filter(aOp => aRequest.id != aOp.id); + } + if (aEntries) { + this.mFbElement.onFreeBusy(aEntries); + } + } +}; diff --git a/calendar/base/content/dialogs/calendar-event-dialog-attendees.xml b/calendar/base/content/dialogs/calendar-event-dialog-attendees.xml new file mode 100644 index 000000000..5a8513a1a --- /dev/null +++ b/calendar/base/content/dialogs/calendar-event-dialog-attendees.xml @@ -0,0 +1,1604 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<!DOCTYPE dialog [ + <!ENTITY % dtd1 SYSTEM "chrome://calendar/locale/global.dtd" > %dtd1; + <!ENTITY % dtd2 SYSTEM "chrome://calendar/locale/calendar.dtd" > %dtd2; + <!ENTITY % dtd3 SYSTEM "chrome://calendar/locale/calendar-event-dialog.dtd"> %dtd3; +]> + +<bindings xmlns="http://www.mozilla.org/xbl" + xmlns:xbl="http://www.mozilla.org/xbl" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <binding id="attendees-list"> + <content> + <xul:listbox anonid="listbox" + seltype="multiple" + class="listbox-noborder" + rows="-1" + flex="1"> + <xul:listcols> + <xul:listcol/> + <xul:listcol/> + <xul:listcol flex="1"/> + </xul:listcols> + <xul:listitem anonid="item" class="addressingWidgetItem" allowevents="true"> + <xul:listcell class="addressingWidgetCell" align="center" pack="center"> + <xul:image id="attendeeCol1#1" anonid="rolestatus-icon"/> + </xul:listcell> + <xul:listcell class="addressingWidgetCell"> + <xul:image id="attendeeCol2#1" anonid="usertype-icon" class="usertype-icon" onclick="this.parentNode.select();"/> + </xul:listcell> + <xul:listcell class="addressingWidgetCell"> + <xul:textbox id="attendeeCol3#1" + anonid="input" + class="plain textbox-addressingWidget uri-element" + type="autocomplete" + flex="1" + autocompletesearch="addrbook ldap" + autocompletesearchparam="{}" + timeout="300" + maxrows="4" + completedefaultindex="true" + forcecomplete="true" + completeselectedindex="true" + minresultsforpopup="1" + onblur="if (this.localName == 'textbox') document.getBindingParent(this).returnHit(this, true)" + ignoreblurwhilesearching="true" + oninput="this.setAttribute('dirty', 'true');" + ontextentered="document.getBindingParent(this).returnHit(this);"> + </xul:textbox> + </xul:listcell> + </xul:listitem> + </xul:listbox> + </content> + + <implementation> + <field name="mMaxAttendees">0</field> + <field name="mContentHeight">0</field> + <field name="mRowHeight">0</field> + <field name="mNumColumns">0</field> + <field name="mIsOffline">0</field> + <field name="mIsReadOnly">false</field> + <field name="mIsInvitation">false</field> + <field name="mPopupOpen">false</field> + + <constructor><![CDATA[ + Components.utils.import("resource://calendar/modules/calUtils.jsm"); + Components.utils.import("resource://gre/modules/Services.jsm"); + Components.utils.import("resource:///modules/mailServices.js"); + + this.mMaxAttendees = 0; + + window.addEventListener("load", this.onLoad.bind(this), true); + ]]></constructor> + + <method name="onLoad"> + <body><![CDATA[ + this.onInitialize(); + + // this trigger the continous update chain, which + // effectively calls this.onModify() on predefined + // time intervals [each second]. + let self = this; + let callback = function() { + setTimeout(callback, 1000); + self.onModify(); + }; + callback(); + ]]></body> + </method> + + <method name="onInitialize"> + <body><![CDATA[ + let args = window.arguments[0]; + let organizer = args.organizer; + let attendees = args.attendees; + let calendar = args.calendar; + + this.mIsReadOnly = calendar.readOnly; + + // assume we're the organizer [in case that the calendar + // does not support the concept of identities]. + let organizerID = ((organizer && organizer.id) + ? organizer.id + : calendar.getProperty("organizerId")); + + calendar = cal.wrapInstance(calendar, Components.interfaces.calISchedulingSupport); + this.mIsInvitation = (calendar && calendar.isInvitation(args.item)); + + let listbox = + document.getAnonymousElementByAttribute( + this, "anonid", "listbox"); + let template = + document.getAnonymousElementByAttribute( + this, "anonid", "item"); + template.focus(); + + if (this.mIsReadOnly || this.mIsInvitation) { + listbox.setAttribute("disabled", "true"); + } + + // TODO: the organizer should show up in the attendee list, but this information + // should be based on the organizer contained in the appropriate field of calIItemBase. + // This is currently not supported, since we're still missing calendar identities. + if (organizerID && organizerID != "") { + if (organizer) { + if (!organizer.id) { + organizer.id = organizerID; + } + if (!organizer.role) { + organizer.role = "CHAIR"; + } + if (!organizer.participationStatus) { + organizer.participationStatus = "ACCEPTED"; + } + } else { + organizer = this.createAttendee(); + organizer.id = organizerID; + organizer.role = "CHAIR"; + organizer.participationStatus = "ACCEPTED"; + } + if (!organizer.commonName || !organizer.commonName.length) { + organizer.commonName = calendar.getProperty("organizerCN"); + } + organizer.isOrganizer = true; + this.appendAttendee(organizer, listbox, template, true); + } + + let numRowsAdded = 0; + if (attendees.length > 0) { + for (let attendee of attendees) { + this.appendAttendee(attendee, listbox, template, false); + numRowsAdded++; + } + } + if (numRowsAdded == 0) { + this.appendAttendee(null, listbox, template, false); + } + + // detach the template item from the listbox, but hold the reference. + // until this function returns we add at least a single copy of this template back again. + template.remove(); + + this.setFocus(this.mMaxAttendees); + ]]></body> + </method> + + <!-- appends a new row using an existing attendee structure --> + <method name="appendAttendee"> + <parameter name="aAttendee"/> + <parameter name="aParentNode"/> + <parameter name="aTemplateNode"/> + <parameter name="aDisableIfOrganizer"/> + <body><![CDATA[ + // create a new listbox item and append it to our parent control. + let newNode = aTemplateNode.cloneNode(true); + + let input = + document.getAnonymousElementByAttribute( + newNode, "anonid", "input"); + let roleStatusIcon = + document.getAnonymousElementByAttribute( + newNode, "anonid", "rolestatus-icon"); + let userTypeIcon = + document.getAnonymousElementByAttribute( + newNode, "anonid", "usertype-icon"); + + // We always clone the first row. The problem is that the first row + // could be focused. When we clone that row, we end up with a cloned + // XUL textbox that has a focused attribute set. Therefore we think + // we're focused and don't properly refocus. The best solution to this + // would be to clone a template row that didn't really have any presentation, + // rather than using the real visible first row of the listbox. + // For now we'll just put in a hack that ensures the focused attribute + // is never copied when the node is cloned. + if (input.getAttribute("focused") != "") { + input.removeAttribute("focused"); + } + + aParentNode.appendChild(newNode); + + // the template could have its fields disabled, + // that's why we need to reset their status. + input.removeAttribute("disabled"); + userTypeIcon.removeAttribute("disabled"); + roleStatusIcon.removeAttribute("disabled"); + + if (this.mIsReadOnly || this.mIsInvitation) { + input.setAttribute("disabled", "true"); + userTypeIcon.setAttribute("disabled", "true"); + roleStatusIcon.setAttribute("disabled", "true"); + } + + // disable the input-field [name <email>] if this attendee + // appears to be the organizer. + if (aDisableIfOrganizer && aAttendee && aAttendee.isOrganizer) { + input.setAttribute("disabled", "true"); + } + + this.mMaxAttendees++; + let rowNumber = this.mMaxAttendees; + if (rowNumber >= 0) { + roleStatusIcon.setAttribute("id", "attendeeCol1#" + rowNumber); + userTypeIcon.setAttribute("id", "attendeeCol2#" + rowNumber); + input.setAttribute("id", "attendeeCol3#" + rowNumber); + } + + if (!aAttendee) { + aAttendee = this.createAttendee(); + } + + // construct the display string from common name and/or email address. + let commonName = aAttendee.commonName || ""; + let inputValue = cal.removeMailTo(aAttendee.id || ""); + if (commonName.length) { + // Make the commonName appear in quotes if it contains a + // character that could confuse the header parser + if (commonName.search(/[,;<>@]/) != -1) { + commonName = '"' + commonName + '"'; + } + inputValue = inputValue.length ? commonName + " <" + inputValue + ">" : commonName; + } + + // trim spaces if any + inputValue = inputValue.trim(); + + // don't set value with null, otherwise autocomplete stops working, + // but make sure attendee and dirty are set + if (inputValue.length) { + input.setAttribute("value", inputValue); + input.value = inputValue; + } + input.attendee = aAttendee; + input.setAttribute("dirty", "true"); + + if (aAttendee) { + // Set up userType + setElementValue(userTypeIcon, aAttendee.userType || false, "cutype"); + this.updateTooltip(userTypeIcon); + + // Set up role/status icon + if (aAttendee.isOrganizer) { + roleStatusIcon.setAttribute("class", "status-icon"); + setElementValue(roleStatusIcon, aAttendee.participationStatus || false, "status"); + } else { + roleStatusIcon.setAttribute("class", "role-icon"); + setElementValue(roleStatusIcon, aAttendee.role || false, "role"); + } + this.updateTooltip(roleStatusIcon); + } + + return true; + ]]></body> + </method> + + <method name="appendNewRow"> + <parameter name="aSetFocus"/> + <parameter name="aInsertAfter"/> + <body><![CDATA[ + let listbox = + document.getAnonymousElementByAttribute( + this, "anonid", "listbox"); + let listitem1 = this.getListItem(1); + let newNode = null; + + if (listbox && listitem1) { + let newAttendee = this.createAttendee(); + let nextDummy = this.getNextDummyRow(); + newNode = listitem1.cloneNode(true); + + if (aInsertAfter) { + listbox.insertBefore(newNode, aInsertAfter.nextSibling); + } else if (nextDummy) { + listbox.replaceChild(newNode, nextDummy); + } else { + listbox.appendChild(newNode); + } + + let input = + document.getAnonymousElementByAttribute( + newNode, "anonid", "input"); + let roleStatusIcon = + document.getAnonymousElementByAttribute( + newNode, "anonid", "rolestatus-icon"); + let userTypeIcon = + document.getAnonymousElementByAttribute( + newNode, "anonid", "usertype-icon"); + + // the template could have its fields disabled, + // that's why we need to reset their status. + input.removeAttribute("disabled"); + roleStatusIcon.removeAttribute("disabled"); + userTypeIcon.removeAttribute("disabled"); + + if (this.mIsReadOnly || this.mIsInvitation) { + input.setAttribute("disabled", "true"); + roleStatusIcon.setAttribute("disabled", "true"); + userTypeIcon.setAttribute("disabled", "true"); + } + + this.mMaxAttendees++; + let rowNumber = this.mMaxAttendees; + if (rowNumber >= 0) { + roleStatusIcon.setAttribute("id", "attendeeCol1#" + rowNumber); + userTypeIcon.setAttribute("id", "attendeeCol2#" + rowNumber); + input.setAttribute("id", "attendeeCol3#" + rowNumber); + } + + input.value = null; + input.removeAttribute("value"); + input.attendee = newAttendee; + + // set role and participation status + roleStatusIcon.setAttribute("class", "role-icon"); + roleStatusIcon.setAttribute("role", "REQ-PARTICIPANT"); + userTypeIcon.setAttribute("cutype", "INDIVIDUAL"); + + // Set tooltip for rolenames and usertype icon + this.updateTooltip(roleStatusIcon); + this.updateTooltip(userTypeIcon); + + // We always clone the first row. The problem is that the first row + // could be focused. When we clone that row, we end up with a cloned + // XUL textbox that has a focused attribute set. Therefore we think + // we're focused and don't properly refocus. The best solution to this + // would be to clone a template row that didn't really have any presentation, + // rather than using the real visible first row of the listbox. + // For now we'll just put in a hack that ensures the focused attribute + // is never copied when the node is cloned. + if (input.getAttribute("focused") != "") { + input.removeAttribute("focused"); + } + + // focus on new input widget + if (aSetFocus) { + this.setFocus(newNode); + } + } + return newNode; + ]]></body> + </method> + + <property name="attendees"> + <getter><![CDATA[ + let attendees = []; + + for (let i = 1; true; i++) { + let inputField = this.getInputElement(i); + if (!inputField) { + break; + } else if (inputField.value == "") { + continue; + } + + // the inputfield already has a reference to the attendee + // object, we just need to fill in the name. + let attendee = inputField.attendee.clone(); + if (attendee.isOrganizer) { + continue; + } + + attendee.role = this.getRoleElement(i).getAttribute("role"); + // attendee.participationStatus = this.getStatusElement(i).getAttribute("status"); + let userType = this.getUserTypeElement(i).getAttribute("cutype"); + attendee.userType = (userType == "INDIVIDUAL" ? null : userType); // INDIVIDUAL is the default + + // break the list of potentially many attendees back into individual names. This + // is required in case the user entered comma-separated attendees in one field and + // then clicked OK without switching to the next line. + let parsedInput = MailServices.headerParser.makeFromDisplayAddress(inputField.value); + let j = 0; + let addAttendee = function(aAddress) { + if (j > 0) { + attendee = attendee.clone(); + } + attendee.id = cal.prependMailTo(aAddress.email); + if (aAddress.name.length > 0) { + // we remove any double quotes within CN due to bug 1209399 + attendee.commonName = aAddress.name.replace(/(?:[\\]"|")/, ""); + } + attendees.push(attendee); + j++; + }; + parsedInput.forEach(addAttendee); + } + + return attendees; + ]]></getter> + </property> + + <property name="organizer"> + <getter><![CDATA[ + for (let i = 1; true; i++) { + let inputField = this.getInputElement(i); + if (!inputField) { + break; + } else if (inputField.value == "") { + continue; + } + + // The inputfield already has a reference to the attendee + // object, we just need to fill in the name. + let attendee = inputField.attendee.clone(); + + // attendee.role = this.getRoleElement(i).getAttribute("role"); + attendee.participationStatus = this.getStatusElement(i).getAttribute("status"); + // Organizers do not have a CUTYPE + attendee.userType = null; + + // break the list of potentially many attendees back into individual names + let parsedInput = MailServices.headerParser.makeFromDisplayAddress(inputField.value); + if (parsedInput[0].email > 0) { + attendee.id = cal.prependMailTo(parsedInput[0].email); + } + if (parsedInput[0].name.length > 0) { + attendee.commonName = parsedInput[0].name; + } + + if (attendee.isOrganizer) { + return attendee; + } + } + + return null; + ]]></getter> + </property> + + <method name="_resolveListByName"> + <parameter name="value"/> + <body><![CDATA[ + let entries = MailServices.headerParser.makeFromDisplayAddress(value); + return entries.length ? this._findListInAddrBooks(entries[0].name) : null; + ]]></body> + </method> + + <method name="_findListInAddrBooks"> + <parameter name="entryname"/> + <body><![CDATA[ + let allAddressBooks = MailServices.ab.directories; + + while (allAddressBooks.hasMoreElements()) { + let abDir = null; + try { + abDir = allAddressBooks.getNext() + .QueryInterface(Components.interfaces.nsIAbDirectory); + } catch (ex) { + cal.WARN("[eventDialog] Error Encountered" + ex); + } + + if (abDir != null && abDir.supportsMailingLists) { + let childNodes = abDir.childNodes; + while (childNodes.hasMoreElements()) { + let dir = null; + try { + dir = childNodes.getNext().QueryInterface(Components.interfaces.nsIAbDirectory); + } catch (ex) { + cal.WARN("[eventDialog] Error Encountered" + ex); + } + + if (dir && dir.isMailList && (dir.dirName == entryname)) { + return dir; + } + } + } + } + return null; + ]]></body> + </method> + + <method name="_getListEntriesInt"> + <parameter name="mailingList"/> + <parameter name="attendees"/> + <parameter name="allListsUri"/> + <body><![CDATA[ + let addressLists = mailingList.addressLists; + for (let i = 0; i < addressLists.length; i++) { + let abCard = addressLists.queryElementAt(i, Components.interfaces.nsIAbCard); + let thisId = abCard.primaryEmail; + if (abCard.displayName.length > 0) { + let rCn = abCard.displayName; + if (rCn.includes(",")) { + rCn = '"' + rCn + '"'; + } + thisId = rCn + " <" + thisId + ">"; + } + if (attendees.some(att => att == thisId)) { + continue; + } + + if (abCard.displayName.length > 0) { + let list = this._findListInAddrBooks(abCard.displayName); + if (list) { + if (allListsUri.some(uri => uri == list.URI)) { + continue; + } + allListsUri.push(list.URI); + + this._getListEntriesInt(list, attendees, allListsUri); + + continue; + } + } + + attendees.push(thisId); + } + + return attendees; + ]]></body> + </method> + + <method name="_getListEntries"> + <parameter name="mailingList"/> + <body><![CDATA[ + + let attendees = []; + let allListsUri = []; + + allListsUri.push(mailingList.URI); + + this._getListEntriesInt(mailingList, attendees, allListsUri); + + return attendees; + + ]]></body> + </method> + + <method name="_fillListItemWithEntry"> + <parameter name="listitem"/> + <parameter name="entry"/> + <parameter name="rowNumber"/> + <body><![CDATA[ + let newAttendee = this.createAttendee(entry); + let input = document.getAnonymousElementByAttribute(listitem, "anonid", "input"); + input.removeAttribute("disabled"); + input.setAttribute("id", "attendeeCol3#" + rowNumber); + + input.attendee = newAttendee; + input.value = entry; + input.setAttribute("value", entry); + input.setAttribute("dirty", "true"); + if (input.getAttribute("focused") != "") { + input.removeAttribute("focused"); + } + + let roleStatusIcon = document.getAnonymousElementByAttribute(listitem, "anonid", "rolestatus-icon"); + roleStatusIcon.removeAttribute("disabled"); + roleStatusIcon.setAttribute("id", "attendeeCol1#" + rowNumber); + roleStatusIcon.setAttribute("class", "role-icon"); + roleStatusIcon.setAttribute("role", newAttendee.role); + + let userTypeIcon = document.getAnonymousElementByAttribute(listitem, "anonid", "usertype-icon"); + userTypeIcon.removeAttribute("disabled"); + userTypeIcon.setAttribute("id", "attendeeCol2#" + rowNumber); + userTypeIcon.setAttribute("cutype", newAttendee.userType); + ]]></body> + </method> + + <method name="resolvePotentialList"> + <parameter name="aInput"/> + <body><![CDATA[ + let fieldValue = aInput.value; + if (aInput.id.length > 0 && fieldValue.length > 0) { + let mailingList = this._resolveListByName(fieldValue); + if (mailingList) { + let entries = this._getListEntries(mailingList); + if (entries.length > 0) { + let currentIndex = parseInt(aInput.id.substr(13), 10); + let template = document.getAnonymousElementByAttribute(this, "anonid", "item"); + let currentNode = template.parentNode.childNodes[currentIndex]; + this._fillListItemWithEntry(currentNode, entries[0], currentIndex); + entries.shift(); + let nextNode = template.parentNode.childNodes[currentIndex + 1]; + currentIndex++; + for (let entry of entries) { + currentNode = template.cloneNode(true); + template.parentNode.insertBefore(currentNode, nextNode); + this._fillListItemWithEntry(currentNode, entry, currentIndex); + currentIndex++; + } + this.mMaxAttendees += entries.length; + for (let i = currentIndex; i <= this.mMaxAttendees; i++) { + let row = template.parentNode.childNodes[i]; + let roleStatusIcon = document.getAnonymousElementByAttribute(row, "anonid", "rolestatus-icon"); + roleStatusIcon.setAttribute("id", "attendeeCol1#" + i); + + let userTypeIcon = document.getAnonymousElementByAttribute(row, "anonid", "usertype-icon"); + userTypeIcon.setAttribute("id", "attendeeCol2#" + i); + + let input = document.getAnonymousElementByAttribute(row, "anonid", "input"); + input.setAttribute("id", "attendeeCol3#" + i); + input.setAttribute("dirty", "true"); + } + } + } + } + ]]></body> + </method> + + <method name="onModify"> + <body><![CDATA[ + let list = []; + for (let i = 1; i <= this.mMaxAttendees; i++) { + // retrieve the string from the appropriate row + let input = this.getInputElement(i); + if (input && input.value) { + // parse the string to break this down to individual names and addresses + let parsedInput = MailServices.headerParser.makeFromDisplayAddress(input.value); + let email = cal.prependMailTo(parsedInput[0].email); + + let isdirty = false; + if (input.hasAttribute("dirty")) { + isdirty = input.getAttribute("dirty"); + } + input.removeAttribute("dirty"); + let entry = { + dirty: isdirty, + calid: email + }; + list.push(entry); + } + } + + let event = document.createEvent("Events"); + event.initEvent("modify", true, false); + event.details = list; + this.dispatchEvent(event); + ]]></body> + </method> + + <method name="updateTooltip"> + <parameter name="targetIcon"/> + <body><![CDATA[ + // Function setting the tooltip of attendeeicons based on their role + if (targetIcon.className == "role-icon") { + let role = targetIcon.getAttribute("role"); + // Set tooltip for rolenames + + const roleMap = { + "REQ-PARTICIPANT": "required", + "OPT-PARTICIPANT": "optional", + "NON-PARTICIPANT": "nonparticipant", + "CHAIR": "chair" + }; + + let roleNameString = "event.attendee.role." + (role in roleMap ? roleMap[role] : "unknown"); + let tooltip = cal.calGetString("calendar-event-dialog-attendees", + roleNameString, + role in roleMap ? null : [role]); + targetIcon.setAttribute("tooltiptext", tooltip); + } else if (targetIcon.className == "usertype-icon") { + let cutype = targetIcon.getAttribute("cutype"); + const cutypeMap = { + INDIVIDUAL: "individual", + GROUP: "group", + RESOURCE: "resource", + ROOM: "room", + // I've decided UNKNOWN will not be handled. + }; + + let cutypeString = "event.attendee.usertype." + (cutype in cutypeMap ? cutypeMap[cutype] : "unknown"); + let tooltip = cal.calGetString("calendar-event-dialog-attendees", + cutypeString, + cutype in cutypeMap ? null : [cutype]); + targetIcon.setAttribute("tooltiptext", tooltip); + } + ]]></body> + </method> + + <property name="documentSize"> + <getter><![CDATA[ + return this.mRowHeight * this.mMaxAttendees; + ]]></getter> + </property> + + <method name="fitDummyRows"> + <body><![CDATA[ + setTimeout(() => { + this.calcContentHeight(); + this.createOrRemoveDummyRows(); + }, 0); + ]]></body> + </method> + + <method name="calcContentHeight"> + <body><![CDATA[ + let listbox = + document.getAnonymousElementByAttribute( + this, "anonid", "listbox"); + let items = listbox.getElementsByTagNameNS("*", "listitem"); + this.mContentHeight = 0; + if (items.length > 0) { + let i = 0; + do { + this.mRowHeight = items[i].boxObject.height; + ++i; + } while (i < items.length && !this.mRowHeight); + this.mContentHeight = this.mRowHeight * items.length; + } + ]]></body> + </method> + + <method name="createOrRemoveDummyRows"> + <body><![CDATA[ + let listbox = + document.getAnonymousElementByAttribute( + this, "anonid", "listbox"); + let listboxHeight = listbox.boxObject.height; + + // remove rows to remove scrollbar + let kids = listbox.childNodes; + for (let i = kids.length - 1; this.mContentHeight > listboxHeight && i >= 0; --i) { + if (kids[i].hasAttribute("_isDummyRow")) { + this.mContentHeight -= this.mRowHeight; + kids[i].remove(); + } + } + + // add rows to fill space + if (this.mRowHeight) { + while (this.mContentHeight + this.mRowHeight < listboxHeight) { + this.createDummyItem(listbox); + this.mContentHeight += this.mRowHeight; + } + } + ]]></body> + </method> + + <method name="createDummyCell"> + <parameter name="aParent"/> + <body><![CDATA[ + let cell = document.createElementNS("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul", "listcell"); + cell.setAttribute("class", "addressingWidgetCell dummy-row-cell"); + if (aParent) { + aParent.appendChild(cell); + } + return cell; + ]]></body> + </method> + + <method name="createDummyItem"> + <parameter name="aParent"/> + <body><![CDATA[ + let titem = document.createElementNS("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul", "listitem"); + titem.setAttribute("_isDummyRow", "true"); + titem.setAttribute("class", "dummy-row"); + for (let i = this.numColumns; i > 0; i--) { + this.createDummyCell(titem); + } + if (aParent) { + aParent.appendChild(titem); + } + return titem; + ]]></body> + </method> + + <!-- gets the next row from the top down --> + <method name="getNextDummyRow"> + <body><![CDATA[ + let listbox = + document.getAnonymousElementByAttribute( + this, "anonid", "listbox"); + let kids = listbox.childNodes; + for (let i = 0; i < kids.length; ++i) { + if (kids[i].hasAttribute("_isDummyRow")) { + return kids[i]; + } + } + return null; + ]]></body> + </method> + + <!-- This method returns the <xul:listitem> at row numer 'aRow' --> + <method name="getListItem"> + <parameter name="aRow"/> + <body><![CDATA[ + let listbox = + document.getAnonymousElementByAttribute( + this, "anonid", "listbox"); + if (listbox && aRow > 0) { + let listitems = listbox.getElementsByTagNameNS("*", "listitem"); + if (listitems && listitems.length >= aRow) { + return listitems[aRow - 1]; + } + } + return 0; + ]]></body> + </method> + + <method name="getInputFromListitem"> + <parameter name="aListItem"/> + <body><![CDATA[ + return aListItem.getElementsByTagNameNS("*", "textbox")[0]; + ]]></body> + </method> + + <method name="getRowByInputElement"> + <parameter name="aElement"/> + <body><![CDATA[ + let row = 0; + while (aElement && aElement.localName != "listitem") { + aElement = aElement.parentNode; + } + if (aElement) { + while (aElement) { + if (aElement.localName == "listitem") { + ++row; + } + aElement = aElement.previousSibling; + } + } + return row; + ]]></body> + </method> + + <!-- This method returns the <xul:textbox> that contains + the name of the attendee at row number 'aRow' --> + <method name="getInputElement"> + <parameter name="aRow"/> + <body><![CDATA[ + return document.getAnonymousElementByAttribute(this, "id", "attendeeCol3#" + aRow); + ]]></body> + </method> + + <method name="getRoleElement"> + <parameter name="aRow"/> + <body><![CDATA[ + return document.getAnonymousElementByAttribute(this, "id", "attendeeCol1#" + aRow); + ]]></body> + </method> + + <method name="getStatusElement"> + <parameter name="aRow"/> + <body><![CDATA[ + return document.getAnonymousElementByAttribute(this, "id", "attendeeCol1#" + aRow); + ]]></body> + </method> + + <method name="getUserTypeElement"> + <parameter name="aRow"/> + <body><![CDATA[ + return document.getAnonymousElementByAttribute(this, "id", "attendeeCol2#" + aRow); + ]]></body> + </method> + + <method name="setFocus"> + <parameter name="aRow"/> + <body><![CDATA[ + let self = this; + let set_focus = function() { + let node; + if (typeof aRow == "number") { + node = self.getListItem(aRow); + } else { + node = aRow; + } + + // do we need to scroll in order to see the selected row? + let listbox = + document.getAnonymousElementByAttribute( + self, "anonid", "listbox"); + let firstVisibleRow = listbox.getIndexOfFirstVisibleRow(); + let numOfVisibleRows = listbox.getNumberOfVisibleRows(); + if (aRow <= firstVisibleRow) { + listbox.scrollToIndex(aRow - 1); + } else if (aRow - 1 >= (firstVisibleRow + numOfVisibleRows)) { + listbox.scrollToIndex(aRow - numOfVisibleRows); + } + let input = + document.getAnonymousElementByAttribute( + node, "anonid", "input"); + input.focus(); + }; + setTimeout(set_focus, 0); + ]]></body> + </method> + + <property name="firstVisibleRow"> + <getter><![CDATA[ + let listbox = + document.getAnonymousElementByAttribute( + this, "anonid", "listbox"); + return listbox.getIndexOfFirstVisibleRow(); + ]]></getter> + </property> + + <method name="createAttendee"> + <body><![CDATA[ + let attendee = createAttendee(); + attendee.id = ""; + attendee.rsvp = "TRUE"; + attendee.role = "REQ-PARTICIPANT"; + attendee.participationStatus = "NEEDS-ACTION"; + return attendee; + ]]></body> + </method> + + <property name="numColumns"> + <getter><![CDATA[ + if (!this.mNumColumns) { + let listbox = + document.getAnonymousElementByAttribute( + this, "anonid", "listbox"); + let listCols = listbox.getElementsByTagNameNS("*", "listcol"); + this.mNumColumns = listCols.length; + if (!this.mNumColumns) { + this.mNumColumns = 1; + } + } + return this.mNumColumns; + ]]></getter> + </property> + + <property name="ratio"> + <setter><![CDATA[ + let listbox = + document.getAnonymousElementByAttribute( + this, "anonid", "listbox"); + let rowcount = listbox.getRowCount(); + listbox.scrollToIndex(Math.floor(rowcount * val)); + return val; + ]]></setter> + </property> + + <method name="returnHit"> + <parameter name="element"/> + <parameter name="noAdvance"/> + <body><![CDATA[ + function parseHeaderValue(aMsgIAddressObject) { + if (aMsgIAddressObject.name.match(/[<>@,]/)) { + // special handling only needed for a name with a comma which are not already quoted + return (aMsgIAddressObject.name.match(/^".*"$/) + ? aMsgIAddressObject.name + : '"' + aMsgIAddressObject.name + '"' + ) + " <" + aMsgIAddressObject.email + ">"; + } else { + return aMsgIAddressObject.toString(); + } + } + + let arrowLength = 1; + if (element.value.includes(",") || element.value.match(/^[^"].*[<>@,].*[^"] <.+@.+>$/)) { + let strippedAddresses = element.value.replace(/.* >> /, ""); + let addresses = MailServices.headerParser.makeFromDisplayAddress(strippedAddresses); + element.value = parseHeaderValue(addresses[0]); + + // the following code is needed to split attendees, if the user enters a comma + // separated list of attendees without using autocomplete functionality + let insertAfterItem = this.getListItem(this.getRowByInputElement(element)); + for (let key in addresses) { + if (key > 0) { + insertAfterItem = this.appendNewRow(false, insertAfterItem); + let textinput = this.getInputFromListitem(insertAfterItem); + textinput.value = parseHeaderValue(addresses[key]); + } + } + arrowLength = addresses.length; + } + + if (!noAdvance) { + this.arrowHit(element, arrowLength); + } + ]]></body> + </method> + + <method name="arrowHit"> + <parameter name="aElement"/> + <parameter name="aDirection"/> + <body><![CDATA[ + let row = this.getRowByInputElement(aElement) + aDirection; + if (row) { + if (row > this.mMaxAttendees) { + this.appendNewRow(true); + } else { + let input = this.getInputElement(row); + if (input.hasAttribute("disabled")) { + return; + } + this.setFocus(row); + } + let event = document.createEvent("Events"); + event.initEvent("rowchange", true, false); + event.details = row; + this.dispatchEvent(event); + } + ]]></body> + </method> + + <method name="deleteHit"> + <parameter name="aElement"/> + <body><![CDATA[ + // don't delete the row if only the organizer is remaining + if (this.mMaxAttendees <= 1) { + return; + } + + let row = this.getRowByInputElement(aElement); + this.deleteRow(row); + if (row > 0) { + row = row - 1; + } + this.setFocus(row); + this.onModify(); + + let event = document.createEvent("Events"); + event.initEvent("rowchange", true, false); + event.details = row; + this.dispatchEvent(event); + ]]></body> + </method> + + <method name="deleteRow"> + <parameter name="aRow"/> + <body><![CDATA[ + // reset id's in order to not break the sequence + let maxAttendees = this.mMaxAttendees; + this.removeRow(aRow); + let numberOfCols = this.numColumns; + for (let row = aRow + 1; row <= maxAttendees; row++) { + for (let col = 1; col <= numberOfCols; col++) { + let colID = "attendeeCol" + col + "#" + row; + let elem = document.getAnonymousElementByAttribute(this, "id", colID); + if (elem) { + elem.setAttribute("id", "attendeeCol" + col + "#" + (row - 1)); + } + } + } + ]]></body> + </method> + + <method name="removeRow"> + <parameter name="aRow"/> + <body><![CDATA[ + this.getListItem(aRow).remove(); + this.fitDummyRows(); + this.mMaxAttendees--; + ]]></body> + </method> + </implementation> + + <handlers> + <handler event="click" button="0"><![CDATA[ + function cycle(values, current) { + let nextIndex = (values.indexOf(current) + 1) % values.length; + return values[nextIndex]; + } + + let target = event.originalTarget; + if (target.className == "role-icon") { + if (target.getAttribute("disabled") != "true") { + const roleCycle = ["REQ-PARTICIPANT", "OPT-PARTICIPANT", + "NON-PARTICIPANT", "CHAIR"]; + + let nextValue = cycle(roleCycle, target.getAttribute("role")); + target.setAttribute("role", nextValue); + this.updateTooltip(target); + } + } else if (target.className == "status-icon") { + if (target.getAttribute("disabled") != "true") { + const statusCycle = ["ACCEPTED", "DECLINED", "TENTATIVE"]; + + let nextValue = cycle(statusCycle, target.getAttribute("status")); + target.setAttribute("status", nextValue); + this.updateTooltip(target); + } + } else if (target.className == "usertype-icon") { + let fieldNum = target.getAttribute("id").split("#")[1]; + let inputField = this.getInputElement(fieldNum); + if (target.getAttribute("disabled") != "true" && + !inputField.attendee.isOrganizer) { + const cutypeCycle = ["INDIVIDUAL", "GROUP", "RESOURCE", "ROOM"]; + + let nextValue = cycle(cutypeCycle, target.getAttribute("cutype")); + target.setAttribute("cutype", nextValue); + this.updateTooltip(target); + } + } else if (this.mIsReadOnly || this.mIsInvitation || target == null || + (target.localName != "listboxbody" && + target.localName != "listcell" && + target.localName != "listitem")) { + // These are cases where we don't want to append a new row, keep + // them here so we can put the rest in the else case. + } else { + let lastInput = this.getInputElement(this.mMaxAttendees); + if (lastInput && lastInput.value) { + this.appendNewRow(true); + } + } + ]]></handler> + + <handler event="popupshown"><![CDATA[ + this.mPopupOpen = true; + ]]></handler> + + <handler event="popuphidden"><![CDATA[ + this.mPopupOpen = false; + ]]></handler> + + <handler event="keydown"><![CDATA[ + if (this.mIsReadOnly || this.mIsInvitation) { + return; + } + if (event.originalTarget.localName == "input") { + switch (event.keyCode) { + case KeyEvent.DOM_VK_DELETE: + case KeyEvent.DOM_VK_BACK_SPACE: { + let curRowId = this.getRowByInputElement(event.originalTarget); + let allSelected = (event.originalTarget.textLength == + event.originalTarget.selectionEnd - + event.originalTarget.selectionStart); + + if (!event.originalTarget.value || + event.originalTarget.textLength < 2 || + allSelected) { + // if the user selected the entire attendee string, only one character was + // left or the row was already empty before hitting the key, we remove the + // entire row to assure the attendee is deleted + this.deleteHit(event.originalTarget); + + // if the last row was removed, we append an empty one which has the focus + // to enable adding a new attendee directly with freebusy information cleared + let targetRowId = (event.keyCode == KeyEvent.DOM_VK_BACK_SPACE && curRowId > 2) + ? curRowId - 1 : curRowId; + if (this.mMaxAttendees == 1) { + this.appendNewRow(true); + } else { + this.setFocus(targetRowId); + } + + // set cursor to begin or end of focused input box based on deletion direction + let cPos = 0; + let input = document.getAnonymousElementByAttribute(this.getListItem(targetRowId), + "anonid", "input"); + if (targetRowId != curRowId) { + cPos = input.textLength; + } + input.setSelectionRange(cPos, cPos); + } + + event.stopPropagation(); + break; + } + } + } + ]]></handler> + + <handler event="keypress" phase="capturing"><![CDATA[ + // In case we're currently showing the autocompletion popup + // don't care about keypress-events and let them go. Otherwise + // this event indicates the user wants to travel between + // the different attendees. In this case we set the focus + // appropriately and stop the event propagation. + if (this.mPopupOpen || this.mIsReadOnly || this.mIsInvitation) { + return; + } + if (event.originalTarget.localName == "input") { + switch (event.keyCode) { + case KeyEvent.DOM_VK_UP: + this.arrowHit(event.originalTarget, -1); + event.stopPropagation(); + break; + case KeyEvent.DOM_VK_DOWN: + this.arrowHit(event.originalTarget, 1); + event.stopPropagation(); + break; + case KeyEvent.DOM_VK_TAB: + this.arrowHit(event.originalTarget, event.shiftKey ? -1 : +1); + break; + } + } + ]]></handler> + </handlers> + </binding> + + <!-- the 'selection-bar' binding implements the vertical bar that provides + a visual indication for the time range the event is configured for. --> + <binding id="selection-bar"> + <content> + <xul:scrollbox anonid="scrollbox" width="0" orient="horizontal" flex="1"> + <xul:box class="selection-bar" anonid="selection-bar"> + <xul:box class="selection-bar-left" anonid="leftbox"/> + <xul:spacer class="selection-bar-spacer" flex="1"/> + <xul:box class="selection-bar-right" anonid="rightbox"/> + </xul:box> + </xul:scrollbox> + </content> + + <implementation> + <field name="mRange">0</field> + <field name="mStartHour">0</field> + <field name="mEndHour">24</field> + <field name="mContentWidth">0</field> + <field name="mHeaderHeight">0</field> + <field name="mRatio">0</field> + <field name="mBaseDate">null</field> + <field name="mStartDate">null</field> + <field name="mEndDate">null</field> + <field name="mMouseX">0</field> + <field name="mMouseY">0</field> + <field name="mDragState">0</field> + <field name="mMargin">0</field> + <field name="mWidth">0</field> + <field name="mForce24Hours">false</field> + <field name="mZoomFactor">100</field> + <!-- constant that defines at which ratio an event is clipped, when moved or resized --> + <field name="mfClipRatio">0.7</field> + <field name="mLeftBox"/> + <field name="mRightBox"/> + <field name="mSelectionbar"/> + + <property name="zoomFactor"> + <getter><![CDATA[ + return this.mZoomFactor; + ]]></getter> + <setter><![CDATA[ + this.mZoomFactor = val; + return val; + ]]></setter> + </property> + + <property name="force24Hours"> + <getter><![CDATA[ + return this.mForce24Hours; + ]]></getter> + <setter><![CDATA[ + this.mForce24Hours = val; + this.initTimeRange(); + this.update(); + return val; + ]]></setter> + </property> + + <property name="ratio"> + <setter><![CDATA[ + this.mRatio = val; + this.update(); + return val; + ]]></setter> + </property> + + <constructor><![CDATA[ + Components.utils.import("resource://gre/modules/Preferences.jsm"); + + this.initTimeRange(); + + // The basedate is the date/time from which the display + // of the timebar starts. The range is the number of days + // we should be able to show. the start- and enddate + // is the time the event is scheduled for. + this.mRange = Number(this.getAttribute("range")); + this.mSelectionbar = + document.getAnonymousElementByAttribute( + this, "anonid", "selection-bar"); + ]]></constructor> + + <property name="baseDate"> + <setter><![CDATA[ + // we need to convert the date/time in question in + // order to calculate with hours that are aligned + // with our timebar display. + let kDefaultTimezone = calendarDefaultTimezone(); + this.mBaseDate = val.getInTimezone(kDefaultTimezone); + this.mBaseDate.isDate = true; + this.mBaseDate.makeImmutable(); + return val; + ]]></setter> + </property> + + <property name="startDate"> + <setter><![CDATA[ + // currently we *always* set the basedate to be + // equal to the startdate. we'll most probably + // want to change this later. + this.baseDate = val; + // we need to convert the date/time in question in + // order to calculate with hours that are aligned + // with our timebar display. + let kDefaultTimezone = calendarDefaultTimezone(); + this.mStartDate = val.getInTimezone(kDefaultTimezone); + this.mStartDate.makeImmutable(); + return val; + ]]></setter> + <getter><![CDATA[ + return this.mStartDate; + ]]></getter> + </property> + + <property name="endDate"> + <setter><![CDATA[ + // we need to convert the date/time in question in + // order to calculate with hours that are aligned + // with our timebar display. + let kDefaultTimezone = calendarDefaultTimezone(); + this.mEndDate = val.getInTimezone(kDefaultTimezone); + if (this.mEndDate.isDate) { + this.mEndDate.day += 1; + } + this.mEndDate.makeImmutable(); + return val; + ]]></setter> + <getter><![CDATA[ + return this.mEndDate; + ]]></getter> + </property> + + <property name="leftdragWidth"> + <getter><![CDATA[ + if (!this.mLeftBox) { + this.mLeftBox = + document.getAnonymousElementByAttribute( + this, "anonid", "leftbox"); + } + return this.mLeftBox.boxObject.width; + ]]></getter> + </property> + <property name="rightdragWidth"> + <getter><![CDATA[ + if (!this.mRightBox) { + this.mRightBox = + document.getAnonymousElementByAttribute( + this, "anonid", "rightbox"); + } + return this.mRightBox.boxObject.width; + ]]></getter> + </property> + + <method name="init"> + <parameter name="width"/> + <parameter name="height"/> + <body><![CDATA[ + this.mContentWidth = width; + this.mHeaderHeight = height + 2; + this.mMargin = 0; + this.update(); + ]]></body> + </method> + + <!-- given some specific date this method calculates the + corrposonding offset in fractional hours --> + <method name="date2offset"> + <parameter name="date"/> + <body><![CDATA[ + let num_hours = this.mEndHour - this.mStartHour; + let diff = date.subtractDate(this.mBaseDate); + let offset = diff.days * num_hours; + let hours = (diff.hours - this.mStartHour) + (diff.minutes / 60.0); + if (hours < 0) { + hours = 0; + } + if (hours > num_hours) { + hours = num_hours; + } + offset += hours; + return offset; + ]]></body> + </method> + + <method name="update"> + <body><![CDATA[ + if (!this.mStartDate || !this.mEndDate) { + return; + } + + // Calculate the relation of startdate/basedate and enddate/startdate. + let offset = this.mStartDate.subtractDate(this.mBaseDate); + + // Calculate how much pixels a single hour and a single day take up. + let num_hours = this.mEndHour - this.mStartHour; + let hour_width = this.mContentWidth / num_hours; + + // Calculate the offset in fractional hours that corrospond + // to our start- and end-time. + let start_offset_in_hours = this.date2offset(this.mStartDate); + let end_offset_in_hours = this.date2offset(this.mEndDate); + let duration_in_hours = end_offset_in_hours - start_offset_in_hours; + + // Calculate width & margin for the selection bar based on the + // relation of startdate/basedate and enddate/startdate. + // This is a simple conversion from hours to pixels. + this.mWidth = duration_in_hours * hour_width; + let totaldragwidths = this.leftdragWidth + this.rightdragWidth; + if (this.mWidth < totaldragwidths) { + this.mWidth = totaldragwidths; + } + this.mMargin = start_offset_in_hours * hour_width; + + // Calculate the difference between content and container in pixels. + // The container is the window showing this control, the content is the + // total number of pixels the selection bar can theoretically take up. + let total_width = this.mContentWidth * this.mRange - this.parentNode.boxObject.width; + + // Calculate the current scroll offset. + offset = Math.floor(total_width * this.mRatio); + + // The final margin is the difference between the date-based margin + // and the scroll-based margin. + this.mMargin -= offset; + + // Set the styles based on the calculations above for the 'selection-bar'. + let style = "width: " + this.mWidth + + "px; margin-inline-start: " + this.mMargin + + "px; margin-top: " + this.mHeaderHeight + "px;"; + this.mSelectionbar.setAttribute("style", style); + + let event = document.createEvent("Events"); + event.initEvent("timechange", true, false); + event.startDate = this.mStartDate; + event.endDate = this.mEndDate.clone(); + if (event.endDate.isDate) { + event.endDate.day--; + } + event.endDate.makeImmutable(); + this.dispatchEvent(event); + ]]></body> + </method> + + <method name="setWidth"> + <parameter name="width"/> + <body><![CDATA[ + let scrollbox = + document.getAnonymousElementByAttribute( + this, "anonid", "scrollbox"); + scrollbox.setAttribute("width", width); + ]]></body> + </method> + + <method name="initTimeRange"> + <body><![CDATA[ + if (this.force24Hours) { + this.mStartHour = 0; + this.mEndHour = 24; + } else { + this.mStartHour = Preferences.get("calendar.view.daystarthour", 8); + this.mEndHour = Preferences.get("calendar.view.dayendhour", 19); + } + ]]></body> + </method> + + <method name="moveTime"> + <parameter name="time"/> + <parameter name="delta"/> + <parameter name="doclip"/> + <body><![CDATA[ + let newTime = time.clone(); + let clip_minutes = 60 * this.zoomFactor / 100; + if (newTime.isDate) { + clip_minutes = 60 * 24; + } + let num_hours = this.mEndHour - this.mStartHour; + let hour_width = this.mContentWidth / num_hours; + let minutes_per_pixel = 60 / hour_width; + let minute_shift = minutes_per_pixel * delta; + let isClipped = Math.abs(minute_shift) >= (this.mfClipRatio * clip_minutes); + if (isClipped) { + if (delta > 0) { + if (time.isDate) { + newTime.day++; + } else { + if (doclip) { + newTime.minute -= newTime.minute % clip_minutes; + } + newTime.minute += clip_minutes; + } + } else if (delta < 0) { + if (time.isDate) { + newTime.day--; + } else { + if (doclip) { + newTime.minute -= newTime.minute % clip_minutes; + } + newTime.minute -= clip_minutes; + } + } + } + + if (!newTime.isDate) { + if (newTime.hour < this.mStartHour) { + newTime.hour = this.mEndHour - 1; + newTime.day--; + } + if (newTime.hour >= this.mEndHour) { + newTime.hour = this.mStartHour; + newTime.day++; + } + } + + return newTime; + ]]></body> + </method> + </implementation> + + <handlers> + <handler event="mousedown"><![CDATA[ + let element = event.target; + this.mMouseX = event.screenX; + let mouseX = event.clientX - element.boxObject.x; + + if (mouseX >= this.mMargin) { + if (mouseX <= (this.mMargin + this.mWidth)) { + if (mouseX <= (this.mMargin + this.leftdragWidth)) { + // Move the startdate only... + window.setCursor("w-resize"); + this.mDragState = 2; + } else if (mouseX >= (this.mMargin + this.mWidth - (this.rightdragWidth))) { + // Move the enddate only.. + window.setCursor("e-resize"); + this.mDragState = 3; + } else { + // Move the startdate and the enddate + this.mDragState = 1; + window.setCursor("grab"); + } + } + } + ]]></handler> + + <handler event="mousemove"><![CDATA[ + let mouseX = event.screenX; + if (this.mDragState == 1) { + // Move the startdate and the enddate + let delta = mouseX - this.mMouseX; + let newStart = this.moveTime(this.mStartDate, delta, false); + if (newStart.compare(this.mStartDate) != 0) { + newEnd = this.moveTime(this.mEndDate, delta, false); + + // We need to adapt this date in case we're dealing with + // an all-day event. This is because setting 'endDate' will + // automatically add one day extra for all-day events. + if (newEnd.isDate) { + newEnd.day--; + } + + this.startDate = newStart; + this.endDate = newEnd; + this.mMouseX = mouseX; + this.update(); + } + } else if (this.mDragState == 2) { + // Move the startdate only... + let delta = event.screenX - this.mSelectionbar.boxObject.screenX; + let newStart = this.moveTime(this.mStartDate, delta, true); + if (newStart.compare(this.mEndDate) >= 0) { + if (this.mStartDate.isDate) { + return; + } + newStart = this.mEndDate; + } + if (newStart.compare(this.mStartDate) != 0) { + this.startDate = newStart; + this.update(); + } + } else if (this.mDragState == 3) { + // Move the enddate only.. + let delta = mouseX - (this.mSelectionbar.boxObject.screenX + + this.mSelectionbar.boxObject.width); + let newEnd = this.moveTime(this.mEndDate, delta, true); + if (newEnd.compare(this.mStartDate) < 0) { + newEnd = this.mStartDate; + } + if (newEnd.compare(this.mEndDate) != 0) { + // We need to adapt this date in case we're dealing with + // an all-day event. This is because setting 'endDate' will + // automatically add one day extra for all-day events. + if (newEnd.isDate) { + newEnd.day--; + } + + // Don't allow all-day events to be shorter than a single day. + if (!newEnd.isDate || (newEnd.compare(this.startDate) >= 0)) { + this.endDate = newEnd; + this.update(); + } + } + } + ]]></handler> + + <handler event="mouseup"><![CDATA[ + this.mDragState = 0; + window.setCursor("auto"); + ]]></handler> + </handlers> + </binding> +</bindings> diff --git a/calendar/base/content/dialogs/calendar-event-dialog-attendees.xul b/calendar/base/content/dialogs/calendar-event-dialog-attendees.xul new file mode 100644 index 000000000..de09754a6 --- /dev/null +++ b/calendar/base/content/dialogs/calendar-event-dialog-attendees.xul @@ -0,0 +1,198 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<?xml-stylesheet type="text/css" href="chrome://global/skin/global.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar-common/skin/calendar-attendees.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/calendar-event-dialog.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/content/calendar-event-dialog.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/content/datetimepickers/datetimepickers.css"?> + +<!DOCTYPE dialog [ + <!ENTITY % dtd1 SYSTEM "chrome://calendar/locale/calendar.dtd" > %dtd1; + <!ENTITY % dtd2 SYSTEM "chrome://calendar/locale/calendar-event-dialog.dtd" > %dtd2; +]> + +<dialog id="calendar-event-dialog-attendees-v2" + title="&invite.title.label;" + windowtype="Calendar:EventDialog:Attendees" + onload="onLoad()" + ondialogaccept="return onAccept();" + ondialogcancel="return onCancel();" + defaultButton="none" + persist="screenX screenY height width" + orient="vertical" + style="padding-top: 8px; padding-bottom: 10px; padding-inline-start: 8px; padding-inline-end: 10px;" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <!-- Javascript includes --> + <script type="application/javascript" src="chrome://calendar/content/calendar-event-dialog-attendees.js"/> + <script type="application/javascript" src="chrome://calendar/content/calendar-dialog-utils.js"/> + <script type="application/javascript" src="chrome://calendar/content/calUtils.js"/> + <script type="application/javascript" src="chrome://calendar/content/calendar-statusbar.js"/> + <script type="application/javascript" src="chrome://calendar/content/calendar-ui-utils.js"/> + + <hbox align="center" pack="end"> + <spacer flex="1"/> + <label value="&event.freebusy.suggest.slot;"/> + <button label="&event.freebusy.button.previous.slot;" + dir="normal" + class="left-icon" + id="previous-slot" + oncommand="onPreviousSlot();"/> + <button label="&event.freebusy.button.next.slot;" + dir="reverse" + class="right-icon" + id="next-slot" + oncommand="onNextSlot();"/> + <spacer style="width: 10em"/> + <label value="&event.freebusy.zoom;" control="zoom-menulist"/> + <toolbarbutton id="zoom-out-button" + class="zoom-out-icon" + oncommand="zoomWithButtons(true);"/> + <menulist id="zoom-menulist" + oncommand="setZoomFactor(this.value);" + persist="value"> + <menupopup> + <menuitem label="400%" value="25"/> + <menuitem label="200%" value="50"/> + <menuitem label="100%" value="100"/> + <menuitem label="50%" value="200"/> + <menuitem label="25%" value="400"/> + </menupopup> + </menulist> + <toolbarbutton id="zoom-in-button" + class="zoom-in-icon" + oncommand="zoomWithButtons(false);"/> + </hbox> + <hbox flex="1"> + <vbox id="attendees-container" flex="1" persist="width"> + <box class="attendee-spacer-top"/> + <attendees-list flex="1" id="attendees-list"/> + <box class="attendee-spacer-bottom"/> + </vbox> + <splitter id="splitter"/> + <vbox id="freebusy-container" persist="width"> + <stack flex="1"> + <vbox flex="1"> + <freebusy-timebar id="timebar" + range="16"/> + <freebusy-grid flex="1" + id="freebusy-grid" + range="16"/> + </vbox> + <selection-bar id="selection-bar" + range="16"/> + </stack> + <scrollbar orient="horizontal" + id="horizontal-scrollbar" + maxpos="100"/> + </vbox> + <vbox + id="vertical-scrollbar-box" + collapsed="true"> + <box class="attendee-spacer-top"/> + <scrollbar orient="vertical" + flex="1" + id="vertical-scrollbar" + maxpos="100"/> + <box class="attendee-spacer-bottom"/> + </vbox> + </hbox> + <hbox> + <grid flex="1"> + <columns> + <column/> <!-- role icon --> + <column flex="1"/><!-- role description --> + <column/> <!-- status color --> + <column flex="1"/><!-- status description --> + <column/> <!-- status color --> + <column flex="1"/><!-- status description --> + </columns> + <rows> + <row align="center"> + <image class="role-icon" role="REQ-PARTICIPANT"/> + <label value="&event.attendee.role.required;"/> + <image class="usertype-icon" cutype="INDIVIDUAL"/> + <label value="&event.attendee.usertype.individual;"/> + <box class="legend" status="FREE"/> + <label value="&event.freebusy.legend.free;"/> + </row> + <row align="center"> + <image class="role-icon" role="OPT-PARTICIPANT"/> + <label value="&event.attendee.role.optional;"/> + <image class="usertype-icon" cutype="GROUP"/> + <label value="&event.attendee.usertype.group;"/> + <box class="legend" status="BUSY_TENTATIVE"/> + <label value="&event.freebusy.legend.busy_tentative;"/> + </row> + <row align="center"> + <image class="role-icon" role="CHAIR"/> + <label value="&event.attendee.role.chair;"/> + <image class="usertype-icon" cutype="RESOURCE"/> + <label value="&event.attendee.usertype.resource;"/> + <box class="legend" status="BUSY"/> + <label value="&event.freebusy.legend.busy;"/> + </row> + <row align="center"> + <image class="role-icon" role="NON-PARTICIPANT"/> + <label value="&event.attendee.role.nonparticipant;"/> + <image class="usertype-icon" cutype="ROOM"/> + <label value="&event.attendee.usertype.room;"/> + <box class="legend" status="BUSY_UNAVAILABLE"/> + <label value="&event.freebusy.legend.busy_unavailable;"/> + </row> + <row align="center"> + <spacer/> + <spacer/> + <spacer/> + <spacer/> + <box class="legend" status="UNKNOWN"/> + <label value="&event.freebusy.legend.unknown;"/> + </row> + </rows> + </grid> + <vbox> + <grid> + <columns> + <column/> + <column flex="1"/> + </columns> + <rows> + <row align="center"> + <spacer/> + <checkbox id="all-day" + oncommand="changeAllDay();" + label="&event.alldayevent.label;"/> + </row> + <row align="center"> + <label value="&newevent.from.label;" control="event-starttime"/> + <datetimepicker id="event-starttime" + onchange="updateStartTime();"/> + <label id="timezone-starttime" + crop="right" + class="text-link" + flex="1" + collapsed="true" + hyperlink="true" + onclick="editStartTimezone()"/> + </row> + <row align="center"> + <label value="&newevent.to.label;" control="event-endtime"/> + <datetimepicker id="event-endtime" + onchange="updateEndTime();"/> + <label id="timezone-endtime" + crop="right" + class="text-link" + flex="1" + collapsed="true" + hyperlink="true" + onclick="editEndTimezone()"/> + </row> + </rows> + </grid> + </vbox> + </hbox> + <separator class="groove"/> +</dialog> diff --git a/calendar/base/content/dialogs/calendar-event-dialog-freebusy.xml b/calendar/base/content/dialogs/calendar-event-dialog-freebusy.xml new file mode 100644 index 000000000..94066fbdb --- /dev/null +++ b/calendar/base/content/dialogs/calendar-event-dialog-freebusy.xml @@ -0,0 +1,1599 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<!DOCTYPE dialog [ + <!ENTITY % dtd1 SYSTEM "chrome://calendar/locale/global.dtd" > %dtd1; + <!ENTITY % dtd2 SYSTEM "chrome://calendar/locale/calendar.dtd" > %dtd2; + <!ENTITY % dtd3 SYSTEM "chrome://calendar/locale/calendar-event-dialog.dtd" > %dtd3; +]> + +<bindings xmlns="http://www.mozilla.org/xbl" + xmlns:xbl="http://www.mozilla.org/xbl" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <!-- + ######################################################################## + ## scroll-container + ######################################################################## + --> + <binding id="scroll-container" extends="xul:box"> + <content> + <xul:box class="container" + xbl:inherits="flex" + anonid="container" + style="overflow: hidden; clip: rect(0px 0px 0px 0px);"> + <xul:box class="content" + xbl:inherits="flex,orient" + anonid="content"> + <children/> + </xul:box> + </xul:box> + </content> + + <implementation> + <property name="x"> + <getter><![CDATA[ + let content = + document.getAnonymousElementByAttribute( + this, "anonid", "content"); + let margin = getComputedStyle(content, null).marginInlineStart; + return -parseInt(margin.replace(/px/, ""), 10); + ]]></getter> + <setter><![CDATA[ + let content = + document.getAnonymousElementByAttribute( + this, "anonid", "content"); + content.setAttribute("style", + "margin-inline-start: " + (-val) + "px;"); + return val; + ]]></setter> + </property> + + <property name="y"> + <getter><![CDATA[ + let content = + document.getAnonymousElementByAttribute( + this, "anonid", "content"); + let margin = getComputedStyle(content, null).marginTop; + return -parseInt(margin.replace(/px/, ""), 10); + ]]></getter> + <setter><![CDATA[ + let content = + document.getAnonymousElementByAttribute( + this, "anonid", "content"); + content.setAttribute("style", + "margin-top: " + (-val) + "px;"); + return val; + ]]></setter> + </property> + </implementation> + </binding> + + <!-- + ######################################################################## + ## freebusy-day + ######################################################################## + --> + <binding id="freebusy-day" extends="xul:box"> + <content> + <xul:box orient="vertical"> + <xul:text class="freebusy-timebar-title" + style="font-weight:bold;" + anonid="day"/> + <xul:box equalsize="always" anonid="hours"/> + </xul:box> + </content> + + <implementation> + <field name="mDateFormatter">null</field> + <field name="mStartDate">null</field> + <field name="mEndDate">null</field> + <field name="mStartHour">0</field> + <field name="mEndHour">24</field> + <field name="mForce24Hours">false</field> + <field name="mZoomFactor">100</field> + + <property name="zoomFactor"> + <getter><![CDATA[ + return this.mZoomFactor; + ]]></getter> + <setter><![CDATA[ + this.mZoomFactor = val; + let hours = document.getAnonymousElementByAttribute(this, "anonid", "hours"); + removeChildren(hours); + return val; + ]]></setter> + </property> + + <property name="force24Hours"> + <getter><![CDATA[ + return this.mForce24Hours; + ]]></getter> + <setter><![CDATA[ + this.mForce24Hours = val; + this.initTimeRange(); + + let hours = document.getAnonymousElementByAttribute(this, "anonid", "hours"); + removeChildren(hours); + return val; + ]]></setter> + </property> + + <constructor><![CDATA[ + Components.utils.import("resource://gre/modules/Preferences.jsm"); + + this.initTimeRange(); + ]]></constructor> + + <method name="initTimeRange"> + <body><![CDATA[ + if (this.force24Hours) { + this.mStartHour = 0; + this.mEndHour = 24; + } else { + this.mStartHour = Preferences.get("calendar.view.daystarthour", 8); + this.mEndHour = Preferences.get("calendar.view.dayendhour", 19); + } + ]]></body> + </method> + + <property name="startDate"> + <setter><![CDATA[ + this.mStartDate = val.clone(); + this.mStartDate.minute = 0; + this.mStartDate.second = 0; + this.mStartDate.makeImmutable(); + return val; + ]]></setter> + </property> + + <property name="endDate"> + <setter><![CDATA[ + this.mEndDate = val.clone(); + this.mEndDate.makeImmutable(); + return val; + ]]></setter> + </property> + + <property name="dayHeight"> + <getter><![CDATA[ + let day = + document.getAnonymousElementByAttribute( + this, "anonid", "day"); + return day.boxObject.height; + ]]></getter> + </property> + + <property name="date"> + <setter><![CDATA[ + let date = val.clone(); + date.hour = 0; + date.minute = 0; + date.isDate = false; + + if (!this.mDateFormatter) { + this.mDateFormatter = + Components.classes[ + "@mozilla.org/calendar/datetime-formatter;1"] + .getService( + Components.interfaces.calIDateTimeFormatter); + } + + // First set the formatted date string as title + let day = + document.getAnonymousElementByAttribute( + this, "anonid", "day"); + let dateValue = this.mZoomFactor > 100 ? this.mDateFormatter.formatDateShort(date) + : this.mDateFormatter.formatDateLong(date); + day.setAttribute("value", dateValue); + + // Now create as many 'hour' elements as needed + let step_in_minutes = Math.floor(60 * this.mZoomFactor / 100); + let hours = + document.getAnonymousElementByAttribute( + this, "anonid", "hours"); + date.hour = this.mStartHour; + if (hours.childNodes.length <= 0) { + let template = createXULElement("text"); + template.className = "freebusy-timebar-hour"; + let count = Math.ceil( + (this.mEndHour - this.mStartHour) * 60 / step_in_minutes); + let remain = count; + let first = true; + while (remain--) { + let newNode = template.cloneNode(false); + let value = this.mDateFormatter.formatTime(date); + if (first) { + newNode.className += " first-in-day"; + first = false; + } + newNode.setAttribute("value", value); + hours.appendChild(newNode); + date.minute += step_in_minutes; + + if (remain == 0) { + newNode.className += " last-in-day"; + } + } + } + + return val; + ]]></setter> + </property> + </implementation> + </binding> + + <!-- + ######################################################################## + ## freebusy-timebar + ######################################################################## + --> + <binding id="freebusy-timebar" extends="xul:box"> + <content> + <xul:listbox anonid="listbox" + class="listbox-noborder" + seltype="multiple" + rows="1" + flex="1" + disabled="true"> + <xul:listcols> + <xul:listcol anonid="day-column" flex="1"/> + </xul:listcols> + <xul:listitem anonid="item" + class="freebusy-listitem" + allowevents="true"> + <xul:listcell > + <xul:scroll-container anonid="container"> + <xul:freebusy-day anonid="template"/> + </xul:scroll-container> + </xul:listcell> + </xul:listitem> + </xul:listbox> + </content> + + <implementation> + <field name="mNumDays">0</field> + <field name="mRange">0</field> + <field name="mStartDate">null</field> + <field name="mEndDate">null</field> + <field name="mDayOffset">0</field> + <field name="mScrollOffset">0</field> + <field name="mStartHour">0</field> + <field name="mEndHour">24</field> + <field name="mForce24Hours">false</field> + <field name="mZoomFactor">100</field> + + <property name="zoomFactor"> + <getter><![CDATA[ + return this.mZoomFactor; + ]]></getter> + <setter><![CDATA[ + this.mZoomFactor = val; + + let template = + document.getAnonymousElementByAttribute( + this, "anonid", "template"); + let parent = template.parentNode; + while (parent.childNodes.length > 1) { + parent.lastChild.remove(); + } + + template.force24Hours = this.mForce24Hours; + template.zoomFactor = this.mZoomFactor; + + this.onLoad(); + + return val; + ]]></setter> + </property> + + <property name="force24Hours"> + <getter><![CDATA[ + return this.mForce24Hours; + ]]></getter> + <setter><![CDATA[ + this.mForce24Hours = val; + this.initTimeRange(); + + let template = + document.getAnonymousElementByAttribute( + this, "anonid", "template"); + + let parent = template.parentNode; + while (parent.childNodes.length > 1) { + parent.lastChild.remove(); + } + + template.force24Hours = this.mForce24Hours; + template.zoomFactor = this.mZoomFactor; + + this.onLoad(); + + return val; + ]]></setter> + </property> + + <property name="contentWidth"> + <getter><![CDATA[ + // Calculate the difference between the first to day-elements, since + // the width of the head element does not specify the width we need + // due to an arbitrary margin value. + let template = + document.getAnonymousElementByAttribute( + this, "anonid", "template"); + return template.nextSibling.boxObject.x - template.boxObject.x; + ]]></getter> + </property> + + <property name="containerWidth"> + <getter><![CDATA[ + return this.parentNode.boxObject.width; + ]]></getter> + </property> + + <property name="startDate"> + <setter><![CDATA[ + this.mStartDate = val.clone(); + this.mStartDate.makeImmutable(); + return val; + ]]></setter> + <getter><![CDATA[ + return this.mStartDate; + ]]></getter> + </property> + + <property name="endDate"> + <setter><![CDATA[ + this.mEndDate = val.clone(); + this.mEndDate.makeImmutable(); + return val; + ]]></setter> + <getter><![CDATA[ + return this.mEndDate; + ]]></getter> + </property> + + <property name="dayOffset"> + <setter><![CDATA[ + this.mDayOffset = val; + let container = + document.getAnonymousElementByAttribute( + this, "anonid", "container"); + let date = this.mStartDate.clone(); + date.day += val; + let numChilds = container.childNodes.length; + for (let i = 0; i < numChilds; i++) { + let child = container.childNodes[i]; + child.date = date; + date.day++; + } + return val; + ]]></setter> + </property> + + <property name="step"> + <getter><![CDATA[ + // How much pixels spans a single day + let oneday = this.contentWidth; + + // The difference in pixels between the content and the container. + let shift = (oneday * this.mRange) - (this.containerWidth); + + // What we want to know is the scale of the total shift + // needed to step one block further. Since the content + // is divided into 'numHours' equal parts, we can simply state: + let numHours = this.mEndHour - this.mStartHour; + return (this.contentWidth) / (numHours * shift); + ]]></getter> + </property> + + <property name="scroll"> + <setter><![CDATA[ + this.mScrollOffset = val; + + // How much pixels spans a single day + let oneday = this.contentWidth; + + // The difference in pixels between the content and the container. + let shift = (oneday * this.mRange) - (this.containerWidth); + + // Now calculate the (positive) offset in pixels which the content + // needs to be shifted. This is a simple scaling in one dimension. + let offset = Math.floor(val * shift); + + // Now find out how much days this offset effectively skips. + // this is a simple division which always yields a positive integer value. + this.dayOffset = (offset - (offset % oneday)) / oneday; + + // Set the pixel offset for the content which will always need + // to be in the range [0 <= offset <= oneday]. + offset %= oneday; + + // Set the offset at the content node. + let container = + document.getAnonymousElementByAttribute( + this, "anonid", "container"); + container.x = offset; + return val; + ]]></setter> + <getter><![CDATA[ + return this.mScrollOffset; + ]]></getter> + </property> + + <constructor><![CDATA[ + Components.utils.import("resource://gre/modules/Preferences.jsm"); + + let args = window.arguments[0]; + let startTime = args.startTime; + let endTime = args.endTime; + + this.initTimeRange(); + + // The basedate is the date/time from which the display + // of the timebar starts. The range is the number of days + // we should be able to show. The start- and enddate + // is the time the event is scheduled for. + let kDefaultTimezone = calendarDefaultTimezone(); + this.startDate = startTime.getInTimezone(kDefaultTimezone); + this.endDate = endTime.getInTimezone(kDefaultTimezone); + this.mRange = Number(this.getAttribute("range")); + + window.addEventListener("load", this.onLoad.bind(this), true); + ]]></constructor> + + <method name="refresh"> + <body><![CDATA[ + let date = this.mStartDate.clone(); + let template = + document.getAnonymousElementByAttribute( + this, "anonid", "template"); + let parent = template.parentNode; + let numChilds = parent.childNodes.length; + for (let i = 0; i < numChilds; i++) { + let child = parent.childNodes[i]; + child.startDate = this.mStartDate; + child.endDate = this.mEndDate; + child.date = date; + date.day++; + } + let offset = this.mDayOffset; + this.dayOffset = offset; + ]]></body> + </method> + + <method name="onLoad"> + <body><![CDATA[ + this.initialize(); + let template = + document.getAnonymousElementByAttribute( + this, "anonid", "template"); + let event = document.createEvent("Events"); + event.initEvent("timebar", true, false); + event.details = this.contentWidth; + event.height = template.dayHeight; + this.dispatchEvent(event); + ]]></body> + </method> + + <method name="initialize"> + <body><![CDATA[ + let args = window.arguments[0]; + let startTime = args.startTime; + let endTime = args.endTime; + + let kDefaultTimezone = calendarDefaultTimezone(); + this.startDate = startTime.getInTimezone(kDefaultTimezone); + this.endDate = endTime.getInTimezone(kDefaultTimezone); + + // Set the number of 'freebusy-day'-elements + // we need to fill up the content box. + // TODO: hardcoded value + this.mNumDays = 4 * this.mZoomFactor / 100; + if (this.mNumDays < 2) { + this.mNumDays = 2; + } + + // Now create those elements and set their date property. + let date = this.mStartDate.clone(); + let template = + document.getAnonymousElementByAttribute( + this, "anonid", "template"); + template.force24Hours = this.mForce24Hours; + template.zoomFactor = this.mZoomFactor; + template.startDate = this.mStartDate; + template.endDate = this.mEndDate; + template.date = date; + let parent = template.parentNode; + if (parent.childNodes.length <= 1) { + let count = this.mNumDays - 1; + if (count > 0) { + for (let i = 0; i < count; i++) { + date.day++; + let newNode = template.cloneNode(false); + newNode.force24Hours = this.mForce24Hours; + newNode.zoomFactor = this.mZoomFactor; + newNode.startDate = this.mStartDate; + newNode.endDate = this.mEndDate; + newNode.date = date; + parent.appendChild(newNode); + } + } + } + ]]></body> + </method> + + <method name="initTimeRange"> + <body><![CDATA[ + if (this.force24Hours) { + this.mStartHour = 0; + this.mEndHour = 24; + } else { + this.mStartHour = Preferences.get("calendar.view.daystarthour", 8); + this.mEndHour = Preferences.get("calendar.view.dayendhour", 19); + } + ]]></body> + </method> + </implementation> + </binding> + + <!-- + ######################################################################## + ## freebusy-row + ######################################################################## + --> + <binding id="freebusy-row" extends="xul:box"> + <content> + <xul:scroll-container flex="1" anonid="container"> + <xul:box equalsize="always" anonid="hours"/> + </xul:scroll-container> + </content> + + <implementation> + <field name="mState">null</field> + <field name="mEntries">null</field> + <field name="mOffset">0</field> + <field name="mStartDate">null</field> + <field name="mEndDate">null</field> + <field name="mRange">0</field> + <field name="mStartHour">0</field> + <field name="mEndHour">24</field> + <field name="mForce24Hours">false</field> + <field name="mZoomFactor">100</field> + + <property name="zoomFactor"> + <getter><![CDATA[ + return this.mZoomFactor; + ]]></getter> + <setter><![CDATA[ + this.mZoomFactor = val; + + let hours = document.getAnonymousElementByAttribute(this, "anonid", "hours"); + removeChildren(hours); + this.onLoad(); + + return val; + ]]></setter> + </property> + + <property name="force24Hours"> + <getter><![CDATA[ + return this.mForce24Hours; + ]]></getter> + <setter><![CDATA[ + this.mForce24Hours = val; + this.initTimeRange(); + + let hours = document.getAnonymousElementByAttribute(this, "anonid", "hours"); + removeChildren(hours); + this.onLoad(); + + return val; + ]]></setter> + </property> + + <property name="startDate"> + <setter><![CDATA[ + this.mStartDate = val.clone(); + this.mStartDate.isDate = false; + this.mStartDate.makeImmutable(); + return val; + ]]></setter> + </property> + + <property name="endDate"> + <setter><![CDATA[ + this.mEndDate = val.clone(); + this.mEndDate.isDate = false; + this.mEndDate.makeImmutable(); + return val; + ]]></setter> + </property> + + <property name="numHours"> + <getter><![CDATA[ + let numHours = this.mEndHour - this.mStartHour; + return Math.ceil(numHours * 100 / this.mZoomFactor); + ]]></getter> + </property> + + <property name="contentWidth"> + <getter><![CDATA[ + let hours = + document.getAnonymousElementByAttribute( + this, "anonid", "hours"); + return (hours.childNodes[1].boxObject.x - + hours.childNodes[0].boxObject.x) * + this.numHours; + ]]></getter> + </property> + + <property name="containerWidth"> + <getter><![CDATA[ + // Step up the hierarchy until we reach the listbox + return this.parentNode + .parentNode + .parentNode + .parentNode.boxObject.width; + ]]></getter> + </property> + + <property name="dayOffset"> + <setter><![CDATA[ + this.mOffset = val * this.numHours; + this.showState(); + return val; + ]]></setter> + </property> + + <property name="documentSize"> + <getter><![CDATA[ + return this.contentWidth * this.mRange; + ]]></getter> + </property> + + <property name="scroll"> + <setter><![CDATA[ + // How much pixels spans a single day + let oneday = this.contentWidth; + if (oneday <= 0) { + return val; + } + + // The difference in pixels between the content and the container. + let shift = (oneday * this.mRange) - (this.containerWidth); + + // Now calculate the (positive) offset in pixels which the content + // needs to be shifted. This is a simple scaling in one dimension. + let offset = Math.floor(val * shift); + + // Now find out how much days this offset effectively skips. + // this is a simple division which always yields a positive integer value. + this.dayOffset = (offset - (offset % oneday)) / oneday; + + // Set the pixel offset for the content which will always need + // to be in the range [0 <= offset <= oneday]. + offset %= oneday; + + // Set the offset at the content node. + let container = + document.getAnonymousElementByAttribute( + this, "anonid", "container"); + container.x = offset; + return val; + ]]></setter> + </property> + + <constructor><![CDATA[ + Components.utils.import("resource://gre/modules/Preferences.jsm"); + + this.initTimeRange(); + this.mRange = Number(this.getAttribute("range")); + this.onLoad(); + ]]></constructor> + + <method name="onLoad"> + <body><![CDATA[ + let numHours = this.mEndHour - this.mStartHour; + this.mState = new Array(this.mRange * numHours); + for (let i = 0; i < this.mState.length; i++) { + this.mState[i] = Components.interfaces.calIFreeBusyInterval.UNKNOWN; + } + + let step_in_minutes = Math.floor(60 * this.mZoomFactor / 100); + let formatter = Components.classes[ + "@mozilla.org/calendar/datetime-formatter;1"] + .getService( + Components.interfaces.calIDateTimeFormatter); + let date = cal.jsDateToDateTime(new Date()); + date.hour = this.mStartHour; + date.minute = 0; + let hours = + document.getAnonymousElementByAttribute( + this, "anonid", "hours"); + if (hours.childNodes.length <= 0) { + let template = createXULElement("text"); + template.className = "freebusy-grid"; + // TODO: hardcoded value + let num_days = Math.max(2, 4 * this.mZoomFactor / 100); + let count = Math.ceil( + (this.mEndHour - this.mStartHour) * 60 / step_in_minutes); + let remain = count; + for (let day = 1; day <= num_days; day++) { + let first = true; + while (remain--) { + let newNode = template.cloneNode(false); + let value = formatter.formatTime(date); + if (first) { + newNode.className += " first-in-day"; + first = false; + } + + newNode.setAttribute("value", value); + hours.appendChild(newNode); + date.minute += step_in_minutes; + + if (remain == 0) { + newNode.className += " last-in-day"; + } + } + date.hour = this.mStartHour; + date.day++; + remain = count; + } + } + ]]></body> + </method> + + <method name="onFreeBusy"> + <parameter name="aEntries"/> + <body><![CDATA[ + // The argument denotes the requested freebusy intervals. + // We need to set our state array according to this + // result. After the state has been updated we call showState() + // which will map the entries to attributes on the xul elements. + if (aEntries) { + // Remember the free/busy array which is used to find a + // new time for an event. We store this array only if + // the provider returned a valid array. In any other case + // (temporarily clean the display) we keep the last know result. + this.mEntries = aEntries; + + let kDefaultTimezone = calendarDefaultTimezone(); + + let start = this.mStartDate.clone(); + start.hour = 0; + start.minute = 0; + start.second = 0; + start.timezone = kDefaultTimezone; + let end = start.clone(); + end.day += this.mRange; + end.timezone = kDefaultTimezone; + + // First of all set all state slots to 'free' + for (let i = 0; i < this.mState.length; i++) { + this.mState[i] = Components.interfaces.calIFreeBusyInterval.FREE; + } + + // Iterate all incoming freebusy entries + for (let entry of aEntries) { + let rangeStart = entry.interval.start.getInTimezone(kDefaultTimezone); + let rangeEnd = entry.interval.end.getInTimezone(kDefaultTimezone); + + if (rangeStart.compare(start) < 0) { + rangeStart = start.clone(); + } + if (rangeEnd.compare(end) > 0) { + rangeEnd = end.clone(); + } + + let rangeDuration = rangeEnd.subtractDate(rangeStart); + let rangeStartHour = rangeStart.hour; + let rangeEndHour = rangeStartHour + (rangeDuration.inSeconds / 3600); + + if ((rangeStartHour < this.mEndHour) && + (rangeEndHour >= this.mStartHour)) { + let dayingrid = start.clone(); + dayingrid.year = rangeStart.year; + dayingrid.month = rangeStart.month; + dayingrid.day = rangeStart.day; + dayingrid.getInTimezone(kDefaultTimezone); + + // Ok, this is an entry we're interested in. Find out + // which hours are actually occupied. + let offset = rangeStart.subtractDate(dayingrid); + + // Calculate how many days we're offset from the + // start of the grid. Eliminate hours in case + // we encounter the daylight-saving hop. + let dayoffset = dayingrid.subtractDate(start); + dayoffset.hours = 0; + + // Add both offsets to find the total offset. + // dayoffset -> offset in days from start of grid + // offset -> offset in hours from start of current day + offset.addDuration(dayoffset); + + let duration = rangeEnd.subtractDate(rangeStart); + let start_in_minutes = Math.floor(offset.inSeconds / 60); + let end_in_minutes = Math.ceil((duration.inSeconds / 60) + + (offset.inSeconds / 60)); + + let minute2offset = function(value, fNumHours, numHours, start_hour, zoomfactor) { + // 'value' is some integer in the interval [0, range * 24 * 60]. + // we need to map this offset into our array which + // holds elements for 'range' days with [start, end] hours each. + let minutes_per_day = 24 * 60; + let day = (value - (value % minutes_per_day)) / minutes_per_day; + let minute = Math.floor(value % minutes_per_day) - (start_hour * 60); + + minute = Math.max(0, minute); + + if (minute >= (numHours * 60)) { + minute = (numHours * 60) - 1; + } + // How to get from minutes to offset? + // 60 = 100%, 30 = 50%, 15 = 25%, etc. + let minutes_per_block = 60 * zoomfactor / 100; + + let block = Math.floor(minute / minutes_per_block); + + return Math.ceil(fNumHours) * day + block; + }; + + // Number of hours (fractional representation) + let calcNumHours = this.mEndHour - this.mStartHour; + let fNumHours = calcNumHours * 100 / this.mZoomFactor; + + let start_offset = + minute2offset(start_in_minutes, + fNumHours, + calcNumHours, + this.mStartHour, + this.mZoomFactor); + let end_offset = + minute2offset(end_in_minutes - 1, + fNumHours, + calcNumHours, + this.mStartHour, + this.mZoomFactor); + + // Set all affected state slots + for (let i = start_offset; i <= end_offset; i++) { + this.mState[i] = entry.freeBusyType; + } + } + } + } else { + // First of all set all state slots to 'unknown' + for (let i = 0; i < this.mState.length; i++) { + this.mState[i] = Components.interfaces.calIFreeBusyInterval.UNKNOWN; + } + } + + this.showState(); + ]]></body> + </method> + + <method name="showState"> + <body><![CDATA[ + let hours = + document.getAnonymousElementByAttribute( + this, "anonid", "hours"); + for (let i = 0; i < hours.childNodes.length; i++) { + let hour = hours.childNodes[i]; + switch (this.mState[i + this.mOffset]) { + case Components.interfaces.calIFreeBusyInterval.FREE: + hour.setAttribute("state", "free"); + break; + case Components.interfaces.calIFreeBusyInterval.BUSY: + hour.setAttribute("state", "busy"); + break; + case Components.interfaces.calIFreeBusyInterval.BUSY_TENTATIVE: + hour.setAttribute("state", "busy_tentative"); + break; + case Components.interfaces.calIFreeBusyInterval.BUSY_UNAVAILABLE: + hour.setAttribute("state", "busy_unavailable"); + break; + default: + hour.removeAttribute("state"); + } + } + ]]></body> + </method> + + <method name="nextSlot"> + <parameter name="aStartTime"/> + <parameter name="aEndTime"/> + <parameter name="allDay"/> + <body><![CDATA[ + let newTime = aStartTime.clone(); + let duration = aEndTime.subtractDate(aStartTime); + let newEndTime = newTime.clone(); + newEndTime.addDuration(duration); + + let kDefaultTimezone = calendarDefaultTimezone(); + + if (this.mEntries) { + for (let entry of this.mEntries) { + let rangeStart = + entry.interval.start.getInTimezone(kDefaultTimezone); + let rangeEnd = + entry.interval.end.getInTimezone(kDefaultTimezone); + + let isZeroLength = !newTime.compare(newEndTime); + if ((isZeroLength && + newTime.compare(rangeStart) >= 0 && + newTime.compare(rangeEnd) < 0) || + (!isZeroLength && + newTime.compare(rangeEnd) < 0 && + newEndTime.compare(rangeStart) > 0)) { + // Current range of event conflicts with another event. + // we need to find a new time for this event. A trivial approach + // is to set the new start-time of the event equal to the end-time + // of the conflict-range. All-day events need to be considered + // separately, in which case we skip to the next day. + newTime = rangeEnd.clone(); + if (allDay) { + if (!((newTime.hour == 0) && + (newTime.minute == 0) && + (newTime.second == 0))) { + newTime.day++; + newTime.hour = 0; + newTime.minute = 0; + newTime.second = 0; + } + } + newEndTime = newTime.clone(); + newEndTime.addDuration(duration); + } + } + } + + return newTime; + ]]></body> + </method> + + <method name="initTimeRange"> + <body><![CDATA[ + if (this.force24Hours) { + this.mStartHour = 0; + this.mEndHour = 24; + } else { + this.mStartHour = Preferences.get("calendar.view.daystarthour", 8); + this.mEndHour = Preferences.get("calendar.view.dayendhour", 19); + } + ]]></body> + </method> + </implementation> + </binding> + + <!-- ############################################################################# --> + <!-- 'freebusy-grid'-binding --> + <!-- ############################################################################# --> + + <!-- id's are evil, use anonid --> + <binding id="freebusy-grid"> + <content> + <xul:listbox anonid="listbox" + class="listbox-noborder" + seltype="multiple" + rows="-1" + disabled="true" + flex="1" + style="min-width: 50em; min-height: 30em"> + <xul:listcols> + <xul:listcol anonid="grid-column" flex="1"/> + </xul:listcols> + <xul:listitem anonid="item" + class="addressingWidgetItem" + allowevents="true"> + <xul:listcell class="addressingWidgetCell"> + <xul:freebusy-row id="attendeeCol4#1" + anonid="grid" + dirty="true" + xbl:inherits="range"/> + </xul:listcell> + </xul:listitem> + </xul:listbox> + </content> + + <implementation> + <field name="mContentHeight">0</field> + <field name="mRowHeight">0</field> + <field name="mNumColumns">0</field> + <field name="mMaxFreeBusy">0</field> + <field name="mPendingRequests">null</field> + <field name="mStartDate">null</field> + <field name="mEndDate">null</field> + <field name="mScrollOffset">0</field> + <field name="mRange">0</field> + <field name="mStartHour">0</field> + <field name="mEndHour">24</field> + <field name="mForce24Hours">false</field> + <field name="mZoomFactor">100</field> + + <property name="zoomFactor"> + <getter><![CDATA[ + return this.mZoomFactor; + ]]></getter> + <setter><![CDATA[ + this.mZoomFactor = val; + for (let i = 1; i <= this.mMaxFreeBusy; i++) { + let freebusy = this.getFreeBusyElement(i); + freebusy.zoomFactor = this.mZoomFactor; + } + this.forceRefresh(); + return val; + ]]></setter> + </property> + + <property name="force24Hours"> + <getter><![CDATA[ + return this.mForce24Hours; + ]]></getter> + <setter><![CDATA[ + this.mForce24Hours = val; + this.initTimeRange(); + for (let i = 1; i <= this.mMaxFreeBusy; i++) { + let freebusy = this.getFreeBusyElement(i); + freebusy.force24Hours = this.mForce24Hours; + } + return val; + ]]></setter> + </property> + + <property name="firstVisibleRow"> + <getter><![CDATA[ + let listbox = + document.getAnonymousElementByAttribute( + this, "anonid", "listbox"); + return listbox.getIndexOfFirstVisibleRow(); + ]]></getter> + <setter><![CDATA[ + let listbox = + document.getAnonymousElementByAttribute( + this, "anonid", "listbox"); + listbox.scrollToIndex(val); + return val; + ]]></setter> + </property> + + <property name="ratio"> + <setter><![CDATA[ + let listbox = + document.getAnonymousElementByAttribute( + this, "anonid", "listbox"); + let rowcount = listbox.getRowCount(); + listbox.scrollToIndex(Math.floor(rowcount * val)); + return val; + ]]></setter> + </property> + + <constructor><![CDATA[ + Components.utils.import("resource://gre/modules/Preferences.jsm"); + + this.initTimeRange(); + + this.mRange = Number(this.getAttribute("range")); + + this.mMaxFreeBusy = 0; + this.mPendingRequests = []; + + window.addEventListener("load", this.onLoad.bind(this), true); + window.addEventListener("unload", this.onUnload.bind(this), true); + ]]></constructor> + + <property name="startDate"> + <getter><![CDATA[ + return this.mStartDate; + ]]></getter> + <setter><![CDATA[ + this.mStartDate = val.clone(); + this.mStartDate.makeImmutable(); + for (let i = 1; i <= this.mMaxFreeBusy; i++) { + this.getFreeBusyElement(i).startDate = val; + } + return val; + ]]></setter> + </property> + + <property name="endDate"> + <getter><![CDATA[ + return this.mEndDate; + ]]></getter> + <setter><![CDATA[ + this.mEndDate = val.clone(); + this.mEndDate.makeImmutable(); + for (let i = 1; i <= this.mMaxFreeBusy; i++) { + this.getFreeBusyElement(i).endDate = val; + } + return val; + ]]></setter> + </property> + + <property name="documentSize"> + <getter><![CDATA[ + return this.getFreeBusyElement(1).documentSize; + ]]></getter> + </property> + + <method name="onLoad"> + <body><![CDATA[ + this.onInitialize(); + ]]></body> + </method> + + <method name="onUnload"> + <body><![CDATA[ + // Cancel pending free/busy requests + for (let request of this.mPendingRequests) { + request.cancel(null); + } + + this.mPendingRequests = []; + ]]></body> + </method> + + <method name="onInitialize"> + <body><![CDATA[ + let args = window.arguments[0]; + let startTime = args.startTime; + let endTime = args.endTime; + + let kDefaultTimezone = calendarDefaultTimezone(); + this.startDate = startTime.getInTimezone(kDefaultTimezone); + this.endDate = endTime.getInTimezone(kDefaultTimezone); + + let listbox = + document.getAnonymousElementByAttribute( + this, "anonid", "listbox"); + let template = + document.getAnonymousElementByAttribute( + this, "anonid", "item"); + this.appendNewRow(listbox, template, null); + template.remove(); + + this.updateFreeBusy(); + ]]></body> + </method> + + <method name="onChangeCalendar"> + <parameter name="calendar"/> + <body><![CDATA[ + ]]></body> + </method> + + <!-- appends a new empty row --> + <method name="appendNewRow"> + <parameter name="aParentNode"/> + <parameter name="aTemplateNode"/> + <parameter name="aReplaceNode"/> + <body><![CDATA[ + this.mMaxFreeBusy++; + let newNode = aTemplateNode.cloneNode(true); + if (aReplaceNode) { + aParentNode.replaceChild(newNode, aReplaceNode); + } else { + aParentNode.appendChild(newNode); + } + + let grid = + document.getAnonymousElementByAttribute( + newNode, "anonid", "grid"); + let rowNumber = this.mMaxFreeBusy; + if (rowNumber >= 0) { + grid.setAttribute("id", "attendeeCol4#" + rowNumber); + } + + // Propagate start/enddate to the new row. + grid.startDate = this.mStartDate; + grid.endDate = this.mEndDate; + + grid.force24Hours = this.mForce24Hours; + grid.zoomFactor = this.mZoomFactor; + + // We always clone the first row. The problem is that the first row + // could be focused. When we clone that row, we end up with a cloned + // XUL textbox that has a focused attribute set. Therefore we think + // we're focused and don't properly refocus. The best solution to this + // would be to clone a template row that didn't really have any presentation, + // rather than using the real visible first row of the listbox. + // For now we'll just put in a hack that ensures the focused attribute + // is never copied when the node is cloned. + if (grid.getAttribute("focused") != "") { + grid.removeAttribute("focused"); + } + ]]></body> + </method> + + <property name="scroll"> + <setter><![CDATA[ + this.mScrollOffset = val; + for (let i = 1; i <= this.mMaxFreeBusy; i++) { + this.getFreeBusyElement(i).scroll = val; + } + return val; + ]]></setter> + </property> + + <method name="onModify"> + <parameter name="event"/> + <body><![CDATA[ + // Add or remove rows depending on the number of items + // contained in the list passed as argument. + let list = event.details; + if (this.mMaxFreeBusy != list.length) { + let listbox = + document.getAnonymousElementByAttribute( + this, "anonid", "listbox"); + let template = + document.getAnonymousElementByAttribute( + this, "anonid", "item"); + while (this.mMaxFreeBusy < list.length) { + let nextDummy = this.getNextDummyRow(); + this.appendNewRow(listbox, template, nextDummy); + template = + document.getAnonymousElementByAttribute( + this, "anonid", "item"); + } + while (this.mMaxFreeBusy > list.length) { + this.deleteRow(this.mMaxFreeBusy); + } + } + + // Store the attributes in our grid rows. + for (let i = 1; i <= this.mMaxFreeBusy; i++) { + let freebusy = this.getFreeBusyElement(i); + freebusy.setAttribute("calid", list[i - 1].calid); + freebusy.removeAttribute("dirty"); + if (list[i - 1].dirty) { + freebusy.setAttribute("dirty", "true"); + } + } + + // Align all rows + this.scroll = this.mScrollOffset; + + this.updateFreeBusy(); + ]]></body> + </method> + + <!-- updateFreeBusy(), implementation of the core functionality of this binding --> + <method name="updateFreeBusy"> + <body><![CDATA[ + let fbService = getFreeBusyService(); + for (let i = 1; i <= this.mMaxFreeBusy; i++) { + // Retrieve the string from the appropriate row + let freebusy = this.getFreeBusyElement(i); + if (freebusy.hasAttribute("dirty")) { + freebusy.removeAttribute("dirty"); + let calid = freebusy.getAttribute("calid"); + if (calid && calid.length > 0) { + // Define the datetime range we would like to ask for. + let start = this.mStartDate.clone(); + start.hour = 0; + start.minute = 0; + start.second = 0; + let end = start.clone(); + end.day += this.mRange; + // Update with 'no data available' until response will be received + freebusy.onFreeBusy(null); + try { + let listener = new calFreeBusyListener(freebusy, this); + let request = fbService.getFreeBusyIntervals(calid, + start, + end, + Components.interfaces.calIFreeBusyInterval.BUSY_ALL, + listener); + if (request && request.isPending) { + this.mPendingRequests.push(request); + } + } catch (ex) { + Components.utils.reportError(ex); + } + } + } + } + ]]></body> + </method> + + <method name="nextSlot"> + <body><![CDATA[ + let startTime = this.mStartDate.clone(); + let endTime = this.mEndDate.clone(); + + startTime.isDate = false; + endTime.isDate = false; + + let allDay = this.mStartDate.isDate; + let step_in_minutes = Math.floor(60 * this.zoomFactor / 100); + if (allDay) { + step_in_minutes = 60 * 24; + endTime.day++; + } + + let duration = endTime.subtractDate(startTime); + + startTime.minute += step_in_minutes; + + if (startTime.hour < this.mStartHour) { + startTime.hour = this.mStartHour; + startTime.minute = 0; + } + + endTime = startTime.clone(); + endTime.addDuration(duration); + if (endTime.hour > this.mEndHour) { + startTime.day++; + startTime.hour = this.mStartHour; + startTime.minute = 0; + endTime = startTime.clone(); + endTime.addDuration(duration); + if (endTime.hour > this.mEndHour) { + return this.mStartDate.clone(); + } + } + + // Now iterate all freebusy-rows and ask each one + // if it wants to modify the suggested time slot. + // we keep iterating the rows until all of them + // are happy with it. + let recheck; + do { + recheck = false; + + for (let i = 1; i <= this.mMaxFreeBusy; i++) { + let row = this.getFreeBusyElement(i); + let newTime = row.nextSlot(startTime, endTime, allDay); + if (newTime) { + if (newTime.compare(startTime) != 0) { + startTime = newTime; + + if (startTime.hour < this.mStartHour) { + startTime.hour = this.mStartHour; + startTime.minute = 0; + } + + endTime = startTime.clone(); + endTime.addDuration(duration); + + if (endTime.hour > this.mEndHour) { + startTime.day++; + startTime.hour = this.mStartHour; + startTime.minute = 0; + endTime = startTime.clone(); + endTime.addDuration(duration); + } + + recheck = true; + } + } else { + // A new slot could not be found + // and the given time was also invalid. + return this.mStartDate.clone(); + } + } + } while (recheck); + + // Return the unmodifed startdate of the item + // in case no possible match was found. + if (startTime.compare(this.mStartDate) == 0) { + return this.mStartDate.clone(); + } + + // Special case for allday events - if the original + // datetime was indeed a date we need to carry this + // state over to the calculated datetime. + if (this.mStartDate.isDate) { + startTime.isDate = true; + } + + // In case the new starttime happens to be scheduled + // on a different day, we also need to update the + // complete freebusy informations and appropriate + // underlying arrays holding the informaion. + if (this.mStartDate.day != startTime.day) { + for (let i = 1; i <= this.mMaxFreeBusy; i++) { + let fbelem = this.getFreeBusyElement(i); + fbelem.setAttribute("dirty", "true"); + } + this.updateFreeBusy(); + } + + // Return the new starttime of the item. + return startTime; + ]]></body> + </method> + + <method name="forceRefresh"> + <body><![CDATA[ + for (let i = 1; i <= this.mMaxFreeBusy; i++) { + let row = this.getFreeBusyElement(i); + row.setAttribute("dirty", "true"); + } + this.updateFreeBusy(); + ]]></body> + </method> + + <!-- This method returns the <xul:listitem> at row numer 'aRow' --> + <method name="getListItem"> + <parameter name="aRow"/> + <body><![CDATA[ + let listbox = + document.getAnonymousElementByAttribute( + this, "anonid", "listbox"); + if (listbox && aRow > 0) { + let listitems = listbox.getElementsByTagNameNS("*", "listitem"); + if (listitems && listitems.length >= aRow) { + return listitems[aRow - 1]; + } + } + return 0; + ]]></body> + </method> + + <method name="getFreeBusyElement"> + <parameter name="aRow"/> + <body><![CDATA[ + return document.getAnonymousElementByAttribute(this, "id", + "attendeeCol4#" + aRow); + ]]></body> + </method> + + <method name="deleteRow"> + <parameter name="aRow"/> + <body><![CDATA[ + // Reset id's in order to not break the sequence + let max = this.mMaxFreeBusy; + this.removeRow(aRow); + let numberOfCols = this.numColumns; + for (let row = aRow + 1; row <= max; row++) { + for (let col = 1; col <= numberOfCols; col++) { + let colID = "attendeeCol" + col + "#" + row; + let elem = document.getAnonymousElementByAttribute(this, "id", colID); + if (elem) { + elem.setAttribute( + "id", + "attendeeCol" + (col) + "#" + (row - 1)); + } + } + } + ]]></body> + </method> + + <method name="removeRow"> + <parameter name="aRow"/> + <body><![CDATA[ + this.getListItem(aRow).remove(); + this.fitDummyRows(); + this.mMaxFreeBusy--; + ]]></body> + </method> + + <!-- gets the next row from the top down --> + <method name="getNextDummyRow"> + <body><![CDATA[ + let listbox = + document.getAnonymousElementByAttribute( + this, "anonid", "listbox"); + let kids = listbox.childNodes; + for (let i = 0; i < kids.length; ++i) { + if (kids[i].hasAttribute("_isDummyRow")) { + return kids[i]; + } + } + return null; + ]]></body> + </method> + + <method name="fitDummyRows"> + <body><![CDATA[ + setTimeout(() => { + this.calcContentHeight(); + this.createOrRemoveDummyRows(); + }, 0); + ]]></body> + </method> + + <method name="calcContentHeight"> + <body><![CDATA[ + let listbox = + document.getAnonymousElementByAttribute( + this, "anonid", "listbox"); + let items = listbox.getElementsByTagNameNS("*", "listitem"); + this.mContentHeight = 0; + if (items.length > 0) { + let i = 0; + do { + this.mRowHeight = items[i].boxObject.height; + ++i; + } while (i < items.length && !this.mRowHeight); + this.mContentHeight = this.mRowHeight * items.length; + } + ]]></body> + </method> + + <method name="createOrRemoveDummyRows"> + <body><![CDATA[ + let listbox = + document.getAnonymousElementByAttribute( + this, "anonid", "listbox"); + let listboxHeight = listbox.boxObject.height; + + // Remove rows to remove scrollbar + let kids = listbox.childNodes; + for (let i = kids.length - 1; this.mContentHeight > listboxHeight && i >= 0; --i) { + if (kids[i].hasAttribute("_isDummyRow")) { + this.mContentHeight -= this.mRowHeight; + kids[i].remove(); + } + } + + // Add rows to fill space + if (this.mRowHeight) { + while (this.mContentHeight + this.mRowHeight < listboxHeight) { + this.createDummyItem(listbox); + this.mContentHeight += this.mRowHeight; + } + } + ]]></body> + </method> + + <method name="createDummyCell"> + <parameter name="aParent"/> + <body><![CDATA[ + let cell = document.createElement("listcell"); + cell.setAttribute("class", "addressingWidgetCell dummy-row-cell"); + if (aParent) { + aParent.appendChild(cell); + } + return cell; + ]]></body> + </method> + + <method name="createDummyItem"> + <parameter name="aParent"/> + <body><![CDATA[ + let titem = document.createElement("listitem"); + titem.setAttribute("_isDummyRow", "true"); + titem.setAttribute("class", "dummy-row"); + for (let i = this.numColumns; i > 0; i--) { + this.createDummyCell(titem); + } + if (aParent) { + aParent.appendChild(titem); + } + return titem; + ]]></body> + </method> + + <property name="numColumns"> + <getter><![CDATA[ + if (!this.mNumColumns) { + let listbox = + document.getAnonymousElementByAttribute( + this, "anonid", "listbox"); + let listCols = listbox.getElementsByTagNameNS("*", "listcol"); + this.mNumColumns = listCols.length || 1; + } + return this.mNumColumns; + ]]></getter> + </property> + + <method name="initTimeRange"> + <body><![CDATA[ + if (this.force24Hours) { + this.mStartHour = 0; + this.mEndHour = 24; + } else { + this.mStartHour = Preferences.get("calendar.view.daystarthour", 8); + this.mEndHour = Preferences.get("calendar.view.dayendhour", 19); + } + ]]></body> + </method> + </implementation> + </binding> +</bindings> diff --git a/calendar/base/content/dialogs/calendar-event-dialog-recurrence-preview.xml b/calendar/base/content/dialogs/calendar-event-dialog-recurrence-preview.xml new file mode 100644 index 000000000..e5138544b --- /dev/null +++ b/calendar/base/content/dialogs/calendar-event-dialog-recurrence-preview.xml @@ -0,0 +1,245 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<bindings xmlns="http://www.mozilla.org/xbl" + xmlns:xbl="http://www.mozilla.org/xbl" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <binding id="recurrence-preview" extends="xul:box"> + <resources> + <stylesheet src="chrome://calendar/content/widgets/calendar-widget-bindings.css"/> + </resources> + <content> + <xul:box flex="1" style="overflow: hidden;"> + <xul:grid flex="1" anonid="mainbox"> + <xul:columns> + <xul:column anonid="column"/> + <xul:column flex="1"/> + </xul:columns> + <xul:rows> + <xul:row anonid="row"> + <xul:minimonth anonid="minimonth" readonly="true"/> + <xul:minimonth anonid="minimonth" readonly="true"/> + <xul:minimonth anonid="minimonth" readonly="true"/> + <xul:spacer/> + </xul:row> + </xul:rows> + </xul:grid> + </xul:box> + </content> + + <implementation> + <field name="mRecurrenceInfo">null</field> + <field name="mResizeHandler">null</field> + <field name="mDateTime">null</field> + + <constructor><![CDATA[ + this.mResizeHandler = this.onResize.bind(this); + window.addEventListener("resize", this.mResizeHandler, true); + ]]></constructor> + + <destructor><![CDATA[ + window.removeEventListener("resize", this.mResizeHandler, true); + ]]></destructor> + + <property name="dateTime"> + <setter><![CDATA[ + this.mDateTime = val.clone(); + return this.mDateTime; + ]]></setter> + <getter><![CDATA[ + if (this.mDateTime == null) { + this.mDateTime = now(); + } + return this.mDateTime; + ]]></getter> + </property> + <method name="onResize"> + <body><![CDATA[ + let minimonth = + document.getAnonymousElementByAttribute( + this, "anonid", "minimonth"); + + let row = + document.getAnonymousElementByAttribute( + this, "anonid", "row"); + let rows = row.parentNode; + + let contentWidth = minimonth.boxObject.width; + let containerWidth = + document.getAnonymousNodes(this)[0] + .boxObject.width; + + // Now find out how much elements can be displayed. + // this is a simple division which always yields a positive integer value. + let numHorizontal = + (containerWidth - + (containerWidth % contentWidth)) / + contentWidth; + + let contentHeight = minimonth.boxObject.height; + let containerHeight = + document.getAnonymousNodes(this)[0] + .boxObject.height; + + // Now find out how much elements can be displayed. + // this is a simple division which always yields a positive integer value. + let numVertical = + (containerHeight - + (containerHeight % contentHeight)) / + contentHeight; + numVertical = Math.max(1, numVertical); + + // Count the number of existing rows + let numRows = 0; + let rowIterator = row; + while (rowIterator) { + numRows++; + rowIterator = rowIterator.nextSibling; + } + + // Adjust rows + while (numRows < numVertical) { + let newNode = row.cloneNode(true); + rows.appendChild(newNode); + numRows++; + } + while (numRows > numVertical) { + rows.firstChild.remove(); + numRows--; + } + + // Adjust columns in the grid + let column = + document.getAnonymousElementByAttribute( + this, "anonid", "column"); + let columns = column.parentNode; + while ((columns.childNodes.length - 1) < numHorizontal) { + let newColumn = column.cloneNode(false); + columns.insertBefore(newColumn, column.nextSibling); + } + while ((columns.childNodes.length - 1) > numHorizontal) { + columns.firstChild.remove(); + } + + // Walk all rows and adjust column elements + row = document.getAnonymousElementByAttribute( + this, "anonid", "row"); + while (row) { + let firstChild = row.firstChild; + while ((row.childNodes.length - 1) < numHorizontal) { + let newNode = firstChild.cloneNode(true); + firstChild.parentNode.insertBefore(newNode, firstChild); + } + while ((row.childNodes.length - 1) > numHorizontal) { + row.firstChild.remove(); + } + row = row.nextSibling; + } + + this.updateContent(); + this.updatePreview(this.mRecurrenceInfo); + ]]></body> + </method> + + <method name="updateContent"> + <body><![CDATA[ + let date = cal.dateTimeToJsDate(this.dateTime); + let row = document.getAnonymousElementByAttribute( + this, "anonid", "row"); + while (row) { + let numChilds = row.childNodes.length - 1; + for (let i = 0; i < numChilds; i++) { + let minimonth = row.childNodes[i]; + minimonth.showMonth(date); + date.setMonth(date.getMonth() + 1); + } + row = row.nextSibling; + } + ]]></body> + </method> + + <method name="updatePreview"> + <parameter name="aRecurrenceInfo"/> + <body><![CDATA[ + this.mRecurrenceInfo = aRecurrenceInfo; + let start = this.dateTime.clone(); + start.day = 1; + start.hour = 0; + start.minute = 0; + start.second = 0; + let end = start.clone(); + end.month++; + + // the 'minimonth' controls are arranged in a + // grid, sorted by rows first -> iterate the rows that may exist. + let row = document.getAnonymousElementByAttribute(this, "anonid", "row"); + while (row) { + // now iterater all the child nodes of this row + // in order to visit each minimonth in turn. + let numChilds = row.childNodes.length - 1; + for (let i = 0; i < numChilds; i++) { + // we now have one of the minimonth controls while 'start' + // and 'end' are set to the interval this minimonth shows. + let minimonth = row.childNodes[i]; + minimonth.showMonth(cal.dateTimeToJsDate(start)); + if (aRecurrenceInfo) { + // retrieve an array of dates that represents all occurrences + // that fall into this time interval [start,end[. + // note: the following loop assumes that this array conains + // dates that are strictly monotonically increasing. + // should getOccurrenceDates() not enforce this assumption we + // need to fall back to some different algorithm. + let dates = aRecurrenceInfo.getOccurrenceDates(start, end, 0, {}); + + // now run throgh all days of this month and set the + // 'busy' attribute with respect to the occurrence array. + let index = 0; + let occurrence = null; + if (index < dates.length) { + occurrence = + dates[index++] + .getInTimezone(start.timezone); + } + let current = start.clone(); + while (current.compare(end) < 0) { + let box = minimonth.getBoxForDate(current); + if (box) { + if (occurrence && + occurrence.day == current.day && + occurrence.month == current.month && + occurrence.year == current.year) { + box.setAttribute("busy", 1); + if (index < dates.length) { + occurrence = + dates[index++] + .getInTimezone(start.timezone); + // take into account that the very next occurrence + // can happen at the same day as the previous one. + if (occurrence.day == current.day && + occurrence.month == current.month && + occurrence.year == current.year) { + continue; + } + } else { + occurrence = null; + } + } else { + box.removeAttribute("busy"); + } + } + current.day++; + } + } + start.month++; + end.month++; + } + row = row.nextSibling; + } + ]]></body> + </method> + </implementation> + </binding> +</bindings> diff --git a/calendar/base/content/dialogs/calendar-event-dialog-recurrence.js b/calendar/base/content/dialogs/calendar-event-dialog-recurrence.js new file mode 100644 index 000000000..6be40c577 --- /dev/null +++ b/calendar/base/content/dialogs/calendar-event-dialog-recurrence.js @@ -0,0 +1,804 @@ +/* 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 onLoad, onAccept, onCancel */ + +Components.utils.import("resource://calendar/modules/calRecurrenceUtils.jsm"); +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); + +var gIsReadOnly = false; +var gStartTime = null; +var gEndTime = null; +var gUntilDate = null; + +/** + * Sets up the recurrence dialog from the window arguments. Takes care of filling + * the dialog controls with the recurrence information for this window. + */ +function onLoad() { + changeWidgetsOrder(); + + let args = window.arguments[0]; + let item = args.calendarEvent; + let calendar = item.calendar; + let recinfo = args.recurrenceInfo; + + gStartTime = args.startTime; + gEndTime = args.endTime; + let preview = document.getElementById("recurrence-preview"); + preview.dateTime = gStartTime.getInTimezone(calendarDefaultTimezone()); + + onChangeCalendar(calendar); + + // Set starting value for 'repeat until' rule and highlight the start date. + let repeatDate = cal.dateTimeToJsDate(gStartTime.getInTimezone(cal.floating())); + setElementValue("repeat-until-date", repeatDate); + document.getElementById("repeat-until-date").extraDate = repeatDate; + + if (item.parentItem != item) { + item = item.parentItem; + } + let rule = null; + if (recinfo) { + // Split out rules and exceptions + try { + let rrules = splitRecurrenceRules(recinfo); + let rules = rrules[0]; + // Deal with the rules + if (rules.length > 0) { + // We only handle 1 rule currently + rule = cal.wrapInstance(rules[0], Components.interfaces.calIRecurrenceRule); + } + } catch (ex) { + Components.utils.reportError(ex); + } + } + if (!rule) { + rule = createRecurrenceRule(); + rule.type = "DAILY"; + rule.interval = 1; + rule.count = -1; + } + initializeControls(rule); + + // Update controls + updateRecurrenceDeck(); + + opener.setCursor("auto"); + self.focus(); +} + +/** + * Initialize the dialog controls according to the passed rule + * + * @param rule The recurrence rule to parse. + */ +function initializeControls(rule) { + function getOrdinalAndWeekdayOfRule(aByDayRuleComponent) { + return { + ordinal: (aByDayRuleComponent - (aByDayRuleComponent % 8)) / 8, + weekday: Math.abs(aByDayRuleComponent % 8) + }; + } + + function setControlsForByMonthDay_YearlyRule(aDate, aByMonthDay) { + if (aByMonthDay == -1) { + // The last day of the month. + document.getElementById("yearly-group").selectedIndex = 1; + setElementValue("yearly-ordinal", -1); + setElementValue("yearly-weekday", -1); + } else { + if (aByMonthDay < -1) { + // The UI doesn't manage negative days apart from -1 but we can + // display in the controls the day from the start of the month. + aByMonthDay += aDate.endOfMonth.day + 1; + } + document.getElementById("yearly-group").selectedIndex = 0; + setElementValue("yearly-days", aByMonthDay); + } + } + + function everyWeekDay(aByDay) { + // Checks if aByDay contains only values from 1 to 7 with any order. + let mask = aByDay.reduce((value, item) => value | (1 << item), 1); + return aByDay.length == 7 && mask == Math.pow(2, 8) - 1; + } + + switch (rule.type) { + case "DAILY": + document.getElementById("period-list").selectedIndex = 0; + setElementValue("daily-days", rule.interval); + break; + case "WEEKLY": + setElementValue("weekly-weeks", rule.interval); + document.getElementById("period-list").selectedIndex = 1; + break; + case "MONTHLY": + setElementValue("monthly-interval", rule.interval); + document.getElementById("period-list").selectedIndex = 2; + break; + case "YEARLY": + setElementValue("yearly-interval", rule.interval); + document.getElementById("period-list").selectedIndex = 3; + break; + default: + document.getElementById("period-list").selectedIndex = 0; + dump("unable to handle your rule type!\n"); + break; + } + + let byDayRuleComponent = rule.getComponent("BYDAY", {}); + let byMonthDayRuleComponent = rule.getComponent("BYMONTHDAY", {}); + let byMonthRuleComponent = rule.getComponent("BYMONTH", {}); + let kDefaultTimezone = calendarDefaultTimezone(); + let startDate = gStartTime.getInTimezone(kDefaultTimezone); + + // "DAILY" ruletype + // byDayRuleComponents may have been set priorily by "MONTHLY"- ruletypes + // where they have a different context- + // that's why we also query the current rule-type + if (byDayRuleComponent.length == 0 || rule.type != "DAILY") { + document.getElementById("daily-group").selectedIndex = 0; + } else { + document.getElementById("daily-group").selectedIndex = 1; + } + + // "WEEKLY" ruletype + if (byDayRuleComponent.length == 0 || rule.type != "WEEKLY") { + document.getElementById("daypicker-weekday").days = [startDate.weekday + 1]; + } else { + document.getElementById("daypicker-weekday").days = byDayRuleComponent; + } + + // "MONTHLY" ruletype + let ruleComponentsEmpty = (byDayRuleComponent.length == 0 && + byMonthDayRuleComponent.length == 0); + if (ruleComponentsEmpty || rule.type != "MONTHLY") { + document.getElementById("monthly-group").selectedIndex = 1; + document.getElementById("monthly-days").days = [startDate.day]; + let day = Math.floor((startDate.day - 1) / 7) + 1; + setElementValue("monthly-ordinal", day); + setElementValue("monthly-weekday", startDate.weekday + 1); + } else if (everyWeekDay(byDayRuleComponent)) { + // Every day of the month. + document.getElementById("monthly-group").selectedIndex = 0; + setElementValue("monthly-ordinal", 0); + setElementValue("monthly-weekday", -1); + } else if (byDayRuleComponent.length > 0) { + // One of the first five days or weekdays of the month. + document.getElementById("monthly-group").selectedIndex = 0; + let ruleInfo = getOrdinalAndWeekdayOfRule(byDayRuleComponent[0]); + setElementValue("monthly-ordinal", ruleInfo.ordinal); + setElementValue("monthly-weekday", ruleInfo.weekday); + } else if (byMonthDayRuleComponent.length == 1 && byMonthDayRuleComponent[0] == -1) { + // The last day of the month. + document.getElementById("monthly-group").selectedIndex = 0; + setElementValue("monthly-ordinal", byMonthDayRuleComponent[0]); + setElementValue("monthly-weekday", byMonthDayRuleComponent[0]); + } else if (byMonthDayRuleComponent.length > 0) { + document.getElementById("monthly-group").selectedIndex = 1; + document.getElementById("monthly-days").days = byMonthDayRuleComponent; + } + + // "YEARLY" ruletype + if (byMonthRuleComponent.length == 0 || rule.type != "YEARLY") { + setElementValue("yearly-month-rule", startDate.month + 1); + setElementValue("yearly-month-ordinal", startDate.month + 1); + if (byMonthDayRuleComponent.length > 0) { + setControlsForByMonthDay_YearlyRule(startDate, byMonthDayRuleComponent[0]); + } else { + setElementValue("yearly-days", startDate.day); + let ordinalDay = Math.floor((startDate.day - 1) / 7) + 1; + setElementValue("yearly-ordinal", ordinalDay); + setElementValue("yearly-weekday", startDate.weekday + 1); + } + } else { + setElementValue("yearly-month-rule", byMonthRuleComponent[0]); + setElementValue("yearly-month-ordinal", byMonthRuleComponent[0]); + if (byMonthDayRuleComponent.length > 0) { + let date = startDate.clone(); + date.month = byMonthRuleComponent[0] - 1; + setControlsForByMonthDay_YearlyRule(date, byMonthDayRuleComponent[0]); + } else if (byDayRuleComponent.length > 0) { + document.getElementById("yearly-group").selectedIndex = 1; + if (everyWeekDay(byDayRuleComponent)) { + // Every day of the month. + setElementValue("yearly-ordinal", 0); + setElementValue("yearly-weekday", -1); + } else { + let yearlyRuleInfo = getOrdinalAndWeekdayOfRule(byDayRuleComponent[0]); + setElementValue("yearly-ordinal", yearlyRuleInfo.ordinal); + setElementValue("yearly-weekday", yearlyRuleInfo.weekday); + } + } else if (byMonthRuleComponent.length > 0) { + document.getElementById("yearly-group").selectedIndex = 0; + setElementValue("yearly-days", startDate.day); + } + } + + /* load up the duration of the event radiogroup */ + if (rule.isByCount) { + if (rule.count == -1) { + setElementValue("recurrence-duration", "forever"); + } else { + setElementValue("recurrence-duration", "ntimes"); + setElementValue("repeat-ntimes-count", rule.count); + } + } else { + let untilDate = rule.untilDate; + if (untilDate) { + gUntilDate = untilDate.getInTimezone(gStartTime.timezone); // calIRecurrenceRule::untilDate is always UTC or floating + // Change the until date to start date if the rule has a forbidden + // value (earlier than the start date). + if (gUntilDate.compare(gStartTime) < 0) { + gUntilDate = gStartTime.clone(); + } + let repeatDate = cal.dateTimeToJsDate(gUntilDate.getInTimezone(cal.floating())); + setElementValue("recurrence-duration", "until"); + setElementValue("repeat-until-date", repeatDate); + } else { + setElementValue("recurrence-duration", "forever"); + } + } +} + +/** + * Save the recurrence information selected in the dialog back to the given + * item. + * + * @param item The item to save back to. + * @return The saved recurrence info. + */ +function onSave(item) { + // Always return 'null' if this item is an occurrence. + if (!item || item.parentItem != item) { + return null; + } + + // This works, but if we ever support more complex recurrence, + // e.g. recurrence for Martians, then we're going to want to + // not clone and just recreate the recurrenceInfo each time. + // The reason is that the order of items (rules/dates/datesets) + // matters, so we can't always just append at the end. This + // code here always inserts a rule first, because all our + // exceptions should come afterward. + let deckNumber = Number(getElementValue("period-list")); + + let args = window.arguments[0]; + let recurrenceInfo = args.recurrenceInfo; + if (recurrenceInfo) { + recurrenceInfo = recurrenceInfo.clone(); + let rrules = splitRecurrenceRules(recurrenceInfo); + if (rrules[0].length > 0) { + recurrenceInfo.deleteRecurrenceItem(rrules[0][0]); + } + recurrenceInfo.item = item; + } else { + recurrenceInfo = createRecurrenceInfo(item); + } + + let recRule = createRecurrenceRule(); + const ALL_WEEKDAYS = [2, 3, 4, 5, 6, 7, 1]; // The sequence MO,TU,WE,TH,FR,SA,SU. + switch (deckNumber) { + case 0: { + recRule.type = "DAILY"; + let dailyGroup = document.getElementById("daily-group"); + if (dailyGroup.selectedIndex == 0) { + let ndays = Math.max(1, Number(getElementValue("daily-days"))); + recRule.interval = ndays; + } else { + recRule.interval = 1; + let onDays = [2, 3, 4, 5, 6]; + recRule.setComponent("BYDAY", onDays.length, onDays); + } + break; + } + case 1: { + recRule.type = "WEEKLY"; + let ndays = Number(getElementValue("weekly-weeks")); + recRule.interval = ndays; + let onDays = document.getElementById("daypicker-weekday").days; + if (onDays.length > 0) { + recRule.setComponent("BYDAY", onDays.length, onDays); + } + break; + } + case 2: { + recRule.type = "MONTHLY"; + let monthInterval = Number(getElementValue("monthly-interval")); + recRule.interval = monthInterval; + let monthlyGroup = document.getElementById("monthly-group"); + if (monthlyGroup.selectedIndex == 0) { + let monthlyOrdinal = Number(getElementValue("monthly-ordinal")); + let monthlyDOW = Number(getElementValue("monthly-weekday")); + if (monthlyDOW < 0) { + if (monthlyOrdinal == 0) { + // Monthly rule "Every day of the month". + recRule.setComponent("BYDAY", 7, ALL_WEEKDAYS); + } else { + // One of the first five days or the last day of the month. + recRule.setComponent("BYMONTHDAY", 1, [monthlyOrdinal]); + } + } else { + let sign = monthlyOrdinal < 0 ? -1 : 1; + let onDays = [(Math.abs(monthlyOrdinal) * 8 + monthlyDOW) * sign]; + recRule.setComponent("BYDAY", onDays.length, onDays); + } + } else { + let monthlyDays = document.getElementById("monthly-days").days; + if (monthlyDays.length > 0) { + recRule.setComponent("BYMONTHDAY", monthlyDays.length, monthlyDays); + } + } + break; + } + case 3: { + recRule.type = "YEARLY"; + let yearInterval = Number(getElementValue("yearly-interval")); + recRule.interval = yearInterval; + let yearlyGroup = document.getElementById("yearly-group"); + if (yearlyGroup.selectedIndex == 0) { + let yearlyByMonth = [Number(getElementValue("yearly-month-ordinal"))]; + recRule.setComponent("BYMONTH", yearlyByMonth.length, yearlyByMonth); + let yearlyByDay = [Number(getElementValue("yearly-days"))]; + recRule.setComponent("BYMONTHDAY", yearlyByDay.length, yearlyByDay); + } else { + let yearlyByMonth = [Number(getElementValue("yearly-month-rule"))]; + recRule.setComponent("BYMONTH", yearlyByMonth.length, yearlyByMonth); + let yearlyOrdinal = Number(getElementValue("yearly-ordinal")); + let yearlyDOW = Number(getElementValue("yearly-weekday")); + if (yearlyDOW < 0) { + if (yearlyOrdinal == 0) { + // Yearly rule "Every day of a month". + recRule.setComponent("BYDAY", 7, ALL_WEEKDAYS); + } else { + // One of the first five days or the last of a month. + recRule.setComponent("BYMONTHDAY", 1, [yearlyOrdinal]); + } + } else { + let sign = yearlyOrdinal < 0 ? -1 : 1; + let onDays = [(Math.abs(yearlyOrdinal) * 8 + yearlyDOW) * sign]; + recRule.setComponent("BYDAY", onDays.length, onDays); + } + } + break; + } + } + + // Figure out how long this event is supposed to last + switch (document.getElementById("recurrence-duration").selectedItem.value) { + case "forever": { + recRule.count = -1; + break; + } + case "ntimes": { + recRule.count = Math.max(1, getElementValue("repeat-ntimes-count")); + break; + } + case "until": { + let untilDate = cal.jsDateToDateTime(getElementValue("repeat-until-date"), gStartTime.timezone); + untilDate.isDate = gStartTime.isDate; // enforce same value type as DTSTART + if (!gStartTime.isDate) { + // correct UNTIL to exactly match start date's hour, minute, second: + untilDate.hour = gStartTime.hour; + untilDate.minute = gStartTime.minute; + untilDate.second = gStartTime.second; + } + recRule.untilDate = untilDate; + break; + } + } + + if (recRule.interval < 1) { + return null; + } + + recurrenceInfo.insertRecurrenceItemAt(recRule, 0); + return recurrenceInfo; +} + +/** + * Handler function to be called when the accept button is pressed. + * + * @return Returns true if the window should be closed + */ +function onAccept() { + let args = window.arguments[0]; + let item = args.calendarEvent; + args.onOk(onSave(item)); + // Don't close the dialog if a warning must be showed. + return !checkUntilDate.warning; +} + +/** + * Handler function to be called when the Cancel button is pressed. + * + * @return Returns true if the window should be closed + */ +function onCancel() { + // Don't show any warning if the dialog must be closed. + checkUntilDate.warning = false; + return true; +} + +/** + * Handler function called when the calendar is changed (also for initial + * setup). + * + * XXX we don't change the calendar in this dialog, this function should be + * consolidated or renamed. + * + * @param calendar The calendar to use for setup. + */ +function onChangeCalendar(calendar) { + let args = window.arguments[0]; + let item = args.calendarEvent; + + // Set 'gIsReadOnly' if the calendar is read-only + gIsReadOnly = false; + if (calendar && calendar.readOnly) { + gIsReadOnly = true; + } + + // Disable or enable controls based on a set or rules + // - whether this item is a stand-alone item or an occurrence + // - whether or not this item is read-only + // - whether or not the state of the item allows recurrence rules + // - tasks without an entrydate are invalid + disableOrEnable(item); + + updateRecurrenceControls(); +} + +/** + * Disable or enable certain controls based on the given item: + * Uses the following attribute: + * + * - disable-on-occurrence + * - disable-on-readonly + * + * A task without a start time is also considered readonly. + * + * @param item The item to check. + */ +function disableOrEnable(item) { + if (item.parentItem != item) { + disableRecurrenceFields("disable-on-occurrence"); + } else if (gIsReadOnly) { + disableRecurrenceFields("disable-on-readonly"); + } else if (isToDo(item) && !gStartTime) { + disableRecurrenceFields("disable-on-readonly"); + } else { + enableRecurrenceFields("disable-on-readonly"); + } +} + +/** + * Disables all fields that have an attribute that matches the argument and is + * set to "true". + * + * @param aAttributeName The attribute to search for. + */ +function disableRecurrenceFields(aAttributeName) { + let disableElements = document.getElementsByAttribute(aAttributeName, "true"); + for (let i = 0; i < disableElements.length; i++) { + disableElements[i].setAttribute("disabled", "true"); + } +} + +/** + * Enables all fields that have an attribute that matches the argument and is + * set to "true". + * + * @param aAttributeName The attribute to search for. + */ +function enableRecurrenceFields(aAttributeName) { + let enableElements = document.getElementsByAttribute(aAttributeName, "true"); + for (let i = 0; i < enableElements.length; i++) { + enableElements[i].removeAttribute("disabled"); + } +} + +/** + * Split rules into negative and positive rules. + * + * XXX This function is duplicate from calendar-dialog-utils.js, which we may + * want to include in this dialog. + * + * @param recurrenceInfo An item's recurrence info to parse. + * @return An array with two elements: an array of positive + * rules and an array of negative rules. + */ +function splitRecurrenceRules(recurrenceInfo) { + let recItems = recurrenceInfo.getRecurrenceItems({}); + let rules = []; + let exceptions = []; + for (let recItem of recItems) { + if (recItem.isNegative) { + exceptions.push(recItem); + } else { + rules.push(recItem); + } + } + return [rules, exceptions]; +} + +/** + * Handler function to update the period-deck when an item from the period-list + * is selected. Also updates the controls on that deck. + */ +function updateRecurrenceDeck() { + document.getElementById("period-deck") + .selectedIndex = Number(getElementValue("period-list")); + updateRecurrenceControls(); +} + +/** + * Updates the controls regarding ranged controls (i.e repeat forever, repeat + * until, repeat n times...) + */ +function updateRecurrenceRange() { + let args = window.arguments[0]; + let item = args.calendarEvent; + if (item.parentItem != item || gIsReadOnly) { + return; + } + + let radioRangeForever = + document.getElementById("recurrence-range-forever"); + let radioRangeFor = + document.getElementById("recurrence-range-for"); + let radioRangeUntil = + document.getElementById("recurrence-range-until"); + let rangeTimesCount = + document.getElementById("repeat-ntimes-count"); + let rangeUntilDate = + document.getElementById("repeat-until-date"); + let rangeAppointmentsLabel = + document.getElementById("repeat-appointments-label"); + + radioRangeForever.removeAttribute("disabled"); + radioRangeFor.removeAttribute("disabled"); + radioRangeUntil.removeAttribute("disabled"); + rangeAppointmentsLabel.removeAttribute("disabled"); + + let durationSelection = document.getElementById("recurrence-duration") + .selectedItem.value; + + if (durationSelection == "ntimes") { + rangeTimesCount.removeAttribute("disabled"); + } else { + rangeTimesCount.setAttribute("disabled", "true"); + } + + if (durationSelection == "until") { + rangeUntilDate.removeAttribute("disabled"); + } else { + rangeUntilDate.setAttribute("disabled", "true"); + } +} + +/** + * Updates the recurrence preview calendars using the window's item. + */ +function updatePreview() { + let args = window.arguments[0]; + let item = args.calendarEvent; + if (item.parentItem != item) { + item = item.parentItem; + } + + // TODO: We should better start the whole dialog with a newly cloned item + // and always pump changes immediately into it. This would eliminate the + // need to break the encapsulation, as we do it here. But we need the item + // to contain the startdate in order to calculate the recurrence preview. + item = item.clone(); + let kDefaultTimezone = calendarDefaultTimezone(); + if (isEvent(item)) { + let startDate = gStartTime.getInTimezone(kDefaultTimezone); + let endDate = gEndTime.getInTimezone(kDefaultTimezone); + if (startDate.isDate) { + endDate.day--; + } + + item.startDate = startDate; + item.endDate = endDate; + } + if (isToDo(item)) { + let entryDate = gStartTime; + if (entryDate) { + entryDate = entryDate.getInTimezone(kDefaultTimezone); + } else { + item.recurrenceInfo = null; + } + item.entryDate = entryDate; + let dueDate = gEndTime; + if (dueDate) { + dueDate = dueDate.getInTimezone(kDefaultTimezone); + } + item.dueDate = dueDate; + } + + let recInfo = onSave(item); + let preview = document.getElementById("recurrence-preview"); + preview.updatePreview(recInfo); +} + +/** + * 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, shows a warning and prevents to close the + * dialog when the user enters a wrong until date. + */ +function checkUntilDate() { + let untilDate = cal.jsDateToDateTime(getElementValue("repeat-until-date"), gStartTime.timezone); + let startDate = gStartTime.clone(); + startDate.isDate = true; + if (untilDate.compare(startDate) < 0) { + let repeatDate = cal.dateTimeToJsDate((gUntilDate || gStartTime).getInTimezone(cal.floating())); + setElementValue("repeat-until-date", repeatDate); + checkUntilDate.warning = true; + let callback = function() { + // No warning when the dialog is being closed with the Cancel button. + if (!checkUntilDate.warning) { + return; + } + Services.prompt.alert(null, document.title, + cal.calGetString("calendar", "warningUntilDateBeforeStart")); + checkUntilDate.warning = false; + }; + setTimeout(callback, 1); + } else { + gUntilDate = untilDate; + updateRecurrenceControls(); + } +} + +/** + * Update all recurrence controls on the dialog. + */ +function updateRecurrenceControls() { + updateRecurrencePattern(); + updateRecurrenceRange(); + updatePreview(); +} + +/** + * Disables/enables controls related to the recurrence pattern. + * the status of the controls depends on which period entry is selected + * and which form of pattern rule is selected. + */ +function updateRecurrencePattern() { + let args = window.arguments[0]; + let item = args.calendarEvent; + if (item.parentItem != item || gIsReadOnly) { + return; + } + + switch (Number(getElementValue("period-list"))) { + // daily + case 0: { + let dailyGroup = document.getElementById("daily-group"); + let dailyDays = document.getElementById("daily-days"); + dailyDays.removeAttribute("disabled"); + if (dailyGroup.selectedIndex == 1) { + dailyDays.setAttribute("disabled", "true"); + } + break; + } + // weekly + case 1: { + break; + } + // monthly + case 2: { + let monthlyGroup = document.getElementById("monthly-group"); + let monthlyOrdinal = document.getElementById("monthly-ordinal"); + let monthlyWeekday = document.getElementById("monthly-weekday"); + let monthlyDays = document.getElementById("monthly-days"); + monthlyOrdinal.removeAttribute("disabled"); + monthlyWeekday.removeAttribute("disabled"); + monthlyDays.removeAttribute("disabled"); + if (monthlyGroup.selectedIndex == 0) { + monthlyDays.setAttribute("disabled", "true"); + } else { + monthlyOrdinal.setAttribute("disabled", "true"); + monthlyWeekday.setAttribute("disabled", "true"); + } + break; + } + // yearly + case 3: { + let yearlyGroup = document.getElementById("yearly-group"); + let yearlyDays = document.getElementById("yearly-days"); + let yearlyMonthOrdinal = document.getElementById("yearly-month-ordinal"); + let yearlyPeriodOfMonthLabel = document.getElementById("yearly-period-of-month-label"); + let yearlyOrdinal = document.getElementById("yearly-ordinal"); + let yearlyWeekday = document.getElementById("yearly-weekday"); + let yearlyMonthRule = document.getElementById("yearly-month-rule"); + let yearlyPeriodOfLabel = document.getElementById("yearly-period-of-label"); + yearlyDays.removeAttribute("disabled"); + yearlyMonthOrdinal.removeAttribute("disabled"); + yearlyOrdinal.removeAttribute("disabled"); + yearlyWeekday.removeAttribute("disabled"); + yearlyMonthRule.removeAttribute("disabled"); + yearlyPeriodOfLabel.removeAttribute("disabled"); + yearlyPeriodOfMonthLabel.removeAttribute("disabled"); + if (yearlyGroup.selectedIndex == 0) { + yearlyOrdinal.setAttribute("disabled", "true"); + yearlyWeekday.setAttribute("disabled", "true"); + yearlyMonthRule.setAttribute("disabled", "true"); + yearlyPeriodOfLabel.setAttribute("disabled", "true"); + } else { + yearlyDays.setAttribute("disabled", "true"); + yearlyMonthOrdinal.setAttribute("disabled", "true"); + yearlyPeriodOfMonthLabel.setAttribute("disabled", "true"); + } + break; + } + } +} + +/** + * This function changes the order for certain elements using a locale string. + * This is needed for some locales that expect a different wording order. + * + * @param aPropKey The locale property key to get the order from + * @param aPropParams An array of ids to be passed to the locale property. + * These should be the ids of the elements to change + * the order for. + */ +function changeOrderForElements(aPropKey, aPropParams) { + let localeOrder; + let parents = {}; + + for (let key in aPropParams) { + // Save original parents so that the nodes to reorder get appended to + // the correct parent nodes. + parents[key] = document.getElementById(aPropParams[key]).parentNode; + } + + try { + localeOrder = calGetString("calendar-event-dialog", + aPropKey, + aPropParams); + + localeOrder = localeOrder.split(" "); + } catch (ex) { + let msg = "The key " + aPropKey + " in calendar-event-dialog.prop" + + "erties has incorrect number of params. Expected " + + aPropParams.length + " params."; + Components.utils.reportError(msg + " " + ex); + return; + } + + // Add elements in the right order, removing them from their old parent + for (let i = 0; i < aPropParams.length; i++) { + let newEl = document.getElementById(localeOrder[i]); + if (newEl) { + parents[i].appendChild(newEl.parentNode.removeChild(newEl)); + } else { + cal.ERROR("Localization error, could not find node '" + localeOrder[i] + "'. Please have your localizer check the string '" + aPropKey + "'"); + } + } +} + +/** + * Change locale-specific widget order for Edit Recurrence window + */ +function changeWidgetsOrder() { + changeOrderForElements("monthlyOrder", + ["monthly-ordinal", + "monthly-weekday"]); + changeOrderForElements("yearlyOrder", + ["yearly-days", + "yearly-period-of-month-label", + "yearly-month-ordinal"]); + changeOrderForElements("yearlyOrder2", + ["yearly-ordinal", + "yearly-weekday", + "yearly-period-of-label", + "yearly-month-rule"]); +} diff --git a/calendar/base/content/dialogs/calendar-event-dialog-recurrence.xul b/calendar/base/content/dialogs/calendar-event-dialog-recurrence.xul new file mode 100644 index 000000000..ea8a942e0 --- /dev/null +++ b/calendar/base/content/dialogs/calendar-event-dialog-recurrence.xul @@ -0,0 +1,514 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<?xml-stylesheet type="text/css" href="chrome://global/skin/global.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/calendar-event-dialog.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/content/calendar-event-dialog.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/content/datetimepickers/datetimepickers.css"?> + +<!DOCTYPE dialog [ + <!ENTITY % dialogDTD SYSTEM "chrome://calendar/locale/calendar-event-dialog.dtd"> + %dialogDTD; +]> + +<dialog id="calendar-event-dialog-recurrence" + title="&recurrence.title.label;" + windowtype="Calendar:EventDialog:Recurrence" + onload="onLoad()" + ondialogaccept="return onAccept();" + ondialogcancel="return onCancel();" + persist="screenX screenY width height" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <!-- Javascript includes --> + <script type="application/javascript" src="chrome://calendar/content/calendar-event-dialog-recurrence.js"/> + <script type="application/javascript" src="chrome://calendar/content/calendar-dialog-utils.js"/> + <script type="application/javascript" src="chrome://calendar/content/calendar-ui-utils.js"/> + <script type="application/javascript" src="chrome://calendar/content/calUtils.js"/> + <script type="application/javascript" src="chrome://calendar/content/calendar-statusbar.js"/> + + <!-- recurrence pattern --> + <groupbox id="recurrence-pattern-groupbox"> + <caption id="recurrence-pattern-caption" + label="&event.recurrence.pattern.label;"/> + <grid id="recurrence-pattern-grid"> + <columns id="recurrence-pattern-columns"> + <column id="recurrence-pattern-description-column"/> + <column id="recurrence-pattern-controls-column"/> + </columns> + <rows id="recurrence-pattern-rows"> + <row id="recurrence-pattern-repeat-row" align="center"> + <label value="&event.recurrence.occurs.label;" + disable-on-readonly="true" + disable-on-occurrence="true" + control="period-list"/> + <menulist id="period-list" + oncommand="updateRecurrenceDeck();" + disable-on-readonly="true" + disable-on-occurrence="true"> + <menupopup id="period-list-menupopup"> + <menuitem id="period-list-day-menuitem" + label="&event.recurrence.day.label;" + value="0"/> + <menuitem id="period-list-week-menuitem" + label="&event.recurrence.week.label;" + value="1"/> + <menuitem id="period-list-month-menuitem" + label="&event.recurrence.month.label;" + value="2"/> + <menuitem id="period-list-year-menuitem" + label="&event.recurrence.year.label;" + value="3"/> + </menupopup> + </menulist> + </row> + <row id="recurrence-pattern-period-row" align="top"> + <spacer/> + <deck id="period-deck" oncommand="updateRecurrenceControls();"> + + <!-- Daily --> + <box id="period-deck-daily-box" + orient="vertical" + align="top"> + <radiogroup id="daily-group"> + <box id="daily-period-every-box" orient="horizontal" align="center"> + <radio id="daily-group-every-radio" + label="&event.recurrence.pattern.every.label;" + disable-on-readonly="true" + disable-on-occurrence="true" + selected="true"/> + <textbox id="daily-days" + type="number" + value="1" + min="1" + max="0x7FFF" + size="3" + onkeyup="updateRecurrenceControls();" + disable-on-readonly="true" + disable-on-occurrence="true"/> + <label id="daily-group-every-units-label" + value="&repeat.units.days.both;" + disable-on-readonly="true" + disable-on-occurrence="true"/> + <spacer id="daily-group-spacer" flex="1"/> + </box> + <radio id="daily-group-weekday-radio" + label="&event.recurrence.pattern.every.weekday.label;" + disable-on-readonly="true" + disable-on-occurrence="true"/> + </radiogroup> + </box> + <!-- Weekly --> + <vbox id="period-deck-weekly-box"> + <hbox id="weekly-period-every-box" align="center"> + <label id="weekly-period-every-label" + value="&event.recurrence.pattern.weekly.every.label;" + disable-on-readonly="true" + disable-on-occurrence="true" + control="weekly-weeks"/> + <textbox id="weekly-weeks" + type="number" + value="1" + min="1" + max="0x7FFF" + size="3" + onkeyup="updateRecurrenceControls();" + disable-on-readonly="true" + disable-on-occurrence="true"/> + <label id="weekly-period-units-label" + value="&repeat.units.weeks.both;" + disable-on-readonly="true" + disable-on-occurrence="true"/> + </hbox> + <hbox align="center"> + <label id="weekly-period-on-label" + value="&event.recurrence.on.label;" + disable-on-readonly="true" + disable-on-occurrence="true" + control="daypicker-weekday"/> + <daypicker-weekday id="daypicker-weekday" + flex="1" + disable-on-readonly="true" + disable-on-occurrence="true" + onselect="updateRecurrenceControls();"/> + </hbox> + </vbox> + + <!-- Monthly --> + <vbox id="period-deck-monthly-box"> + <hbox id="montly-period-every-box" align="center"> + <label id="monthly-period-every-label" + value="&event.recurrence.pattern.monthly.every.label;" + disable-on-readonly="true" + disable-on-occurrence="true" + control="monthly-interval"/> + <textbox id="monthly-interval" + type="number" + value="1" + min="1" + max="0x7FFF" + size="3" + onkeyup="updateRecurrenceControls();" + disable-on-readonly="true" + disable-on-occurrence="true"/> + <label id="monthly-period-units-label" + value="&repeat.units.months.both;" + disable-on-readonly="true" + disable-on-occurrence="true"/> + </hbox> + <radiogroup id="monthly-group"> + <box id="monthly-period-relative-date-box" + orient="horizontal" align="center"> + <radio id="montly-period-relative-date-radio" + selected="true" + disable-on-readonly="true" + disable-on-occurrence="true"/> + <menulist id="monthly-ordinal" + disable-on-readonly="true" + disable-on-occurrence="true"> + <menupopup id="montly-ordinal-menupopup"> + <menuitem id="monthly-ordinal-every-label" + label="&event.recurrence.monthly.every.label;" + value="0"/> + <menuitem id="monthly-ordinal-first-label" + label="&event.recurrence.monthly.first.label;" + value="1"/> + <menuitem id="monthly-ordinal-second-label" + label="&event.recurrence.monthly.second.label;" + value="2"/> + <menuitem id="monthly-ordinal-third-label" + label="&event.recurrence.monthly.third.label;" + value="3"/> + <menuitem id="monthly-ordinal-fourth-label" + label="&event.recurrence.monthly.fourth.label;" + value="4"/> + <menuitem id="monthly-ordinal-fifth-label" + label="&event.recurrence.monthly.fifth.label;" + value="5"/> + <menuitem id="monthly-ordinal-last-label" + label="&event.recurrence.monthly.last.label;" + value="-1"/> + </menupopup> + </menulist> + <menulist id="monthly-weekday" + disable-on-readonly="true" + disable-on-occurrence="true"> + <menupopup id="monthly-weekday-menupopup"> + <menuitem id="monthly-weekday-1" + label="&event.recurrence.pattern.monthly.week.1.label;" + value="1"/> + <menuitem id="monthly-weekday-2" + label="&event.recurrence.pattern.monthly.week.2.label;" + value="2"/> + <menuitem id="monthly-weekday-3" + label="&event.recurrence.pattern.monthly.week.3.label;" + value="3"/> + <menuitem id="monthly-weekday-4" + label="&event.recurrence.pattern.monthly.week.4.label;" + value="4"/> + <menuitem id="monthly-weekday-5" + label="&event.recurrence.pattern.monthly.week.5.label;" + value="5"/> + <menuitem id="monthly-weekday-6" + label="&event.recurrence.pattern.monthly.week.6.label;" + value="6"/> + <menuitem id="monthly-weekday-7" + label="&event.recurrence.pattern.monthly.week.7.label;" + value="7"/> + <menuitem id="monthly-weekday-dayofmonth" + label="&event.recurrence.repeat.dayofmonth.label;" + value="-1"/> + </menupopup> + </menulist> + </box> + <box id="monthly-period-specific-date-box" + orient="horizontal" + align="center"> + <radio id="montly-period-specific-date-radio" + label="&event.recurrence.repeat.recur.label;" + disable-on-readonly="true" + disable-on-occurrence="true"/> + <daypicker-monthday id="monthly-days" + onselect="updateRecurrenceControls();" + disable-on-readonly="true" + disable-on-occurrence="true"/> + </box> + </radiogroup> + </vbox> + + <!-- Yearly --> + <box id="period-deck-yearly-box" + orient="vertical" + align="top"> + <hbox id="yearly-period-every-box" align="center"> + <label id="yearly-period-every-label" + value="&event.recurrence.every.label;" + control="yearly-interval"/> + <textbox id="yearly-interval" + type="number" + value="1" + min="1" + max="0x7FFF" + size="3" + onkeyup="updateRecurrenceControls();" + disable-on-readonly="true" + disable-on-occurrence="true"/> + <label id="yearly-period-units-label" value="&repeat.units.years.both;"/> + </hbox> + <radiogroup id="yearly-group"> + <grid id="yearly-period-grid"> + <columns id="yearly-period-columns"> + <column id="yearly-period-radio-column"/> + <column id="yearly-period-controls-column"/> + </columns> + <rows id="yearly-period-rows"> + <row id="yearly-period-absolute-row" align="center"> + <radio id="yearly-period-absolute-radio" + label="&event.recurrence.pattern.yearly.every.month.label;" + selected="true" + disable-on-readonly="true" + disable-on-occurrence="true"/> + <box id="yearly-period-absolute-controls" + orient="horizontal" + align="center"> + <textbox id="yearly-days" + type="number" + value="1" + min="1" + size="3" + onkeyup="updateRecurrenceControls();" + disable-on-readonly="true" + disable-on-occurrence="true"/> + <label id="yearly-period-of-month-label" + value="&event.recurrence.pattern.yearly.of.label;" + control="yearly-month-ordinal"/> + <menulist id="yearly-month-ordinal" + disable-on-readonly="true" + disable-on-occurrence="true"> + <menupopup id="yearly-month-ordinal-menupopup"> + <menuitem id="yearly-month-ordinal-1" + label="&event.recurrence.pattern.yearly.month.1.label;" + value="1"/> + <menuitem id="yearly-month-ordinal-2" + label="&event.recurrence.pattern.yearly.month.2.label;" + value="2"/> + <menuitem id="yearly-month-ordinal-3" + label="&event.recurrence.pattern.yearly.month.3.label;" + value="3"/> + <menuitem id="yearly-month-ordinal-4" + label="&event.recurrence.pattern.yearly.month.4.label;" + value="4"/> + <menuitem id="yearly-month-ordinal-5" + label="&event.recurrence.pattern.yearly.month.5.label;" + value="5"/> + <menuitem id="yearly-month-ordinal-6" + label="&event.recurrence.pattern.yearly.month.6.label;" + value="6"/> + <menuitem id="yearly-month-ordinal-7" + label="&event.recurrence.pattern.yearly.month.7.label;" + value="7"/> + <menuitem id="yearly-month-ordinal-8" + label="&event.recurrence.pattern.yearly.month.8.label;" + value="8"/> + <menuitem id="yearly-month-ordinal-9" + label="&event.recurrence.pattern.yearly.month.9.label;" + value="9"/> + <menuitem id="yearly-month-ordinal-10" + label="&event.recurrence.pattern.yearly.month.10.label;" + value="10"/> + <menuitem id="yearly-month-ordinal-11" + label="&event.recurrence.pattern.yearly.month.11.label;" + value="11"/> + <menuitem id="yearly-month-ordinal-12" + label="&event.recurrence.pattern.yearly.month.12.label;" + value="12"/> + </menupopup> + </menulist> + </box> + </row> + <row id="yearly-period-relative-row" align="center"> + <radio id="yearly-period-relative-radio" + disable-on-readonly="true" + disable-on-occurrence="true"/> + <box id="yearly-period-relative-controls" + orient="horizontal" + align="center"> + <menulist id="yearly-ordinal" + disable-on-readonly="true" + disable-on-occurrence="true"> + <menupopup id="yearly-ordinal-menupopup"> + <menuitem id="yearly-ordinal-every" + label="&event.recurrence.yearly.every.label;" + value="0"/> + <menuitem id="yearly-ordinal-first" + label="&event.recurrence.yearly.first.label;" + value="1"/> + <menuitem id="yearly-ordinal-second" + label="&event.recurrence.yearly.second.label;" + value="2"/> + <menuitem id="yearly-ordinal-third" + label="&event.recurrence.yearly.third.label;" + value="3"/> + <menuitem id="yearly-ordinal-fourth" + label="&event.recurrence.yearly.fourth.label;" + value="4"/> + <menuitem id="yearly-ordinal-fifth" + label="&event.recurrence.yearly.fifth.label;" + value="5"/> + <menuitem id="yearly-ordinal-last" + label="&event.recurrence.yearly.last.label;" + value="-1"/> + </menupopup> + </menulist> + <menulist id="yearly-weekday" + disable-on-readonly="true" + disable-on-occurrence="true"> + <menupopup id="yearly-weekday-menupopup"> + <menuitem id="yearly-weekday-1" + label="&event.recurrence.pattern.yearly.week.1.label;" + value="1"/> + <menuitem id="yearly-weekday-2" + label="&event.recurrence.pattern.yearly.week.2.label;" + value="2"/> + <menuitem id="yearly-weekday-3" + label="&event.recurrence.pattern.yearly.week.3.label;" + value="3"/> + <menuitem id="yearly-weekday-4" + label="&event.recurrence.pattern.yearly.week.4.label;" + value="4"/> + <menuitem id="yearly-weekday-5" + label="&event.recurrence.pattern.yearly.week.5.label;" + value="5"/> + <menuitem id="yearly-weekday-6" + label="&event.recurrence.pattern.yearly.week.6.label;" + value="6"/> + <menuitem id="yearly-weekday-7" + label="&event.recurrence.pattern.yearly.week.7.label;" + value="7"/> + <menuitem id="yearly-weekday--1" + label="&event.recurrence.pattern.yearly.day.label;" + value="-1"/> + </menupopup> + </menulist> + </box> + </row> + <row id="yearly-period-monthname-row" align="center"> + <label id="yearly-period-of-label" + value="&event.recurrence.of.label;" + control="yearly-month-rule"/> + <menulist id="yearly-month-rule" + disable-on-readonly="true" + disable-on-occurrence="true"> + <menupopup id="yearly-month-rule-menupopup"> + <menuitem id="yearly-month-rule-1" + label="&event.recurrence.pattern.yearly.month2.1.label;" + value="1"/> + <menuitem id="yearly-month-rule-2" + label="&event.recurrence.pattern.yearly.month2.2.label;" + value="2"/> + <menuitem id="yearly-month-rule-3" + label="&event.recurrence.pattern.yearly.month2.3.label;" + value="3"/> + <menuitem id="yearly-month-rule-4" + label="&event.recurrence.pattern.yearly.month2.4.label;" + value="4"/> + <menuitem id="yearly-month-rule-5" + label="&event.recurrence.pattern.yearly.month2.5.label;" + value="5"/> + <menuitem id="yearly-month-rule-6" + label="&event.recurrence.pattern.yearly.month2.6.label;" + value="6"/> + <menuitem id="yearly-month-rule-7" + label="&event.recurrence.pattern.yearly.month2.7.label;" + value="7"/> + <menuitem id="yearly-month-rule-8" + label="&event.recurrence.pattern.yearly.month2.8.label;" + value="8"/> + <menuitem id="yearly-month-rule-9" + label="&event.recurrence.pattern.yearly.month2.9.label;" + value="9"/> + <menuitem id="yearly-month-rule-10" + label="&event.recurrence.pattern.yearly.month2.10.label;" + value="10"/> + <menuitem id="yearly-month-rule-11" + label="&event.recurrence.pattern.yearly.month2.11.label;" + value="11"/> + <menuitem id="yearly-month-rule-12" + label="&event.recurrence.pattern.yearly.month2.12.label;" + value="12"/> + </menupopup> + </menulist> + </row> + </rows> + </grid> + </radiogroup> + </box> + </deck> + </row> + </rows> + </grid> + </groupbox> + + <!-- range of recurrence --> + <groupbox id="recurrence-range-groupbox"> + <caption id="recurrence-range-caption" + label="&event.recurrence.range.label;"/> + <vbox> + <radiogroup id="recurrence-duration" + oncommand="updateRecurrenceControls()"> + <radio id="recurrence-range-forever" + label="&event.recurrence.forever.label;" + value="forever" + selected="true" + disable-on-readonly="true" + disable-on-occurrence="true"/> + <box id="recurrence-range-count-box" + orient="horizontal" + align="center"> + <radio id="recurrence-range-for" + label="&event.recurrence.repeat.for.label;" + value="ntimes" + disable-on-readonly="true" + disable-on-occurrence="true"/> + <textbox id="repeat-ntimes-count" + type="number" + value="5" + min="1" + max="0x7FFF" + size="3" + onkeyup="updateRecurrenceControls();" + disable-on-readonly="true" + disable-on-occurrence="true"/> + <label id="repeat-appointments-label" + value="&event.recurrence.appointments.label;" + disable-on-readonly="true" + disable-on-occurrence="true"/> + </box> + <box id="recurrence-range-until-box" + orient="horizontal" + align="center"> + <radio id="recurrence-range-until" + label="&event.repeat.until.label;" + value="until" + disable-on-readonly="true" + disable-on-occurrence="true" + control="repeat-until-date"/> + <datepicker id="repeat-until-date" + onchange="checkUntilDate();" + disable-on-readonly="true" + disable-on-occurrence="true"/> + </box> + </radiogroup> + </vbox> + </groupbox> + + <!-- preview --> + <groupbox id="preview-border" flex="1"> + <label id="recurrence-preview-label" + value="&event.recurrence.preview.label;" + control="recurrence-preview"/> + <recurrence-preview id="recurrence-preview" flex="1"/> + </groupbox> +</dialog> diff --git a/calendar/base/content/dialogs/calendar-event-dialog-reminder.js b/calendar/base/content/dialogs/calendar-event-dialog-reminder.js new file mode 100644 index 000000000..4f22889b9 --- /dev/null +++ b/calendar/base/content/dialogs/calendar-event-dialog-reminder.js @@ -0,0 +1,446 @@ +/* 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 onLoad, onReminderSelected, updateReminder, onNewReminder, + * onRemoveReminder, onAccept, onCancel + */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://calendar/modules/calIteratorUtils.jsm"); +Components.utils.import("resource://gre/modules/PluralForm.jsm"); +Components.utils.import("resource://gre/modules/Preferences.jsm"); + +var allowedActionsMap = {}; + +/** + * Sets up the reminder dialog. + */ +function onLoad() { + let calendar = window.arguments[0].calendar; + + // Make sure the origin menulist uses the right labels, depending on if the + // dialog is showing an event or task. + function _sn(x) { return cal.calGetString("calendar-alarms", getItemBundleStringName(x)); } + + setElementValue("reminder-before-start-menuitem", + _sn("reminderCustomOriginBeginBefore"), + "label"); + + setElementValue("reminder-after-start-menuitem", + _sn("reminderCustomOriginBeginAfter"), + "label"); + + setElementValue("reminder-before-end-menuitem", + _sn("reminderCustomOriginEndBefore"), + "label"); + + setElementValue("reminder-after-end-menuitem", + _sn("reminderCustomOriginEndAfter"), + "label"); + + + // Set up the action map + let supportedActions = calendar.getProperty("capabilities.alarms.actionValues") || + ["DISPLAY"]; // TODO email support, "EMAIL" + for (let action of supportedActions) { + allowedActionsMap[action] = true; + } + + // Hide all actions that are not supported by this provider + let firstAvailableItem; + let actionNodes = document.getElementById("reminder-actions-menupopup").childNodes; + for (let actionNode of actionNodes) { + let shouldHide = !(actionNode.value in allowedActionsMap) || + (actionNode.hasAttribute("provider") && + actionNode.getAttribute("provider") != calendar.type); + setElementValue(actionNode, shouldHide && "true", "hidden"); + if (!firstAvailableItem && !shouldHide) { + firstAvailableItem = actionNode; + } + } + + // Correct the selected item on the supported actions list. This will be + // changed when reminders are loaded, but in case there are none we need to + // provide a sensible default. + if (firstAvailableItem) { + document.getElementById("reminder-actions-menulist").selectedItem = firstAvailableItem; + } + + loadReminders(); + opener.setCursor("auto"); +} + +/** + * Load Reminders from the window's arguments and set up dialog controls to + * their initial values. + */ +function loadReminders() { + let args = window.arguments[0]; + let listbox = document.getElementById("reminder-listbox"); + let reminders = args.reminders || args.item.getAlarms({}); + + // This dialog should not be shown if the calendar doesn't support alarms at + // all, so the case of maxCount = 0 breaking this logic doesn't apply. + let maxReminders = args.calendar.getProperty("capabilities.alarms.maxCount"); + let count = Math.min(reminders.length, maxReminders || reminders.length); + for (let i = 0; i < count; i++) { + if (reminders[i].action in allowedActionsMap) { + // Set up the listitem and add it to the listbox, but only if the + // action is actually supported by the calendar. + listbox.appendChild(setupListItem(null, reminders[i].clone(), args.item)); + } + } + + // Set up a default absolute date. This will be overridden if the selected + // alarm is absolute. + let absDate = document.getElementById("reminder-absolute-date"); + absDate.value = cal.dateTimeToJsDate(getDefaultStartDate()); + + if (listbox.childNodes.length) { + // We have reminders, select the first by default. For some reason, + // setting the selected index in a load handler makes the selection + // break for the set item, therefore we need a setTimeout. + setupMaxReminders(); + setTimeout(() => { listbox.selectedIndex = 0; }, 0); + } else { + // Make sure the fields are disabled if we have no alarms + setupRadioEnabledState(true); + } +} + +/** + * Sets up the enabled state of the reminder details controls. Used when + * switching between absolute and relative alarms to disable and enable the + * needed controls. + * + * @param aDisableAll Disable all relation controls. Used when no alarms + * are added yet. + */ +function setupRadioEnabledState(aDisableAll) { + let relationItem = document.getElementById("reminder-relation-radiogroup").selectedItem; + let relativeDisabled, absoluteDisabled; + + // Note that the mix of string/boolean here is not a mistake. + // setElementValue removes the attribute from the node if the second + // parameter is === false, otherwise sets the attribute value to the given + // string (i.e "true"). + if (aDisableAll) { + relativeDisabled = "true"; + absoluteDisabled = "true"; + } else if (relationItem) { + // This is not a mistake, when this function is called from onselect, + // the value has not been set. + relativeDisabled = (relationItem.value == "absolute") && "true"; + absoluteDisabled = (relationItem.value == "relative") && "true"; + } else { + relativeDisabled = false; + absoluteDisabled = false; + } + + setElementValue("reminder-length", relativeDisabled, "disabled"); + setElementValue("reminder-unit", relativeDisabled, "disabled"); + setElementValue("reminder-relation-origin", relativeDisabled, "disabled"); + + setElementValue("reminder-absolute-date", absoluteDisabled, "disabled"); + + let disableAll = (aDisableAll ? "true" : false); + setElementValue("reminder-relative-radio", disableAll, "disabled"); + setElementValue("reminder-absolute-radio", disableAll, "disabled"); + setElementValue("reminder-actions-menulist", disableAll, "disabled"); +} + +/** + * Sets up the max reminders notification. Shows or hides the notification + * depending on if the max reminders limit has been hit or not. + */ +function setupMaxReminders() { + let args = window.arguments[0]; + let listbox = document.getElementById("reminder-listbox"); + let notificationbox = document.getElementById("reminder-notifications"); + let maxReminders = args.calendar.getProperty("capabilities.alarms.maxCount"); + + // != null is needed here to ensure cond to be true/false, instead of + // true/null. The former is needed for setElementValue. + let cond = (maxReminders != null && listbox.childNodes.length >= maxReminders); + + // If we hit the maximum number of reminders, show the error box and + // disable the new button. + setElementValue("reminder-new-button", cond && "true", "disabled"); + + if (!setupMaxReminders.notification) { + let notification = createXULElement("notification"); + let localeErrorString = + calGetString("calendar-alarms", + getItemBundleStringName("reminderErrorMaxCountReached"), + [maxReminders]); + let pluralErrorLabel = PluralForm.get(maxReminders, localeErrorString) + .replace("#1", maxReminders); + + notification.setAttribute("label", pluralErrorLabel); + notification.setAttribute("type", "warning"); + notification.setAttribute("hideclose", "true"); + setupMaxReminders.notification = notification; + } + + if (cond) { + notificationbox.appendChild(setupMaxReminders.notification); + } else { + try { + notificationbox.removeNotification(setupMaxReminders.notification); + } catch (e) { + // It's only ok to swallow this if the notification element hasn't been + // added. Then the call will throw a DOM NOT_FOUND_ERR. + if (e.code != e.NOT_FOUND_ERR) { + throw e; + } + } + } +} + +/** + * Sets up a reminder listitem for the list of reminders applied to this item. + * + * @param aListItem (optional) A reference listitem to set up. If not + * passed, a new listitem will be created. + * @param aReminder The calIAlarm to display in this listitem + * @param aItem The item the alarm is set up on. + * @return The XUL listitem node showing the passed reminder. + */ +function setupListItem(aListItem, aReminder, aItem) { + let listitem = aListItem || createXULElement("listitem"); + + // Create a random id to be used for accessibility + let reminderId = cal.getUUID(); + let ariaLabel = "reminder-action-" + aReminder.action + " " + reminderId; + + listitem.reminder = aReminder; + listitem.setAttribute("id", reminderId); + listitem.setAttribute("label", aReminder.toString(aItem)); + listitem.setAttribute("aria-labelledby", ariaLabel); + listitem.setAttribute("class", "reminder-icon listitem-iconic"); + listitem.setAttribute("value", aReminder.action); + return listitem; +} + +/** + * Handler function to be called when a reminder is selected in the listbox. + * Sets up remaining controls to show the selected alarm. + */ +function onReminderSelected() { + let length = document.getElementById("reminder-length"); + let unit = document.getElementById("reminder-unit"); + let relationOrigin = document.getElementById("reminder-relation-origin"); + let absDate = document.getElementById("reminder-absolute-date"); + let actionType = document.getElementById("reminder-actions-menulist"); + let relationType = document.getElementById("reminder-relation-radiogroup"); + + let listbox = document.getElementById("reminder-listbox"); + let listitem = listbox.selectedItem; + + if (listitem) { + let reminder = listitem.reminder; + + // Action + actionType.value = reminder.action; + + // Absolute/relative things + if (reminder.related == Components.interfaces.calIAlarm.ALARM_RELATED_ABSOLUTE) { + relationType.value = "absolute"; + + // Date + absDate.value = cal.dateTimeToJsDate(reminder.alarmDate || cal.getDefaultStartDate()); + } else { + relationType.value = "relative"; + + // Unit and length + let alarmlen = Math.abs(reminder.offset.inSeconds / 60); + if (alarmlen % 1440 == 0) { + unit.value = "days"; + length.value = alarmlen / 1440; + } else if (alarmlen % 60 == 0) { + unit.value = "hours"; + length.value = alarmlen / 60; + } else { + unit.value = "minutes"; + length.value = alarmlen; + } + + // Relation + let relation = (reminder.offset.isNegative ? "before" : "after"); + + // Origin + let origin; + if (reminder.related == Components.interfaces.calIAlarm.ALARM_RELATED_START) { + origin = "START"; + } else if (reminder.related == Components.interfaces.calIAlarm.ALARM_RELATED_END) { + origin = "END"; + } + + relationOrigin.value = [relation, origin].join("-"); + } + } else { + // no list item is selected, disable elements + setupRadioEnabledState(true); + } +} + +/** + * Handler function to be called when an aspect of the alarm has been changed + * using the dialog controls. + * + * @param event The DOM event caused by the change. + */ +function updateReminder(event) { + if (event.explicitOriginalTarget.localName == "listitem" || + event.explicitOriginalTarget.id == "reminder-remove-button" || + !document.commandDispatcher.focusedElement) { + // Do not set things if the select came from selecting or removing an + // alarm from the list, or from setting when the dialog initially loaded. + // XXX Quite fragile hack since radio/radiogroup doesn't have the + // supressOnSelect stuff. + return; + } + let listbox = document.getElementById("reminder-listbox"); + let relationItem = document.getElementById("reminder-relation-radiogroup").selectedItem; + let listitem = listbox.selectedItem; + if (!listitem || !relationItem) { + return; + } + let reminder = listitem.reminder; + let length = document.getElementById("reminder-length"); + let unit = document.getElementById("reminder-unit"); + let relationOrigin = document.getElementById("reminder-relation-origin"); + let [relation, origin] = relationOrigin.value.split("-"); + let absDate = document.getElementById("reminder-absolute-date"); + let action = document.getElementById("reminder-actions-menulist").selectedItem.value; + + // Action + reminder.action = action; + + if (relationItem.value == "relative") { + if (origin == "START") { + reminder.related = Components.interfaces.calIAlarm.ALARM_RELATED_START; + } else if (origin == "END") { + reminder.related = Components.interfaces.calIAlarm.ALARM_RELATED_END; + } + + // Set up offset, taking units and before/after into account + let offset = cal.createDuration(); + offset[unit.value] = length.value; + offset.normalize(); + offset.isNegative = (relation == "before"); + reminder.offset = offset; + } else if (relationItem.value == "absolute") { + reminder.related = Components.interfaces.calIAlarm.ALARM_RELATED_ABSOLUTE; + + if (absDate.value) { + reminder.alarmDate = cal.jsDateToDateTime(absDate.value, + window.arguments[0].timezone); + } else { + reminder.alarmDate = null; + } + } + + setupListItem(listitem, reminder, window.arguments[0].item); +} + +/** + * Gets the locale stringname that is dependant on the item type. This function + * appends the item type, i.e |aPrefix + "Event"|. + * + * @param aPrefix The prefix to prepend to the item type + * @return The full string name. + */ +function getItemBundleStringName(aPrefix) { + if (isEvent(window.arguments[0].item)) { + return aPrefix + "Event"; + } else { + return aPrefix + "Task"; + } +} + +/** + * Handler function to be called when the "new" button is pressed, to create a + * new reminder item. + */ +function onNewReminder() { + let itemType = (isEvent(window.arguments[0].item) ? "event" : "todo"); + let listbox = document.getElementById("reminder-listbox"); + + let reminder = cal.createAlarm(); + let alarmlen = Preferences.get("calendar.alarms." + itemType + "alarmlen", 15); + + // Default is a relative DISPLAY alarm, |alarmlen| minutes before the event. + // If DISPLAY is not supported by the provider, then pick the provider's + // first alarm type. + let offset = cal.createDuration(); + offset.minutes = alarmlen; + offset.normalize(); + offset.isNegative = true; + reminder.related = reminder.ALARM_RELATED_START; + reminder.offset = offset; + if ("DISPLAY" in allowedActionsMap) { + reminder.action = "DISPLAY"; + } else { + let calendar = window.arguments[0].calendar; + let actions = calendar.getProperty("capabilities.alarms.actionValues") || []; + reminder.action = actions[0]; + } + + // Set up the listbox + let listitem = setupListItem(null, reminder, window.arguments[0].item); + listbox.appendChild(listitem); + listbox.selectItem(listitem); + + // Since we've added an item, its safe to always enable the button + enableElement("reminder-remove-button"); + + // Set up the enabled state and max reminders + setupRadioEnabledState(); + setupMaxReminders(); +} + +/** + * Handler function to be called when the "remove" button is pressed to remove + * the selected reminder item and advance the selection. + */ +function onRemoveReminder() { + let listbox = document.getElementById("reminder-listbox"); + let listitem = listbox.selectedItem; + let newSelection = listitem ? listitem.nextSibling || listitem.previousSibling + : null; + + listbox.clearSelection(); + listitem.remove(); + listbox.selectItem(newSelection); + + setElementValue("reminder-remove-button", + listbox.childNodes.length < 1 && "true", + "disabled"); + setupMaxReminders(); +} + +/** + * Handler function to be called when the accept button is pressed. + * + * @return Returns true if the window should be closed + */ +function onAccept() { + let listbox = document.getElementById("reminder-listbox"); + let reminders = Array.from(listbox.childNodes).map(node => node.reminder); + if (window.arguments[0].onOk) { + window.arguments[0].onOk(reminders); + } + + return true; +} + +/** + * Handler function to be called when the cancel button is pressed. + */ +function onCancel() { + if (window.arguments[0].onCancel) { + window.arguments[0].onCancel(); + } +} diff --git a/calendar/base/content/dialogs/calendar-event-dialog-reminder.xul b/calendar/base/content/dialogs/calendar-event-dialog-reminder.xul new file mode 100644 index 000000000..6ee951af0 --- /dev/null +++ b/calendar/base/content/dialogs/calendar-event-dialog-reminder.xul @@ -0,0 +1,121 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<?xml-stylesheet type="text/css" href="chrome://global/skin/global.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar-common/skin/calendar-alarms.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/calendar-event-dialog.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/content/datetimepickers/datetimepickers.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/content/calendar-bindings.css"?> + +<!DOCTYPE dialog SYSTEM "chrome://calendar/locale/dialogs/calendar-event-dialog-reminder.dtd" > + +<dialog id="calendar-event-dialog-reminder" + title="&reminderdialog.title;" + windowtype="Calendar:EventDialog:Reminder" + onload="onLoad()" + ondialogaccept="return onAccept();" + ondialogcancel="return onCancel();" + persist="screenX screenY width height" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <!-- Javascript includes --> + <script type="application/javascript" src="chrome://calendar/content/calendar-event-dialog-reminder.js"/> + <script type="application/javascript" src="chrome://calendar/content/calendar-ui-utils.js"/> + <script type="application/javascript" src="chrome://calendar/content/calUtils.js"/> + + <notificationbox id="reminder-notifications"/> + + <!-- Listbox with custom reminders --> + <vbox flex="1"> + <listbox id="reminder-listbox" + seltype="single" + class="event-dialog-listbox" + onselect="onReminderSelected()" + flex="1"/> + <hbox id="reminder-action-buttons-box" pack="end"> + <button id="reminder-new-button" + label="&reminder.add.label;" + accesskey="&reminder.add.accesskey;" + oncommand="onNewReminder()"/> + <button id="reminder-remove-button" + label="&reminder.remove.label;" + accesskey="&reminder.remove.accesskey;" + oncommand="onRemoveReminder()"/> + </hbox> + </vbox> + + <!-- Custom reminder details --> + <calendar-caption id="reminder-details-caption" label="&reminder.reminderDetails.label;"/> + <radiogroup id="reminder-relation-radiogroup" + onselect="setupRadioEnabledState(); updateReminder(event)"> + <hbox id="reminder-relative-box" align="top" flex="1"> + <radio id="reminder-relative-radio" + value="relative" + aria-labeledby="reminder-length reminder-unit reminder-relation reminder-origin"/> + <vbox id="reminder-relative-box" flex="1"> + <hbox id="reminder-relative-length-unit-relation" flex="1"> + <textbox id="reminder-length" + type="number" + size="1" + min="0" + onkeyup="updateReminder(event)"/> + <menulist id="reminder-unit" oncommand="updateReminder(event)" flex="1"> + <menupopup id="reminder-unit-menupopup"> + <menuitem id="reminder-minutes-menuitem" + label="&alarm.units.minutes;" + value="minutes"/> + <menuitem id="reminder-hours-menuitem" + label="&alarm.units.hours;" + value="hours"/> + <menuitem id="reminder-days-menuitem" + label="&alarm.units.days;" + value="days"/> + </menupopup> + </menulist> + </hbox> + <menulist id="reminder-relation-origin" oncommand="updateReminder(event)"> + <menupopup id="reminder-relation-origin-menupopup"> + <!-- The labels here will be set in calendar-event-dialog-reminder.js --> + <menuitem id="reminder-before-start-menuitem" + value="before-START"/> + <menuitem id="reminder-after-start-menuitem" + value="after-START"/> + <menuitem id="reminder-before-end-menuitem" + value="before-END"/> + <menuitem id="reminder-after-end-menuitem" + value="after-END"/> + </menupopup> + </menulist> + </vbox> + </hbox> + <hbox id="reminder-absolute-box" flex="1"> + <radio id="reminder-absolute-radio" + control="reminder-absolute-date" + value="absolute"/> + <datetimepicker id="reminder-absolute-date"/> + </hbox> + </radiogroup> + + <!-- Custom reminder action --> + <calendar-caption id="reminder-actions-caption" + control="reminder-actions-menulist" + label="&reminder.action.label;"/> + <menulist id="reminder-actions-menulist" + oncommand="updateReminder(event)" + class="reminder-icon"> + <!-- Make sure the id is formatted "reminder-action-<VALUE>", for accessibility --> + <!-- TODO provider specific --> + <menupopup id="reminder-actions-menupopup"> + <menuitem id="reminder-action-DISPLAY" + class="reminder-icon menuitem-iconic" + value="DISPLAY" + label="&reminder.action.alert.label;"/> + <menuitem id="reminder-action-EMAIL" + class="reminder-icon menuitem-iconic" + value="EMAIL" + label="&reminder.action.email.label;"/> + </menupopup> + </menulist> +</dialog> diff --git a/calendar/base/content/dialogs/calendar-event-dialog-timezone.js b/calendar/base/content/dialogs/calendar-event-dialog-timezone.js new file mode 100644 index 000000000..998d0a3b5 --- /dev/null +++ b/calendar/base/content/dialogs/calendar-event-dialog-timezone.js @@ -0,0 +1,138 @@ +/* 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 onLoad, onAccept, onCancel */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); + +/** + * Sets up the timezone dialog from the window arguments, also setting up all + * dialog controls from the window's dates. + */ +function onLoad() { + let args = window.arguments[0]; + window.time = args.time; + window.onAcceptCallback = args.onOk; + + let tzProvider = args.calendar.getProperty("timezones.provider") || + cal.getTimezoneService(); + window.tzProvider = tzProvider; + + let menulist = document.getElementById("timezone-menulist"); + let tzMenuPopup = document.getElementById("timezone-menupopup"); + + // floating and UTC (if supported) at the top: + if (args.calendar.getProperty("capabilities.timezones.floating.supported") !== false) { + addMenuItem(tzMenuPopup, floating().displayName, floating().tzid); + } + if (args.calendar.getProperty("capabilities.timezones.UTC.supported") !== false) { + addMenuItem(tzMenuPopup, UTC().displayName, UTC().tzid); + } + + let enumerator = tzProvider.timezoneIds; + let tzids = {}; + let displayNames = []; + while (enumerator.hasMore()) { + let timezone = tzProvider.getTimezone(enumerator.getNext()); + if (timezone && !timezone.isFloating && !timezone.isUTC) { + let displayName = timezone.displayName; + displayNames.push(displayName); + tzids[displayName] = timezone.tzid; + } + } + // the display names need to be sorted + displayNames.sort(String.localeCompare); + for (let i = 0; i < displayNames.length; ++i) { + let displayName = displayNames[i]; + addMenuItem(tzMenuPopup, displayName, tzids[displayName]); + } + + let index = findTimezone(window.time.timezone); + if (index < 0) { + index = findTimezone(calendarDefaultTimezone()); + if (index < 0) { + index = 0; + } + } + + menulist = document.getElementById("timezone-menulist"); + menulist.selectedIndex = index; + + updateTimezone(); + + opener.setCursor("auto"); +} + +/** + * Find the index of the timezone menuitem corresponding to the given timezone. + * + * @param timezone The calITimezone to look for. + * @return The index of the childnode below "timezone-menulist" + */ +function findTimezone(timezone) { + let tzid = timezone.tzid; + let menulist = document.getElementById("timezone-menulist"); + let numChilds = menulist.childNodes[0].childNodes.length; + for (let i = 0; i < numChilds; i++) { + let menuitem = menulist.childNodes[0].childNodes[i]; + if (menuitem.getAttribute("value") == tzid) { + return i; + } + } + return -1; +} + +/** + * Handler function to call when the timezone selection has changed. Updates the + * timezone-time field and the timezone-stack. + */ +function updateTimezone() { + let menulist = document.getElementById("timezone-menulist"); + let menuitem = menulist.selectedItem; + let timezone = window.tzProvider.getTimezone(menuitem.getAttribute("value")); + + // convert the date/time to the currently selected timezone + // and display the result in the appropriate control. + // 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. + let datetime = document.getElementById("timezone-time"); + let time = window.time.getInTimezone(timezone); + time.timezone = cal.floating(); + datetime.value = cal.dateTimeToJsDate(time); + + // don't highlight any timezone in the map by default + let standardTZOffset = "none"; + if (timezone.isUTC) { + standardTZOffset = "+0000"; + } else if (!timezone.isFloating) { + let standard = timezone.icalComponent.getFirstSubcomponent("STANDARD"); + // any reason why valueAsIcalString is used instead of plain value? xxx todo: ask mickey + standardTZOffset = standard.getFirstProperty("TZOFFSETTO").valueAsIcalString; + } + + let image = document.getElementById("highlighter"); + image.setAttribute("tzid", standardTZOffset); +} +/** + * Handler function to be called when the accept button is pressed. + * + * @return Returns true if the window should be closed + */ +function onAccept() { + let menulist = document.getElementById("timezone-menulist"); + let menuitem = menulist.selectedItem; + let timezoneString = menuitem.getAttribute("value"); + let timezone = window.tzProvider.getTimezone(timezoneString); + let datetime = window.time.getInTimezone(timezone); + window.onAcceptCallback(datetime); + return true; +} + +/** + * Handler function to be called when the cancel button is pressed. + * + */ +function onCancel() { +} diff --git a/calendar/base/content/dialogs/calendar-event-dialog-timezone.xul b/calendar/base/content/dialogs/calendar-event-dialog-timezone.xul new file mode 100644 index 000000000..bcdd1c7fa --- /dev/null +++ b/calendar/base/content/dialogs/calendar-event-dialog-timezone.xul @@ -0,0 +1,46 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<?xml-stylesheet type="text/css" href="chrome://global/skin/global.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/calendar-event-dialog.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/content/datetimepickers/datetimepickers.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar-common/skin/calendar-timezone-highlighter.css"?> + +<!DOCTYPE dialog [ + <!ENTITY % dtd1 SYSTEM "chrome://calendar/locale/global.dtd" > %dtd1; + <!ENTITY % dtd2 SYSTEM "chrome://calendar/locale/calendar.dtd" > %dtd2; + <!ENTITY % dtd3 SYSTEM "chrome://calendar/locale/calendar-event-dialog.dtd" > %dtd3; + <!ENTITY % dtd4 SYSTEM "chrome://calendar/locale/preferences/timezones.dtd" > %dtd4; +]> + +<dialog id="calendar-event-dialog-timezone" + title="&timezone.title.label;" + windowtype="Calendar:EventDialog:Timezone" + onload="onLoad()" + ondialogaccept="return onAccept();" + ondialogcancel="return onCancel();" + persist="screenX screenY width height" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <!-- Javascript includes --> + <script type="application/javascript" src="chrome://calendar/content/calendar-event-dialog-timezone.js"/> + <script type="application/javascript" src="chrome://calendar/content/calendar-dialog-utils.js"/> + <script type="application/javascript" src="chrome://calendar/content/calendar-ui-utils.js"/> + <script type="application/javascript" src="chrome://calendar/content/calUtils.js"/> + + <hbox align="center"> + <spacer flex="1"/> + <datetimepicker id="timezone-time" disabled="true"/> + </hbox> + + <menulist id="timezone-menulist" oncommand="updateTimezone()"> + <menupopup id="timezone-menupopup" style="height: 460px;"/> + </menulist> + + <stack id="timezone-stack"> + <image src="chrome://calendar-common/skin/timezone_map.png"/> + <image class="timezone-highlight" tzid="+0000" id="highlighter"/> + </stack> +</dialog> diff --git a/calendar/base/content/dialogs/calendar-event-dialog.css b/calendar/base/content/dialogs/calendar-event-dialog.css new file mode 100644 index 000000000..4b1093acd --- /dev/null +++ b/calendar/base/content/dialogs/calendar-event-dialog.css @@ -0,0 +1,64 @@ +/* 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/. */ + +daypicker-weekday { + -moz-binding: url(chrome://calendar/content/calendar-daypicker.xml#daypicker-weekday); + -moz-user-focus: normal; +} + +daypicker-monthday { + -moz-binding: url(chrome://calendar/content/calendar-daypicker.xml#daypicker-monthday); + -moz-user-focus: normal; +} + +recurrence-preview { + -moz-binding: url(chrome://calendar/content/calendar-event-dialog-recurrence-preview.xml#recurrence-preview); + -moz-user-focus: normal; +} + +/****************************************************************************************/ + +attendees-list { + -moz-binding: url(chrome://calendar/content/calendar-event-dialog-attendees.xml#attendees-list); + -moz-user-focus: normal; +} + +selection-bar { + -moz-binding: url(chrome://calendar/content/calendar-event-dialog-attendees.xml#selection-bar); + -moz-user-focus: normal; +} + +/****************************************************************************************/ + +scroll-container { + -moz-binding: url(chrome://calendar/content/calendar-event-dialog-freebusy.xml#scroll-container); + -moz-user-focus: normal; +} + +freebusy-day { + -moz-binding: url(chrome://calendar/content/calendar-event-dialog-freebusy.xml#freebusy-day); + -moz-user-focus: normal; +} + +freebusy-timebar { + -moz-binding: url(chrome://calendar/content/calendar-event-dialog-freebusy.xml#freebusy-timebar); + -moz-user-focus: normal; +} + +freebusy-row { + -moz-binding: url(chrome://calendar/content/calendar-event-dialog-freebusy.xml#freebusy-row); + -moz-user-focus: normal; +} + +freebusy-grid { + -moz-binding: url(chrome://calendar/content/calendar-event-dialog-freebusy.xml#freebusy-grid); + -moz-user-focus: normal; +} + +/****************************************************************************************/ + +timezone-page { + -moz-binding: url(chrome://calendar/content/calendar-event-dialog-timezone.xml#timezone-page); + -moz-user-focus: normal; +} diff --git a/calendar/base/content/dialogs/calendar-event-dialog.xul b/calendar/base/content/dialogs/calendar-event-dialog.xul new file mode 100644 index 000000000..b639dd515 --- /dev/null +++ b/calendar/base/content/dialogs/calendar-event-dialog.xul @@ -0,0 +1,648 @@ +<?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/. --> + +<?xml-stylesheet type="text/css" href="chrome://global/skin/global.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar-common/skin/calendar-alarms.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar-common/skin/calendar-attendees.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/content/widgets/calendar-widget-bindings.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/calendar-event-dialog.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/content/calendar-event-dialog.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/content/datetimepickers/datetimepickers.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/primaryToolbar.css"?> +<?xml-stylesheet type="text/css" href="chrome://messenger/skin/messenger.css"?> + +<!DOCTYPE dialog [ + <!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"> + %brandDTD; + %globalDTD; + %calendarDTD; + %eventDialogDTD; +]> + +<?xul-overlay href="chrome://lightning/content/lightning-item-toolbar.xul"?> + +<!-- Dialog id is changed during excution to allow different Window-icons + on this dialog. document.loadOverlay() will not work on this one. --> +<dialog id="calendar-event-dialog" + title="&event.title.label;" + windowtype="Calendar:EventDialog" + onload="onLoadLightningItemPanel();" + onunload="onUnloadLightningItemPanel();" + ondialogaccept="return onAccept();" + ondialogcancel="return onCancel();" + persist="screenX screenY width height" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <!-- Javascript includes --> + <script type="application/javascript" + src="chrome://lightning/content/lightning-item-panel.js"/> + <script type="application/javascript" + src="chrome://calendar/content/calendar-dialog-utils.js"/> + <script type="application/javascript" + src="chrome://calendar/content/calendar-ui-utils.js"/> + <script type="application/javascript" + src="chrome://global/content/globalOverlay.js"/> + <script type="application/javascript" + src="chrome://messenger/content/toolbarIconColor.js"/> + + <stringbundleset id="stringbundleset"> + <stringbundle id="languageBundle" + src="chrome://global/locale/languageNames.properties"/> + </stringbundleset> + + <!-- Command updater --> + <commandset id="globalEditMenuItems" + commandupdater="true" + events="focus" + oncommandupdate="goUpdateGlobalEditMenuItems()"/> + <commandset id="selectEditMenuItems" + commandupdater="true" + events="select" + oncommandupdate="goUpdateSelectEditMenuItems()"/> + <commandset id="undoEditMenuItems" + commandupdater="true" + events="undo" + oncommandupdate="goUpdateUndoEditMenuItems()"/> + <commandset id="clipboardEditMenuItems" + commandupdater="true" + events="clipboard" + oncommandupdate="goUpdatePasteMenuItems()"/> + + <!-- Commands --> + <commandset id="itemCommands"> + + <!-- Item menu --> + <command id="cmd_item_new_event" + oncommand="openNewEvent()"/> + <command id="cmd_item_new_task" + oncommand="openNewTask()"/> + <command id="cmd_item_new_message" + oncommand="openNewMessage()"/> + <command id="cmd_item_new_card" + oncommand="openNewCardDialog()"/> + <command id="cmd_item_close" + oncommand="cancelDialog()"/> + <command id="cmd_save" + disable-on-readonly="true" + oncommand="onCommandSave()"/> + <command id="cmd_item_delete" + disable-on-readonly="true" + oncommand="onCommandDeleteItem()"/> + <command id="cmd_printSetup" + oncommand="PrintUtils.showPageSetup()"/> + <command id="cmd_print" + disabled="true" + oncommand="calPrint()"/> + + <!-- Edit menu --> + <command id="cmd_undo" + disabled="true" + oncommand="goDoCommand('cmd_undo')"/> + <command id="cmd_redo" + disabled="true" + oncommand="goDoCommand('cmd_redo')"/> + <command id="cmd_cut" + disabled="true" + oncommand="goDoCommand('cmd_cut')"/> + <command id="cmd_copy" + disabled="true" + oncommand="goDoCommand('cmd_copy')"/> + <command id="cmd_paste" + disabled="true" + oncommand="goDoCommand('cmd_paste')"/> + <command id="cmd_selectAll" + disabled="true" + oncommand="goDoCommand('cmd_selectAll')"/> + + <!-- View menu --> + <command id="cmd_toolbar" + oncommand="onCommandViewToolbar('event-toolbar', + 'view-toolbars-event-menuitem')"/> + <command id="cmd_customize" + oncommand="onCommandCustomize()"/> + <command id="cmd_toggle_link" + persist="checked" + oncommand="toggleLink()"/> + + <!-- 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();"/> + <command id="cmd_email" + oncommand="sendMailToAttendees(window.attendees);"/> + <command id="cmd_email_undecided" + oncommand="sendMailToUndecidedAttendees(window.attendees);"/> + + <!-- accept, attachments, timezone --> + <command id="cmd_accept" + disable-on-readonly="true" + oncommand="acceptDialog();"/> + <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="new-event-key" + modifiers="accel" + key="&event.dialog.new.event.key2;" + command="cmd_item_new_event"/> + <key id="new-task-key" + modifiers="accel" + key="&event.dialog.new.task.key2;" + command="cmd_item_new_task"/> + <key id="new-message-key" + modifiers="accel" + key="&event.dialog.new.message.key2;" + command="cmd_item_new_message"/> + <key id="close-key" + modifiers="accel" + key="&event.dialog.close.key;" + command="cmd_item_close"/> + <key id="save-key" + modifiers="accel" + 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"/> + <key id="print-key" + modifiers="accel" + key="&event.dialog.print.key;" + command="cmd_print"/> + <key id="undo-key" + modifiers="accel" + key="&event.dialog.undo.key;" + command="cmd_undo"/> + <key id="redo-key" + modifiers="accel" + key="&event.dialog.redo.key;" + command="cmd_redo"/> + <key id="cut-key" + modifiers="accel" + key="&event.dialog.cut.key;" + command="cmd_cut"/> + <key id="copy-key" + modifiers="accel" + key="&event.dialog.copy.key;" + command="cmd_copy"/> + <key id="paste-key" + modifiers="accel" + key="&event.dialog.paste.key;" + command="cmd_paste"/> + <key id="select-all-key" + modifiers="accel" + key="&event.dialog.select.all.key;" + command="cmd_selectAll"/> + </keyset> + + <menupopup id="event-dialog-toolbar-context-menu"> + <menuitem id="CustomizeDialogToolbar" + label="&event.menu.view.toolbars.customize.label;" + command="cmd_customize"/> + </menupopup> + + <!-- Toolbox contains the menubar --> + <toolbox id="event-toolbox" + class="mail-toolbox" + mode="full" + defaultmode="full" +#ifdef XP_MACOSX + iconsize="small" + defaulticonsize="small" +#endif + labelalign="end" + defaultlabelalign="end" + isNotMainWindow="true"> + + <!-- Menubar --> + <menubar id="event-menubar"> + + <!-- Item menu --> + <!-- These 2 Strings are placeholders, values are set at runtime --> + <menu label="Item" + accesskey="I" + id="item-menu"> + <menupopup id="item-menupopup"> + <menu id="item-new-menu" + label="&event.menu.item.new.label;" + accesskey="&event.menu.item.new.accesskey;"> + <menupopup id="item-new-menupopup"> + <menuitem id="item-new-message-menuitem" + label="&event.menu.item.new.message.label;" + accesskey="&event.menu.item.new.message.accesskey;" + key="new-message-key" + command="cmd_item_new_message" + disable-on-readonly="true"/> + <menuitem id="item-new-event-menuitem" + label="&event.menu.item.new.event.label;" + accesskey="&event.menu.item.new.event.accesskey;" + key="new-event-key" + command="cmd_item_new_event" + disable-on-readonly="true"/> + <menuitem id="item-new-task-menuitem" + label="&event.menu.item.new.task.label;" + accesskey="&event.menu.item.new.task.accesskey;" + key="new-task-key" + command="cmd_item_new_task" + disable-on-readonly="true"/> + <menuseparator id="item-new-menuseparator1"/> + <menuitem id="item-new-address-menuitem" + label="&event.menu.item.new.contact.label;" + accesskey="&event.menu.item.new.contact.accesskey;" + command="cmd_item_new_card" + disable-on-readonly="true"/> + </menupopup> + </menu> + <menuseparator id="item-menuseparator1"/> + <menuitem id="item-save-menuitem" + label="&event.menu.item.save.label;" + accesskey="&event.menu.item.save.accesskey;" + key="save-key" + command="cmd_save"/> + <menuitem id="item-saveandclose-menuitem" + label="&event.menu.item.saveandclose.label;" + accesskey="&event.menu.item.saveandclose.accesskey;" + key="saveandclose-key" + command="cmd_accept"/> + <menuitem id="item-delete-menuitem" + label="&event.menu.item.delete.label;" + accesskey="&event.menu.item.delete.accesskey;" + command="cmd_item_delete" + disable-on-readonly="true"/> + <menuitem id="item-pagesetup-menuitem" + label="&event.menu.item.page.setup.label;" + accesskey="&event.menu.item.page.setup.accesskey;" + command="cmd_printSetup" + disable-on-readonly="true"/> + <menuitem id="item-print-menuitem" + label="&event.menu.item.print.label;" + accesskey="&event.menu.item.print.accesskey;" + key="print-key" + command="cmd_print" + disable-on-readonly="true"/> + <menuseparator id="item-menuseparator1"/> + <menuitem id="item-close-menuitem" + label="&event.menu.item.close.label;" + accesskey="&event.menu.item.close.accesskey;" + key="close-key" + command="cmd_item_close" + disable-on-readonly="true"/> + </menupopup> + </menu> + + <!-- Edit menu --> + <menu id="edit-menu" + label="&event.menu.edit.label;" + accesskey="&event.menu.edit.accesskey;" + collapse-on-readonly="true"> + <menupopup id="edit-menupopup"> + <menuitem id="edit-undo-menuitem" + label="&event.menu.edit.undo.label;" + accesskey="&event.menu.edit.undo.accesskey;" + key="undo-key" + command="cmd_undo"/> + <menuitem id="edit-redo-menuitem" + label="&event.menu.edit.redo.label;" + accesskey="&event.menu.edit.redo.accesskey;" + key="redo-key" + command="cmd_redo"/> + <menuseparator id="edit-menuseparator1"/> + <menuitem id="edit-cut-menuitem" + label="&event.menu.edit.cut.label;" + accesskey="&event.menu.edit.cut.accesskey;" + key="cut-key" + command="cmd_cut"/> + <menuitem id="edit-copy-menuitem" + label="&event.menu.edit.copy.label;" + accesskey="&event.menu.edit.copy.accesskey;" + key="copy-key" + command="cmd_copy"/> + <menuitem id="edit-paste-menuitem" + label="&event.menu.edit.paste.label;" + accesskey="&event.menu.edit.paste.accesskey;" + key="paste-key" + command="cmd_paste"/> + <menuseparator id="edit-menuseparator2"/> + <menuitem id="edit-selectall-menuitem" + label="&event.menu.edit.select.all.label;" + accesskey="&event.menu.edit.select.all.accesskey;" + key="select-all-key" + command="cmd_selectAll"/> + </menupopup> + </menu> + + <!-- View menu --> + <menu id="view-menu" + label="&event.menu.view.label;" + accesskey="&event.menu.view.accesskey;" + collapse-on-readonly="true"> + <menupopup id="view-menupopup"> + <menu id="view-toolbars-menu" + label="&event.menu.view.toolbars.label;" + accesskey="&event.menu.view.toolbars.accesskey;"> + <menupopup id="view-toolbars-menupopup"> + <menuitem id="view-toolbars-event-menuitem" + label="&event.menu.view.toolbars.event.label;" + accesskey="&event.menu.view.toolbars.event.accesskey;" + type="checkbox" + checked="true" + command="cmd_toolbar"/> + <menuseparator id="view-toolbars-menuseparator1"/> + <menuitem id="view-toolbars-customize-menuitem" + label="&event.menu.view.toolbars.customize.label;" + accesskey="&event.menu.view.toolbars.customize.accesskey;" + command="cmd_customize"/> + </menupopup> + </menu> + <menuseparator id="view-menu-toolbars-separator"/> + <menuitem id="view-show-link-menuitem" + label="&event.menu.view.showlink.label;" + accesskey="&event.menu.view.showlink.accesskey;" + type="checkbox" + command="cmd_toggle_link" + observes="cmd_toggle_link"/> + </menupopup> + </menu> + + <!-- Options menu --> + <menu id="options-menu" + label="&event.menu.options.label;" + accesskey="&event.menu.options.accesskey;"> + <menupopup id="options-menupopup"> + <menuitem id="options-attendees-menuitem" + label="&event.menu.options.attendees.label;" + accesskey="&event.menu.options.attendees.accesskey;" + command="cmd_attendees" + disable-on-readonly="true"/> + <menu id="options-attachments-menu" + label="&event.attachments.menubutton.label;" + accesskey="&event.attachments.menubutton.accesskey;"> + <menupopup id="options-attachments-menupopup"> + <menuitem id="options-attachments-url-menuitem" + label="&event.attachments.url.label;" + accesskey="&event.attachments.url.accesskey;" + command="cmd_attach_url"/> + </menupopup> + </menu> + <menuitem id="options-timezones-menuitem" + label="&event.menu.options.timezone2.label;" + accesskey="&event.menu.options.timezone2.accesskey;" + type="checkbox" + command="cmd_timezone" + disable-on-readonly="true"/> + <menuseparator id="options-menuseparator1"/> + <menu id="options-priority-menu" + label="&event.menu.options.priority2.label;" + accesskey="&event.menu.options.priority2.accesskey;" + disable-on-readonly="true"> + <menupopup id="options-priority-menupopup"> + <menuitem id="options-priority-none-menuitem" + label="&event.menu.options.priority.notspecified.label;" + accesskey="&event.menu.options.priority.notspecified.accesskey;" + type="radio" + command="cmd_priority_none" + disable-on-readonly="true"/> + <menuitem id="options-priority-low-menuitem" + label="&event.menu.options.priority.low.label;" + accesskey="&event.menu.options.priority.low.accesskey;" + type="radio" + command="cmd_priority_low" + disable-on-readonly="true"/> + <menuitem id="options-priority-normal-label" + label="&event.menu.options.priority.normal.label;" + accesskey="&event.menu.options.priority.normal.accesskey;" + type="radio" + command="cmd_priority_normal" + disable-on-readonly="true"/> + <menuitem id="options-priority-high-label" + label="&event.menu.options.priority.high.label;" + accesskey="&event.menu.options.priority.high.accesskey;" + type="radio" + command="cmd_priority_high" + disable-on-readonly="true"/> + </menupopup> + </menu> + <menu id="options-privacy-menu" + label="&event.menu.options.privacy.label;" + accesskey="&event.menu.options.privacy.accesskey;" + disable-on-readonly="true"> + <menupopup id="options-privacy-menupopup"> + <menuitem id="options-privacy-public-menuitem" + label="&event.menu.options.privacy.public.label;" + accesskey="&event.menu.options.privacy.public.accesskey;" + type="radio" + privacy="PUBLIC" + oncommand="editPrivacy(this, event)" + disable-on-readonly="true"/> + <menuitem id="options-privacy-confidential-menuitem" + label="&event.menu.options.privacy.confidential.label;" + accesskey="&event.menu.options.privacy.confidential.accesskey;" + type="radio" + privacy="CONFIDENTIAL" + oncommand="editPrivacy(this, event)" + disable-on-readonly="true"/> + <menuitem id="options-privacy-private-menuitem" + label="&event.menu.options.privacy.private.label;" + accesskey="&event.menu.options.privacy.private.accesskey;" + type="radio" + privacy="PRIVATE" + oncommand="editPrivacy(this, event)" + disable-on-readonly="true"/> + </menupopup> + </menu> + <menu id="options-status-menu" + label="&newevent.status.label;" + accesskey="&newevent.status.accesskey;" + class="event-only" + disable-on-readonly="true"> + <menupopup id="options-status-menupopup"> + <menuitem id="options-status-none-menuitem" + label="&newevent.eventStatus.none.label;" + accesskey="&newevent.eventStatus.none.accesskey;" + type="radio" + command="cmd_status_none" + disable-on-readonly="true"/> + <menuitem id="options-status-tentative-menuitem" + label="&newevent.status.tentative.label;" + accesskey="&newevent.status.tentative.accesskey;" + type="radio" + command="cmd_status_tentative" + disable-on-readonly="true"/> + <menuitem id="options-status-confirmed-menuitem" + label="&newevent.status.confirmed.label;" + accesskey="&newevent.status.confirmed.accesskey;" + type="radio" + command="cmd_status_confirmed" + disable-on-readonly="true"/> + <menuitem id="options-status-canceled-menuitem" + label="&newevent.eventStatus.cancelled.label;" + accesskey="&newevent.eventStatus.cancelled.accesskey;" + type="radio" + command="cmd_status_cancelled" + disable-on-readonly="true"/> + </menupopup> + </menu> + <menuseparator id="options-menuseparator2" class="event-only"/> + <menu id="options-freebusy-menu" + class="event-only" + label="&event.menu.options.show.time.label;" + accesskey="&event.menu.options.show.time.accesskey;" + disable-on-readonly="true"> + <menupopup id="options-freebusy-menupopup"> + <menuitem id="options-freebusy-busy-menuitem" + label="&event.menu.options.show.time.busy.label;" + accesskey="&event.menu.options.show.time.busy.accesskey;" + type="radio" + command="cmd_showtimeas_busy" + disable-on-readonly="true"/> + <menuitem id="options-freebusy-free-menuitem" + label="&event.menu.options.show.time.free.label;" + accesskey="&event.menu.options.show.time.free.accesskey;" + type="radio" + command="cmd_showtimeas_free" + disable-on-readonly="true"/> + </menupopup> + </menu> + </menupopup> + </menu> + </menubar> + + <!-- toolbarpalette items are added with an overlay --> + <toolbarpalette id="event-toolbarpalette"/> + <!-- toolboxid is set here since we move the toolbar around in tabs --> + <toolbar id="event-toolbar" + toolboxid="event-toolbox" + class="chromeclass-toolbar" + customizable="true" + labelalign="end" + defaultlabelalign="end" + context="event-dialog-toolbar-context-menu" + defaultset="button-saveandclose,button-attendees,button-privacy,button-url,button-delete"/> + <toolbarset id="custom-toolbars" context="event-dialog-toolbar-context-menu"/> + </toolbox> + + <!-- the iframe is inserted here dynamically in the "load" handler function --> + + <statusbar class="chromeclass-status" id="status-bar"> + <statusbarpanel id="status-text" + flex="1"/> + <statusbarpanel id="status-privacy" + align="center" + flex="1" + collapsed="true" + pack="start"> + <label value="&event.statusbarpanel.privacy.label;"/> + <hbox id="status-privacy-public-box" privacy="PUBLIC"> + <label value="&event.menu.options.privacy.public.label;"/> + </hbox> + <hbox id="status-privacy-confidential-box" privacy="CONFIDENTIAL"> + <label value="&event.menu.options.privacy.confidential.label;"/> + </hbox> + <hbox id="status-privacy-private-box" privacy="PRIVATE"> + <label value="&event.menu.options.privacy.private.label;"/> + </hbox> + </statusbarpanel> + <statusbarpanel id="status-priority" + align="center" + flex="1" + collapsed="true" + pack="start"> + <label value="&event.priority2.label;"/> + <image id="image-priority-low" + class="cal-statusbar-1" + collapsed="true" + value="low"/> + <image id="image-priority-normal" + class="cal-statusbar-1" + collapsed="true" + value="normal"/> + <image id="image-priority-high" + class="cal-statusbar-1" + collapsed="true" + value="high"/> + </statusbarpanel> + <statusbarpanel id="status-status" + align="center" + flex="1" + collapsed="true" + pack="start"> + <label value="&task.status.label;"/> + <label id="status-status-tentative-label" + value="&newevent.status.tentative.label;" + hidden="true"/> + <label id="status-status-confirmed-label" + value="&newevent.status.confirmed.label;" + hidden="true"/> + <label id="status-status-cancelled-label" + value="&newevent.eventStatus.cancelled.label;" + hidden="true"/> + </statusbarpanel> + <statusbarpanel id="status-freebusy" + class="event-only" + align="center" + flex="1" + collapsed="true" + pack="start"> + <label value="&event.statusbarpanel.freebusy.label;"/> + <label id="status-freebusy-free-label" + value="&event.freebusy.legend.free;" + hidden="true"/> + <label id="status-freebusy-busy-label" + value="&event.freebusy.legend.busy;" + hidden="true"/> + </statusbarpanel> + </statusbar> +</dialog> diff --git a/calendar/base/content/dialogs/calendar-invitations-dialog.css b/calendar/base/content/dialogs/calendar-invitations-dialog.css new file mode 100644 index 000000000..5a693fcb1 --- /dev/null +++ b/calendar/base/content/dialogs/calendar-invitations-dialog.css @@ -0,0 +1,15 @@ +/* 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/. */ + +calendar-invitations-richlistbox { + -moz-binding: url(chrome://calendar/content/calendar-invitations-list.xml#calendar-invitations-richlistbox); +} + +calendar-invitations-richlistitem { + -moz-binding: url(chrome://calendar/content/calendar-invitations-list.xml#calendar-invitations-richlistitem); +} + +calendar-invitations-richlistitem[selected="true"] { + -moz-user-focus: normal; +} diff --git a/calendar/base/content/dialogs/calendar-invitations-dialog.js b/calendar/base/content/dialogs/calendar-invitations-dialog.js new file mode 100644 index 000000000..ce699c805 --- /dev/null +++ b/calendar/base/content/dialogs/calendar-invitations-dialog.js @@ -0,0 +1,119 @@ +/* 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 onLoad, onUnload, onAccept, onCancel */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://calendar/modules/calAlarmUtils.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + +/** + * Sets up the invitations dialog from the window arguments, retrieves the + * invitations from the invitations manager. + */ +function onLoad() { + let operationListener = { + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calIOperationListener]), + onOperationComplete: function(aCalendar, aStatus, aOperationType, aId, aDetail) { + let updatingBox = document.getElementById("updating-box"); + updatingBox.setAttribute("hidden", "true"); + let richListBox = document.getElementById("invitations-listbox"); + if (richListBox.getRowCount() > 0) { + richListBox.selectedIndex = 0; + } else { + let noInvitationsBox = + document.getElementById("noinvitations-box"); + noInvitationsBox.removeAttribute("hidden"); + } + }, + onGetResult: function(aCalendar, aStatus, aItemType, aDetail, aCount, aItems) { + if (!Components.isSuccessCode(aStatus)) { + return; + } + document.title = invitationsText + " (" + aCount + ")"; + let updatingBox = document.getElementById("updating-box"); + updatingBox.setAttribute("hidden", "true"); + let richListBox = document.getElementById("invitations-listbox"); + for (let item of aItems) { + richListBox.addCalendarItem(item); + } + } + }; + + let updatingBox = document.getElementById("updating-box"); + updatingBox.removeAttribute("hidden"); + + let args = window.arguments[0]; + args.invitationsManager.getInvitations(operationListener, + args.onLoadOperationListener); + + opener.setCursor("auto"); +} + +/** + * Cleans up the invitations dialog, cancels pending requests. + */ +function onUnload() { + let args = window.arguments[0]; + args.requestManager.cancelPendingRequests(); +} + +/** + * Handler function to be called when the accept button is pressed. + * + * @return Returns true if the window should be closed + */ +function onAccept() { + let args = window.arguments[0]; + fillJobQueue(args.queue); + args.invitationsManager.processJobQueue(args.queue, args.finishedCallBack); + return true; +} + +/** + * Handler function to be called when the cancel button is pressed. + */ +function onCancel() { + let args = window.arguments[0]; + if (args.finishedCallBack) { + args.finishedCallBack(); + } +} + +/** + * Fills the job queue from the invitations-listbox's items. The job queue + * contains objects for all items that have a modified participation status. + * + * @param queue The queue to fill. + */ +function fillJobQueue(queue) { + let richListBox = document.getElementById("invitations-listbox"); + let rowCount = richListBox.getRowCount(); + for (let i = 0; i < rowCount; i++) { + let richListItem = richListBox.getItemAtIndex(i); + let newStatus = richListItem.participationStatus; + let oldStatus = richListItem.initialParticipationStatus; + if (newStatus != oldStatus) { + let actionString = "modify"; + let oldCalendarItem = richListItem.calendarItem; + let newCalendarItem = oldCalendarItem.clone(); + + // set default alarm on unresponded items that have not been declined: + if (!newCalendarItem.getAlarms({}).length && + (oldStatus == "NEEDS-ACTION") && + (newStatus != "DECLINED")) { + cal.alarms.setDefaultValues(newCalendarItem); + } + + richListItem.setCalendarItemParticipationStatus(newCalendarItem, + newStatus); + let job = { + action: actionString, + oldItem: oldCalendarItem, + newItem: newCalendarItem + }; + queue.push(job); + } + } +} diff --git a/calendar/base/content/dialogs/calendar-invitations-dialog.xul b/calendar/base/content/dialogs/calendar-invitations-dialog.xul new file mode 100644 index 000000000..d8c6eb9c7 --- /dev/null +++ b/calendar/base/content/dialogs/calendar-invitations-dialog.xul @@ -0,0 +1,49 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> +<?xml-stylesheet href="chrome://calendar/content/calendar-invitations-dialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://calendar/skin/calendar-invitations-dialog.css" type="text/css"?> + +<!DOCTYPE dialog [ + <!ENTITY % dtd1 SYSTEM "chrome://calendar/locale/calendar-invitations-dialog.dtd" > %dtd1; +]> + +<dialog + id="calendar-invitations-dialog" + title="&calendar.invitations.dialog.invitations.text;" + windowtype="Calendar:InvitationsDialog" + buttons="accept,cancel" + ondialogaccept="return onAccept();" + ondialogcancel="return onCancel();" + onload="return onLoad();" + onunload="return onUnload();" + persist="screenX screenY width height" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <!-- Javascript includes --> + <script type="application/javascript" src="chrome://calendar/content/calendar-invitations-dialog.js"/> + <script type="application/javascript" src="chrome://calendar/content/calUtils.js"/> + <script type="application/javascript" src="chrome://calendar/content/calendar-ui-utils.js"/> + + <script type="application/javascript" > + var invitationsText = "&calendar.invitations.dialog.invitations.text;"; + </script> + + <vbox id="dialog-box" flex="1"> + <stack flex="1"> + <calendar-invitations-richlistbox id="invitations-listbox" flex="1"/> + <hbox id="updating-box" align="center" pack="center" hidden="true"> + <label value="&calendar.invitations.dialog.statusmessage.updating.text;" + crop="end"/> + <image class="calendar-invitations-updating-icon"/> + </hbox> + <hbox id="noinvitations-box" align="center" pack="center" hidden="true"> + <label value="&calendar.invitations.dialog.statusmessage.noinvitations.text;" + crop="end"/> + </hbox> + </stack> + </vbox> +</dialog> diff --git a/calendar/base/content/dialogs/calendar-invitations-list.xml b/calendar/base/content/dialogs/calendar-invitations-list.xml new file mode 100644 index 000000000..0ddd27993 --- /dev/null +++ b/calendar/base/content/dialogs/calendar-invitations-list.xml @@ -0,0 +1,240 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<!DOCTYPE dialog [ + <!ENTITY % dtd1 SYSTEM "chrome://calendar/locale/calendar-invitations-dialog.dtd" > %dtd1; +]> + +<bindings id="calendar-invitations-list-bindings" + xmlns="http://www.mozilla.org/xbl" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:xbl="http://www.mozilla.org/xbl"> + <binding id="calendar-invitations-richlistbox" + extends="chrome://global/content/bindings/richlistbox.xml#richlistbox" + xbl:inherits="flex"> + <implementation> + <!-- methods --> + <method name="addCalendarItem"> + <parameter name="aItem"/> + <body><![CDATA[ + let newNode = createXULElement("calendar-invitations-richlistitem"); + this.appendChild(newNode); + newNode.setAttribute("anonid", "invitations-listitem"); + newNode.calendarItem = aItem; + ]]></body> + </method> + </implementation> + </binding> + + <binding id="calendar-invitations-richlistitem" + extends="chrome://global/content/bindings/richlistbox.xml#richlistitem"> + <content> + <xul:hbox align="start" flex="1"> + <xul:image anonid="icon" class="calendar-invitations-richlistitem-icon"/> + <xul:vbox flex="1"> + <xul:label anonid="title" class="calendar-invitations-richlistitem-title" + crop="end"/> + <xul:label anonid="date" crop="end"/> + <xul:label anonid="recurrence" crop="end"/> + <xul:label anonid="location" crop="end"/> + <xul:label anonid="organizer" crop="end"/> + <xul:label anonid="attendee" crop="end"/> + <xul:label anonid="spacer" value="" hidden="true"/> + </xul:vbox> + <xul:vbox> + <xul:button anonid="accept" + xbl:inherits="group=itemId" + type="radio" + class="calendar-invitations-richlistitem-accept-button + calendar-invitations-richlistitem-button" + label="&calendar.invitations.list.accept.button.label;" + oncommand="accept();"/> + <xul:button anonid="decline" + xbl:inherits="group=itemId" + type="radio" + class="calendar-invitations-richlistitem-decline-button + calendar-invitations-richlistitem-button" + label="&calendar.invitations.list.decline.button.label;" + oncommand="decline();"/> + </xul:vbox> + </xul:hbox> + </content> + + <implementation> + <!-- fields --> + <field name="mDateFormatter">null</field> + <field name="mCalendarItem">null</field> + <field name="mInitialParticipationStatus">null</field> + <field name="mParticipationStatus">null</field> + + <property name="mStrings"> + <getter> + return { + alldayEvent: "&calendar.invitations.list.alldayevent.text;", + recurrentEvent: "&calendar.invitations.list.recurrentevent.text;", + location: "&calendar.invitations.list.location.text;", + organizer: "&calendar.invitations.list.organizer.text;", + attendee: "&calendar.invitations.list.attendee.text;", + none: "&calendar.invitations.list.none.text;" + }; + </getter> + </property> + + <!-- properties --> + <property name="calendarItem"> + <getter><![CDATA[ + return this.mCalendarItem; + ]]></getter> + <setter><![CDATA[ + this.setCalendarItem(val); + return val; + ]]></setter> + </property> + + <property name="initialParticipationStatus"> + <getter><![CDATA[ + return this.mInitialParticipationStatus; + ]]></getter> + <setter><![CDATA[ + this.mInitialParticipationStatus = val; + return val; + ]]></setter> + </property> + + <property name="participationStatus"> + <getter><![CDATA[ + return this.mParticipationStatus; + ]]></getter> + <setter><![CDATA[ + this.mParticipationStatus = val; + let icon = document.getAnonymousElementByAttribute( + this, "anonid", "icon"); + icon.setAttribute("status", val); + return val; + ]]></setter> + </property> + + <!-- constructor --> + <constructor><![CDATA[ + Components.utils.import("resource://calendar/modules/calUtils.jsm"); + this.mDateFormatter = getDateFormatter(); + ]]></constructor> + + <!-- methods --> + <method name="setCalendarItem"> + <parameter name="aItem"/> + <body><![CDATA[ + this.mCalendarItem = aItem; + this.mInitialParticipationStatus = + this.getCalendarItemParticipationStatus(aItem); + this.participationStatus = this.mInitialParticipationStatus; + + let titleLabel = document.getAnonymousElementByAttribute( + this, "anonid", "title"); + titleLabel.setAttribute("value", aItem.title); + + let dateLabel = document.getAnonymousElementByAttribute( + this, "anonid", "date"); + let dateString = this.mDateFormatter.formatItemInterval(aItem); + if (aItem.startDate.isDate) { + dateString += ", " + this.mStrings.alldayEvent; + } + dateLabel.setAttribute("value", dateString); + + let recurrenceLabel = document.getAnonymousElementByAttribute( + this, "anonid", "recurrence"); + if (aItem.recurrenceInfo) { + recurrenceLabel.setAttribute("value", this.mStrings.recurrentEvent); + } else { + recurrenceLabel.setAttribute("hidden", "true"); + let spacer = document.getAnonymousElementByAttribute( + this, "anonid", "spacer"); + spacer.removeAttribute("hidden"); + } + + let locationLabel = document.getAnonymousElementByAttribute( + this, "anonid", "location"); + let locationString = this.mStrings.location; + let locationProperty = aItem.getProperty("LOCATION"); + if (locationProperty && locationProperty.length > 0) { + locationString += locationProperty; + } else { + locationString += this.mStrings.none; + } + locationLabel.setAttribute("value", locationString); + + let organizerLabel = document.getAnonymousElementByAttribute( + this, "anonid", "organizer"); + let organizerString = this.mStrings.organizer; + let org = aItem.organizer; + if (org) { + if (org.commonName && org.commonName.length > 0) { + organizerString += org.commonName; + } else if (org.id) { + organizerString += org.id.replace(/^mailto:/i, ""); + } + } + organizerLabel.setAttribute("value", organizerString); + + let attendeeLabel = document.getAnonymousElementByAttribute( + this, "anonid", "attendee"); + let attendeeString = this.mStrings.attendee; + let att = cal.getInvitedAttendee(aItem); + if (att) { + if (att.commonName && att.commonName.length > 0) { + attendeeString += att.commonName; + } else if (att.id) { + attendeeString += att.id.replace(/^mailto:/i, ""); + } + } + attendeeLabel.setAttribute("value", attendeeString); + this.setAttribute("itemId", aItem.hashId); + ]]> + </body> + </method> + + <method name="getCalendarItemParticipationStatus"> + <parameter name="aItem"/> + <body><![CDATA[ + let att = cal.getInvitedAttendee(aItem); + return (att ? att.participationStatus : null); + ]]></body> + </method> + + <method name="setCalendarItemParticipationStatus"> + <parameter name="aItem"/> + <parameter name="aStatus"/> + <body><![CDATA[ + let calendar = cal.wrapInstance(aItem.calendar, Components.interfaces.calISchedulingSupport); + if (calendar) { + let att = calendar.getInvitedAttendee(aItem); + if (att) { + let att_ = att.clone(); + att_.participationStatus = aStatus; + + // Update attendee + aItem.removeAttendee(att); + aItem.addAttendee(att_); + return true; + } + } + return false; + ]]></body> + </method> + + <method name="accept"> + <body><![CDATA[ + this.participationStatus = "ACCEPTED"; + ]]></body> + </method> + + <method name="decline"> + <body><![CDATA[ + this.participationStatus = "DECLINED"; + ]]></body> + </method> + </implementation> + </binding> +</bindings> diff --git a/calendar/base/content/dialogs/calendar-migration-dialog.js b/calendar/base/content/dialogs/calendar-migration-dialog.js new file mode 100644 index 000000000..331debe89 --- /dev/null +++ b/calendar/base/content/dialogs/calendar-migration-dialog.js @@ -0,0 +1,647 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +var SUNBIRD_UID = "{718e30fb-e89b-41dd-9da7-e25a45638b28}"; +var FIREFOX_UID = "{ec8030f7-c20a-464f-9b0e-13a3a9e97384}"; + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); +Components.utils.import("resource://gre/modules/Preferences.jsm"); + +// +// The front-end wizard bits. +// +var gMigrateWizard = { + /** + * Called from onload of the migrator window. Takes all of the migrators + * that were passed in via window.arguments and adds them to checklist. The + * user can then check these off to migrate the data from those sources. + */ + loadMigrators: function gmw_load() { + var listbox = document.getElementById("datasource-list"); + + //XXX Once we have branding for lightning, this hack can go away + var props = Services.strings.createBundle("chrome://calendar/locale/migration.properties"); + + var wizard = document.getElementById("migration-wizard"); + var desc = document.getElementById("wizard-desc"); + // Since we don't translate "Lightning"... + wizard.title = props.formatStringFromName("migrationTitle", + ["Lightning"], + 1); + desc.textContent = props.formatStringFromName("migrationDescription", + ["Lightning"], + 1); + + migLOG("migrators: " + window.arguments.length); + for (var migrator of window.arguments[0]) { + var listItem = document.createElement("listitem"); + listItem.setAttribute("type", "checkbox"); + listItem.setAttribute("checked", true); + listItem.setAttribute("label", migrator.title); + listItem.migrator = migrator; + listbox.appendChild(listItem); + } + }, + + /** + * Called from the second page of the wizard. Finds all of the migrators + * that were checked and begins migrating their data. Also controls the + * progress dialog so the user can see what is happening. (somewhat) + */ + migrateChecked: function gmw_migrate() { + var migrators = []; + + // Get all the checked migrators into an array + var listbox = document.getElementById("datasource-list"); + for (var i = listbox.childNodes.length-1; i >= 0; i--) { + if (listbox.childNodes[i].getAttribute("checked")) { + migrators.push(listbox.childNodes[i].migrator); + } + } + + // If no migrators were checked, then we're done + if (migrators.length == 0) { + window.close(); + } + + // Don't let the user get away while we're migrating + //XXX may want to wire this into the 'cancel' function once that's + // written + var wizard = document.getElementById("migration-wizard"); + wizard.canAdvance = false; + wizard.canRewind = false; + + // We're going to need this for the progress meter's description + var props = Services.strings.createBundle("chrome://calendar/locale/migration.properties"); + var label = document.getElementById("progress-label"); + var meter = document.getElementById("migrate-progressmeter"); + + var i = 0; + // Because some of our migrators involve async code, we need this + // call-back function so we know when to start the next migrator. + function getNextMigrator() { + if (migrators[i]) { + var mig = migrators[i]; + + // Increment i to point to the next migrator + i++; + migLOG("starting migrator: " + mig.title); + label.value = props.formatStringFromName("migratingApp", + [mig.title], + 1); + meter.value = (i-1)/migrators.length*100; + mig.args.push(getNextMigrator); + + try { + mig.migrate.apply(mig, mig.args); + } catch (e) { + migLOG("Failed to migrate: " + mig.title); + migLOG(e); + getNextMigrator(); + } + } else { + migLOG("migration done"); + wizard.canAdvance = true; + label.value = props.GetStringFromName("finished"); + meter.value = 100; + gMigrateWizard.setCanRewindFalse(); + } + } + + // And get the first migrator + getNextMigrator(); + }, + + /** + * Makes sure the wizard "back" button can not be pressed. + */ + setCanRewindFalse: function gmw_finish() { + document.getElementById('migration-wizard').canRewind = false; + } +}; + +// +// The more back-end data detection bits +// + + +/** + * A data migrator prototype, holding the information for migration + * + * @class + * @param aTitle The title of the migrator + * @param aMigrateFunction The function to call when migrating + * @param aArguments The arguments to pass in. + */ +function dataMigrator(aTitle, aMigrateFunction, aArguments) { + this.title = aTitle; + this.migrate = aMigrateFunction; + this.args = aArguments || []; +} + +var gDataMigrator = { + mIsInFirefox: false, + mPlatform: null, + mDirService: null, + mIoService: null, + + /** + * Cached getter for the directory service. + */ + get dirService() { + if (!this.mDirService) { + this.mDirService = Services.dirsvc; + } + return this.mDirService; + }, + + /** + * Call to do a general data migration (for a clean profile) Will run + * through all of the known migrator-checkers. These checkers will return + * an array of valid dataMigrator objects, for each kind of data they find. + * If there is at least one valid migrator, we'll pop open the migration + * wizard, otherwise, we'll return silently. + */ + checkAndMigrate: function gdm_migrate() { + if (Services.appinfo.ID == FIREFOX_UID) { + this.mIsInFirefox = true; + // We can't handle Firefox Lightning yet + migLOG("Holy cow, you're Firefox-Lightning! sorry, can't help."); + return; + } + + this.mPlatform = Services.appinfo.OS.toLowerCase(); + + migLOG("mPlatform is: " + this.mPlatform); + + var DMs = []; + var migrators = [this.checkOldCal, + this.checkEvolution, + this.checkWindowsMail, + this.checkIcal]; + // XXX also define a category and an interface here for pluggability + for (var migrator of migrators) { + var migs = migrator.call(this); + for (var dm of migs) { + DMs.push(dm); + } + } + + if (DMs.length == 0) { + // No migration available + return; + } + migLOG("DMs: " + DMs.length); + + var url = "chrome://calendar/content/calendar-migration-dialog.xul"; + openDialog(url, "migration", "modal,centerscreen,chrome,resizable=no,width=500,height=400", DMs); + }, + + /** + * Checks to see if we can find any traces of an older moz-cal program. + * This could be either the old calendar-extension, or Sunbird 0.2. If so, + * it offers to move that data into our new storage format. + */ + checkOldCal: function gdm_calold() { + migLOG("Checking for the old calendar extension/app"); + + // This is the function that the migration wizard will call to actually + // migrate the data. It's defined here because we may use it multiple + // times (with different aProfileDirs), for instance if there is both + // a Thunderbird and Firefox cal-extension + function extMigrator(aProfileDir, aCallback) { + // Get the old datasource + var dataSource = aProfileDir.clone(); + dataSource.append("CalendarManager.rdf"); + if (!dataSource.exists()) { + return; + } + + // Let this be a lesson to anyone designing APIs. The RDF API is so + // impossibly confusing that it's actually simpler/cleaner/shorter + // to simply parse as XML and use the better DOM APIs. + var req = Components.classes["@mozilla.org/xmlextras/xmlhttprequest;1"] + .createInstance(Components.interfaces.nsIXMLHttpRequest); + req.open('GET', "file://" + dataSource.path, true); + req.onreadystatechange = function calext_onreadychange() { + if (req.readyState == 4) { + migLOG(req.responseText); + parseAndMigrate(req.responseXML, aCallback) + } + }; + req.send(null); + } + + // Callback from the XHR above. Parses CalendarManager.rdf and imports + // the data describe therein. + function parseAndMigrate(aDoc, aCallback) { + // For duplicate detection + var calManager = getCalendarManager(); + var uris = []; + for (var oldCal of calManager.getCalendars({})) { + uris.push(oldCal.uri); + } + + function getRDFAttr(aNode, aAttr) { + return aNode.getAttributeNS("http://home.netscape.com/NC-rdf#", + aAttr); + } + + const RDFNS = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; + var nodes = aDoc.getElementsByTagNameNS(RDFNS, "Description"); + migLOG("nodes: " + nodes.length); + for (var i = 0; i < nodes.length; i++) { + migLOG("Beginning calendar node"); + var calendar; + var node = nodes[i]; + if (getRDFAttr(node, "remote") == "false") { + migLOG("not remote"); + var localFile = Components.classes["@mozilla.org/file/local;1"] + .createInstance(Components.interfaces.nsILocalFile); + localFile.initWithPath(getRDFAttr(node, "path")); + calendar = gDataMigrator.importICSToStorage(localFile); + } else { + // Remote subscription + // XXX check for duplicates + var url = makeURL(getRDFAttr(node, "remotePath")); + calendar = calManager.createCalendar("ics", url); + } + calendar.name = getRDFAttr(node, "name"); + calendar.setProperty("color", getRDFAttr(node, "color")); + calManager.registerCalendar(calendar); + getCompositeCalendar().addCalendar(calendar); + } + aCallback(); + } + + var migrators = []; + + // Look in our current profile directory, in case we're upgrading in + // place + var profileDir = this.dirService.get("ProfD", Components.interfaces.nsILocalFile); + profileDir.append("Calendar"); + if (profileDir.exists()) { + migLOG("Found old extension directory in current app"); + let title = "Mozilla Calendar Extension"; + migrators.push(new dataMigrator(title, extMigrator, [profileDir])); + } + + // Check the profiles of the various other moz-apps for calendar data + var profiles = []; + + // Do they use Firefox? + var ffProf, sbProf, tbProf; + if ((ffProf = this.getFirefoxProfile())) { + profiles.push(ffProf); + } + + // We're lightning, check Sunbird + if ((sbProf = this.getSunbirdProfile())) { + profiles.push(sbProf); + } + + // Now check all of the profiles in each of these folders for data + for (var prof of profiles) { + var dirEnum = prof.directoryEntries; + while (dirEnum.hasMoreElements()) { + var profile = dirEnum.getNext().QueryInterface(Components.interfaces.nsIFile); + if (profile.isFile()) { + continue; + } else { + profile.append("Calendar"); + if (profile.exists()) { + migLOG("Found old extension directory at" + profile.path); + var title = "Mozilla Calendar"; + migrators.push(new dataMigrator(title, extMigrator, [profile])); + } + } + } + } + + return migrators; + }, + + /** + * Checks to see if Apple's iCal is installed and offers to migrate any data + * the user has created in it. + */ + checkIcal: function gdm_ical() { + migLOG("Checking for ical data"); + + function icalMigrate(aDataDir, aCallback) { + aDataDir.append("Sources"); + var dirs = aDataDir.directoryEntries; + var calManager = getCalendarManager(); + + var i = 1; + while(dirs.hasMoreElements()) { + var dataDir = dirs.getNext().QueryInterface(Components.interfaces.nsIFile); + var dataStore = dataDir.clone(); + dataStore.append("corestorage.ics"); + if (!dataStore.exists()) { + continue; + } + + var chars = []; + var fileStream = Components.classes["@mozilla.org/network/file-input-stream;1"] + .createInstance(Components.interfaces.nsIFileInputStream); + + fileStream.init(dataStore, 0x01, parseInt("0444", 8), {}); + var convStream = Components.classes["@mozilla.org/intl/converter-input-stream;1"] + .getService(Components.interfaces.nsIConverterInputStream); + convStream.init(fileStream, 'UTF-8', 0, 0x0000); + var tmpStr = {}; + var str = ""; + while (convStream.readString(-1, tmpStr)) { + str += tmpStr.value; + } + + // Strip out the timezone definitions, since it makes the file + // invalid otherwise + var index = str.indexOf(";TZID="); + while (index != -1) { + var endIndex = str.indexOf(':', index); + var otherEnd = str.indexOf(';', index+2); + if (otherEnd < endIndex) { + endIndex = otherEnd; + } + var sub = str.substring(index, endIndex); + str = str.split(sub).join(""); + index = str.indexOf(";TZID="); + } + var tempFile = gDataMigrator.dirService.get("TmpD", Components.interfaces.nsIFile); + tempFile.append("icalTemp.ics"); + tempFile.createUnique(Components.interfaces.nsIFile.NORMAL_FILE_TYPE, + parseInt("0600", 8)); + var tempUri = Services.io.newFileURI(tempFile); + + var stream = Components.classes["@mozilla.org/network/file-output-stream;1"] + .createInstance(Components.interfaces.nsIFileOutputStream); + stream.init(tempFile, 0x2A, parseInt("0600", 8), 0); + var convStream = Components.classes["@mozilla.org/intl/converter-output-stream;1"] + .createInstance(Components.interfaces.nsIConverterOutputStream); + convStream.init(stream, 'UTF-8', 0, 0x0000); + convStream.writeString(str); + + var calendar = gDataMigrator.importICSToStorage(tempFile); + calendar.name = "iCalendar"+i; + i++; + calManager.registerCalendar(calendar); + getCompositeCalendar().addCalendar(calendar); + } + migLOG("icalMig making callback"); + aCallback(); + } + var profileDir = this.dirService.get("ProfD", Components.interfaces.nsILocalFile); + var icalSpec = profileDir.path; + var diverge = icalSpec.indexOf("Thunderbird"); + if (diverge == -1) { + return []; + } + icalSpec = icalSpec.substr(0, diverge); + var icalFile = Components.classes["@mozilla.org/file/local;1"] + .createInstance(Components.interfaces.nsILocalFile); + icalFile.initWithPath(icalSpec); + icalFile.append("Application Support"); + + icalFile.append("iCal"); + if (icalFile.exists()) { + return [new dataMigrator("Apple iCal", icalMigrate, [icalFile])]; + } + + return []; + }, + + /** + * Checks to see if Evolution is installed and offers to migrate any data + * stored there. + */ + checkEvolution: function gdm_evolution() { + function evoMigrate(aDataDir, aCallback) { + var i = 1; + function evoDataMigrate(dataStore) { + migLOG("Migrating evolution data file in " + dataStore.path); + if (dataStore.exists()) { + var calendar = gDataMigrator.importICSToStorage(dataStore); + calendar.name = "Evolution " + (i++); + calManager.registerCalendar(calendar); + getCompositeCalendar().addCalendar(calendar); + } + return dataStore.exists(); + } + + var calManager = getCalendarManager(); + var dirs = aDataDir.directoryEntries; + while (dirs.hasMoreElements()) { + var dataDir = dirs.getNext().QueryInterface(Components.interfaces.nsIFile); + var dataStore = dataDir.clone(); + dataStore.append("calendar.ics"); + evoDataMigrate(dataStore); + } + + aCallback(); + } + + var evoDir = this.dirService.get("Home", Components.interfaces.nsILocalFile); + evoDir.append(".evolution"); + evoDir.append("calendar"); + evoDir.append("local"); + return (evoDir.exists() ? [new dataMigrator("Evolution", evoMigrate, [evoDir])] : []); + }, + + checkWindowsMail: function gdm_windowsMail() { + function doMigrate(aCalendarNodes, aMailDir, aCallback) { + let calManager = cal.getCalendarManager(); + + for (let node of aCalendarNodes) { + let name = node.getElementsByTagName("Name")[0].textContent; + let color = node.getElementsByTagName("Color")[0].textContent; + let enabled = node.getElementsByTagName("Enabled")[0].textContent == "True"; + + // The name is quoted, and the color also contains an alpha + // value. Lets just ignore the alpha value and take the + // color part. + name = name.replace(/(^'|'$)/g, ""); + color = color.replace(/0x[0-9a-fA-F]{2}([0-9a-fA-F]{4})/, "#$1"); + + let calfile = aMailDir.clone(); + calfile.append(name + ".ics"); + + if (calfile.exists()) { + let storage = gDataMigrator.importICSToStorage(calfile) + storage.name = name; + + if (color) { + storage.setProperty("color", color); + } + calManager.registerCalendar(storage); + + if (enabled) { + getCompositeCalendar().addCalendar(storage); + } + } + } + aCallback(); + } + + if (!this.dirService.has("LocalAppData")) { + // We are probably not on windows + return []; + } + + let maildir = this.dirService.get("LocalAppData", + Components.interfaces.nsILocalFile); + + maildir.append("Microsoft"); + maildir.append("Windows Calendar"); + maildir.append("Calendars"); + + let settingsxml = maildir.clone(); + settingsxml.append("Settings.xml"); + + let migrators = []; + if (settingsxml.exists()) { + let settingsXmlUri = Services.io.newFileURI(settingsxml); + + let req = new XMLHttpRequest(); + req.open("GET", settingsXmlUri.spec, false); + req.send(null); + if (req.status == 0) { + // The file was found, it seems we are on windows vista. + let doc = req.responseXML; + let root = doc.documentElement; + + // Get all calendar property tags and return the migrator. + let calendars = doc.getElementsByTagName("VCalendar"); + if (calendars.length > 0) { + migrators = [new dataMigrator("Windows Calendar", doMigrate.bind(null, calendars, maildir))]; + } + } + } + return migrators; + }, + + /** + * Creates and registers a storage calendar and imports the given ics file into it. + * + * @param icsFile The nsI(Local)File to import. + */ + importICSToStorage: function migrateIcsStorage(icsFile) { + const uri = 'moz-storage-calendar://'; + let calendar = cal.getCalendarManager().createCalendar("storage", makeURL(uri)); + let icsImporter = Components.classes["@mozilla.org/calendar/import;1?type=ics"] + .getService(Components.interfaces.calIImporter); + + let inputStream = Components.classes["@mozilla.org/network/file-input-stream;1"] + .createInstance(Components.interfaces.nsIFileInputStream); + let items = []; + + calendar.id = cal.getUUID(); + + try { + inputStream.init(icsFile, MODE_RDONLY, parseInt("0444", 8), {}); + items = icsImporter.importFromStream(inputStream, {}); + } catch(ex) { + switch (ex.result) { + case Components.interfaces.calIErrors.INVALID_TIMEZONE: + showError(calGetString("calendar", "timezoneError", [icsFile.path])); + break; + default: + showError(calGetString("calendar", "unableToRead") + icsFile.path + "\n"+ ex); + } + } finally { + inputStream.close(); + } + + // Defined in import-export.js + putItemsIntoCal(calendar, items, icsFile.leafName); + + return calendar; + }, + + /** + * Helper functions for getting the profile directory of various MozApps + * (Getting the profile dir is way harder than it should be.) + * + * Sunbird: + * Unix: ~jdoe/.mozilla/sunbird/ + * Windows: %APPDATA%\Mozilla\Sunbird\Profiles + * Mac OS X: ~jdoe/Library/Application Support/Sunbird/Profiles + * + * Firefox: + * Unix: ~jdoe/.mozilla/firefox/ + * Windows: %APPDATA%\Mozilla\Firefox\Profiles + * Mac OS X: ~jdoe/Library/Application Support/Firefox/Profiles + * + * Thunderbird: + * Unix: ~jdoe/.thunderbird/ + * Windows: %APPDATA%\Thunderbird\Profiles + * Mac OS X: ~jdoe/Library/Thunderbird/Profiles + * + * Notice that Firefox and Sunbird follow essentially the same pattern, so + * we group them with getNormalProfile + */ + getFirefoxProfile: function gdm_getFF() { + return this.getNormalProfile("Firefox"); + }, + + /** + * @see getFirefoxProfile + */ + getThunderbirdProfile: function gdm_getTB() { + let profileRoot = this.dirService.get("DefProfRt", Components.interfaces.nsILocalFile); + migLOG("searching for Thunderbird in " + profileRoot.path); + return profileRoot.exists() ? profileRoot : null; + }, + + /** + * @see getFirefoxProfile + */ + getSunbirdProfile: function gdm_getSB() { + return this.getNormalProfile("Sunbird"); + }, + + /** + * Common function to retrieve the profile directory for a given app. + * @see getFirefoxProfile + */ + getNormalProfile: function gdm_getNorm(aAppName) { + var localFile; + var profileRoot = this.dirService.get("DefProfRt", Components.interfaces.nsILocalFile); + migLOG("profileRoot = " + profileRoot.path); + + switch (this.mPlatform) { + case "winnt": + localFile = profileRoot.parent.parent; + localFile.append("Mozilla"); + localFile.append(aAppName); + localFile.append("Profiles"); + break; + default: // Unix + localFile = profileRoot.parent; + localFile.append(".mozilla"); + localFile.append(aAppName.toLowerCase()); + break; + } + migLOG("searching for " + aAppName + " in " + localFile.path); + return localFile.exists() ? localFile : null; + } +}; + +/** + * logs to system and error console, depending on the calendar.migration.log + * preference. + * + * XXX Use log4moz instead. + * + * @param aString The string to log + */ +function migLOG(aString) { + if (!Preferences.get("calendar.migration.log", false)) { + return; + } + Services.console.logStringMessage(aString); + dump(aString+"\n"); +} diff --git a/calendar/base/content/dialogs/calendar-migration-dialog.xul b/calendar/base/content/dialogs/calendar-migration-dialog.xul new file mode 100644 index 000000000..467eab05b --- /dev/null +++ b/calendar/base/content/dialogs/calendar-migration-dialog.xul @@ -0,0 +1,48 @@ +<?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/. --> + +<!-- Style sheets --> +<?xml-stylesheet href="chrome://global/skin/" type="text/css"?> + +<!DOCTYPE dialog +[ + <!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd"> + %brandDTD; + <!ENTITY % migrationDtd SYSTEM "chrome://calendar/locale/migration.dtd"> + %migrationDtd; +]> + +<wizard id="migration-wizard" + title="&migration.title;" + windowtype="Calendar:MigrationWizard" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + onload="gMigrateWizard.loadMigrators()" + branded="true" + persist="screenX screenY"> + + <script type="application/javascript" src="chrome://calendar/content/calendar-migration-dialog.js"/> + <script type="application/javascript" src="chrome://calendar/content/import-export.js"/> + <script type="application/javascript" src="chrome://calendar/content/calUtils.js"/> + + <wizardpage id="wizardPage1" + pageid="initialPage" + next="progressPage" + label="&migration.welcome;"> + <label id="wizard-desc" control="datasource-list">&migration.list.description;</label> + <listbox id="datasource-list" flex="1"> + </listbox> + </wizardpage> + + <wizardpage id="wizardPage2" + pageid="progressPage" + label="&migration.importing;" + onpageshow="gMigrateWizard.migrateChecked()"> + <label control="migrate-progressmeter">&migration.progress.description;</label> + <vbox flex="1"> + <progressmeter id="migrate-progressmeter" mode="determined" value="0" /> + <label value="" flex="1" id="progress-label"/> + </vbox> + </wizardpage> +</wizard> diff --git a/calendar/base/content/dialogs/calendar-occurrence-prompt.xul b/calendar/base/content/dialogs/calendar-occurrence-prompt.xul new file mode 100644 index 000000000..9aea75baf --- /dev/null +++ b/calendar/base/content/dialogs/calendar-occurrence-prompt.xul @@ -0,0 +1,85 @@ +<?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/. --> + +<?xml-stylesheet href="chrome://global/skin/" type="text/css"?> +<?xml-stylesheet href="chrome://calendar-common/skin/calendar-occurrence-prompt.css" type="text/css"?> + +<!DOCTYPE dialog SYSTEM "chrome://calendar/locale/calendar-occurrence-prompt.dtd"> + +<dialog id="calendar-occurrence-prompt" + buttons="accept,cancel" + windowtype="Calendar:OccurrencePrompt" + ondialogcancel="return exitOccurrenceDialog(0)" + ondialogaccept="exitOccurrenceDialog(1)" + onload="onLoad()" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:xhtml2="http://www.w3.org/TR/xhtml2" + xmlns:wairole="http://www.w3.org/2005/01/wai-rdf/GUIRoleTaxonomy#" + xhtml2:role="wairole:alertdialog"> + <script type="application/javascript"><![CDATA[ + Components.utils.import("resource://calendar/modules/calUtils.jsm"); + function exitOccurrenceDialog(aReturnValue) { + window.arguments[0].value = aReturnValue; + window.close(); + return true; + } + + function getDialogString(key) { + return cal.calGetString("calendar-occurrence-prompt", key); + } + + function onLoad() { + var action = window.arguments[0].action || "edit"; + var itemType = (cal.isEvent(window.arguments[0].item) ? "event" : "task"); + + // Set up title + document.title = getDialogString("windowtitle." + itemType + "." + action); + document.getElementById("title-label").value = window.arguments[0].item.title; + + // Set up header + document.getElementById("isrepeating-label").value = + getDialogString("header.isrepeating." + itemType + ".label"); + + // Set up buttons + document.getElementById("accept-buttons-box") + .setAttribute("action", action); + + document.getElementById("accept-occurrence-button").label = + getDialogString("buttons.occurrence." + action + ".label"); + + document.getElementById("accept-allfollowing-button").label = + getDialogString("buttons.allfollowing." + action + ".label"); + document.getElementById("accept-parent-button").label = + getDialogString("buttons.parent." + action + ".label"); + } + ]]></script> + + <vbox id="occurrence-prompt-header" pack="center"> + <label id="title-label" crop="end"/> + <label id="isrepeating-label"/> + </vbox> + + <vbox id="accept-buttons-box" flex="1" pack="center"> + <button id="accept-occurrence-button" + default="true" + dlgtype="accept" + class="occurrence-accept-buttons" + accesskey="&buttons.occurrence.accesskey;" + oncommand="exitOccurrenceDialog(1)" + pack="start"/> + <!-- XXXphilipp Button is hidden until all following is implemented --> + <button id="accept-allfollowing-button" + class="occurrence-accept-buttons" + accesskey="&buttons.allfollowing.accesskey;" + oncommand="exitOccurrenceDialog(2)" + hidden="true" + pack="start"/> + <button id="accept-parent-button" + class="occurrence-accept-buttons" + accesskey="&buttons.parent.accesskey;" + oncommand="exitOccurrenceDialog(3)" + pack="start"/> + </vbox> +</dialog> diff --git a/calendar/base/content/dialogs/calendar-print-dialog.js b/calendar/base/content/dialogs/calendar-print-dialog.js new file mode 100644 index 000000000..94afeeb0c --- /dev/null +++ b/calendar/base/content/dialogs/calendar-print-dialog.js @@ -0,0 +1,320 @@ +/* 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 loadCalendarPrintDialog, printAndClose, onDatePick */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + +/** + * Gets the calendar view from the opening window + */ +function getCalendarView() { + let theView = window.opener.currentView(); + if (!theView.startDay) { + theView = null; + } + return theView; +} + +/** + * Loads the print dialog, setting up all needed elements. + */ +function loadCalendarPrintDialog() { + // set the datepickers to the currently selected dates + let theView = getCalendarView(); + if (theView) { + document.getElementById("start-date-picker").value = cal.dateTimeToJsDate(theView.startDay); + document.getElementById("end-date-picker").value = cal.dateTimeToJsDate(theView.endDay); + } else { + document.getElementById("printCurrentViewRadio").setAttribute("disabled", true); + } + if (!theView || !theView.getSelectedItems({}).length) { + document.getElementById("selected").setAttribute("disabled", true); + } + document.getElementById(theView ? "printCurrentViewRadio" : "custom-range") + .setAttribute("selected", true); + + // Get a list of formatters + let catman = Components.classes["@mozilla.org/categorymanager;1"] + .getService(Components.interfaces.nsICategoryManager); + let catenum = catman.enumerateCategory("cal-print-formatters"); + + // Walk the list, adding items to the layout menupopup + let layoutList = document.getElementById("layout-field"); + while (catenum.hasMoreElements()) { + let entry = catenum.getNext(); + entry = entry.QueryInterface(Components.interfaces.nsISupportsCString); + let contractid = catman.getCategoryEntry("cal-print-formatters", entry); + let formatter = Components.classes[contractid] + .getService(Components.interfaces.calIPrintFormatter); + // Use the contractid as value + layoutList.appendItem(formatter.name, contractid); + } + layoutList.selectedIndex = 0; + + opener.setCursor("auto"); + + eventsAndTasksOptions("tasks"); + + refreshHtml(); + + self.focus(); +} + +/** + * Retrieves a settings object containing info on what to print. The + * receiverFunc will be called with the settings object containing various print + * settings. + * + * @param receiverFunc The callback function to call on completion. + */ +function getPrintSettings(receiverFunc) { + let tempTitle = document.getElementById("title-field").value; + let settings = {}; + let requiresFetch = true; + settings.title = tempTitle || calGetString("calendar", "Untitled"); + settings.layoutCId = document.getElementById("layout-field").value; + settings.start = null; + settings.end = null; + settings.eventList = []; + settings.printEvents = document.getElementById("events").checked; + settings.printTasks = document.getElementById("tasks").checked; + settings.printCompletedTasks = document.getElementById("completed-tasks").checked; + settings.printTasksWithNoDueDate = document.getElementById("tasks-with-no-due-date").checked; + let theView = getCalendarView(); + switch (document.getElementById("view-field").selectedItem.value) { + case "currentView": + case "": { // just in case + settings.start = theView.startDay.clone(); + settings.end = theView.endDay.clone(); + settings.end.day += 1; + settings.start.isDate = false; + settings.end.isDate = false; + break; + } + case "selected": { + let selectedItems = theView.getSelectedItems({}); + settings.eventList = selectedItems.filter((item) => { + if (cal.isEvent(item) && !settings.printEvents) { + return false; + } + if (cal.isToDo(item) && !settings.printTasks) { + return false; + } + return true; + }); + + // If tasks should be printed, also include selected tasks from the + // opening window. + if (settings.printTasks) { + let selectedTasks = window.opener.getSelectedTasks(); + for (let task of selectedTasks) { + settings.eventList.push(task); + } + } + + // We've set the event list above, no need to fetch items below. + requiresFetch = false; + break; + } + case "custom": { + // We return the time from the timepickers using the selected + // timezone, as not doing so in timezones with a positive offset + // from UTC may cause the printout to include the wrong days. + let currentTimezone = cal.calendarDefaultTimezone(); + settings.start = cal.jsDateToDateTime(document.getElementById("start-date-picker").value); + settings.start = settings.start.getInTimezone(currentTimezone); + settings.end = cal.jsDateToDateTime(document.getElementById("end-date-picker").value); + settings.end = settings.end.getInTimezone(currentTimezone); + settings.end = settings.end.clone(); + settings.end.day += 1; + break; + } + default: { + dump("Error : no case in printDialog.js::printCalendar()"); + break; + } + } + + // Some filters above might have filled the events list themselves. If not, + // then fetch the items here. + if (requiresFetch) { + let listener = { + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calIOperationListener]), + onOperationComplete: function(aCalendar, aStatus, aOperationType, aId, aDateTime) { + receiverFunc(settings); + }, + onGetResult: function(aCalendar, aStatus, aItemType, aDetail, aCount, aItems) { + settings.eventList = settings.eventList.concat(aItems); + if (!settings.printTasksWithNoDueDate) { + eventWithDueDate = []; + for (let item of settings.eventList) { + if (item.dueDate || item.endDate) { + eventWithDueDate.push(item); + } + } + settings.eventList = eventWithDueDate; + } + } + }; + let filter = getFilter(settings); + if (filter) { + window.opener.getCompositeCalendar().getItems(filter, 0, settings.start, settings.end, listener); + } else { + // No filter means no items, just complete with the empty list set above + receiverFunc(settings); + } + } else { + receiverFunc(settings); + } +} + +/** + * Sets up the filter for a getItems call based on the javascript settings + * object + * + * @param settings The settings data to base upon + */ +function getFilter(settings) { + let filter = 0; + if (settings.printTasks) { + filter |= Components.interfaces.calICalendar.ITEM_FILTER_TYPE_TODO; + if (settings.printCompletedTasks) { + filter |= Components.interfaces.calICalendar.ITEM_FILTER_COMPLETED_ALL; + } else { + filter |= Components.interfaces.calICalendar.ITEM_FILTER_COMPLETED_NO; + } + } + + if (settings.printEvents) { + filter |= Components.interfaces.calICalendar.ITEM_FILTER_TYPE_EVENT | + Components.interfaces.calICalendar.ITEM_FILTER_CLASS_OCCURRENCES; + } + return filter; +} + +/** + * Looks at the selections the user has made (start date, layout, etc.), and + * updates the HTML in the iframe accordingly. This is also called when a + * dialog UI element has changed, since we'll want to refresh the preview. + */ +function refreshHtml(finishFunc) { + getPrintSettings((settings) => { + document.title = calGetString("calendar", "PrintPreviewWindowTitle", [settings.title]); + + let printformatter = Components.classes[settings.layoutCId] + .createInstance(Components.interfaces.calIPrintFormatter); + let html = ""; + try { + let pipe = Components.classes["@mozilla.org/pipe;1"] + .createInstance(Components.interfaces.nsIPipe); + const PR_UINT32_MAX = 4294967295; // signals "infinite-length" + pipe.init(true, true, 0, PR_UINT32_MAX, null); + printformatter.formatToHtml(pipe.outputStream, + settings.start, + settings.end, + settings.eventList.length, + settings.eventList, + settings.title); + pipe.outputStream.close(); + // convert byte-array to UTF-8 string: + let convStream = Components.classes["@mozilla.org/intl/converter-input-stream;1"] + .createInstance(Components.interfaces.nsIConverterInputStream); + convStream.init(pipe.inputStream, "UTF-8", 0, + Components.interfaces.nsIConverterInputStream.DEFAULT_REPLACEMENT_CHARACTER); + try { + let portion = {}; + while (convStream.readString(-1, portion)) { + html += portion.value; + } + } finally { + convStream.close(); + } + } catch (e) { + Components.utils.reportError("Calendar print dialog:refreshHtml: " + e); + } + + let iframeDoc = document.getElementById("content").contentDocument; + iframeDoc.documentElement.innerHTML = html; + iframeDoc.title = settings.title; + + if (finishFunc) { + finishFunc(); + } + } +); +} + +/** + * This is a nsIWebProgressListener that closes the dialog on completion, makes + * sure printing works without issues + */ +var closeOnComplete = { + onStateChange: function(aProgress, aRequest, aStateFlags, aStatus) { + if (aStateFlags & Components.interfaces.nsIWebProgressListener.STATE_STOP) { + // The request is complete, close the window. + document.documentElement.cancelDialog(); + } + }, + + onProgressChange: function() {}, + onLocationChange: function() {}, + onStatusChange: function() {}, + onSecurityChange: function() {} +}; + +/** + * Prints the document and then closes the window + */ +function printAndClose() { + refreshHtml(() => { + let webBrowserPrint = PrintUtils.getWebBrowserPrint(); + let printSettings = PrintUtils.getPrintSettings(); + + // Evicts "about:blank" header + printSettings.docURL = " "; + + // Start the printing, this is just what PrintUtils does, but we + // apply our own settings. + try { + webBrowserPrint.print(printSettings, closeOnComplete); + if (gPrintSettingsAreGlobal && gSavePrintSettings) { + let PSSVC = Components.classes["@mozilla.org/gfx/printsettings-service;1"] + .getService(Components.interfaces.nsIPrintSettingsService); + PSSVC.savePrintSettingsToPrefs(printSettings, true, + printSettings.kInitSaveAll); + PSSVC.savePrintSettingsToPrefs(printSettings, false, + printSettings.kInitSavePrinterName); + } + } catch (e) { + // Pressing cancel is expressed as an NS_ERROR_ABORT return value, + // causing an exception to be thrown which we catch here. + if (e.result != Components.results.NS_ERROR_ABORT) { + throw e; + } + } + }); + return false; // leave open +} + +/** + * Called when once a date has been selected in the datepicker. + */ +function onDatePick() { + calRadioGroupSelectItem("view-field", "custom-range"); + setTimeout(refreshHtml, 0); +} + +function eventsAndTasksOptions(targetId) { + let checkbox = document.getElementById(targetId); + let checked = checkbox.getAttribute("checked") == "true"; + // Workaround to make the checkbox persistent (bug 15232). + checkbox.setAttribute("checked", checked ? "true" : "false"); + + if (targetId == "tasks") { + setElementValue("tasks-with-no-due-date", !checked, "disabled"); + setElementValue("completed-tasks", !checked, "disabled"); + } +} diff --git a/calendar/base/content/dialogs/calendar-print-dialog.xul b/calendar/base/content/dialogs/calendar-print-dialog.xul new file mode 100644 index 000000000..2e179c7f8 --- /dev/null +++ b/calendar/base/content/dialogs/calendar-print-dialog.xul @@ -0,0 +1,131 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<?xml-stylesheet href="chrome://global/skin/" type="text/css"?> +<?xml-stylesheet href="chrome://calendar/content/datetimepickers/datetimepickers.css" type="text/css"?> + +<!DOCTYPE dialog [ + <!ENTITY % dtd1 SYSTEM "chrome://calendar/locale/global.dtd" > %dtd1; + <!ENTITY % dtd2 SYSTEM "chrome://calendar/locale/calendar.dtd" > %dtd2; +]> + + +<dialog id="calendar-new-printwindow" + title="&calendar.print.window.title;" + windowtype="Calendar:PrintDialog" + onload="loadCalendarPrintDialog();" + buttons="accept,cancel" + buttonlabelaccept="&calendar.print.button.label;" + buttonaccesskeyaccept="&calendar.print.button.accesskey;" + defaultButton="accept" + ondialogaccept="return printAndClose();" + ondialogcancel="return true;" + persist="screenX screenY width height" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <script type="application/javascript" src="chrome://calendar/content/calendar-print-dialog.js"/> + <script type="application/javascript" src="chrome://calendar/content/calUtils.js"/> + <script type="application/javascript" src="chrome://calendar/content/calendar-ui-utils.js"/> + <script type="application/javascript" src="chrome://global/content/printUtils.js"/> + + <hbox id="firstHbox" flex="1"> + <vbox id="groupboxVbox"> + <groupbox id="settingsGroup"> + <caption label="&calendar.print.settingsGroup.label;"/> + + <grid> + <columns> + <column/> + <column flex="1"/> + </columns> + + <rows> + <row align="center"> + <label control="title-field" + value="&calendar.print.title.label;"/> + <textbox id="title-field" + class="padded" + flex="1" + onchange="refreshHtml();"/> + </row> + <row align="center"> + <label control="layout-field" + value="&calendar.print.layout.label;"/> + <hbox> + <menulist id="layout-field"> + <!-- This menupopup will be populated by calendar-print-dialog.js! --> + <menupopup id="layout-menulist-menupopup" + oncommand="refreshHtml();"/> + </menulist> + <spacer flex="1"/> + </hbox> + </row> + </rows> + </grid> + </groupbox> + + <groupbox id="what-to-print-group"> + <caption label="&calendar.print.range.label;"/> + <grid id="grid-events-and-tasks"> + <columns id="columns-for-events-and-tasks"> + <column id="column-event"> + <checkbox id="events" label="&calendar.print.events.label;" checked="true" + oncommand="eventsAndTasksOptions(this.id); refreshHtml();" persist="checked" autocheck="false"/> + </column> + <column id="column-tasks"> + <checkbox id="tasks" label="&calendar.print.tasks.label;" checked="true" + oncommand="eventsAndTasksOptions(this.id); refreshHtml();" persist="checked" autocheck="false"/> + </column> + </columns> + </grid> + <radiogroup id="view-field" + oncommand="refreshHtml();"> + <radio id="printCurrentViewRadio" + label="&calendar.print.currentView2.label;" + value="currentView"/> + <radio id="selected" + label="&calendar.print.selectedEventsAndTasks.label;" + value="selected"/> + <radio id="custom-range" + label="&calendar.print.custom.label;" + value="custom"/> + <grid> + <columns> + <column/> + <column flex="1"/> + </columns> + + <rows> + <row align="center"> + <label control="start-date-picker" + value="&calendar.print.from.label;"/> + <datepicker id="start-date-picker" + onchange="onDatePick();"/> + </row> + <row align="center"> + <label control="end-date-picker" + value="&calendar.print.to.label;"/> + <datepicker id="end-date-picker" + onchange="onDatePick();"/> + </row> + </rows> + </grid> + </radiogroup> + </groupbox> + <groupbox id="optionsGroup" label="&calendar.print.optionsGroup.label;"> + <caption label="&calendar.print.optionsGroup.label;"/> + <checkbox id="tasks-with-no-due-date" label="&calendar.print.taskswithnoduedate.label;" checked="true" oncommand="refreshHtml();"/> + <checkbox id="completed-tasks" label="&calendar.print.completedtasks.label;" checked="true" oncommand="refreshHtml();"/> + </groupbox> + </vbox> + + <splitter/> + + <iframe src="about:blank" + id="content" + flex="1" + style="border: 2px solid #3c3c3c; width:30em; height:30em; background-color: white;"/> + </hbox> +</dialog> diff --git a/calendar/base/content/dialogs/calendar-properties-dialog.js b/calendar/base/content/dialogs/calendar-properties-dialog.js new file mode 100644 index 000000000..b52134c71 --- /dev/null +++ b/calendar/base/content/dialogs/calendar-properties-dialog.js @@ -0,0 +1,178 @@ +/* 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 onLoad, onAcceptDialog, unsubscribeCalendar */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://gre/modules/PluralForm.jsm"); + +/** + * The calendar to modify, is retrieved from window.arguments[0].calendar + */ +var gCalendar; + +/** + * This function gets called when the calendar properties dialog gets opened. To + * open the window, use an object as argument. The object needs a 'calendar' + * attribute that passes the calendar in question. + */ +function onLoad() { + gCalendar = window.arguments[0].calendar; + let calColor = gCalendar.getProperty("color"); + + document.getElementById("calendar-name").value = gCalendar.name; + document.getElementById("calendar-color").value = calColor || "#A8C2E1"; + document.getElementById("calendar-uri").value = gCalendar.uri.spec; + document.getElementById("read-only").checked = gCalendar.readOnly; + + // Set up refresh interval + initRefreshInterval(); + + // Set up the cache field + let cacheBox = document.getElementById("cache"); + let canCache = (gCalendar.getProperty("cache.supported") !== false); + let alwaysCache = gCalendar.getProperty("cache.always"); + if (!canCache || alwaysCache) { + cacheBox.setAttribute("disable-capability", "true"); + cacheBox.hidden = true; + cacheBox.disabled = true; + } + cacheBox.checked = alwaysCache || (canCache && gCalendar.getProperty("cache.enabled")); + + // Set up the show alarms row and checkbox + let suppressAlarmsRow = document.getElementById("calendar-suppressAlarms-row"); + let suppressAlarms = gCalendar.getProperty("suppressAlarms"); + document.getElementById("fire-alarms").checked = !suppressAlarms; + + suppressAlarmsRow.hidden = + (gCalendar.getProperty("capabilities.alarms.popup.supported") === false); + + // Set up the disabled checkbox + let calendarDisabled = false; + if (gCalendar.getProperty("force-disabled")) { + showElement("force-disabled-description"); + disableElement("calendar-enabled-checkbox"); + } else { + calendarDisabled = gCalendar.getProperty("disabled"); + document.getElementById("calendar-enabled-checkbox").checked = !calendarDisabled; + hideElement(document.documentElement.getButton("extra1")); + } + setupEnabledCheckbox(); + + // start focus on title, unless we are disabled + if (!calendarDisabled) { + document.getElementById("calendar-name").focus(); + } + + sizeToContent(); +} + +/** + * Called when the dialog is accepted, to save settings. + * + * @return Returns true if the dialog should be closed. + */ +function onAcceptDialog() { + // Save calendar name + gCalendar.name = document.getElementById("calendar-name").value; + + // Save calendar color + gCalendar.setProperty("color", document.getElementById("calendar-color").value); + + // Save readonly state + gCalendar.readOnly = document.getElementById("read-only").checked; + + // Save supressAlarms + gCalendar.setProperty("suppressAlarms", !document.getElementById("fire-alarms").checked); + + // Save refresh interval + if (gCalendar.canRefresh) { + let value = getElementValue("calendar-refreshInterval-menulist"); + gCalendar.setProperty("refreshInterval", value); + } + + // Save cache options + let alwaysCache = gCalendar.getProperty("cache.always"); + if (!alwaysCache) { + gCalendar.setProperty("cache.enabled", document.getElementById("cache").checked); + } + + if (!gCalendar.getProperty("force-disabled")) { + // Save disabled option (should do this last), remove auto-enabled + gCalendar.setProperty("disabled", !document.getElementById("calendar-enabled-checkbox").checked); + gCalendar.deleteProperty("auto-enabled"); + } + + // tell standard dialog stuff to close the dialog + return true; +} + +/** + * When the calendar is disabled, we need to disable a number of other elements + */ +function setupEnabledCheckbox() { + let isEnabled = document.getElementById("calendar-enabled-checkbox").checked; + let els = document.getElementsByAttribute("disable-with-calendar", "true"); + for (let i = 0; i < els.length; i++) { + els[i].disabled = !isEnabled || (els[i].getAttribute("disable-capability") == "true"); + } +} + +/** + * Called to unsubscribe from a calendar. The button for this function is not + * shown unless the provider for the calendar is missing (i.e force-disabled) + */ +function unsubscribeCalendar() { + let calmgr = cal.getCalendarManager(); + + calmgr.unregisterCalendar(gCalendar); + window.close(); +} + +function initRefreshInterval() { + function createMenuItem(minutes) { + let menuitem = createXULElement("menuitem"); + menuitem.setAttribute("value", minutes); + + let everyMinuteString = cal.calGetString("calendar", "calendarPropertiesEveryMinute"); + let label = PluralForm.get(minutes, everyMinuteString).replace("#1", minutes); + menuitem.setAttribute("label", label); + + return menuitem; + } + + setBooleanAttribute("calendar-refreshInterval-row", "hidden", !gCalendar.canRefresh); + + if (gCalendar.canRefresh) { + let refreshInterval = gCalendar.getProperty("refreshInterval"); + if (refreshInterval === null) { + refreshInterval = 30; + } + + let foundValue = false; + let separator = document.getElementById("calendar-refreshInterval-manual-separator"); + let menulist = document.getElementById("calendar-refreshInterval-menulist"); + for (let min of [1, 5, 15, 30, 60]) { + let menuitem = createMenuItem(min); + + separator.parentNode.insertBefore(menuitem, separator); + if (refreshInterval == min) { + menulist.selectedItem = menuitem; + foundValue = true; + } + } + + if (refreshInterval == 0) { + menulist.selectedItem = document.getElementById("calendar-refreshInterval-manual"); + foundValue = true; + } + + if (!foundValue) { + // Special menuitem in case the user changed the value in the config editor. + let menuitem = createMenuItem(refreshInterval); + separator.parentNode.insertBefore(menuitem, separator.nextSibling); + menulist.selectedItem = menuitem; + } + } +} diff --git a/calendar/base/content/dialogs/calendar-properties-dialog.xul b/calendar/base/content/dialogs/calendar-properties-dialog.xul new file mode 100644 index 000000000..f48d31a61 --- /dev/null +++ b/calendar/base/content/dialogs/calendar-properties-dialog.xul @@ -0,0 +1,115 @@ +<?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/. --> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> +<?xml-stylesheet href="chrome://calendar-common/skin/calendar-properties-dialog.css" type="text/css"?> + +<!DOCTYPE dialog +[ + <!ENTITY % dtd1 SYSTEM "chrome://calendar/locale/global.dtd" > %dtd1; + <!ENTITY % dtd2 SYSTEM "chrome://calendar/locale/calendar.dtd" > %dtd2; +]> + +<dialog + id="calendar-properties-dialog-2" + windowtype="Calendar:PropertiesDialog" + title="&calendar.server.dialog.title.edit;" + buttons="accept,cancel,extra1" + buttonlabelextra1="&calendarproperties.unsubscribe.label;" + buttonaccesskeyextra1="&calendarproperties.unsubscribe.accesskey;" + ondialogextra1="unsubscribeCalendar()" + ondialogaccept="return onAcceptDialog();" + ondialogcancel="return true;" + onload="onLoad()" + persist="screenX screenY" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + width="500"> + + <script type="application/javascript" src="chrome://calendar/content/calendar-properties-dialog.js"/> + <script type="application/javascript" src="chrome://calendar/content/calUtils.js"/> + <script type="application/javascript" src="chrome://calendar/content/calendar-ui-utils.js"/> + + <description id="force-disabled-description" hidden="true">&calendarproperties.forceDisabled.label;</description> + + <checkbox id="calendar-enabled-checkbox" + label="&calendarproperties.enabled.label;" + oncommand="setupEnabledCheckbox()"/> + + <grid id="calendar-properties-grid"> + <columns> + <column/> + <column flex="1"/> + </columns> + <rows id="calendar-properties-rows"> + <row id="calendar-name-row" + align="center"> + <label value="&calendar.server.dialog.name.label;" + disable-with-calendar="true" + control="calendar-name"/> + <textbox id="calendar-name" + flex="1" + disable-with-calendar="true"/> + </row> + <row id="calendar-color-row" + align="center"> + <label value="&calendarproperties.color.label;" + disable-with-calendar="true" + control="calendar-color"/> + <hbox align="center"> + <html:input id="calendar-color" + class="small-margin" + type="color" + disable-with-calendar="true"/> + </hbox> + </row> + <row id="calendar-uri-row" align="center"> + <label value="&calendarproperties.location.label;" + disable-with-calendar="true" + control="calendar-uri"/> + <!-- XXX Make location field readonly until Bug 315307 is fixed --> + <textbox id="calendar-uri" readonly="true" disable-with-calendar="true"/> + </row> + <row id="calendar-refreshInterval-row" align="center"> + <label value="&calendarproperties.refreshInterval.label;" + disable-with-calendar="true" + control="calendar-refreshInterval-textbox"/> + <menulist id="calendar-refreshInterval-menulist" + disable-with-calendar="true" + label="&calendarproperties.refreshInterval.label;"> + <menupopup id="calendar-refreshInterval-menupopup"> + <!-- This will be filled programatically to reduce the number of needed strings --> + <menuseparator id="calendar-refreshInterval-manual-separator"/> + <menuitem id="calendar-refreshInterval-manual" + value="0" + label="&calendarproperties.refreshInterval.manual.label;"/> + </menupopup> + </menulist> + </row> + <row id="calendar-readOnly-row" + align="center"> + <spacer/> + <checkbox id="read-only" + label="&calendarproperties.readonly.label;" + disable-with-calendar="true"/> + </row> + <row id="calendar-suppressAlarms-row" + align="center"> + <spacer/> + <checkbox id="fire-alarms" + label="&calendarproperties.firealarms.label;" + disable-with-calendar="true"/> + </row> + <row id="calendar-cache-row" + align="center"> + <spacer/> + <checkbox id="cache" + label="&calendarproperties.cache3.label;" + disable-with-calendar="true"/> + </row> + <spacer/> + </rows> + </grid> +</dialog> diff --git a/calendar/base/content/dialogs/calendar-providerUninstall-dialog.js b/calendar/base/content/dialogs/calendar-providerUninstall-dialog.js new file mode 100644 index 000000000..2231129c7 --- /dev/null +++ b/calendar/base/content/dialogs/calendar-providerUninstall-dialog.js @@ -0,0 +1,38 @@ +/* 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 onLoad, onAccept, onCancel */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); + +function onLoad() { + let extension = window.arguments[0].extension; + document.getElementById("provider-name-label").value = extension.name; + + let calendars = cal.getCalendarManager().getCalendars({}) + .filter(x => x.providerID == extension.id); + + document.getElementById("calendar-list-tree").calendars = calendars; +} + +function onAccept() { + // Tell our caller that the extension should be uninstalled. + let args = window.arguments[0]; + args.shouldUninstall = true; + + // Unsubscribe from all selected calendars + let calendarList = document.getElementById("calendar-list-tree"); + let calendars = calendarList.selectedCalendars || []; + let calMgr = cal.getCalendarManager(); + calendars.forEach(calMgr.unregisterCalendar, calMgr); + + return true; +} + +function onCancel() { + let args = window.arguments[0]; + args.shouldUninstall = false; + + return true; +} diff --git a/calendar/base/content/dialogs/calendar-providerUninstall-dialog.xul b/calendar/base/content/dialogs/calendar-providerUninstall-dialog.xul new file mode 100644 index 000000000..bdebaceb7 --- /dev/null +++ b/calendar/base/content/dialogs/calendar-providerUninstall-dialog.xul @@ -0,0 +1,37 @@ +<?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/. --> + +<?xml-stylesheet type="text/css" href="chrome://global/skin/global.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar-common/skin/calendar-providerUninstall-dialog.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/content/widgets/calendar-widget-bindings.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/calendar-management.css"?> + +<!DOCTYPE dialog SYSTEM "chrome://calendar/locale/provider-uninstall.dtd" > + +<dialog id="calendar-provider-uninstall-dialog" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + title="&providerUninstall.title;" + windowtype="Calendar:ProviderUninstall" + height="320" + width="480" + onload="onLoad()" + buttonlabelaccept="&providerUninstall.accept.label;" + buttonaccesskeyaccept="&providerUninstall.accept.accesskey;" + ondialogaccept="return onAccept()" + ondialogcancel="return onCancel()"> + + <script type="application/javascript" src="chrome://calendar/content/calendar-providerUninstall-dialog.js"/> + <script type="application/javascript" src="chrome://calendar/content/calendar-ui-utils.js"/> + + <description id="pre-name-description">&providerUninstall.preName.label;</description> + <label id="provider-name-label"/> + <description id="post-name-description">&providerUninstall.postName.label;</description> + <description id="reinstall-note-description">&providerUninstall.reinstallNote.label;</description> + + <calendar-list-tree id="calendar-list-tree" + hidecolumnpicker="true" + ignoredisabledstate="true" + flex="1"/> +</dialog> diff --git a/calendar/base/content/dialogs/calendar-subscriptions-dialog.css b/calendar/base/content/dialogs/calendar-subscriptions-dialog.css new file mode 100644 index 000000000..03ad1cc25 --- /dev/null +++ b/calendar/base/content/dialogs/calendar-subscriptions-dialog.css @@ -0,0 +1,13 @@ +/* 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/. */ + +calendar-subscriptions-richlistbox { + -moz-binding: url("chrome://calendar/content/calendar-subscriptions-list.xml#calendar-subscriptions-richlistbox"); + -moz-user-focus: normal; +} + +calendar-subscriptions-richlistitem { + -moz-binding: url("chrome://calendar/content/calendar-subscriptions-list.xml#calendar-subscriptions-richlistitem"); + -moz-user-focus: normal; +} diff --git a/calendar/base/content/dialogs/calendar-subscriptions-dialog.js b/calendar/base/content/dialogs/calendar-subscriptions-dialog.js new file mode 100644 index 000000000..f1fd6ac61 --- /dev/null +++ b/calendar/base/content/dialogs/calendar-subscriptions-dialog.js @@ -0,0 +1,154 @@ +/* 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 onLoad, onUnload, onKeyPress, onTextBoxKeyPress, onAccept, + * onCancel, onSubscribe, onUnsubscribe + */ + +/** + * Cancels any pending search operations. + */ +var gCurrentSearchOperation = null; +function cancelPendingSearchOperation() { + if (gCurrentSearchOperation && gCurrentSearchOperation.isPending) { + gCurrentSearchOperation.cancel(Components.interfaces.calIErrors.OPERATION_CANCELLED); + } + gCurrentSearchOperation = null; +} + +/** + * Sets up the subscriptions dialog. + */ +function onLoad() { + opener.setCursor("auto"); +} + +/** + * Cleans up the subscriptions dialog. + */ +function onUnload() { + cancelPendingSearchOperation(); +} + +/** + * Handler function to handle dialog keypress events. + * (Cancels the search when pressing escape) + */ +function onKeyPress(event) { + switch (event.keyCode) { + case 27: /* ESC */ + if (gCurrentSearchOperation) { + cancelPendingSearchOperation(); + document.getElementById("status-deck").selectedIndex = 0; + event.stopPropagation(); + event.preventDefault(); + } + break; + } +} + +/** + * Handler function to handle keypress events in the textbox. + * (Starts the search when hitting enter) + */ +function onTextBoxKeyPress(event) { + switch (event.keyCode) { + case 13: /* RET */ + onSearch(); + event.stopPropagation(); + event.preventDefault(); + break; + } +} + +/** + * Handler function to be called when the accept button is pressed. + * + * @return Returns true if the window should be closed + */ +function onAccept() { + let richListBox = document.getElementById("subscriptions-listbox"); + let rowCount = richListBox.getRowCount(); + for (let i = 0; i < rowCount; i++) { + let richListItem = richListBox.getItemAtIndex(i); + let checked = richListItem.checked; + if (checked != richListItem.subscribed) { + let calendar = richListItem.calendar; + if (checked) { + getCalendarManager().registerCalendar(calendar); + } else { + getCalendarManager().unregisterCalendar(calendar); + } + } + } + return true; +} + +/** + * Handler function to be called when the cancel button is pressed. + */ +function onCancel() { +} + +/** + * Performs the search for subscriptions, canceling any pending searches. + */ +function onSearch() { + cancelPendingSearchOperation(); + + let richListBox = document.getElementById("subscriptions-listbox"); + richListBox.clear(); + + let registeredCals = {}; + for (let calendar of getCalendarManager().getCalendars({})) { + registeredCals[calendar.id] = true; + } + + let opListener = { + onResult: function(operation, result) { + if (result) { + for (let calendar of result) { + richListBox.addCalendar(calendar, registeredCals[calendar.id]); + } + } + if (!operation.isPending) { + let statusDeck = document.getElementById("status-deck"); + if (richListBox.getRowCount() > 0) { + statusDeck.selectedIndex = 0; + } else { + statusDeck.selectedIndex = 2; + } + } + } + }; + + let operation = getCalendarSearchService().searchForCalendars(document.getElementById("search-textbox").value, + 0 /* hints */, 50, opListener); + if (operation && operation.isPending) { + gCurrentSearchOperation = op; + document.getElementById("status-deck").selectedIndex = 1; + } +} + +/** + * Markes the selected item in the subscriptions-listbox for subscribing. The + * actual subscribe happens when the window is closed. + */ +function onSubscribe() { + let item = document.getElementById("subscriptions-listbox").selectedItem; + if (item && !item.disabled) { + item.checked = true; + } +} + +/** + * Unmarkes the selected item in the subscriptions-listbox for subscribing. The + * actual subscribe happens when the window is closed. + */ +function onUnsubscribe() { + let item = document.getElementById("subscriptions-listbox").selectedItem; + if (item && !item.disabled) { + item.checked = false; + } +} diff --git a/calendar/base/content/dialogs/calendar-subscriptions-dialog.xul b/calendar/base/content/dialogs/calendar-subscriptions-dialog.xul new file mode 100644 index 000000000..02790c650 --- /dev/null +++ b/calendar/base/content/dialogs/calendar-subscriptions-dialog.xul @@ -0,0 +1,85 @@ +<?xml version="1.0"?> +<!-- 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/. +--> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> +<?xml-stylesheet href="chrome://calendar/content/calendar-subscriptions-dialog.css" type="text/css"?> +<?xml-stylesheet href="chrome://calendar-common/skin/calendar-subscriptions-dialog.css" type="text/css"?> + +<!DOCTYPE dialog +[ + <!ENTITY % dtd1 SYSTEM "chrome://calendar/locale/calendar-subscriptions-dialog.dtd" > %dtd1; +]> + +<dialog + id="calendar-subscriptions-dialog" + title="&calendar.subscriptions.dialog.title;" + windowtype="Calendar:SubscriptionsDialog" + buttons="accept,cancel" + ondialogaccept="return onAccept();" + ondialogcancel="return onCancel();" + onload="return onLoad();" + onunload="return onUnload();" + onkeypress="onKeyPress(event);" + persist="screenX screenY width height" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <!-- Javascript includes --> + <script type="application/javascript" src="chrome://calendar/content/calendar-subscriptions-dialog.js"/> + <script type="application/javascript" src="chrome://calendar/content/calUtils.js"/> + <script type="application/javascript" src="chrome://calendar/content/calendar-ui-utils.js"/> + + <vbox flex="1"> + <grid flex="1"> + <columns> + <column flex="1"/> + <column/> + </columns> + <rows> + <row> + <label value="&calendar.subscriptions.dialog.search.label.value;" + crop="end"/> + </row> + <row> + <textbox id="search-textbox" onkeypress="onTextBoxKeyPress(event);"/> + <button label="&calendar.subscriptions.dialog.search.button.label;" + oncommand="onSearch();"/> + </row> + <row> + <label class="calendar-subscriptions-select-label" + value="&calendar.subscriptions.dialog.select.label.value;" + crop="end"/> + </row> + <row flex="1"> + <calendar-subscriptions-richlistbox id="subscriptions-listbox" flex="1"/> + <vbox> + <button id="subscribe-button" + label="&calendar.subscriptions.dialog.subscribe.button.label;" + oncommand="onSubscribe();"/> + <button id="unsubscribe-button" + label="&calendar.subscriptions.dialog.unsubscribe.button.label;" + oncommand="onUnsubscribe();"/> + </vbox> + </row> + </rows> + </grid> + <deck id="status-deck" selectedIndex="0"> + <hbox class="calendar-subscriptions-status-box"> + <image class="calendar-subscriptions-status-icon"/> + </hbox> + <hbox class="calendar-subscriptions-status-box"> + <image class="calendar-subscriptions-status-icon" busy="true"/> + <label value="&calendar.subscriptions.dialog.statusmessage.busy.label;" + crop="end"/> + </hbox> + <hbox class="calendar-subscriptions-status-box"> + <image class="calendar-subscriptions-status-icon"/> + <label value="&calendar.subscriptions.dialog.statusmessage.nomatches.label;" + crop="end"/> + </hbox> + </deck> + <separator class="groove"/> + </vbox> +</dialog> diff --git a/calendar/base/content/dialogs/calendar-summary-dialog.js b/calendar/base/content/dialogs/calendar-summary-dialog.js new file mode 100644 index 000000000..eb94b04db --- /dev/null +++ b/calendar/base/content/dialogs/calendar-summary-dialog.js @@ -0,0 +1,401 @@ +/* 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 onLoad, onAccept, onCancel, updatePartStat, browseDocument, + * sendMailToOrganizer + */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://calendar/modules/calItipUtils.jsm"); +Components.utils.import("resource://calendar/modules/calAlarmUtils.jsm"); +Components.utils.import("resource://calendar/modules/calRecurrenceUtils.jsm"); + +/** + * Sets up the summary dialog, setting all needed fields on the dialog from the + * item received in the window arguments. + */ +function onLoad() { + let args = window.arguments[0]; + let item = args.calendarEvent; + item = item.clone(); // use an own copy of the passed item + window.calendarItem = item; + + // the calling entity provides us with an object that is responsible + // for recording details about the initiated modification. the 'finalize'-property + // is our hook in order to receive a notification in case the operation needs + // to be terminated prematurely. this function will be called if the calling + // entity needs to immediately terminate the pending modification. in this + // case we serialize the item and close the window. + if (args.job) { + // store the 'finalize'-functor in the provided job-object. + args.job.finalize = () => { + // store any pending modifications... + this.onAccept(); + + let calendarItem = window.calendarItem; + + // ...and close the window. + window.close(); + + return calendarItem; + }; + } + + // set the dialog-id to enable the right window-icon to be loaded. + if (cal.isEvent(item)) { + setDialogId(document.documentElement, "calendar-event-summary-dialog"); + } else if (cal.isToDo(item)) { + setDialogId(document.documentElement, "calendar-task-summary-dialog"); + } + + window.attendees = item.getAttendees({}); + + let calendar = cal.wrapInstance(item.calendar, Components.interfaces.calISchedulingSupport); + window.readOnly = !(isCalendarWritable(calendar) && + (userCanModifyItem(item) || + (calendar && + item.calendar.isInvitation(item) && + userCanRespondToInvitation(item)))); + if (!window.readOnly && calendar) { + let attendee = calendar.getInvitedAttendee(item); + if (attendee) { + // if this is an unresponded invitation, preset our default alarm values: + if (!item.getAlarms({}).length && + (attendee.participationStatus == "NEEDS-ACTION")) { + cal.alarms.setDefaultValues(item); + } + + window.attendee = attendee.clone(); + // Since we don't have API to update an attendee in place, remove + // and add again. Also, this is needed if the attendee doesn't exist + // (i.e REPLY on a mailing list) + item.removeAttendee(attendee); + item.addAttendee(window.attendee); + + // make partstat NEEDS-ACTION only available as a option to change to, + // if the user hasn't ever made a decision prior to opening the dialog + let partStat = window.attendee.participationStatus || "NEEDS-ACTION"; + if (partStat == "NEEDS-ACTION" && cal.isEvent(item)) { + document.getElementById("item-participation-needs-action").removeAttribute("hidden"); + } + } + } + + document.getElementById("item-title").value = item.title; + + document.getElementById("item-start-row").Item = item; + document.getElementById("item-end-row").Item = item; + + updateInvitationStatus(); + + // show reminder if this item is *not* readonly. + // this case happens for example if this is an invitation. + let argCalendar = window.arguments[0].calendarEvent.calendar; + let supportsReminders = + (argCalendar.getProperty("capabilities.alarms.oninvitations.supported") !== false); + if (!window.readOnly && supportsReminders) { + document.getElementById("reminder-row").removeAttribute("hidden"); + loadReminders(window.calendarItem.getAlarms({})); + updateReminder(); + } + + updateRepeatDetails(); + updateAttendees(); + updateLink(); + + let location = item.getProperty("LOCATION"); + if (location && location.length) { + document.getElementById("location-row").removeAttribute("hidden"); + document.getElementById("item-location").value = location; + } + + let categories = item.getCategories({}); + if (categories.length > 0) { + document.getElementById("category-row").removeAttribute("hidden"); + document.getElementById("item-category").value = categories.join(", "); // TODO l10n-unfriendly + } + + let organizer = item.organizer; + if (organizer && organizer.id) { + document.getElementById("organizer-row").removeAttribute("hidden"); + let cell = document.getElementsByClassName("item-organizer-cell")[0]; + let text = cell.getElementsByTagName("label")[0]; + let icon = cell.getElementsByTagName("img")[0]; + + let role = organizer.role || "REQ-PARTICIPANT"; + let userType = organizer.userType || "INDIVIDUAL"; + let partstat = organizer.participationStatus || "NEEDS-ACTION"; + let orgName = (organizer.commonName && organizer.commonName.length) + ? organizer.commonName : organizer.toString(); + let userTypeString = cal.calGetString("calendar", "dialog.tooltip.attendeeUserType2." + userType, + [organizer.toString()]); + let roleString = cal.calGetString("calendar", "dialog.tooltip.attendeeRole2." + role, + [userTypeString]); + let partstatString = cal.calGetString("calendar", "dialog.tooltip.attendeePartStat2." + partstat, + [orgName]); + let tooltip = cal.calGetString("calendar", "dialog.tooltip.attendee.combined", + [roleString, partstatString]); + + text.setAttribute("value", orgName); + cell.setAttribute("tooltiptext", tooltip); + icon.setAttribute("partstat", partstat); + icon.setAttribute("usertype", userType); + icon.setAttribute("role", role); + } + + let status = item.getProperty("STATUS"); + if (status && status.length) { + let statusRow = document.getElementById("status-row"); + for (let i = 0; i < statusRow.childNodes.length; i++) { + if (statusRow.childNodes[i].getAttribute("status") == status) { + statusRow.removeAttribute("hidden"); + if (status == "CANCELLED" && cal.isToDo(item)) { + // There are two labels for CANCELLED, the second one is for + // todo items. Increment the counter here. + i++; + } + statusRow.childNodes[i].removeAttribute("hidden"); + break; + } + } + } + + if (item.hasProperty("DESCRIPTION")) { + let description = item.getProperty("DESCRIPTION"); + if (description && description.length) { + document.getElementById("item-description-box") + .removeAttribute("hidden"); + let textbox = document.getElementById("item-description"); + textbox.value = description; + textbox.inputField.readOnly = true; + } + } + + document.title = item.title; + + let attachments = item.getAttachments({}); + if (attachments.length) { + // we only want to display uri type attachments and no ones received inline with the + // invitation message (having a CID: prefix results in about:blank) here + let attCounter = 0; + attachments.forEach(aAttachment => { + if (aAttachment.uri && aAttachment.uri.spec != "about:blank") { + let attachment = document.getElementById("attachment-template").cloneNode(true); + attachment.removeAttribute("id"); + attachment.removeAttribute("hidden"); + + let label = attachment.getElementsByTagName("label")[0]; + label.setAttribute("value", aAttachment.uri.spec); + label.setAttribute("hashid", aAttachment.hashId); + + let icon = attachment.getElementsByTagName("image")[0]; + let iconSrc = aAttachment.uri.spec.length ? aAttachment.uri.spec : "dummy.html"; + icon.setAttribute("src", "moz-icon://" + iconSrc); + + document.getElementById("item-attachment-cell").appendChild(attachment); + attCounter++; + } + }); + if (attCounter > 0) { + document.getElementById("attachments-row").removeAttribute("hidden"); + } + } + // If this item is read only we remove the 'cancel' button as users + // can't modify anything, thus we go ahead with an 'ok' button only. + if (window.readOnly) { + document.documentElement.getButton("cancel").setAttribute("collapsed", "true"); + } + + window.focus(); + opener.setCursor("auto"); +} + +/** + * Saves any changed information to the item. + * + * @return Returns true if the dialog + */ +function onAccept() { + dispose(); + if (window.readOnly) { + return true; + } + let args = window.arguments[0]; + let oldItem = args.calendarEvent; + let newItem = window.calendarItem; + let calendar = newItem.calendar; + saveReminder(newItem); + args.onOk(newItem, calendar, oldItem); + window.calendarItem = newItem; + return true; +} + +/** + * Called when closing the dialog and any changes should be thrown away. + */ +function onCancel() { + dispose(); + return true; +} + +/** + * Sets the dialog's invitation status dropdown to the value specified by the + * user's invitation status. + */ +function updateInvitationStatus() { + if (!window.readOnly) { + if (window.attendee) { + let invitationRow = document.getElementById("invitation-row"); + invitationRow.removeAttribute("hidden"); + let statusElement = document.getElementById("item-participation"); + statusElement.value = window.attendee.participationStatus || "NEEDS-ACTION"; + } + } +} + +/** + * When the summary dialog is showing an invitation, this function updates the + * user's invitation status from the value chosen in the dialog. + */ +function updatePartStat() { + let statusElement = document.getElementById("item-participation"); + if (window.attendee) { + let item = window.arguments[0]; + let aclEntry = item.calendar.aclEntry; + if (aclEntry) { + let userAddresses = aclEntry.getUserAddresses({}); + if (userAddresses.length > 0 && + !cal.attendeeMatchesAddresses(window.attendee, userAddresses)) { + window.attendee.setProperty("SENT-BY", "mailto:" + userAddresses[0]); + } + } + + window.attendee.participationStatus = statusElement.value; + } +} + +/** + * Updates the dialog w.r.t recurrence, i.e shows a text describing the item's + * recurrence) + */ +function updateRepeatDetails() { + let args = window.arguments[0]; + let item = args.calendarEvent; + + // step to the parent (in order to show the + // recurrence info which is stored at the parent). + item = item.parentItem; + + // retrieve a valid recurrence rule from the currently + // set recurrence info. bail out if there's more + // than a single rule or something other than a rule. + let recurrenceInfo = item.recurrenceInfo; + if (!recurrenceInfo) { + return; + } + + document.getElementById("repeat-row").removeAttribute("hidden"); + + // First of all collapse the details text. If we fail to + // create a details string, we simply don't show anything. + // this could happen if the repeat rule is something exotic + // we don't have any strings prepared for. + let repeatDetails = document.getElementById("repeat-details"); + repeatDetails.setAttribute("collapsed", "true"); + + // Try to create a descriptive string from the rule(s). + let kDefaultTimezone = calendarDefaultTimezone(); + let startDate = item.startDate || item.entryDate; + let endDate = item.endDate || item.dueDate; + startDate = startDate ? startDate.getInTimezone(kDefaultTimezone) : null; + endDate = endDate ? endDate.getInTimezone(kDefaultTimezone) : null; + let detailsString = recurrenceRule2String(recurrenceInfo, startDate, + endDate, startDate.isDate); + + if (!detailsString) { + detailsString = cal.calGetString("calendar-event-dialog", "ruleTooComplexSummary"); + } + + // Now display the string... + let lines = detailsString.split("\n"); + repeatDetails.removeAttribute("collapsed"); + while (repeatDetails.childNodes.length > lines.length) { + repeatDetails.lastChild.remove(); + } + let numChilds = repeatDetails.childNodes.length; + for (let i = 0; i < lines.length; i++) { + if (i >= numChilds) { + let newNode = repeatDetails.firstChild + .cloneNode(true); + repeatDetails.appendChild(newNode); + } + repeatDetails.childNodes[i].value = lines[i]; + repeatDetails.childNodes[i].setAttribute("tooltiptext", detailsString); + } +} + +/** + * Updates the attendee listbox, displaying all attendees invited to the + * window's item. + */ +function updateAttendees() { + if (window.attendees && window.attendees.length) { + document.getElementById("item-attendees").removeAttribute("hidden"); + setupAttendees(); + } +} + +/** + * Updates the reminder, called when a reminder has been selected in the + * menulist. + */ +function updateReminder() { + commonUpdateReminder(); +} + +/** + * Browse the item's attached URL. + * + * XXX This function is broken, should be fixed in bug 471967 + */ +function browseDocument() { + let args = window.arguments[0]; + let item = args.calendarEvent; + let url = item.getProperty("URL"); + launchBrowser(url); +} + +/** + * Extracts the item's organizer and opens a compose window to send the + * organizer an email. + */ +function sendMailToOrganizer() { + let args = window.arguments[0]; + let item = args.calendarEvent; + let organizer = item.organizer; + let email = cal.getAttendeeEmail(organizer, true); + let emailSubject = cal.calGetString("calendar-event-dialog", "emailSubjectReply", [item.title]); + let identity = item.calendar.getProperty("imip.identity"); + sendMailTo(email, emailSubject, null, identity); +} + +/** + * Opens an attachment + * + * @param {AUTF8String} aAttachmentId The hashId of the attachment to open + */ +function openAttachment(aAttachmentId) { + if (!aAttachmentId) { + return; + } + let args = window.arguments[0]; + let item = args.calendarEvent; + let attachments = item.getAttachments({}) + .filter(aAttachment => aAttachment.hashId == aAttachmentId); + if (attachments.length && attachments[0].uri && attachments[0].uri.spec != "about:blank") { + let externalLoader = Components.classes["@mozilla.org/uriloader/external-protocol-service;1"] + .getService(Components.interfaces.nsIExternalProtocolService); + externalLoader.loadUrl(attachments[0].uri); + } +} diff --git a/calendar/base/content/dialogs/calendar-summary-dialog.xul b/calendar/base/content/dialogs/calendar-summary-dialog.xul new file mode 100644 index 000000000..734a6ad8a --- /dev/null +++ b/calendar/base/content/dialogs/calendar-summary-dialog.xul @@ -0,0 +1,300 @@ +<?xml version="1.0"?> +<!-- 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/. +--> + +<?xml-stylesheet type="text/css" href="chrome://global/skin/global.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar-common/skin/calendar-alarms.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar-common/skin/calendar-attendees.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/skin/calendar-event-dialog.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar-common/skin/dialogs/calendar-event-dialog.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/content/datetimepickers/datetimepickers.css"?> +<?xml-stylesheet type="text/css" href="chrome://calendar/content/calendar-bindings.css"?> + +<!DOCTYPE dialog [ + <!ENTITY % globalDTD SYSTEM "chrome://calendar/locale/global.dtd" > + <!ENTITY % calendarDTD SYSTEM "chrome://calendar/locale/calendar.dtd" > + <!ENTITY % dialogDTD SYSTEM "chrome://calendar/locale/calendar-event-dialog.dtd" > + <!ENTITY % brandDTD SYSTEM "chrome://branding/locale/brand.dtd" > + %globalDTD; + %calendarDTD; + %dialogDTD; + %brandDTD; +]> + +<!-- Dialog id is changed during excution to allow different Window-icons + on this dialog. document.loadOverlay() will not work on this one. --> +<dialog id="calendar-summary-dialog" + windowtype="Calendar:EventSummaryDialog" + onload="onLoad()" + ondialogaccept="return onAccept();" + ondialogcancel="return onCancel();" + onresize="rearrangeAttendees();" + persist="screenX screenY width height" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <!-- Javascript includes --> + <script type="application/javascript" + src="chrome://calendar/content/calendar-summary-dialog.js"/> + <script type="application/javascript" + src="chrome://calendar/content/calendar-dialog-utils.js"/> + <script type="application/javascript" + src="chrome://calendar/content/calUtils.js"/> + <script type="application/javascript" + src="chrome://calendar/content/calendar-ui-utils.js"/> + <script type="application/javascript" + src="chrome://calendar/content/calendar-item-editing.js"/> + <script type="application/javascript" + src="chrome://calendar/content/calApplicationUtils.js"/> + + <!-- General --> + <box id="item-general-box" orient="vertical"> + <calendar-caption label="&read.only.general.label;"/> + <box orient="horizontal"> + <grid flex="1"> + <columns> + <column/> + <column flex="1"/> + </columns> + <rows> + <row align="top"> + <label value="&read.only.title.label;"/> + <label id="item-title" crop="end"/> + </row> + <row class="item-date-row" + id="item-start-row" + mode="start" + taskStartLabel="&read.only.task.start.label;" + eventStartLabel="&read.only.event.start.label;" + align="start"/> + <row class="item-date-row" + id="item-end-row" + mode="end" + taskDueLabel="&read.only.task.due.label;" + eventEndLabel="&read.only.event.end.label;" + align="start"/> + <row id="repeat-row" align="top" hidden="true"> + <label value="&read.only.repeat.label;"/> + <box id="repeat-details" orient="vertical"> + <label/> + </box> + </row> + <row id="location-row" align="top" hidden="true"> + <label value="&read.only.location.label;"/> + <label id="item-location" crop="end"/> + </row> + <row id="category-row" align="top" hidden="true"> + <label value="&read.only.category.label;"/> + <label id="item-category" crop="end"/> + </row> + <row id="organizer-row" align="top" hidden="true" class="item-attendees-row"> + <label value="&read.only.organizer.label;"/> + <hbox class="item-organizer-cell"> + <img class="itip-icon"/> + <label id="item-organizer" + class="text-link item-attendees-cell-label" + crop="end" + onclick="sendMailToOrganizer()"/> + <spacer flex="1"/> + </hbox> + </row> + <row id="status-row" align="top" hidden="true"> + <label value="&task.status.label;"/> + <label value="&newevent.status.tentative.label;" hidden="true" status="TENTATIVE"/> + <label value="&newevent.status.confirmed.label;" hidden="true" status="CONFIRMED"/> + <label value="&newevent.eventStatus.cancelled.label;" hidden="true" status="CANCELLED"/> + <label value="&newevent.todoStatus.cancelled.label;" hidden="true" status="CANCELLED"/> + <label value="&newevent.status.needsaction.label;" hidden="true" status="NEEDS-ACTION"/> + <label value="&newevent.status.inprogress.label;" hidden="true" status="IN-PROCESS"/> + <label value="&newevent.status.completed.label;" hidden="true" status="COMPLETED"/> + </row> + <separator id="item-main-separator" flex="1" class="groove" hidden="true"/> + <row id="invitation-row" hidden="true" align="center"> + <label value="&read.only.reply.label;" control="item-participation"/> + <hbox pack="start"> + <menulist id="item-participation" oncommand="updatePartStat()"> + <menupopup> + <menuitem label="&read.only.accept.label;" value="ACCEPTED"/> + <menuitem label="&read.only.tentative.label;" value="TENTATIVE"/> + <menuitem label="&read.only.decline.label;" value="DECLINED"/> + <menuitem label="&read.only.needs.action.label;" value="NEEDS-ACTION" + hidden="true" id="item-participation-needs-action"/> + </menupopup> + </menulist> + </hbox> + </row> + <row id="reminder-row" hidden="true" align="center"> + <label value="&read.only.reminder.label;" control="item-alarm"/> + <hbox id="event-grid-alarm-picker-box" + align="center"> + <menulist id="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 id="reminder-custom-menuitem" + label="&event.reminder.custom.label;" + value="custom"/> + </menupopup> + </menulist> + <hbox id="reminder-details"> + <hbox id="reminder-icon-box" + class="alarm-icons-box" + align="center"/> + <!-- TODO oncommand? onkeypress? --> + <label id="reminder-multiple-alarms-label" + hidden="true" + value="&event.reminder.multiple.label;" + class="text-link" + disable-on-readonly="true" + flex="1" + hyperlink="true" + onclick="updateReminder()"/> + <label id="reminder-single-alarms-label" + hidden="true" + class="text-link" + disable-on-readonly="true" + flex="1" + hyperlink="true" + onclick="updateReminder()"/> + </hbox> + </hbox> + </row> + <row id="attachments-row" + align="top" + hidden="true" + class="item-attachments-row"> + <label value="&read.only.attachments.label;" + control="item-attachment-cell" /> + <vbox id="item-attachment-cell"> + <!-- attachment box template --> + <hbox id="attachment-template" + hidden="true" + align="center" + disable-on-readonly="true"> + <image class="attachment-icon"/> + <label class="text-link item-attachment-cell-label" + onclick="openAttachment(this.getAttribute('hashid'), event)" + crop="end" + flex="1" /> + </hbox> + </vbox> + </row> + </rows> + </grid> + </box> + </box> + + <!-- attendee box template --> + <vbox id="item-attendees-box-template"> + <hbox flex="1" class="item-attendees-row" equalsize="always" hidden="true"> + <box class="item-attendees-cell" hidden="true" flex="1"> + <img class="itip-icon"/> + <label class="item-attendees-cell-label" crop="end" flex="1"/> + </box> + <box hidden="true" flex="1"/> + </hbox> + </vbox> + + <!-- Attendees --> + <box id="item-attendees" orient="vertical" hidden="true" flex="1"> + <spacer class="default-spacer"/> + <calendar-caption label="&read.only.attendees.label;" + control="item-attendees-box"/> + <vbox id="item-attendees-box" flex="1" /> + </box> + + <!-- Description --> + <box id="item-description-box" hidden="true" orient="vertical" flex="1"> + <spacer class="default-spacer"/> + <calendar-caption label="&read.only.description.label;" + control="item-description"/> + <box orient="horizontal" flex="1"> + <textbox id="item-description" + multiline="true" + rows="6" + flex="1"/> + </box> + </box> + + <!-- URL link --> + <box id="event-grid-link-row" hidden="true" orient="vertical"> + <spacer class="default-spacer"/> + <calendar-caption label="&read.only.link.label;" + control="url-link"/> + <label id="url-link" + class="text-link default-indent" + onclick="launchBrowser(this.getAttribute('href'), event)" + oncommand="launchBrowser(this.getAttribute('href'), event)" + crop="end"/> + </box> + +</dialog> diff --git a/calendar/base/content/dialogs/chooseCalendarDialog.xul b/calendar/base/content/dialogs/chooseCalendarDialog.xul new file mode 100644 index 000000000..1fb85eade --- /dev/null +++ b/calendar/base/content/dialogs/chooseCalendarDialog.xul @@ -0,0 +1,91 @@ +<?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/. --> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> + +<!-- DTD File with all strings specific to the file --> +<!DOCTYPE page +[ + <!ENTITY % dtd1 SYSTEM "chrome://calendar/locale/calendar.dtd" > %dtd1; +]> + +<dialog id="chooseCalendar" + title="&calendar.select.dialog.title;" + windowtype="Calendar:CalendarPicker" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + buttons="accept,cancel" + onload="setTimeout('loadCalendars()',0);" + ondialogaccept="return doOK();" + persist="screenX screenY height width"> + + <script type="application/javascript" src="chrome://calendar/content/calendar-ui-utils.js"/> + <script type="application/javascript"><![CDATA[ + function loadCalendars() { + const calendarManager = Components.classes["@mozilla.org/calendar/manager;1"] + .getService(Components.interfaces.calICalendarManager); + var listbox = document.getElementById("calendar-list"); + var composite = window.opener.getCompositeCalendar(); + var selectedIndex = 0; + var calendars; + + if (window.arguments[0].calendars) { + calendars = window.arguments[0].calendars; + } else { + calendars = calendarManager.getCalendars({}); + } + calendars = sortCalendarArray(calendars); + + for (var i = 0; i < calendars.length; i++) { + var calendar = calendars[i]; + var listItem = document.createElement("listitem"); + + var colorCell = document.createElement("listcell"); + try { + var calColor = calendar.getProperty('color'); + colorCell.style.background = calColor || "#a8c2e1"; + } catch(e) {} + listItem.appendChild(colorCell); + + var nameCell = document.createElement("listcell"); + nameCell.setAttribute("label", calendar.name); + listItem.appendChild(nameCell); + + listItem.calendar = calendar; + listbox.appendChild(listItem); + listItem.setAttribute("flex","1"); + + // Select the default calendar of the opening calendar window. + if (calendar.id == composite.defaultCalendar.id) { + selectedIndex = i; + } + } + document.getElementById("prompt").textContent = window.arguments[0].promptText; + + if (calendars.length) { + listbox.ensureIndexIsVisible(selectedIndex); + var selItem = listbox.getItemAtIndex(selectedIndex); + listbox.timedSelect(selItem, 0); + } else { + // If there are no calendars, then disable the accept button + document.documentElement.getButton("accept").setAttribute("disabled", "true"); + } + } + + function doOK() { + var listbox = document.getElementById("calendar-list"); + window.arguments[0].onOk(listbox.selectedItem.calendar); + } + ]]></script> + + <vbox id="dialog-box" flex="1"> + <label id="prompt" control="calendar-list"/> + <listbox id="calendar-list" rows="5" flex="1" seltype="single"> + <listcols> + <listcol/> + <listcol flex="1"/> + </listcols> + </listbox> + </vbox> +</dialog> diff --git a/calendar/base/content/import-export.js b/calendar/base/content/import-export.js new file mode 100644 index 000000000..c4bd1d54c --- /dev/null +++ b/calendar/base/content/import-export.js @@ -0,0 +1,358 @@ +/* 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/. */ + +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + +/* exported loadEventsFromFile, exportEntireCalendar */ + +// File constants copied from file-utils.js +var MODE_RDONLY = 0x01; +var MODE_WRONLY = 0x02; +var MODE_CREATE = 0x08; +var MODE_TRUNCATE = 0x20; + +/** + * Shows a file dialog, reads the selected file(s) and tries to parse events from it. + * + * @param aCalendar (optional) If specified, the items will be imported directly + * into the calendar + */ +function loadEventsFromFile(aCalendar) { + const nsIFilePicker = Components.interfaces.nsIFilePicker; + + let picker = Components.classes["@mozilla.org/filepicker;1"] + .createInstance(nsIFilePicker); + picker.init(window, + calGetString("calendar", "filepickerTitleImport"), + nsIFilePicker.modeOpen); + picker.defaultExtension = "ics"; + + // Get a list of importers + let contractids = []; + let catman = Components.classes["@mozilla.org/categorymanager;1"] + .getService(Components.interfaces.nsICategoryManager); + let catenum = catman.enumerateCategory("cal-importers"); + let currentListLength = 0; + let defaultCIDIndex = 0; + while (catenum.hasMoreElements()) { + let entry = catenum.getNext(); + entry = entry.QueryInterface(Components.interfaces.nsISupportsCString); + let contractid = catman.getCategoryEntry("cal-importers", entry); + let importer; + try { + importer = Components.classes[contractid] + .getService(Components.interfaces.calIImporter); + } catch (e) { + cal.WARN("Could not initialize importer: " + contractid + "\nError: " + e); + continue; + } + let types = importer.getFileTypes({}); + for (let type of types) { + picker.appendFilter(type.description, type.extensionFilter); + if (type.extensionFilter == "*." + picker.defaultExtension) { + picker.filterIndex = currentListLength; + defaultCIDIndex = currentListLength; + } + contractids.push(contractid); + currentListLength++; + } + } + + let rv = picker.show(); + + if (rv != nsIFilePicker.returnCancel && + picker.file && picker.file.path && picker.file.path.length > 0) { + let filterIndex = picker.filterIndex; + if (picker.filterIndex < 0 || picker.filterIndex > contractids.length) { + // For some reason the wrong filter was selected, assume default extension + filterIndex = defaultCIDIndex; + } + + let filePath = picker.file.path; + let importer = Components.classes[contractids[filterIndex]] + .getService(Components.interfaces.calIImporter); + + const nsIFileInputStream = Components.interfaces.nsIFileInputStream; + + let inputStream = Components.classes["@mozilla.org/network/file-input-stream;1"] + .createInstance(nsIFileInputStream); + let items = []; + let exception; + + try { + inputStream.init(picker.file, MODE_RDONLY, parseInt("0444", 8), {}); + items = importer.importFromStream(inputStream, {}); + } catch (ex) { + exception = ex; + switch (ex.result) { + case Components.interfaces.calIErrors.INVALID_TIMEZONE: + showError(cal.calGetString("calendar", "timezoneError", [filePath])); + break; + default: + showError(cal.calGetString("calendar", "unableToRead") + filePath + "\n" + ex); + } + } finally { + inputStream.close(); + } + + if (!items.length && !exception) { + // the ics did not contain any events, so there's no need to proceed. But we should + // notify the user about it, if we haven't before. + showError(cal.calGetString("calendar", "noItemsInCalendarFile", [filePath])); + return; + } + + if (aCalendar) { + putItemsIntoCal(aCalendar, items); + return; + } + + let calendars = cal.getCalendarManager().getCalendars({}); + calendars = calendars.filter(isCalendarWritable); + + if (calendars.length < 1) { + // XXX alert something? + return; + } else if (calendars.length == 1) { + // There's only one calendar, so it's silly to ask what calendar + // the user wants to import into. + putItemsIntoCal(calendars[0], items, filePath); + } else { + // Ask what calendar to import into + let args = {}; + args.onOk = (aCal) => { putItemsIntoCal(aCal, items, filePath); }; + args.calendars = calendars; + args.promptText = calGetString("calendar", "importPrompt"); + openDialog("chrome://calendar/content/chooseCalendarDialog.xul", + "_blank", "chrome,titlebar,modal,resizable", args); + } + } +} + +/** + * Put items into a certain calendar, catching errors and showing them to the + * user. + * + * @param destCal The destination calendar. + * @param aItems An array of items to put into the calendar. + * @param aFilePath The original file path, for error messages. + */ +function putItemsIntoCal(destCal, aItems, aFilePath) { + // Set batch for the undo/redo transaction manager + startBatchTransaction(); + + // And set batch mode on the calendar, to tell the views to not + // redraw until all items are imported + destCal.startBatch(); + + // This listener is needed to find out when the last addItem really + // finished. Using a counter to find the last item (which might not + // be the last item added) + let count = 0; + let failedCount = 0; + let duplicateCount = 0; + // Used to store the last error. Only the last error, because we don't + // wan't to bomb the user with thousands of error messages in case + // something went really wrong. + // (example of something very wrong: importing the same file twice. + // quite easy to trigger, so we really should do this) + let lastError; + let listener = { + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calIOperationListener]), + onOperationComplete: function(aCalendar, aStatus, aOperationType, aId, aDetail) { + count++; + if (!Components.isSuccessCode(aStatus)) { + if (aStatus == Components.interfaces.calIErrors.DUPLICATE_ID) { + duplicateCount++; + } else { + failedCount++; + lastError = aStatus; + } + } + // See if it is time to end the calendar's batch. + if (count == aItems.length) { + destCal.endBatch(); + if (!failedCount && duplicateCount) { + showError(calGetString("calendar", "duplicateError", [duplicateCount, aFilePath])); + } else if (failedCount) { + showError(calGetString("calendar", "importItemsFailed", [failedCount, lastError.toString()])); + } + } + } + }; + + for (let item of aItems) { + // XXX prompt when finding a duplicate. + try { + destCal.addItem(item, listener); + } catch (e) { + failedCount++; + lastError = e; + // Call the listener's operationComplete, to increase the + // counter and not miss failed items. Otherwise, endBatch might + // never be called. + listener.onOperationComplete(null, null, null, null, null); + Components.utils.reportError("Import error: " + e); + } + } + + // End transmgr batch + endBatchTransaction(); +} + +/** + * Save data to a file. Create the file or overwrite an existing file. + * + * @param calendarEventArray (required) Array of calendar events that should + * be saved to file. + * @param aDefaultFileName (optional) Initial filename shown in SaveAs dialog. + */ +function saveEventsToFile(calendarEventArray, aDefaultFileName) { + if (!calendarEventArray || !calendarEventArray.length) { + return; + } + + // Show the 'Save As' dialog and ask for a filename to save to + const nsIFilePicker = Components.interfaces.nsIFilePicker; + + let picker = Components.classes["@mozilla.org/filepicker;1"] + .createInstance(nsIFilePicker); + + picker.init(window, + calGetString("calendar", "filepickerTitleExport"), + nsIFilePicker.modeSave); + + if (aDefaultFileName && aDefaultFileName.length && aDefaultFileName.length > 0) { + picker.defaultString = aDefaultFileName; + } else if (calendarEventArray.length == 1 && calendarEventArray[0].title) { + picker.defaultString = calendarEventArray[0].title; + } else { + picker.defaultString = calGetString("calendar", "defaultFileName"); + } + + picker.defaultExtension = "ics"; + + // Get a list of exporters + let contractids = []; + let catman = Components.classes["@mozilla.org/categorymanager;1"] + .getService(Components.interfaces.nsICategoryManager); + let catenum = catman.enumerateCategory("cal-exporters"); + let currentListLength = 0; + let defaultCIDIndex = 0; + while (catenum.hasMoreElements()) { + let entry = catenum.getNext(); + entry = entry.QueryInterface(Components.interfaces.nsISupportsCString); + let contractid = catman.getCategoryEntry("cal-exporters", entry); + let exporter; + try { + exporter = Components.classes[contractid] + .getService(Components.interfaces.calIExporter); + } catch (e) { + cal.WARN("Could not initialize exporter: " + contractid + "\nError: " + e); + continue; + } + let types = exporter.getFileTypes({}); + for (let type of types) { + picker.appendFilter(type.description, type.extensionFilter); + if (type.extensionFilter == "*." + picker.defaultExtension) { + picker.filterIndex = currentListLength; + defaultCIDIndex = currentListLength; + } + contractids.push(contractid); + currentListLength++; + } + } + + let rv = picker.show(); + + // Now find out as what to save, convert the events and save to file. + if (rv != nsIFilePicker.returnCancel && picker.file && picker.file.path.length > 0) { + let filterIndex = picker.filterIndex; + if (picker.filterIndex < 0 || picker.filterIndex > contractids.length) { + // For some reason the wrong filter was selected, assume default extension + filterIndex = defaultCIDIndex; + } + + let exporter = Components.classes[contractids[filterIndex]] + .getService(Components.interfaces.calIExporter); + + let filePath = picker.file.path; + if (!filePath.includes(".")) { + filePath += "." + exporter.getFileTypes({})[0].defaultExtension; + } + + const nsILocalFile = Components.interfaces.nsILocalFile; + const nsIFileOutputStream = Components.interfaces.nsIFileOutputStream; + + let outputStream; + let localFileInstance = Components.classes["@mozilla.org/file/local;1"] + .createInstance(nsILocalFile); + localFileInstance.initWithPath(filePath); + + outputStream = Components.classes["@mozilla.org/network/file-output-stream;1"] + .createInstance(nsIFileOutputStream); + try { + outputStream.init(localFileInstance, + MODE_WRONLY | MODE_CREATE | MODE_TRUNCATE, + parseInt("0664", 8), + 0); + + // XXX Do the right thing with unicode and stuff. Or, again, should the + // exporter handle that? + exporter.exportToStream(outputStream, + calendarEventArray.length, + calendarEventArray, + null); + outputStream.close(); + } catch (ex) { + showError(calGetString("calendar", "unableToWrite") + filePath); + } + } +} + +/** + * Exports all the events and tasks in a calendar. If aCalendar is not specified, + * the user will be prompted with a list of calendars to choose which one to export. + * + * @param aCalendar (optional) A specific calendar to export + */ +function exportEntireCalendar(aCalendar) { + let itemArray = []; + let getListener = { + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calIOperationListener]), + onOperationComplete: function(aOpCalendar, aStatus, aOperationType, aId, aDetail) { + saveEventsToFile(itemArray, aOpCalendar.name); + }, + onGetResult: function(aOpCalendar, aStatus, aItemType, aDetail, aCount, aItems) { + for (let item of aItems) { + itemArray.push(item); + } + } + }; + + let getItemsFromCal = function(aCal) { + aCal.getItems(Components.interfaces.calICalendar.ITEM_FILTER_ALL_ITEMS, + 0, null, null, getListener); + }; + + if (aCalendar) { + getItemsFromCal(aCalendar); + } else { + let count = {}; + let calendars = getCalendarManager().getCalendars(count); + + if (count.value == 1) { + // There's only one calendar, so it's silly to ask what calendar + // the user wants to import into. + getItemsFromCal(calendars[0]); + } else { + // Ask what calendar to import into + let args = {}; + args.onOk = getItemsFromCal; + args.promptText = calGetString("calendar", "exportPrompt"); + openDialog("chrome://calendar/content/chooseCalendarDialog.xul", + "_blank", "chrome,titlebar,modal,resizable", args); + } + } +} diff --git a/calendar/base/content/preferences/alarms.js b/calendar/base/content/preferences/alarms.js new file mode 100644 index 000000000..aef659636 --- /dev/null +++ b/calendar/base/content/preferences/alarms.js @@ -0,0 +1,137 @@ +/* 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 gAlarmsPane */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); + +/** + * Global Object to hold methods for the alarms pref pane + */ +var gAlarmsPane = { + /** + * Initialize the alarms pref pane. Sets up dialog controls to match the + * values set in prefs. + */ + init: function() { + // Enable/disable the alarm sound URL box and buttons + this.alarmsPlaySoundPrefChanged(); + + // Set the correct singular/plural for the time units + updateMenuLabelsPlural("eventdefalarmlen", "eventdefalarmunit"); + updateMenuLabelsPlural("tododefalarmlen", "tododefalarmunit"); + updateUnitLabelPlural("defaultsnoozelength", "defaultsnoozelengthunit", "minutes"); + }, + + /** + * Converts the given file url to a nsILocalFile + * + * @param aFileURL A string with a file:// url. + * @return The corresponding nsILocalFile. + */ + convertURLToLocalFile: function(aFileURL) { + // Convert the file url into a nsILocalFile + if (aFileURL) { + let fph = Services.io + .getProtocolHandler("file") + .QueryInterface(Components.interfaces.nsIFileProtocolHandler); + return fph.getFileFromURLSpec(aFileURL); + } else { + return null; + } + }, + + /** + * Handler function to be called when the calendar.alarms.soundURL pref has + * changed. Updates the label in the dialog. + */ + readSoundLocation: function() { + let soundUrl = document.getElementById("alarmSoundFileField"); + soundUrl.value = document.getElementById("calendar.alarms.soundURL").value; + if (soundUrl.value.startsWith("file://")) { + soundUrl.label = this.convertURLToLocalFile(soundUrl.value).leafName; + } else { + soundUrl.label = soundUrl.value; + } + soundUrl.image = "moz-icon://" + soundUrl.label + "?size=16"; + return undefined; + }, + + /** + * Causes the default sound to be selected in the dialog controls + */ + useDefaultSound: function() { + let defaultSoundUrl = "chrome://calendar/content/sound.wav"; + document.getElementById("calendar.alarms.soundURL").value = defaultSoundUrl; + document.getElementById("alarmSoundCheckbox").checked = true; + this.readSoundLocation(); + }, + + /** + * Opens a filepicker to open a local sound for the alarm. + */ + browseAlarm: function() { + const nsIFilePicker = Components.interfaces.nsIFilePicker; + let picker = Components.classes["@mozilla.org/filepicker;1"] + .createInstance(nsIFilePicker); + + let bundlePreferences = document.getElementById("bundleCalendarPreferences"); + let title = bundlePreferences.getString("Open"); + let wildmat = "*.wav"; + let label = bundlePreferences.getFormattedString("filterWav", [wildmat], 1); + + picker.init(window, title, nsIFilePicker.modeOpen); + picker.appendFilter(label, wildmat); + picker.appendFilters(nsIFilePicker.filterAll); + + let ret = picker.show(); + + if (ret == nsIFilePicker.returnOK) { + document.getElementById("calendar.alarms.soundURL").value = picker.fileURL.spec; + document.getElementById("alarmSoundCheckbox").checked = true; + this.readSoundLocation(); + } + }, + + /** + * Plays the alarm sound currently selected. + */ + previewAlarm: function() { + let soundUrl = document.getElementById("alarmSoundFileField").value; + let soundIfc = Components.classes["@mozilla.org/sound;1"] + .createInstance(Components.interfaces.nsISound); + let url; + try { + soundIfc.init(); + if (soundUrl && soundUrl.length && soundUrl.length > 0) { + url = Services.io.newURI(soundUrl, null, null); + soundIfc.play(url); + } else { + soundIfc.beep(); + } + } catch (ex) { + dump("alarms.js previewAlarm Exception caught! " + ex + "\n"); + } + }, + + /** + * Handler function to call when the calendar.alarms.playsound preference + * has been changed. Updates the disabled state of fields that depend on + * playing a sound. + */ + alarmsPlaySoundPrefChanged: function() { + let alarmsPlaySoundPref = + document.getElementById("calendar.alarms.playsound"); + + let items = [document.getElementById("alarmSoundFileField"), + document.getElementById("calendar.prefs.alarm.sound.useDefault"), + document.getElementById("calendar.prefs.alarm.sound.browse"), + document.getElementById("calendar.prefs.alarm.sound.play")]; + + for (let i = 0; i < items.length; i++) { + items[i].disabled = !alarmsPlaySoundPref.value; + } + } +}; diff --git a/calendar/base/content/preferences/alarms.xul b/calendar/base/content/preferences/alarms.xul new file mode 100644 index 000000000..4f1f29316 --- /dev/null +++ b/calendar/base/content/preferences/alarms.xul @@ -0,0 +1,239 @@ +<?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/. --> + +<!DOCTYPE overlay [ + <!ENTITY % alarmsDTD SYSTEM "chrome://calendar/locale/preferences/alarms.dtd"> + <!ENTITY % globalDTD SYSTEM "chrome://calendar/locale/global.dtd"> + %alarmsDTD; + %globalDTD; +]> + +<overlay id="AlarmsPaneOverlay" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <vbox id="calPreferencesBoxAlarms"> + <stringbundle id="bundleCalendarPreferences" + src="chrome://calendar/locale/calendar.properties"/> + <script type="application/javascript" + src="chrome://calendar/content/preferences/alarms.js"/> + <script type="application/javascript" + src="chrome://calendar/content/calendar-dialog-utils.js"/> + <script type="application/javascript" + src="chrome://calendar/content/calendar-ui-utils.js"/> + + <preferences> + <preference id="calendar.alarms.playsound" + name="calendar.alarms.playsound" + type="bool" + onchange="gAlarmsPane.alarmsPlaySoundPrefChanged();"/> + <preference id="calendar.alarms.soundURL" + name="calendar.alarms.soundURL" + type="string" + onchange="gAlarmsPane.readSoundLocation();"/> + <preference id="calendar.alarms.show" + name="calendar.alarms.show" + type="bool"/> + <preference id="calendar.alarms.showmissed" + name="calendar.alarms.showmissed" + type="bool"/> + <preference id="calendar.alarms.onforevents" + name="calendar.alarms.onforevents" + type="int"/> + <preference id="calendar.alarms.onfortodos" + name="calendar.alarms.onfortodos" + type="int"/> + <preference id="calendar.alarms.eventalarmlen" + name="calendar.alarms.eventalarmlen" + type="int"/> + <preference id="calendar.alarms.eventalarmunit" + name="calendar.alarms.eventalarmunit" + type="string"/> + <preference id="calendar.alarms.todoalarmlen" + name="calendar.alarms.todoalarmlen" + type="int"/> + <preference id="calendar.alarms.todoalarmunit" + name="calendar.alarms.todoalarmunit" + type="string"/> + <preference id="calendar.alarms.defaultsnoozelength" + name="calendar.alarms.defaultsnoozelength" + type="int"/> + </preferences> + + <groupbox> + <caption label="&pref.alarmgoesoff.label;"/> + <grid id="alarm-sound-grid"> + <columns id="alarm-sound-columns"> + <column id="alarm-sound-label-column"/> + <column id="alarm-sound-content-column" flex="1"/> + </columns> + <rows id="alarm-sound-rows"> + <row id="alarm-sound-soundfile-row" align="center"> + <checkbox id="alarmSoundCheckbox" + preference="calendar.alarms.playsound" + label="&pref.playasound;" + accesskey="&pref.calendar.alarms.playsound.accessKey;"/> + <filefield id="alarmSoundFileField" + flex="1" + preference="calendar.alarms.soundURL" + preference-editable="true" + onsyncfrompreference="return gAlarmsPane.readSoundLocation();"/> + </row> + <row id="alarm-sound-buttons-row"> + <spacer id="alarm-sound-spacer"/> + <hbox id="alarm-sound-buttons-box"> + <button id="calendar.prefs.alarm.sound.useDefault" + flex="1" + label="&pref.calendar.alarms.sound.useDefault.label;" + accesskey="&pref.calendar.alarms.sound.useDefault.accessKey;" + oncommand="gAlarmsPane.useDefaultSound()"/> + <button id="calendar.prefs.alarm.sound.browse" + flex="1" + label="&pref.calendar.alarms.sound.browse.label;" + accesskey="&pref.calendar.alarms.sound.browse.accessKey;" + oncommand="gAlarmsPane.browseAlarm()"/> + <button id="calendar.prefs.alarm.sound.play" + flex="1" + label="&pref.calendar.alarms.sound.play.label;" + accesskey="&pref.calendar.alarms.sound.play.accessKey;" + oncommand="gAlarmsPane.previewAlarm()"/> + </hbox> + </row> + </rows> + </grid> + <hbox align="center" flex="1"> + <checkbox id="alarmshow" + preference="calendar.alarms.show" + label="&pref.showalarmbox;" + accesskey="&pref.calendar.alarms.showAlarmBox.accessKey;"/> + </hbox> + <hbox align="center" flex="1"> + <checkbox id="missedalarms" + preference="calendar.alarms.showmissed" + label="&pref.missedalarms;" + accesskey="&pref.calendar.alarms.missedAlarms.accessKey;"/> + </hbox> + </groupbox> + + <groupbox> + <caption label="&pref.calendar.alarms.defaults.label;"/> + <grid> + <columns> + <column flex="1"/> + <column/> + </columns> + <rows> + <row align="center"> + <label value="&pref.defaultsnoozelength.label;" + accesskey="&pref.defaultsnoozelength.accesskey;" + control="defaultsnoozelength"/> + <hbox align="center"> + <textbox id="defaultsnoozelength" + preference="calendar.alarms.defaultsnoozelength" + type="number" + min="0" + maxlength="4" + size="3" + onselect="updateUnitLabelPlural('defaultsnoozelength','defaultsnoozelengthunit','minutes')" + oninput="updateUnitLabelPlural('defaultsnoozelength','defaultsnoozelengthunit','minutes')"/> + <label id="defaultsnoozelengthunit"/> + </hbox> + </row> + <row align="center"> + <label value="&pref.defalarm4events.label;" + accesskey="&pref.defalarm4events.accesskey;" + control="eventdefalarm"/> + <menulist id="eventdefalarm" + crop="none" + preference="calendar.alarms.onforevents"> + <menupopup id="eventdefalarmpopup"> + <menuitem id="eventdefalarmon" + label="&pref.alarm.on;" + value="1"/> + <menuitem id="eventdefalarmoff" + label="&pref.alarm.off;" + value="0" + selected="true"/> + </menupopup> + </menulist> + </row> + <row align="center"> + <label value="&pref.defalarm4todos.label;" + accesskey="&pref.defalarm4todos.accesskey;" + control="tododefalarm"/> + <menulist id="tododefalarm" + crop="none" + preference="calendar.alarms.onfortodos"> + <menupopup id="tododefalarmpopup"> + <menuitem id="tododefalarmon" + label="&pref.alarm.on;" + value="1"/> + <menuitem id="tododefalarmoff" + label="&pref.alarm.off;" + value="0" + selected="true"/> + </menupopup> + </menulist> + </row> + <row align="center"> + <label value="&pref.defalarmlen4events.label;" + accesskey="&pref.defalarmlen4events.accesskey;" + control="eventdefalarmlen"/> + <hbox align="center"> + <textbox id="eventdefalarmlen" + preference="calendar.alarms.eventalarmlen" + type="number" + min="0" + size="3" + onselect="updateMenuLabelsPlural('eventdefalarmlen','eventdefalarmunit')" + oninput="updateMenuLabelsPlural('eventdefalarmlen','eventdefalarmunit')"/> + <menulist id="eventdefalarmunit" + crop="none" + preference="calendar.alarms.eventalarmunit"> + <menupopup id="eventdefalarmunitpopup"> + <menuitem id="eventdefalarmunitmin" + value="minutes" + selected="true"/> + <menuitem id="eventdefalarmunithour" + value="hours"/> + <menuitem id="eventdefalarmunitday" + value="days"/> + </menupopup> + </menulist> + </hbox> + </row> + <row align="center"> + <label value="&pref.defalarmlen4todos.label;" + accesskey="&pref.defalarmlen4todos.accesskey;" + control="tododefalarmlen"/> + <hbox align="center"> + <textbox id="tododefalarmlen" + preference="calendar.alarms.todoalarmlen" + type="number" + min="0" + size="3" + onselect="updateMenuLabelsPlural('tododefalarmlen','tododefalarmunit')" + oninput="updateMenuLabelsPlural('tododefalarmlen','tododefalarmunit')"/> + <menulist id="tododefalarmunit" + crop="none" + preference="calendar.alarms.todoalarmunit"> + <menupopup id="tododefalarmunitpopup"> + <menuitem id="tododefalarmunitmin" + value="minutes" + selected="true"/> + <menuitem id="tododefalarmunithour" + value="hours"/> + <menuitem id="tododefalarmunitday" + value="days"/> + </menupopup> + </menulist> + </hbox> + </row> + </rows> + </grid> + + </groupbox> + + </vbox> +</overlay> diff --git a/calendar/base/content/preferences/categories.js b/calendar/base/content/preferences/categories.js new file mode 100644 index 000000000..bf70f4acd --- /dev/null +++ b/calendar/base/content/preferences/categories.js @@ -0,0 +1,339 @@ +/* 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 gCategoriesPane */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); +Components.utils.import("resource://gre/modules/Preferences.jsm"); +Components.utils.import("resource://gre/modules/AppConstants.jsm"); + +var gCategoryList; +var categoryPrefBranch = Services.prefs.getBranch("calendar.category.color."); + +/** + * Global Object to hold methods for the categories pref pane + */ +var gCategoriesPane = { + + mCategoryDialog: null, + mWinProp: null, + mLoadInContent: false, + + /** + * Initialize the categories pref pane. Sets up dialog controls to show the + * categories saved in preferences. + */ + init: function() { + // On non-instant-apply platforms, once this pane has been loaded, + // attach our "revert all changes" function to the parent prefwindow's + // "ondialogcancel" event. + let parentPrefWindow = document.documentElement; + if (!parentPrefWindow.instantApply) { + let existingOnDialogCancel = parentPrefWindow.getAttribute("ondialogcancel"); + parentPrefWindow.setAttribute("ondialogcancel", + "gCategoriesPane.panelOnCancel(); " + + existingOnDialogCancel); + } + + // A list of preferences to be reverted when the dialog is cancelled. + // It needs to be a property of the parent to be visible onCancel + if (!("backupPrefList" in parent)) { + parent.backupPrefList = []; + } + + let categories = document.getElementById("calendar.categories.names").value; + + // If no categories are configured load a default set from properties file + if (!categories) { + categories = cal.setupDefaultCategories(); + document.getElementById("calendar.categories.names").value = categories; + } + + gCategoryList = categoriesStringToArray(categories); + + // When categories is empty, split returns an array containing one empty + // string, rather than an empty array. This results in an empty listbox + // child with no corresponding category. + if (gCategoryList.length == 1 && !gCategoryList[0].length) { + gCategoryList.pop(); + } + + this.updateCategoryList(); + + this.mCategoryDialog = "chrome://calendar/content/preferences/editCategory.xul"; + + // Workaround for Bug 1151440 - the HTML color picker won't work + // in linux when opened from modal dialog + this.mWinProp = "centerscreen, chrome, resizable=no"; + if (AppConstants.platform != "linux") { + this.mWinProp += ", modal"; + } + + this.mLoadInContent = Preferences.get( + "mail.preferences.inContent", + false + ); + if (this.mLoadInContent) { + gSubDialog.init(); + } + }, + + /** + * Updates the listbox containing the categories from the categories saved + * in preferences. + */ + + updatePrefs: function() { + cal.sortArrayByLocaleCollator(gCategoryList); + document.getElementById("calendar.categories.names").value = + categoriesArrayToString(gCategoryList); + }, + + updateCategoryList: function() { + this.updatePrefs(); + let listbox = document.getElementById("categorieslist"); + + listbox.clearSelection(); + this.updateButtons(); + + + while (listbox.lastChild.id != "categoryColumns") { + listbox.lastChild.remove(); + } + + for (let i = 0; i < gCategoryList.length; i++) { + let newListItem = document.createElement("listitem"); + let categoryName = document.createElement("listcell"); + categoryName.setAttribute("id", gCategoryList[i]); + categoryName.setAttribute("label", gCategoryList[i]); + let categoryNameFix = formatStringForCSSRule(gCategoryList[i]); + let categoryColor = document.createElement("listcell"); + try { + let colorCode = categoryPrefBranch.getCharPref(categoryNameFix); + categoryColor.setAttribute("id", colorCode); + categoryColor.setAttribute("style", "background-color: " + colorCode + ";"); + } catch (ex) { + categoryColor.setAttribute("label", noneLabel); + } + + newListItem.appendChild(categoryName); + newListItem.appendChild(categoryColor); + listbox.appendChild(newListItem); + } + }, + + /** + * Adds a category, opening the edit category dialog to prompt the user to + * set up the category. + */ + addCategory: function() { + let listbox = document.getElementById("categorieslist"); + listbox.clearSelection(); + this.updateButtons(); + let params = { + title: newTitle, + category: "", + color: null + }; + if (this.mLoadInContent) { + gSubDialog.open(this.mCategoryDialog, "resizable=no", params); + } else { + window.openDialog(this.mCategoryDialog, "addCategory", this.mWinProp, params); + } + }, + + /** + * Edits the currently selected category using the edit category dialog. + */ + editCategory: function() { + let list = document.getElementById("categorieslist"); + let categoryNameFix = formatStringForCSSRule(gCategoryList[list.selectedIndex]); + let currentColor = null; + try { + currentColor = categoryPrefBranch.getCharPref(categoryNameFix); + } catch (ex) { + // If the pref doesn't exist, don't bail out here. + } + let params = { + title: editTitle, + category: gCategoryList[list.selectedIndex], + color: currentColor + }; + if (list.selectedItem) { + if (this.mLoadInContent) { + gSubDialog.open(this.mCategoryDialog, "resizable=no", params); + } else { + window.openDialog(this.mCategoryDialog, "editCategory", this.mWinProp, params); + } + } + }, + + /** + * Removes the selected category. + */ + deleteCategory: function() { + let list = document.getElementById("categorieslist"); + if (list.selectedCount < 1) { + return; + } + + let categoryNameFix = formatStringForCSSRule(gCategoryList[list.selectedIndex]); + this.backupData(categoryNameFix); + try { + categoryPrefBranch.clearUserPref(categoryNameFix); + } catch (ex) { + // If the pref doesn't exist, don't bail out here. + } + + // Remove category entry from listbox and gCategoryList. + let newSelection = list.selectedItem.nextSibling || + list.selectedItem.previousSibling; + let selectedItems = Array.slice(list.selectedItems).concat([]); + for (let i = list.selectedCount - 1; i >= 0; i--) { + let item = selectedItems[i]; + if (item == newSelection) { + newSelection = newSelection.nextSibling || + newSelection.previousSibling; + } + gCategoryList.splice(list.getIndexOfItem(item), 1); + item.remove(); + } + list.selectedItem = newSelection; + this.updateButtons(); + + // Update the prefs from gCategoryList + this.updatePrefs(); + }, + + /** + * Saves the given category to the preferences. + * + * @param categoryName The name of the category. + * @param categoryColor The color of the category + */ + saveCategory: function(categoryName, categoryColor) { + let list = document.getElementById("categorieslist"); + // Check to make sure another category doesn't have the same name + let toBeDeleted = -1; + for (let i = 0; i < gCategoryList.length; i++) { + if (i == list.selectedIndex) { + continue; + } + + if (categoryName.toLowerCase() == gCategoryList[i].toLowerCase()) { + if (Services.prompt.confirm(null, overwriteTitle, overwrite)) { + if (list.selectedIndex != -1) { + // Don't delete the old category yet. It will mess up indices. + toBeDeleted = list.selectedIndex; + } + list.selectedIndex = i; + } else { + return; + } + } + } + + if (categoryName.length == 0) { + Services.prompt.alert(null, null, noBlankCategories); + return; + } + + let categoryNameFix = formatStringForCSSRule(categoryName); + if (list.selectedIndex == -1) { + this.backupData(categoryNameFix); + gCategoryList.push(categoryName); + if (categoryColor) { + categoryPrefBranch.setCharPref(categoryNameFix, categoryColor); + } + } else { + this.backupData(categoryNameFix); + gCategoryList.splice(list.selectedIndex, 1, categoryName); + if (categoryColor) { + categoryPrefBranch.setCharPref(categoryNameFix, categoryColor); + } else { + try { + categoryPrefBranch.clearUserPref(categoryNameFix); + } catch (ex) { + dump("Exception caught in 'saveCategory': " + ex + "\n"); + } + } + } + + // If 'Overwrite' was chosen, delete category that was being edited + if (toBeDeleted != -1) { + list.selectedIndex = toBeDeleted; + this.deleteCategory(); + } + + this.updateCategoryList(); + + let updatedCategory = gCategoryList.indexOf(categoryName); + list.ensureIndexIsVisible(updatedCategory); + list.selectedIndex = updatedCategory; + }, + + /** + * Enable the edit and delete category buttons. + */ + updateButtons: function() { + let categoriesList = document.getElementById("categorieslist"); + document.getElementById("deleteCButton").disabled = (categoriesList.selectedCount <= 0); + document.getElementById("editCButton").disabled = (categoriesList.selectedCount != 1); + }, + + /** + * Backs up the category name in case the dialog is canceled. + * + * @see formatStringForCSSRule + * @param categoryNameFix The formatted category name. + */ + backupData: function(categoryNameFix) { + let currentColor; + try { + currentColor = categoryPrefBranch.getCharPref(categoryNameFix); + } catch (ex) { + dump("Exception caught in 'backupData': " + ex + "\n"); + currentColor = "##NEW"; + } + + for (let i = 0; i < parent.backupPrefList.length; i++) { + if (categoryNameFix == parent.backupPrefList[i].name) { + return; + } + } + parent.backupPrefList[parent.backupPrefList.length] = + { name: categoryNameFix, color: currentColor }; + }, + + /** + * Event Handler function to be called on doubleclick of the categories + * list. If the edit function is enabled and the user doubleclicked on a + * list item, then edit the selected category. + */ + listOnDblClick: function(event) { + if (event.target.localName == "listitem" && + !document.getElementById("editCButton").disabled) { + this.editCategory(); + } + }, + + /** + * Reverts category preferences in case the cancel button is pressed. + */ + panelOnCancel: function() { + for (let i = 0; i < parent.backupPrefList.length; i++) { + if (parent.backupPrefList[i].color == "##NEW") { + try { + categoryPrefBranch.clearUserPref(parent.backupPrefList[i].name); + } catch (ex) { + dump("Exception caught in 'panelOnCancel': " + ex + "\n"); + } + } else { + categoryPrefBranch.setCharPref(parent.backupPrefList[i].name, + parent.backupPrefList[i].color); + } + } + } +}; diff --git a/calendar/base/content/preferences/categories.xul b/calendar/base/content/preferences/categories.xul new file mode 100644 index 000000000..23339e273 --- /dev/null +++ b/calendar/base/content/preferences/categories.xul @@ -0,0 +1,60 @@ +<?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/. --> + +<!DOCTYPE overlay SYSTEM "chrome://calendar/locale/preferences/categories.dtd"> + +<overlay id="CategoriesPaneOverlay" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <vbox id="calPreferencesBoxCategories"> + <script type="application/javascript" + src="chrome://calendar/content/preferences/categories.js"/> + + <!-- Get the localized text for use in the .js --> + <script type="application/javascript"> + var noneLabel = "&pref.categories.none.label;"; + var newTitle = "&pref.categories.new.title;"; + var editTitle = "&pref.categories.edit.title;"; + var overwrite = "&pref.categories.overwrite;"; + var overwriteTitle = "&pref.categories.overwrite.title;"; + var noBlankCategories = "&pref.categories.noBlankCategories;"; + </script> + + <preferences> + <preference id="calendar.categories.names" + name="calendar.categories.names" + type="string"/> + </preferences> + + <listbox id="categorieslist" + flex="1" + seltype="multiple" + onselect="gCategoriesPane.updateButtons()" + ondblclick="gCategoriesPane.listOnDblClick(event)"> + <listhead> + <listheader label="&pref.categories.name.label;"/> + <listheader label="&pref.categories.color.label;"/> + </listhead> + <listcols id="categoryColumns"> + <listcol flex="3"/> + <listcol flex="1"/> + </listcols> + </listbox> + <hbox pack="end"> + <button label="&pref.categories.newButton.label;" + accesskey="&pref.categories.newButton.accesskey;" + oncommand="gCategoriesPane.addCategory()"/> + <button id="editCButton" + label="&pref.categories.editButton.label;" + accesskey="&pref.categories.editButton.accesskey;" + oncommand="gCategoriesPane.editCategory()"/> + <button id="deleteCButton" + label="&pref.categories.removeButton.label;" + accesskey="&pref.categories.removeButton.accesskey;" + oncommand="gCategoriesPane.deleteCategory()"/> + </hbox> + + </vbox> +</overlay> diff --git a/calendar/base/content/preferences/editCategory.js b/calendar/base/content/preferences/editCategory.js new file mode 100644 index 000000000..24f523c4c --- /dev/null +++ b/calendar/base/content/preferences/editCategory.js @@ -0,0 +1,111 @@ +/* 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 editCategoryLoad, doOK, categoryNameChanged, clickColor, delay */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); + +// Global variable, set to true if the user has picked a custom color. +var customColorSelected = false; + +/** + * Load Handler, called when the edit category dialog is loaded + */ +function editCategoryLoad() { + let winArg = window.arguments[0]; + let color = winArg.color || cal.hashColor(winArg.category); + let hasColor = (winArg.color != null); + document.getElementById("categoryName").value = winArg.category; + document.getElementById("categoryColor").value = color; + document.getElementById("useColor").checked = hasColor; + customColorSelected = hasColor; + document.title = winArg.title; + + toggleColor(); +} + +/** + * Handler function to be called when the category dialog is accepted and + * the opener should further process the selected name and color + */ +function doOK() { + let color = document.getElementById("useColor").checked + ? document.getElementById("categoryColor").value + : null; + + let categoryName = document.getElementById("categoryName").value; + window.opener.gCategoriesPane.saveCategory(categoryName, color); + return true; +} + +/** + * Handler function to be called when the category name changed + */ +function categoryNameChanged() { + let newValue = document.getElementById("categoryName").value; + + // The user removed the category name, assign the color automatically again. + if (newValue == "") { + customColorSelected = false; + } + + if (!customColorSelected && document.getElementById("useColor").checked) { + // Color is wanted, choose the color based on the category name's hash. + document.getElementById("categoryColor").value = cal.hashColor(newValue); + } +} + +/** + * Handler function to be called when the color picker's color has been changed. + */ +function colorPickerChanged() { + document.getElementById("useColor").checked = true; + customColorSelected = true; +} + +/** + * Handler called when the use color checkbox is toggled. + */ +function toggleColor() { + let useColor = document.getElementById("useColor").checked; + let categoryColor = document.getElementById("categoryColor"); + + if (useColor) { + categoryColor.setAttribute("type", "color"); + if (toggleColor.lastColor) { + categoryColor.value = toggleColor.lastColor; + } + } else { + categoryColor.setAttribute("type", "button"); + toggleColor.lastColor = categoryColor.value; + categoryColor.value = ""; + } +} + +/** + * Click handler for the color picker. Turns the button back into a colorpicker + * when clicked. + */ +function clickColor() { + let categoryColor = document.getElementById("categoryColor"); + if (categoryColor.getAttribute("type") == "button") { + colorPickerChanged(); + toggleColor(); + categoryColor.click(); + } +} + +/** + * Call the function after the given timeout, resetting the timer if delay is + * called again with the same function. + * + * @param timeout The timeout interval. + * @param func The function to call after the timeout. + */ +function delay(timeout, func) { + if (func.timer) { + clearTimeout(func.timer); + } + func.timer = setTimeout(func, timeout); +} diff --git a/calendar/base/content/preferences/editCategory.xul b/calendar/base/content/preferences/editCategory.xul new file mode 100644 index 000000000..64c868003 --- /dev/null +++ b/calendar/base/content/preferences/editCategory.xul @@ -0,0 +1,41 @@ +<?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/. --> + +<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?> + +<!-- DTD File with all strings specific to the file --> +<!DOCTYPE dialog +[ + <!ENTITY % dtd1 SYSTEM "chrome://calendar/locale/preferences/categories.dtd" > %dtd1; + <!ENTITY % dtd2 SYSTEM "chrome://calendar/locale/global.dtd" > %dtd2; +]> + +<dialog id="editCategory" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:html="http://www.w3.org/1999/xhtml" + buttons="accept,cancel" + onload="editCategoryLoad();" + ondialogaccept="return doOK();"> + <script type="application/javascript" src="chrome://calendar/content/preferences/editCategory.js"/> + + <vbox id="dialog-box"> + <label value="&pref.categories.name.label;" + control="categoryName"/> + <textbox id="categoryName" + flex="1" + onchange="categoryNameChanged()" + oninput="delay(500, categoryNameChanged)"/> + <hbox id="colorSelectRow"> + <checkbox id="useColor" + label="&pref.categories.usecolor.label;" + oncommand="toggleColor(); categoryNameChanged()"/> + <html:input id="categoryColor" + type="color" + style="width: 64px; height: 23px" + onclick="clickColor()" + onchange="colorPickerChanged()"/> + </hbox> + </vbox> +</dialog> diff --git a/calendar/base/content/preferences/general.js b/calendar/base/content/preferences/general.js new file mode 100644 index 000000000..166e45a6a --- /dev/null +++ b/calendar/base/content/preferences/general.js @@ -0,0 +1,122 @@ +/* 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 gCalendarGeneralPane */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); + +/** + * Global Object to hold methods for the general pref pane + */ +var gCalendarGeneralPane = { + /** + * Initialize the general pref pane. Sets up dialog controls to match the + * values set in prefs. + */ + init: function() { + let formatter = Components.classes["@mozilla.org/calendar/datetime-formatter;1"] + .getService(Components.interfaces.calIDateTimeFormatter); + + let dateFormattedLong = formatter.formatDateLong(now()); + let dateFormattedShort = formatter.formatDateShort(now()); + + // menu items include examples of current date formats. + document.getElementById("dateformat-long-menuitem") + .setAttribute("label", labelLong + ": " + dateFormattedLong); + document.getElementById("dateformat-short-menuitem") + .setAttribute("label", labelShort + ": " + dateFormattedShort); + + // deselect and reselect to update visible item title + updateSelectedLabel("dateformat"); + updateUnitLabelPlural("defaultlength", "defaultlengthunit", "minutes"); + this.updateDefaultTodoDates(); + + let tzMenuList = document.getElementById("calendar-timezone-menulist"); + let tzMenuPopup = document.getElementById("calendar-timezone-menupopup"); + + let tzService = cal.getTimezoneService(); + let enumerator = tzService.timezoneIds; + let tzids = {}; + let displayNames = []; + // don't rely on what order the timezone-service gives you + while (enumerator.hasMore()) { + let timezone = tzService.getTimezone(enumerator.getNext()); + if (timezone && !timezone.isFloating && !timezone.isUTC) { + let displayName = timezone.displayName; + displayNames.push(displayName); + tzids[displayName] = timezone.tzid; + } + } + // the display names need to be sorted + displayNames.sort(String.localeCompare); + for (let displayName of displayNames) { + addMenuItem(tzMenuPopup, displayName, tzids[displayName]); + } + + let prefValue = document.getElementById("calendar-timezone-local").value; + if (!prefValue) { + prefValue = calendarDefaultTimezone().tzid; + } + tzMenuList.value = prefValue; + + // Set the soondays menulist preference + this.initializeTodaypaneMenu(); + }, + + updateDefaultTodoDates: function() { + let defaultDue = document.getElementById("default_task_due").value; + let defaultStart = document.getElementById("default_task_start").value; + let offsetValues = ["offsetcurrent", "offsetnexthour"]; + + document.getElementById("default_task_due_offset") + .style.visibility = offsetValues.includes(defaultDue) ? "" : "hidden"; + document.getElementById("default_task_start_offset") + .style.visibility = offsetValues.includes(defaultStart) ? "" : "hidden"; + + updateMenuLabelsPlural("default_task_start_offset_text", "default_task_start_offset_units"); + updateMenuLabelsPlural("default_task_due_offset_text", "default_task_due_offset_units"); + }, + + updateItemtypeDeck: function() { + let panelId = document.getElementById("defaults-itemtype-menulist").value; + let panel = document.getElementById(panelId); + document.getElementById("defaults-itemtype-deck").selectedPanel = panel; + }, + + initializeTodaypaneMenu: function() { + // Assign the labels for the menuitem + let soondaysMenu = document.getElementById("soondays-menulist"); + let items = soondaysMenu.getElementsByTagName("menuitem"); + for (let menuItem of items) { + let menuitemValue = Number(menuItem.value); + if (menuitemValue > 7) { + menuItem.label = unitPluralForm(menuitemValue / 7, "weeks"); + } else { + menuItem.label = unitPluralForm(menuitemValue, "days"); + } + } + + let prefName = "calendar.agendaListbox.soondays"; + let soonpref = Preferences.get(prefName, 5); + + // Check if soonDays preference has been edited with a wrong value. + if (soonpref > 0 && soonpref <= 28) { + if (soonpref % 7 != 0) { + let intSoonpref = Math.floor(soonpref / 7) * 7; + soonpref = (intSoonpref == 0 ? soonpref : intSoonpref); + Preferences.set(prefName, soonpref, "INT"); + } + } else { + soonpref = soonpref > 28 ? 28 : 1; + Preferences.set(prefName, soonpref, "INT"); + } + + document.getElementById("soondays-menulist").value = soonpref; + }, + + updateTodaypaneMenu: function() { + let soonpref = Number(document.getElementById("soondays-menulist").value); + Preferences.set("calendar.agendaListbox.soondays", soonpref); + } +}; diff --git a/calendar/base/content/preferences/general.xul b/calendar/base/content/preferences/general.xul new file mode 100644 index 000000000..49fddb994 --- /dev/null +++ b/calendar/base/content/preferences/general.xul @@ -0,0 +1,309 @@ +<?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/. --> + +<!DOCTYPE overlay [ + <!ENTITY % generalDTD SYSTEM "chrome://calendar/locale/preferences/general.dtd"> + <!ENTITY % globalDTD SYSTEM "chrome://calendar/locale/global.dtd"> + <!ENTITY % eventDTD SYSTEM "chrome://calendar/locale/calendar-event-dialog.dtd"> + %generalDTD; + %globalDTD; + %eventDTD; +]> + +<overlay id="CalendarGeneralPaneOverlay" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <vbox id="calPreferencesBoxGeneral"> + <script type="application/javascript" + src="chrome://calendar/content/preferences/general.js"/> + <script type="application/javascript" + src="chrome://calendar/content/calendar-ui-utils.js"/> + <script type="application/javascript" + src="chrome://calendar/content/calUtils.js"/> + + <!-- Get the localized text for use in the .js --> + <script type="application/javascript"> + var labelLong = "&pref.dateformat.long;"; + var labelShort = "&pref.dateformat.short;"; + </script> + + <preferences> + <preference id="calendar.date.format" + name="calendar.date.format" + type="int"/> + <preference id="calendar.event.defaultlength" + name="calendar.event.defaultlength" + type="int"/> + <preference id="calendar-timezone-local" + name="calendar.timezone.local" + type="string"/> + <preference id="calendar.task.defaultstart" + name="calendar.task.defaultstart" + type="string"/> + <preference id="calendar.task.defaultstartoffset" + name="calendar.task.defaultstartoffset" + type="int"/> + <preference id="calendar.task.defaultstartoffsetunits" + name="calendar.task.defaultstartoffsetunits" + type="string"/> + <preference id="calendar.task.defaultdue" + name="calendar.task.defaultdue" + type="string"/> + <preference id="calendar.task.defaultdueoffset" + name="calendar.task.defaultdueoffset" + type="int"/> + <preference id="calendar.task.defaultdueoffsetunits" + name="calendar.task.defaultdueoffsetunits" + type="string"/> + <preference id="calendar.view.useSystemColors" + name="calendar.view.useSystemColors" + type="bool"/> + <preference id="calendar.agendaListbox.soondays" + name="calendar.agendaListbox.soondays" + type="int"/> + <preference id="calendar.item.editInTab" + name="calendar.item.editInTab" + type="bool"/> + </preferences> + + <groupbox> + <caption label="&pref.mainbox.label;"/> + <hbox align="center"> + <label value="&pref.dateformat.label;" + accesskey="&pref.dateformat.accesskey;" + control="dateformat"/> + <menulist id="dateformat" crop="none" + preference="calendar.date.format"> + <menupopup id="dateformatpopup"> + <menuitem id="dateformat-long-menuitem" + label="&pref.dateformat.long;" + value="0"/> + <menuitem id="dateformat-short-menuitem" + label="&pref.dateformat.short;" + value="1"/> + </menupopup> + </menulist> + </hbox> + </groupbox> + + <groupbox> + <caption label="&pref.timezones.caption;"/> + <hbox align="center"> + <label value="&pref.timezones.label;" + accesskey="&pref.timezones.accesskey;" + control="calendar-timezone-menulist"/> + <menulist id="calendar-timezone-menulist" + preference="calendar-timezone-local"> + <menupopup id="calendar-timezone-menupopup"/> + </menulist> + </hbox> + </groupbox> + + <groupbox id="defaults-itemtype-groupbox"> + <caption id="defaults-itemtype-caption" label="&pref.defaults.label;"/> + <hbox id="defaults-itemtype-box" align="top"> + <menulist id="defaults-itemtype-menulist" + flex="1" + oncommand="gCalendarGeneralPane.updateItemtypeDeck()"> + <menupopup id="defaults-itemtype-menupopup"> + <menuitem id="defaults-itemtype-event" + label="&pref.events.label;" + value="defaults-event-grid"/> + <menuitem id="defaults-itemtype-task" + label="&pref.tasks.label;" + value="defaults-task-grid"/> + </menupopup> + </menulist> + <spacer id="defaults-itemtype-spacer" flex="1"/> + <deck id="defaults-itemtype-deck" flex="1"> + <grid id="defaults-event-grid"> + <columns id="defaults-event-grid-columns"> + <column id="defaults-event-grid-column"/> + </columns> + <rows id="defaults-event-grid-rows"> + <row id="defaults-event-grid-row" align="center"> + <label id="default-event-length-label" + value="&pref.default_event_length.label;" + accesskey="&pref.default_event_length.accesskey;" + control="defaultlength"/> + <textbox id="defaultlength" + preference="calendar.event.defaultlength" + type="number" + min="0" + maxlength="3" + size="3" + onselect="updateUnitLabelPlural('defaultlength','defaultlengthunit','minutes')" + oninput="updateUnitLabelPlural('defaultlength','defaultlengthunit','minutes')"/> + <label id="defaultlengthunit"/> + </row> + </rows> + </grid> + <grid id="defaults-task-grid"> + <columns id="defaults-task-grid-columns"> + <column id="defaults-task-grid-label-column"/> + <column id="defaults-task-grid-value-column"/> + <column id="defaults-task-grid-offset-column"/> + </columns> + <rows id="defaults-task-grid-rows"> + <row id="defaults-task-start-row" align="center"> + <label id="default-task-start-label" + value="&read.only.task.start.label;" + control="default_task_start"/> + <menulist id="default_task_start" + crop="none" + oncommand="gCalendarGeneralPane.updateDefaultTodoDates()" + preference="calendar.task.defaultstart"> + <menupopup id="default_task_start_popup"> + <menuitem id="default_task_start_none" + label="&pref.default_task_none.label;" + value="none" + selected="true"/> + <menuitem id="default_task_start_start_of_day" + label="&pref.default_task_start_of_day.label;" + value="startofday"/> + <menuitem id="default_task_start_tomorrow" + label="&pref.default_task_tomorrow.label;" + value="tomorrow"/> + <menuitem id="default_task_start_next_week" + label="&pref.default_task_next_week.label;" + value="nextweek"/> + <menuitem id="default_task_start_offset_current" + label="&pref.default_task_offset_current.label;" + value="offsetcurrent"/> + <menuitem id="default_task_start_offset_next_hour" + label="&pref.default_task_offset_next_hour.label;" + value="offsetnexthour"/> + </menupopup> + </menulist> + <hbox id="default_task_start_offset" align="center"> + <textbox id="default_task_start_offset_text" + preference="calendar.task.defaultstartoffset" + type="number" + min="0" + maxlength="3" + size="3" + onselect="updateMenuLabelsPlural('default_task_start_offset_text', 'default_task_start_offset_units')" + oninput="updateMenuLabelsPlural('default_task_start_offset_text', 'default_task_start_offset_units')"/> + <menulist id="default_task_start_offset_units" + crop="none" + preference="calendar.task.defaultstartoffsetunits"> + <menupopup id="default_task_start_offset_units_popup"> + <menuitem id="default_task_start_offset_units_minutes" + value="minutes" + selected="true"/> + <menuitem id="default_task_start_offset_units_hours" + value="hours"/> + <menuitem id="default_task_start_offset_units_days" + value="days"/> + </menupopup> + </menulist> + </hbox> + </row> + <row id="defaults-task-due-row" align="center"> + <label id="default-task-due-label" + value="&read.only.task.due.label;" + control="default_task_due"/> + <menulist id="default_task_due" + crop="none" + oncommand="gCalendarGeneralPane.updateDefaultTodoDates()" + preference="calendar.task.defaultdue"> + <menupopup id="default_task_due_popup"> + <menuitem id="default_task_due_none" + label="&pref.default_task_none.label;" + value="none" + selected="true"/> + <menuitem id="default_task_due_end_of_day" + label="&pref.default_task_end_of_day.label;" + value="endofday"/> + <menuitem id="default_task_due_tomorrow" + label="&pref.default_task_tomorrow.label;" + value="tomorrow"/> + <menuitem id="default_task_due_next_week" + label="&pref.default_task_next_week.label;" + value="nextweek"/> + <menuitem id="default_task_due_offset_current" + label="&pref.default_task_offset_start.label;" + value="offsetcurrent"/> + <menuitem id="default_task_due_offset_next_hour" + label="&pref.default_task_offset_next_hour.label;" + value="offsetnexthour"/> + </menupopup> + </menulist> + <hbox id="default_task_due_offset" align="center"> + <textbox id="default_task_due_offset_text" + preference="calendar.task.defaultdueoffset" + type="number" + min="0" + maxlength="3" + size="3" + onselect="updateMenuLabelsPlural('default_task_due_offset_text', 'default_task_due_offset_units')" + oninput="updateMenuLabelsPlural('default_task_due_offset_text', 'default_task_due_offset_units')"/> + <menulist id="default_task_due_offset_units" + crop="none" + preference="calendar.task.defaultdueoffsetunits"> + <menupopup id="default_task_due_offset_units_popup"> + <menuitem id="default_task_due_offset_units_minutes" + value="minutes" + selected="true"/> + <menuitem id="default_task_due_offset_units_hours" + value="hours"/> + <menuitem id="default_task_due_offset_units_days" + value="days"/> + </menupopup> + </menulist> + </hbox> + </row> + </rows> + </grid> + </deck> + </hbox> + </groupbox> + + <groupbox> + <caption label="&pref.calendar.todaypane.agenda.caption;"/> + <hbox align="center"> + <label value="&pref.soondays.label;" + accesskey="&pref.soondays.accesskey;" + control="soondays-menulist"/> + <menulist id="soondays-menulist" + preference="calendar.agendaListbox.soondays" + oncommand="gCalendarGeneralPane.updateTodaypaneMenu()"> + <menupopup id="soondaysdurationpopup"> + <menuitem value="1"/> + <menuitem value="2"/> + <menuitem value="3"/> + <menuitem value="4"/> + <menuitem value="5"/> + <menuitem value="6"/> + <menuitem value="7"/> + <menuitem value="14"/> + <menuitem value="21"/> + <menuitem value="28"/> + </menupopup> + </menulist> + </hbox> + </groupbox> + + <groupbox> + <caption label="&pref.accessibility.label;"/> + <hbox align="center"> + <checkbox id="systemColors" pack="end" + label="&pref.systemcolors.label;" + accesskey="&pref.systemcolors.accesskey;" + preference="calendar.view.useSystemColors"/> + </hbox> + </groupbox> + + <groupbox> + <caption label="&pref.eventsandtasks.label;"/> + <hbox align="center"> + <checkbox id="tabedit" pack="end" + label="&pref.editInTab.label;" + accesskey="&pref.editInTab.accesskey;" + preference="calendar.item.editInTab"/> + </hbox> + </groupbox> + </vbox> +</overlay> diff --git a/calendar/base/content/preferences/views.js b/calendar/base/content/preferences/views.js new file mode 100644 index 000000000..aa4c29e30 --- /dev/null +++ b/calendar/base/content/preferences/views.js @@ -0,0 +1,99 @@ +/* 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 gViewsPane */ + +/** + * Global Object to hold methods for the views pref pane + */ +var gViewsPane = { + /** + * Initialize the views pref pane. Sets up dialog controls to match the + * values set in prefs. + */ + init: function() { + this.updateViewEndMenu(document.getElementById("daystarthour").value); + this.updateViewStartMenu(document.getElementById("dayendhour").value); + this.updateViewWorkDayCheckboxes(document.getElementById("weekstarts").value); + this.initializeViewStartEndMenus(); + }, + + /** + * Initialize the strings for the "day starts at" and "day ends at" + * menulists. This is needed to respect locales that use AM/PM. + */ + initializeViewStartEndMenus: function() { + let labelIdStart; + let labelIdEnd; + let timeFormatter = Components.classes["@mozilla.org/intl/scriptabledateformat;1"] + .getService(Components.interfaces.nsIScriptableDateFormat); + // 1 to 23 instead of 0 to 24 to keep midnight & noon as the localized strings + for (let theHour = 1; theHour <= 23; theHour++) { + let time = timeFormatter.FormatTime("", Components.interfaces.nsIScriptableDateFormat + .timeFormatNoSeconds, theHour, 0, 0); + + labelIdStart = "timeStart" + theHour; + labelIdEnd = "timeEnd" + theHour; + // This if block to keep Noon as the localized string, instead of as a number. + if (theHour != 12) { + document.getElementById(labelIdStart).setAttribute("label", time); + document.getElementById(labelIdEnd).setAttribute("label", time); + } + } + // Deselect and reselect to update visible item title + updateSelectedLabel("daystarthour"); + updateSelectedLabel("dayendhour"); + }, + + + /** + * Updates the view end menu to only display hours after the selected view + * start. + * + * @param aStartValue The value selected for view start. + */ + updateViewEndMenu: function(aStartValue) { + let endMenuKids = document.getElementById("dayendhourpopup") + .childNodes; + for (let i = 0; i < endMenuKids.length; i++) { + if (Number(endMenuKids[i].value) <= Number(aStartValue)) { + endMenuKids[i].setAttribute("hidden", true); + } else { + endMenuKids[i].removeAttribute("hidden"); + } + } + }, + + /** + * Updates the view start menu to only display hours before the selected view + * end. + * + * @param aEndValue The value selected for view end. + */ + updateViewStartMenu: function(aEndValue) { + let startMenuKids = document.getElementById("daystarthourpopup") + .childNodes; + for (let i = 0; i < startMenuKids.length; i++) { + if (Number(startMenuKids[i].value) >= Number(aEndValue)) { + startMenuKids[i].setAttribute("hidden", true); + } else { + startMenuKids[i].removeAttribute("hidden"); + } + } + }, + + /** + * Update the workday checkboxes based on the start of the week. + * + * @Param weekStart The (0-based) index of the weekday the week + * should start at. + */ + updateViewWorkDayCheckboxes: function(weekStart) { + weekStart = Number(weekStart); + for (let i = weekStart; i < weekStart + 7; i++) { + let checkbox = document.getElementById("dayoff" + (i % 7)); + checkbox.parentNode.appendChild(checkbox); + } + } +}; diff --git a/calendar/base/content/preferences/views.xul b/calendar/base/content/preferences/views.xul new file mode 100644 index 000000000..24fc362fb --- /dev/null +++ b/calendar/base/content/preferences/views.xul @@ -0,0 +1,306 @@ +<?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/. --> + +<!DOCTYPE overlay [ + <!ENTITY % viewsDTD SYSTEM "chrome://calendar/locale/preferences/views.dtd"> + <!ENTITY % globalDTD SYSTEM "chrome://calendar/locale/global.dtd"> + %viewsDTD; + %globalDTD; +]> + +<overlay id="ViewsPaneOverlay" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <vbox id="calPreferencesBoxViews"> + <script type="application/javascript" + src="chrome://calendar/content/preferences/views.js"/> + <script type="application/javascript" + src="chrome://calendar/content/calendar-ui-utils.js"/> + + <preferences> + <preference id="calendar.week.start" + name="calendar.week.start" + type="int"/> + <preference id="calendar.view-minimonth.showWeekNumber" + name="calendar.view-minimonth.showWeekNumber" + type="bool"/> + <preference id="calendar.week.d0sundaysoff" + name="calendar.week.d0sundaysoff" + type="bool" + inverted="true"/> + <preference id="calendar.week.d1mondaysoff" + name="calendar.week.d1mondaysoff" + type="bool" + inverted="true"/> + <preference id="calendar.week.d2tuesdaysoff" + name="calendar.week.d2tuesdaysoff" + type="bool" + inverted="true"/> + <preference id="calendar.week.d3wednesdaysoff" + name="calendar.week.d3wednesdaysoff" + type="bool" + inverted="true"/> + <preference id="calendar.week.d4thursdaysoff" + name="calendar.week.d4thursdaysoff" + type="bool" + inverted="true"/> + <preference id="calendar.week.d5fridaysoff" + name="calendar.week.d5fridaysoff" + type="bool" + inverted="true"/> + <preference id="calendar.week.d6saturdaysoff" + name="calendar.week.d6saturdaysoff" + type="bool" + inverted="true"/> + <preference id="calendar.view.daystarthour" + name="calendar.view.daystarthour" + type="int"/> + <preference id="calendar.view.dayendhour" + name="calendar.view.dayendhour" + type="int"/> + <preference id="calendar.view.visiblehours" + name="calendar.view.visiblehours" + type="int"/> + <preference id="calendar.weeks.inview" + name="calendar.weeks.inview" + type="int"/> + <preference id="calendar.previousweeks.inview" + name="calendar.previousweeks.inview" + type="int"/> + </preferences> + + <groupbox> + <caption label="&pref.calendar.view.allview.caption;"/> + <hbox> + <hbox align="center" flex="1"> + <label value="&pref.weekstarts.label;" + accesskey="&pref.weekstarts.accesskey;" + control="weekstarts"/> + <menulist id="weekstarts" + preference="calendar.week.start" + oncommand="gViewsPane.updateViewWorkDayCheckboxes(this.value)"> + <menupopup id="weekstartspopup"> + <menuitem label="&day.1.name;" value="0"/> + <menuitem label="&day.2.name;" value="1"/> + <menuitem label="&day.3.name;" value="2"/> + <menuitem label="&day.4.name;" value="3"/> + <menuitem label="&day.5.name;" value="4"/> + <menuitem label="&day.6.name;" value="5"/> + <menuitem label="&day.7.name;" value="6"/> + </menupopup> + </menulist> + </hbox> + <hbox align="center" flex="1"> + <checkbox id="weekNumber" + crop="end" + label="&pref.calendar.view-minimonth.showweeknumber.label;" + accesskey="&pref.calendar.view-minimonth.showweeknumber.accesskey;" + preference="calendar.view-minimonth.showWeekNumber"/> + </hbox> + </hbox> + </groupbox> + + <groupbox> + <caption label="&pref.calendar.view.workweek.caption;"/> + <label value="&pref.daysoff.label;"/> + <hbox> + <checkbox id="dayoff0" + class="dayOffCheckbox" + label="&day.1.Ddd;" + accesskey="&day.1.Ddd.accesskey;" + orient="vertical" + preference="calendar.week.d0sundaysoff"/> + <checkbox id="dayoff1" + class="dayOffCheckbox" + label="&day.2.Ddd;" + accesskey="&day.2.Ddd.accesskey;" + orient="vertical" + preference="calendar.week.d1mondaysoff"/> + <checkbox id="dayoff2" + class="dayOffCheckbox" + label="&day.3.Ddd;" + accesskey="&day.3.Ddd.accesskey;" + orient="vertical" + preference="calendar.week.d2tuesdaysoff"/> + <checkbox id="dayoff3" + class="dayOffCheckbox" + label="&day.4.Ddd;" + accesskey="&day.4.Ddd.accesskey;" + orient="vertical" + preference="calendar.week.d3wednesdaysoff"/> + <checkbox id="dayoff4" + class="dayOffCheckbox" + label="&day.5.Ddd;" + accesskey="&day.5.Ddd.accesskey;" + orient="vertical" + preference="calendar.week.d4thursdaysoff"/> + <checkbox id="dayoff5" + class="dayOffCheckbox" + label="&day.6.Ddd;" + accesskey="&day.6.Ddd.accesskey;" + orient="vertical" + preference="calendar.week.d5fridaysoff"/> + <checkbox id="dayoff6" + class="dayOffCheckbox" + label="&day.7.Ddd;" + accesskey="&day.7.Ddd.accesskey;" + orient="vertical" + preference="calendar.week.d6saturdaysoff"/> + </hbox> + </groupbox> + + <groupbox> + <caption label="&pref.calendar.view.dayandweekviews.caption;"/> + <grid> + <columns> + <column/> + <column/> + <column flex="1"/> + </columns> + <rows> + <row align="center"> + <label value="&pref.calendar.view.daystart.label;" + accesskey="&pref.calendar.view.daystart.accesskey;" + control="daystarthour"/> + <menulist id="daystarthour" + oncommand="gViewsPane.updateViewEndMenu(this.value);" + preference="calendar.view.daystarthour"> + <menupopup id="daystarthourpopup"> + <menuitem id="timeStart0" label="&time.midnight;" value="0"/> + <menuitem id="timeStart1" value="1"/> + <menuitem id="timeStart2" value="2"/> + <menuitem id="timeStart3" value="3"/> + <menuitem id="timeStart4" value="4"/> + <menuitem id="timeStart5" value="5"/> + <menuitem id="timeStart6" value="6"/> + <menuitem id="timeStart7" value="7"/> + <menuitem id="timeStart8" value="8"/> + <menuitem id="timeStart9" value="9"/> + <menuitem id="timeStart10" value="10"/> + <menuitem id="timeStart11" value="11"/> + <menuitem id="timeStart12" label="&time.noon;" value="12"/> + <menuitem id="timeStart13" value="13"/> + <menuitem id="timeStart14" value="14"/> + <menuitem id="timeStart15" value="15"/> + <menuitem id="timeStart16" value="16"/> + <menuitem id="timeStart17" value="17"/> + <menuitem id="timeStart18" value="18"/> + <menuitem id="timeStart19" value="19"/> + <menuitem id="timeStart20" value="20"/> + <menuitem id="timeStart21" value="21"/> + <menuitem id="timeStart22" value="22"/> + <menuitem id="timeStart23" value="23"/> + </menupopup> + </menulist> + <hbox align="center" pack="center"> + <label value="&pref.calendar.view.visiblehours.label;" + accesskey="&pref.calendar.view.visiblehours.accesskey;" + control="visiblehours"/> + <menulist id="visiblehours" + preference="calendar.view.visiblehours"> + <menupopup id="visiblehourspopup"> + <menuitem label="1" value="1"/> + <menuitem label="2" value="2"/> + <menuitem label="3" value="3"/> + <menuitem label="4" value="4"/> + <menuitem label="5" value="5"/> + <menuitem label="6" value="6"/> + <menuitem label="7" value="7"/> + <menuitem label="8" value="8"/> + <menuitem label="9" value="9"/> + <menuitem label="10" value="10"/> + <menuitem label="11" value="11"/> + <menuitem label="12" value="12"/> + <menuitem label="13" value="13"/> + <menuitem label="14" value="14"/> + <menuitem label="15" value="15"/> + <menuitem label="16" value="16"/> + <menuitem label="17" value="17"/> + <menuitem label="18" value="18"/> + <menuitem label="19" value="19"/> + <menuitem label="20" value="20"/> + <menuitem label="21" value="21"/> + <menuitem label="22" value="22"/> + <menuitem label="23" value="23"/> + <menuitem label="24" value="24"/> + </menupopup> + </menulist> + <label value="&pref.calendar.view.visiblehoursend.label;"/> + </hbox> + </row> + <row align="center"> + <label value="&pref.calendar.view.dayend.label;" + accesskey="&pref.calendar.view.dayend.accesskey;" + control="dayendhour"/> + <menulist id="dayendhour" + oncommand="gViewsPane.updateViewStartMenu(this.value);" + preference="calendar.view.dayendhour"> + <menupopup id="dayendhourpopup"> + <menuitem id="timeEnd1" value="1"/> + <menuitem id="timeEnd2" value="2"/> + <menuitem id="timeEnd3" value="3"/> + <menuitem id="timeEnd4" value="4"/> + <menuitem id="timeEnd5" value="5"/> + <menuitem id="timeEnd6" value="6"/> + <menuitem id="timeEnd7" value="7"/> + <menuitem id="timeEnd8" value="8"/> + <menuitem id="timeEnd9" value="9"/> + <menuitem id="timeEnd10" value="10"/> + <menuitem id="timeEnd11" value="11"/> + <menuitem id="timeEnd12" label="&time.noon;" value="12"/> + <menuitem id="timeEnd13" value="13"/> + <menuitem id="timeEnd14" value="14"/> + <menuitem id="timeEnd15" value="15"/> + <menuitem id="timeEnd16" value="16"/> + <menuitem id="timeEnd17" value="17"/> + <menuitem id="timeEnd18" value="18"/> + <menuitem id="timeEnd19" value="19"/> + <menuitem id="timeEnd20" value="20"/> + <menuitem id="timeEnd21" value="21"/> + <menuitem id="timeEnd22" value="22"/> + <menuitem id="timeEnd23" value="23"/> + <menuitem id="timeEnd24" label="&time.midnight;" value="24"/> + </menupopup> + </menulist> + </row> + </rows> + </grid> + </groupbox> + + <groupbox id="viewsMultiweekGroupbox"> + <caption label="&pref.calendar.view.multiweekview.caption;"/> + <hbox align="center"> + <label value="&pref.numberofweeks.label;" + accesskey="&pref.numberofweeks.accesskey;" + control="viewsMultiweekTotalWeeks"/> + <menulist id="viewsMultiweekTotalWeeks" + preference="calendar.weeks.inview"> + <menupopup> + <menuitem label="&pref.numberofweeks.1;" value="1"/> + <menuitem label="&pref.numberofweeks.2;" value="2"/> + <menuitem label="&pref.numberofweeks.3;" value="3"/> + <menuitem label="&pref.numberofweeks.4;" value="4"/> + <menuitem label="&pref.numberofweeks.5;" value="5"/> + <menuitem label="&pref.numberofweeks.6;" value="6"/> + </menupopup> + </menulist> + </hbox> + <hbox align="center" id="previousWeeksBox"> + <label value="&pref.numberofpreviousweeks.label;" + accesskey="&pref.numberofpreviousweeks.accesskey;" + control="viewsMultiweekPreviousWeeks"/> + <menulist id="viewsMultiweekPreviousWeeks" + preference="calendar.previousweeks.inview"> + <menupopup> + <menuitem label="&pref.numberofweeks.0;" value="0"/> + <menuitem label="&pref.numberofweeks.1;" value="1"/> + <menuitem label="&pref.numberofweeks.2;" value="2"/> + </menupopup> + </menulist> + </hbox> + </groupbox> + + </vbox> +</overlay> diff --git a/calendar/base/content/today-pane.js b/calendar/base/content/today-pane.js new file mode 100644 index 000000000..057e1608c --- /dev/null +++ b/calendar/base/content/today-pane.js @@ -0,0 +1,482 @@ +/* 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/. */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); + +/** + * Namespace object to hold functions related to the today pane. + */ +var TodayPane = { + paneViews: null, + start: null, + cwlabel: null, + previousMode: null, + switchCounter: 0, + minidayTimer: null, + minidayDrag: { + startX: 0, + startY: 0, + distance: 0, + session: false + }, + + /** + * Load Handler, sets up the today pane controls. + */ + onLoad: function() { + TodayPane.paneViews = [cal.calGetString("calendar", "eventsandtasks"), + cal.calGetString("calendar", "tasksonly"), + cal.calGetString("calendar", "eventsonly")]; + agendaListbox.setupCalendar(); + TodayPane.initializeMiniday(); + TodayPane.setShortWeekdays(); + + document.getElementById("modeBroadcaster").addEventListener("DOMAttrModified", TodayPane.onModeModified, false); + TodayPane.setTodayHeader(); + + document.getElementById("today-splitter").addEventListener("command", onCalendarViewResize, false); + TodayPane.updateSplitterState(); + TodayPane.previousMode = document.getElementById("modeBroadcaster").getAttribute("mode"); + TodayPane.showTodayPaneStatusLabel(); + }, + + /** + * Unload handler, cleans up the today pane on window unload. + */ + onUnload: function() { + document.getElementById("modeBroadcaster").removeEventListener("DOMAttrModified", TodayPane.onModeModified, false); + document.getElementById("today-splitter").removeEventListener("command", onCalendarViewResize, false); + }, + + /** + * Sets up the label for the switcher that allows switching between today pane + * views. (event+task, task only, event only) + */ + setTodayHeader: function() { + let currentMode = document.getElementById("modeBroadcaster").getAttribute("mode"); + let agendaIsVisible = document.getElementById("agenda-panel").isVisible(currentMode); + let todoIsVisible = document.getElementById("todo-tab-panel").isVisible(currentMode); + let index = 2; + if (agendaIsVisible && todoIsVisible) { + index = 0; + } else if (!agendaIsVisible && todoIsVisible) { + index = 1; + } else if (agendaIsVisible && !todoIsVisible) { + index = 2; + } else { // agendaIsVisible == false && todoIsVisible == false: + // In this case something must have gone wrong + // - probably in the previous session - and no pane is displayed. + // We set a default by only displaying agenda-pane. + agendaIsVisible = true; + document.getElementById("agenda-panel").setVisible(agendaIsVisible); + index = 2; + } + let todayHeader = document.getElementById("today-pane-header"); + todayHeader.setAttribute("index", index); + todayHeader.setAttribute("value", this.paneViews[index]); + let todayPaneSplitter = document.getElementById("today-pane-splitter"); + setBooleanAttribute(todayPaneSplitter, "hidden", index != 0); + let todayIsVisible = document.getElementById("today-pane-panel").isVisible(); + + // Disable or enable the today pane menuitems that have an attribute + // name="minidisplay" depending on the visibility of elements. + let menu = document.getElementById("ltnTodayPaneMenuPopup"); + if (menu) { + setAttributeToChildren(menu, "disabled", !todayIsVisible || !agendaIsVisible, "name", "minidisplay"); + } + + onCalendarViewResize(); + }, + + /** + * Sets up the miniday display in the today pane. + */ + initializeMiniday: function() { + // initialize the label denoting the current month, year and calendarweek + // with numbers that are supposed to consume the largest width + // in order to guarantee that the text will not be cropped when modified + // during runtime + const kYEARINIT = "5555"; + const kCALWEEKINIT = "55"; + let monthdisplaydeck = document.getElementById("monthNameContainer"); + let childNodes = monthdisplaydeck.childNodes; + + for (let i = 0; i < childNodes.length; i++) { + let monthlabel = childNodes[i]; + this.setMonthDescription(monthlabel, i, kYEARINIT, kCALWEEKINIT); + } + + let now = cal.now(); + // Workaround for bug 1070491. Show the correct month and year + // after startup even if deck's selectedIndex is reset to 0. + this.setMonthDescription(childNodes[0], now.month, now.year, kCALWEEKINIT); + + agendaListbox.addListener(this); + this.setDay(now); + }, + + /** + * Go to month/week/day views when double-clicking a label inside miniday + */ + onDoubleClick: function(aEvent) { + if (aEvent.button == 0) { + if (aEvent.target.id == "datevalue-label") { + switchCalendarView("day", true); + } else if (aEvent.target.parentNode.id == "weekdayNameContainer") { + switchCalendarView("day", true); + } else if (aEvent.target.id == "currentWeek-label") { + switchCalendarView("week", true); + } else if (aEvent.target.parentNode.id == "monthNameContainer") { + switchCalendarView("month", true); + } else { + return; + } + let title = document.getElementById("calendar-tab-button") + .getAttribute("tooltiptext"); + document.getElementById("tabmail").openTab("calendar", { title: title }); + currentView().goToDay(agendaListbox.today.start); + } + }, + + /** + * Set conditions about start dragging on day-label or start switching + * with time on navigation buttons. + */ + onMousedown: function(aEvent, aDir) { + if (aEvent.button != 0) { + return; + } + let element = aEvent.target; + if (element.id == "previous-day-button" || + element.id == "next-day-button") { + // Start switching days by pressing, without release, the navigation buttons + element.addEventListener("mouseout", TodayPane.stopSwitching, false); + element.addEventListener("mouseup", TodayPane.stopSwitching, false); + TodayPane.minidayTimer = setTimeout(TodayPane.updateAdvanceTimer.bind(TodayPane, Event, aDir), 500); + } else if (element.id == "datevalue-label") { + // Start switching days by dragging the mouse with a starting point on the day label + window.addEventListener("mousemove", TodayPane.onMousemove, false); + window.addEventListener("mouseup", TodayPane.stopSwitching, false); + TodayPane.minidayDrag.startX = aEvent.clientX; + TodayPane.minidayDrag.startY = aEvent.clientY; + } + }, + + /** + * Figure out the mouse distance from the center of the day's label + * to the current position. + * + * NOTE: This function is usually called without the correct this pointer. + */ + onMousemove: function(aEvent) { + const MIN_DRAG_DISTANCE_SQ = 49; + let x = aEvent.clientX - TodayPane.minidayDrag.startX; + let y = aEvent.clientY - TodayPane.minidayDrag.startY; + if (TodayPane.minidayDrag.session) { + if (x * x + y * y >= MIN_DRAG_DISTANCE_SQ) { + let distance = Math.floor(Math.sqrt(x * x + y * y) - Math.sqrt(MIN_DRAG_DISTANCE_SQ)); + // Dragging on the left/right side, the day date decrease/increase + TodayPane.minidayDrag.distance = (x > 0) ? distance : -distance; + } else { + TodayPane.minidayDrag.distance = 0; + } + } else if (x * x + y * y > 9) { + // move the mouse a bit before starting the drag session + window.addEventListener("mouseout", TodayPane.stopSwitching, false); + TodayPane.minidayDrag.session = true; + let dragCenterImage = document.getElementById("dragCenter-image"); + dragCenterImage.removeAttribute("hidden"); + // Move the starting point in the center so we have a fixed + // point where stopping the day switching while still dragging + let centerObj = dragCenterImage.boxObject; + TodayPane.minidayDrag.startX = Math.floor(centerObj.x + centerObj.width / 2); + TodayPane.minidayDrag.startY = Math.floor(centerObj.y + centerObj.height / 2); + + TodayPane.updateAdvanceTimer(); + } + }, + + /** + * Figure out the days switching speed according to the position (when + * dragging) or time elapsed (when pressing buttons). + */ + updateAdvanceTimer: function(aEvent, aDir) { + const INITIAL_TIME = 400; + const REL_DISTANCE = 8; + const MINIMUM_TIME = 100; + const ACCELERATE_COUNT_LIMIT = 7; + const SECOND_STEP_TIME = 200; + if (TodayPane.minidayDrag.session) { + // Dragging the day label: days switch with cursor distance and time. + let dir = (TodayPane.minidayDrag.distance > 0) - (TodayPane.minidayDrag.distance < 0); + TodayPane.advance(dir); + let distance = Math.abs(TodayPane.minidayDrag.distance); + // Linear relation between distance and switching speed + let timeInterval = Math.max(Math.ceil(INITIAL_TIME - distance * REL_DISTANCE), MINIMUM_TIME); + TodayPane.minidayTimer = setTimeout(TodayPane.updateAdvanceTimer.bind(TodayPane, null, null), timeInterval); + } else { + // Keeping pressed next/previous day buttons causes days switching (with + // three levels higher speed after some commutations). + TodayPane.advance(parseInt(aDir, 10)); + TodayPane.switchCounter++; + let timeInterval = INITIAL_TIME; + if (TodayPane.switchCounter > 2 * ACCELERATE_COUNT_LIMIT) { + timeInterval = MINIMUM_TIME; + } else if (TodayPane.switchCounter > ACCELERATE_COUNT_LIMIT) { + timeInterval = SECOND_STEP_TIME; + } + TodayPane.minidayTimer = setTimeout(TodayPane.updateAdvanceTimer.bind(TodayPane, aEvent, aDir), timeInterval); + } + }, + + /** + * Stop automatic days switching when releasing the mouse button or the + * position is outside the window. + * + * NOTE: This function is usually called without the correct this pointer. + */ + stopSwitching: function(aEvent) { + let element = aEvent.target; + if (TodayPane.minidayDrag.session && + aEvent.type == "mouseout" && + element.id != "messengerWindow") { + return; + } + if (TodayPane.minidayTimer) { + clearTimeout(TodayPane.minidayTimer); + delete TodayPane.minidayTimer; + if (TodayPane.switchCounter == 0 && !TodayPane.minidayDrag.session) { + let dir = element.getAttribute("dir"); + TodayPane.advance(parseInt(dir, 10)); + } + } + if (element.id == "previous-day-button" || + element.id == "next-day-button") { + TodayPane.switchCounter = 0; + let button = document.getElementById(element.id); + button.removeEventListener("mouseout", TodayPane.stopSwitching, false); + } + if (TodayPane.minidayDrag.session) { + window.removeEventListener("mouseout", TodayPane.stopSwitching, false); + TodayPane.minidayDrag.distance = 0; + document.getElementById("dragCenter-image").setAttribute("hidden", "true"); + TodayPane.minidayDrag.session = false; + } + window.removeEventListener("mousemove", TodayPane.onMousemove, false); + window.removeEventListener("mouseup", TodayPane.stopSwitching, false); + }, + + /** + * Helper function to set the month description on the today pane header. + * + * @param aMonthLabel The XUL node to set the month label on. + * @param aIndex The month number, 0-based. + * @param aYear The year this month should be displayed with + * @param aCalWeek The calendar week that should be shown. + * @return The value set on aMonthLabel. + */ + setMonthDescription: function(aMonthLabel, aIndex, aYear, aCalWeek) { + if (this.cwlabel == null) { + this.cwlabel = cal.calGetString("calendar", "shortcalendarweek"); + } + document.getElementById("currentWeek-label").value = this.cwlabel + " " + aCalWeek; + aMonthLabel.value = cal.getDateFormatter().shortMonthName(aIndex) + " " + aYear; + return aMonthLabel.value; + }, + + /** + * Cycle the view shown in the today pane (event+task, event, task). + * + * @param aCycleForward If true, the views are cycled in the forward + * direction, otherwise in the opposite direction + */ + cyclePaneView: function(aCycleForward) { + if (this.paneViews == null) { + return; + } + let index = parseInt(document.getElementById("today-pane-header").getAttribute("index"), 10); + index = index + aCycleForward; + let nViewLen = this.paneViews.length; + if (index >= nViewLen) { + index = 0; + } else if (index == -1) { + index = nViewLen - 1; + } + let agendaPanel = document.getElementById("agenda-panel"); + let todoPanel = document.getElementById("todo-tab-panel"); + let currentMode = document.getElementById("modeBroadcaster").getAttribute("mode"); + let isTodoPanelVisible = (index != 2 && todoPanel.isVisibleInMode(currentMode)); + let isAgendaPanelVisible = (index != 1 && agendaPanel.isVisibleInMode(currentMode)); + todoPanel.setVisible(isTodoPanelVisible); + agendaPanel.setVisible(isAgendaPanelVisible); + this.setTodayHeader(); + }, + + /** + * Shows short weekday names in the weekdayNameContainer + */ + setShortWeekdays: function() { + let weekdisplaydeck = document.getElementById("weekdayNameContainer"); + let childNodes = weekdisplaydeck.childNodes; + + // Workaround for bug 1070491. Show the correct weekday after + // startup even if deck's selectedIndex is reset to 0. + let weekday = cal.now().weekday + 1; + childNodes[0].setAttribute("value", cal.calGetString("dateFormat", "day." + weekday + ".Mmm")); + + for (let i = 1; i < childNodes.length; i++) { + childNodes[i].setAttribute("value", cal.calGetString("dateFormat", "day." + i + ".Mmm")); + } + }, + + /** + * Sets the shown date from a JSDate. + * + * @param aNewDate The date to show. + */ + setDaywithjsDate: function(aNewDate) { + let newdatetime = cal.jsDateToDateTime(aNewDate, cal.floating()); + newdatetime = newdatetime.getInTimezone(cal.calendarDefaultTimezone()); + this.setDay(newdatetime, true); + }, + + /** + * Sets the first day shown in the today pane. + * + * @param aNewDate The calIDateTime to set. + * @param aDontUpdateMinimonth If true, the minimonth will not be + * updated to show the same date. + */ + setDay: function(aNewDate, aDontUpdateMinimonth) { + this.start = aNewDate.clone(); + + let daylabel = document.getElementById("datevalue-label"); + daylabel.value = this.start.day; + + let weekdaylabel = document.getElementById("weekdayNameContainer"); + weekdaylabel.selectedIndex = this.start.weekday + 1; + + let monthnamedeck = document.getElementById("monthNameContainer"); + monthnamedeck.selectedIndex = this.start.month; + + let selMonthPanel = monthnamedeck.selectedPanel; + this.setMonthDescription(selMonthPanel, + this.start.month, + this.start.year, + cal.getWeekInfoService().getWeekTitle(this.start)); + if (!aDontUpdateMinimonth) { + document.getElementById("today-Minimonth").value = cal.dateTimeToJsDate(this.start); + } + this.updatePeriod(); + }, + + /** + * Advance by a given number of days in the today pane. + * + * @param aDir The number of days to advance. Negative numbers advance + * backwards in time. + */ + advance: function(aDir) { + if (aDir != 0) { + this.start.day += aDir; + this.setDay(this.start); + } + }, + + /** + * Checks if the today pane is showing today's date. + */ + showsToday: function() { + return cal.sameDay(cal.now(), this.start); + }, + + /** + * Update the period headers in the agenda listbox using the today pane's + * start date. + */ + updatePeriod: function() { + agendaListbox.refreshPeriodDates(this.start.clone()); + updateCalendarToDoUnifinder(); + }, + + /** + * Display a certain section in the minday/minimonth part of the todaypane. + * + * @param aSection The section to display + */ + displayMiniSection: function(aSection) { + document.getElementById("today-minimonth-box").setVisible(aSection == "minimonth"); + document.getElementById("mini-day-box").setVisible(aSection == "miniday"); + document.getElementById("today-none-box").setVisible(aSection == "none"); + setBooleanAttribute(document.getElementById("today-Minimonth"), "freebusy", aSection == "minimonth"); + }, + + /** + * Handler function for the DOMAttrModified event used to observe the + * todaypane-splitter. + * + * @param aEvent The DOM event occurring on attribute modification. + */ + onModeModified: function(aEvent) { + if (aEvent.attrName == "mode") { + let todaypane = document.getElementById("today-pane-panel"); + // Store the previous mode panel's width. + todaypane.setModeAttribute("modewidths", todaypane.width, TodayPane.previousMode); + + TodayPane.setTodayHeader(); + TodayPane.updateSplitterState(); + todaypane.width = todaypane.getModeAttribute("modewidths", "width"); + TodayPane.previousMode = document.getElementById("modeBroadcaster").getAttribute("mode"); + } + }, + + /** + * Toggle the today-pane and update its visual appearance. + * + * @param aEvent The DOM event occurring on activated command. + */ + toggleVisibility: function(aEvent) { + document.getElementById("today-pane-panel").togglePane(aEvent); + TodayPane.setTodayHeader(); + TodayPane.updateSplitterState(); + }, + + /** + * Update the today-splitter state and today-pane width with saved + * mode-dependent values. + */ + updateSplitterState: function() { + let splitter = document.getElementById("today-splitter"); + let todaypaneVisible = document.getElementById("today-pane-panel").isVisible(); + setElementValue(splitter, !todaypaneVisible && "true", "hidden"); + if (todaypaneVisible) { + splitter.setAttribute("state", "open"); + } + }, + + /** + * Generates the todaypane toggle command when the today-splitter + * is being collapsed or uncollapsed. + */ + onCommandTodaySplitter: function() { + let todaypane = document.getElementById("today-pane-panel"); + let splitter = document.getElementById("today-splitter"); + let splitterCollapsed = splitter.getAttribute("state") == "collapsed"; + + if (splitterCollapsed == todaypane.isVisible()) { + document.getElementById("calendar_toggle_todaypane_command").doCommand(); + } + }, + + /** + * Checks if the todayPaneStatusLabel should be hidden. + */ + showTodayPaneStatusLabel: function() { + let attributeValue = Preferences.get("calendar.view.showTodayPaneStatusLabel", true) && "false"; + setElementValue(document.getElementById("calendar-status-todaypane-button"), !attributeValue, "hideLabel"); + } +}; + +window.addEventListener("load", TodayPane.onLoad, false); +window.addEventListener("unload", TodayPane.onUnload, false); diff --git a/calendar/base/content/today-pane.xul b/calendar/base/content/today-pane.xul new file mode 100644 index 000000000..53dd71145 --- /dev/null +++ b/calendar/base/content/today-pane.xul @@ -0,0 +1,293 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<!DOCTYPE overlay +[ + <!ENTITY % dtd1 SYSTEM "chrome://calendar/locale/global.dtd" > %dtd1; + <!ENTITY % dtd2 SYSTEM "chrome://lightning/locale/lightning.dtd" > %dtd2; + <!ENTITY % dtd3 SYSTEM "chrome://messenger/locale/messenger.dtd" > %dtd3; + <!ENTITY % dtd4 SYSTEM "chrome://calendar/locale/calendar.dtd" > %dtd4; + <!ENTITY % dtd5 SYSTEM "chrome://global/locale/global.dtd" > %dtd5; + <!ENTITY % dtd6 SYSTEM "chrome://calendar/locale/calendar-event-dialog.dtd"> %dtd6; +]> + +<?xml-stylesheet href="chrome://calendar/skin/today-pane.css" type="text/css"?> +<?xml-stylesheet href="chrome://calendar/content/widgets/calendar-widget-bindings.css" type="text/css"?> + +<overlay xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + +<script type="application/javascript" src="chrome://calendar/content/today-pane.js"/> +<script type="application/javascript" src="chrome://calendar/content/agenda-listbox.js"/> +<script type="application/javascript" src="chrome://calendar/content/calendar-management.js"/> +<script type="application/javascript" src="chrome://calendar/content/calendar-dnd-listener.js"/> +<script type="application/javascript" src="chrome://calendar/content/calendar-item-editing.js"/> + + <modevbox id="today-pane-panel" + mode="mail,calendar,task" modewidths="200,200,200" modesplitterstates="open,open,open" + refcontrol="calendar_toggle_todaypane_command" + broadcaster="modeBroadcaster" persist="modewidths"> + <sidebarheader align="center"> + <label id ="today-pane-header"/> + <spacer flex="1"/> + <modehbox mode="mail,calendar" broadcaster="modeBroadcaster"> + <toolbarbutton id="today-pane-cycler-prev" + dir="prev" + class="today-pane-cycler" + oncommand="TodayPane.cyclePaneView(-1);"/> + <toolbarbutton id="today-pane-cycler-next" + dir="next" + class="today-pane-cycler" + oncommand="TodayPane.cyclePaneView(1);"/> + </modehbox> + <spacer id="buttonspacer"/> + <toolbarbutton id="today-closer" class="today-closebutton close-icon" + oncommand="document.getElementById('today-pane-panel').setVisible(false, true, true); + TodayPane.setTodayHeader(); + TodayPane.updateSplitterState();"/> + </sidebarheader> + <vbox flex="1"> + <modevbox id="agenda-panel" + flex="1" + mode="mail,calendar,task" + collapsedinmodes="calendar" + persist="collapsed height collapsedinmodes" + broadcaster="modeBroadcaster"> + <modebox id="today-none-box" + mode="mail,calendar,task" + collapsedinmodes="mail,calendar,task" + broadcaster="modeBroadcaster" + refcontrol="ltnTodayPaneDisplayNone" + persist="collapsedinmodes"/> + <modehbox id="today-minimonth-box" + pack="center" + class="today-subpane" + mode="mail,calendar,task" + broadcaster="modeBroadcaster" + collapsedinmodes="mail,calendar,task" + refcontrol="ltnTodayPaneDisplayMinimonth" + persist="collapsedinmodes"> + <minimonth id="today-Minimonth" freebusy="true" onchange="TodayPane.setDaywithjsDate(this.value);"/> + </modehbox> + <modebox id="mini-day-box" + mode="mail,calendar,task" + class="today-subpane" + refcontrol="ltnTodayPaneDisplayMiniday" + broadcaster="modeBroadcaster" + collapsedinmodes="" + persist="collapsedinmodes" + onDOMMouseScroll="TodayPane.advance(event.detail > 0 ? 1 : -1);"> + <stack flex="1"> + <image id="mini-day-image" flex="1"/> + <hbox flex="1"> + <stack id="dateContainer"> + <hbox pack="center" + align="center"> + <label id="datevalue-label" class="dateValue" + ondblclick="TodayPane.onDoubleClick(event);" + onmousedown="TodayPane.onMousedown(event);"/> + </hbox> + <hbox flex="1" pack="center" align="center" mousethrough="always"> + <image id="dragCenter-image" hidden="true"/> + </hbox> + </stack> + <vbox flex="1"> + <hbox pack="center"> + <deck id="weekdayNameContainer" pack="center" + ondblclick="TodayPane.onDoubleClick(event);"> + <label/><!-- workaround for bug 1070491--> + <label/> + <label/> + <label/> + <label/> + <label/> + <label/> + <label/> + </deck> + <spacer id="weekspacer" flex="1"/> + <hbox pack="end"> + <toolbarbutton id="previous-day-button" + class="miniday-nav-buttons" + tooltiptext="&onedaybackward.tooltip;" + onmousedown="TodayPane.onMousedown(event, parseInt(this.getAttribute('dir')));" + dir="-1"/> + <toolbarbutton id="today-button" + class="miniday-nav-buttons" + tooltiptext="&showToday.tooltip;" + oncommand="TodayPane.setDay(now());"/> + <toolbarbutton id="next-day-button" + class="miniday-nav-buttons" + tooltiptext="&onedayforward.tooltip;" + onmousedown="TodayPane.onMousedown(event, parseInt(this.getAttribute('dir')));" + dir="1"/> + </hbox> + </hbox> + <hbox pack="start"> + <deck id ="monthNameContainer" class="monthlabel" + ondblclick="TodayPane.onDoubleClick(event);"> + <label/> + <label/> + <label/> + <label/> + <label/> + <label/> + <label/> + <label/> + <label/> + <label/> + <label/> + <label/> + </deck> + <label id="currentWeek-label" class="monthlabel" + ondblclick="TodayPane.onDoubleClick(event);"/> + <spacer flex="1"/> + </hbox> + </vbox> + <toolbarbutton id="miniday-dropdown-button" + tooltiptext="&showselectedday.tooltip;" + type="menu"> + <panel id="miniday-month-panel" position="after_end"> + <minimonth id="todayMinimonth" + flex="1" + onchange="TodayPane.setDaywithjsDate(this.value); + document.getElementById('miniday-month-panel').hidePopup();"/> + </panel> + </toolbarbutton> + </hbox> + </stack> + </modebox> + <vbox flex="1"> + <hbox id="agenda-toolbar" iconsize="small"> + <toolbarbutton id="todaypane-new-event-button" + mode="mail" + iconsize="small" + orient="horizontal" + label="&calendar.newevent.button.label;" + tooltiptext="&calendar.newevent.button.tooltip;" + oncommand="agendaListbox.createNewEvent(event)"> + <observes element="calendar_new_event_command" attribute="disabled"/> + </toolbarbutton> + </hbox> + <vbox id="richlistitem-container" hidden="true"> + <agenda-checkbox-richlist-item id="today-header-hidden" + title="&calendar.today.button.label;" + checked="true" + persist="checked"/> + <agenda-checkbox-richlist-item id="tomorrow-header-hidden" + title="&calendar.tomorrow.button.label;" + checked="false" + persist="checked"/> + <agenda-checkbox-richlist-item id="nextweek-header-hidden" + title="&calendar.upcoming.button.label;" + checked="false" + persist="checked"/> + </vbox> + <richlistbox id="agenda-listbox" flex="1" context="_child" + onblur="agendaListbox.onBlur();" + onfocus="agendaListbox.onFocus();" + onkeypress="agendaListbox.onKeyPress(event);" + ondblclick="agendaListbox.createNewEvent(event);" + ondragstart="nsDragAndDrop.startDrag(event, calendarCalendarButtonDNDObserver);" + ondragover="nsDragAndDrop.dragOver(event, calendarCalendarButtonDNDObserver);" + ondrop="nsDragAndDrop.drop(event, calendarCalendarButtonDNDObserver);"> + <menupopup id="agenda-menupopup" onpopupshowing="return agendaListbox.setupContextMenu(event.target)"> + <menuitem label="&calendar.context.modifyorviewitem.label;" + accesskey="&calendar.context.modifyorviewitem.accesskey;" + observes="agenda_edit_event_command"/> + <menu id="agenda-context-menu-convert-menu" + label="&calendar.context.convertmenu.label;" + accesskey="&calendar.context.convertmenu.accesskey.calendar;"> + <menupopup id="agenda-context-menu-convert-menupopup"> + <menuitem id="agenda-context-menu-convert-message-menuitem" + label="&calendar.context.convertmenu.message.label;" + accesskey="&calendar.context.convertmenu.message.accesskey;" + oncommand="calendarMailButtonDNDObserver.onDropItems(agendaListbox.getSelectedItems())"/> + <menuitem id="agenda-context-menu-convert-task-menuitem" + class="event-only" + label="&calendar.context.convertmenu.task.label;" + accesskey="&calendar.context.convertmenu.task.accesskey;" + oncommand="calendarTaskButtonDNDObserver.onDropItems(agendaListbox.getSelectedItems())"/> + </menupopup> + </menu> + <menuseparator id="calendar-today-pane-menuseparator-before-delete"/> + <menuitem label="&calendar.context.deleteevent.label;" + accesskey="&calendar.context.deleteevent.accesskey;" + key="calendar-delete-item-key" + observes="agenda_delete_event_command"/> + <menu id="calendar-today-pane-menu-attendance-menu" + class="attendance-menu" + label="&calendar.context.attendance.menu.label;" + accesskey="&calendar.context.attendance.menu.accesskey;" + oncommand="setContextPartstat(event.target.value, event.target.getAttribute('scope'), agendaListbox.getSelectedItems({}))" + observes="calendar_attendance_command"> + <menupopup id="calendar-today-pane-menu-attendance-menupopup"> + <label id="calendar-today-pane-attendance-thisoccurrence-label" + class="calendar-context-heading-label" + scope="all-occurrences" + value="&calendar.context.attendance.occurrence.label;"/> + <menuitem id="calendar-today-pane-menu-attend-accept-menuitem" + type="radio" + scope="this-occurrence" + name="calendar-today-pane-attendance" + label="&read.only.accept.label;" value="ACCEPTED"/> + <menuitem id="calendar-today-pane-menu-attend-tentative-menuitem" + type="radio" + scope="this-occurrence" + name="calendar-today-pane-attendance" + label="&read.only.tentative.label;" value="TENTATIVE"/> + <menuitem id="calendar-today-pane-menu-attend-declined-menuitem" + type="radio" + scope="this-occurrence" + name="calendar-today-pane-attendance" + label="&read.only.decline.label;" value="DECLINED"/> + <menuitem id="calendar-today-pane-menu-attend-needsaction-menuitem" + type="radio" + scope="this-occurrence" + name="calendar-today-pane-attendance" + label="&read.only.needs.action.label;" value="NEEDS-ACTION"/> + <label id="calendar-today-pane-attendance-alloccurrence-label" + class="calendar-context-heading-label" + scope="all-occurrences" + value="&calendar.context.attendance.all.label;"/> + <menuitem id="calendar-today-pane-menu-attend-accept-all-menuitem" + type="radio" + scope="all-occurrences" + name="calendar-today-pane-attendance-all" + label="&read.only.accept.label;" value="ACCEPTED"/> + <menuitem id="calendar-today-pane-menu-attend-tentative-all-menuitem" + type="radio" + scope="all-occurrences" + name="calendar-today-pane-attendance-all" + label="&read.only.tentative.label;" value="TENTATIVE"/> + <menuitem id="calendar-today-pane-menu-attend-declined-all-menuitem" + type="radio" + scope="all-occurrences" + name="calendar-today-pane-attendance-all" + label="&read.only.decline.label;" value="DECLINED"/> + <menuitem id="calendar-today-pane-menu-attend-needsaction-all-menuitem" + type="radio" + scope="all-occurrences" + name="calendar-today-pane-attendance-all" + label="&read.only.needs.action.label;" value="NEEDS-ACTION"/> + </menupopup> + </menu> + </menupopup> + </richlistbox> + </vbox> + </modevbox> + <splitter id="today-pane-splitter" persist="hidden"/> + <modevbox id="todo-tab-panel" flex="1" mode="mail,calendar" + collapsedinmodes="mail,task" + broadcaster="modeBroadcaster" + persist="height collapsedinmodes" + ondragstart="nsDragAndDrop.startDrag(event, calendarTaskButtonDNDObserver);" + ondragover="nsDragAndDrop.dragOver(event, calendarTaskButtonDNDObserver);" + ondrop="nsDragAndDrop.drop(event, calendarTaskButtonDNDObserver);"/> + </vbox> + </modevbox> + + <commandset id="calendar_commands"> + <command id="calendar_toggle_todaypane_command" oncommand="TodayPane.toggleVisibility(event)"/> + </commandset> +</overlay> diff --git a/calendar/base/content/widgets/calendar-alarm-widget.xml b/calendar/base/content/widgets/calendar-alarm-widget.xml new file mode 100644 index 000000000..13c559b73 --- /dev/null +++ b/calendar/base/content/widgets/calendar-alarm-widget.xml @@ -0,0 +1,351 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<!DOCTYPE bindings +[ + <!ENTITY % dtd1 SYSTEM "chrome://calendar/locale/global.dtd" > %dtd1; + <!ENTITY % dtd2 SYSTEM "chrome://calendar/locale/calendar.dtd" > %dtd2; +]> + +<bindings id="calendar-alarms" + xmlns="http://www.mozilla.org/xbl" + xmlns:xbl="http://www.mozilla.org/xbl" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <binding id="calendar-alarm-widget" extends="chrome://global/content/bindings/richlistbox.xml#richlistitem"> + <resources> + <stylesheet src="chrome://global/skin/button.css"/> + <stylesheet src="chrome://calendar/skin/calendar-alarm-dialog.css"/> + </resources> + + <content orient="horizontal"> + <xul:vbox pack="start"> + <xul:image class="alarm-calendar-image"/> + </xul:vbox> + <xul:vbox flex="1"> + <xul:label anonid="alarm-title-label" class="alarm-title-label" crop="end"/> + <xul:vbox class="additional-information-box"> + <xul:label anonid="alarm-date-label" class="alarm-date-label"/> + <xul:hbox> + <xul:label anonid="alarm-location-label" class="location-label">&calendar.alarm.location.label;</xul:label> + <xul:description anonid="alarm-location-description" + class="alarm-location-description" + crop="end" + flex="1"/> + </xul:hbox> + <xul:hbox pack="start"> + <xul:label class="text-link alarm-details-label" + value="&calendar.alarm.details.label;" + onclick="showDetails(event)" + onkeypress="showDetails(event)"/> + </xul:hbox> + </xul:vbox> + </xul:vbox> + <xul:spacer flex="1"/> + <xul:label anonid="alarm-relative-date-label" class="alarm-relative-date-label"/> + <xul:vbox class="alarm-action-buttons" pack="center"> + <xul:button anonid="alarm-snooze-button" + type="menu" + label="&calendar.alarm.snoozefor.label;"> + <xul:menupopup type="snooze-menupopup" ignorekeys="true"/> + </xul:button> + <xul:button anonid="alarm-dismiss-button" + label="&calendar.alarm.dismiss.label;" + oncommand="dismissAlarm()"/> + </xul:vbox> + </content> + + <implementation> + <constructor><![CDATA[ + Components.utils.import("resource://calendar/modules/calUtils.jsm"); + ]]></constructor> + + <field name="mItem">null</field> + <field name="mAlarm">null</field> + + <property name="item" + onget="return this.mItem;" + onset="this.mItem = val; this.updateLabels(); return val;"/> + <property name="alarm" + onget="return this.mAlarm;" + onset="this.mAlarm = val; this.updateLabels(); return val;"/> + + <method name="updateLabels"> + <body><![CDATA[ + if (!this.mItem || !this.mAlarm) { + // Setup not complete, do nothing for now. + return; + } + + let formatter = cal.getDateFormatter(); + let titleLabel = document.getAnonymousElementByAttribute(this, "anonid", "alarm-title-label"); + let locationDescription = document.getAnonymousElementByAttribute(this, "anonid", "alarm-location-description"); + let dateLabel = document.getAnonymousElementByAttribute(this, "anonid", "alarm-date-label"); + + // Dates + if (cal.isEvent(this.mItem)) { + dateLabel.textContent = formatter.formatItemInterval(this.mItem); + } else if (cal.isToDo(this.mItem)) { + let startDate = this.mItem.entryDate || this.mItem.dueDate; + if (startDate) { + // A Task with a start or due date, show with label + startDate = startDate.getInTimezone(cal.calendarDefaultTimezone()); + dateLabel.textContent = calGetString("calendar", + "alarmStarts", + [formatter.formatDateTime(startDate)]); + } else { + // If the task has no start date, then format the alarm date. + dateLabel.textContent = formatter.formatDateTime(this.mAlarm.alarmDate); + } + } else { + throw Components.results.NS_ERROR_ILLEGAL_VALUE; + } + + // Relative date + this.updateRelativeDateLabel(); + + // Title, location + titleLabel.textContent = this.mItem.title || ""; + locationDescription.textContent = this.mItem.getProperty("LOCATION") || ""; + locationDescription.hidden = (locationDescription.textContent.length < 1); + + document.getAnonymousElementByAttribute(this, "anonid", "alarm-location-label").hidden = + (locationDescription.textContent.length < 1); + ]]></body> + </method> + + <method name="updateRelativeDateLabel"> + <body><![CDATA[ + let formatter = cal.getDateFormatter(); + let item = this.mItem; + let relativeDateLabel = document.getAnonymousElementByAttribute(this, "anonid", "alarm-relative-date-label"); + let relativeDateString; + let startDate = item[calGetStartDateProp(item)] || item[calGetEndDateProp(item)]; + if (startDate) { + startDate = startDate.getInTimezone(calendarDefaultTimezone()); + let currentDate = now(); + let sinceDayStart = (currentDate.hour * 3600) + (currentDate.minute * 60); + + currentDate.second = 0; + startDate.second = 0; + + let sinceAlarm = currentDate.subtractDate(startDate).inSeconds; + this.mAlarmToday = (sinceAlarm < sinceDayStart) && (sinceAlarm > sinceDayStart - 86400); + + if (this.mAlarmToday) { + // The alarm is today + relativeDateString = calGetString("calendar", + "alarmTodayAt", + [formatter.formatTime(startDate)]); + } else if (sinceAlarm <= sinceDayStart - 86400 && sinceAlarm > sinceDayStart - 172800) { + // The alarm is tomorrow + relativeDateString = calGetString("calendar", + "alarmTomorrowAt", + [formatter.formatTime(startDate)]); + } else if (sinceAlarm < sinceDayStart + 86400 && sinceAlarm > sinceDayStart) { + // The alarm is yesterday + relativeDateString = calGetString("calendar", + "alarmYesterdayAt", + [formatter.formatTime(startDate)]); + } else { + // The alarm is way back + relativeDateString = [formatter.formatDateTime(startDate)]; + } + } else { + // No start or end date, therefore the alarm must be absolute and + // have an alarm date. + relativeDateString = [formatter.formatDateTime(this.mAlarm.alarmDate)]; + } + + relativeDateLabel.textContent = relativeDateString; + ]]></body> + </method> + + <method name="showDetails"> + <parameter name="event"/> + <body><![CDATA[ + if (event.type == "click" || + (event.type == "keypress" && + event.keyCode == event.DOM_VK_RETURN)) { + let detailsEvent = document.createEvent("Events"); + detailsEvent.initEvent("itemdetails", true, false); + this.dispatchEvent(detailsEvent); + } + ]]></body> + </method> + + <method name="dismissAlarm"> + <body><![CDATA[ + let dismissEvent = document.createEvent("Events"); + dismissEvent.initEvent("dismiss", true, false); + this.dispatchEvent(dismissEvent); + ]]></body> + </method> + </implementation> + </binding> + + <binding id="calendar-snooze-popup"> + <content ignorekeys="true"> + <xul:menuitem label="&calendar.alarm.snooze.5minutes.label;" + value="5" + oncommand="snoozeItem(event)"/> + <xul:menuitem label="&calendar.alarm.snooze.10minutes.label;" + value="10" + oncommand="snoozeItem(event)"/> + <xul:menuitem label="&calendar.alarm.snooze.15minutes.label;" + value="15" + oncommand="snoozeItem(event)"/> + <xul:menuitem label="&calendar.alarm.snooze.30minutes.label;" + value="30" + oncommand="snoozeItem(event)"/> + <xul:menuitem label="&calendar.alarm.snooze.45minutes.label;" + value="45" + oncommand="snoozeItem(event)"/> + <xul:menuitem label="&calendar.alarm.snooze.1hour.label;" + value="60" + oncommand="snoozeItem(event)"/> + <xul:menuitem label="&calendar.alarm.snooze.2hours.label;" + value="120" + oncommand="snoozeItem(event)"/> + <xul:menuitem label="&calendar.alarm.snooze.1day.label;" + value="1440" + oncommand="snoozeItem(event)"/> + <children/> + <xul:menuseparator/> + <xul:hbox class="snooze-options-box"> + <xul:textbox anonid="snooze-value-textbox" + oninput="updateAccessibleName()" + onselect="updateAccessibleName()" + type="number" + size="3"/> + <xul:menulist anonid="snooze-unit-menulist" + class="snooze-unit-menulist menuitem-non-iconic" + allowevents="true"> + <xul:menupopup anonid="snooze-unit-menupopup" + position="after_start" + ignorekeys="true" + class="menulist-menupopup"> + <xul:menuitem closemenu="single" class="unit-menuitem" value="1"/> + <xul:menuitem closemenu="single" class="unit-menuitem" value="60"/> + <xul:menuitem closemenu="single" class="unit-menuitem" value="1440"/> + </xul:menupopup> + </xul:menulist> + <xul:toolbarbutton anonid="snooze-popup-ok" + class="snooze-popup-button snooze-popup-ok-button" + oncommand="snoozeOk()"/> + <xul:toolbarbutton anonid="snooze-popup-cancel" + class="snooze-popup-button snooze-popup-cancel-button" + aria-label="&calendar.alarm.snooze.cancel;" + oncommand="snoozeCancel()"/> + </xul:hbox> + </content> + <implementation> + <constructor><![CDATA[ + Components.utils.import("resource://gre/modules/Preferences.jsm"); + + let snoozePref = Preferences.get("calendar.alarms.defaultsnoozelength", 0); + if (snoozePref <= 0) { + snoozePref = 5; + } + + let unitList = document.getAnonymousElementByAttribute(this, "anonid", "snooze-unit-menulist"); + let unitValue = document.getAnonymousElementByAttribute(this, "anonid", "snooze-value-textbox"); + + let selectedIndex = 0; + if ((snoozePref % 60) == 0) { + snoozePref = snoozePref / 60; + if ((snoozePref % 24) == 0) { + snoozePref = snoozePref / 24; + selectedIndex = 2; // Days + } else { + selectedIndex = 1; // Hours + } + } else { + selectedIndex = 0; // Minutes + } + + unitList.selectedIndex = selectedIndex; + unitValue.value = snoozePref; + + updateAccessibleName(); + ]]></constructor> + + <method name="snoozeAlarm"> + <parameter name="minutes"/> + <body><![CDATA[ + let snoozeEvent = document.createEvent("Events"); + snoozeEvent.initEvent("snooze", true, false); + snoozeEvent.detail = minutes; + + // The onsnooze attribute is set on the menupopup, this binding is + // instanciated on the menupopup's arrowscrollbox. Therefore we need + // to go up one node. + let handler = this.parentNode.getAttribute("onsnooze"); + let cancel = false; + if (handler) { + let func = new Function("event", handler); + cancel = (func.call(this, snoozeEvent) === false); + } + + if (!cancel) { + this.dispatchEvent(snoozeEvent); + } + ]]></body> + </method> + + <method name="snoozeItem"> + <parameter name="event"/> + <body><![CDATA[ + this.snoozeAlarm(event.target.value); + ]]></body> + </method> + + <method name="snoozeOk"> + <body><![CDATA[ + let unitList = document.getAnonymousElementByAttribute(this, "anonid", "snooze-unit-menulist"); + let unitValue = document.getAnonymousElementByAttribute(this, "anonid", "snooze-value-textbox"); + + let minutes = (unitList.value || 1) * unitValue.value; + this.snoozeAlarm(minutes); + ]]></body> + </method> + + <method name="snoozeCancel"> + <body><![CDATA[ + this.parentNode.hidePopup(); + ]]></body> + </method> + + <method name="updateAccessibleName"> + <body><![CDATA[ + let unitList = document.getAnonymousElementByAttribute(this, "anonid", "snooze-unit-menulist"); + let unitPopup = document.getAnonymousElementByAttribute(this, "anonid", "snooze-unit-menupopup"); + let unitValue = document.getAnonymousElementByAttribute(this, "anonid", "snooze-value-textbox"); + let okButton = document.getAnonymousElementByAttribute(this, "anonid", "snooze-popup-ok"); + + function unitName(list) { + return { 1: "unitMinutes", 60: "unitHours", 1440: "unitDays" }[list.value] || "unitMinutes"; + } + + let pluralString = cal.calGetString("calendar", unitName(unitList)); + let unitPlural = PluralForm.get(unitValue.value, pluralString) + .replace("#1", unitValue.value); + + let accessibleString = cal.calGetString("calendar-alarms", + "reminderSnoozeOkA11y", + [unitPlural]); + okButton.setAttribute("aria-label", accessibleString); + + let items = unitPopup.getElementsByTagName("xul:menuitem"); + for (let menuItem of items) { + pluralString = cal.calGetString("calendar", unitName(menuItem)); + menuItem.label = PluralForm.get(unitValue.value, pluralString) + .replace("#1", "").trim(); + } + ]]></body> + </method> + </implementation> + </binding> +</bindings> diff --git a/calendar/base/content/widgets/calendar-list-tree.xml b/calendar/base/content/widgets/calendar-list-tree.xml new file mode 100644 index 000000000..a91fc873f --- /dev/null +++ b/calendar/base/content/widgets/calendar-list-tree.xml @@ -0,0 +1,1110 @@ +<?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/. --> + +<!DOCTYPE overlay SYSTEM "chrome://calendar/locale/calendar.dtd"> + +<bindings id="calendar-list-tree-bindings" + xmlns="http://www.mozilla.org/xbl" + xmlns:xbl="http://www.mozilla.org/xbl" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <binding id="full-calendar-list-tree" extends="#calendar-list-tree"> + <!-- + - This binding implements a full calendar list, that automatically adds + - and removes calendars when a calendar is registered or unregistered. + --> + <implementation> + <constructor><![CDATA[ + Components.utils.import("resource://calendar/modules/calUtils.jsm"); + let calMgr = cal.getCalendarManager(); + calMgr.addObserver(this.calMgrObserver); + + ]]></constructor> + <destructor><![CDATA[ + let calMgr = cal.getCalendarManager(); + calMgr.removeObserver(this.calMgrObserver); + this.calMgrObserver.listTree = null; + ]]></destructor> + + <field name="mAddingFromComposite">false</field> + + <property name="compositeCalendar"> + <getter><![CDATA[ + if (!this.mCompositeCalendar) { + throw Components.Exception("Calendar list has no composite calendar yet", + Components.results.NS_ERROR_NOT_INITIALIZED); + } + return this.mCompositeCalendar; + ]]></getter> + <setter><![CDATA[ + this.mCompositeCalendar = val; + this.mCompositeCalendar.addObserver(this.compositeObserver); + + // Now that we have a composite calendar, we can get all calendars + // from the calendar manager. + this.mAddingFromComposite = true; + let calendars = sortCalendarArray(getCalendarManager().getCalendars({})); + calendars.forEach(this.addCalendar, this); + this.mAddingFromComposite = false; + + return val; + ]]></setter> + </property> + + <property name="calendars"> + <getter><![CDATA[ + return this.mCalendarList; + ]]></getter> + <setter><![CDATA[ + // Setting calendars externally is not wanted. This is done internally + // in the compositeCalendar setter. + throw Components.Exception("Seting calendars on type='full' is not supported", + Components.results.NS_ERROR_NOT_IMPLEMENTED); + ]]></setter> + </property> + + <field name="calMgrObserver"><![CDATA[ + ({ + listTree: this, + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calICalendarManagerObserver]), + + // calICalendarManagerObserver + onCalendarRegistered: function(aCalendar) { + this.listTree.addCalendar(aCalendar); + let composite = this.listTree.compositeCalendar; + let inComposite = aCalendar.getProperty(composite.prefPrefix + + "-in-composite"); + if ((inComposite === null) || inComposite) { + composite.addCalendar(aCalendar); + } + }, + + onCalendarUnregistering: function(aCalendar) { + this.listTree.removeCalendar(aCalendar); + }, + + onCalendarDeleting: function(aCalendar) { + // Now that the calendar is unregistered, update the commands to + // make sure that New Event/Task commands are correctly + // enabled/disabled. + document.commandDispatcher.updateCommands("calendar_commands"); + } + }) + ]]></field> + <field name="compositeObserver"><![CDATA[ + ({ + listTree: this, + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calICompositeObserver, + Components.interfaces.calIObserver]), + + // calICompositeObserver + onCalendarAdded: function(aCalendar) { + // Make sure the checkbox state is updated + this.listTree.updateCalendar(aCalendar); + }, + + onCalendarRemoved: function(aCalendar) { + // Make sure the checkbox state is updated + this.listTree.updateCalendar(aCalendar); + }, + + onDefaultCalendarChanged: function(aCalendar) { + }, + + // calIObserver + onStartBatch: function() { }, + onEndBatch: function() { }, + onLoad: function() { }, + + onAddItem: function(aItem) { + if (aItem.calendar.type != "caldav") { + this.listTree.ensureCalendarVisible(aItem.calendar); + } + }, + onModifyItem: function(aNewItem, aOldItem) { + if (aNewItem.calendar.type != "caldav") { + this.listTree.ensureCalendarVisible(aNewItem.calendar); + } + }, + onDeleteItem: function(aDeletedItem) { }, + onError: function(aCalendar, aErrNo, aMessage) { }, + + onPropertyChanged: function(aCalendar, aName, aValue, aOldValue) { + switch (aName) { + case "disabled": + case "readOnly": + calendarUpdateNewItemsCommand(); + document.commandDispatcher.updateCommands("calendar_commands"); + break; + } + }, + + onPropertyDeleting: function(aCalendar, aName) { + } + }) + ]]></field> + </implementation> + <handlers> + <handler event="dblclick"><![CDATA[ + let col = {}; + let calendar = this.getCalendarFromEvent(event, col); + if (event.button != 0 || + (col.value && col.value.element && + col.value.element.getAttribute("anonid") == "checkbox-treecol")) { + // Only left clicks that are not on the checkbox column + return; + } + if (calendar) { + openCalendarProperties(calendar); + } else { + openCalendarWizard(); + } + ]]></handler> + </handlers> + </binding> + + <binding id="calendar-list-tree"> + <content> + <xul:tree anonid="tree" + xbl:inherits="hidecolumnpicker" + hidecolumnpicker="true" + seltype="single" + flex="1"> + <xul:treecols anonid="treecols" + xbl:inherits="hideheader" + hideheader="true"> + <xul:treecol anonid="checkbox-treecol" + xbl:inherits="cycler,hideheader" + cycler="true" + hideheader="true" + width="17"/> + <xul:treecol anonid="color-treecol" + xbl:inherits="cycler,hideheader" + hideheader="true" + width="16"/> + <xul:treecol anonid="calendarname-treecol" + xbl:inherits="cycler,hideheader" + hideheader="true" + label="&calendar.unifinder.tree.calendarname.label;" + flex="1"/> + <xul:treecol anonid="status-treecol" + xbl:inherits="cycler,hideheader" + hideheader="true" + width="18"/> + <children includes="treecol"/> + <xul:treecol anonid="scrollbar-spacer" + xbl:inherits="cycler,hideheader" + fixed="true" + hideheader="true"> + <!-- This is a very elegant workaround to make sure the last column + is not covered by the scrollbar in case of an overflow. This + treecol needs to be here last --> + <xul:slider anonid="scrollbar-slider" orient="vertical"/> + </xul:treecol> + </xul:treecols> + <xul:treechildren anonid="treechildren" + xbl:inherits="tooltip=childtooltip,context=childcontext" + tooltip="_child" + context="_child" + ondragstart="onDragStart(event);" + onoverflow="displayScrollbarSpacer(true)" + onunderflow="displayScrollbarSpacer(false)"> + <children includes="tooltip|menupopup"/> + </xul:treechildren> + </xul:tree> + </content> + <implementation implements="nsITreeView"> + + <field name="mCalendarList">[]</field> + <field name="mCompositeCalendar">null</field> + <field name="tree">null</field> + <field name="treebox">null</field> + <field name="ruleCache">null</field> + <field name="mCachedSheet">null</field> + + <field name="mCycleCalendarFlag">null</field> + <field name="mCycleTimer">null</field> + + <constructor><![CDATA[ + Components.utils.import("resource://calendar/modules/calUtils.jsm"); + Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + this.tree.view = this; + this.ruleCache = {}; + this.mCycleCalendarFlag = {}; + ]]></constructor> + <destructor><![CDATA[ + // Clean up the calendar manager observers. Do not use removeCalendar + // here since that will remove the calendar from the composite calendar. + for (let calendar of this.mCalendarList) { + calendar.removeObserver(this.calObserver); + } + + this.tree.view = null; + this.calObserver.listTree = null; + + if (this.mCompositeCalendar) { + this.mCompositeCalendar.removeObserver(this.compositeObserver); + } + ]]></destructor> + + <field name="calObserver"><![CDATA[ + ({ + listTree: this, + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calIObserver]), + + // calIObserver. Note that each registered calendar uses this observer + onStartBatch: function() { }, + onEndBatch: function() { }, + onLoad: function() { }, + + onAddItem: function(aItem) { }, + onModifyItem: function(aNewItem, aOldItem) { }, + onDeleteItem: function(aDeletedItem) { }, + onError: function(aCalendar, aErrNo, aMessage) { }, + + onPropertyChanged: function(aCalendar, aName, aValue, aOldValue) { + switch (aName) { + case "color": + // TODO See other TODO in this file about updateStyleSheetForViews + if ("updateStyleSheetForViews" in window) { + updateStyleSheetForViews(aCalendar); + } + this.listTree.updateCalendarColor(aCalendar); + // Fall through, update item in any case + case "name": + case "currentStatus": + case "readOnly": + case "disabled": + this.listTree.updateCalendar(aCalendar); + // Fall through, update commands in any cases. + } + }, + + onPropertyDeleting: function(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); + } + }) + ]]></field> + + <field name="compositeObserver"><![CDATA[ + ({ + listTree: this, + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calICompositeObserver]), + + // calICompositeObserver + onCalendarAdded: function(aCalendar) { + // Make sure the checkbox state is updated + this.listTree.updateCalendar(aCalendar); + }, + + onCalendarRemoved: function(aCalendar) { + // Make sure the checkbox state is updated + this.listTree.updateCalendar(aCalendar); + }, + + onDefaultCalendarChanged: function(aCalendar) { + } + }) + ]]></field> + + <property name="treechildren" + readonly="true" + onget="return document.getAnonymousElementByAttribute(this, 'anonid', 'treechildren')"/> + <property name="tree" + readonly="true" + onget="return document.getAnonymousElementByAttribute(this, 'anonid', 'tree')"/> + + + <property name="sheet" readonly="true"> + <getter><![CDATA[ + if (!this.mCachedSheet) { + for (let sheet of document.styleSheets) { + if (sheet.href == "chrome://calendar/skin/calendar-management.css") { + this.mCachedSheet = sheet; + break; + } + } + if (!this.mCachedSheet) { + cal.ERROR("Could not find calendar-management.css, needs to be added to " + + window.document.title + "'s stylesheets"); + } + } + + return this.mCachedSheet; + ]]></getter> + </property> + + <property name="calendars"> + <getter><![CDATA[ + return this.mCalendarList; + ]]></getter> + <setter><![CDATA[ + this.mCalendarList = val; + this.mCalendarList.forEach(this.addCalendar, this); + return this.mCalendarList; + ]]></setter> + </property> + + <property name="compositeCalendar"> + <getter><![CDATA[ + if (!this.mCompositeCalendar) { + this.mCompositeCalendar = + Components.classes["@mozilla.org/calendar/calendar;1?type=composite"] + .createInstance(Components.interfaces.calICompositeCalendar); + } + + return this.mCompositeCalendar; + ]]></getter> + <setter><![CDATA[ + if (this.mCompositeCalendar) { + throw Components.Exception("A composite calendar has already been set", + Components.results.NS_ERROR_ALREADY_INITIALIZED); + } + this.mCompositeCalendar = val; + this.mCompositeCalendar.addObserver(this.compositeObserver); + return val; + ]]></setter> + </property> + + <property name="sortOrder" + readonly="true" + onget="return this.mCalendarList.map(x => x.id);"/> + <property name="selectedCalendars" + readonly="true" + onget="return this.compositeCalendar.getCalendars({});"/> + <property name="allowDrag" + onget="return (this.getAttribute('allowdrag') == 'true');" + onset="return setBooleanAttribute(this, 'allowdrag', val);"/> + <property name="writable" + onget="return (this.getAttribute('writable') == 'true');" + onset="return setBooleanAttribute(this, 'writable', val);"/> + <property name="ignoreDisabledState" + onget="return (this.getAttribute('ignoredisabledstate') == 'true');" + onset="return setBooleanAttribute(this, 'ignoredisabledstate', val);"/> + + <method name="sortOrderChanged"> + <parameter name=""/> + <body><![CDATA[ + if (this.mAddingFromComposite) { + return; + } + let event = document.createEvent("Events"); + event.initEvent("SortOrderChanged", true, false); + event.sortOrder = this.sortOrder; + this.dispatchEvent(event); + + let handler = this.getAttribute("onSortOrderChanged"); + if (handler) { + // Call the given code in a function + let func = new Function("event", handler); + func(event); + } + ]]></body> + </method> + <method name="displayScrollbarSpacer"> + <parameter name="aShouldDisplay"/> + <body><![CDATA[ + let spacer = document.getAnonymousElementByAttribute(this, "anonid", "scrollbar-spacer"); + spacer.collapsed = !aShouldDisplay; + ]]></body> + </method> + + <method name="ensureCalendarVisible"> + <parameter name="aCalendar"/> + <body><![CDATA[ + this.compositeCalendar.addCalendar(aCalendar); + ]]></body> + </method> + + <method name="getColumn"> + <parameter name="aAnonId"/> + <body><![CDATA[ + let colElem = document.getAnonymousElementByAttribute(this, "anonid", aAnonId); + return this.treebox.columns.getColumnFor(colElem); + ]]></body> + </method> + + <method name="findIndexById"> + <!-- + - Find the array index of the calendar with the passed id. + - + - @param aId The calendar id to find an index for. + - @return The array index, or -1 if not found. + --> + <parameter name="aId"/> + <body><![CDATA[ + for (let i = 0; i < this.mCalendarList.length; i++) { + if (this.mCalendarList[i].id == aId) { + return i; + } + } + return -1; + ]]></body> + </method> + + <method name="addCalendar"> + <!-- + - Add a calendar to the calendar list + - + - @param aCalendar The calendar to add. + --> + <parameter name="aCalendar"/> + <body><![CDATA[ + let composite = this.compositeCalendar; + + let initialSortOrderPos = aCalendar.getProperty("initialSortOrderPos"); + if (initialSortOrderPos != null && initialSortOrderPos < this.mCalendarList.length) { + // Insert the calendar at the requested sort order position + // and then discard the property + this.mCalendarList.splice(initialSortOrderPos, 0, aCalendar); + aCalendar.deleteProperty("initialSortOrderPos"); + } else { + this.mCalendarList.push(aCalendar); + } + this.treebox.rowCountChanged(this.mCalendarList.length - 1, 1); + + if (!composite.defaultCalendar || + aCalendar.id == composite.defaultCalendar.id) { + this.tree.view.selection.select(this.mCalendarList.length - 1); + } + + this.updateCalendarColor(aCalendar); + + // TODO This should be done only once outside of this binding, but to + // do that right, we need to have an easy way to register an observer + // all calendar properties. This could be the calendar manager that + // holds an observer on every calendar anyway, which would then use the + // global observer service which clients can register with. + if ("updateStyleSheetForViews" in window) { + updateStyleSheetForViews(aCalendar); + } + + // Watch the calendar for changes, i.e color. + aCalendar.addObserver(this.calObserver); + + // Adding a calendar causes the sortorder to be changed. + this.sortOrderChanged(); + + // Re-assign defaultCalendar, sometimes it is not the right one after + // remove & add calendar. + if (composite.defaultCalendar && this.tree.currentIndex > -1) { + let currentCal = this.getCalendar(this.tree.currentIndex); + if (composite.defaultCalendar.id != currentCal.id) { + composite.defaultCalendar = currentCal; + } + } + ]]></body> + </method> + + <method name="removeCalendar"> + <!-- + - Remove a calendar from the calendar list + - + - @param aCalendar The calendar to remove. + --> + <parameter name="aCalendar"/> + <body><![CDATA[ + let index = this.findIndexById(aCalendar.id); + if (index < 0) { + return; + } + + this.mCalendarList.splice(index, 1); + if (index == this.rowCount) { + index--; + } + + this.tree.view.selection.select(index + 1); + this.treebox.rowCountChanged(index, -1); + + aCalendar.removeObserver(this.calObserver); + + // Make sure the calendar is removed from the composite calendar + this.compositeCalendar.removeCalendar(aCalendar); + + // Remove the css style rule from the sheet. + let sheet = this.sheet; + for (let i = 0; i < sheet.cssRules.length; i++) { + if (sheet.cssRules[i] == this.ruleCache[aCalendar.id]) { + sheet.deleteRule(i); + delete this.ruleCache[aCalendar.id]; + break; + } + } + + this.sortOrderChanged(); + ]]></body> + </method> + + <method name="updateCalendar"> + <!-- + - Update a calendar's tree row (to refresh the color and such) + - + - @param aCalendar The calendar to update. + --> + <parameter name="aCalendar"/> + <body><![CDATA[ + this.treebox.invalidateRow(this.findIndexById(aCalendar.id)); + ]]></body> + </method> + + <method name="updateCalendarColor"> + <!-- + - Update a calendar's color rules. + - + - @param aCalendar The calendar to update. + --> + <parameter name="aCalendar"/> + <body><![CDATA[ + let color = aCalendar.getProperty("color") || "#a8c2e1"; + let sheet = this.sheet; + if (!(aCalendar.id in this.ruleCache)) { + let ruleString = "calendar-list-tree > tree > treechildren" + + "::-moz-tree-cell(color-treecol, id-" + + aCalendar.id + ") {}"; + + let ruleIndex = sheet.insertRule(ruleString, sheet.cssRules.length); + this.ruleCache[aCalendar.id] = sheet.cssRules[ruleIndex]; + } + this.ruleCache[aCalendar.id].style.backgroundColor = color; + ]]></body> + </method> + + <method name="getCalendarFromEvent"> + <!-- + - Get the calendar from the given DOM event. This can be a Mouse event or a + - keyboard event. + - + - @param event The DOM event to check + - @param aCol An out-object for the column id. + - @param aRow An out-object for the row index. + --> + <parameter name="event"/> + <parameter name="aCol"/> + <parameter name="aRow"/> + <body><![CDATA[ + if (event.clientX && event.clientY) { + // If we have a client point, get the row directly from the client + // point. + aRow = aRow || {}; + this.treebox.getCellAt(event.clientX, + event.clientY, + aRow, + aCol || {}, + {}); + } else if (document.popupNode && document.popupNode.contextCalendar) { + // Otherwise, we can try to get the context calendar from the popupNode. + return document.popupNode.contextCalendar; + } + return aRow && aRow.value > -1 && this.mCalendarList[aRow.value]; + ]]></body> + </method> + + <method name="getCalendar"> + <!-- + - Get the calendar from a certain index. + - + - @param aIndex The index to get the calendar for. + --> + <parameter name="aIndex"/> + <body><![CDATA[ + let index = Math.max(0, Math.min(this.mCalendarList.length - 1, aIndex)); + return this.mCalendarList[index]; + ]]></body> + </method> + + <!-- Implement nsITreeView --> + <property name="rowCount" + readonly="true" + onget="return this.mCalendarList.length"/> + + <method name="getCellProperties"> + <parameter name="aRow"/> + <parameter name="aCol"/> + <body><![CDATA[ + try { + let rowProps = this.getRowProperties(aRow); + let colProps = this.getColumnProperties(aCol); + return rowProps + (rowProps && colProps ? " " : "") + colProps; + } catch (e) { + // It seems errors in these functions are not shown, do this + // explicitly. + cal.ERROR("Error getting cell props: " + e); + return ""; + } + ]]></body> + </method> + + <method name="getRowProperties"> + <parameter name="aRow"/> + <body><![CDATA[ + let properties = []; + let calendar = this.getCalendar(aRow); + let composite = this.compositeCalendar; + + // Set up the composite calendar status + properties.push(composite.getCalendarById(calendar.id) ? "checked" : "unchecked"); + + // Set up the calendar id + properties.push("id-" + calendar.id); + + // Get the calendar color + let color = (calendar.getProperty("color") || "").substr(1); + + // Set up the calendar color (background) + properties.push("color-" + (color || "default")); + + // Set a property to get the contrasting text color (foreground) + properties.push(cal.getContrastingTextColor(color || "a8c2e1")); + + let currentStatus = calendar.getProperty("currentStatus"); + if (!Components.isSuccessCode(currentStatus)) { + // 'readfailed' is supposed to "win" over 'readonly', meaning that + // if reading from a calendar fails there is no further need to also display + // information about 'readonly' status + properties.push("readfailed"); + } else if (calendar.readOnly) { + properties.push("readonly"); + } + + // Set up the disabled state + properties.push(!this.ignoreDisabledState && calendar.getProperty("disabled") ? + "disabled" : "enabled"); + + return properties.join(" "); + ]]></body> + </method> + + <method name="getColumnProperties"> + <parameter name="aCol"/> + <body><![CDATA[ + // Workaround for anonymous treecols + return aCol.element.getAttribute("anonid"); + ]]></body> + </method> + + <method name="isContainer"> + <parameter name="aRow"/> + <body><![CDATA[ + return false; + ]]></body> + </method> + + <method name="isContainerOpen"> + <parameter name="aRow"/> + <body><![CDATA[ + return false; + ]]></body> + </method> + + <method name="isContainerEmpty"> + <parameter name="aRow"/> + <body><![CDATA[ + return false; + ]]></body> + </method> + + <method name="isSeparator"> + <parameter name="aRow"/> + <body><![CDATA[ + return false; + ]]></body> + </method> + + <method name="isSorted"> + <parameter name="aRow"/> + <body><![CDATA[ + return false; + ]]></body> + </method> + + <method name="onDragStart"> + <!-- + - Initiate a drag operation for the calendar list. Can be used in the + - dragstart handler. + - + - @param event The DOM event containing drag information. + --> + <parameter name="event"/> + <body><![CDATA[ + let calendar = this.getCalendarFromEvent(event); + if (this.allowDrag && event.dataTransfer) { + // Setting data starts a drag session, do this only if dragging + // is enabled for this binding. + event.dataTransfer.setData("application/x-moz-calendarID", calendar.id); + event.dataTransfer.effectAllowed = "move"; + } + ]]></body> + </method> + + <method name="canDrop"> + <parameter name="aRow"/> + <parameter name="aOrientation"/> + <body><![CDATA[ + let dragSession = cal.getDragService().getCurrentSession(); + let dataTransfer = dragSession && dragSession.dataTransfer; + if (!this.allowDrag || !dataTransfer) { + // If dragging is not allowed or there is no data transfer then + // we can't drop (i.e dropping a file on the calendar list). + return false; + } + + let dragCalId = dataTransfer.getData("application/x-moz-calendarID"); + + return (aOrientation != Components.interfaces.nsITreeView.DROP_ON && + dragCalId != null); + ]]></body> + </method> + + <method name="drop"> + <parameter name="aRow"/> + <parameter name="aOrientation"/> + <body><![CDATA[ + let dragSession = cal.getDragService().getCurrentSession(); + let dataTransfer = dragSession.dataTransfer; + let dragCalId = dataTransfer && + dataTransfer.getData("application/x-moz-calendarID"); + if (!this.allowDrag || !dataTransfer || !dragCalId) { + return false; + } + + let oldIndex = -1; + for (let i = 0; i < this.mCalendarList.length; i++) { + if (this.mCalendarList[i].id == dragCalId) { + oldIndex = i; + break; + } + } + if (oldIndex < 0) { + return false; + } + + // If no row is specified (-1), then assume append. + let row = (aRow < 0 ? this.mCalendarList.length - 1 : aRow); + let targetIndex = row + Math.max(0, aOrientation); + + // We don't need to move if the target row has the same index as the old + // row. The same goes for dropping after the row before the old row or + // before the row after the old row. Think about it :-) + if (aRow != oldIndex && row + aOrientation != oldIndex) { + // Add the new one, remove the old one. + this.mCalendarList.splice(targetIndex, 0, this.mCalendarList[oldIndex]); + this.mCalendarList.splice(oldIndex + (oldIndex > targetIndex ? 1 : 0), 1); + + // Invalidate the tree rows between the old item and the new one. + if (oldIndex < targetIndex) { + this.treebox.invalidateRange(oldIndex, targetIndex); + } else { + this.treebox.invalidateRange(targetIndex, oldIndex); + } + + // Fire event + this.sortOrderChanged(); + } + return true; + ]]></body> + </method> + + <method name="foreignDrop"> + <!-- + - This function can be used by other nodes to simulate dropping on the + - tree. This can be used for example on the tree header so that the row + - will be inserted before the first visible row. The event client + - coordinate are used to determine if the row should be dropped before the + - first row (above treechildren) or below the last visible row (below top + - of treechildren). + - + - @param event The DOM drop event. + - @return Boolean indicating if the drop succeeded. + - + --> + <parameter name="event"/> + <body><![CDATA[ + let hasDropped; + if (event.clientY < this.tree.boxObject.y) { + hasDropped = this.drop(this.treebox.getFirstVisibleRow(), -1); + } else { + hasDropped = this.drop(this.treebox.getLastVisibleRow(), 1); + } + if (hasDropped) { + event.preventDefault(); + } + return hasDropped; + ]]></body> + </method> + + <method name="foreignCanDrop"> + <!-- + - Similar function to foreignCanDrop but for the dragenter event + - @see ::foreignDrop + --> + <parameter name="event"/> + <body><![CDATA[ + // The dragenter/dragover events expect false to be returned when + // dropping is allowed, therefore we return !canDrop. + if (event.clientY < this.tree.boxObject.y) { + return !this.canDrop(this.treebox.getFirstVisibleRow(), -1); + } else { + return !this.canDrop(this.treebox.getLastVisibleRow(), 1); + } + ]]></body> + </method> + + <method name="getParentIndex"> + <parameter name="aRow"/> + <body><![CDATA[ + return -1; + ]]></body> + </method> + + <method name="hasNextSibling"> + <parameter name="aRow"/> + <parameter name="aAfterIndex"/> + <body><![CDATA[ + ]]></body> + </method> + + <method name="getLevel"> + <parameter name="aRow"/> + <body><![CDATA[ + return 0; + ]]></body> + </method> + + <method name="getImageSrc"> + <parameter name="aRow"/> + <body><![CDATA[ + ]]></body> + </method> + + <method name="getProgressMode"> + <parameter name="aRow"/> + <parameter name="aCol"/> + <body><![CDATA[ + ]]></body> + </method> + + <method name="getCellValue"> + <parameter name="aRow"/> + <parameter name="aCol"/> + <body><![CDATA[ + let calendar = this.getCalendar(aRow); + let composite = this.compositeCalendar; + + switch (aCol.element.getAttribute("anonid")) { + case "checkbox-treecol": + return composite.getCalendarById(calendar.id) ? "true" : "false"; + case "status-treecol": + // The value of this cell shows the calendar readonly state + return (calendar.readOnly ? "true" : "false"); + } + return null; + ]]></body> + </method> + + <method name="getCellText"> + <parameter name="aRow"/> + <parameter name="aCol"/> + <body><![CDATA[ + switch (aCol.element.getAttribute("anonid")) { + case "calendarname-treecol": + return this.getCalendar(aRow).name; + } + return ""; + ]]></body> + </method> + + <method name="setTree"> + <parameter name="aTreeBox"/> + <body><![CDATA[ + this.treebox = aTreeBox; + ]]></body> + </method> + + <method name="toggleOpenState"> + <parameter name="aRow"/> + <body><![CDATA[ + ]]></body> + </method> + + <method name="cycleHeader"> + <parameter name="aCol"/> + <body><![CDATA[ + ]]></body> + </method> + + <method name="cycleCell"> + <parameter name="aRow"/> + <parameter name="aCol"/> + <body><![CDATA[ + let calendar = this.getCalendar(aRow); + if (this.mCycleCalendarFlag[calendar.id]) { + delete this.mCycleCalendarFlag[calendar.id]; + } else { + this.mCycleCalendarFlag[calendar.id] = calendar; + } + + if (this.mCycleTimer) { + clearTimeout(this.mCycleTimer); + } + this.treebox.invalidateRow(aRow); + this.mCycleTimer = setTimeout(this.cycleCellCommit.bind(this), 200); + ]]></body> + </method> + + <method name="cycleCellCommit"> + <body><![CDATA[ + let composite = this.compositeCalendar; + try { + composite.startBatch(); + for (let id in this.mCycleCalendarFlag) { + if (composite.getCalendarById(id)) { + composite.removeCalendar(this.mCycleCalendarFlag[id]); + } else { + composite.addCalendar(this.mCycleCalendarFlag[id]); + } + delete this.mCycleCalendarFlag[id]; + } + } finally { + composite.endBatch(); + } + ]]></body> + </method> + + <method name="isEditable"> + <parameter name="aRow"/> + <parameter name="aCol"/> + <body><![CDATA[ + return false; + ]]></body> + </method> + + <method name="setCellValue"> + <parameter name="aRow"/> + <parameter name="aCol"/> + <parameter name="aValue"/> + <body><![CDATA[ + let calendar = this.getCalendar(aRow); + let composite = this.compositeCalendar; + + switch (aCol.element.getAttribute("anonid")) { + case "checkbox-treecol": + if (aValue == "true") { + composite.addCalendar(calendar); + } else { + composite.removeCalendar(calendar); + } + break; + default: + return null; + } + return aValue; + ]]></body> + </method> + + <method name="setCellText"> + <parameter name="aRow"/> + <parameter name="aCol"/> + <parameter name="aValue"/> + <body><![CDATA[ + ]]></body> + </method> + + <method name="performAction"> + <parameter name="aAction"/> + <body><![CDATA[ + ]]></body> + </method> + + <method name="performActionOnRow"> + <parameter name="aAction"/> + <parameter name="aRow"/> + <body><![CDATA[ + ]]></body> + </method> + + <method name="performActionOnCell"> + <parameter name="aAction"/> + <parameter name="aRow"/> + <parameter name="aCol"/> + <body><![CDATA[ + ]]></body> + </method> + </implementation> + <handlers> + <handler event="select"><![CDATA[ + this.compositeCalendar.defaultCalendar = this.getCalendar(this.tree.currentIndex); + ]]></handler> + + <handler event="keypress" keycode="VK_DELETE"><![CDATA[ + if (this.writable) { + promptDeleteCalendar(this.compositeCalendar.defaultCalendar); + event.preventDefault(); + } + ]]></handler> + + <!-- use key=" " since keycode="VK_SPACE" doesn't work --> + <handler event="keypress" key=" "><![CDATA[ + if (this.tree.currentIndex > -1) { + this.cycleCell(this.tree.currentIndex, this.getColumn("checkbox-treecol")); + event.preventDefault(); + } + ]]></handler> + + <handler event="keypress" keycode="VK_DOWN" modifiers="control"><![CDATA[ + if (!this.allowDrag) { + return; + } + + let idx = this.tree.currentIndex; + + if (idx < this.mCalendarList.length - 1) { + this.mCalendarList.splice(idx + 1, 0, this.mCalendarList.splice(idx, 1)[0]); + this.treebox.invalidateRange(idx, idx + 1); + + if (this.tree.view.selection.isSelected(idx)) { + this.tree.view.selection.toggleSelect(idx); + this.tree.view.selection.toggleSelect(idx + 1); + } + if (this.tree.view.selection.currentIndex == idx) { + this.tree.view.selection.currentIndex = idx + 1; + } + + // Fire event + this.sortOrderChanged(); + } + // Don't call the default <key> handler. + event.preventDefault(); + ]]></handler> + + <handler event="keypress" keycode="VK_UP" modifiers="control"><![CDATA[ + if (!this.allowDrag) { + return; + } + + let idx = this.tree.currentIndex; + if (idx > 0) { + this.mCalendarList.splice(idx - 1, 0, this.mCalendarList.splice(idx, 1)[0]); + this.treebox.invalidateRange(idx - 1, idx); + + if (this.tree.view.selection.isSelected(idx)) { + this.tree.view.selection.toggleSelect(idx); + this.tree.view.selection.toggleSelect(idx - 1); + } + if (this.tree.view.selection.currentIndex == idx) { + this.tree.view.selection.currentIndex = idx - 1; + } + + // Fire event + this.sortOrderChanged(); + } + // Don't call the default <key> handler. + event.preventDefault(); + ]]></handler> + </handlers> + </binding> +</bindings> diff --git a/calendar/base/content/widgets/calendar-subscriptions-list.xml b/calendar/base/content/widgets/calendar-subscriptions-list.xml new file mode 100644 index 000000000..852f2c89f --- /dev/null +++ b/calendar/base/content/widgets/calendar-subscriptions-list.xml @@ -0,0 +1,129 @@ +<?xml version="1.0"?> +<!-- 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/. +--> + +<bindings id="calendar-subscriptions-list-bindings" + xmlns="http://www.mozilla.org/xbl" + xmlns:html="http://www.w3.org/1999/xhtml" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:xbl="http://www.mozilla.org/xbl"> + + <binding id="calendar-subscriptions-richlistbox" + extends="chrome://global/content/bindings/richlistbox.xml#richlistbox" + xbl:inherits="flex"> + + <implementation> + <method name="addCalendar"> + <parameter name="aCalendar"/> + <parameter name="bSubscribed"/> + <body><![CDATA[ + let newNode = createXULElement("calendar-subscriptions-richlistitem"); + this.appendChild(newNode); + newNode.setAttribute("anonid", "subscriptions-listitem"); + newNode.calendar = aCalendar; + newNode.subscribed = bSubscribed; + ]]></body> + </method> + + <method name="clear"> + <body><![CDATA[ + while (this.hasChildNodes()) { + this.lastChild.remove(); + } + ]]></body> + </method> + </implementation> + </binding> + + <binding id="calendar-subscriptions-richlistitem" + extends="chrome://global/content/bindings/richlistbox.xml#richlistitem"> + <content> + <xul:hbox flex="1"> + <xul:checkbox anonid="subscription-checkbox" class="calendar-subscriptions-richlistitem-checkbox"/> + <xul:label anonid="subscription-name" flex="1" crop="end"/> + </xul:hbox> + </content> + + <implementation> + <field name="mCalendar">null</field> + <field name="mSubscribed">false</field> + + <property name="calendar"> + <getter><![CDATA[ + return this.mCalendar; + ]]></getter> + <setter><![CDATA[ + this.setCalendar(val); + return val; + ]]></setter> + </property> + + <property name="subscribed"> + <getter><![CDATA[ + return this.mSubscribed; + ]]></getter> + <setter><![CDATA[ + this.mSubscribed = val; + this.checked = val; + return val; + ]]></setter> + </property> + + <property name="checked"> + <getter><![CDATA[ + let checkbox = document.getAnonymousElementByAttribute( + this, "anonid", "subscription-checkbox"); + if (checkbox.getAttribute("checked") == "true") { + return true; + } else { + return false; + } + ]]></getter> + <setter><![CDATA[ + let checkbox = document.getAnonymousElementByAttribute( + this, "anonid", "subscription-checkbox"); + if (val) { + checkbox.setAttribute("checked", "true"); + } else { + checkbox.removeAttribute("checked"); + } + return val; + ]]></setter> + </property> + + <property name="disabled"> + <getter><![CDATA[ + let checkbox = document.getAnonymousElementByAttribute( + this, "anonid", "subscription-checkbox"); + if (checkbox.getAttribute("disabled") == "true") { + return true; + } else { + return false; + } + ]]></getter> + <setter><![CDATA[ + let checkbox = document.getAnonymousElementByAttribute( + this, "anonid", "subscription-checkbox"); + if (val) { + checkbox.setAttribute("disabled", "true"); + } else { + checkbox.removeAttribute("disabled"); + } + return val; + ]]></setter> + </property> + + <method name="setCalendar"> + <parameter name="aCalendar"/> + <body><![CDATA[ + this.mCalendar = aCalendar; + let label = document.getAnonymousElementByAttribute( + this, "anonid", "subscription-name"); + label.setAttribute("value", aCalendar.name); + ]]></body> + </method> + </implementation> + </binding> +</bindings> diff --git a/calendar/base/content/widgets/calendar-widget-bindings.css b/calendar/base/content/widgets/calendar-widget-bindings.css new file mode 100644 index 000000000..f3dd4029a --- /dev/null +++ b/calendar/base/content/widgets/calendar-widget-bindings.css @@ -0,0 +1,70 @@ +/* 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/. */ + +treenode-checkbox { + -moz-binding: url("chrome://calendar/content/widgets/calendar-widgets.xml#treenode-checkbox"); +} + +modebox { + -moz-binding: url(chrome://calendar/content/widgets/calendar-widgets.xml#modebox); + -moz-user-focus: normal; +} + +modevbox { + -moz-binding: url(chrome://calendar/content/widgets/calendar-widgets.xml#modevbox); + -moz-user-focus: normal; +} + +modehbox { + -moz-binding: url(chrome://calendar/content/widgets/calendar-widgets.xml#modehbox); + -moz-user-focus: normal; +} + +toolbarbutton[doubleimage="true"] { + -moz-binding: url(chrome://calendar/content/widgets/calendar-widgets.xml#doubleimage-toolbarbutton); +} + +toolbarbutton[todaypane="true"] { + -moz-binding: url(chrome://calendar/content/widgets/calendar-widgets.xml#todaypane-toolbarbutton); +} + +minimonth { + -moz-binding: url("chrome://calendar/content/widgets/minimonth.xml#minimonth"); +} + +.minimonth-day { + -moz-binding: url("chrome://calendar/content/widgets/minimonth.xml#minimonth-day"); +} + +minimonth-header { + -moz-binding: url("chrome://calendar/content/widgets/minimonth.xml#active-minimonth-header"); +} + +minimonth-header[readonly="true"] { + -moz-binding: url("chrome://calendar/content/widgets/minimonth.xml#minimonth-header"); +} + +dragndropContainer { + -moz-binding: url(chrome://calendar/content/widgets/calendar-widgets.xml#dragndropContainer); +} + +tab[calview] { + -moz-binding: url(chrome://calendar/content/widgets/calendar-widgets.xml#view-tab); +} + +calendar-list-tree { + -moz-binding: url(chrome://calendar/content/widgets/calendar-list-tree.xml#calendar-list-tree); +} + +calendar-list-tree[type="full"] { + -moz-binding: url(chrome://calendar/content/widgets/calendar-list-tree.xml#full-calendar-list-tree); +} + +menulist[type="panel-menulist"] { + -moz-binding: url(chrome://calendar/content/widgets/calendar-widgets.xml#panel-menulist); +} + +panel[type="category-panel"] { + -moz-binding: url(chrome://calendar/content/widgets/calendar-widgets.xml#category-panel); +} diff --git a/calendar/base/content/widgets/calendar-widgets.xml b/calendar/base/content/widgets/calendar-widgets.xml new file mode 100644 index 000000000..4e2e8e5b1 --- /dev/null +++ b/calendar/base/content/widgets/calendar-widgets.xml @@ -0,0 +1,731 @@ +<?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/. --> +<!DOCTYPE dialog [ + <!ENTITY % dtd1 SYSTEM "chrome://global/locale/global.dtd" > %dtd1; + <!ENTITY % dtd2 SYSTEM "chrome://calendar/locale/calendar-event-dialog.dtd"> %dtd2; +]> + +<bindings id="calendar-widgets" + xmlns="http://www.mozilla.org/xbl" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + xmlns:xbl="http://www.mozilla.org/xbl"> + + <!-- Unfortunately, the normal menulist binding doesn't allow a panel child + This binding replaces the usual menulist to allow a panel --> + <binding id="panel-menulist" extends="chrome://global/content/bindings/menulist.xml#menulist"> + <content sizetopopup="pref"> + <xul:hbox class="menulist-label-box" flex="1"> + <xul:image class="menulist-icon" xbl:inherits="src=image,src"/> + <xul:label class="menulist-label" xbl:inherits="value=label,crop,accesskey" crop="right" flex="1"/> + </xul:hbox> + <xul:dropmarker class="menulist-dropmarker" type="menu" xbl:inherits="disabled,open"/> + <children includes="menupopup|panel"/> + </content> + </binding> + + <binding id="category-panel" extends="chrome://global/content/bindings/popup.xml#panel"> + <resources> + <stylesheet src="chrome://calendar/skin/widgets/calendar-widgets.css"/> + </resources> + <content> + <xul:textbox anonid="category-textbox" + class="categories-textbox" + type="search" + searchbutton="true" + placeholder="&event.categories.textbox.label;" + oncommand="document.getBindingParent(this).addNewCategory();" + flex="1"/> + <xul:listbox anonid="categories-listbox" + class="categories-listbox" + onselect="document.getBindingParent(this).selectCategory()" + selType="multiple" + > + <children/> + </xul:listbox> + </content> + <implementation> + <field name="_maxCount">0</field> + + <property name="categories" readonly="true"> + <getter><![CDATA[ + let categoryListbox = document.getAnonymousElementByAttribute(this, "anonid", "categories-listbox"); + if (this.maxCount == 1) { + let selectedItem = categoryListbox.selectedItem; + return selectedItem ? [selectedItem.getAttribute("value")] : []; + } else { + let checkedNodes = categoryListbox.getElementsByAttribute("checked", "true"); + let sliceEnd = this.maxCount > 0 ? this.maxCount : checkedNodes.length; + return Array.slice(checkedNodes, 0, sliceEnd) + .map(x => x.getAttribute("value")); + } + ]]></getter> + </property> + + <property name="maxCount"> + <getter><![CDATA[ + return this._maxCount; + ]]></getter> + <setter><![CDATA[ + if (this._maxCount != val) { + this._maxCount = val; + this.setupSelection(); + } + ]]></setter> + </property> + + <method name="selectCategory"> + <body><![CDATA[ + this.setupSelection(); + if (this.maxCount == 1) { + this.hidePopup(); + } + ]]></body> + </method> + + <method name="setupSelection"> + <body><![CDATA[ + let categoryListbox = document.getAnonymousElementByAttribute(this, "anonid", "categories-listbox"); + categoryListbox.setAttribute("seltype", this.maxCount == 1 ? "single" : "multiple"); + + if (this.maxCount == 1) { + for (let node of categoryListbox.childNodes) { + // Single selection doesn't have checkboxes + node.removeAttribute("type"); + + // Even though we have single select, these may be checked + // in case the user switches between calendars that support + // one vs multiple categories. Uncheck the other nodes to + // make sure the UX is not weird. + setBooleanAttribute(node, "checked", node == categoryListbox.selectedItem); + } + } else { + let categoryTextbox = document.getAnonymousElementByAttribute(this, "anonid", "category-textbox"); + let maxCountReached = this.maxCount > 0 && this.categories.length == this.maxCount; + setBooleanAttribute(categoryTextbox, "disabled", maxCountReached); + + for (let node of categoryListbox.childNodes) { + // Multiselect has checkboxes + node.setAttribute("type", "checkbox"); + + if (maxCountReached && node.getAttribute("checked") != "true") { + // If the maxcount is reached, disable all unchecked items + node.setAttribute("disabled", "true"); + } else if (!maxCountReached) { + // If its not reached, remove the disabled attribute + node.removeAttribute("disabled"); + } + } + } + ]]></body> + </method> + + <method name="insertCategory"> + <parameter name="category" /> + <parameter name="categories" /> + <parameter name="categoryListbox" /> + <parameter name="compare" /> + <body><![CDATA[ + let newIndex = cal.binaryInsert(categories, category, compare, true); + let item = categoryListbox.childNodes[Math.min(newIndex, categoryListbox.childNodes.length - 1)]; + + if (!item || item.getAttribute("value") != category) { + // The item doesn't exist, insert it at the correct spot. + item = categoryListbox.insertItemAt(newIndex, category, category); + + if (this.maxCount != 1) { + item.setAttribute("type", "checkbox"); + } + } + + item.setAttribute("checked", "true"); + return item; + ]]></body> + </method> + + <method name="addNewCategory"> + <body><![CDATA[ + let categoryListbox = document.getAnonymousElementByAttribute(this, "anonid", "categories-listbox"); + let categoryTextbox = document.getAnonymousElementByAttribute(this, "anonid", "category-textbox"); + let category = categoryTextbox.value; + + if (!category) { + return; + } + + let localeCollator = cal.createLocaleCollator(); + let compare = localeCollator.compareString.bind(localeCollator, 0); + + let children = categoryListbox.childNodes; + let categories = []; + for (let i = 0; i < children.length; i++) { + categories.push(children[i].label); + } + + let item = this.insertCategory(category, categories, categoryListbox, compare); + categoryTextbox.value = ""; + + if (this.maxCount == 1) { + categoryListbox.selectedItem = item; + } else { + this.selectCategory(); + } + + categoryListbox.ensureElementIsVisible(item); + ]]></body> + </method> + + <method name="loadItem"> + <parameter name="aItem"/> + <body><![CDATA[ + let categoryListbox = document.getAnonymousElementByAttribute(this, "anonid", "categories-listbox"); + let categoryList = getPrefCategoriesArray(); + + cal.sortArrayByLocaleCollator(categoryList); + + removeChildren(categoryListbox); + + for (let cat of categoryList) { + // First insert all categories from the prefs + let item = categoryListbox.appendItem(cat, cat); + item.setAttribute("type", "checkbox"); + } + + if (aItem) { + let localeCollator = cal.createLocaleCollator(); + let compare = localeCollator.compareString.bind(localeCollator, 0); + + // Ensure the item's categories are in the list and they are checked. + for (let cat of aItem.getCategories({})) { + this.insertCategory(cat, categoryList, categoryListbox, compare); + } + } + ]]></body> + </method> + </implementation> + </binding> + + <binding id="doubleimage-toolbarbutton" extends="chrome://global/content/bindings/toolbarbutton.xml#toolbarbutton"> + <resources> + <stylesheet src="chrome://calendar/skin/widgets/calendar-widgets.css"/> + </resources> + + <content> + <children includes="observes|template|menupopup|tooltip"/> + <xul:image class="toolbarbutton-icon-begin" xbl:inherits="validate,src-begin=image,toolbarmode,buttonstyle"/> + <xul:label class="toolbarbutton-text" crop="right" flex="1" + xbl:inherits="value=label,accesskey,crop,toolbarmode,buttonstyle"/> + <xul:image class="toolbarbutton-icon-end" xbl:inherits="validate,src-end=image,toolbarmode,buttonstyle"/> + </content> + </binding> + + <binding id="todaypane-toolbarbutton" extends="chrome://calendar/content/widgets/calendar-widgets.xml#doubleimage-toolbarbutton"> + <content> + <children includes="observes|template|menupopup|tooltip"/> + <xul:stack pack="center" align="end"> + <xul:image class="toolbarbutton-icon-begin" xbl:inherits="validate,src-begin=image,toolbarmode,buttonstyle"/> + <xul:label anonid="day-label" class="toolbarbutton-day-text"/> + </xul:stack> + <xul:label class="toolbarbutton-text" crop="right" flex="1" + xbl:inherits="value=label,accesskey,crop,toolbarmode,buttonstyle"/> + <xul:image class="toolbarbutton-icon-end" xbl:inherits="validate,src-end=image,toolbarmode,buttonstyle"/> + </content> + + <implementation> + <constructor><![CDATA[ + this.setUpTodayDate(); + ]]></constructor> + + <method name="setUpTodayDate"> + <body><![CDATA[ + let dayNumber = calGetString("dateFormat", "day." + cal.now().day + ".number"); + document.getAnonymousElementByAttribute(this, "anonid", "day-label").value = dayNumber; + ]]></body> + </method> + </implementation> + </binding> + + <!-- this binding directly extends to a checkbox but is visualized as + a treenode in a treecontrol--> + <binding id="treenode-checkbox" extends="chrome://global/content/bindings/checkbox.xml#checkbox-baseline"> + <resources> + <stylesheet src="chrome://calendar/skin/widgets/calendar-widgets.css"/> + </resources> + </binding> + + <!-- this binding directly extends to a xul:box element and automatically + sets the "orient" attribute to "vertical" thus behaving like a vbox--> + <binding id="modevbox" extends="chrome://calendar/content/widgets/calendar-widgets.xml#modebox"> + <resources> + <stylesheet src="chrome://calendar/skin/widgets/calendar-widgets.css"/> + </resources> + + <implementation> + <constructor><![CDATA[ + this.setAttribute("orient", "vertical"); + ]]></constructor> + </implementation> + </binding> + + <!-- this binding directly extends to a xul:box element and automatically + sets the "orient" attribute to "horizontal" thus behaving like a vbox--> + <binding id="modehbox" extends="chrome://calendar/content/widgets/calendar-widgets.xml#modebox"> + <resources> + <stylesheet src="chrome://calendar/skin/widgets/calendar-widgets.css"/> + </resources> + <implementation> + <constructor><![CDATA[ + this.setAttribute("orient", "horizontal"); + ]]></constructor> + </implementation> + </binding> + + <!-- this binding directly extends to a xul:box element and enriches this with some functionality: It is designed + to be displayed only 1) in given application modes (e.g "task" mode, "calendar" mode) and 2) only in relation + to the "checked" attribute of command or a checkbox control. + - The attribute "mode" denotes a coma-separated list of all modes that the binding should not be collapsed in, + e.g. mode="calendar,task" + - The attribute "broadcaster" points to the id of a broadcaster that is supposed to be notified (by the application) + as soon as the mode changes. When this happens the modebox" will be notified and will check if it should + collapse itself or not. + - The attribute "refcontrol" points to a control either a "command", "checkbox" or a "treenode-checkbox" or other + elements that support a "checked" attribute that is often used to denote whether a modebox is supposed to be + displayed or not. If "refcontrol" is set to the id of a command you can there set the oncommend attribute like: + "oncommand='document.getElementById('my-mode-pane').togglePane(event)'. In case it is a checkbox element or derived + checkbox element this is done automatically by listening to the event "CheckboxChange"; + So if the current application mode is one of the modes listed in the "mode" attribute it is + additionally verified if the xul-element denoted by "refcontrol" is checked or not. During runtime an attribute named + "collapsedinmodes" with the collpsed modes comma-separated e.g. "mail,calendar,task. This attribute is also made + persistent--> + <binding id="modebox" extends="xul:box"> + <resources> + <stylesheet src="chrome://calendar/skin/widgets/calendar-widgets.css"/> + </resources> + <implementation> + <field name="mBroadcaster">null</field>; + <field name="mModHandler">null</field>; + <field name="mRefControl">null</field>; + <field name="mControlHandler">null</field>; + + <constructor><![CDATA[ + if (this.hasAttribute("broadcaster")) { + this.setAttribute("broadcaster", this.getAttribute("broadcaster")); + } + if (this.hasAttribute("refcontrol")) { + this.mRefControl = document.getElementById(this.getAttribute("refcontrol")); + if (this.mRefControl && ((this.mRefControl.localName == "treenode-checkbox") || + (this.mRefControl.localName == "checkbox"))) { + this.mControlHandler = { + binding: this, + handleEvent: function(aEvent, aHandled) { + return this.binding.onCheckboxStateChange(aEvent, this.binding); + } + }; + this.mRefControl.addEventListener("CheckboxStateChange", this.mControlHandler, true); + } + } + ]]></constructor> + + <destructor><![CDATA[ + if (this.mBroadcaster) { + this.mBroadcaster.removeEventListener("DOMAttrModified", this.mModHandler, true); + } + if (this.mRefControl) { + this.mRefControl.removeEventListener("CheckboxStateChange", this.mControlHandler, true); + } + ]]></destructor> + + <property name="currentMode"> + <getter><![CDATA[ + if (this.mBroadcaster && this.mBroadcaster.hasAttribute("mode")) { + return this.mBroadcaster.getAttribute("mode"); + } else { + return ""; + } + ]]></getter> + </property> + + <method name="isVisible"> + <parameter name="aMode"/> + <body><![CDATA[ + let lMode = aMode || this.currentMode; + if (!this.isVisibleInMode(lMode)) { + return false; + } + let collapsedModes = this.getAttribute("collapsedinmodes").split(","); + return !collapsedModes.includes(lMode); + ]]></body> + </method> + + <method name="setModeAttribute"> + <parameter name="aModeAttribute"/> + <parameter name="aModeValue"/> + <parameter name="amode"/> + <body><![CDATA[ + if (this.hasAttribute(aModeAttribute)) { + let lMode = amode || this.currentMode; + let modeAttributeValues = this.getAttribute(aModeAttribute).split(","); + let modes = this.getAttribute("mode").split(","); + modeAttributeValues[modes.indexOf(lMode)] = aModeValue; + this.setAttribute(aModeAttribute, modeAttributeValues.join(",")); + } + ]]></body> + </method> + + <method name="getModeAttribute"> + <parameter name="aModeAttribute"/> + <parameter name="aAttribute"/> + <parameter name="amode"/> + <body><![CDATA[ + if (this.hasAttribute(aModeAttribute)) { + let lMode = amode || this.currentMode; + let modeAttributeValues = this.getAttribute(aModeAttribute).split(","); + let modes = this.getAttribute("mode").split(","); + return modeAttributeValues[modes.indexOf(lMode)]; + } else { + return ""; + } + ]]></body> + </method> + + <method name="setVisible"> + <parameter name="aVisible"/> + <parameter name="aPushModeCollapsedAttribute"/> + <parameter name="aNotifyRefControl"/> + <body><![CDATA[ + let notifyRefControl = aNotifyRefControl == null || aNotifyRefControl === true; + let pushModeCollapsedAttribute = aPushModeCollapsedAttribute == null || + aPushModeCollapsedAttribute === true; + let collapsedModes = []; + let modeIndex = -1; + let display = aVisible; + let collapsedInMode = false; + if (this.hasAttribute("collapsedinmodes")) { + collapsedModes = this.getAttribute("collapsedinmodes").split(","); + modeIndex = collapsedModes.indexOf(this.currentMode); + collapsedInMode = modeIndex > -1; + } + if (aVisible === true && pushModeCollapsedAttribute == false) { + display = (aVisible === true) && (!collapsedInMode); + } + + setBooleanAttribute(this, "collapsed", !display || !this.isVisibleInMode()); + if (pushModeCollapsedAttribute) { + if (!display) { + if (modeIndex == -1) { + collapsedModes.push(this.currentMode); + if (this.getAttribute("collapsedinmodes") == ",") { + collapsedModes.splice(0, 2); + } + } + } else if (modeIndex > -1) { + collapsedModes.splice(modeIndex, 1); + if (collapsedModes.join(",") == "") { + collapsedModes[0] = ","; + } + } + this.setAttribute("collapsedinmodes", collapsedModes.join(",")); + let id = this.getAttribute("id"); + if (id) { + document.persist(id, "collapsedinmodes"); + } + } + if (notifyRefControl === true) { + if (this.hasAttribute("refcontrol")) { + let command = document.getElementById(this.getAttribute("refcontrol")); + if (command) { + command.setAttribute("checked", display); + setBooleanAttribute(command, "disabled", !this.isVisibleInMode()); + } + } + } + ]]></body> + </method> + + <method name="isVisibleInMode"> + <parameter name="aMode"/> + <body><![CDATA[ + let lMode = aMode || this.currentMode; + let display = true; + let lModes = []; + if (this.hasAttribute("mode")) { + let modeString = this.getAttribute("mode"); + lModes = modeString.split(","); + } + if (lModes && lModes.length > 0) { + display = lModes.includes(lMode); + } + return display; + ]]></body> + </method> + + <method name="onModeModified"> + <parameter name="aEvent"/> + <parameter name="aBinding"/> + <body><![CDATA[ + if (aEvent.attrName == "mode") { + let display = aBinding.isVisibleInMode(aEvent.newValue); + aBinding.setVisible(display, false, true); + } + ]]></body> + </method> + + <method name="togglePane"> + <parameter name="aEvent"/> + <body><![CDATA[ + let command = aEvent.target; + let newValue = (command.getAttribute("checked") == "true" ? "false" : "true"); + command.setAttribute("checked", newValue); + this.setVisible(newValue == "true", true, true); + ]]></body> + </method> + + <method name="onCheckboxStateChange"> + <parameter name="aEvent"/> + <parameter name="aBinding"/> + <body><![CDATA[ + let newValue = aEvent.target.checked; + this.setVisible(newValue, true, true); + ]]></body> + </method> + + <method name="setAttribute"> + <parameter name="aAttr"/> + <parameter name="aVal"/> + <body><![CDATA[ + if (aAttr == "broadcaster") { + this.mBroadcaster = document.getElementById(aVal); + if (this.mBroadcaster) { + this.mModHandler = { + binding: this, + handleEvent: function(aEvent, aHandled) { + return this.binding.onModeModified(aEvent, this.binding); + } + }; + this.mBroadcaster.addEventListener("DOMAttrModified", this.mModHandler, true); + } + } + return XULElement.prototype.setAttribute.call(this, aAttr, aVal); + ]]></body> + </method> + </implementation> + </binding> + + <!-- This binding may server as a droptarget container for arbitrary items + it contains methods to add DropShadows. This binding is meant to be used + as a parent binding. The methods may be overwritten. --> + <binding id="dragndropContainer"> + <implementation> + <field name="mDropShadows">[]</field> + <field name="mCalendarView">null</field> + + <!-- The ViewController that supports the interface 'calICalendarView'--> + <property name="calendarView" + onget="return this.mCalendarView;" + onset="return (this.mCalendarView = val);"/> + + <!-- method to add individual code e.g to set up the new item during + 'ondrop' --> + <method name="onDropItem"> + <parameter name="aItem"/> + <body><![CDATA[ + // method that may be overridden by derived bindings... + ]]></body> + </method> + + <method name="getDropShadows"> + <body><![CDATA[ + return this.mDropShadows; + ]]></body> + </method> + + <!-- Adds the dropshadows to the children of the binding. The dropshadows + are added at the first position of the children --> + <method name="addDropShadows"> + <body><![CDATA[ + if (this.mDropShadows) { + if (this.getElementsByAttribute("class", "dropshadow").length == 0) { + let offset = this.calendarView.mShadowOffset; + let shadowStartDate = this.date.clone(); + shadowStartDate.addDuration(offset); + this.calendarView.mDropShadows = []; + for (let i = 0; i < this.calendarView.mDropShadowsLength; i++) { + let box = this.calendarView.findDayBoxForDate(shadowStartDate); + if (!box) { + // Dragging to the end or beginning of a view + shadowStartDate.day += 1; + continue; + } + let dropshadow = createXULElement("box"); + dropshadow.setAttribute("class", "dropshadow"); + if (box.hasChildNodes()) { + box.insertBefore(dropshadow, box.firstChild); + } else { + box.appendChild(dropshadow); + } + shadowStartDate.day += 1; + this.calendarView.mDropShadows.push(box); + } + } + } + ]]></body> + </method> + + <!-- removes all dropShadows from the binding. Dropshadows are recognized + as such by carrying an attribute "dropshadow" --> + <method name="removeDropShadows"> + <body><![CDATA[ + // method that may be overwritten by derived bindings... + if (this.calendarView.mDropShadows) { + for (let shadow of this.calendarView.mDropShadows) { + cal.removeChildElementsByAttribute(shadow, "class", "dropshadow"); + } + } + this.calendarView.mDropShadows = null; + ]]></body> + </method> + + <!-- By setting the attribute "dropbox" to "true" or "false" the + dropshadows are added or removed --> + <method name="setAttribute"> + <parameter name="aAttr"/> + <parameter name="aVal"/> + <body><![CDATA[ + if (aAttr == "dropbox") { + let session = cal.getDragService().getCurrentSession(); + let startingDayBox = session.sourceNode.mParentBox; + if (session) { + session.canDrop = true; + // no shadows when dragging in the initial position + if (aVal == "true" && this != startingDayBox) { + this.mDropShadows = [session.sourceNode.sourceObject]; + this.addDropShadows(); + } else { + this.removeDropShadows(); + } + } + } + return XULElement.prototype.setAttribute.call(this, aAttr, aVal); + ]]></body> + </method> + </implementation> + + <handlers> + <handler event="dragstart" phase="capturing"><![CDATA[ + let draggedDOMNode = event.target; + if (!draggedDOMNode || draggedDOMNode.parentNode != this) { + return; + } + let item = draggedDOMNode.occurrence.clone(); + let beginMoveDate = draggedDOMNode.mParentBox.date; + let itemStartDate = (item.startDate || item.entryDate || item.dueDate).getInTimezone(calendarView.mTimezone); + let itemEndDate = (item.endDate || item.dueDate || item.entryDate).getInTimezone(calendarView.mTimezone); + let oneMoreDay = (itemEndDate.hour > 0 || itemEndDate.minute > 0); + itemStartDate.isDate = true; + itemEndDate.isDate = true; + let offsetDuration = itemStartDate.subtractDate(beginMoveDate); + let lenDuration = itemEndDate.subtractDate(itemStartDate); + let len = lenDuration.weeks * 7 + lenDuration.days; + this.calendarView.mShadowOffset = offsetDuration; + this.calendarView.mDropShadowsLength = oneMoreDay ? len + 1 : len; + ]]></handler> + + <handler event="dragover"><![CDATA[ + let session = cal.getDragService().getCurrentSession(); + if (!session || !session.sourceNode || !session.sourceNode.sourceObject) { + // No source item? Then this is not for us. + return; + } + + // We handled the event + event.preventDefault(); + ]]></handler> + + <handler event="dragenter"><![CDATA[ + if (event.target.localName == this.localName) { + let session = cal.getDragService().getCurrentSession(); + if (session) { + if (!session.sourceNode || !session.sourceNode.sourceObject) { + // No source item? Then this is not for us. + return; + } + + // We can drop now, tell the drag service. + event.preventDefault(); + + if (!this.hasAttribute("dropbox") || this.getAttribute("dropbox") == "false") { + // As it turned out it was not possible to remove the remaining dropshadows + // at the "dragleave" or "dragexit" event, majorly because it was not reliably + // fired. As the dragndropcontainer may be anonymous it is further on not + // possible to remove the dropshadows by something like + // "document.getElementsByAttribute('dropbox').removeDropShadows();"; + // So we have to remove them at the currentView(). The restriction of course is + // that these containers so far may not be used for drag and drop from/to e.g. + // the today-pane. + currentView().removeDropShadows(); + } + this.setAttribute("dropbox", "true"); + } + } + ]]></handler> + + <handler event="drop"><![CDATA[ + let session = cal.getDragService().getCurrentSession(); + if (!session || !session.sourceNode || !session.sourceNode.sourceObject) { + // No source node? Not our drag. + return; + } + let item = session.sourceNode.sourceObject.clone(); + this.setAttribute("dropbox", "false"); + let transfer = Components.classes["@mozilla.org/widget/transferable;1"] + .createInstance(Components.interfaces.nsITransferable); + transfer.init(null); + + if (isEvent(item)) { + transfer.addDataFlavor("application/x-moz-cal-event"); + } else { + transfer.addDataFlavor("application/x-moz-cal-task"); + } + + session.getData(transfer, 0); + item = session.sourceNode.sourceObject; + + let newItem = this.onDropItem(item).clone(); + let newStart = newItem.startDate || newItem.entryDate || newItem.dueDate; + let newEnd = newItem.endDate || newItem.dueDate || newItem.entryDate; + let offset = this.calendarView.mShadowOffset; + newStart.addDuration(offset); + newEnd.addDuration(offset); + this.calendarView.controller.modifyOccurrence(item, newStart, newEnd); + + // We handled the event + event.stopPropagation(); + ]]></handler> + + <handler event="dragend"><![CDATA[ + currentView().removeDropShadows(); + ]]></handler> + </handlers> + </binding> + + <binding id="view-tab" extends="chrome://global/content/bindings/tabbox.xml#tab"> + <resources> + <stylesheet src="chrome://calendar/skin/widgets/calendar-widgets.css"/> + </resources> + + <content> + <xul:hbox class="tab-middle box-inherit" xbl:inherits="align,dir,pack,orient,selected" flex="1"> + <xul:image class="tab-icon" xbl:inherits="validate,src=image"/> + <xul:stack> + <xul:label class="tab-text unselected-text" + xbl:inherits="value=label,accesskey,crop,disabled,selected" + flex="1"/> + <xul:label class="tab-text selected-text" + xbl:inherits="value=label,accesskey,crop,disabled,selected" + flex="1"/> + </xul:stack> + </xul:hbox> + </content> + </binding> +</bindings> diff --git a/calendar/base/content/widgets/minimonth.xml b/calendar/base/content/widgets/minimonth.xml new file mode 100644 index 000000000..931aa0028 --- /dev/null +++ b/calendar/base/content/widgets/minimonth.xml @@ -0,0 +1,1221 @@ +<?xml version="1.0"?> +<!-- 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/. --> + +<!-- + MiniMonth Calendar: day-of-month grid XBL component. + Displays month name and year above grid of days of month by week rows. + Arrows move forward or back a month. + Selecting a month name from month menu moves to that month in same year. + Selecting a year from year menu moves to same month in selected year. + Clicking on a day cell calls onchange attribute. + Changing month via arrows or menus calls onmonthchange attribute. + + At site, can provide id, and code to run when value changed by picker. + <calendar id="my-date-picker" onchange="myDatePick( this );"/> + + May get/set value in javascript with + document.getElementById("my-date-picker").value = new Date(); + + Use attributes onpopuplisthidden and onmonthchange for working around + bugs that occur when minimonth is displayed in a popup (as in datepicker): + Currently (2005.3) + whenever a child popup is hidden, the parent popup needs to be reshown. + Use onpopuplisthidden to reshow parent popop (hidePopup, openPopup). + When title month or year changes, parent popup may need to be reshown. + Use onmonthchange to reshow parent popop (hidePopup, openPopup). +--> + +<!DOCTYPE bindings +[ + <!ENTITY % dtd1 SYSTEM "chrome://calendar/locale/global.dtd" > %dtd1; + <!ENTITY % dtd2 SYSTEM "chrome://global/locale/global.dtd" > %dtd2; +]> + +<bindings id="xulMiniMonth" + xmlns="http://www.mozilla.org/xbl" + xmlns:xbl="http://www.mozilla.org/xbl" + xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + + <binding id="minimonth-header" extends="xul:box"> + <content class="minimonth-month-box" align="center"> + <xul:deck anonid="monthheader" xbl:inherits="selectedIndex=month" class="minimonth-month-name-readonly"> + <xul:text value="&month.1.name;"/> + <xul:text value="&month.2.name;"/> + <xul:text value="&month.3.name;"/> + <xul:text value="&month.4.name;"/> + <xul:text value="&month.5.name;"/> + <xul:text value="&month.6.name;"/> + <xul:text value="&month.7.name;"/> + <xul:text value="&month.8.name;"/> + <xul:text value="&month.9.name;"/> + <xul:text value="&month.10.name;"/> + <xul:text value="&month.11.name;"/> + <xul:text value="&month.12.name;"/> + </xul:deck> + <xul:text anonid="yearcell" class="minimonth-year-name-readonly" xbl:inherits="value=year"/> + <xul:spacer flex="1"/> + </content> + </binding> + + <binding id="active-minimonth-header" extends="chrome://calendar/content/widgets/minimonth.xml#minimonth-header"> + <content class="minimonth-month-box" align="center"> + <xul:deck anonid="monthheader" xbl:inherits="selectedIndex=month" class="minimonth-month-name"> + <xul:toolbarbutton label="&month.1.name;" oncommand="showPopupList(event, 'months-popup')"/> + <xul:toolbarbutton label="&month.2.name;" oncommand="showPopupList(event, 'months-popup')"/> + <xul:toolbarbutton label="&month.3.name;" oncommand="showPopupList(event, 'months-popup')"/> + <xul:toolbarbutton label="&month.4.name;" oncommand="showPopupList(event, 'months-popup')"/> + <xul:toolbarbutton label="&month.5.name;" oncommand="showPopupList(event, 'months-popup')"/> + <xul:toolbarbutton label="&month.6.name;" oncommand="showPopupList(event, 'months-popup')"/> + <xul:toolbarbutton label="&month.7.name;" oncommand="showPopupList(event, 'months-popup')"/> + <xul:toolbarbutton label="&month.8.name;" oncommand="showPopupList(event, 'months-popup')"/> + <xul:toolbarbutton label="&month.9.name;" oncommand="showPopupList(event, 'months-popup')"/> + <xul:toolbarbutton label="&month.10.name;" oncommand="showPopupList(event, 'months-popup')"/> + <xul:toolbarbutton label="&month.11.name;" oncommand="showPopupList(event, 'months-popup')"/> + <xul:toolbarbutton label="&month.12.name;" oncommand="showPopupList(event, 'months-popup')"/> + </xul:deck> + <xul:toolbarbutton anonid="yearcell" + class="minimonth-year-name" + oncommand="showPopupList(event, 'years-popup')" + xbl:inherits="label=year"/> + <xul:spacer flex="1"/> + <xul:toolbarbutton anonid="back-button" class="minimonth-nav-btns" dir="-1" + oncommand="this.kMinimonth.advanceMonth(parseInt(this.getAttribute('dir'), 10))" + tooltiptext="&onemonthbackward.tooltip;"/> + <xul:toolbarbutton anonid="today-button" class="minimonth-nav-btns" dir="0" + oncommand="this.kMinimonth.value = new Date();" + tooltiptext="&showToday.tooltip;"/> + <xul:toolbarbutton anonid="forward-button" class="minimonth-nav-btns" dir="1" + oncommand="this.kMinimonth.advanceMonth(parseInt(this.getAttribute('dir'), 10))" + tooltiptext="&onemonthforward.tooltip;"/> + <xul:popupset anonid="minmonth-popupset"> + <xul:menupopup anonid="months-popup" position="after_start" + onpopupshowing="event.stopPropagation();" + onpopuphidden="firePopupListHidden();"> + <xul:vbox> + <xul:text class="minimonth-list" value="&month.1.name;" index="0"/> + <xul:text class="minimonth-list" value="&month.2.name;" index="1"/> + <xul:text class="minimonth-list" value="&month.3.name;" index="2"/> + <xul:text class="minimonth-list" value="&month.4.name;" index="3"/> + <xul:text class="minimonth-list" value="&month.5.name;" index="4"/> + <xul:text class="minimonth-list" value="&month.6.name;" index="5"/> + <xul:text class="minimonth-list" value="&month.7.name;" index="6"/> + <xul:text class="minimonth-list" value="&month.8.name;" index="7"/> + <xul:text class="minimonth-list" value="&month.9.name;" index="8"/> + <xul:text class="minimonth-list" value="&month.10.name;" index="9"/> + <xul:text class="minimonth-list" value="&month.11.name;" index="10"/> + <xul:text class="minimonth-list" value="&month.12.name;" index="11"/> + </xul:vbox> + </xul:menupopup> + <xul:menupopup anonid="years-popup" position="after_start" + onpopupshowing="moveYears('reset', 0); event.stopPropagation();" + onpopuphidden="firePopupListHidden();"> + <xul:vbox> + <xul:autorepeatbutton class="autorepeatbutton-up" + orient="vertical" + oncommand="moveYears('up', 1);"/> + <xul:text class="minimonth-list"/> + <xul:text class="minimonth-list"/> + <xul:text class="minimonth-list"/> + <xul:text class="minimonth-list"/> + <xul:text class="minimonth-list"/> + <xul:text class="minimonth-list"/> + <xul:text class="minimonth-list"/> + <xul:text class="minimonth-list"/> + <xul:text class="minimonth-list"/> + <xul:autorepeatbutton class="autorepeatbutton-down" + orient="vertical" + oncommand="moveYears('down', 1);"/> + </xul:vbox> + </xul:menupopup> + </xul:popupset> + </content> + <implementation> + <field name="kMinimonth">null</field> + <field name="mPopup">null</field> + <field name="mScrollYearsHandler">null</field> + <field name="mPixelScrollDelta">0</field> + <constructor><![CDATA[ + this.kMinimonth = getParentNodeOrThis(this, "minimonth"); + document.getAnonymousElementByAttribute(this, "anonid", "back-button").kMinimonth = this.kMinimonth; + document.getAnonymousElementByAttribute(this, "anonid", "today-button").kMinimonth = this.kMinimonth; + document.getAnonymousElementByAttribute(this, "anonid", "forward-button").kMinimonth = this.kMinimonth; + + this.mScrollYearsHandler = this.scrollYears.bind(this); + document.getAnonymousElementByAttribute(this, "anonid", "years-popup") + .addEventListener("wheel", this.mScrollYearsHandler, true); + ]]></constructor> + + <destructor><![CDATA[ + document.getAnonymousElementByAttribute(this, "anonid", "years-popup") + .removeEventListener("wheel", this.mScrollYearsHandler, true); + this.mScrollYearsHandler = null; + ]]></destructor> + + <method name="showPopupList"> + <parameter name="aEvent"/> + <parameter name="aPopupAnonId"/> + <body><![CDATA[ + // Close open popups (if any), to prevent linux crashes + if (this.mPopup) { + this.mPopup.hidePopup(); + } + this.mPopup = document.getAnonymousElementByAttribute(this, "anonid", aPopupAnonId); + this.mPopup.openPopup(aEvent.target, "after_start"); + ]]></body> + </method> + + <method name="hidePopupList"> + <body><![CDATA[ + // Close open popups (if any) + let popup = this.mPopup; + this.mPopup = null; + if (popup) { + popup.hidePopup(); + } + ]]></body> + </method> + + <method name="firePopupListHidden"> + <body><![CDATA[ + if (this.mPopup) { + this.mPopup = null; + this.kMinimonth.fireEvent("popuplisthidden"); + } + ]]></body> + </method> + + <method name="updateMonthPopup"> + <parameter name="aDate"/> + <body><![CDATA[ + let months = document.getAnonymousElementByAttribute(this, "anonid", "months-popup").firstChild.childNodes; + let month = aDate.getMonth(); + for (let i = 0; i < months.length; i++) { + months[i].setAttribute("current", i == month ? "true" : "false"); + } + ]]></body> + </method> + + <method name="updateYearPopup"> + <parameter name="aDate"/> + <body><![CDATA[ + let years = document.getAnonymousElementByAttribute(this, "anonid", "years-popup").firstChild.childNodes; + let year = new Date(aDate); + let compFullYear = aDate.getFullYear(); + year.setFullYear(Math.max(1, compFullYear - Math.trunc(years.length / 2) + 1)); + for (let i = 1; i < years.length - 1; i++) { + let curfullYear = year.getFullYear(); + years[i].setAttribute("value", curfullYear); + years[i].setAttribute("current", curfullYear == compFullYear ? "true" : "false"); + year.setFullYear(curfullYear + 1); + } + ]]></body> + </method> + + <method name="scrollYears"> + <parameter name="event"/> + <body><![CDATA[ + let yearPopup = getParentNodeOrThis(event.target, "menupopup"); + const pixelThreshold = 75; + if (yearPopup) { + let monthList = yearPopup.getElementsByAttribute("class", "minimonth-list"); + if (monthList && monthList.length > 0) { + if (event.deltaMode == event.DOM_DELTA_PAGE) { + let dir = event.deltaY > 0 ? "up" : "down"; + this.moveYears(dir, Math.abs(event.deltaY) * monthList.length); + } else if (event.deltaMode == event.DOM_DELTA_LINE) { + let dir = event.deltaY > 0 ? "up" : "down"; + this.moveYears(dir, 1); + } else if (event.deltaMode == event.DOM_DELTA_PIXEL) { + this.mPixelScrollDelta += event.deltaY; + if (this.mPixelScrollDelta > pixelThreshold) { + this.moveYears("down", 1); + this.mPixelScrollDelta = 0; + } else if (this.mPixelScrollDelta < -pixelThreshold) { + this.moveYears("up", 1); + this.mPixelScrollDelta = 0; + } + } + + event.stopPropagation(); + event.preventDefault(); + } + } + ]]></body> + </method> + + <method name="moveYears"> + <parameter name="direction"/> + <parameter name="scrollOffset"/> + <body><![CDATA[ + // Update the year popup + let years = document.getAnonymousElementByAttribute(this, "anonid", "years-popup").firstChild.childNodes; + let current = this.getAttribute("year"); + let offset; + switch (direction) { + case "reset": { + let middleyear = years[Math.floor(years.length / 2)].getAttribute("value"); + if (current <= (years.length / 2)) { + offset = 1 - years[1].getAttribute("value"); + } else { + offset = current - middleyear; + } + break; + } + case "up": { + offset = -Math.abs(scrollOffset) || -1; + break; + } + case "down": { + offset = Math.abs(scrollOffset) || 1; + break; + } + } + + // Disable the up arrow when we get to the year 1. + years[0].disabled = parseInt(years[1].getAttribute("value"), 10) + offset < 2; + + if (!offset) { + // No need to loop through when the offset is zero. + return; + } + + // Go through all visible years and set the new value. Be sure to + // skip the autorepeatbuttons. + for (let i = 1; i < years.length - 1; i++) { + let value = parseInt(years[i].getAttribute("value"), 10) + offset; + years[i].setAttribute("value", value); + years[i].setAttribute("current", value == current ? "true" : "false"); + } + ]]></body> + </method> + </implementation> + + <handlers> + <handler event="bindingattached" action="this.initialize();"/> + + <!-- handle click from nested months popup and years popup --> + <handler event="click"><![CDATA[ + let element = event.originalTarget; + let popup = getParentNodeOrThis(element, "menupopup"); + if (popup) { + let anonid = popup.getAttribute("anonid"); + switch (anonid) { + case "months-popup": { + this.hidePopupList(); + this.kMinimonth.switchMonth(element.getAttribute("index")); + break; + } + case "years-popup": { + this.hidePopupList(); + let value = element.getAttribute("value"); + if (value) { + this.kMinimonth.switchYear(value); + } + break; + } + } + } + ]]></handler> + </handlers> + </binding> + + <binding id="minimonth" extends="xul:box"> + <resources> + <stylesheet src="chrome://calendar-common/skin/widgets/minimonth.css"/> + </resources> + + <content orient="vertical" xbl:inherits="onchange,onmonthchange,onpopuplisthidden,readonly"> + <xul:minimonth-header anonid="minimonth-header" xbl:inherits="readonly,month,year"/> + <xul:vbox anonid="minimonth-calendar" class="minimonth-cal-box"> + <xul:hbox class="minimonth-row-head" anonid="minimonth-row-header" equalsize="always"> + <xul:text class="minimonth-row-header-week" flex="1"/> + <xul:text class="minimonth-row-header" flex="1"/> + <xul:text class="minimonth-row-header" flex="1"/> + <xul:text class="minimonth-row-header" flex="1"/> + <xul:text class="minimonth-row-header" flex="1"/> + <xul:text class="minimonth-row-header" flex="1"/> + <xul:text class="minimonth-row-header" flex="1"/> + <xul:text class="minimonth-row-header" flex="1"/> + </xul:hbox> + <xul:hbox class="minimonth-row-body" equalsize="always" flex="1"> + <xul:text class="minimonth-week" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + </xul:hbox> + <xul:hbox class="minimonth-row-body" equalsize="always" flex="1"> + <xul:text class="minimonth-week" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + </xul:hbox> + <xul:hbox class="minimonth-row-body" equalsize="always" flex="1"> + <xul:text class="minimonth-week" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + </xul:hbox> + <xul:hbox class="minimonth-row-body" equalsize="always" flex="1"> + <xul:text class="minimonth-week" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + </xul:hbox> + <xul:hbox class="minimonth-row-body" equalsize="always" flex="1"> + <xul:text class="minimonth-week" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + </xul:hbox> + <xul:hbox class="minimonth-row-body" equalsize="always" flex="1"> + <xul:text class="minimonth-week" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + <xul:text class="minimonth-day" flex="1"/> + </xul:hbox> + </xul:vbox> + </content> + + <implementation implements="calICompositeObserver calIOperationListener nsIObserver" > + <property name="value" + onget="return this.mValue" + onset="this.update(val)"/> + + <property name="extra" + onget="return this.mExtraDate" + onset="this.mExtraDate = val"/> + + <!--returns the first (inclusive) date of the minimonth as a calIDateTime object--> + <property name="firstDate" readonly="true"> + <getter><![CDATA[ + let calbox = document.getAnonymousElementByAttribute(this, "anonid", "minimonth-calendar"); + let date = calbox.childNodes[1].firstChild.nextSibling.date; + return cal.jsDateToDateTime(date); + ]]></getter> + </property> + + <!--returns the last (exclusive) date of the minimonth as a calIDateTime object--> + <property name="lastDate" readonly="true"> + <getter><![CDATA[ + let calbox = document.getAnonymousElementByAttribute(this, "anonid", "minimonth-calendar"); + let date = calbox.lastChild.lastChild.date; + let lastDateTime = cal.jsDateToDateTime(date); + lastDateTime.day = lastDateTime.day + 1; + return lastDateTime; + ]]></getter> + </property> + + <field name="mDaymap">null</field> + <field name="mValue">null</field> + <field name="mEditorDate">null</field> + <field name="mExtraDate">null</field> + <field name="mPixelScrollDelta">0</field> + <field name="mIsReadOnly">false</field> + <field name="mObservesComposite">false</field> + <field name="mShowWeekNumber">true</field> + + <constructor><![CDATA[ + Components.utils.import("resource://gre/modules/Services.jsm"); + Components.utils.import("resource://gre/modules/Preferences.jsm"); + + this.mToday = false; + this.mSelected = false; + this.mExtra = false; + this.mValue = new Date(); // Default to "today" + // save references for convenience + if (this.hasAttribute("readonly")) { + this.mIsReadOnly = this.getAttribute("readonly") == "true"; + } + this.refreshDisplay(); + if (this.hasAttribute("freebusy")) { + this._setFreeBusy(this.getAttribute("freebusy") == "true"); + } + this.mShowWeekNumber = Preferences.get("calendar.view-minimonth.showWeekNumber", true); + + // Add pref observer + let branch = Services.prefs.getBranch(""); + branch.addObserver("calendar.", this, false); + ]]></constructor> + + <destructor><![CDATA[ + Components.utils.import("resource://gre/modules/Services.jsm"); + + if (this.mObservesComposite == true) { + getCompositeCalendar().removeObserver(this); + } + + // Remove pref observer + let branch = Services.prefs.getBranch(""); + branch.removeObserver("calendar.", this, false); + ]]></destructor> + + <!-- calIOperationListener methods --> + <method name="onOperationComplete"> + <parameter name="aCalendar"/> + <parameter name="aStatus"/> + <parameter name="aOperationType"/> + <parameter name="aId"/> + <parameter name="aDetail"/> + <body><![CDATA[ + ]]></body> + </method> + + <method name="onGetResult"> + <parameter name="aCalendar"/> + <parameter name="aStatus"/> + <parameter name="aItemType"/> + <parameter name="aDetail"/> + <parameter name="aCount"/> + <parameter name="aItems"/> + <body><![CDATA[ + if (!Components.isSuccessCode(aStatus)) { + return; + } + for (let item of aItems) { + this.setBusyDaysForOccurrence(item, true); + } + ]]></body> + </method> + + <method name="setBusyDaysForItem"> + <parameter name="aItem"/> + <parameter name="aState"/> + <body><![CDATA[ + let items = [aItem]; + if (aItem.recurrenceInfo) { + let startDate = this.firstDate; + let endDate = this.lastDate; + items = aItem.getOccurrencesBetween(startDate, endDate, {}); + } + for (let item of items) { + this.setBusyDaysForOccurrence(item, aState); + } + ]]></body> + </method> + + <method name="parseBoxBusy"> + <parameter name="aBox"/> + <body><![CDATA[ + let boxBusy = {}; + + let busyStr = aBox.getAttribute("busy"); + if (busyStr && busyStr.length > 0) { + let calChunks = busyStr.split("\u001A"); + for (let chunk of calChunks) { + let expr = chunk.split("="); + boxBusy[expr[0]] = parseInt(expr[1], 10); + } + } + + return boxBusy; + ]]></body> + </method> + + <method name="updateBoxBusy"> + <parameter name="aBox"/> + <parameter name="aBoxBusy"/> + <body><![CDATA[ + let calChunks = []; + + for (let calId in aBoxBusy) { + if (aBoxBusy[calId]) { + calChunks.push(calId + "=" + aBoxBusy[calId]); + } + } + + if (calChunks.length > 0) { + let busyStr = calChunks.join("\u001A"); + aBox.setAttribute("busy", busyStr); + } else { + aBox.removeAttribute("busy"); + } + ]]></body> + </method> + + <method name="removeCalendarFromBoxBusy"> + <parameter name="aBox"/> + <parameter name="aCalendar"/> + <body><![CDATA[ + let boxBusy = this.parseBoxBusy(aBox); + if (boxBusy[aCalendar.id]) { + delete boxBusy[aCalendar.id]; + } + this.updateBoxBusy(aBox, boxBusy); + ]]></body> + </method> + + <method name="setBusyDaysForOccurrence"> + <parameter name="aOccurrence"/> + <parameter name="aState"/> + <body><![CDATA[ + if (aOccurrence.getProperty("TRANSP") == "TRANSPARENT") { + // Skip transparent events + return; + } + let start = aOccurrence[calGetStartDateProp(aOccurrence)] || aOccurrence.dueDate; + let end = aOccurrence[calGetEndDateProp(aOccurrence)] || start; + if (!start) { + return; + } + + // We need to compare with midnight of the current day, so reset the + // time here. + let current = start.clone().getInTimezone(cal.calendarDefaultTimezone()); + current.hour = 0; + current.minute = 0; + current.second = 0; + + // Cache the result so the compare isn't called in each iteration. + let compareResult = (start.compare(end) == 0 ? 1 : 0); + + // Setup the busy days. + while (current.compare(end) < compareResult) { + let box = this.getBoxForDate(current); + if (box) { + let busyCalendars = this.parseBoxBusy(box); + if (!busyCalendars[aOccurrence.calendar.id]) { + busyCalendars[aOccurrence.calendar.id] = 0; + } + busyCalendars[aOccurrence.calendar.id] += (aState ? 1 : -1); + this.updateBoxBusy(box, busyCalendars); + } + current.day++; + } + ]]></body> + </method> + + <!--calIObserver methods --> + <method name="onStartBatch"> + <parameter name="aCalendar"/> + <body><![CDATA[ + ]]></body> + </method> + + <method name="onEndBatch"> + <parameter name="aCalendar"/> + <body><![CDATA[ + ]]></body> + </method> + + <method name="onLoad"> + <parameter name="aCalendar"/> + <body><![CDATA[ + ]]></body> + </method> + + <method name="onAddItem"> + <parameter name="aItem"/> + <body><![CDATA[ + this.setBusyDaysForItem(aItem, true); + ]]></body> + </method> + + <method name="onDeleteItem"> + <parameter name="aItem"/> + <body><![CDATA[ + this.setBusyDaysForItem(aItem, false); + ]]></body> + </method> + + <method name="onModifyItem"> + <parameter name="aNewItem"/> + <parameter name="aOldItem"/> + <body><![CDATA[ + this.setBusyDaysForItem(aOldItem, false); + this.setBusyDaysForItem(aNewItem, true); + ]]></body> + </method> + + <method name="onError"> + <parameter name="aCalendar"/> + <parameter name="aErrNo"/> + <parameter name="aMessage"/> + <body><![CDATA[ + ]]></body> + </method> + + <method name="onPropertyChanged"> + <parameter name="aCalendar"/> + <parameter name="aName"/> + <parameter name="aValue"/> + <parameter name="aOldValue"/> + <body><![CDATA[ + switch (aName) { + case "disabled": + this.resetAttributesForDate(); + this.getItems(); + break; + } + ]]></body> + </method> + + <method name="onPropertyDeleting"> + <parameter name="aCalendar"/> + <parameter name="aName"/> + <body><![CDATA[ + this.onPropertyChanged(aCalendar, aName, null, null); + ]]></body> + </method> + + <!-- calICompositeObserver methods --> + <method name="onCalendarAdded"> + <parameter name="aCalendar"/> + <body><![CDATA[ + this.getItems(aCalendar); + ]]></body> + </method> + + <method name="onCalendarRemoved"> + <parameter name="aCalendar"/> + <body><![CDATA[ + for (let day in this.mDayMap) { + this.removeCalendarFromBoxBusy(this.mDayMap[day], aCalendar); + } + ]]></body> + </method> + + <method name="onDefaultCalendarChanged"> + <parameter name="aCalendar"/> + <body><![CDATA[ + ]]></body> + </method> + + <!-- nsIObserver methods --> + <method name="observe"> + <parameter name="aSubject"/> + <parameter name="aTopic"/> + <parameter name="aData"/> + <body><![CDATA[ + switch (aData) { + case "calendar.week.start": + case "calendar.view-minimonth.showWeekNumber": + this.refreshDisplay(); + break; + } + ]]></body> + </method> + + <method name="refreshDisplay"> + <body><![CDATA[ + // Find out which should be the first day of the week + this.weekStart = Preferences.get("calendar.week.start", 0); + this.mShowWeekNumber = Preferences.get("calendar.view-minimonth.showWeekNumber", true); + if (!this.mValue) { + this.mValue = new Date(); + } + this.setHeader(); + this.showMonth(this.mValue); + ]]></body> + </method> + + <method name="setHeader"> + <body><![CDATA[ + // Reset the headers + let header = document.getAnonymousElementByAttribute(this, "anonid", "minimonth-row-header"); + let dayList = new Array(7); + let tempDate = new Date(); + let i, j; + let useOSFormat; + tempDate.setDate(tempDate.getDate() - (tempDate.getDay() - this.weekStart)); + for (i = 0; i < header.childNodes.length - 1; i++) { + // if available, use UILocale days, else operating system format + try { + dayList[i] = calGetString("dateFormat", + "day." + (tempDate.getDay() + 1) + ".short"); + } catch (e) { + dayList[i] = tempDate.toLocaleFormat("%a"); + useOSFormat = true; + } + tempDate.setDate(tempDate.getDate() + 1); + } + + if (useOSFormat) { + // To keep datepicker popup compact, shrink localized weekday + // abbreviations down to 1 or 2 chars so each column of week can + // be as narrow as 2 digits. + // + // 1. Compute the minLength of the day name abbreviations. + let minLength = dayList[0].length; + for (i = 1; i < dayList.length; i++) { + minLength = Math.min(minLength, dayList[i].length); + } + // 2. If some day name abbrev. is longer than 2 chars (not Catalan), + // and ALL localized day names share same prefix (as in Chinese), + // then trim shared "day-" prefix. + if (dayList.some(dayAbbr => dayAbbr.length > 2)) { + for (let endPrefix = 0; endPrefix < minLength; endPrefix++) { + let suffix = dayList[0][endPrefix]; + if (dayList.some(dayAbbr => dayAbbr[endPrefix] != suffix)) { + if (endPrefix > 0) { + for (i = 0; i < dayList.length; i++) { // trim prefix chars. + dayList[i] = dayList[i].substring(endPrefix); + } + } + break; + } + } + } + // 3. trim each day abbreviation to 1 char if unique, else 2 chars. + for (i = 0; i < dayList.length; i++) { + let foundMatch = 1; + for (j = 0; j < dayList.length; j++) { + if (i != j) { + if (dayList[i].substring(0, 1) == dayList[j].substring(0, 1)) { + foundMatch = 2; + break; + } + } + } + dayList[i] = dayList[i].substring(0, foundMatch); + } + } + + setBooleanAttribute(header.childNodes[0], "hidden", !this.mShowWeekNumber); + for (let column = 1; column < header.childNodes.length; column++) { + header.childNodes[column].setAttribute("value", dayList[column - 1]); + } + ]]></body> + </method> + + <method name="showMonth"> + <parameter name="aDate"/> + <body><![CDATA[ + // Use mExtraDate if aDate is null. + aDate = new Date(aDate || this.mExtraDate); + + aDate.setDate(1); + // We set the hour and minute to something highly unlikely to be the + // exact change point of DST, so timezones like America/Sao Paulo + // don't display some days twice. + aDate.setHours(12); + aDate.setMinutes(34); + aDate.setSeconds(0); + aDate.setMilliseconds(0); + // Don't fire onmonthchange event upon initialization + let monthChanged = this.mEditorDate && (this.mEditorDate.valueOf() != aDate.valueOf()); + this.mEditorDate = aDate; // only place mEditorDate is set. + + if (this.mToday) { + this.mToday.removeAttribute("today"); + this.mToday = null; + } + + if (this.mSelected) { + this.mSelected.removeAttribute("selected"); + this.mSelected = null; + } + + if (this.mExtra) { + this.mExtra.removeAttribute("extra"); + this.mExtra = null; + } + + // Update the month and year title + this.setAttribute("month", aDate.getMonth()); + this.setAttribute("year", aDate.getFullYear()); + if (!this.mIsReadOnly) { + // Update the month popup + let header = document.getAnonymousElementByAttribute(this, "anonid", "minimonth-header"); + header.updateYearPopup(aDate); + header.updateMonthPopup(aDate); + } + // Update the calendar + let calbox = document.getAnonymousElementByAttribute(this, "anonid", "minimonth-calendar"); + let date = this._getStartDate(aDate); + + // get today's date + let today = new Date(); + + this.mDayMap = {}; + let defaultTz = cal.calendarDefaultTimezone(); + for (let k = 1; k < calbox.childNodes.length; k++) { + let row = calbox.childNodes[k]; + + // Set the week number. + let firstElement = row.childNodes[0]; + setBooleanAttribute(firstElement, "hidden", !this.mShowWeekNumber); + if (this.mShowWeekNumber) { + let weekNumber = cal.getWeekInfoService() + .getWeekTitle(cal.jsDateToDateTime(date, defaultTz)); + firstElement.setAttribute("value", weekNumber); + } + + for (let i = 1; i < 8; i++) { + let day = row.childNodes[i]; + let ymd = date.getFullYear() + "-" + + date.getMonth() + "-" + + date.getDate(); + this.mDayMap[ymd] = day; + + if (!this.mIsReadOnly) { + day.setAttribute("interactive", "true"); + } + + if (aDate.getMonth() == date.getMonth()) { + day.removeAttribute("othermonth"); + } else { + day.setAttribute("othermonth", "true"); + } + + // highlight today + if (this._sameDay(today, date)) { + this.mToday = day; + day.setAttribute("today", "true"); + } + + // highlight the current date + let val = this.value; + if (this._sameDay(val, date)) { + this.mSelected = day; + day.setAttribute("selected", "true"); + } + + // highlight the extra date + if (this._sameDay(this.mExtraDate, date)) { + this.mExtra = day; + day.setAttribute("extra", "true"); + } + + day.date = new Date(date); + day.minimonthParent = this; + day.setAttribute("value", date.getDate()); + date.setDate(date.getDate() + 1); + + if (monthChanged) { + this.resetAttributesForDate(day.date); + } + } + } + + if (monthChanged) { + this.fireEvent("monthchange"); + } + + if (this.getAttribute("freebusy") == "true") { + this.getItems(); + } + ]]></body> + </method> + + <!--Attention - duplicate!!!!--> + <method name="fireEvent"> + <parameter name="aEventName"/> + <body><![CDATA[ + let event = document.createEvent("Events"); + event.initEvent(aEventName, true, true); + this.dispatchEvent(event); + ]]></body> + </method> + + <method name="getBoxForDate"> + <parameter name="aDate"/> + <body><![CDATA[ + // aDate is a calIDateTime + let ymd = [aDate.year, aDate.month, aDate.day].join("-"); + return (ymd in this.mDayMap ? this.mDayMap[ymd] : null); + ]]></body> + </method> + + <method name="resetAttributesForDate"> + <parameter name="aDate"/> + <body><![CDATA[ + function removeForBox(aBox) { + let allowedAttributes = 0; + while (aBox.attributes.length > allowedAttributes) { + switch (aBox.attributes[allowedAttributes].nodeName) { + case "selected": + case "othermonth": + case "today": + case "extra": + case "interactive": + case "value": + case "class": + case "flex": + allowedAttributes++; + break; + default: + aBox.removeAttribute(aBox.attributes[allowedAttributes].nodeName); + break; + } + } + } + + if (aDate) { + let box = this.getBoxForDate(cal.jsDateToDateTime(aDate, cal.calendarDefaultTimezone())); + if (box) { + removeForBox(box); + } + } else { + let calbox = document.getAnonymousElementByAttribute(this, "anonid", "minimonth-calendar"); + for (let k = 1; k < calbox.childNodes.length; k++) { + for (let i = 1; i < 8; i++) { + removeForBox(calbox.childNodes[k].childNodes[i]); + } + } + } + ]]></body> + </method> + + <method name="_setFreeBusy"> + <parameter name="aFreeBusy"/> + <body><![CDATA[ + if (aFreeBusy == true) { + if (this.mObservesComposite == false) { + getCompositeCalendar().addObserver(this); + this.mObservesComposite = true; + this.getItems(); + } + } else if (this.mObservesComposite == true) { + getCompositeCalendar().removeObserver(this); + this.mObservesComposite = false; + } + ]]></body> + </method> + + <method name="removeAttribute"> + <parameter name="aAttr"/> + <body><![CDATA[ + if (aAttr == "freebusy") { + this._setFreeBusy(false); + } + // this should be done using lookupMethod(), see bug 286629 + let ret = XULElement.prototype.removeAttribute.call(this, aAttr); + return ret; + ]]></body> + </method> + + <method name="setAttribute"> + <parameter name="aAttr"/> + <parameter name="aVal"/> + <body><![CDATA[ + if (aAttr == "freebusy") { + this._setFreeBusy(aVal == "true"); + } + // this should be done using lookupMethod(), see bug 286629 + let ret = XULElement.prototype.setAttribute.call(this, aAttr, aVal); + return ret; + ]]></body> + </method> + + <method name="getItems"> + <parameter name="aCalendar"/> + <body><![CDATA[ + // The minimonth automatically clears extra styles on a month change. + // Therefore we only need to fill the minimonth with new info. + + let calendar = aCalendar || getCompositeCalendar(); + let filter = calendar.ITEM_FILTER_COMPLETED_ALL | + calendar.ITEM_FILTER_CLASS_OCCURRENCES | + calendar.ITEM_FILTER_ALL_ITEMS; + + // Get new info + calendar.getItems(filter, + 0, + this.firstDate, + this.lastDate, + this); + ]]></body> + </method> + + <method name="onSelectDay"> + <parameter name="aDayBox"/> + <body><![CDATA[ + if (this.mIsReadOnly) { + return; + } + if (this.mSelected) { + this.mSelected.removeAttribute("selected"); + } + this.mSelected = aDayBox; + this.value = aDayBox.date; + this.fireEvent("select"); + ]]></body> + </method> + + <method name="update"> + <parameter name="aValue"/> + <body><![CDATA[ + this.mValue = aValue; + if (this.mValue) { + this.fireEvent("change"); + } + this.showMonth(aValue); + ]]></body> + </method> + + <method name="hidePopupList"> + <body><![CDATA[ + if (!this.mIsReadOnly) { + let header = document.getAnonymousElementByAttribute(this, "anonid", "minimonth-header"); + header.hidePopupList(); + } + ]]></body> + </method> + + <method name="switchMonth"> + <parameter name="aMonth"/> + <body><![CDATA[ + let newMonth = new Date(this.mEditorDate); + newMonth.setMonth(aMonth); + this.showMonth(newMonth); + ]]></body> + </method> + + <method name="switchYear"> + <parameter name="aYear"/> + <body><![CDATA[ + let newMonth = new Date(this.mEditorDate); + newMonth.setFullYear(aYear); + this.showMonth(newMonth); + ]]></body> + </method> + + <method name="selectDate"> + <parameter name="aDate"/> + <parameter name="aMainDate"/> + <body><![CDATA[ + if (!aMainDate || aDate < this._getStartDate(aMainDate) || aDate > this._getEndDate(aMainDate)) { + aMainDate = new Date(aDate); + aMainDate.setDate(1); + } + // note, that aMainDate and this.mEditorDate refer to the first day + // of the corresponding month + let sameMonth = this._sameDay(aMainDate, this.mEditorDate); + let sameDate = this._sameDay(aDate, this.mValue); + if (!sameMonth && !sameDate) { + // change month and select day + this.mValue = aDate; + this.showMonth(aMainDate); + } else if (!sameMonth) { + // change month only + this.showMonth(aMainDate); + } else if (!sameDate) { + // select day only + let day = this.getBoxForDate(cal.jsDateToDateTime(aDate, cal.calendarDefaultTimezone())); + if (this.mSelected) { + this.mSelected.removeAttribute("selected"); + } + this.mSelected = day; + day.setAttribute("selected", "true"); + this.mValue = aDate; + } + ]]></body> + </method> + + <method name="_getStartDate"> + <parameter name="aMainDate"/> + <body><![CDATA[ + let date = new Date(aMainDate); + let firstWeekday = (7 + aMainDate.getDay() - this.weekStart) % 7; + date.setDate(date.getDate() - firstWeekday); + return date; + ]]></body> + </method> + + <method name="_getEndDate"> + <parameter name="aMainDate"/> + <body><![CDATA[ + let date = this._getStartDate(aMainDate); + let calbox = document.getAnonymousElementByAttribute(this, "anonid", "minimonth-calendar"); + let days = (calbox.childNodes.length - 1) * 7; + date.setDate(date.getDate() + days - 1); + return date; + ]]></body> + </method> + + <method name="_sameDay"> + <parameter name="aDate1"/> + <parameter name="aDate2"/> + <body><![CDATA[ + if (aDate1 && aDate2 && + (aDate1.getDate() == aDate2.getDate()) && + (aDate1.getMonth() == aDate2.getMonth()) && + (aDate1.getFullYear() == aDate2.getFullYear())) { + return true; + } + return false; + ]]></body> + </method> + + <method name="advanceMonth"> + <parameter name="aDir"/> + <body><![CDATA[ + let advEditorDate = new Date(this.mEditorDate); // at 1st of month + let advMonth = this.mEditorDate.getMonth() + aDir; + advEditorDate.setMonth(advMonth); + this.showMonth(advEditorDate); + ]]></body> + </method> + </implementation> + + <handlers> + <handler event="wheel"><![CDATA[ + const pixelThreshold = 150; + let deltaView = 0; + if (this.mIsReadOnly) { + // No scrolling on readonly months + return; + } + if (event.deltaMode == event.DOM_DELTA_LINE || + event.deltaMode == event.DOM_DELTA_PAGE) { + if (event.deltaY != 0) { + deltaView = event.deltaY > 0 ? 1 : -1; + } + } else if (event.deltaMode == event.DOM_DELTA_PIXEL) { + this.mPixelScrollDelta += event.deltaY; + if (this.mPixelScrollDelta > pixelThreshold) { + deltaView = 1; + this.mPixelScrollDelta = 0; + } else if (this.mPixelScrollDelta < -pixelThreshold) { + deltaView = -1; + this.mPixelScrollDelta = 0; + } + } + + if (deltaView != 0) { + this.advanceMonth(deltaView); + } + + event.stopPropagation(); + event.preventDefault(); + ]]></handler> + </handlers> + </binding> + + <binding id="minimonth-day" extends="xul:text"> + <handlers> + <handler event="click" button="0"><![CDATA[ + if (this.minimonthParent.getAttribute("readonly") != "true") { + this.setAttribute("selected", "true"); + this.minimonthParent.onSelectDay(this); + } + ]]></handler> + </handlers> + </binding> +</bindings> diff --git a/calendar/base/jar.mn b/calendar/base/jar.mn new file mode 100644 index 000000000..d9d53ea07 --- /dev/null +++ b/calendar/base/jar.mn @@ -0,0 +1,203 @@ +#filter substitution +# 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/. + +calendar.jar: +% resource calendar . +% content calendar %content/calendar/ + content/calendar/agenda-listbox.js (content/agenda-listbox.js) + content/calendar/agenda-listbox.xml (content/agenda-listbox.xml) + content/calendar/calendar-bindings.css (content/calendar-bindings.css) + content/calendar/calendar-calendars-list.xul (content/calendar-calendars-list.xul) + content/calendar/calendar-chrome-startup.js (content/calendar-chrome-startup.js) + content/calendar/calendar-clipboard.js (content/calendar-clipboard.js) +* content/calendar/calendar-common-sets.xul (content/calendar-common-sets.xul) + content/calendar/calendar-common-sets.js (content/calendar-common-sets.js) + content/calendar/calendar-daypicker.xml (content/calendar-daypicker.xml) + content/calendar/calendar-views.xml (content/calendar-views.xml) +* content/calendar/calendar-dnd-listener.js (content/calendar-dnd-listener.js) + content/calendar/calendar-extract.js (content/calendar-extract.js) + content/calendar/calendar-invitations-manager.js (content/calendar-invitations-manager.js) + content/calendar/calendar-item-editing.js (content/calendar-item-editing.js) + content/calendar/calendar-item-bindings.xml (content/calendar-item-bindings.xml) + content/calendar/calendar-management.js (content/calendar-management.js) + content/calendar/calendar-menus.xml (content/calendar-menus.xml) + content/calendar/calendar-views.xul (content/calendar-views.xul) + content/calendar/calendar-month-view.xml (content/calendar-month-view.xml) + content/calendar/calendar-multiday-view.xml (content/calendar-multiday-view.xml) + content/calendar/calendar-base-view.xml (content/calendar-base-view.xml) + content/calendar/calendar-statusbar.js (content/calendar-statusbar.js) + content/calendar/calendar-task-editing.js (content/calendar-task-editing.js) + content/calendar/calendar-task-tree.xml (content/calendar-task-tree.xml) + content/calendar/calendar-task-tree.js (content/calendar-task-tree.js) + content/calendar/calendar-task-view.xul (content/calendar-task-view.xul) + content/calendar/calendar-task-view.js (content/calendar-task-view.js) + content/calendar/calendar-ui-utils.js (content/calendar-ui-utils.js) + content/calendar/calendar-unifinder.xul (content/calendar-unifinder.xul) + content/calendar/calendar-unifinder.js (content/calendar-unifinder.js) + content/calendar/calendar-unifinder-todo.xul (content/calendar-unifinder-todo.xul) + content/calendar/calendar-unifinder-todo.js (content/calendar-unifinder-todo.js) + content/calendar/calendar-view-bindings.css (content/calendar-view-bindings.css) + content/calendar/calendar-view-core.xml (content/calendar-view-core.xml) + content/calendar/calendar-views.js (content/calendar-views.js) + content/calendar/import-export.js (content/import-export.js) + content/calendar/today-pane.xul (content/today-pane.xul) + content/calendar/today-pane.js (content/today-pane.js) + content/calendar/calendar-alarm-dialog.js (content/dialogs/calendar-alarm-dialog.js) + content/calendar/calendar-alarm-dialog.xul (content/dialogs/calendar-alarm-dialog.xul) + content/calendar/calendar-conflicts-dialog.xul (content/dialogs/calendar-conflicts-dialog.xul) + content/calendar/calendar-creation.js (content/dialogs/calendar-creation.js) + content/calendar/calendar-dialog-utils.js (content/dialogs/calendar-dialog-utils.js) + content/calendar/calendar-error-prompt.xul (content/dialogs/calendar-error-prompt.xul) + content/calendar/calendar-event-dialog.css (content/dialogs/calendar-event-dialog.css) +* content/calendar/calendar-event-dialog.xul (content/dialogs/calendar-event-dialog.xul) + content/calendar/calendar-event-dialog-attendees.xml (content/dialogs/calendar-event-dialog-attendees.xml) + content/calendar/calendar-event-dialog-freebusy.xml (content/dialogs/calendar-event-dialog-freebusy.xml) + content/calendar/calendar-event-dialog-recurrence.xul (content/dialogs/calendar-event-dialog-recurrence.xul) + content/calendar/calendar-event-dialog-recurrence.js (content/dialogs/calendar-event-dialog-recurrence.js) + content/calendar/calendar-event-dialog-recurrence-preview.xml (content/dialogs/calendar-event-dialog-recurrence-preview.xml) + content/calendar/calendar-event-dialog-reminder.js (content/dialogs/calendar-event-dialog-reminder.js) + content/calendar/calendar-event-dialog-reminder.xul (content/dialogs/calendar-event-dialog-reminder.xul) + content/calendar/calendar-event-dialog-timezone.js (content/dialogs/calendar-event-dialog-timezone.js) + content/calendar/calendar-event-dialog-timezone.xul (content/dialogs/calendar-event-dialog-timezone.xul) + content/calendar/calendar-event-dialog-attendees.xul (content/dialogs/calendar-event-dialog-attendees.xul) + content/calendar/calendar-event-dialog-attendees.js (content/dialogs/calendar-event-dialog-attendees.js) + content/calendar/calendar-invitations-dialog.css (content/dialogs/calendar-invitations-dialog.css) + content/calendar/calendar-invitations-dialog.js (content/dialogs/calendar-invitations-dialog.js) + content/calendar/calendar-invitations-dialog.xul (content/dialogs/calendar-invitations-dialog.xul) + content/calendar/calendar-invitations-list.xml (content/dialogs/calendar-invitations-list.xml) +* content/calendar/calendar-migration-dialog.js (content/dialogs/calendar-migration-dialog.js) + content/calendar/calendar-migration-dialog.xul (content/dialogs/calendar-migration-dialog.xul) + content/calendar/calendar-occurrence-prompt.xul (content/dialogs/calendar-occurrence-prompt.xul) + content/calendar/calendar-print-dialog.js (content/dialogs/calendar-print-dialog.js) + content/calendar/calendar-print-dialog.xul (content/dialogs/calendar-print-dialog.xul) + content/calendar/calendar-properties-dialog.xul (content/dialogs/calendar-properties-dialog.xul) + content/calendar/calendar-properties-dialog.js (content/dialogs/calendar-properties-dialog.js) + content/calendar/calendar-providerUninstall-dialog.xul (content/dialogs/calendar-providerUninstall-dialog.xul) + content/calendar/calendar-providerUninstall-dialog.js (content/dialogs/calendar-providerUninstall-dialog.js) + content/calendar/calendar-subscriptions-dialog.css (content/dialogs/calendar-subscriptions-dialog.css) + content/calendar/calendar-subscriptions-dialog.js (content/dialogs/calendar-subscriptions-dialog.js) + content/calendar/calendar-subscriptions-dialog.xul (content/dialogs/calendar-subscriptions-dialog.xul) + content/calendar/calendar-summary-dialog.js (content/dialogs/calendar-summary-dialog.js) + content/calendar/calendar-summary-dialog.xul (content/dialogs/calendar-summary-dialog.xul) + content/calendar/chooseCalendarDialog.xul (content/dialogs/chooseCalendarDialog.xul) + content/calendar/preferences/alarms.xul (content/preferences/alarms.xul) + content/calendar/preferences/alarms.js (content/preferences/alarms.js) + content/calendar/preferences/categories.xul (content/preferences/categories.xul) + content/calendar/preferences/categories.js (content/preferences/categories.js) + content/calendar/preferences/editCategory.xul (content/preferences/editCategory.xul) + content/calendar/preferences/editCategory.js (content/preferences/editCategory.js) + content/calendar/preferences/general.js (content/preferences/general.js) +* content/calendar/preferences/general.xul (content/preferences/general.xul) + content/calendar/preferences/views.js (content/preferences/views.js) + content/calendar/preferences/views.xul (content/preferences/views.xul) + content/calendar/widgets/minimonth.xml (content/widgets/minimonth.xml) + content/calendar/widgets/calendar-alarm-widget.xml (content/widgets/calendar-alarm-widget.xml) + content/calendar/widgets/calendar-widgets.xml (content/widgets/calendar-widgets.xml) + content/calendar/widgets/calendar-list-tree.xml (content/widgets/calendar-list-tree.xml) + content/calendar/calendar-subscriptions-list.xml (content/widgets/calendar-subscriptions-list.xml) + content/calendar/widgets/calendar-widget-bindings.css (content/widgets/calendar-widget-bindings.css) + content/calendar/calApplicationUtils.js (src/calApplicationUtils.js) + content/calendar/calUtils.js (src/calUtils.js) + content/calendar/calFilter.js (src/calFilter.js) + content/calendar/WindowsNTToZoneInfoTZId.properties (src/WindowsNTToZoneInfoTZId.properties) +% skin calendar classic/1.0 chrome/skin/linux/calendar/ +% skin calendar classic/1.0 chrome/skin/windows/calendar/ os=WINNT +% skin calendar-common classic/1.0 chrome/skin/common/ +% style chrome://global/content/customizeToolbar.xul chrome://calendar/skin/calendar-task-view.css +% style chrome://global/content/customizeToolbar.xul chrome://calendar/skin/calendar-event-dialog.css +% style chrome://calendar/content/calendar-event-dialog.xul chrome://calendar-common/skin/dialogs/calendar-event-dialog.css +% style chrome://lightning/content/lightning-item-iframe.xul chrome://calendar-common/skin/dialogs/calendar-event-dialog.css +% style chrome://calendar/content/calendar-event-dialog-attendees.xul chrome://calendar-common/skin/dialogs/calendar-event-dialog.css + ../skin/common/alarm-flashing.png (themes/common/images/alarm-flashing.png) + ../skin/common/alarm-icons.png (themes/common/images/alarm-icons.png) + ../skin/common/attendee-icons.png (themes/common/images/attendee-icons.png) + ../skin/common/calendar-overlay.png (themes/common/images/calendar-overlay.png) + ../skin/common/calendar-status.png (themes/common/images/calendar-status.png) + ../skin/common/checkbox-images.png (themes/common/images/checkbox-images.png) + ../skin/common/classification.png (themes/common/images/classification.png) + ../skin/common/day-box-item-image.png (themes/common/images/day-box-item-image.png) + ../skin/common/event-grippy-bottom.png (themes/common/images/event-grippy-bottom.png) + ../skin/common/event-grippy-left.png (themes/common/images/event-grippy-left.png) + ../skin/common/event-grippy-right.png (themes/common/images/event-grippy-right.png) + ../skin/common/event-grippy-top.png (themes/common/images/event-grippy-top.png) + ../skin/common/ok-cancel.png (themes/common/images/ok-cancel.png) + ../skin/common/task-images.png (themes/common/images/task-images.png) + ../skin/common/timezone_map.png (themes/common/images/timezone_map.png) + ../skin/common/timezones.png (themes/common/images/timezones.png) + ../skin/common/calendar-event-dialog.png (themes/common/dialogs/images/calendar-event-dialog.png) + ../skin/common/calendar-event-tab.png (themes/common/dialogs/images/calendar-event-tab.png) + ../skin/common/calendar-task-tab.png (themes/common/dialogs/images/calendar-task-tab.png) + ../skin/common/widgets/nav-arrow.svg (themes/common/widgets/images/nav-arrow.svg) + ../skin/common/widgets/nav-today.svg (themes/common/widgets/images/nav-today.svg) + ../skin/common/widgets/nav-today-hov.svg (themes/common/widgets/images/nav-today-hov.svg) + ../skin/common/widgets/view-navigation.svg (themes/common/widgets/images/view-navigation.svg) + ../skin/common/widgets/view-navigation-hov.svg (themes/common/widgets/images/view-navigation-hov.svg) + ../skin/common/widgets/drag-center.svg (themes/common/widgets/images/drag-center.svg) + ../skin/common/calendar-alarms.css (themes/common/calendar-alarms.css) + ../skin/common/calendar-attendees.css (themes/common/calendar-attendees.css) + ../skin/common/calendar-creation-wizard.css (themes/common/calendar-creation-wizard.css) + ../skin/common/calendar-daypicker.css (themes/common/calendar-daypicker.css) + ../skin/common/calendar-management.css (themes/common/calendar-management.css) + ../skin/common/calendar-occurrence-prompt.css (themes/common/calendar-occurrence-prompt.css) + ../skin/common/calendar-printing.css (themes/common/calendar-printing.css) + ../skin/common/calendar-providerUninstall-dialog.css (themes/common/calendar-providerUninstall-dialog.css) + ../skin/common/calendar-task-tree.css (themes/common/calendar-task-tree.css) + ../skin/common/calendar-task-view.css (themes/common/calendar-task-view.css) + ../skin/common/calendar-toolbar.svg (themes/common/calendar-toolbar.svg) + ../skin/common/calendar-toolbar-osxlion.svg (themes/common/calendar-toolbar-osxlion.svg) + ../skin/common/calendar-itip-icons.svg (themes/common/calendar-itip-icons.svg) + ../skin/common/calendar-unifinder.css (themes/common/calendar-unifinder.css) + ../skin/common/calendar-views.css (themes/common/calendar-views.css) + ../skin/common/today-pane.css (themes/common/today-pane.css) + ../skin/common/today-pane-cycler.svg (themes/common/today-pane-cycler.svg) + ../skin/common/dialogs/calendar-alarm-dialog.css (themes/common/dialogs/calendar-alarm-dialog.css) + ../skin/common/dialogs/calendar-event-dialog.css (themes/common/dialogs/calendar-event-dialog.css) + ../skin/common/dialogs/calendar-invitations-dialog.css (themes/common/dialogs/calendar-invitations-dialog.css) + ../skin/common/calendar-event-dialog-attendees.png (themes/common/dialogs/images/calendar-event-dialog-attendees.png) + ../skin/common/calendar-invitations-dialog-button-images.png (themes/common/dialogs/images/calendar-invitations-dialog-button-images.png) + ../skin/common/calendar-invitations-dialog-list-images.png (themes/common/dialogs/images/calendar-invitations-dialog-list-images.png) + ../skin/common/calendar-properties-dialog.css (themes/common/dialogs/calendar-properties-dialog.css) + ../skin/common/calendar-subscriptions-dialog.css (themes/common/dialogs/calendar-subscriptions-dialog.css) + ../skin/common/calendar-timezone-highlighter.css (themes/common/dialogs/calendar-timezone-highlighter.css) + ../skin/common/widgets/calendar-widgets.css (themes/common/widgets/calendar-widgets.css) + ../skin/common/widgets/minimonth.css (themes/common/widgets/minimonth.css) + +# Linux theme files + ../skin/linux/calendar/cal-icon32.png (themes/linux/images/cal-icon32.png) + ../skin/linux/calendar/cal-icon24.png (themes/linux/images/cal-icon24.png) + ../skin/linux/calendar/calendar-alarm-dialog.css (themes/linux/dialogs/calendar-alarm-dialog.css) + ../skin/linux/calendar/calendar-daypicker.css (themes/linux/calendar-daypicker.css) + ../skin/linux/calendar/calendar-event-dialog.css (themes/linux/dialogs/calendar-event-dialog.css) + ../skin/linux/calendar/calendar-invitations-dialog.css (themes/linux/dialogs/calendar-invitations-dialog.css) + ../skin/linux/calendar/calendar-management.css (themes/linux/calendar-management.css) + ../skin/linux/calendar/calendar-task-tree.css (themes/linux/calendar-task-tree.css) + ../skin/linux/calendar/calendar-task-view.css (themes/linux/calendar-task-view.css) + ../skin/linux/calendar/calendar-unifinder.css (themes/linux/calendar-unifinder.css) + ../skin/linux/calendar/calendar-views.css (themes/linux/calendar-views.css) + ../skin/linux/calendar/today-pane.css (themes/linux/today-pane.css) + ../skin/linux/calendar/widgets/calendar-widgets.css (themes/linux/widgets/calendar-widgets.css) + ../skin/linux/calendar/calendar-occurrence-prompt.png (themes/linux/images/calendar-occurrence-prompt.png) + +# Windows theme files + ../skin/windows/calendar/cal-icon32.png (themes/windows/images/cal-icon32.png) + ../skin/windows/calendar/cal-icon24.png (themes/windows/images/cal-icon24.png) + ../skin/windows/calendar/calendar-alarm-dialog.css (themes/windows/dialogs/calendar-alarm-dialog.css) + ../skin/windows/calendar/calendar-daypicker.css (themes/windows/calendar-daypicker.css) + ../skin/windows/calendar/calendar-event-dialog.css (themes/windows/dialogs/calendar-event-dialog.css) + ../skin/windows/calendar/calendar-invitations-dialog.css (themes/windows/dialogs/calendar-invitations-dialog.css) + ../skin/windows/calendar/calendar-management.css (themes/windows/calendar-management.css) + ../skin/windows/calendar/calendar-task-tree.css (themes/windows/calendar-task-tree.css) + ../skin/windows/calendar/calendar-task-view.css (themes/windows/calendar-task-view.css) + ../skin/windows/calendar/calendar-unifinder.css (themes/windows/calendar-unifinder.css) + ../skin/windows/calendar/calendar-views.css (themes/windows/calendar-views.css) + ../skin/windows/calendar/today-pane.css (themes/windows/today-pane.css) + ../skin/windows/calendar/widgets/calendar-widgets.css (themes/windows/widgets/calendar-widgets.css) + ../skin/windows/calendar/calendar-event-dialog-toolbar.png (themes/windows/dialogs/images/calendar-event-dialog-toolbar.png) + ../skin/windows/calendar/calendar-event-dialog-toolbar-small.png (themes/windows/dialogs/images/calendar-event-dialog-toolbar-small.png) + ../skin/windows/calendar/calendar-occurrence-prompt.png (themes/windows/images/calendar-occurrence-prompt.png) + ../skin/windows/calendar/tasks-actions.png (themes/windows/images/tasks-actions.png) + ../skin/windows/calendar/toolbar-large.png (themes/windows/images/toolbar-large.png) + ../skin/windows/calendar/toolbar-small.png (themes/windows/images/toolbar-small.png) + ../skin/windows/calendar/calendar-occurrence-prompt-aero.png (themes/windows/images/calendar-occurrence-prompt-aero.png) diff --git a/calendar/base/modules/calAlarmUtils.jsm b/calendar/base/modules/calAlarmUtils.jsm new file mode 100644 index 000000000..25532a24b --- /dev/null +++ b/calendar/base/modules/calAlarmUtils.jsm @@ -0,0 +1,170 @@ +/* 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/. */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://gre/modules/Preferences.jsm"); + +this.EXPORTED_SYMBOLS = ["cal"]; // even though it's defined in calUtils.jsm, import needs this +cal.alarms = { + /** + * Read default alarm settings from user preferences and apply them to the + * event/todo passed in. The item's calendar should be set to ensure the + * correct alarm type is set. + * + * @param aItem The item to apply the default alarm values to. + */ + setDefaultValues: function(aItem) { + let type = cal.isEvent(aItem) ? "event" : "todo"; + if (Preferences.get("calendar.alarms.onfor" + type + "s", 0) == 1) { + let alarmOffset = cal.createDuration(); + let alarm = cal.createAlarm(); + let units = Preferences.get("calendar.alarms." + type + "alarmunit", "minutes"); + + // Make sure the alarm pref is valid, default to minutes otherwise + if (!["weeks", "days", "hours", "minutes", "seconds"].includes(units)) { + units = "minutes"; + } + + alarmOffset[units] = Preferences.get("calendar.alarms." + type + "alarmlen", 0); + alarmOffset.normalize(); + alarmOffset.isNegative = true; + if (type == "todo" && !aItem.entryDate) { + // You can't have an alarm if the entryDate doesn't exist. + aItem.entryDate = cal.now(); + } + alarm.related = Components.interfaces.calIAlarm.ALARM_RELATED_START; + alarm.offset = alarmOffset; + + // Default to a display alarm, unless the calendar doesn't support + // it or we have no calendar yet. (Man this is hard to wrap) + let actionValues = (aItem.calendar && + aItem.calendar.getProperty("capabilities.alarms.actionValues")) || + ["DISPLAY"]; + + alarm.action = (actionValues.includes("DISPLAY") ? "DISPLAY" : actionValues[0]); + aItem.addAlarm(alarm); + } + }, + + /** + * Calculate the alarm date for a calIAlarm. + * + * @param aItem The item used to calculate the alarm date. + * @param aAlarm The alarm to calculate the date for. + * @return The alarm date. + */ + calculateAlarmDate: function(aItem, aAlarm) { + if (aAlarm.related == aAlarm.ALARM_RELATED_ABSOLUTE) { + return aAlarm.alarmDate; + } else { + let returnDate; + if (aAlarm.related == aAlarm.ALARM_RELATED_START) { + returnDate = aItem[cal.calGetStartDateProp(aItem)]; + } else if (aAlarm.related == aAlarm.ALARM_RELATED_END) { + returnDate = aItem[cal.calGetEndDateProp(aItem)]; + } + + if (returnDate && aAlarm.offset) { + // Handle all day events. This is kinda weird, because they don't + // have a well defined startTime. We just consider the start/end + // to be midnight in the user's timezone. + if (returnDate.isDate) { + let timezone = cal.calendarDefaultTimezone(); + // This returns a copy, so no extra cloning needed. + returnDate = returnDate.getInTimezone(timezone); + returnDate.isDate = false; + } else { + if (returnDate.timezone.tzid == "floating") { + let timezone = cal.calendarDefaultTimezone(); + returnDate = returnDate.getInTimezone(timezone); + } else { + // Clone the date to correctly add the duration. + returnDate = returnDate.clone(); + } + } + + returnDate.addDuration(aAlarm.offset); + return returnDate; + } + } + return null; + }, + + /** + * Calculate the alarm offset for a calIAlarm. The resulting offset is + * related to either start or end of the event, depending on the aRelated + * parameter. + * + * @param aItem The item to calculate the offset for. + * @param aAlarm The alarm to calculate the offset for. + * @param aRelated (optional) A relation constant from calIAlarm. If not + * passed, ALARM_RELATED_START will be assumed. + * @return The alarm offset. + */ + calculateAlarmOffset: function(aItem, aAlarm, aRelated) { + let offset = aAlarm.offset; + if (aAlarm.related == aAlarm.ALARM_RELATED_ABSOLUTE) { + let returnDate; + if (aRelated === undefined || aRelated == aAlarm.ALARM_RELATED_START) { + returnDate = aItem[cal.calGetStartDateProp(aItem)]; + } else if (aRelated == aAlarm.ALARM_RELATED_END) { + returnDate = aItem[cal.calGetEndDateProp(aItem)]; + } + + if (returnDate && aAlarm.alarmDate) { + offset = aAlarm.alarmDate.subtractDate(returnDate); + } + } + return offset; + }, + + /** + * Adds reminder images to a given node, making sure only one icon per alarm + * action is added. + * + * @param aElement The element to add the images to. + * @param aReminders The set of reminders to add images for. + */ + addReminderImages: function(aElement, aReminders) { + function createOwnedXULNode(elem) { + const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + return aElement.ownerDocument.createElementNS(XUL_NS, elem); + } + + function setupActionImage(node, reminder) { + let image = node || createOwnedXULNode("image"); + image.setAttribute("class", "reminder-icon"); + image.setAttribute("value", reminder.action); + return image; + } + + // Fill up the icon box with the alarm icons, show max one icon per + // alarm type. + let countIconChildren = aElement.childNodes.length; + let actionMap = {}; + let i, offset; + for (i = 0, offset = 0; i < aReminders.length; i++) { + let reminder = aReminders[i]; + if (reminder.action in actionMap) { + // Only show one icon of each type; + offset++; + continue; + } + actionMap[reminder.action] = true; + + if (i - offset >= countIconChildren) { + // Not enough nodes, append it. + aElement.appendChild(setupActionImage(null, reminder)); + } else { + // There is already a node there, change its properties + setupActionImage(aElement.childNodes[i - offset], reminder); + } + } + + // Remove unused image nodes + for (i -= offset; i < countIconChildren; i++) { + aElement.childNodes[i].remove(); + } + } +}; diff --git a/calendar/base/modules/calAsyncUtils.jsm b/calendar/base/modules/calAsyncUtils.jsm new file mode 100644 index 000000000..cbb2adb5a --- /dev/null +++ b/calendar/base/modules/calAsyncUtils.jsm @@ -0,0 +1,128 @@ +/* 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/. */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://gre/modules/Promise.jsm"); +Components.utils.import("resource://gre/modules/PromiseUtils.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + +/* + * Asynchronous tools for handling calendar operations. + */ + +this.EXPORTED_SYMBOLS = ["cal"]; // even though it's defined in calUtils.jsm, import needs this +var cIOL = Components.interfaces.calIOperationListener; +var cIC = Components.interfaces.calICalendar; + +var promisifyProxyHandler = { + promiseOperation: function(target, name, args) { + let deferred = PromiseUtils.defer(); + let listener = cal.async.promiseOperationListener(deferred); + args.push(listener); + target[name](...args); + return deferred.promise; + }, + get: function(target, name) { + switch (name) { + case "adoptItem": + case "addItem": + case "modifyItem": + case "deleteItem": + case "getItem": + case "getItems": + return (...args) => this.promiseOperation(target, name, args); + case "getAllItems": + return () => this.promiseOperation(target, "getItems", [cIC.ITEM_FILTER_ALL_ITEMS, 0, null, null]); + default: + return target[name]; + } + } +}; + +cal.async = { + /** + * Creates a proxy to the given calendar where the CRUD operations are replaced + * with versions that return a promise and don't take a listener. + * + * Before: + * calendar.addItem(item, { + * onGetResult: function() {}, + * onOperationComplete: function (c,status,t,c,detail) { + * if (Components.isSuccessCode(status)) { + * handleSuccess(detail); + * } else { + * handleFailure(status); + * } + * } + * }); + * + * After: + * let pcal = promisifyCalendar(calendar); + * pcal.addItem(item).then(handleSuccess, handleFailure); + * + * Bonus methods in addition: + * pcal.getAllItems() // alias for getItems without any filters + * + * IMPORTANT: Don't pass this around thinking its like an xpcom calICalendar, + * otherwise code might indefinitely wait for the listener to return or there + * will be complaints that an argument is missing. + */ + promisifyCalendar: function(aCalendar) { + return new Proxy(aCalendar, promisifyProxyHandler); + }, + /** + * Create an operation listener (calIOperationListener) that resolves when + * the operation succeeds. Note this listener will collect the items, so it + * might not be a good idea in a situation where a lot of items will be + * retrieved. + * + * Standalone Usage: + * function promiseAddItem(aItem) { + * let deferred = PromiseUtils.defer(); + * let listener = cal.async.promiseOperationListener(deferred); + * aItem.calendar.addItem(aItem, listener); + * return deferred.promise; + * } + * + * See also promisifyCalendar, where the above can be replaced with: + * function promiseAddItem(aItem) { + * let calendar = cal.async.promisifyCalendar(aItem.calendar); + * return calendar.addItem(aItem); + * } + */ + promiseOperationListener: function(deferred) { + return { + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calIOperationListener]), + items: [], + itemStatus: Components.results.NS_OK, + onGetResult: function(aCalendar, aStatus, aItemType, aDetail, + aCount, aItems) { + this.itemStatus = aStatus; + if (Components.isSuccessCode(aStatus)) { + this.items = this.items.concat(aItems); + } else { + this.itemSuccess = aStatus; + } + }, + + onOperationComplete: function(aCalendar, aStatus, aOpType, aId, aDetail) { + if (!Components.isSuccessCode(aStatus)) { + // This function has failed, reject with the status + deferred.reject(aStatus); + } else if (!Components.isSuccessCode(this.itemStatus)) { + // onGetResult has failed, reject with its status + deferred.reject(this.itemStatus); + } else if (aOpType == cIOL.GET) { + // Success of a GET operation: resolve with array of + // resulting items. + deferred.resolve(this.items); + } else { /* ADD,MODIFY,DELETE: resolve with 1 item */ + // Success of an ADD MODIFY or DELETE operation, resolve + // with the one item that was processed. + deferred.resolve(aDetail); + } + } + }; + } +}; diff --git a/calendar/base/modules/calAuthUtils.jsm b/calendar/base/modules/calAuthUtils.jsm new file mode 100644 index 000000000..2862f3edf --- /dev/null +++ b/calendar/base/modules/calAuthUtils.jsm @@ -0,0 +1,385 @@ +/* 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/. */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); +Components.utils.import("resource://gre/modules/Preferences.jsm"); + +/* + * Authentication helper code + */ + +this.EXPORTED_SYMBOLS = ["cal"]; // even though it's defined in calUtils.jsm, import needs this +cal.auth = { + /** + * Auth prompt implementation - Uses password manager if at all possible. + */ + Prompt: function() { + this.mWindow = cal.getCalendarWindow(); + this.mReturnedLogins = {}; + }, + + /** + * Tries to get the username/password combination of a specific calendar name + * from the password manager or asks the user. + * + * @param in aTitle The dialog title. + * @param in aCalendarName The calendar name or url to look up. Can be null. + * @param inout aUsername The username that belongs to the calendar. + * @param inout aPassword The password that belongs to the calendar. + * @param inout aSavePassword Should the password be saved? + * @param in aFixedUsername Whether the user name is fixed or editable + * @return Could a password be retrieved? + */ + getCredentials: function(aTitle, aCalendarName, aUsername, aPassword, + aSavePassword, aFixedUsername) { + if (typeof aUsername != "object" || + typeof aPassword != "object" || + typeof aSavePassword != "object") { + throw new Components.Exception("", Components.results.NS_ERROR_XPC_NEED_OUT_OBJECT); + } + + let prompter = Services.ww.getNewPrompter(null); + + // Only show the save password box if we are supposed to. + let savepassword = null; + if (Preferences.get("signon.rememberSignons", true)) { + savepassword = cal.calGetString("passwordmgr", "rememberPassword", null, "passwordmgr"); + } + + let aText; + if (aFixedUsername) { + aText = cal.calGetString("commonDialogs", "EnterPasswordFor", [aUsername.value, aCalendarName], "global"); + return prompter.promptPassword(aTitle, + aText, + aPassword, + savepassword, + aSavePassword); + } else { + aText = cal.calGetString("commonDialogs", "EnterUserPasswordFor2", [aCalendarName], "global"); + return prompter.promptUsernameAndPassword(aTitle, + aText, + aUsername, + aPassword, + savepassword, + aSavePassword); + } + }, + + /** + * Make sure the passed origin is actually an uri string, because password + * manager functions require it. This is a fallback for compatibility only + * and should be removed a few versions after Lightning 5.5 + * + * @param aOrigin The hostname or origin to check + * @return The origin uri + */ + _ensureOrigin: function(aOrigin) { + try { + return Services.io.newURI(aOrigin, null, null).spec; + } catch (e) { + return "https://" + aOrigin; + } + }, + + /** + * Helper to insert/update an entry to the password manager. + * + * @param aUserName The username + * @param aPassword The corresponding password + * @param aOrigin The corresponding origin + * @param aRealm The password realm (unused on branch) + */ + passwordManagerSave: function(aUsername, aPassword, aOrigin, aRealm) { + cal.ASSERT(aUsername); + cal.ASSERT(aPassword); + + let origin = this._ensureOrigin(aOrigin); + + try { + let logins = Services.logins.findLogins({}, origin, null, aRealm); + + let newLoginInfo = Components.classes["@mozilla.org/login-manager/loginInfo;1"] + .createInstance(Components.interfaces.nsILoginInfo); + newLoginInfo.init(origin, null, aRealm, aUsername, aPassword, "", ""); + if (logins.length > 0) { + Services.logins.modifyLogin(logins[0], newLoginInfo); + } else { + Services.logins.addLogin(newLoginInfo); + } + } catch (exc) { + // Only show the message if its not an abort, which can happen if + // the user canceled the master password dialog + cal.ASSERT(exc.result == Components.results.NS_ERROR_ABORT, exc); + } + }, + + /** + * Helper to retrieve an entry from the password manager. + * + * @param in aUsername The username to search + * @param out aPassword The corresponding password + * @param aOrigin The corresponding origin + * @param aRealm The password realm (unused on branch) + * @return Does an entry exist in the password manager + */ + passwordManagerGet: function(aUsername, aPassword, aOrigin, aRealm) { + cal.ASSERT(aUsername); + + if (typeof aPassword != "object") { + throw new Components.Exception("", Components.results.NS_ERROR_XPC_NEED_OUT_OBJECT); + } + + let origin = this._ensureOrigin(aOrigin); + + try { + if (!Services.logins.getLoginSavingEnabled(origin)) { + return false; + } + + let logins = Services.logins.findLogins({}, origin, null, aRealm); + for (let loginInfo of logins) { + if (loginInfo.username == aUsername) { + aPassword.value = loginInfo.password; + return true; + } + } + } catch (exc) { + cal.ASSERT(false, exc); + } + return false; + }, + + /** + * Helper to remove an entry from the password manager + * + * @param aUsername The username to remove. + * @param aOrigin The corresponding origin + * @param aRealm The password realm (unused on branch) + * @return Could the user be removed? + */ + passwordManagerRemove: function(aUsername, aOrigin, aRealm) { + cal.ASSERT(aUsername); + + let origin = this._ensureOrigin(aOrigin); + + try { + let logins = Services.logins.findLogins({}, origin, null, aRealm); + for (let loginInfo of logins) { + if (loginInfo.username == aUsername) { + Services.logins.removeLogin(loginInfo); + return true; + } + } + } catch (exc) { + // If no logins are found, fall through to the return statement below. + } + return false; + } +}; + +/** + * Calendar Auth prompt implementation. This instance of the auth prompt should + * be used by providers and other components that handle authentication using + * nsIAuthPrompt2 and friends. + * + * This implementation guarantees there are no request loops when an invalid + * password is stored in the login-manager. + * + * There is one instance of that object per calendar provider. + */ +cal.auth.Prompt.prototype = { + mProvider: null, + + getPasswordInfo: function(aPasswordRealm) { + let username; + let password; + let found = false; + + let logins = Services.logins.findLogins({}, aPasswordRealm.prePath, null, aPasswordRealm.realm); + if (logins.length) { + username = logins[0].username; + password = logins[0].password; + found = true; + } + if (found) { + let keyStr = aPasswordRealm.prePath + ":" + aPasswordRealm.realm; + let now = new Date(); + // Remove the saved password if it was already returned less + // than 60 seconds ago. The reason for the timestamp check is that + // nsIHttpChannel can call the nsIAuthPrompt2 interface + // again in some situation. ie: When using Digest auth token + // expires. + if (this.mReturnedLogins[keyStr] && + now.getTime() - this.mReturnedLogins[keyStr].getTime() < 60000) { + cal.LOG("Credentials removed for: user=" + username + ", host=" + aPasswordRealm.prePath + ", realm=" + aPasswordRealm.realm) +; + delete this.mReturnedLogins[keyStr]; + cal.auth.passwordManagerRemove(username, + aPasswordRealm.prePath, + aPasswordRealm.realm); + return { found: false, username: username }; + } else { + this.mReturnedLogins[keyStr] = now; + } + } + return { found: found, username: username, password: password }; + }, + + /** + * Requests a username and a password. Implementations will commonly show a + * dialog with a username and password field, depending on flags also a + * domain field. + * + * @param aChannel + * The channel that requires authentication. + * @param level + * One of the level constants NONE, PW_ENCRYPTED, SECURE. + * @param authInfo + * Authentication information object. The implementation should fill in + * this object with the information entered by the user before + * returning. + * + * @retval true + * Authentication can proceed using the values in the authInfo + * object. + * @retval false + * Authentication should be cancelled, usually because the user did + * not provide username/password. + * + * @note Exceptions thrown from this function will be treated like a + * return value of false. + */ + promptAuth: function(aChannel, aLevel, aAuthInfo) { + let hostRealm = {}; + hostRealm.prePath = aChannel.URI.prePath; + hostRealm.realm = aAuthInfo.realm; + let port = aChannel.URI.port; + if (port == -1) { + let handler = Services.io.getProtocolHandler(aChannel.URI.scheme) + .QueryInterface(Components.interfaces.nsIProtocolHandler); + port = handler.defaultPort; + } + hostRealm.passwordRealm = aChannel.URI.host + ":" + port + " (" + aAuthInfo.realm + ")"; + + let pwInfo = this.getPasswordInfo(hostRealm); + aAuthInfo.username = pwInfo.username; + if (pwInfo && pwInfo.found) { + aAuthInfo.password = pwInfo.password; + return true; + } else { + let prompter2 = Components.classes["@mozilla.org/embedcomp/window-watcher;1"] + .getService(Components.interfaces.nsIPromptFactory) + .getPrompt(this.mWindow, Components.interfaces.nsIAuthPrompt2); + return prompter2.promptAuth(aChannel, aLevel, aAuthInfo); + } + }, + + /** + * Asynchronously prompt the user for a username and password. + * This has largely the same semantics as promptAuth(), + * but must return immediately after calling and return the entered + * data in a callback. + * + * If the user closes the dialog using a cancel button or similar, + * the callback's nsIAuthPromptCallback::onAuthCancelled method must be + * called. + * Calling nsICancelable::cancel on the returned object SHOULD close the + * dialog and MUST call nsIAuthPromptCallback::onAuthCancelled on the provided + * callback. + * + * @throw NS_ERROR_NOT_IMPLEMENTED + * Asynchronous authentication prompts are not supported; + * the caller should fall back to promptUsernameAndPassword(). + */ + asyncPromptAuth: function(aChannel, // nsIChannel + aCallback, // nsIAuthPromptCallback + aContext, // nsISupports + aLevel, // PRUint32 + aAuthInfo) { // nsIAuthInformation + let self = this; + let promptlistener = { + onPromptStart: function() { + res = self.promptAuth(aChannel, aLevel, aAuthInfo); + + if (res) { + gAuthCache.setAuthInfo(hostKey, aAuthInfo); + this.onPromptAuthAvailable(); + return true; + } + + this.onPromptCanceled(); + return false; + }, + + onPromptAuthAvailable: function() { + let authInfo = gAuthCache.retrieveAuthInfo(hostKey); + if (authInfo) { + aAuthInfo.username = authInfo.username; + aAuthInfo.password = authInfo.password; + } + aCallback.onAuthAvailable(aContext, aAuthInfo); + }, + + onPromptCanceled: function() { + gAuthCache.retrieveAuthInfo(hostKey); + aCallback.onAuthCancelled(aContext, true); + } + }; + + let hostKey = aChannel.URI.prePath + ":" + aAuthInfo.realm; + gAuthCache.planForAuthInfo(hostKey); + + function queuePrompt() { + let asyncprompter = Components.classes["@mozilla.org/messenger/msgAsyncPrompter;1"] + .getService(Components.interfaces.nsIMsgAsyncPrompter); + asyncprompter.queueAsyncAuthPrompt(hostKey, false, promptlistener); + } + + self.mWindow = cal.getCalendarWindow(); + + // the prompt will fail if we are too early + if (self.mWindow.document.readyState == "complete") { + queuePrompt(); + } else { + self.mWindow.addEventListener("load", queuePrompt, true); + } + } +}; + +// Cache for authentication information since onAuthInformation in the prompt +// listener is called without further information. If the password is not +// saved, there is no way to retrieve it. We use ref counting to avoid keeping +// the password in memory longer than needed. +var gAuthCache = { + _authInfoCache: new Map(), + planForAuthInfo: function(hostKey) { + let authInfo = this._authInfoCache.get(hostKey); + if (authInfo) { + authInfo.refCnt++; + } else { + this._authInfoCache.set(hostKey, { refCnt: 1 }); + } + }, + + setAuthInfo: function(hostKey, aAuthInfo) { + let authInfo = this._authInfoCache.get(hostKey); + if (authInfo) { + authInfo.username = aAuthInfo.username; + authInfo.password = aAuthInfo.password; + } + }, + + retrieveAuthInfo: function(hostKey) { + let authInfo = this._authInfoCache.get(hostKey); + if (authInfo) { + authInfo.refCnt--; + + if (authInfo.refCnt == 0) { + this._authInfoCache.delete(hostKey); + } + } + return authInfo; + } +}; diff --git a/calendar/base/modules/calExtract.jsm b/calendar/base/modules/calExtract.jsm new file mode 100644 index 000000000..146ee11b4 --- /dev/null +++ b/calendar/base/modules/calExtract.jsm @@ -0,0 +1,1296 @@ +/* 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.EXPORTED_SYMBOLS = ["Extractor"]; +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); +Components.utils.import("resource://gre/modules/Preferences.jsm"); + +/** +* Initializes extraction +* +* @param fallbackLocale locale to use when others are not found or +* detection is disabled +* @param dayStart ambiguous hours earlier than this are considered to +* be in the afternoon, when null then by default +* set to 6 +* @param fixedLang whether to use only fallbackLocale for extraction +*/ +function Extractor(fallbackLocale, dayStart, fixedLang) { + // url for multi locale AMO build + this.bundleUrl = "resource://calendar/chrome/calendar-LOCALE/locale/LOCALE/calendar/calendar-extract.properties"; + // url for single locale python packaged build + this.packagedUrl = "jar:resource://calendar/chrome.jar!/calendar-LOCALE/locale/LOCALE/calendar/calendar-extract.properties"; + this.fallbackLocale = fallbackLocale; + this.email = ""; + this.marker = "--MARK--"; + // this should never be found in an email + this.defPattern = "061dc19c-719f-47f3-b2b5-e767e6f02b7a"; + this.collected = []; + this.numbers = []; + this.hourlyNumbers = []; + this.dailyNumbers = []; + this.allMonths = ""; + this.months = []; + this.dayStart = 6; + this.now = new Date(); + this.bundle = ""; + this.overrides = {}; + this.fixedLang = true; + + if (dayStart != null) { + this.dayStart = dayStart; + } + + if (fixedLang != null) { + this.fixedLang = fixedLang; + } + + if (!this.checkBundle(fallbackLocale)) { + this.bundleUrl = this.packagedUrl; + cal.WARN("Your installed Lightning only includes a single locale, extracting event info from other languages is likely inaccurate. You can install Lightning from addons.mozilla.org manually for multiple locale support."); + } +} + +Extractor.prototype = { + /** + * Removes confusing data like urls, timezones and phone numbers from email + * Also removes standard signatures and quoted content from previous emails + */ + cleanup: function() { + // XXX remove earlier correspondence + // ideally this should be considered with lower certainty to fill in + // missing information + + // remove last line preceeding quoted message and first line of the quote + this.email = this.email.replace(/\r?\n[^>].*\r?\n>+.*$/m, ""); + // remove the rest of quoted content + this.email = this.email.replace(/^>+.*$/gm, ""); + + // urls often contain dates dates that can confuse extraction + this.email = this.email.replace(/https?:\/\/[^\s]+\s/gm, ""); + this.email = this.email.replace(/www\.[^\s]+\s/gm, ""); + + // remove phone numbers + // TODO allow locale specific configuration of formats + this.email = this.email.replace(/\d-\d\d\d-\d\d\d-\d\d\d\d/gm, ""); + + // remove standard signature + this.email = this.email.replace(/\r?\n-- \r?\n[\S\s]+$/, ""); + + // XXX remove timezone info, for now + this.email = this.email.replace(/gmt[+-]\d{2}:\d{2}/gi, ""); + }, + + checkBundle: function(locale) { + let path = this.bundleUrl.replace(/LOCALE/g, locale); + let bundle = Services.strings.createBundle(path); + + try { + bundle.GetStringFromName("from.today"); + return true; + } catch (ex) { + return false; + } + }, + + avgNonAsciiCharCode: function() { + let sum = 0; + let cnt = 0; + + for (let i = 0; i < this.email.length; i++) { + let char = this.email.charCodeAt(i); + if (char > 128) { + sum += char; + cnt++; + } + } + + let nonAscii = sum / cnt || 0; + cal.LOG("[calExtract] Average non-ascii charcode: " + nonAscii); + return nonAscii; + }, + + setLanguage: function() { + let path; + + if (this.fixedLang == true) { + if (this.checkBundle(this.fallbackLocale)) { + cal.LOG("[calExtract] Fixed locale was used to choose " + + this.fallbackLocale + " patterns."); + } else { + cal.LOG("[calExtract] " + this.fallbackLocale + + " patterns were not found. Using en-US instead"); + this.fallbackLocale = "en-US"; + } + + path = this.bundleUrl.replace(/LOCALE/g, this.fallbackLocale); + + let pref = "calendar.patterns.last.used.languages"; + let lastUsedLangs = Preferences.get(pref, ""); + if (lastUsedLangs == "") { + Preferences.set(pref, this.fallbackLocale); + } else { + let langs = lastUsedLangs.split(","); + let idx = langs.indexOf(this.fallbackLocale); + if (idx == -1) { + Preferences.set(pref, this.fallbackLocale + "," + lastUsedLangs); + } else { + langs.splice(idx, 1); + Preferences.set(pref, this.fallbackLocale + "," + langs.join(",")); + } + } + } else { + let spellclass = "@mozilla.org/spellchecker/engine;1"; + let mozISpellCheckingEngine = Components.interfaces.mozISpellCheckingEngine; + let spellchecker = Components.classes[spellclass] + .getService(mozISpellCheckingEngine); + + let arr = {}; + let cnt = {}; + spellchecker.getDictionaryList(arr, cnt); + let dicts = arr.value; + + if (dicts.length == 0) { + cal.LOG("[calExtract] There are no dictionaries installed and " + + "enabled. You might want to add some if date and time " + + "extraction from emails seems inaccurate."); + } + + let patterns; + let words = this.email.split(/\s+/); + let most = 0; + let mostLocale; + for (let dict in dicts) { + // dictionary locale and patterns locale match + if (this.checkBundle(dicts[dict])) { + let time1 = (new Date()).getTime(); + spellchecker.dictionary = dicts[dict]; + let dur = (new Date()).getTime() - time1; + cal.LOG("[calExtract] Loading " + dicts[dict] + + " dictionary took " + dur + "ms"); + patterns = dicts[dict]; + // beginning of dictionary locale matches patterns locale + } else if (this.checkBundle(dicts[dict].substring(0, 2))) { + let time1 = (new Date()).getTime(); + spellchecker.dictionary = dicts[dict]; + let dur = (new Date()).getTime() - time1; + cal.LOG("[calExtract] Loading " + dicts[dict] + + " dictionary took " + dur + "ms"); + patterns = dicts[dict].substring(0, 2); + // dictionary for which patterns aren't present + } else { + cal.LOG("[calExtract] Dictionary present, rules missing: " + dicts[dict]); + continue; + } + + let correct = 0; + let total = 0; + for (let word in words) { + words[word] = words[word].replace(/[()\d,;:?!#\.]/g, ""); + if (words[word].length >= 2) { + total++; + if (spellchecker.check(words[word])) { + correct++; + } + } + } + + let percentage = correct / total * 100.0; + cal.LOG("[calExtract] " + dicts[dict] + " dictionary matches " + + percentage + "% of words"); + + if (percentage > 50.0 && percentage > most) { + mostLocale = patterns; + most = percentage; + } + } + + let avgCharCode = this.avgNonAsciiCharCode(); + + // using dictionaries for language recognition with non-latin letters doesn't work + // very well, possibly because of bug 471799 + if (avgCharCode > 48000 && avgCharCode < 50000) { + cal.LOG("[calExtract] Using ko patterns based on charcodes"); + path = this.bundleUrl.replace(/LOCALE/g, "ko"); + // is it possible to differentiate zh-TW and zh-CN? + } else if (avgCharCode > 24000 && avgCharCode < 32000) { + cal.LOG("[calExtract] Using zh-TW patterns based on charcodes"); + path = this.bundleUrl.replace(/LOCALE/g, "zh-TW"); + } else if (avgCharCode > 14000 && avgCharCode < 24000) { + cal.LOG("[calExtract] Using ja patterns based on charcodes"); + path = this.bundleUrl.replace(/LOCALE/g, "ja"); + // Bulgarian also looks like that + } else if (avgCharCode > 1000 && avgCharCode < 1200) { + cal.LOG("[calExtract] Using ru patterns based on charcodes"); + path = this.bundleUrl.replace(/LOCALE/g, "ru"); + // dictionary based + } else if (most > 0) { + cal.LOG("[calExtract] Using " + mostLocale + " patterns based on dictionary"); + path = this.bundleUrl.replace(/LOCALE/g, mostLocale); + // fallbackLocale matches patterns exactly + } else if (this.checkBundle(this.fallbackLocale)) { + cal.LOG("[calExtract] Falling back to " + this.fallbackLocale); + path = this.bundleUrl.replace(/LOCALE/g, this.fallbackLocale); + // beginning of fallbackLocale matches patterns + } else if (this.checkBundle(this.fallbackLocale.substring(0, 2))) { + this.fallbackLocale = this.fallbackLocale.substring(0, 2); + cal.LOG("[calExtract] Falling back to " + this.fallbackLocale); + path = this.bundleUrl.replace(/LOCALE/g, this.fallbackLocale); + } else { + cal.LOG("[calExtract] Using en-US"); + path = this.bundleUrl.replace(/LOCALE/g, "en-US"); + } + } + this.bundle = Services.strings.createBundle(path); + }, + + /** + * Extracts dates, times and durations from email + * + * @param body email body + * @param now reference time against which relative times are interpreted, + * when null current time is used + * @param sel selection object of email content, when defined times + * outside selection are disgarded + * @param title email title + * @return sorted list of extracted datetime objects + */ + extract: function(title, body, now, sel) { + let initial = {}; + this.collected = []; + this.email = title + "\r\n" + body; + if (now != null) { + this.now = now; + } + + initial.year = now.getFullYear(); + initial.month = now.getMonth() + 1; + initial.day = now.getDate(); + initial.hour = now.getHours(); + initial.minute = now.getMinutes(); + + this.collected.push({ + year: initial.year, + month: initial.month, + day: initial.day, + hour: initial.hour, + minute: initial.minute, + relation: "start" + }); + + this.cleanup(); + cal.LOG("[calExtract] Email after processing for extraction: \n" + this.email); + + this.overrides = JSON.parse(Preferences.get("calendar.patterns.override", "{}")); + this.setLanguage(); + + for (let i = 0; i <= 31; i++) { + this.numbers[i] = this.getPatterns("number." + i); + } + this.dailyNumbers = this.numbers.join(this.marker); + + this.hourlyNumbers = this.numbers[0] + this.marker; + for (let i = 1; i <= 22; i++) { + this.hourlyNumbers += this.numbers[i] + this.marker; + } + this.hourlyNumbers += this.numbers[23]; + + this.hourlyNumbers = this.hourlyNumbers.replace(/\|/g, this.marker); + this.dailyNumbers = this.dailyNumbers.replace(/\|/g, this.marker); + + for (let i = 0; i < 12; i++) { + this.months[i] = this.getPatterns("month." + (i + 1)); + } + this.allMonths = this.months.join(this.marker).replace(/\|/g, this.marker); + + // time + this.extractTime("from.noon", "start", 12, 0); + this.extractTime("until.noon", "end", 12, 0); + + this.extractHour("from.hour", "start", "none"); + this.extractHour("from.hour.am", "start", "ante"); + this.extractHour("from.hour.pm", "start", "post"); + this.extractHour("until.hour", "end", "none"); + this.extractHour("until.hour.am", "end", "ante"); + this.extractHour("until.hour.pm", "end", "post"); + + this.extractHalfHour("from.half.hour.before", "start", "ante"); + this.extractHalfHour("until.half.hour.before", "end", "ante"); + this.extractHalfHour("from.half.hour.after", "start", "post"); + this.extractHalfHour("until.half.hour.after", "end", "post"); + + this.extractHourMinutes("from.hour.minutes", "start", "none"); + this.extractHourMinutes("from.hour.minutes.am", "start", "ante"); + this.extractHourMinutes("from.hour.minutes.pm", "start", "post"); + this.extractHourMinutes("until.hour.minutes", "end", "none"); + this.extractHourMinutes("until.hour.minutes.am", "end", "ante"); + this.extractHourMinutes("until.hour.minutes.pm", "end", "post"); + + // date + this.extractRelativeDay("from.today", "start", 0); + this.extractRelativeDay("from.tomorrow", "start", 1); + this.extractRelativeDay("until.tomorrow", "end", 1); + this.extractWeekDay("from.weekday.", "start"); + this.extractWeekDay("until.weekday.", "end"); + this.extractDate("from.ordinal.date", "start"); + this.extractDate("until.ordinal.date", "end"); + + this.extractDayMonth("from.month.day", "start"); + this.extractDayMonthYear("from.year.month.day", "start"); + this.extractDayMonth("until.month.day", "end"); + this.extractDayMonthYear("until.year.month.day", "end"); + this.extractDayMonthName("from.monthname.day", "start"); + this.extractDayMonthNameYear("from.year.monthname.day", "start"); + this.extractDayMonthName("until.monthname.day", "end"); + this.extractDayMonthNameYear("until.year.monthname.day", "end"); + + // duration + this.extractDuration("duration.minutes", 1); + this.extractDuration("duration.hours", 60); + this.extractDuration("duration.days", 60 * 24); + + if (sel !== undefined && sel !== null) { + this.markSelected(sel, title); + } + this.markContained(); + this.collected = this.collected.sort(this.sort); + + return this.collected; + }, + + extractDayMonthYear: function(pattern, relation) { + let alts = this.getRepPatterns(pattern, ["(\\d{1,2})", "(\\d{1,2})", + "(\\d{2,4})"]); + let res; + for (let alt in alts) { + let positions = alts[alt].positions; + let re = new RegExp(alts[alt].pattern, "ig"); + + while ((res = re.exec(this.email)) != null) { + if (!this.limitNums(res, this.email) && !this.limitChars(res, this.email)) { + let day = parseInt(res[positions[1]], 10); + let month = parseInt(res[positions[2]], 10); + let year = parseInt(this.normalizeYear(res[positions[3]]), 10); + + if (this.isValidDay(day) && this.isValidMonth(month) && + this.isValidYear(year)) { + let rev = this.prefixSuffixStartEnd(res, relation, this.email); + this.guess(year, month, day, null, null, + rev.start, rev.end, rev.pattern, rev.relation, pattern); + } + } + } + } + }, + + extractDayMonthNameYear: function(pattern, relation) { + let alts = this.getRepPatterns(pattern, ["(\\d{1,2})", + "(" + this.allMonths + ")", + "(\\d{2,4})"]); + let res; + for (let alt in alts) { + let exp = alts[alt].pattern.split(this.marker).join("|"); + let positions = alts[alt].positions; + let re = new RegExp(exp, "ig"); + + while ((res = re.exec(this.email)) != null) { + if (!this.limitNums(res, this.email) && !this.limitChars(res, this.email)) { + let day = parseInt(res[positions[1]], 10); + let month = res[positions[2]]; + let year = parseInt(this.normalizeYear(res[positions[3]]), 10); + + if (this.isValidDay(day)) { + for (let i = 0; i < 12; i++) { + if (this.months[i].split("|").includes(month.toLowerCase())) { + let rev = this.prefixSuffixStartEnd(res, relation, this.email); + this.guess(year, i + 1, day, null, null, + rev.start, rev.end, rev.pattern, rev.relation, pattern); + break; + } + } + } + } + } + } + }, + + extractRelativeDay: function(pattern, relation, offset) { + let re = new RegExp(this.getPatterns(pattern), "ig"); + let res; + if ((res = re.exec(this.email)) != null) { + if (!this.limitChars(res, this.email)) { + let item = new Date(this.now.getTime() + 60 * 60 * 24 * 1000 * offset); + let rev = this.prefixSuffixStartEnd(res, relation, this.email); + this.guess(item.getFullYear(), item.getMonth() + 1, item.getDate(), + null, null, + rev.start, rev.end, rev.pattern, rev.relation, pattern); + } + } + }, + + extractDayMonthName: function(pattern, relation) { + let alts = this.getRepPatterns(pattern, + ["(\\d{1,2}" + this.marker + this.dailyNumbers + ")", + "(" + this.allMonths + ")"]); + let res; + for (let alt in alts) { + let exp = alts[alt].pattern.split(this.marker).join("|"); + let positions = alts[alt].positions; + let re = new RegExp(exp, "ig"); + + while ((res = re.exec(this.email)) != null) { + if (!this.limitNums(res, this.email) && !this.limitChars(res, this.email)) { + let day = this.parseNumber(res[positions[1]], this.numbers); + let month = res[positions[2]]; + + if (this.isValidDay(day)) { + for (let i = 0; i < 12; i++) { + let months = this.unescape(this.months[i]).split("|"); + if (months.includes(month.toLowerCase())) { + let date = { year: this.now.getFullYear(), month: i + 1, day: day }; + if (this.isPastDate(date, this.now)) { + // find next such date + let item = new Date(this.now.getTime()); + while (true) { + item.setDate(item.getDate() + 1); + if (item.getMonth() == date.month - 1 && + item.getDate() == date.day) { + date.year = item.getFullYear(); + break; + } + } + } + + let rev = this.prefixSuffixStartEnd(res, relation, this.email); + this.guess(date.year, date.month, date.day, null, null, + rev.start, rev.end, rev.pattern, rev.relation, pattern); + break; + } + } + } + } + } + } + }, + + extractDayMonth: function(pattern, relation) { + let alts = this.getRepPatterns(pattern, ["(\\d{1,2})", "(\\d{1,2})"]); + let res; + for (let alt in alts) { + let re = new RegExp(alts[alt].pattern, "ig"); + let positions = alts[alt].positions; + + while ((res = re.exec(this.email)) != null) { + if (!this.limitNums(res, this.email) && !this.limitChars(res, this.email)) { + let day = parseInt(res[positions[1]], 10); + let month = parseInt(res[positions[2]], 10); + + if (this.isValidMonth(month) && this.isValidDay(day)) { + let date = { year: this.now.getFullYear(), month: month, day: day }; + + if (this.isPastDate(date, this.now)) { + // find next such date + let item = new Date(this.now.getTime()); + while (true) { + item.setDate(item.getDate() + 1); + if (item.getMonth() == date.month - 1 && + item.getDate() == date.day) { + date.year = item.getFullYear(); + break; + } + } + } + + let rev = this.prefixSuffixStartEnd(res, relation, this.email); + this.guess(date.year, date.month, date.day, null, null, + rev.start, rev.end, rev.pattern, rev.relation, pattern); + } + } + } + } + }, + + extractDate: function(pattern, relation) { + let alts = this.getRepPatterns(pattern, + ["(\\d{1,2}" + this.marker + this.dailyNumbers + ")"]); + let res; + for (let alt in alts) { + let exp = alts[alt].pattern.split(this.marker).join("|"); + let re = new RegExp(exp, "ig"); + + while ((res = re.exec(this.email)) != null) { + if (!this.limitNums(res, this.email) && !this.limitChars(res, this.email)) { + let day = this.parseNumber(res[1], this.numbers); + if (this.isValidDay(day)) { + let item = new Date(this.now.getTime()); + if (this.now.getDate() != day) { + // find next nth date + while (true) { + item.setDate(item.getDate() + 1); + if (item.getDate() == day) { + break; + } + } + } + + let rev = this.prefixSuffixStartEnd(res, relation, this.email); + this.guess(item.getFullYear(), item.getMonth() + 1, day, + null, null, + rev.start, rev.end, + rev.pattern, rev.relation, pattern, true); + } + } + } + } + }, + + extractWeekDay: function(pattern, relation) { + let days = []; + for (let i = 0; i < 7; i++) { + days[i] = this.getPatterns(pattern + i); + let re = new RegExp(days[i], "ig"); + let res = re.exec(this.email); + if (res) { + if (!this.limitChars(res, this.email)) { + let date = new Date(); + date.setDate(this.now.getDate()); + date.setMonth(this.now.getMonth()); + date.setYear(this.now.getFullYear()); + + let diff = (i - date.getDay() + 7) % 7; + date.setDate(date.getDate() + diff); + + let rev = this.prefixSuffixStartEnd(res, relation, this.email); + this.guess(date.getFullYear(), date.getMonth() + 1, date.getDate(), + null, null, + rev.start, rev.end, + rev.pattern, rev.relation, pattern + i, true); + } + } + } + }, + + extractHour: function(pattern, relation, meridiem) { + let alts = this.getRepPatterns(pattern, + ["(\\d{1,2}" + this.marker + this.hourlyNumbers + ")"]); + let res; + for (let alt in alts) { + let exp = alts[alt].pattern.split(this.marker).join("|"); + let re = new RegExp(exp, "ig"); + + while ((res = re.exec(this.email)) != null) { + if (!this.limitNums(res, this.email) && !this.limitChars(res, this.email)) { + let hour = this.parseNumber(res[1], this.numbers); + + if (meridiem == "ante" && hour == 12) { + hour = hour - 12; + } else if (meridiem == "post" && hour != 12) { + hour = hour + 12; + } else { + hour = this.normalizeHour(hour); + } + + if (this.isValidHour(res[1])) { + let rev = this.prefixSuffixStartEnd(res, relation, this.email); + this.guess(null, null, null, hour, 0, + rev.start, rev.end, rev.pattern, rev.relation, pattern, true); + } + } + } + } + }, + + extractHalfHour: function(pattern, relation, direction) { + let alts = this.getRepPatterns(pattern, + ["(\\d{1,2}" + this.marker + this.hourlyNumbers + ")"]); + let res; + for (let alt in alts) { + let exp = alts[alt].pattern.split(this.marker).join("|"); + let re = new RegExp(exp, "ig"); + + while ((res = re.exec(this.email)) != null) { + if (!this.limitNums(res, this.email) && !this.limitChars(res, this.email)) { + let hour = this.parseNumber(res[1], this.numbers); + + hour = this.normalizeHour(hour); + if (direction == "ante") { + if (hour == 1) { + hour = 12; + } else { + hour = hour - 1; + } + } + + if (this.isValidHour(hour)) { + let rev = this.prefixSuffixStartEnd(res, relation, this.email); + this.guess(null, null, null, hour, 30, + rev.start, rev.end, rev.pattern, rev.relation, pattern, true); + } + } + } + } + }, + + extractHourMinutes: function(pattern, relation, meridiem) { + let alts = this.getRepPatterns(pattern, ["(\\d{1,2})", "(\\d{2})"]); + let res; + for (let alt in alts) { + let positions = alts[alt].positions; + let re = new RegExp(alts[alt].pattern, "ig"); + + while ((res = re.exec(this.email)) != null) { + if (!this.limitNums(res, this.email) && !this.limitChars(res, this.email)) { + let hour = parseInt(res[positions[1]], 10); + let minute = parseInt(res[positions[2]], 10); + + if (meridiem == "ante" && hour == 12) { + hour = hour - 12; + } else if (meridiem == "post" && hour != 12) { + hour = hour + 12; + } else { + hour = this.normalizeHour(hour); + } + + if (this.isValidHour(hour) && this.isValidMinute(hour)) { + let rev = this.prefixSuffixStartEnd(res, relation, this.email); + this.guess(null, null, null, hour, minute, + rev.start, rev.end, rev.pattern, rev.relation, pattern); + } + } + } + } + }, + + extractTime: function(pattern, relation, hour, minute) { + let re = new RegExp(this.getPatterns(pattern), "ig"); + let res; + if ((res = re.exec(this.email)) != null) { + if (!this.limitChars(res, this.email)) { + let rev = this.prefixSuffixStartEnd(res, relation, this.email); + this.guess(null, null, null, hour, minute, + rev.start, rev.end, rev.pattern, rev.relation, pattern); + } + } + }, + + extractDuration: function(pattern, unit) { + let alts = this.getRepPatterns(pattern, + ["(\\d{1,2}" + this.marker + this.dailyNumbers + ")"]); + let res; + for (let alt in alts) { + let exp = alts[alt].pattern.split(this.marker).join("|"); + let re = new RegExp(exp, "ig"); + + while ((res = re.exec(this.email)) != null) { + if (!this.limitNums(res, this.email) && !this.limitChars(res, this.email)) { + let length = this.parseNumber(res[1], this.numbers); + let guess = {}; + let rev = this.prefixSuffixStartEnd(res, "duration", this.email); + guess.duration = length * unit; + guess.start = rev.start; + guess.end = rev.end; + guess.str = rev.pattern; + guess.relation = rev.relation; + guess.pattern = pattern; + this.collected.push(guess); + } + } + } + }, + + markContained: function() { + for (let outer = 0; outer < this.collected.length; outer++) { + for (let inner = 0; inner < this.collected.length; inner++) { + // included but not exactly the same + if (outer != inner && + this.collected[outer].start && this.collected[outer].end && + this.collected[inner].start && this.collected[inner].end && + this.collected[inner].start >= this.collected[outer].start && + this.collected[inner].end <= this.collected[outer].end && + !(this.collected[inner].start == this.collected[outer].start && + this.collected[inner].end == this.collected[outer].end)) { + cal.LOG("[calExtract] " + this.collected[outer].str + " found as well, disgarding " + this.collected[inner].str); + this.collected[inner].relation = "notadatetime"; + } + } + } + }, + + markSelected: function(sel, title) { + if (sel.rangeCount > 0) { + // mark the ones to not use + for (let i = 0; i < sel.rangeCount; i++) { + cal.LOG("[calExtract] Selection " + i + " is " + sel); + for (let j = 0; j < this.collected.length; j++) { + let selection = sel.getRangeAt(i).toString(); + + if (!selection.includes(this.collected[j].str) && + !title.includes(this.collected[j].str) && + this.collected[j].start != null) { // always keep email date, needed for tasks + cal.LOG("[calExtract] Marking " + JSON.stringify(this.collected[j]) + " as notadatetime"); + this.collected[j].relation = "notadatetime"; + } + } + } + } + }, + + sort: function(one, two) { + let rc; + // sort the guess from email date as the last one + if (one.start == null && two.start != null) { + return 1; + } else if (one.start != null && two.start == null) { + return -1; + } else if (one.start == null && two.start == null) { + return 0; + // sort dates before times + } else if (one.year != null && two.year == null) { + return -1; + } else if (one.year == null && two.year != null) { + return 1; + } else if (one.year != null && two.year != null) { + rc = (one.year > two.year) - (one.year < two.year); + if (rc == 0) { + rc = (one.month > two.month) - (one.month < two.month); + if (rc == 0) { + rc = (one.day > two.day) - (one.day < two.day); + } + } + return rc; + } else { + rc = (one.hour > two.hour) - (one.hour < two.hour); + if (rc == 0) { + rc = (one.minute > two.minute) - (one.minute < two.minute); + } + return rc; + } + }, + + /** + * Guesses start time from list of guessed datetimes + * + * @param isTask whether start time should be guessed for task or event + * @return datetime object for start time + */ + guessStart: function(isTask) { + let startTimes = this.collected.filter(val => val.relation == "start"); + if (startTimes.length == 0) { + return {}; + } + + for (let val in startTimes) { + cal.LOG("[calExtract] Start: " + JSON.stringify(startTimes[val])); + } + + let guess = {}; + let wDayInit = startTimes.filter(val => val.day != null && val.start === undefined); + + // with tasks we don't try to guess start but assume email date + if (isTask) { + guess.year = wDayInit[0].year; + guess.month = wDayInit[0].month; + guess.day = wDayInit[0].day; + guess.hour = wDayInit[0].hour; + guess.minute = wDayInit[0].minute; + return guess; + } + + let wDay = startTimes.filter(val => val.day != null && val.start !== undefined); + let wDayNA = wDay.filter(val => val.ambiguous === undefined); + + let wMinute = startTimes.filter(val => val.minute != null && val.start !== undefined); + let wMinuteNA = wMinute.filter(val => val.ambiguous === undefined); + + if (wMinuteNA.length != 0) { + guess.hour = wMinuteNA[0].hour; + guess.minute = wMinuteNA[0].minute; + } else if (wMinute.length != 0) { + guess.hour = wMinute[0].hour; + guess.minute = wMinute[0].minute; + } + + // first use unambiguous guesses + if (wDayNA.length != 0) { + guess.year = wDayNA[0].year; + guess.month = wDayNA[0].month; + guess.day = wDayNA[0].day; + // then also ambiguous ones + } else if (wDay.length != 0) { + guess.year = wDay[0].year; + guess.month = wDay[0].month; + guess.day = wDay[0].day; + // next possible day considering time + } else if (guess.hour != null && + (wDayInit[0].hour > guess.hour || + (wDayInit[0].hour == guess.hour && + wDayInit[0].minute > guess.minute))) { + let nextDay = new Date(wDayInit[0].year, wDayInit[0].month - 1, wDayInit[0].day); + nextDay.setTime(nextDay.getTime() + 60 * 60 * 24 * 1000); + guess.year = nextDay.getFullYear(); + guess.month = nextDay.getMonth() + 1; + guess.day = nextDay.getDate(); + // and finally when nothing was found then use initial guess from send time + } else { + guess.year = wDayInit[0].year; + guess.month = wDayInit[0].month; + guess.day = wDayInit[0].day; + } + + cal.LOG("[calExtract] Start picked: " + JSON.stringify(guess)); + return guess; + }, + + /** + * Guesses end time from list of guessed datetimes relative to start time + * + * @param start start time to consider when guessing + * @param isTask whether start time should be guessed for task or event + * @return datetime object for end time + */ + guessEnd: function(start, isTask) { + let guess = {}; + let endTimes = this.collected.filter(val => val.relation == "end"); + let durations = this.collected.filter(val => val.relation == "duration"); + if (endTimes.length == 0 && durations.length == 0) { + return {}; + } else { + for (let val in endTimes) { + cal.LOG("[calExtract] End: " + JSON.stringify(endTimes[val])); + } + + let wDay = endTimes.filter(val => val.day != null); + let wDayNA = wDay.filter(val => val.ambiguous === undefined); + let wMinute = endTimes.filter(val => val.minute != null); + let wMinuteNA = wMinute.filter(val => val.ambiguous === undefined); + + // first set non-ambiguous dates + let pos = isTask == true ? 0 : wDayNA.length - 1; + if (wDayNA.length != 0) { + guess.year = wDayNA[pos].year; + guess.month = wDayNA[pos].month; + guess.day = wDayNA[pos].day; + // then ambiguous dates + } else if (wDay.length != 0) { + pos = isTask == true ? 0 : wDay.length - 1; + guess.year = wDay[pos].year; + guess.month = wDay[pos].month; + guess.day = wDay[pos].day; + } + + // then non-ambiguous times + if (wMinuteNA.length != 0) { + pos = isTask == true ? 0 : wMinuteNA.length - 1; + guess.hour = wMinuteNA[pos].hour; + guess.minute = wMinuteNA[pos].minute; + if (guess.day == null || guess.day == start.day) { + if (wMinuteNA[pos].hour < start.hour || + (wMinuteNA[pos].hour == start.hour && + wMinuteNA[pos].minute < start.minute)) { + let nextDay = new Date(start.year, start.month - 1, start.day); + nextDay.setTime(nextDay.getTime() + 60 * 60 * 24 * 1000); + guess.year = nextDay.getFullYear(); + guess.month = nextDay.getMonth() + 1; + guess.day = nextDay.getDate(); + } + } + // and ambiguous times + } else if (wMinute.length != 0) { + pos = isTask == true ? 0 : wMinute.length - 1; + guess.hour = wMinute[pos].hour; + guess.minute = wMinute[pos].minute; + if (guess.day == null || guess.day == start.day) { + if (wMinute[pos].hour < start.hour || + (wMinute[pos].hour == start.hour && + wMinute[pos].minute < start.minute)) { + let nextDay = new Date(start.year, start.month - 1, start.day); + nextDay.setTime(nextDay.getTime() + 60 * 60 * 24 * 1000); + guess.year = nextDay.getFullYear(); + guess.month = nextDay.getMonth() + 1; + guess.day = nextDay.getDate(); + } + } + } + + // fill in date when time was guessed + if (guess.minute != null && guess.day == null) { + guess.year = start.year; + guess.month = start.month; + guess.day = start.day; + } + + // fill in end from total duration + if (guess.day == null && guess.hour == null) { + let duration = 0; + + for (let val in durations) { + duration += durations[val].duration; + cal.LOG("[calExtract] Dur: " + JSON.stringify(durations[val])); + } + + if (duration != 0) { + let startDate = new Date(start.year, start.month - 1, start.day); + if ("hour" in start) { + startDate.setHours(start.hour); + startDate.setMinutes(start.minute); + } else { + startDate.setHours(0); + startDate.setMinutes(0); + } + + let endTime = new Date(startDate.getTime() + duration * 60 * 1000); + guess.year = endTime.getFullYear(); + guess.month = endTime.getMonth() + 1; + guess.day = endTime.getDate(); + if (!(endTime.getHours() == 0 && endTime.getMinutes() == 0)) { + guess.hour = endTime.getHours(); + guess.minute = endTime.getMinutes(); + } + } + } + + // no zero or negative length events/tasks + let startTime = new Date(start.year || 0, start.month - 1 || 0, start.day || 0, + start.hour || 0, start.minute || 0).getTime(); + let guessTime = new Date(guess.year || 0, guess.month - 1 || 0, guess.day || 0, + guess.hour || 0, guess.minute || 0).getTime(); + if (guessTime <= startTime) { + guess.year = null; + guess.month = null; + guess.day = null; + guess.hour = null; + guess.minute = null; + } + + if (guess.year != null && guess.minute == null && isTask) { + guess.hour = 0; + guess.minute = 0; + } + + cal.LOG("[calExtract] End picked: " + JSON.stringify(guess)); + return guess; + } + }, + + getPatterns: function(name) { + let value; + try { + value = this.bundle.GetStringFromName(name); + if (value.trim() == "") { + cal.LOG("[calExtract] Pattern not found: " + name); + return this.defPattern; + } + + let vals = this.cleanPatterns(value).split("|"); + for (let idx = vals.length - 1; idx >= 0; idx--) { + if (vals[idx].trim() == "") { + vals.splice(idx, 1); + Components.utils.reportError("[calExtract] Faulty extraction pattern " + + value + " for " + name); + } + } + + if (this.overrides[name] !== undefined && + this.overrides[name].add !== undefined) { + let additions = this.overrides[name].add; + additions = this.cleanPatterns(additions).split("|"); + for (let pattern in additions) { + vals.push(additions[pattern]); + cal.LOG("[calExtract] Added " + additions[pattern] + " to " + name); + } + } + + if (this.overrides[name] !== undefined && + this.overrides[name].remove !== undefined) { + let removals = this.overrides[name].remove; + removals = this.cleanPatterns(removals).split("|"); + for (let pattern in removals) { + let idx = vals.indexOf(removals[pattern]); + if (idx != -1) { + vals.splice(idx, 1); + cal.LOG("[calExtract] Removed " + removals[pattern] + " from " + name); + } + } + } + + vals.sort((a, b) => b.length - a.length); + return vals.join("|"); + } catch (ex) { + cal.LOG("[calExtract] Pattern not found: " + name); + + // fake a value to avoid empty regexes creating endless loops + return this.defPattern; + } + }, + + getRepPatterns: function(name, replaceables) { + let alts = []; + let patterns = []; + + try { + let value = this.bundle.GetStringFromName(name); + if (value.trim() == "") { + cal.LOG("[calExtract] Pattern empty: " + name); + return alts; + } + + let vals = this.cleanPatterns(value).split("|"); + for (let idx = vals.length - 1; idx >= 0; idx--) { + if (vals[idx].trim() == "") { + vals.splice(idx, 1); + Components.utils.reportError("[calExtract] Faulty extraction pattern " + + value + " for " + name); + } + } + + if (this.overrides[name] !== undefined && + this.overrides[name].add !== undefined) { + let additions = this.overrides[name].add; + additions = this.cleanPatterns(additions).split("|"); + for (let pattern in additions) { + vals.push(additions[pattern]); + cal.LOG("[calExtract] Added " + additions[pattern] + " to " + name); + } + } + + if (this.overrides[name] !== undefined && + this.overrides[name].remove !== undefined) { + let removals = this.overrides[name].remove; + removals = this.cleanPatterns(removals).split("|"); + for (let pattern in removals) { + let idx = vals.indexOf(removals[pattern]); + if (idx != -1) { + vals.splice(idx, 1); + cal.LOG("[calExtract] Removed " + removals[pattern] + " from " + name); + } + } + } + + vals.sort((a, b) => b.length - a.length); + for (let val in vals) { + let pattern = vals[val]; + for (let cnt = 1; cnt <= replaceables.length; cnt++) { + pattern = pattern.split("#" + cnt).join(replaceables[cnt - 1]); + } + patterns.push(pattern); + } + + for (let val in vals) { + let positions = []; + if (replaceables.length == 1) { + positions[1] = 1; + } else { + positions = this.getPositionsFor(vals[val], name, replaceables.length); + } + alts[val] = { pattern: patterns[val], positions: positions }; + } + } catch (ex) { + cal.LOG("[calExtract] Pattern not found: " + name); + } + return alts; + }, + + getPositionsFor: function(str, name, count) { + let positions = []; + let re = /#(\d)/g; + let match; + let i = 0; + while ((match = re.exec(str))) { + i++; + positions[parseInt(match[1], 10)] = i; + } + + // correctness checking + for (i = 1; i <= count; i++) { + if (positions[i] === undefined) { + Components.utils.reportError("[calExtract] Faulty extraction pattern " + name + + ", missing parameter #" + i); + } + } + return positions; + }, + + cleanPatterns: function(pattern) { + // remove whitespace around | if present + let value = pattern.replace(/\s*\|\s*/g, "|"); + // allow matching for patterns with missing or excessive whitespace + return this.sanitize(value).replace(/\s+/g, "\\s*"); + }, + + isValidYear: function(year) { + return (year >= 2000 && year <= 2050); + }, + + isValidMonth: function(month) { + return (month >= 1 && month <= 12); + }, + + isValidDay: function(day) { + return (day >= 1 && day <= 31); + }, + + isValidHour: function(hour) { + return (hour >= 0 && hour <= 23); + }, + + isValidMinute: function(minute) { + return (minute >= 0 && minute <= 59); + }, + + isPastDate: function(date, referenceDate) { + // avoid changing original refDate + let refDate = new Date(referenceDate.getTime()); + refDate.setHours(0); + refDate.setMinutes(0); + refDate.setSeconds(0); + refDate.setMilliseconds(0); + let jsDate; + if (date.day != null) { + jsDate = new Date(date.year, date.month - 1, date.day); + } + return jsDate < refDate; + }, + + normalizeHour: function(hour) { + if (hour < this.dayStart && hour <= 11) { + return hour + 12; + } + return hour; + }, + + normalizeYear: function(year) { + return (year.length == 2) ? "20" + year : year; + }, + + limitNums: function(res, email) { + let pattern = email.substring(res.index, res.index + res[0].length); + let before = email.charAt(res.index - 1); + let after = email.charAt(res.index + res[0].length); + let result = (/\d/.exec(before) && /\d/.exec(pattern.charAt(0))) || + (/\d/.exec(pattern.charAt(pattern.length - 1)) && /\d/.exec(after)); + return result != null; + }, + + limitChars: function(res, email) { + let alphabet = this.getPatterns("alphabet"); + // for languages without regular alphabet surrounding characters are ignored + if (alphabet == this.defPattern) { + return false; + } + + let pattern = email.substring(res.index, res.index + res[0].length); + let before = email.charAt(res.index - 1); + let after = email.charAt(res.index + res[0].length); + + let re = new RegExp("[" + alphabet + "]"); + let result = (re.exec(before) && re.exec(pattern.charAt(0))) || + (re.exec(pattern.charAt(pattern.length - 1)) && re.exec(after)); + return result != null; + }, + + prefixSuffixStartEnd: function(res, relation, email) { + let pattern = email.substring(res.index, res.index + res[0].length); + let prev = email.substring(0, res.index); + let next = email.substring(res.index + res[0].length); + let prefixSuffix = { + start: res.index, + end: res.index + res[0].length, + pattern: pattern, + relation: relation + }; + let char = "\\s*"; + let psres; + + let re = new RegExp("(" + this.getPatterns("end.prefix") + ")" + char + "$", "ig"); + if ((psres = re.exec(prev)) != null) { + prefixSuffix.relation = "end"; + prefixSuffix.start = psres.index; + prefixSuffix.pattern = psres[0] + pattern; + } + + re = new RegExp("^" + char + "(" + this.getPatterns("end.suffix") + ")", "ig"); + if ((psres = re.exec(next)) != null) { + prefixSuffix.relation = "end"; + prefixSuffix.end = prefixSuffix.end + psres[0].length; + prefixSuffix.pattern = pattern + psres[0]; + } + + re = new RegExp("(" + this.getPatterns("start.prefix") + ")" + char + "$", "ig"); + if ((psres = re.exec(prev)) != null) { + prefixSuffix.relation = "start"; + prefixSuffix.start = psres.index; + prefixSuffix.pattern = psres[0] + pattern; + } + + re = new RegExp("^" + char + "(" + this.getPatterns("start.suffix") + ")", "ig"); + if ((psres = re.exec(next)) != null) { + prefixSuffix.relation = "start"; + prefixSuffix.end = prefixSuffix.end + psres[0].length; + prefixSuffix.pattern = pattern + psres[0]; + } + + re = new RegExp("\\s(" + this.getPatterns("no.datetime.prefix") + ")" + char + "$", "ig"); + + if ((psres = re.exec(prev)) != null) { + prefixSuffix.relation = "notadatetime"; + } + + re = new RegExp("^" + char + "(" + this.getPatterns("no.datetime.suffix") + ")", "ig"); + if ((psres = re.exec(next)) != null) { + prefixSuffix.relation = "notadatetime"; + } + + return prefixSuffix; + }, + + parseNumber: function(numberString, numbers) { + let number = parseInt(numberString, 10); + // number comes in as plain text, numbers are already adjusted for usage + // in regular expression + let cleanNumberString = this.cleanPatterns(numberString); + if (isNaN(number)) { + for (let i = 0; i <= 31; i++) { + let numberparts = numbers[i].split("|"); + if (numberparts.includes(cleanNumberString.toLowerCase())) { + return i; + } + } + return -1; + } else { + return number; + } + }, + + guess: function(year, month, day, hour, minute, start, end, str, + relation, pattern, ambiguous) { + let dateGuess = { + year: year, + month: month, + day: day, + hour: hour, + minute: minute, + start: start, + end: end, + str: str, + relation: relation, + pattern: pattern, + ambiguous: ambiguous + }; + + // past dates are kept for containment checks + if (this.isPastDate(dateGuess, this.now)) { + dateGuess.relation = "notadatetime"; + } + this.collected.push(dateGuess); + }, + + sanitize: function(str) { + return str.replace(/[-[\]{}()*+?.,\\^$]/g, "\\$&"); + }, + + unescape: function(str) { + return str.replace(/\\([\.])/g, "$1"); + } +}; diff --git a/calendar/base/modules/calHashedArray.jsm b/calendar/base/modules/calHashedArray.jsm new file mode 100644 index 000000000..4a53d6521 --- /dev/null +++ b/calendar/base/modules/calHashedArray.jsm @@ -0,0 +1,261 @@ +/* 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/. */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); + +var EXPORTED_SYMBOLS = ["cal"]; // even though it's defined in calUtils.jsm, import needs this + +/** + * An unsorted array of hashable items with some extra functions to quickly + * retrieve the item by its hash id. + * + * Performance Considerations: + * - Accessing items is fast + * - Adding items is fast (they are added to the end) + * - Deleting items is O(n) + * - Modifying items is fast. + */ +cal.HashedArray = function() { + this.clear(); +}; + +cal.HashedArray.prototype = { + mArray: null, + mHash: null, + + mBatch: 0, + mFirstDirty: -1, + + /** + * Returns a copy of the internal array. Note this is a shallow copy. + */ + get arrayCopy() { + return this.mArray.concat([]); + }, + + /** + * The function to retrieve the hashId given the item. This function can be + * overridden by implementations, in case the added items are not instances + * of calIItemBase. + * + * @param item The item to get the hashId for + * @return The hashId of the item + */ + hashAccessor: function(item) { + return item.hashId; + }, + + /** + * Returns the item, given its index in the array + * + * @param index The index of the item to retrieve. + * @return The retrieved item. + */ + itemByIndex: function(index) { + return this.mArray[index]; + }, + + /** + * Returns the item, given its hashId + * + * @param id The hashId of the item to retrieve. + * @return The retrieved item. + */ + itemById: function(id) { + if (this.mBatch > 0) { + throw "Accessing Array by ID not supported in batch mode "; + } + return (id in this.mHash ? this.mArray[this.mHash[id]] : null); + }, + + /** + * Returns the index of the given item. This function is cheap performance + * wise, since it uses the hash + * + * @param item The item to search for. + * @return The index of the item. + */ + indexOf: function(item) { + if (this.mBatch > 0) { + throw "Accessing Array Indexes not supported in batch mode"; + } + let hashId = this.hashAccessor(item); + return (hashId in this.mHash ? this.mHash[hashId] : -1); + }, + + /** + * Remove the item with the given hashId. + * + * @param id The id of the item to be removed + */ + removeById: function(id) { + if (this.mBatch > 0) { + throw "Remvoing by ID in batch mode is not supported"; /* TODO */ + } + let index = this.mHash[id]; + delete this.mHash[id]; + this.mArray.splice(index, 1); + this.reindex(index); + }, + + /** + * Remove the item at the given index. + * + * @param index The index of the item to remove. + */ + removeByIndex: function(index) { + delete this.mHash[this.hashAccessor(this.mArray[index])]; + this.mArray.splice(index, 1); + this.reindex(index); + }, + + /** + * Clear the whole array, removing all items. This also resets batch mode. + */ + clear: function() { + this.mHash = {}; + this.mArray = []; + this.mFirstDirty = -1; + this.mBatch = 0; + }, + + /** + * Add the item to the array + * + * @param item The item to add. + * @return The index of the added item. + */ + addItem: function(item) { + let index = this.mArray.length; + this.mArray.push(item); + this.reindex(index); + return index; + }, + + /** + * Modifies the item in the array. If the item is already in the array, then + * it is replaced by the passed item. Otherwise, the item is added to the + * array. + * + * @param item The item to modify. + * @return The (new) index. + */ + modifyItem: function(item) { + let hashId = this.hashAccessor(item); + if (hashId in this.mHash) { + let index = this.mHash[this.hashAccessor(item)]; + this.mArray[index] = item; + return index; + } else { + return this.addItem(item); + } + }, + + /** + * Reindexes the items in the array. This function is mostly used + * internally. All parameters are inclusive. The ranges are automatically + * swapped if from > to. + * + * @param from (optional) The index to start indexing from. If left + * out, defaults to 0. + * @param to (optional) The index to end indexing on. If left out, + * defaults to the array length. + */ + reindex: function(from, to) { + if (this.mArray.length == 0) { + return; + } + + from = (from === undefined ? 0 : from); + to = (to === undefined ? this.mArray.length - 1 : to); + + from = Math.min(this.mArray.length - 1, Math.max(0, from)); + to = Math.min(this.mArray.length - 1, Math.max(0, to)); + + if (from > to) { + let tmp = from; + from = to; + to = tmp; + } + + if (this.mBatch > 0) { + // No indexing in batch mode, but remember from where to index. + this.mFirstDirty = Math.min(Math.max(0, this.mFirstDirty), from); + return; + } + + for (let idx = from; idx <= to; idx++) { + this.mHash[this.hashAccessor(this.mArray[idx])] = idx; + } + }, + + startBatch: function() { + this.mBatch++; + }, + + endBatch: function() { + this.mBatch = Math.max(0, this.mBatch - 1); + + if (this.mBatch == 0 && this.mFirstDirty > -1) { + this.reindex(this.mFirstDirty); + this.mFirstDirty = -1; + } + }, + + /** + * Iterator to allow iterating the hashed array object. + */ + [Symbol.iterator]: function* () { + yield* this.mArray; + } +}; + +/** + * Sorted hashed array. The array always stays sorted. + * + * Performance Considerations: + * - Accessing items is fast + * - Adding and deleting items is O(n) + * - Modifying items is fast. + */ +cal.SortedHashedArray = function(comparator) { + cal.HashedArray.apply(this, arguments); + if (!comparator) { + throw "Sorted Hashed Array needs a comparator"; + } + this.mCompFunc = comparator; +}; + +cal.SortedHashedArray.prototype = { + __proto__: cal.HashedArray.prototype, + + mCompFunc: null, + + addItem: function(item) { + let newIndex = cal.binaryInsert(this.mArray, item, this.mCompFunc, false); + this.reindex(newIndex); + return newIndex; + }, + + modifyItem: function(item) { + let hashId = this.hashAccessor(item); + if (hashId in this.mHash) { + let cmp = this.mCompFunc(item, this.mArray[this.mHash[hashId]]); + if (cmp == 0) { + // The item will be at the same index, we just need to replace it + this.mArray[this.mHash[hashId]] = item; + return this.mHash[hashId]; + } else { + let oldIndex = this.mHash[hashId]; + + let newIndex = cal.binaryInsert(this.mArray, item, this.mCompFunc, false); + this.mArray.splice(oldIndex, 1); + this.reindex(oldIndex, newIndex); + return newIndex; + } + } else { + return this.addItem(item); + } + } +}; diff --git a/calendar/base/modules/calItemUtils.jsm b/calendar/base/modules/calItemUtils.jsm new file mode 100644 index 000000000..7d825b31c --- /dev/null +++ b/calendar/base/modules/calItemUtils.jsm @@ -0,0 +1,179 @@ +/* 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/. */ + +"use strict"; + +this.EXPORTED_SYMBOLS = ["itemDiff"]; + +Components.utils.import("resource://calendar/modules/calHashedArray.jsm"); + +/** + * Given two sets of items, find out which items were added, changed or + * removed. + * + * The general flow is to first use load/load1 methods to load the engine with + * the first set of items, then use difference/difference1 to load the set of + * items to diff against. Afterwards, call the complete method to tell the + * engine that no more items are coming. + * + * You can then access the mAddedItems/mModifiedItems/mDeletedItems attributes to + * get the items that were changed during the process. + */ +function itemDiff() { + this.reset(); +} + +itemDiff.prototype = { + STATE_INITIAL: 1, + STATE_LOADING: 2, + STATE_DIFFERING: 4, + STATE_COMPLETED: 8, + + state: 1, + mInitialItems: null, + + mModifiedItems: null, + mModifiedOldItems: null, + mAddedItems: null, + mDeletedItems: null, + + /** + * Expect the difference engine to be in the given state. + * + * @param aState The state to be in + * @param aMethod The method name expecting the state + */ + _expectState: function(aState, aMethod) { + if ((this.state & aState) == 0) { + throw new Error("itemDiff method " + aMethod + + " called while in unexpected state " + this.state); + } + }, + + /** + * Load the difference engine with one item, see load. + * + * @param item The item to load + */ + load1: function(item) { + this.load([item]); + }, + + /** + * Loads an array of items. This step cannot be executed + * after calling the difference methods. + * + * @param items The array of items to load + */ + load: function(items) { + this._expectState(this.STATE_INITIAL | this.STATE_LOADING, "load"); + + for (let item of items) { + this.mInitialItems[item.hashId] = item; + } + + this.state = this.STATE_LOADING; + }, + + /** + * Calculates the difference for the passed item, see difference. + * + * @param item The item to calculate difference with + */ + difference1: function(item) { + this.difference([item]); + }, + + /** + * Calculate the difference for the array of items. This method should be + * called after all load methods and before the complete method. + * + * @param items The array of items to calculate difference with + */ + difference: function(items) { + this._expectState(this.STATE_INITIAL | this.STATE_LOADING | this.STATE_DIFFERING, "difference"); + + this.mModifiedOldItems.startBatch(); + this.mModifiedItems.startBatch(); + this.mAddedItems.startBatch(); + + for (let item of items) { + if (item.hashId in this.mInitialItems) { + let oldItem = this.mInitialItems[item.hashId]; + this.mModifiedOldItems.addItem(oldItem); + this.mModifiedItems.addItem(item); + } else { + this.mAddedItems.addItem(item); + } + delete this.mInitialItems[item.hashId]; + } + + this.mModifiedOldItems.endBatch(); + this.mModifiedItems.endBatch(); + this.mAddedItems.endBatch(); + + this.state = this.STATE_DIFFERING; + }, + + /** + * Tell the engine that all load and difference calls have been made, this + * makes sure that all item states are correctly returned. + */ + complete: function() { + this._expectState(this.STATE_INITIAL | this.STATE_LOADING | this.STATE_DIFFERING, "complete"); + + this.mDeletedItems.startBatch(); + + for (let hashId in this.mInitialItems) { + let item = this.mInitialItems[hashId]; + this.mDeletedItems.addItem(item); + } + + this.mDeletedItems.endBatch(); + this.mInitialItems = {}; + + this.state = this.STATE_COMPLETED; + }, + + /** @return a HashedArray containing the new version of the modified items */ + get modifiedItems() { + this._expectState(this.STATE_COMPLETED, "get modifiedItems"); + return this.mModifiedItems; + }, + + /** @return a HashedArray containing the old version of the modified items */ + get modifiedOldItems() { + this._expectState(this.STATE_COMPLETED, "get modifiedOldItems"); + return this.mModifiedOldItems; + }, + + /** @return a HashedArray containing added items */ + get addedItems() { + this._expectState(this.STATE_COMPLETED, "get addedItems"); + return this.mAddedItems; + }, + + /** @return a HashedArray containing deleted items */ + get deletedItems() { + this._expectState(this.STATE_COMPLETED, "get deletedItems"); + return this.mDeletedItems; + }, + + /** @return the number of loaded items */ + get count() { + return Object.keys(this.mInitialItems).length; + }, + + /** + * Resets the difference engine to its initial state. + */ + reset: function() { + this.mInitialItems = {}; + this.mModifiedItems = new cal.HashedArray(); + this.mModifiedOldItems = new cal.HashedArray(); + this.mAddedItems = new cal.HashedArray(); + this.mDeletedItems = new cal.HashedArray(); + this.state = this.STATE_INITIAL; + } +}; diff --git a/calendar/base/modules/calIteratorUtils.jsm b/calendar/base/modules/calIteratorUtils.jsm new file mode 100644 index 000000000..f036dfe20 --- /dev/null +++ b/calendar/base/modules/calIteratorUtils.jsm @@ -0,0 +1,190 @@ +/* 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/. */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); +Components.utils.import("resource://gre/modules/Preferences.jsm"); + +this.EXPORTED_SYMBOLS = ["cal"]; // even though it's defined in calUtils.jsm, import needs this + +/** + * Iterates an array of items, i.e. the passed item including all + * overridden instances of a recurring series. + * + * @param items array of items + */ +cal.itemIterator = function* (items) { + for (let item of items) { + yield item; + let rec = item.recurrenceInfo; + if (rec) { + for (let exid of rec.getExceptionIds({})) { + yield rec.getExceptionFor(exid); + } + } + } +}; + +/** + * Runs the body() function once for each item in the iterator using the event + * queue to make sure other actions could run inbetween. When all iterations are + * done (and also when cal.forEach.BREAK is returned), calls the completed() + * function if passed. + * + * If you would like to break or continue inside the body(), return either + * cal.forEach.BREAK or cal.forEach.CONTINUE + * + * Note since the event queue is used, this function will return immediately, + * before the iteration is complete. If you need to run actions after the real + * for each loop, use the optional completed() function. + * + * @param iter The Iterator or the plain Object to go through in this + * loop. + * @param body The function called for each iteration. Its parameter is + * the single item from the iterator. + * @param completed [optional] The function called after the loop completes. + */ +cal.forEach = function(iterable, body, completed) { + // This should be a const one day, lets keep it a pref for now though until we + // find a sane value. + let LATENCY = Preferences.get("calendar.threading.latency", 250); + + if (typeof iterable == "object" && !iterable[Symbol.iterator]) { + iterable = Object.entries(iterable); + } + + let ourIter = iterable[Symbol.iterator](); + let currentThread = Services.tm.currentThread; + + // This is our dispatcher, it will be used for the iterations + let dispatcher = { + run: function() { + let startTime = (new Date()).getTime(); + while (((new Date()).getTime() - startTime) < LATENCY) { + let next = ourIter.next(); + let done = next.done; + + if (!done) { + let rc = body(next.value); + if (rc == cal.forEach.BREAK) { + done = true; + } + } + + if (done) { + if (completed) { + completed(); + } + return; + } + } + + currentThread.dispatch(this, currentThread.DISPATCH_NORMAL); + } + }; + + currentThread.dispatch(dispatcher, currentThread.DISPATCH_NORMAL); +}; + +cal.forEach.CONTINUE = 1; +cal.forEach.BREAK = 2; + +/** + * "ical" namespace. Used for all iterators (and possibly other functions) that + * are related to libical. + */ +cal.ical = { + /** + * Yields all subcomponents in all calendars in the passed component. + * - If the passed component is an XROOT (contains multiple calendars), + * then go through all VCALENDARs in it and get their subcomponents. + * - If the passed component is a VCALENDAR, iterate through its direct + * subcomponents. + * - Otherwise assume the passed component is the item itself and yield + * only the passed component. + * + * This iterator can only be used in a for..of block: + * for (let component of cal.ical.calendarComponentIterator(aComp)) { ... } + * + * @param aComponent The component to iterate given the above rules. + * @param aCompType The type of item to iterate. + * @return The iterator that yields all items. + */ + calendarComponentIterator: function* (aComponent, aCompType) { + let compType = (aCompType || "ANY"); + if (aComponent && aComponent.componentType == "VCALENDAR") { + yield* cal.ical.subcomponentIterator(aComponent, compType); + } else if (aComponent && aComponent.componentType == "XROOT") { + for (let calComp of cal.ical.subcomponentIterator(aComponent, "VCALENDAR")) { + yield* cal.ical.subcomponentIterator(calComp, compType); + } + } else if (aComponent && (compType == "ANY" || compType == aComponent.componentType)) { + yield aComponent; + } + }, + + /** + * Use to iterate through all subcomponents of a calIIcalComponent. This + * iterators depth is 1, this means no sub-sub-components will be iterated. + * + * This iterator can only be used in a for() block: + * for (let component in cal.ical.subcomponentIterator(aComp)) { ... } + * + * @param aComponent The component who's subcomponents to iterate. + * @param aSubcomp (optional) the specific subcomponent to + * enumerate. If not given, "ANY" will be used. + * @return An iterator object to iterate the properties. + */ + subcomponentIterator: function* (aComponent, aSubcomp) { + let subcompName = (aSubcomp || "ANY"); + for (let subcomp = aComponent.getFirstSubcomponent(subcompName); + subcomp; + subcomp = aComponent.getNextSubcomponent(subcompName)) { + yield subcomp; + } + }, + + /** + * Use to iterate through all properties of a calIIcalComponent. + * This iterator can only be used in a for() block: + * for (let property in cal.ical.propertyIterator(aComp)) { ... } + * + * @param aComponent The component to iterate. + * @param aProperty (optional) the specific property to enumerate. + * If not given, "ANY" will be used. + * @return An iterator object to iterate the properties. + */ + propertyIterator: function* (aComponent, aProperty) { + let propertyName = (aProperty || "ANY"); + for (let prop = aComponent.getFirstProperty(propertyName); + prop; + prop = aComponent.getNextProperty(propertyName)) { + yield prop; + } + }, + + /** + * Use to iterate through all parameters of a calIIcalProperty. + * This iterator behaves similar to the object iterator. Possible uses: + * for (let paramName in cal.ical.paramIterator(prop)) { ... } + * or: + * for (let [paramName, paramValue] of cal.ical.paramIterator(prop)) { ... } + * + * @param aProperty The property to iterate. + * @return An iterator object to iterate the properties. + */ + paramIterator: function* (aProperty) { + let paramSet = new Set(); + for (let paramName = aProperty.getFirstParameterName(); + paramName; + paramName = aProperty.getNextParameterName()) { + // Workaround to avoid infinite loop when the property + // contains duplicate parameters (bug 875739 for libical) + if (!paramSet.has(paramName)) { + yield [paramName, aProperty.getParameter(paramName)]; + paramSet.add(paramName); + } + } + } +}; diff --git a/calendar/base/modules/calItipUtils.jsm b/calendar/base/modules/calItipUtils.jsm new file mode 100644 index 000000000..04cfc14e0 --- /dev/null +++ b/calendar/base/modules/calItipUtils.jsm @@ -0,0 +1,1676 @@ +/* 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/. */ + +Components.utils.import("resource:///modules/mailServices.js"); +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://calendar/modules/calAlarmUtils.jsm"); +Components.utils.import("resource://calendar/modules/calIteratorUtils.jsm"); +Components.utils.import("resource://gre/modules/Preferences.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + +/** + * Scheduling and iTIP helper code + */ +this.EXPORTED_SYMBOLS = ["cal"]; // even though it's defined in calUtils.jsm, import needs this +cal.itip = { + /** + * Gets the sequence/revision number, either of the passed item or + * the last received one of an attendee; see + * <http://tools.ietf.org/html/draft-desruisseaux-caldav-sched-04#section-7.1>. + */ + getSequence: function(item) { + let seq = null; + + let wrappedItem = cal.wrapInstance(item, Components.interfaces.calIAttendee); + if (wrappedItem) { + seq = wrappedItem.getProperty("RECEIVED-SEQUENCE"); + } else if (item) { + // Unless the below is standardized, we store the last original + // REQUEST/PUBLISH SEQUENCE in X-MOZ-RECEIVED-SEQUENCE to test against it + // when updates come in: + seq = item.getProperty("X-MOZ-RECEIVED-SEQUENCE"); + if (seq === null) { + seq = item.getProperty("SEQUENCE"); + } + + // Make sure we don't have a pre Outlook 2007 appointment, but if we do + // use Microsoft's Sequence number. I <3 MS + if ((seq === null) || (seq == "0")) { + seq = item.getProperty("X-MICROSOFT-CDO-APPT-SEQUENCE"); + } + } + + if (seq === null) { + return 0; + } else { + seq = parseInt(seq, 10); + return (isNaN(seq) ? 0 : seq); + } + }, + + /** + * Gets the stamp date-time, either of the passed item or + * the last received one of an attendee; see + * <http://tools.ietf.org/html/draft-desruisseaux-caldav-sched-04#section-7.2>. + */ + getStamp: function(item) { + let dtstamp = null; + + let wrappedItem = cal.wrapInstance(item, Components.interfaces.calIAttendee); + if (wrappedItem) { + let stamp = wrappedItem.getProperty("RECEIVED-DTSTAMP"); + if (stamp) { + dtstamp = cal.createDateTime(stamp); + } + } else if (item) { + // Unless the below is standardized, we store the last original + // REQUEST/PUBLISH DTSTAMP in X-MOZ-RECEIVED-DTSTAMP to test against it + // when updates come in: + let stamp = item.getProperty("X-MOZ-RECEIVED-DTSTAMP"); + if (stamp) { + dtstamp = cal.createDateTime(stamp); + } else { + // xxx todo: are there similar X-MICROSOFT-CDO properties to be considered here? + dtstamp = item.stampTime; + } + } + + return dtstamp; + }, + + /** + * Compares sequences and/or stamps of two items + * + * @param {calIEvent|calIToDo|calIAttendee} aItem1 + * @param {calIEvent|calIToDo|calIAttendee} aItem2 + * @return {Integer} +1 if item2 is newer, -1 if item1 is newer or 0 if both are equal + */ + compare: function(aItem1, aItem2) { + let comp = cal.itip.compareSequence(aItem1, aItem2); + if (comp == 0) { + comp = cal.itip.compareStamp(aItem1, aItem2); + } + return comp; + }, + + /** + * Compares sequences of two items + * + * @param {calIEvent|calIToDo|calIAttendee} aItem1 + * @param {calIEvent|calIToDo|calIAttendee} aItem2 + * @return {Integer} +1 if item2 is newer, -1 if item1 is newer or 0 if both are equal + */ + compareSequence: function(aItem1, aItem2) { + let seq1 = cal.itip.getSequence(aItem1); + let seq2 = cal.itip.getSequence(aItem2); + if (seq1 > seq2) { + return 1; + } else if (seq1 < seq2) { + return -1; + } else { + return 0; + } + }, + + /** + * Compares stamp of two items + * + * @param {calIEvent|calIToDo|calIAttendee} aItem1 + * @param {calIEvent|calIToDo|calIAttendee} aItem2 + * @return {Integer} +1 if item2 is newer, -1 if item1 is newer or 0 if both are equal + */ + compareStamp: function(aItem1, aItem2) { + let st1 = cal.itip.getStamp(aItem1); + let st2 = cal.itip.getStamp(aItem2); + if (st1 && st2) { + return st1.compare(st2); + } else if (!st1 && st2) { + return -1; + } else if (st1 && !st2) { + return 1; + } else { + return 0; + } + }, + + /** + * Checks if the given calendar is a scheduling calendar. This means it + * needs an organizer id and an itip transport. It should also be writable. + * + * @param calendar The calendar to check + * @return True, if its a scheduling calendar. + */ + isSchedulingCalendar: function(calendar) { + return cal.isCalendarWritable(calendar) && + calendar.getProperty("organizerId") && + calendar.getProperty("itip.transport"); + }, + + /** + * Scope: iTIP message receiver + * + * Given an nsIMsgDBHdr and an imipMethod, set up the given itip item. + * + * @param itipItem The item to set up + * @param imipMethod The received imip method + * @param aMsgHdr Information about the received email + */ + initItemFromMsgData: function(itipItem, imipMethod, aMsgHdr) { + // set the sender of the itip message + itipItem.sender = cal.itip.getMessageSender(aMsgHdr); + + // Get the recipient identity and save it with the itip item. + itipItem.identity = cal.itip.getMessageRecipient(aMsgHdr); + + // We are only called upon receipt of an invite, so ensure that isSend + // is false. + itipItem.isSend = false; + + // XXX Get these from preferences + itipItem.autoResponse = Components.interfaces.calIItipItem.USER; + + if (imipMethod && imipMethod.length != 0 && imipMethod.toLowerCase() != "nomethod") { + itipItem.receivedMethod = imipMethod.toUpperCase(); + } else { // There is no METHOD in the content-type header (spec violation). + // Fall back to using the one from the itipItem's ICS. + imipMethod = itipItem.receivedMethod; + } + cal.LOG("iTIP method: " + imipMethod); + + let isWritableCalendar = function(aCalendar) { + /* TODO: missing ACL check for existing items (require callback API) */ + return cal.itip.isSchedulingCalendar(aCalendar) && + cal.userCanAddItemsToCalendar(aCalendar); + }; + + let writableCalendars = cal.getCalendarManager().getCalendars({}).filter(isWritableCalendar); + if (writableCalendars.length > 0) { + let compCal = Components.classes["@mozilla.org/calendar/calendar;1?type=composite"] + .createInstance(Components.interfaces.calICompositeCalendar); + writableCalendars.forEach(compCal.addCalendar, compCal); + itipItem.targetCalendar = compCal; + } + }, + + /** + * Scope: iTIP message receiver + * + * Gets the suggested text to be shown when an imip item has been processed. + * This text is ready localized and can be displayed to the user. + * + * @param aStatus The status of the processing (i.e NS_OK, an error code) + * @param aOperationType An operation type from calIOperationListener + * @return The suggested text. + */ + getCompleteText: function(aStatus, aOperationType) { + function _gs(strName, param) { + return cal.calGetString("lightning", strName, param, "lightning"); + } + + let text = ""; + const cIOL = Components.interfaces.calIOperationListener; + if (Components.isSuccessCode(aStatus)) { + switch (aOperationType) { + case cIOL.ADD: text = _gs("imipAddedItemToCal2"); break; + case cIOL.MODIFY: text = _gs("imipUpdatedItem2"); break; + case cIOL.DELETE: text = _gs("imipCanceledItem2"); break; + } + } else { + text = _gs("imipBarProcessingFailed", [aStatus.toString(16)]); + } + return text; + }, + + /** + * Scope: iTIP message receiver + * + * Gets a text describing the given itip method. The text is of the form + * "This Message contains a ... ". + * + * @param method The method to describe. + * @return The localized text about the method. + */ + getMethodText: function(method) { + function _gs(strName) { + return cal.calGetString("lightning", strName, null, "lightning"); + } + + switch (method) { + case "REFRESH": return _gs("imipBarRefreshText"); + case "REQUEST": return _gs("imipBarRequestText"); + case "PUBLISH": return _gs("imipBarPublishText"); + case "CANCEL": return _gs("imipBarCancelText"); + case "REPLY": return _gs("imipBarReplyText"); + case "COUNTER": return _gs("imipBarCounterText"); + case "DECLINECOUNTER": return _gs("imipBarDeclineCounterText"); + default: + cal.ERROR("Unknown iTIP method: " + method); + return _gs("imipBarUnsupportedText"); + } + }, + + /** + * Scope: iTIP message receiver + * + * Gets localized toolbar label about the message state and triggers buttons to show. + * This returns a JS object with the following structure: + * + * { + * label: "This is a desciptive text about the itip item", + * buttons: ["imipXXXButton", ...], + * hideMenuItem: ["imipXXXButton_Option", ...] + * } + * + * @see processItipItem This takes the same parameters as its optionFunc. + * @param itipItem The itipItem to query. + * @param rc The result of retrieving the item + * @param actionFunc The action function. + */ + getOptionsText: function(itipItem, rc, actionFunc, foundItems) { + function _gs(strName) { + return cal.calGetString("lightning", strName, null, "lightning"); + } + let imipLabel = null; + if (itipItem.receivedMethod) { + imipLabel = cal.itip.getMethodText(itipItem.receivedMethod); + } + let data = { label: imipLabel, buttons: [], hideMenuItems: [] }; + + let disallowedCounter = false; + if (foundItems && foundItems.length) { + let disallow = foundItems[0].getProperty("X-MICROSOFT-DISALLOW-COUNTER"); + disallowedCounter = disallow && disallow == "TRUE"; + } + if (rc == Components.interfaces.calIErrors.CAL_IS_READONLY) { + // No writable calendars, tell the user about it + data.label = _gs("imipBarNotWritable"); + } else if (Components.isSuccessCode(rc) && !actionFunc) { + // This case, they clicked on an old message that has already been + // added/updated, we want to tell them that. + data.label = _gs("imipBarAlreadyProcessedText"); + if (foundItems && foundItems.length) { + data.buttons.push("imipDetailsButton"); + if (itipItem.receivedMethod == "COUNTER" && itipItem.sender) { + if (disallowedCounter) { + data.label = _gs("imipBarDisallowedCounterText"); + } else { + let comparison; + for (let item of itipItem.getItemList({})) { + let attendees = cal.getAttendeesBySender( + item.getAttendees({}), + itipItem.sender + ); + if (attendees.length == 1) { + let replyer = foundItems[0].getAttendeeById(attendees[0].id); + comparison = cal.itip.compareSequence(item, foundItems[0]); + if (comparison == 1) { + data.label = _gs("imipBarCounterErrorText"); + break; + } else if (comparison == -1) { + data.label = _gs("imipBarCounterPreviousVersionText"); + } + } + } + } + } + } else if (itipItem.receivedMethod == "REPLY") { + // The item has been previously removed from the available calendars or the calendar + // containing the item is not available + let delmgr = Components.classes["@mozilla.org/calendar/deleted-items-manager;1"] + .getService(Components.interfaces.calIDeletedItems); + let delTime = null; + let items = itipItem.getItemList({}); + if (items && items.length) { + delTime = delmgr.getDeletedDate(items[0].id); + } + if (delTime) { + data.label = _gs("imipBarReplyToRecentlyRemovedItem", [delTime.toString()]); + } else { + data.label = _gs("imipBarReplyToNotExistingItem"); + } + } else if (itipItem.receivedMethod == "DECLINECOUNTER") { + data.label = _gs("imipBarDeclineCounterText"); + } + } else if (Components.isSuccessCode(rc)) { + cal.LOG("iTIP options on: " + actionFunc.method); + switch (actionFunc.method) { + case "PUBLISH:UPDATE": + case "REQUEST:UPDATE-MINOR": + data.label = _gs("imipBarUpdateText"); + // falls through + case "REPLY": + data.buttons.push("imipUpdateButton"); + break; + case "PUBLISH": + data.buttons.push("imipAddButton"); + break; + case "REQUEST:UPDATE": + case "REQUEST:NEEDS-ACTION": + case "REQUEST": { + if (actionFunc.method == "REQUEST:UPDATE") { + data.label = _gs("imipBarUpdateText"); + } else if (actionFunc.method == "REQUEST:NEEDS-ACTION") { + data.label = _gs("imipBarProcessedNeedsAction"); + } + + let isRecurringMaster = false; + for (let item of itipItem.getItemList({})) { + if (item.recurrenceInfo) { + isRecurringMaster = true; + } + } + if (itipItem.getItemList({}).length > 1 || isRecurringMaster) { + data.buttons.push("imipAcceptRecurrencesButton"); + data.buttons.push("imipDeclineRecurrencesButton"); + } else { + data.buttons.push("imipAcceptButton"); + data.buttons.push("imipDeclineButton"); + } + data.buttons.push("imipMoreButton"); + // Use data.hideMenuItems.push("idOfMenuItem") to hide specific menuitems + // from the dropdown menu of a button. This might be useful to to remove + // a generally available option for a specific invitation, because the + // respective feature is not available for the calendar, the invitation + // is in or the feature is prohibited by the organizer + break; + } + case "CANCEL": { + data.buttons.push("imipDeleteButton"); + break; + } + case "REFRESH": { + data.buttons.push("imipReconfirmButton"); + break; + } + case "COUNTER": { + if (disallowedCounter) { + data.label = _gs("imipBarDisallowedCounterText"); + } + data.buttons.push("imipDeclineCounterButton"); + data.buttons.push("imipRescheduleButton"); + break; + } + default: + data.label = _gs("imipBarUnsupportedText"); + break; + } + } else { + data.label = _gs("imipBarUnsupportedText"); + } + + return data; + }, + + /** + * Scope: iTIP message receiver + * Retrieves the message sender. + * + * @param {nsIMsgHdr} aMsgHdr The message header to check. + * @return The email address of the intended recipient. + */ + getMessageSender: function(aMsgHdr) { + let author = (aMsgHdr && aMsgHdr.author) || ""; + let compFields = Components.classes["@mozilla.org/messengercompose/composefields;1"] + .createInstance(Components.interfaces.nsIMsgCompFields); + let addresses = compFields.splitRecipients(author, true, {}); + if (addresses.length != 1) { + cal.LOG("No unique email address for lookup in message.\r\n" + cal.STACK(20)); + } + return addresses[0] || null; + }, + + /** + * Scope: iTIP message receiver + * + * Retrieves the intended recipient for this message. + * + * @param aMsgHdr The message to check. + * @return The email of the intended recipient. + */ + getMessageRecipient: function(aMsgHdr) { + if (!aMsgHdr) { + return null; + } + + let identities; + let actMgr = MailServices.accounts; + if (aMsgHdr.accountKey) { + // First, check if the message has an account key. If so, we can use the + // account identities to find the correct recipient + identities = actMgr.getAccount(aMsgHdr.accountKey).identities; + } else if (aMsgHdr.folder) { + // Without an account key, we have to revert back to using the server + identities = actMgr.getIdentitiesForServer(aMsgHdr.folder.server); + } + + let emailMap = {}; + if (!identities || identities.length == 0) { + let identity; + // If we were not able to retrieve identities above, then we have no + // choice but to revert to the default identity. + let defaultAccount = actMgr.defaultAccount; + if (defaultAccount) { + identity = defaultAccount.defaultIdentity; + } + if (!identity) { + // If there isn't a default identity (i.e Local Folders is your + // default identity), then go ahead and use the first available + // identity. + let allIdentities = actMgr.allIdentities; + if (allIdentities.length > 0) { + identity = allIdentities.queryElementAt(0, Components.interfaces.nsIMsgIdentity); + } else { + // If there are no identities at all, we cannot get a recipient. + return null; + } + } + emailMap[identity.email.toLowerCase()] = true; + } else { + // Build a map of usable email addresses + for (let i = 0; i < identities.length; i++) { + let identity = identities.queryElementAt(i, Components.interfaces.nsIMsgIdentity); + emailMap[identity.email.toLowerCase()] = true; + } + } + + // First check the recipient list + let toList = MailServices.headerParser.makeFromDisplayAddress(aMsgHdr.recipients || ""); + for (let recipient of toList) { + if (recipient.email.toLowerCase() in emailMap) { + // Return the first found recipient + return recipient; + } + } + + // Maybe we are in the CC list? + let ccList = MailServices.headerParser.makeFromDisplayAddress(aMsgHdr.ccList || ""); + for (let recipient of ccList) { + if (recipient.email.toLowerCase() in emailMap) { + // Return the first found recipient + return recipient; + } + } + + // Hrmpf. Looks like delegation or maybe Bcc. + return null; + }, + + /** + * Scope: iTIP message receiver + * + * Prompt for the target calendar, if needed for the given method. This + * calendar will be set on the passed itip item. + * + * @param aMethod The method to check. + * @param aItipItem The itip item to set the target calendar on. + * @param aWindow The window to open the dialog on. + * @return True, if a calendar was selected or no selection is + * needed. + */ + promptCalendar: function(aMethod, aItipItem, aWindow) { + let needsCalendar = false; + let targetCalendar = null; + switch (aMethod) { + // methods that don't require the calendar chooser: + case "REFRESH": + case "REQUEST:UPDATE": + case "REQUEST:UPDATE-MINOR": + case "PUBLISH:UPDATE": + case "REPLY": + case "CANCEL": + case "COUNTER": + case "DECLINECOUNTER": + needsCalendar = false; + break; + default: + needsCalendar = true; + break; + } + + if (needsCalendar) { + let calendars = cal.getCalendarManager().getCalendars({}).filter(cal.itip.isSchedulingCalendar); + + if (aItipItem.receivedMethod == "REQUEST") { + // try to further limit down the list to those calendars that + // are configured to a matching attendee; + let item = aItipItem.getItemList({})[0]; + let matchingCals = calendars.filter(calendar => cal.getInvitedAttendee(item, calendar) != null); + // if there's none, we will show the whole list of calendars: + if (matchingCals.length > 0) { + calendars = matchingCals; + } + } + + if (calendars.length == 0) { + let msg = cal.calGetString("lightning", "imipNoCalendarAvailable", null, "lightning"); + aWindow.alert(msg); + } else if (calendars.length == 1) { + // There's only one calendar, so it's silly to ask what calendar + // the user wants to import into. + targetCalendar = calendars[0]; + } else { + // Ask what calendar to import into + let args = {}; + args.calendars = calendars; + args.onOk = (aCal) => { targetCalendar = aCal; }; + args.promptText = cal.calGetString("calendar", "importPrompt"); + aWindow.openDialog("chrome://calendar/content/chooseCalendarDialog.xul", + "_blank", "chrome,titlebar,modal,resizable", args); + } + + if (targetCalendar) { + aItipItem.targetCalendar = targetCalendar; + } + } + + return !needsCalendar || targetCalendar != null; + }, + + /** + * Clean up after the given iTIP item. This needs to be called once for each + * time processItipItem is called. May be called with a null itipItem in + * which case it will do nothing. + * + * @param itipItem The iTIP item to clean up for. + */ + cleanupItipItem: function(itipItem) { + if (itipItem) { + let itemList = itipItem.getItemList({}); + if (itemList.length > 0) { + // Again, we can assume the id is the same over all items per spec + ItipItemFinderFactory.cleanup(itemList[0].id); + } + } + }, + + /** + * Scope: iTIP message receiver + * + * Checks the passed iTIP item and calls the passed function with options offered. + * Be sure to call cleanupItipItem at least once after calling this function. + * + * @param itipItem iTIP item + * @param optionsFunc function being called with parameters: itipItem, resultCode, actionFunc + * The action func has a property |method| showing the options: + * * REFRESH -- send the latest item (sent by attendee(s)) + * * PUBLISH -- initial publish, no reply (sent by organizer) + * * PUBLISH:UPDATE -- update of a published item (sent by organizer) + * * REQUEST -- initial invitation (sent by organizer) + * * REQUEST:UPDATE -- rescheduling invitation, has major change (sent by organizer) + * * REQUEST:UPDATE-MINOR -- update of invitation, minor change (sent by organizer) + * * REPLY -- invitation reply (sent by attendee(s)) + * * CANCEL -- invitation cancel (sent by organizer) + * * COUNTER -- counterproposal (sent by attendee) + * * DECLINECOUNTER -- denial of a counterproposal (sent by organizer) + */ + processItipItem: function(itipItem, optionsFunc) { + switch (itipItem.receivedMethod.toUpperCase()) { + case "REFRESH": + case "PUBLISH": + case "REQUEST": + case "CANCEL": + case "COUNTER": + case "DECLINECOUNTER": + case "REPLY": { + // Per iTIP spec (new Draft 4), multiple items in an iTIP message MUST have + // same ID, this simplifies our searching, we can just look for Item[0].id + let itemList = itipItem.getItemList({}); + if (!itipItem.targetCalendar) { + optionsFunc(itipItem, Components.interfaces.calIErrors.CAL_IS_READONLY); + } else if (itemList.length > 0) { + ItipItemFinderFactory.findItem(itemList[0].id, itipItem, optionsFunc); + } else if (optionsFunc) { + optionsFunc(itipItem, Components.results.NS_OK); + } + break; + } + default: { + if (optionsFunc) { + optionsFunc(itipItem, Components.results.NS_ERROR_NOT_IMPLEMENTED); + } + break; + } + } + }, + + /** + * Scope: iTIP message sender + * + * Checks to see if e.g. attendees were added/removed or an item has been + * deleted and sends out appropriate iTIP messages. + */ + checkAndSend: function(aOpType, aItem, aOriginalItem) { + // balance out parts of the modification vs delete confusion, deletion of occurrences + // are notified as parent modifications and modifications of occurrences are notified + // as mixed new-occurrence, old-parent (IIRC). + if (aOriginalItem && aItem.recurrenceInfo) { + if (aOriginalItem.recurrenceId && !aItem.recurrenceId) { + // sanity check: assure aItem doesn't refer to the master + aItem = aItem.recurrenceInfo.getOccurrenceFor(aOriginalItem.recurrenceId); + cal.ASSERT(aItem, "unexpected!"); + if (!aItem) { + return; + } + } + + if (aOriginalItem.recurrenceInfo && aItem.recurrenceInfo) { + // check whether the two differ only in EXDATEs + let clonedItem = aItem.clone(); + let exdates = []; + for (let ritem of clonedItem.recurrenceInfo.getRecurrenceItems({})) { + let wrappedRItem = cal.wrapInstance(ritem, Components.interfaces.calIRecurrenceDate); + if (ritem.isNegative && + wrappedRItem && + !aOriginalItem.recurrenceInfo.getRecurrenceItems({}).some((recitem) => { + let wrappedR = cal.wrapInstance(recitem, Components.interfaces.calIRecurrenceDate); + return recitem.isNegative && + wrappedR && + wrappedR.date.compare(wrappedRItem.date) == 0; + })) { + exdates.push(wrappedRItem); + } + } + if (exdates.length > 0) { + // check whether really only EXDATEs have been added: + let recInfo = clonedItem.recurrenceInfo; + exdates.forEach(recInfo.deleteRecurrenceItem, recInfo); + if (cal.compareItemContent(clonedItem, aOriginalItem)) { // transition into "delete occurrence(s)" + // xxx todo: support multiple + aItem = aOriginalItem.recurrenceInfo.getOccurrenceFor(exdates[0].date); + aOriginalItem = null; + aOpType = Components.interfaces.calIOperationListener.DELETE; + } + } + } + } + + let autoResponse = { value: false }; // controls confirm to send email only once + + let invitedAttendee = cal.isInvitation(aItem) && cal.getInvitedAttendee(aItem); + if (invitedAttendee) { // actually is an invitation copy, fix attendee list to send REPLY + /* We check if the attendee id matches one of of the + * userAddresses. If they aren't equal, it means that + * someone is accepting invitations on behalf of an other user. */ + if (aItem.calendar.aclEntry) { + let userAddresses = aItem.calendar.aclEntry.getUserAddresses({}); + if (userAddresses.length > 0 && + !cal.attendeeMatchesAddresses(invitedAttendee, userAddresses)) { + invitedAttendee = invitedAttendee.clone(); + invitedAttendee.setProperty("SENT-BY", "mailto:" + userAddresses[0]); + } + } + + if (aItem.organizer) { + let origInvitedAttendee = (aOriginalItem && aOriginalItem.getAttendeeById(invitedAttendee.id)); + + if (aOpType == Components.interfaces.calIOperationListener.DELETE) { + // in case the attendee has just deleted the item, we want to send out a DECLINED REPLY: + origInvitedAttendee = invitedAttendee; + invitedAttendee = invitedAttendee.clone(); + invitedAttendee.participationStatus = "DECLINED"; + } + + // We want to send a REPLY send if: + // - there has been a PARTSTAT change + // - in case of an organizer SEQUENCE bump we'd go and reconfirm our PARTSTAT + if (!origInvitedAttendee || + (origInvitedAttendee.participationStatus != invitedAttendee.participationStatus) || + (aOriginalItem && (cal.itip.getSequence(aItem) != cal.itip.getSequence(aOriginalItem)))) { + aItem = aItem.clone(); + aItem.removeAllAttendees(); + aItem.addAttendee(invitedAttendee); + // we remove X-MS-OLK-SENDER to avoid confusing Outlook 2007+ (w/o Exchange) + // about the notification sender (see bug 603933) + if (aItem.hasProperty("X-MS-OLK-SENDER")) { + aItem.deleteProperty("X-MS-OLK-SENDER"); + } + // if the event was delegated to the replying attendee, we may also notify also + // the delegator due to chapter 3.2.2.3. of RfC 5546 + let replyTo = []; + let delegatorIds = invitedAttendee.getProperty("DELEGATED-FROM"); + if (delegatorIds && + Preferences.get("calendar.itip.notifyDelegatorOnReply", false)) { + let getDelegator = function(aDelegatorId) { + let delegator = aOriginalItem.getAttendeeById(aDelegatorId); + if (delegator) { + replyTo.push(delegator); + } + }; + // Our backends currently do not support multi-value params. libical just + // swallows any value but the first, while ical.js fails to parse the item + // at all. Single values are handled properly by both backends though. + // Once bug 1206502 lands, ical.js will handle multi-value params, but + // we end up in different return types of getProperty. A native exposure of + // DELEGATED-FROM and DELEGATED-TO in calIAttendee may change this. + if (Array.isArray(delegatorIds)) { + for (let delegatorId of delegatorIds) { + getDelegator(delegatorId); + } + } else if (typeof delegatorIds == "string") { + getDelegator(delegatorIds); + } + } + replyTo.push(aItem.organizer); + sendMessage(aItem, "REPLY", replyTo, autoResponse); + } + } + return; + } + + if (aItem.getProperty("X-MOZ-SEND-INVITATIONS") != "TRUE") { // Only send invitations/cancellations + // if the user checked the checkbox + return; + } + + // special handling for invitation with event status cancelled + if (aItem.getAttendees({}).length > 0 && + aItem.getProperty("STATUS") == "CANCELLED") { + if (cal.itip.getSequence(aItem) > 0) { + // make sure we send a cancellation and not an request + aOpType = Components.interfaces.calIOperationListener.DELETE; + } else { + // don't send an invitation, if the event was newly created and has status cancelled + return; + } + } + + if (aOpType == Components.interfaces.calIOperationListener.DELETE) { + sendMessage(aItem, "CANCEL", aItem.getAttendees({}), autoResponse); + return; + } // else ADD, MODIFY: + + let originalAtt = (aOriginalItem ? aOriginalItem.getAttendees({}) : []); + let itemAtt = aItem.getAttendees({}); + let canceledAttendees = []; + let addedAttendees = []; + + if (itemAtt.length > 0 || originalAtt.length > 0) { + let attMap = {}; + for (let att of originalAtt) { + attMap[att.id.toLowerCase()] = att; + } + + for (let att of itemAtt) { + if (att.id.toLowerCase() in attMap) { + // Attendee was in original item. + delete attMap[att.id.toLowerCase()]; + } else { + // Attendee only in new item + addedAttendees.push(att); + } + } + + for (let id in attMap) { + let cancAtt = attMap[id]; + canceledAttendees.push(cancAtt); + } + } + + // setting default value to control for sending (cancellation) messages + // this will be set to false, once the user cancels sending manually + let sendOut = true; + // Check to see if some part of the item was updated, if so, re-send REQUEST + if (!aOriginalItem || (cal.itip.compare(aItem, aOriginalItem) > 0)) { // REQUEST + // check whether it's a simple UPDATE (no SEQUENCE change) or real (RE)REQUEST, + // in case of time or location/description change. + let isMinorUpdate = (aOriginalItem && (cal.itip.getSequence(aItem) == cal.itip.getSequence(aOriginalItem))); + + if (!isMinorUpdate || !cal.compareItemContent(stripUserData(aItem), stripUserData(aOriginalItem))) { + let requestItem = aItem.clone(); + if (!requestItem.organizer) { + requestItem.organizer = createOrganizer(requestItem.calendar); + } + + // Fix up our attendees for invitations using some good defaults + let recipients = []; + let reqItemAtt = requestItem.getAttendees({}); + if (!isMinorUpdate) { + requestItem.removeAllAttendees(); + } + for (let attendee of reqItemAtt) { + if (!isMinorUpdate) { + attendee = attendee.clone(); + if (!attendee.role) { + attendee.role = "REQ-PARTICIPANT"; + } + attendee.participationStatus = "NEEDS-ACTION"; + attendee.rsvp = "TRUE"; + requestItem.addAttendee(attendee); + } + recipients.push(attendee); + } + + // if send out should be limited to newly added attendees and no major + // props (attendee is not such) have changed, only the respective attendee + // is added to the recipient list while the attendee information in the + // ical is left to enable the new attendee to see who else is attending + // the event (if not prevented otherwise) + if (isMinorUpdate && + addedAttendees.length > 0 && + Preferences.get("calendar.itip.updateInvitationForNewAttendeesOnly", false)) { + recipients = addedAttendees; + } + + if (recipients.length > 0) { + sendOut = sendMessage(requestItem, "REQUEST", recipients, autoResponse); + } + } + } + + // Cancel the event for all canceled attendees + if (canceledAttendees.length > 0) { + let cancelItem = aOriginalItem.clone(); + cancelItem.removeAllAttendees(); + for (let att of canceledAttendees) { + cancelItem.addAttendee(att); + } + if (sendOut) { + sendMessage(cancelItem, "CANCEL", canceledAttendees, autoResponse); + } + } + }, + + /** + * Bumps the SEQUENCE in case of a major change; XXX todo may need more fine-tuning. + */ + prepareSequence: function(newItem, oldItem) { + if (cal.isInvitation(newItem)) { + return newItem; // invitation copies don't bump the SEQUENCE + } + + if (newItem.recurrenceId && !oldItem.recurrenceId && oldItem.recurrenceInfo) { + // XXX todo: there's still the bug that modifyItem is called with mixed occurrence/parent, + // find original occurrence + oldItem = oldItem.recurrenceInfo.getOccurrenceFor(newItem.recurrenceId); + cal.ASSERT(oldItem, "unexpected!"); + if (!oldItem) { + return newItem; + } + } + + let hashMajorProps = function(aItem) { + const majorProps = { + DTSTART: true, + DTEND: true, + DURATION: true, + DUE: true, + RDATE: true, + RRULE: true, + EXDATE: true, + STATUS: true, + LOCATION: true + }; + + let propStrings = []; + for (let item of cal.itemIterator([aItem])) { + for (let prop of cal.ical.propertyIterator(item.icalComponent)) { + if (prop.propertyName in majorProps) { + propStrings.push(item.recurrenceId + "#" + prop.icalString); + } + } + } + propStrings.sort(); + return propStrings.join(""); + }; + + let hash1 = hashMajorProps(newItem); + let hash2 = hashMajorProps(oldItem); + if (hash1 != hash2) { + newItem = newItem.clone(); + // bump SEQUENCE, it never decreases (mind undo scenario here) + newItem.setProperty("SEQUENCE", + String(Math.max(cal.itip.getSequence(oldItem), + cal.itip.getSequence(newItem)) + 1)); + } + + return newItem; + }, + + /** + * Returns a copy of an itipItem with modified properties and items build from scratch + * Use itipItem.clone() instead if only a simple copy is required + * + * @param {calIItipItem} aItipItem ItipItem to derive a new one from + * @param {Array} aItems calIEvent or calITodo items to be contained in the new itipItem + * @param {JsObject} aProps Properties to be different in the new itipItem + * @return {calIItipItem} + */ + getModifiedItipItem: function(aItipItem, aItems=[], aProps={}) { + let itipItem = Components.classes["@mozilla.org/calendar/itip-item;1"] + .createInstance(Components.interfaces.calIItipItem); + let serializedItems = ""; + for (let item of aItems) { + serializedItems += cal.getSerializedItem(item); + } + itipItem.init(serializedItems); + + itipItem.autoResponse = ("autoResponse" in aProps) ? aProps.autoResponse : aItipItem.autoResponse; + itipItem.identity = ("identity" in aProps) ? aProps.identity : aItipItem.identity; + itipItem.isSend = ("isSend" in aProps) ? aProps.isSend : aItipItem.isSend; + itipItem.localStatus = ("localStatus" in aProps) ? aProps.localStatus : aItipItem.localStatus; + itipItem.receivedMethod = ("receivedMethod" in aProps) ? aProps.receivedMethod : aItipItem.receivedMethod; + itipItem.responseMethod = ("responseMethod" in aProps) ? aProps.responseMethod : aItipItem.responseMethod; + itipItem.targetCalendar = ("targetCalendar" in aProps) ? aProps.targetCalendar : aItipItem.targetCalendar; + + return itipItem; + }, + + /** + * A shortcut to send DECLINECOUNTER messages - for everything else use cal.itip.checkAndSend + * + * @param aItem iTIP item to be sent + * @param aMethod iTIP method + * @param aRecipientsList an array of calIAttendee objects the message should be sent to + * @param aAutoResponse an inout object whether the transport should ask before sending + */ + sendDeclineCounterMessage: function(aItem, aMethod, aRecipientsList, aAutoResponse) { + if (aMethod == "DECLINECOUNTER") { + return sendMessage(aItem, aMethod, aRecipientsList, aAutoResponse); + } + } +}; + +/** local to this module file + * Sets the received info either on the passed attendee or item object. + * + * @param item either calIAttendee or calIItemBase + * @param itipItemItem received iTIP item + */ +function setReceivedInfo(item, itipItemItem) { + let wrappedItem = cal.wrapInstance(item, Components.interfaces.calIAttendee); + item.setProperty(wrappedItem ? "RECEIVED-SEQUENCE" + : "X-MOZ-RECEIVED-SEQUENCE", + String(cal.itip.getSequence(itipItemItem))); + let dtstamp = cal.itip.getStamp(itipItemItem); + if (dtstamp) { + item.setProperty(wrappedItem ? "RECEIVED-DTSTAMP" + : "X-MOZ-RECEIVED-DTSTAMP", + dtstamp.getInTimezone(cal.UTC()).icalString); + } +} + +/** + * Strips user specific data, e.g. categories and alarm settings and returns the stripped item. + */ +function stripUserData(item_) { + let item = item_.clone(); + let stamp = item.stampTime; + let lastModified = item.lastModifiedTime; + item.clearAlarms(); + item.alarmLastAck = null; + item.setCategories(0, []); + item.deleteProperty("RECEIVED-SEQUENCE"); + item.deleteProperty("RECEIVED-DTSTAMP"); + let propEnum = item.propertyEnumerator; + while (propEnum.hasMoreElements()) { + let prop = propEnum.getNext().QueryInterface(Components.interfaces.nsIProperty); + let pname = prop.name; + if (pname.substr(0, "X-MOZ-".length) == "X-MOZ-") { + item.deleteProperty(prop.name); + } + } + item.getAttendees({}).forEach((att) => { + att.deleteProperty("RECEIVED-SEQUENCE"); + att.deleteProperty("RECEIVED-DTSTAMP"); + }); + item.setProperty("DTSTAMP", stamp); + item.setProperty("LAST-MODIFIED", lastModified); // need to be last to undirty the item + return item; +} + +/** local to this module file + * Takes over relevant item information from iTIP item and sets received info. + * + * @param item the stored calendar item to update + * @param itipItemItem the received item + */ +function updateItem(item, itipItemItem) { + function updateUserData(newItem, oldItem) { + // preserve user settings: + newItem.generation = oldItem.generation; + newItem.clearAlarms(); + for (let alarm of oldItem.getAlarms({})) { + newItem.addAlarm(alarm); + } + newItem.alarmLastAck = oldItem.alarmLastAck; + let cats = oldItem.getCategories({}); + newItem.setCategories(cats.length, cats); + } + + let newItem = item.clone(); + newItem.icalComponent = itipItemItem.icalComponent; + setReceivedInfo(newItem, itipItemItem); + updateUserData(newItem, item); + + let recInfo = itipItemItem.recurrenceInfo; + if (recInfo) { + // keep care of installing all overridden items, and mind existing alarms, categories: + for (let rid of recInfo.getExceptionIds({})) { + let excItem = recInfo.getExceptionFor(rid).clone(); + cal.ASSERT(excItem, "unexpected!"); + let newExc = newItem.recurrenceInfo.getOccurrenceFor(rid).clone(); + newExc.icalComponent = excItem.icalComponent; + setReceivedInfo(newExc, itipItemItem); + let existingExcItem = item.recurrenceInfo && item.recurrenceInfo.getExceptionFor(rid); + if (existingExcItem) { + updateUserData(newExc, existingExcItem); + } + newItem.recurrenceInfo.modifyException(newExc, true); + } + } + + return newItem; +} + +/** local to this module file + * Copies the provider-specified properties from the itip item to the passed + * item. Special case property "METHOD" uses the itipItem's receivedMethod. + * + * @param itipItem The itip item containing the receivedMethod. + * @param itipItemItem The calendar item inside the itip item. + * @param item The target item to copy to. + */ +function copyProviderProperties(itipItem, itipItemItem, item) { + // Copy over itip properties to the item if requested by the provider + let copyProps = item.calendar.getProperty("itip.copyProperties") || []; + for (let prop of copyProps) { + if (prop == "METHOD") { + // Special case, this copies over the received method + item.setProperty("METHOD", itipItem.receivedMethod.toUpperCase()); + } else if (itipItemItem.hasProperty(prop)) { + // Otherwise just copy from the item contained in the itipItem + item.setProperty(prop, itipItemItem.getProperty(prop)); + } + } +} + +/** local to this module file + * Creates an organizer calIAttendee object based on the calendar's configured organizer id. + * + * @return calIAttendee object + */ +function createOrganizer(aCalendar) { + let orgId = aCalendar.getProperty("organizerId"); + if (!orgId) { + return null; + } + let organizer = cal.createAttendee(); + organizer.id = orgId; + organizer.commonName = aCalendar.getProperty("organizerCN"); + organizer.role = "REQ-PARTICIPANT"; + organizer.participationStatus = "ACCEPTED"; + organizer.isOrganizer = true; + return organizer; +} + +/** local to this module file + * Sends an iTIP message using the passed item's calendar transport. + * + * @param aItem iTIP item to be sent + * @param aMethod iTIP method + * @param aRecipientsList an array of calIAttendee objects the message should be sent to + * @param autoResponse an inout object whether the transport should ask before sending + */ +function sendMessage(aItem, aMethod, aRecipientsList, autoResponse) { + if (aRecipientsList.length == 0) { + return false; + } + let calendar = cal.wrapInstance(aItem.calendar, Components.interfaces.calISchedulingSupport); + if (calendar) { + if (calendar.QueryInterface(Components.interfaces.calISchedulingSupport) + .canNotify(aMethod, aItem)) { + // provider will handle that, so we return - we leave it also to the provider to + // deal with user canceled notifications (if possible), so set the return value + // to true as false would prevent any further notification within this cycle + return true; + } + } + + let aTransport = aItem.calendar.getProperty("itip.transport"); + if (!aTransport) { // can only send if there's a transport for the calendar + return false; + } + aTransport = aTransport.QueryInterface(Components.interfaces.calIItipTransport); + + let _sendItem = function(aSendToList, aSendItem) { + let cIII = Components.interfaces.calIItipItem; + let itipItem = Components.classes["@mozilla.org/calendar/itip-item;1"] + .createInstance(Components.interfaces.calIItipItem); + itipItem.init(cal.getSerializedItem(aSendItem)); + itipItem.responseMethod = aMethod; + itipItem.targetCalendar = aSendItem.calendar; + itipItem.autoResponse = autoResponse && autoResponse.value ? cIII.AUTO : cIII.USER; + if (autoResponse) { + autoResponse.value = true; // auto every following + } + // XXX I don't know whether the below are used at all, since we don't use the itip processor + itipItem.isSend = true; + + return aTransport.sendItems(aSendToList.length, aSendToList, itipItem); + }; + + // split up transport, if attendee undisclosure is requested + // and this is a message send by the organizer + if (aItem.getProperty("X-MOZ-SEND-INVITATIONS-UNDISCLOSED") == "TRUE" && + aMethod != "REPLY" && + aMethod != "REFRESH" && + aMethod != "COUNTER") { + for (let aRecipient of aRecipientsList) { + // create a list with a single recipient + let sendToList = [aRecipient]; + // remove other recipients from vevent attendee list + let sendItem = aItem.clone(); + sendItem.removeAllAttendees(); + sendItem.addAttendee(aRecipient); + // send message + if (!_sendItem(sendToList, sendItem)) { + return false; + } + } + return true; + } else { + return _sendItem(aRecipientsList, aItem); + } +} + +/** local to this module file + * An operation listener that is used on calendar operations which checks and sends further iTIP + * messages based on the calendar action. + * + * @param opListener operation listener to forward + * @param oldItem the previous item before modification (if any) + */ +function ItipOpListener(opListener, oldItem) { + this.mOpListener = opListener; + this.mOldItem = oldItem; +} +ItipOpListener.prototype = { + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calIOperationListener]), + onOperationComplete: function(aCalendar, aStatus, aOperationType, aId, aDetail) { + cal.ASSERT(Components.isSuccessCode(aStatus), "error on iTIP processing"); + if (Components.isSuccessCode(aStatus)) { + cal.itip.checkAndSend(aOperationType, aDetail, this.mOldItem); + } + if (this.mOpListener) { + this.mOpListener.onOperationComplete(aCalendar, + aStatus, + aOperationType, + aId, + aDetail); + } + }, + onGetResult: function(aCalendar, aStatus, aItemType, aDetail, aCount, aItems) { + } +}; + +/** local to this module file + * Add a parameter SCHEDULE-AGENT=CLIENT to the item before it is + * created or updated so that the providers knows scheduling will + * be handled by the client. + * + * @param item item about to be added or updated + * @param calendar calendar into which the item is about to be added or updated + */ +function addScheduleAgentClient(item, calendar) { + if (calendar.getProperty("capabilities.autoschedule.supported") === true) { + if (item.organizer) { + item.organizer.setProperty("SCHEDULE-AGENT", "CLIENT"); + } + } +} + +var ItipItemFinderFactory = { + /** Map to save finder instances for given ids */ + _findMap: {}, + + /** + * Create an item finder and track its progress. Be sure to clean up the + * finder for this id at some point. + * + * @param aId The item id to search for + * @param aItipItem The iTIP item used for processing + * @param aOptionsFunc The options function used for processing the found item + */ + findItem: function(aId, aItipItem, aOptionsFunc) { + this.cleanup(aId); + let finder = new ItipItemFinder(aId, aItipItem, aOptionsFunc); + this._findMap[aId] = finder; + finder.findItem(); + }, + + /** + * Clean up tracking for the given id. This needs to be called once for + * every time findItem is called. + * + * @param aId The item id to clean up for + */ + cleanup: function(aId) { + if (aId in this._findMap) { + let finder = this._findMap[aId]; + finder.destroy(); + delete this._findMap[aId]; + } + } +}; + +/** local to this module file + * An operation listener triggered by cal.itip.processItipItem() for lookup of the sent iTIP item's UID. + * + * @param itipItem sent iTIP item + * @param optionsFunc options func, see cal.itip.processItipItem() + */ +function ItipItemFinder(aId, itipItem, optionsFunc) { + this.mItipItem = itipItem; + this.mOptionsFunc = optionsFunc; + this.mSearchId = aId; +} + +ItipItemFinder.prototype = { + + QueryInterface: XPCOMUtils.generateQI([ + Components.interfaces.calIObserver, + Components.interfaces.calIOperationListener + ]), + + mSearchId: null, + mItipItem: null, + mOptionsFunc: null, + mFoundItems: null, + + findItem: function() { + this.mFoundItems = []; + this._unobserveChanges(); + this.mItipItem.targetCalendar.getItem(this.mSearchId, this); + }, + + _observeChanges: function(aCalendar) { + this._unobserveChanges(); + this.mObservedCalendar = aCalendar; + + if (this.mObservedCalendar) { + this.mObservedCalendar.addObserver(this); + } + }, + _unobserveChanges: function() { + if (this.mObservedCalendar) { + this.mObservedCalendar.removeObserver(this); + this.mObservedCalendar = null; + } + }, + + onStartBatch: function() {}, + onEndBatch: function() {}, + onError: function() {}, + onPropertyChanged: function() {}, + onPropertyDeleting: function() {}, + onLoad: function(aCalendar) { + // Its possible that the item was updated. We need to re-retrieve the + // items now. + this.findItem(); + }, + + onModifyItem: function(aNewItem, aOldItem) { + let refItem = aOldItem || aNewItem; + if (refItem.id == this.mSearchId) { + // Check existing found items to see if it already exists + let found = false; + for (let [idx, item] of Object.entries(this.mFoundItems)) { + if (item.id == refItem.id && item.calendar.id == refItem.calendar.id) { + if (aNewItem) { + this.mFoundItems.splice(idx, 1, aNewItem); + } else { + this.mFoundItems.splice(idx, 1); + } + found = true; + break; + } + } + + // If it hasn't been found and there is to add a item, add it to the end + if (!found && aNewItem) { + this.mFoundItems.push(aNewItem); + } + this.processFoundItems(); + } + }, + + onAddItem: function(aItem) { + // onModifyItem is set up to also handle additions + this.onModifyItem(aItem, null); + }, + + onDeleteItem: function(aItem) { + // onModifyItem is set up to also handle deletions + this.onModifyItem(null, aItem); + }, + + onOperationComplete: function(aCalendar, aStatus, aOperationType, aId, aDetail) { + this.processFoundItems(); + }, + + destroy: function() { + this._unobserveChanges(); + }, + + processFoundItems: function() { + let rc = Components.results.NS_OK; + const method = this.mItipItem.receivedMethod.toUpperCase(); + let actionMethod = method; + let operations = []; + + if (this.mFoundItems.length > 0) { + // Save the target calendar on the itip item + this.mItipItem.targetCalendar = this.mFoundItems[0].calendar; + this._observeChanges(this.mItipItem.targetCalendar); + + cal.LOG("iTIP on " + method + ": found " + this.mFoundItems.length + " items."); + switch (method) { + // XXX todo: there's still a potential flaw, if multiple PUBLISH/REPLY/REQUEST on + // occurrences happen at once; those lead to multiple + // occurrence modifications. Since those modifications happen + // implicitly on the parent (ics/memory/storage calls modifyException), + // the generation check will fail. We should really consider to allow + // deletion/modification/addition of occurrences directly on the providers, + // which would ease client code a lot. + case "REFRESH": + case "PUBLISH": + case "REQUEST": + case "REPLY": + case "COUNTER": + case "DECLINECOUNTER": + for (let itipItemItem of this.mItipItem.getItemList({})) { + for (let item of this.mFoundItems) { + let rid = itipItemItem.recurrenceId; // XXX todo support multiple + if (rid) { // actually applies to individual occurrence(s) + if (item.recurrenceInfo) { + item = item.recurrenceInfo.getOccurrenceFor(rid); + if (!item) { + continue; + } + } else { // the item has been rescheduled with master: + itipItemItem = itipItemItem.parentItem; + } + } + + switch (method) { + case "REFRESH": { // xxx todo test + let attendees = itipItemItem.getAttendees({}); + cal.ASSERT(attendees.length == 1, + "invalid number of attendees in REFRESH!"); + if (attendees.length > 0) { + let action = function(opListener) { + if (!item.organizer) { + let org = createOrganizer(item.calendar); + if (org) { + item = item.clone(); + item.organizer = org; + } + } + sendMessage(item, "REQUEST", attendees, true /* don't ask */); + }; + operations.push(action); + } + break; + } + case "PUBLISH": + cal.ASSERT(itipItemItem.getAttendees({}).length == 0, + "invalid number of attendees in PUBLISH!"); + if (item.calendar.getProperty("itip.disableRevisionChecks") || + cal.itip.compare(itipItemItem, item) > 0) { + let newItem = updateItem(item, itipItemItem); + let action = function(opListener) { + return newItem.calendar.modifyItem(newItem, item, opListener); + }; + actionMethod = method + ":UPDATE"; + operations.push(action); + } + break; + case "REQUEST": { + let newItem = updateItem(item, itipItemItem); + let att = cal.getInvitedAttendee(newItem); + if (!att) { // fall back to using configured organizer + att = createOrganizer(newItem.calendar); + if (att) { + att.isOrganizer = false; + } + } + if (att) { + let firstFoundItem = this.mFoundItems[0]; + // again, fall back to using configured organizer if not found + let foundAttendee = firstFoundItem.getAttendeeById(att.id) || att; + + // If the the user hasn't responded to the invitation yet and we + // are viewing the current representation of the item, show the + // accept/decline buttons. This means newer events will show the + // "Update" button and older events will show the "already + // processed" text. + if (foundAttendee.participationStatus == "NEEDS-ACTION" && + (item.calendar.getProperty("itip.disableRevisionChecks") || + cal.itip.compare(itipItemItem, item) == 0)) { + actionMethod = "REQUEST:NEEDS-ACTION"; + operations.push((opListener, partStat) => { + let changedItem = firstFoundItem.clone(); + changedItem.removeAttendee(foundAttendee); + foundAttendee = foundAttendee.clone(); + if (partStat) { + foundAttendee.participationStatus = partStat; + } + changedItem.addAttendee(foundAttendee); + + return changedItem.calendar.modifyItem( + changedItem, firstFoundItem, new ItipOpListener(opListener, firstFoundItem)); + }); + } else if (item.calendar.getProperty("itip.disableRevisionChecks") || + cal.itip.compare(itipItemItem, item) > 0) { + addScheduleAgentClient(newItem, item.calendar); + + let isMinorUpdate = cal.itip.getSequence(newItem) == + cal.itip.getSequence(item); + actionMethod = (isMinorUpdate ? method + ":UPDATE-MINOR" + : method + ":UPDATE"); + operations.push((opListener, partStat) => { + if (!partStat) { // keep PARTSTAT + let att_ = cal.getInvitedAttendee(item); + partStat = att_ ? att_.participationStatus : "NEEDS-ACTION"; + } + newItem.removeAttendee(att); + att = att.clone(); + att.participationStatus = partStat; + newItem.addAttendee(att); + return newItem.calendar.modifyItem( + newItem, item, new ItipOpListener(opListener, item)); + }); + } + } + break; + } + case "DECLINECOUNTER": + // nothing to do right now, but once countering is implemented, + // we probably need some action here to remove the proposal from + // the countering attendee's calendar + break; + case "COUNTER": + case "REPLY": { + let attendees = itipItemItem.getAttendees({}); + if (method == "REPLY") { + cal.ASSERT( + attendees.length == 1, + "invalid number of attendees in REPLY!" + ); + } else { + attendees = cal.getAttendeesBySender( + attendees, + this.mItipItem.sender + ); + cal.ASSERT( + attendees.length == 1, + "ambiguous resolution of replying attendee in COUNTER!" + ); + } + // we get the attendee from the event stored in the calendar + let replyer = item.getAttendeeById(attendees[0].id); + if (!replyer && method == "REPLY") { + // We accepts REPLYs also from previously uninvited + // attendees, so we always have one for REPLY + replyer = attendees[0]; + } + let noCheck = item.calendar.getProperty( + "itip.disableRevisionChecks"); + let revCheck = false; + if (replyer && !noCheck) { + revCheck = cal.itip.compare(itipItemItem, replyer) > 0; + if (revCheck && method == "COUNTER") { + revCheck = cal.itip.compareSequence(itipItemItem, item) == 0; + } + } + + if (replyer && (noCheck || revCheck)) { + let newItem = item.clone(); + newItem.removeAttendee(replyer); + replyer = replyer.clone(); + setReceivedInfo(replyer, itipItemItem); + let newPS = itipItemItem.getAttendeeById(replyer.id) + .participationStatus; + replyer.participationStatus = newPS; + newItem.addAttendee(replyer); + + // Make sure the provider-specified properties are copied over + copyProviderProperties(this.mItipItem, itipItemItem, newItem); + + let action = function(opListener) { + // n.b.: this will only be processed in case of reply or + // declining the counter request - of sending the + // appropriate reply will be taken care within the + // opListener (defined in imip-bar.js) + // TODO: move that from imip-bar.js to here + return newItem.calendar.modifyItem( + newItem, item, + newItem.calendar.getProperty("itip.notify-replies") + ? new ItipOpListener(opListener, item) + : opListener); + }; + operations.push(action); + } + break; + } + } + } + } + break; + case "CANCEL": { + let modifiedItems = {}; + for (let itipItemItem of this.mItipItem.getItemList({})) { + for (let item of this.mFoundItems) { + let rid = itipItemItem.recurrenceId; // XXX todo support multiple + if (rid) { // actually a CANCEL of occurrence(s) + if (item.recurrenceInfo) { + // collect all occurrence deletions into a single parent modification: + let newItem = modifiedItems[item.id]; + if (!newItem) { + newItem = item.clone(); + modifiedItems[item.id] = newItem; + + // Make sure the provider-specified properties are copied over + copyProviderProperties(this.mItipItem, itipItemItem, newItem); + + operations.push(opListener => newItem.calendar.modifyItem(newItem, item, opListener)); + } + newItem.recurrenceInfo.removeOccurrenceAt(rid); + } else if (item.recurrenceId && (item.recurrenceId.compare(rid) == 0)) { + // parentless occurrence to be deleted (future) + operations.push(opListener => item.calendar.deleteItem(item, opListener)); + } + } else { + operations.push(opListener => item.calendar.deleteItem(item, opListener)); + } + } + } + break; + } + default: + rc = Components.results.NS_ERROR_NOT_IMPLEMENTED; + break; + } + } else { // not found: + cal.LOG("iTIP on " + method + ": no existing items."); + + // If the item was not found, observe the target calendar anyway. + // It will likely be the composite calendar, so we should update + // if an item was added or removed + this._observeChanges(this.mItipItem.targetCalendar); + + for (let itipItemItem of this.mItipItem.getItemList({})) { + switch (method) { + case "REQUEST": + case "PUBLISH": { + let action = (opListener, partStat) => { + let newItem = itipItemItem.clone(); + setReceivedInfo(newItem, itipItemItem); + newItem.parentItem.calendar = this.mItipItem.targetCalendar; + addScheduleAgentClient(newItem, this.mItipItem.targetCalendar); + if (partStat) { + if (partStat != "DECLINED") { + cal.alarms.setDefaultValues(newItem); + } + let att = cal.getInvitedAttendee(newItem); + if (!att) { // fall back to using configured organizer + att = createOrganizer(newItem.calendar); + if (att) { + att.isOrganizer = false; + newItem.addAttendee(att); + } + } + if (att) { + att.participationStatus = partStat; + } else { + cal.ASSERT(att, "no attendee to reply REQUEST!"); + return null; + } + } else { + cal.ASSERT(itipItemItem.getAttendees({}).length == 0, + "invalid number of attendees in PUBLISH!"); + } + return newItem.calendar.addItem(newItem, + method == "REQUEST" + ? new ItipOpListener(opListener, null) + : opListener); + }; + operations.push(action); + break; + } + case "CANCEL": // has already been processed + case "REPLY": // item has been previously removed from the calendar + case "COUNTER": // the item has been previously removed form the calendar + break; + default: + rc = Components.results.NS_ERROR_NOT_IMPLEMENTED; + break; + } + } + } + + cal.LOG("iTIP operations: " + operations.length); + let actionFunc = null; + if (operations.length > 0) { + actionFunc = function(opListener, partStat) { + for (let operation of operations) { + try { + operation(opListener, partStat); + } catch (exc) { + cal.ERROR(exc); + } + } + }; + actionFunc.method = actionMethod; + } + + this.mOptionsFunc(this.mItipItem, rc, actionFunc, this.mFoundItems); + }, + + onGetResult: function(aCalendar, aStatus, aItemType, aDetail, aCount, aItems) { + if (Components.isSuccessCode(aStatus)) { + this.mFoundItems = this.mFoundItems.concat(aItems); + } + } +}; diff --git a/calendar/base/modules/calPrintUtils.jsm b/calendar/base/modules/calPrintUtils.jsm new file mode 100644 index 000000000..a44b02731 --- /dev/null +++ b/calendar/base/modules/calPrintUtils.jsm @@ -0,0 +1,200 @@ +/* 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/. */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://gre/modules/Preferences.jsm"); +Components.utils.import("resource://calendar/modules/calViewUtils.jsm"); + +this.EXPORTED_SYMBOLS = ["cal"]; // even though it's defined in calUtils.jsm, import needs this +cal.print = { + /** + * Returns a simple key in the format YYYY-MM-DD for use in the table of + * dates to day boxes + * + * @param dt The date to translate + * @return YYYY-MM-DD + */ + getDateKey: function(date) { + return date.year + "-" + date.month + "-" + date.day; + }, + + /** + * Add category styles to the document's "sheet" element. This is needed + * since the HTML created is serialized, so we can't dynamically set the + * styles and can be changed if the print formatter decides to return a + * DOM document instead. + * + * @param document The document that contains <style id="sheet"/>. + * @param categories Array of categories to insert rules for. + */ + insertCategoryRules: function(document, categories) { + let sheet = document.getElementById("sheet"); + sheet.insertedCategoryRules = sheet.insertedCategoryRules || {}; + + for (let category of categories) { + let prefName = cal.formatStringForCSSRule(category); + let color = Preferences.get("calendar.category.color." + prefName) || "transparent"; + if (!(prefName in sheet.insertedCategoryRules)) { + sheet.insertedCategoryRules[prefName] = true; + let ruleAdd = ' .category-color-box[categories~="' + prefName + '"] { ' + + " border: 2px solid " + color + "; }\n"; + sheet.textContent += ruleAdd; + } + } + }, + + /** + * Add calendar styles to the document's "sheet" element. This is needed + * since the HTML created is serialized, so we can't dynamically set the + * styles and can be changed if the print formatter decides to return a + * DOM document instead. + * + * @param document The document that contains <style id="sheet"/>. + * @param categories The calendar to insert a rule for. + */ + insertCalendarRules: function(document, calendar) { + let sheet = document.getElementById("sheet"); + let color = calendar.getProperty("color") || "#A8C2E1"; + sheet.insertedCalendarRules = sheet.insertedCalendarRules || {}; + + if (!(calendar.id in sheet.insertedCalendarRules)) { + sheet.insertedCalendarRules[calendar.id] = true; + let formattedId = cal.formatStringForCSSRule(calendar.id); + let ruleAdd = ' .calendar-color-box[calendar-id="' + formattedId + '"] { ' + + " background-color: " + color + "; " + + " color: " + cal.getContrastingTextColor(color) + "; }\n"; + sheet.textContent += ruleAdd; + } + }, + + /** + * Serializes the given item by setting marked nodes to the item's content. + * Has some expectations about the DOM document (in CSS-selector-speak), all + * following nodes MUST exist. + * + * - #item-template will be cloned and filled, and modified: + * - .item-interval gets the time interval of the item. + * - .item-title gets the item title + * - .category-color-box gets a 2px solid border in category color + * - .calendar-color-box gets background color of the calendar + * + * @param document The DOM Document to set things on + * @param item The item to serialize + * @param dayContainer The DOM Node to insert the container in + */ + addItemToDaybox: function(document, item, boxDate, dayContainer) { + // Clone our template + let itemNode = document.getElementById("item-template").cloneNode(true); + itemNode.removeAttribute("id"); + itemNode.item = item; + + // Fill in details of the item + let itemInterval = cal.print.getItemIntervalString(item, boxDate); + itemNode.querySelector(".item-interval").textContent = itemInterval; + itemNode.querySelector(".item-title").textContent = item.title; + + // Fill in category details + let categoriesArray = item.getCategories({}); + if (categoriesArray.length > 0) { + let cssClassesArray = categoriesArray.map(cal.formatStringForCSSRule); + itemNode.querySelector(".category-color-box") + .setAttribute("categories", cssClassesArray.join(" ")); + + cal.print.insertCategoryRules(document, categoriesArray); + } + + // Fill in calendar color + itemNode.querySelector(".calendar-color-box") + .setAttribute("calendar-id", cal.formatStringForCSSRule(item.calendar.id)); + cal.print.insertCalendarRules(document, item.calendar); + + // Add it to the day container in the right order + cal.binaryInsertNode(dayContainer, itemNode, item, cal.view.compareItems); + }, + + /** + * Serializes the given item by setting marked nodes to the item's + * content. Should be used for tasks with no start and due date. Has + * some expectations about the DOM document (in CSS-selector-speak), + * all following nodes MUST exist. + * + * - Nodes will be added to #task-container. + * - #task-list-box will have the "hidden" attribute removed. + * - #task-template will be cloned and filled, and modified: + * - .task-checkbox gets the "checked" attribute set, if completed + * - .task-title gets the item title. + * + * @param document The DOM Document to set things on + * @param item The item to serialize + */ + addItemToDayboxNodate: function(document, item) { + let taskContainer = document.getElementById("task-container"); + let taskNode = document.getElementById("task-template").cloneNode(true); + taskNode.removeAttribute("id"); + taskNode.item = item; + + let taskListBox = document.getElementById("tasks-list-box"); + if (taskListBox.hasAttribute("hidden")) { + let tasksTitle = document.getElementById("tasks-title"); + taskListBox.removeAttribute("hidden"); + tasksTitle.textContent = cal.calGetString("calendar", "tasksWithNoDueDate"); + } + + // Fill in details of the task + if (item.isCompleted) { + taskNode.querySelector(".task-checkbox").setAttribute("checked", "checked"); + } + + taskNode.querySelector(".task-title").textContent = item.title; + + let collator = cal.createLocaleCollator(); + cal.binaryInsertNode(taskContainer, taskNode, item, (a, b) => collator.compareString(0, a, b), node => node.item.title); + }, + + /** + * Get time interval string for the given item. Returns an empty string for all-day items. + * + * @param aItem The item providing the interval + * @return The string describing the interval + */ + getItemIntervalString: function(aItem, aBoxDate) { + // omit time label for all-day items + let startDate = aItem[cal.calGetStartDateProp(aItem)]; + let endDate = aItem[cal.calGetEndDateProp(aItem)]; + if ((startDate && startDate.isDate) || (endDate && endDate.isDate)) { + return ""; + } + + // check for tasks without start and/or due date + if (!startDate || !endDate) { + return cal.getDateFormatter().formatItemTimeInterval(aItem); + } + + let dateFormatter = cal.getDateFormatter(); + let defaultTimezone = cal.calendarDefaultTimezone(); + startDate = startDate.getInTimezone(defaultTimezone); + endDate = endDate.getInTimezone(defaultTimezone); + let start = startDate.clone(); + let end = endDate.clone(); + start.isDate = true; + end.isDate = true; + if (start.compare(end) == 0) { + // Events that start and end in the same day. + return dateFormatter.formatTimeInterval(startDate, endDate); + } else { + // Events that span two or more days. + let compareStart = aBoxDate.compare(start); + let compareEnd = aBoxDate.compare(end); + if (compareStart == 0) { + return "\u21e4 " + dateFormatter.formatTime(startDate); // unicode '⇤' + } else if (compareStart > 0 && compareEnd < 0) { + return "\u21ff"; // unicode '↔' + } else if (compareEnd == 0) { + return "\u21e5 " + dateFormatter.formatTime(endDate); // unicode '⇥' + } else { + return ""; + } + } + } +}; diff --git a/calendar/base/modules/calProviderUtils.jsm b/calendar/base/modules/calProviderUtils.jsm new file mode 100644 index 000000000..07bd069e2 --- /dev/null +++ b/calendar/base/modules/calProviderUtils.jsm @@ -0,0 +1,856 @@ +/* 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/. */ + +Components.utils.import("resource:///modules/mailServices.js"); +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://calendar/modules/calAuthUtils.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); +Components.utils.import("resource://gre/modules/Preferences.jsm"); +Components.utils.import("resource:///modules/iteratorUtils.jsm"); + +/* + * Provider helper code + */ + +this.EXPORTED_SYMBOLS = ["cal"]; // even though it's defined in calUtils.jsm, import needs this + +/** + * Prepare HTTP channel with standard request headers and upload + * data/content-type if needed + * + * @param arUri Channel Uri, will only be used for a new + * channel. + * @param aUploadData Data to be uploaded, if any. This may be a + * nsIInputStream or string data. In the + * latter case the string will be converted + * to an input stream. + * @param aContentType Value for Content-Type header, if any + * @param aNotificationCallbacks Calendar using channel + * @param aExisting An existing channel to modify (optional) + */ +cal.prepHttpChannel = function(aUri, aUploadData, aContentType, aNotificationCallbacks, aExisting) { + let channel = aExisting || Services.io.newChannelFromURI2(aUri, + null, + Services.scriptSecurityManager.getSystemPrincipal(), + null, + Components.interfaces.nsILoadInfo.SEC_NORMAL, + Components.interfaces.nsIContentPolicy.TYPE_OTHER); + let httpchannel = channel.QueryInterface(Components.interfaces.nsIHttpChannel); + + httpchannel.setRequestHeader("Accept", "text/xml", false); + httpchannel.setRequestHeader("Accept-Charset", "utf-8,*;q=0.1", false); + httpchannel.loadFlags |= Components.interfaces.nsIRequest.LOAD_BYPASS_CACHE; + httpchannel.notificationCallbacks = aNotificationCallbacks; + + if (aUploadData) { + httpchannel = httpchannel.QueryInterface(Components.interfaces.nsIUploadChannel); + let stream; + if (aUploadData instanceof Components.interfaces.nsIInputStream) { + // Make sure the stream is reset + stream = aUploadData.QueryInterface(Components.interfaces.nsISeekableStream); + stream.seek(Components.interfaces.nsISeekableStream.NS_SEEK_SET, 0); + } else { + // Otherwise its something that should be a string, convert it. + let converter = Components.classes["@mozilla.org/intl/scriptableunicodeconverter"] + .createInstance(Components.interfaces.nsIScriptableUnicodeConverter); + converter.charset = "UTF-8"; + stream = converter.convertToInputStream(aUploadData.toString()); + } + + httpchannel.setUploadStream(stream, aContentType, -1); + } + + return httpchannel; +}; + +/** + * calSendHttpRequest; send prepared HTTP request + * + * @param aStreamLoader streamLoader for request + * @param aChannel channel for request + * @param aListener listener for method completion + */ +cal.sendHttpRequest = function(aStreamLoader, aChannel, aListener) { + aStreamLoader.init(aListener); + aChannel.asyncOpen(aStreamLoader, aChannel); +}; + +cal.createStreamLoader = function() { + return Components.classes["@mozilla.org/network/stream-loader;1"] + .createInstance(Components.interfaces.nsIStreamLoader); +}; + +cal.convertByteArray = function(aResult, aResultLength, aCharset, aThrow) { + try { + let resultConverter = Components.classes["@mozilla.org/intl/scriptableunicodeconverter"] + .createInstance(Components.interfaces.nsIScriptableUnicodeConverter); + resultConverter.charset = aCharset || "UTF-8"; + return resultConverter.convertFromByteArray(aResult, aResultLength); + } catch (e) { + if (aThrow) { + throw e; + } + } + return null; +}; + +/** + * getInterface method for providers. This should be called in the context of + * the respective provider, i.e + * + * return cal.InterfaceRequestor_getInterface.apply(this, arguments); + * + * or + * ... + * getInterface: cal.InterfaceRequestor_getInterface, + * ... + * + * NOTE: If the server only provides one realm for all calendars, be sure that + * the |this| object implements calICalendar. In this case the calendar name + * will be appended to the realm. If you need that feature disabled, see the + * capabilities section of calICalendar.idl + * + * @param aIID The interface ID to return + */ +cal.InterfaceRequestor_getInterface = function(aIID) { + try { + // Try to query the this object for the requested interface but don't + // throw if it fails since that borks the network code. + return this.QueryInterface(aIID); + } catch (e) { + // Support Auth Prompt Interfaces + if (aIID.equals(Components.interfaces.nsIAuthPrompt2)) { + if (!this.calAuthPrompt) { + this.calAuthPrompt = new cal.auth.Prompt(); + } + return this.calAuthPrompt; + } else if (aIID.equals(Components.interfaces.nsIAuthPromptProvider) || + aIID.equals(Components.interfaces.nsIPrompt)) { + return Services.ww.getNewPrompter(null); + } else if (aIID.equals(Components.interfaces.nsIBadCertListener2)) { + if (!this.badCertHandler) { + this.badCertHandler = new cal.BadCertHandler(this); + } + return this.badCertHandler; + } else { + Components.returnCode = e; + } + } + return null; +}; + +/** + * Bad Certificate Handler for Network Requests. Shows the Network Exception + * Dialog if a certificate Problem occurs. + */ +cal.BadCertHandler = function(thisProvider) { + this.thisProvider = thisProvider; +}; +cal.BadCertHandler.prototype = { + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.nsIBadCertListener2]), + + notifyCertProblem: function(socketInfo, status, targetSite) { + // Unfortunately we can't pass js objects using the window watcher, so + // we'll just take the first available calendar window. We also need to + // do this on a timer so that the modal window doesn't block the + // network request. + let calWindow = cal.getCalendarWindow(); + + let timerCallback = { + thisProvider: this.thisProvider, + notify: function(timer) { + let params = { + exceptionAdded: false, + sslStatus: status, + prefetchCert: true, + location: targetSite + }; + calWindow.openDialog("chrome://pippki/content/exceptionDialog.xul", + "", + "chrome,centerscreen,modal", + params); + if (this.thisProvider.canRefresh && + params.exceptionAdded) { + // Refresh the provider if the + // exception certificate was added + this.thisProvider.refresh(); + } + } + }; + let timer = Components.classes["@mozilla.org/timer;1"] + .createInstance(Components.interfaces.nsITimer); + timer.initWithCallback(timerCallback, + 0, + Components.interfaces.nsITimer.TYPE_ONE_SHOT); + return true; + } +}; + +/** + * Freebusy interval implementation. All parameters are optional. + * + * @param aCalId The calendar id to set up with. + * @param aFreeBusyType The type from calIFreeBusyInterval. + * @param aStart The start of the interval. + * @param aEnd The end of the interval. + * @return The fresh calIFreeBusyInterval. + */ +cal.FreeBusyInterval = function(aCalId, aFreeBusyType, aStart, aEnd) { + this.calId = aCalId; + this.interval = Components.classes["@mozilla.org/calendar/period;1"] + .createInstance(Components.interfaces.calIPeriod); + this.interval.start = aStart; + this.interval.end = aEnd; + + this.freeBusyType = aFreeBusyType || Components.interfaces.calIFreeBusyInterval.UNKNOWN; +}; +cal.FreeBusyInterval.prototype = { + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calIFreeBusyInterval]), + calId: null, + interval: null, + freeBusyType: Components.interfaces.calIFreeBusyInterval.UNKNOWN +}; + +/** + * Gets the iTIP/iMIP transport if the passed calendar has configured email. + */ +cal.getImipTransport = function(aCalendar) { + // assure an identity is configured for the calendar + return (aCalendar.getProperty("imip.identity") + ? Components.classes["@mozilla.org/calendar/itip-transport;1?type=email"] + .getService(Components.interfaces.calIItipTransport) + : null); +}; + +/** + * Gets the configured identity and account of a particular calendar instance, or null. + * + * @param aCalendar Calendar instance + * @param outAccount Optional out value for account + * @return The configured identity + */ +cal.getEmailIdentityOfCalendar = function(aCalendar, outAccount) { + cal.ASSERT(aCalendar, "no calendar!", Components.results.NS_ERROR_INVALID_ARG); + let key = aCalendar.getProperty("imip.identity.key"); + if (key === null) { // take default account/identity: + let findIdentity = function(account) { + if (account && account.identities.length) { + return account.defaultIdentity || + account.identities.queryElementAt(0, Components.interfaces.nsIMsgIdentity); + } + return null; + }; + + let foundAccount = MailServices.accounts.defaultAccount; + let foundIdentity = findIdentity(foundAccount); + + if (!foundAccount || !foundIdentity) { + let accounts = MailServices.accounts.accounts; + for (let account of fixIterator(accounts, Components.interfaces.nsIMsgAccount)) { + let identity = findIdentity(account); + + if (account && identity) { + foundAccount = account; + foundIdentity = identity; + break; + } + } + } + + if (outAccount) { + outAccount.value = foundIdentity ? foundAccount : null; + } + return foundIdentity; + } else { + if (key.length == 0) { // i.e. "None" + return null; + } + let identity = null; + cal.calIterateEmailIdentities((identity_, account) => { + if (identity_.key == key) { + identity = identity_; + if (outAccount) { + outAccount.value = account; + } + } + return (identity_.key != key); + }); + + if (!identity) { + // dangling identity: + cal.WARN("Calendar " + (aCalendar.uri ? aCalendar.uri.spec : aCalendar.id) + + " has a dangling E-Mail identity configured."); + } + return identity; + } +}; + + +/** + * fromRFC3339 + * Convert a RFC3339 compliant Date string to a calIDateTime. + * + * @param aStr The RFC3339 compliant Date String + * @param aTimezone The timezone this date string is most likely in + * @return A calIDateTime object + */ +cal.fromRFC3339 = function(aStr, aTimezone) { + // XXX I have not covered leapseconds (matches[8]), this might need to + // be done. The only reference to leap seconds I found is bug 227329. + // + + // Create a DateTime instance (calUtils.js) + let dateTime = cal.createDateTime(); + + // Killer regex to parse RFC3339 dates + let re = new RegExp("^([0-9]{4})-([0-9]{2})-([0-9]{2})" + + "([Tt]([0-9]{2}):([0-9]{2}):([0-9]{2})(\\.[0-9]+)?)?" + + "(([Zz]|([+-])([0-9]{2}):([0-9]{2})))?"); + + let matches = re.exec(aStr); + + if (!matches) { + return null; + } + + // Set usual date components + dateTime.isDate = (matches[4] == null); + + dateTime.year = matches[1]; + dateTime.month = matches[2] - 1; // Jan is 0 + dateTime.day = matches[3]; + + if (!dateTime.isDate) { + dateTime.hour = matches[5]; + dateTime.minute = matches[6]; + dateTime.second = matches[7]; + } + + // Timezone handling + if (matches[9] == "Z" || matches[9] == "z") { + // If the dates timezone is "Z" or "z", then this is UTC, no matter + // what timezone was passed + dateTime.timezone = cal.UTC(); + } else if (matches[9] == null) { + // We have no timezone info, only a date. We have no way to + // know what timezone we are in, so lets assume we are in the + // timezone of our local calendar, or whatever was passed. + + dateTime.timezone = aTimezone; + } else { + let offset_in_s = (matches[11] == "-" ? -1 : 1) * + ((matches[12] * 3600) + (matches[13] * 60)); + + // try local timezone first + dateTime.timezone = aTimezone; + + // If offset does not match, go through timezones. This will + // give you the first tz in the alphabet and kill daylight + // savings time, but we have no other choice + if (dateTime.timezoneOffset != offset_in_s) { + // TODO A patch to Bug 363191 should make this more efficient. + + let tzService = cal.getTimezoneService(); + // Enumerate timezones, set them, check their offset + let enumerator = tzService.timezoneIds; + while (enumerator.hasMore()) { + let id = enumerator.getNext(); + dateTime.timezone = tzService.getTimezone(id); + if (dateTime.timezoneOffset == offset_in_s) { + // This is our last step, so go ahead and return + return dateTime; + } + } + // We are still here: no timezone was found + dateTime.timezone = cal.UTC(); + if (!dateTime.isDate) { + dateTime.hour += (matches[11] == "-" ? -1 : 1) * matches[12]; + dateTime.minute += (matches[11] == "-" ? -1 : 1) * matches[13]; + } + } + } + return dateTime; +}; + +/** + * toRFC3339 + * Convert a calIDateTime to a RFC3339 compliant Date string + * + * @param aDateTime The calIDateTime object + * @return The RFC3339 compliant date string + */ +cal.toRFC3339 = function(aDateTime) { + if (!aDateTime) { + return ""; + } + + let full_tzoffset = aDateTime.timezoneOffset; + let tzoffset_hr = Math.floor(Math.abs(full_tzoffset) / 3600); + + let tzoffset_mn = ((Math.abs(full_tzoffset) / 3600).toFixed(2) - + tzoffset_hr) * 60; + + let str = aDateTime.year + "-" + + ("00" + (aDateTime.month + 1)).substr(-2) + "-" + + ("00" + aDateTime.day).substr(-2); + + // Time and Timezone extension + if (!aDateTime.isDate) { + str += "T" + + ("00" + aDateTime.hour).substr(-2) + ":" + + ("00" + aDateTime.minute).substr(-2) + ":" + + ("00" + aDateTime.second).substr(-2); + if (aDateTime.timezoneOffset != 0) { + str += (full_tzoffset < 0 ? "-" : "+") + + ("00" + tzoffset_hr).substr(-2) + ":" + + ("00" + tzoffset_mn).substr(-2); + } else if (aDateTime.timezone.isFloating) { + // RFC3339 Section 4.3 Unknown Local Offset Convention + str += "-00:00"; + } else { + // ZULU Time, according to ISO8601's timezone-offset + str += "Z"; + } + } + return str; +}; + +cal.promptOverwrite = function(aMode, aItem) { + let window = cal.getCalendarWindow(); + let args = { + item: aItem, + mode: aMode, + overwrite: false + }; + + window.openDialog("chrome://calendar/content/calendar-conflicts-dialog.xul", + "calendarConflictsDialog", + "chrome,titlebar,modal", + args); + + return args.overwrite; +}; + +/** + * Observer bag implementation taking care to replay open batch notifications. + */ +cal.ObserverBag = function(iid) { + this.init(iid); +}; +cal.ObserverBag.prototype = { + __proto__: cal.calListenerBag.prototype, + + mBatchCount: 0, + notify: function(func, args) { + switch (func) { + case "onStartBatch": + ++this.mBatchCount; + break; + case "onEndBatch": + --this.mBatchCount; + break; + } + return this.__proto__.__proto__.notify.apply(this, arguments); + }, + + add: function(iface) { + if (this.__proto__.__proto__.add.apply(this, arguments) && (this.mBatchCount > 0)) { + // Replay batch notifications, because the onEndBatch notifications are yet to come. + // We may think about doing the reverse on remove, though I currently see no need: + for (let i = this.mBatchCount; i--;) { + iface.onStartBatch(); + } + } + } +}; + +/** + * Base prototype to be used implementing a provider. + * + * @see e.g. providers/gdata + */ +cal.ProviderBase = function() { + cal.ASSERT("This prototype should only be inherited!"); +}; +cal.ProviderBase.mTransientProperties = { + "cache.uncachedCalendar": true, + "currentStatus": true, + "itip.transport": true, + "imip.identity": true, + "imip.account": true, + "imip.identity.disabled": true, + "organizerId": true, + "organizerCN": true +}; +cal.ProviderBase.prototype = { + QueryInterface: XPCOMUtils.generateQI([ + Components.interfaces.calICalendar, + Components.interfaces.calISchedulingSupport + ]), + + mID: null, + mUri: null, + mACLEntry: null, + mObservers: null, + mProperties: null, + + initProviderBase: function() { + this.wrappedJSObject = this; + this.mObservers = new cal.ObserverBag(Components.interfaces.calIObserver); + this.mProperties = {}; + this.mProperties.currentStatus = Components.results.NS_OK; + }, + + get observers() { + return this.mObservers; + }, + + // attribute AUTF8String id; + get id() { + return this.mID; + }, + set id(aValue) { + if (this.mID) { + throw Components.results.NS_ERROR_ALREADY_INITIALIZED; + } + this.mID = aValue; + + let calMgr = cal.getCalendarManager(); + + // make all properties persistent that have been set so far: + for (let aName in this.mProperties) { + if (!cal.ProviderBase.mTransientProperties[aName]) { + let value = this.mProperties[aName]; + if (value !== null) { + calMgr.setCalendarPref_(this, aName, value); + } + } + } + + let takeOverIfNotPresent = (oldPref, newPref, dontDeleteOldPref) => { + let val = calMgr.getCalendarPref_(this, oldPref); + if (val !== null) { + if (!dontDeleteOldPref) { + calMgr.deleteCalendarPref_(this, oldPref); + } + if (calMgr.getCalendarPref_(this, newPref) === null) { + calMgr.setCalendarPref_(this, newPref, val); + } + } + }; + + // takeover lightning calendar visibility from 0.5: + takeOverIfNotPresent("lightning-main-in-composite", "calendar-main-in-composite"); + takeOverIfNotPresent("lightning-main-default", "calendar-main-default"); + + return aValue; + }, + + // attribute AUTF8String name; + get name() { + return this.getProperty("name"); + }, + set name(aValue) { + return this.setProperty("name", aValue); + }, + + // readonly attribute calICalendarACLManager aclManager; + get aclManager() { + const defaultACLProviderClass = "@mozilla.org/calendar/acl-manager;1?type=default"; + let providerClass = this.getProperty("aclManagerClass"); + if (!providerClass || !Components.classes[providerClass]) { + providerClass = defaultACLProviderClass; + } + return Components.classes[providerClass].getService(Components.interfaces.calICalendarACLManager); + }, + + // readonly attribute calICalendarACLEntry aclEntry; + get aclEntry() { + return this.mACLEntry; + }, + + // attribute calICalendar superCalendar; + get superCalendar() { + // If we have a superCalendar, check this calendar for a superCalendar. + // This will make sure the topmost calendar is returned + return (this.mSuperCalendar ? this.mSuperCalendar.superCalendar : this); + }, + set superCalendar(val) { + return (this.mSuperCalendar = val); + }, + + // attribute nsIURI uri; + get uri() { + return this.mUri; + }, + set uri(aValue) { + return (this.mUri = aValue); + }, + + // attribute boolean readOnly; + get readOnly() { + return this.getProperty("readOnly"); + }, + set readOnly(aValue) { + return this.setProperty("readOnly", aValue); + }, + + // readonly attribute boolean canRefresh; + get canRefresh() { + return false; + }, + + // void startBatch(); + mBatchCount: 0, + startBatch: function() { + if (this.mBatchCount++ == 0) { + this.mObservers.notify("onStartBatch"); + } + }, + + endBatch: function() { + if (this.mBatchCount > 0) { + if (--this.mBatchCount == 0) { + this.mObservers.notify("onEndBatch"); + } + } else { + cal.ASSERT(this.mBatchCount > 0, "unexepcted endBatch!"); + } + }, + + notifyPureOperationComplete: function(aListener, aStatus, aOperationType, aId, aDetail) { + if (aListener) { + try { + aListener.onOperationComplete(this.superCalendar, aStatus, aOperationType, aId, aDetail); + } catch (exc) { + cal.ERROR(exc); + } + } + }, + + notifyOperationComplete: function(aListener, aStatus, aOperationType, aId, aDetail, aExtraMessage) { + this.notifyPureOperationComplete(aListener, aStatus, aOperationType, aId, aDetail); + + if (aStatus == Components.interfaces.calIErrors.OPERATION_CANCELLED) { + return; // cancellation doesn't change current status, no notification + } + if (Components.isSuccessCode(aStatus)) { + this.setProperty("currentStatus", aStatus); + } else { + if (aDetail instanceof Components.interfaces.nsIException) { + this.notifyError(aDetail); // will set currentStatus + } else { + this.notifyError(aStatus, aDetail); // will set currentStatus + } + this.notifyError(aOperationType == Components.interfaces.calIOperationListener.GET + ? Components.interfaces.calIErrors.READ_FAILED + : Components.interfaces.calIErrors.MODIFICATION_FAILED, + aExtraMessage || ""); + } + }, + + // for convenience also callable with just an exception + notifyError: function(aErrNo, aMessage) { + if (aErrNo == Components.interfaces.calIErrors.OPERATION_CANCELLED) { + return; // cancellation doesn't change current status, no notification + } + if (aErrNo instanceof Components.interfaces.nsIException) { + if (!aMessage) { + aMessage = aErrNo.message; + } + aErrNo = aErrNo.result; + } + this.setProperty("currentStatus", aErrNo); + this.observers.notify("onError", [this.superCalendar, aErrNo, aMessage]); + }, + + mTransientPropertiesMode: false, + get transientProperties() { + return this.mTransientPropertiesMode; + }, + set transientProperties(value) { + return (this.mTransientPropertiesMode = value); + }, + + // nsIVariant getProperty(in AUTF8String aName); + getProperty: function(aName) { + switch (aName) { + case "itip.transport": // iTIP/iMIP default: + return cal.getImipTransport(this); + case "itip.notify-replies": // iTIP/iMIP default: + return Preferences.get("calendar.itip.notify-replies", false); + // temporary hack to get the uncached calendar instance: + case "cache.uncachedCalendar": + return this; + } + + let ret = this.mProperties[aName]; + if (ret === undefined) { + ret = null; + switch (aName) { + case "imip.identity": // we want to cache the identity object a little, because + // it is heavily used by the invitation checks + ret = cal.getEmailIdentityOfCalendar(this); + break; + case "imip.account": { + let outAccount = {}; + if (cal.getEmailIdentityOfCalendar(this, outAccount)) { + ret = outAccount.value; + } + break; + } + case "organizerId": { // itip/imip default: derived out of imip.identity + let identity = this.getProperty("imip.identity"); + ret = (identity + ? ("mailto:" + identity.QueryInterface(Components.interfaces.nsIMsgIdentity).email) + : null); + break; + } + case "organizerCN": { // itip/imip default: derived out of imip.identity + let identity = this.getProperty("imip.identity"); + ret = (identity + ? identity.QueryInterface(Components.interfaces.nsIMsgIdentity).fullName + : null); + break; + } + } + if ((ret === null) && + !cal.ProviderBase.mTransientProperties[aName] && + !this.transientProperties) { + if (this.id) { + ret = cal.getCalendarManager().getCalendarPref_(this, aName); + } + switch (aName) { + case "suppressAlarms": + if (this.getProperty("capabilities.alarms.popup.supported") === false) { + // If popup alarms are not supported, + // automatically suppress alarms + ret = true; + } + break; + } + } + this.mProperties[aName] = ret; + } +// cal.LOG("getProperty(\"" + aName + "\"): " + ret); + return ret; + }, + + // void setProperty(in AUTF8String aName, in nsIVariant aValue); + setProperty: function(aName, aValue) { + let oldValue = this.getProperty(aName); + if (oldValue != aValue) { + this.mProperties[aName] = aValue; + switch (aName) { + case "imip.identity.key": // invalidate identity and account object if key is set: + delete this.mProperties["imip.identity"]; + delete this.mProperties["imip.account"]; + delete this.mProperties.organizerId; + delete this.mProperties.organizerCN; + break; + } + if (!this.transientProperties && + !cal.ProviderBase.mTransientProperties[aName] && + this.id) { + cal.getCalendarManager().setCalendarPref_(this, aName, aValue); + } + this.mObservers.notify("onPropertyChanged", + [this.superCalendar, aName, aValue, oldValue]); + } + return aValue; + }, + + // void deleteProperty(in AUTF8String aName); + deleteProperty: function(aName) { + this.mObservers.notify("onPropertyDeleting", [this.superCalendar, aName]); + delete this.mProperties[aName]; + cal.getCalendarManager().deleteCalendarPref_(this, aName); + }, + + // calIOperation refresh + refresh: function() { + return null; + }, + + // void addObserver( in calIObserver observer ); + addObserver: function(aObserver) { + this.mObservers.add(aObserver); + }, + + // void removeObserver( in calIObserver observer ); + removeObserver: function(aObserver) { + this.mObservers.remove(aObserver); + }, + + // calISchedulingSupport: Implementation corresponding to our iTIP/iMIP support + isInvitation: function(aItem) { + if (!this.mACLEntry || !this.mACLEntry.hasAccessControl) { + // No ACL support - fallback to the old method + let id = this.getProperty("organizerId"); + if (id) { + let org = aItem.organizer; + if (!org || !org.id || (org.id.toLowerCase() == id.toLowerCase())) { + return false; + } + return (aItem.getAttendeeById(id) != null); + } + return false; + } + + let org = aItem.organizer; + if (!org || !org.id) { + // HACK + // if we don't have an organizer, this is perhaps because it's an exception + // to a recurring event. We check the parent item. + if (aItem.parentItem) { + org = aItem.parentItem.organizer; + if (!org || !org.id) { + return false; + } + } else { + return false; + } + } + + // We check if : + // - the organizer of the event is NOT within the owner's identities of this calendar + // - if the one of the owner's identities of this calendar is in the attendees + let ownerIdentities = this.mACLEntry.getOwnerIdentities({}); + for (let i = 0; i < ownerIdentities.length; i++) { + let identity = "mailto:" + ownerIdentities[i].email.toLowerCase(); + if (org.id.toLowerCase() == identity) { + return false; + } + + if (aItem.getAttendeeById(identity) != null) { + return true; + } + } + + return false; + }, + + getInvitedAttendee: function(aItem) { + let id = this.getProperty("organizerId"); + let attendee = (id ? aItem.getAttendeeById(id) : null); + + if (!attendee && this.mACLEntry && this.mACLEntry.hasAccessControl) { + let ownerIdentities = this.mACLEntry.getOwnerIdentities({}); + if (ownerIdentities.length > 0) { + let identity; + for (let i = 0; !attendee && i < ownerIdentities.length; i++) { + identity = "mailto:" + ownerIdentities[i].email.toLowerCase(); + attendee = aItem.getAttendeeById(identity); + } + } + } + + return attendee; + }, + + canNotify: function(aMethod, aItem) { + return false; // use outbound iTIP for all + } +}; diff --git a/calendar/base/modules/calRecurrenceUtils.jsm b/calendar/base/modules/calRecurrenceUtils.jsm new file mode 100644 index 000000000..ac7cf5fdb --- /dev/null +++ b/calendar/base/modules/calRecurrenceUtils.jsm @@ -0,0 +1,405 @@ +/* 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 recurrenceRule2String, splitRecurrenceRules, checkRecurrenceRule */ + +Components.utils.import("resource://gre/modules/PluralForm.jsm"); +Components.utils.import("resource://calendar/modules/calUtils.jsm"); + +this.EXPORTED_SYMBOLS = ["recurrenceRule2String", "splitRecurrenceRules", "checkRecurrenceRule"]; + +/** + * This function takes the recurrence info passed as argument and creates a + * literal string representing the repeat pattern in natural language. + * + * @param recurrenceInfo An item's recurrence info to parse. + * @param startDate The start date to base rules on. + * @param endDate The end date to base rules on. + * @param allDay If true, the pattern should assume an allday item. + * @return A human readable string describing the recurrence. + */ +function recurrenceRule2String(recurrenceInfo, startDate, endDate, allDay) { + function getRString(name, args) { + return cal.calGetString("calendar-event-dialog", name, args); + } + function day_of_week(day) { + return Math.abs(day) % 8; + } + function day_position(day) { + return (Math.abs(day) - day_of_week(day)) / 8 * (day < 0 ? -1 : 1); + } + function nounClass(aDayString, aRuleString) { + // Select noun class (grammatical gender) for rule string + let nounClassStr = getRString(aDayString + "Nounclass"); + return aRuleString + nounClassStr.substr(0, 1).toUpperCase() + + nounClassStr.substr(1); + } + function pluralWeekday(aDayString) { + let plural = getRString("pluralForWeekdays") == "true"; + return (plural ? aDayString + "Plural" : aDayString); + } + function everyWeekDay(aByDay) { + // Checks if aByDay contains only values from 1 to 7 with any order. + let mask = aByDay.reduce((value, item) => value | (1 << item), 1); + return aByDay.length == 7 && mask == Math.pow(2, 8) - 1; + } + + + // Retrieve a valid recurrence rule from the currently + // set recurrence info. Bail out if there's more + // than a single rule or something other than a rule. + recurrenceInfo = recurrenceInfo.clone(); + let rrules = splitRecurrenceRules(recurrenceInfo); + if (rrules[0].length == 1) { + let rule = cal.wrapInstance(rrules[0][0], Components.interfaces.calIRecurrenceRule); + // Currently we allow only for BYDAY, BYMONTHDAY, BYMONTH rules. + if (rule && + !checkRecurrenceRule(rule, ["BYSECOND", + "BYMINUTE", + // "BYDAY", + "BYHOUR", + // "BYMONTHDAY", + "BYYEARDAY", + "BYWEEKNO", + // "BYMONTH", + "BYSETPOS"])) { + let dateFormatter = cal.getDateFormatter(); + let ruleString; + if (rule.type == "DAILY") { + if (checkRecurrenceRule(rule, ["BYDAY"])) { + let days = rule.getComponent("BYDAY", {}); + let weekdays = [2, 3, 4, 5, 6]; + if (weekdays.length == days.length) { + let i; + for (i = 0; i < weekdays.length; i++) { + if (weekdays[i] != days[i]) { + break; + } + } + if (i == weekdays.length) { + ruleString = getRString("repeatDetailsRuleDaily4"); + } + } else { + return null; + } + } else { + let dailyString = getRString("dailyEveryNth"); + ruleString = PluralForm.get(rule.interval, dailyString) + .replace("#1", rule.interval); + } + } else if (rule.type == "WEEKLY") { + // weekly recurrence, currently we + // support a single 'BYDAY'-rule only. + if (checkRecurrenceRule(rule, ["BYDAY"])) { + // create a string like 'Monday, Tuesday and Wednesday' + let days = rule.getComponent("BYDAY", {}); + let weekdays = ""; + // select noun class (grammatical gender) according to the + // first day of the list + let weeklyString = nounClass("repeatDetailsDay" + days[0], "weeklyNthOn"); + for (let i = 0; i < days.length; i++) { + if (rule.interval == 1) { + weekdays += getRString(pluralWeekday("repeatDetailsDay" + days[i])); + } else { + weekdays += getRString("repeatDetailsDay" + days[i]); + } + if (days.length > 1 && i == (days.length - 2)) { + weekdays += " " + getRString("repeatDetailsAnd") + " "; + } else if (i < days.length - 1) { + weekdays += ", "; + } + } + + weeklyString = getRString(weeklyString, [weekdays]); + ruleString = PluralForm.get(rule.interval, weeklyString) + .replace("#2", rule.interval); + } else { + let weeklyString = getRString("weeklyEveryNth"); + ruleString = PluralForm.get(rule.interval, weeklyString) + .replace("#1", rule.interval); + } + } else if (rule.type == "MONTHLY") { + if (checkRecurrenceRule(rule, ["BYDAY"])) { + let byday = rule.getComponent("BYDAY", {}); + if (everyWeekDay(byday)) { + // Rule every day of the month. + ruleString = getRString("monthlyEveryDayOfNth"); + ruleString = PluralForm.get(rule.interval, ruleString) + .replace("#2", rule.interval); + } else { + // For rules with generic number of weekdays with and + // without "position" prefix we build two separate + // strings depending on the position and then join them. + // Notice: we build the description string but currently + // the UI can manage only rules with only one weekday. + let weekdaysString_every = ""; + let weekdaysString_position = ""; + let firstDay = byday[0]; + for (let i = 0; i < byday.length; i++) { + if (day_position(byday[i]) == 0) { + if (!weekdaysString_every) { + firstDay = byday[i]; + } + weekdaysString_every += getRString(pluralWeekday("repeatDetailsDay" + byday[i])) + ", "; + } else { + if (day_position(byday[i]) < -1 || day_position(byday[i]) > 5) { + // We support only weekdays with -1 as negative + // position ('THE LAST ...'). + return null; + } + + let duplicateWeekday = byday.some((element) => { + return (day_position(element) == 0 && + day_of_week(byday[i]) == day_of_week(element)); + }); + if (duplicateWeekday) { + // Prevent to build strings such as for example: + // "every Monday and the second Monday...". + continue; + } + + let ordinalString = "repeatOrdinal" + day_position(byday[i]); + let dayString = "repeatDetailsDay" + day_of_week(byday[i]); + ordinalString = nounClass(dayString, ordinalString); + ordinalString = getRString(ordinalString); + dayString = getRString(dayString); + let stringOrdinalWeekday = getRString("ordinalWeekdayOrder", + [ordinalString, dayString]); + weekdaysString_position += stringOrdinalWeekday + ", "; + } + } + let weekdaysString = weekdaysString_every + weekdaysString_position; + weekdaysString = weekdaysString.slice(0, -2) + .replace(/,(?= [^,]*$)/, + " " + getRString("repeatDetailsAnd")); + + let monthlyString = weekdaysString_every ? "monthlyEveryOfEvery" : "monthlyRuleNthOfEvery"; + monthlyString = nounClass("repeatDetailsDay" + day_of_week(firstDay), monthlyString); + monthlyString = getRString(monthlyString, [weekdaysString]); + ruleString = PluralForm.get(rule.interval, monthlyString) + .replace("#2", rule.interval); + } + } else if (checkRecurrenceRule(rule, ["BYMONTHDAY"])) { + let component = rule.getComponent("BYMONTHDAY", {}); + + // First, find out if the 'BYMONTHDAY' component contains + // any elements with a negative value lesser than -1 ("the + // last day"). If so we currently don't support any rule + if (component.some(element => element < -1)) { + // we don't support any other combination for now... + return getRString("ruleTooComplex"); + } else if (component.length == 1 && component[0] == -1) { + // i.e. one day, the last day of the month + let monthlyString = getRString("monthlyLastDayOfNth"); + ruleString = PluralForm.get(rule.interval, monthlyString) + .replace("#1", rule.interval); + } else { + // i.e. one or more monthdays every N months. + + // Build a string with a list of days separated with commas. + let day_string = ""; + let lastDay = false; + for (let i = 0; i < component.length; i++) { + if (component[i] == -1) { + lastDay = true; + continue; + } + day_string += dateFormatter.formatDayWithOrdinal(component[i]) + ", "; + } + if (lastDay) { + day_string += getRString("monthlyLastDay") + ", "; + } + day_string = day_string.slice(0, -2) + .replace(/,(?= [^,]*$)/, + " " + getRString("repeatDetailsAnd")); + + // Add the word "day" in plural form to the list of days then + // compose the final string with the interval of months + let monthlyDayString = getRString("monthlyDaysOfNth_day", [day_string]); + monthlyDayString = PluralForm.get(component.length, monthlyDayString); + let monthlyString = getRString("monthlyDaysOfNth", [monthlyDayString]); + ruleString = PluralForm.get(rule.interval, monthlyString) + .replace("#2", rule.interval); + } + } else { + let monthlyString = getRString("monthlyDaysOfNth", [startDate.day]); + ruleString = PluralForm.get(rule.interval, monthlyString) + .replace("#2", rule.interval); + } + } else if (rule.type == "YEARLY") { + let bymonthday = null; + let bymonth = null; + if (checkRecurrenceRule(rule, ["BYMONTHDAY"])) { + bymonthday = rule.getComponent("BYMONTHDAY", {}); + } + if (checkRecurrenceRule(rule, ["BYMONTH"])) { + bymonth = rule.getComponent("BYMONTH", {}); + } + if ((bymonth && bymonth.length > 1) || + (bymonthday && (bymonthday.length > 1 || bymonthday[0] < -1))) { + // Don't build a string for a recurrence rule that the UI + // currently can't show completely (with more than one month + // or than one monthday, or bymonthdays lesser than -1). + return getRString("ruleTooComplex"); + } + + if (checkRecurrenceRule(rule, ["BYMONTHDAY"]) && + (checkRecurrenceRule(rule, ["BYMONTH"]) || !checkRecurrenceRule(rule, ["BYDAY"]))) { + // RRULE:FREQ=YEARLY;BYMONTH=x;BYMONTHDAY=y. + // RRULE:FREQ=YEARLY;BYMONTHDAY=x (takes the month from the start date). + let monthNumber = bymonth ? bymonth[0] : (startDate.month + 1); + let month = getRString("repeatDetailsMonth" + monthNumber); + let monthDay = bymonthday[0] == -1 ? getRString("monthlyLastDay") + : dateFormatter.formatDayWithOrdinal(bymonthday[0]); + let yearlyString = getRString("yearlyNthOn", [month, monthDay]); + ruleString = PluralForm.get(rule.interval, yearlyString) + .replace("#3", rule.interval); + } else if (checkRecurrenceRule(rule, ["BYMONTH"]) && + checkRecurrenceRule(rule, ["BYDAY"])) { + // RRULE:FREQ=YEARLY;BYMONTH=x;BYDAY=y1,y2,.... + let byday = rule.getComponent("BYDAY", {}); + let month = getRString("repeatDetailsMonth" + bymonth[0]); + if (everyWeekDay(byday)) { + // Every day of the month. + let yearlyString = "yearlyEveryDayOf"; + yearlyString = getRString(yearlyString, [month]); + ruleString = PluralForm.get(rule.interval, yearlyString) + .replace("#2", rule.interval); + } else if (byday.length == 1) { + let dayString = "repeatDetailsDay" + day_of_week(byday[0]); + if (day_position(byday[0]) == 0) { + // Every any weekday. + let yearlyString = "yearlyOnEveryNthOfNth"; + yearlyString = nounClass(dayString, yearlyString); + let day = getRString(pluralWeekday(dayString)); + yearlyString = getRString(yearlyString, [day, month]); + ruleString = PluralForm.get(rule.interval, yearlyString) + .replace("#3", rule.interval); + } else if (day_position(byday[0]) >= -1 || + day_position(byday[0]) <= 5) { + // The first|the second|...|the last Monday, Tuesday, ..., day. + let yearlyString = "yearlyNthOnNthOf"; + yearlyString = nounClass(dayString, yearlyString); + let ordinalString = "repeatOrdinal" + day_position(byday[0]); + ordinalString = nounClass(dayString, ordinalString); + let ordinal = getRString(ordinalString); + let day = getRString(dayString); + yearlyString = getRString(yearlyString, [ordinal, day, month]); + ruleString = PluralForm.get(rule.interval, yearlyString) + .replace("#4", rule.interval); + } else { + return getRString("ruleTooComplex"); + } + } else { + // Currently we don't support yearly rules with + // more than one BYDAY element or exactly 7 elements + // with all the weekdays (the "every day" case). + return getRString("ruleTooComplex"); + } + } else if (checkRecurrenceRule(rule, ["BYMONTH"])) { + // RRULE:FREQ=YEARLY;BYMONTH=x (takes the day from the start date). + let month = getRString("repeatDetailsMonth" + bymonth[0]); + let yearlyString = getRString("yearlyNthOn", [month, startDate.day]); + ruleString = PluralForm.get(rule.interval, yearlyString) + .replace("#3", rule.interval); + } else { + let month = getRString("repeatDetailsMonth" + (startDate.month + 1)); + let yearlyString = getRString("yearlyNthOn", [month, startDate.day]); + ruleString = PluralForm.get(rule.interval, yearlyString) + .replace("#3", rule.interval); + } + } + + let kDefaultTimezone = cal.calendarDefaultTimezone(); + + let detailsString; + if (!endDate || allDay) { + if (rule.isFinite) { + if (rule.isByCount) { + let countString = getRString("repeatCountAllDay", + [ruleString, + dateFormatter.formatDateShort(startDate)]); + detailsString = PluralForm.get(rule.count, countString) + .replace("#3", rule.count); + } else { + let untilDate = rule.untilDate.getInTimezone(kDefaultTimezone); + detailsString = getRString("repeatDetailsUntilAllDay", + [ruleString, + dateFormatter.formatDateShort(startDate), + dateFormatter.formatDateShort(untilDate)]); + } + } else { + detailsString = getRString("repeatDetailsInfiniteAllDay", + [ruleString, + dateFormatter.formatDateShort(startDate)]); + } + } else if (rule.isFinite) { + if (rule.isByCount) { + let countString = getRString("repeatCount", + [ruleString, + dateFormatter.formatDateShort(startDate), + dateFormatter.formatTime(startDate), + dateFormatter.formatTime(endDate)]); + detailsString = PluralForm.get(rule.count, countString) + .replace("#5", rule.count); + } else { + let untilDate = rule.untilDate.getInTimezone(kDefaultTimezone); + detailsString = getRString("repeatDetailsUntil", + [ruleString, + dateFormatter.formatDateShort(startDate), + dateFormatter.formatDateShort(untilDate), + dateFormatter.formatTime(startDate), + dateFormatter.formatTime(endDate)]); + } + } else { + detailsString = getRString("repeatDetailsInfinite", + [ruleString, + dateFormatter.formatDateShort(startDate), + dateFormatter.formatTime(startDate), + dateFormatter.formatTime(endDate)]); + } + return detailsString; + } + } + return null; +} + +/** + * Split rules into negative and positive rules. + * + * @param recurrenceInfo An item's recurrence info to parse. + * @return An array with two elements: an array of positive + * rules and an array of negative rules. + */ +function splitRecurrenceRules(recurrenceInfo) { + let ritems = recurrenceInfo.getRecurrenceItems({}); + let rules = []; + let exceptions = []; + for (let ritem of ritems) { + if (ritem.isNegative) { + exceptions.push(ritem); + } else { + rules.push(ritem); + } + } + return [rules, exceptions]; +} + +/** + * Check if a recurrence rule's component is valid. + * + * @see calIRecurrenceRule + * @param aRule The recurrence rule to check. + * @param aArray An array of component names to check. + * @return Returns true if the rule is valid. + */ +function checkRecurrenceRule(aRule, aArray) { + for (let comp of aArray) { + let ruleComp = aRule.getComponent(comp, {}); + if (ruleComp && ruleComp.length > 0) { + return true; + } + } + return false; +} diff --git a/calendar/base/modules/calUtils.jsm b/calendar/base/modules/calUtils.jsm new file mode 100644 index 000000000..84216089e --- /dev/null +++ b/calendar/base/modules/calUtils.jsm @@ -0,0 +1,1001 @@ +/* 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/. */ + +// New code must not load/import calUtils.js, but should use calUtils.jsm. + +var gCalThreadingEnabled; + +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); +Components.utils.import("resource://gre/modules/Preferences.jsm"); + +// Usually the backend loader gets loaded via profile-after-change, but in case +// a calendar component hooks in earlier, its very likely it will use calUtils. +// Getting the service here will load if its not already loaded +Components.classes["@mozilla.org/calendar/backend-loader;1"].getService(); + +this.EXPORTED_SYMBOLS = ["cal"]; +var cal = { + // new code should land here, + // and more code should be moved from calUtils.js into this object to avoid + // clashes with other extensions + + getDragService: generateServiceAccessor("@mozilla.org/widget/dragservice;1", + Components.interfaces.nsIDragService), + + /** + * Loads an array of calendar scripts into the passed scope. + * + * @param scriptNames an array of calendar script names + * @param scope scope to load into + * @param baseDir base dir; defaults to calendar-js/ + */ + loadScripts: function(scriptNames, scope, baseDir) { + if (!baseDir) { + baseDir = __LOCATION__.parent.parent.clone(); + baseDir.append("calendar-js"); + } + + for (let script of scriptNames) { + if (!script) { + // If the array element is null, then just skip this script. + continue; + } + let scriptFile = baseDir.clone(); + scriptFile.append(script); + let scriptUrlSpec = Services.io.newFileURI(scriptFile).spec; + try { + Services.scriptloader.loadSubScript(scriptUrlSpec, scope); + } catch (exc) { + Components.utils.reportError(exc + " (" + scriptUrlSpec + ")"); + } + } + }, + + loadingNSGetFactory: function(scriptNames, components, scope) { + return function(cid) { + if (!this.inner) { + let global = Components.utils.getGlobalForObject(scope); + cal.loadScripts(scriptNames, global); + if (typeof components == "function") { + components = components.call(global); + } + this.inner = XPCOMUtils.generateNSGetFactory(components); + } + return this.inner(cid); + }; + }, + + /** + * Schedules execution of the passed function to the current thread's queue. + */ + postPone: function(func) { + if (this.threadingEnabled) { + Services.tm.currentThread.dispatch({ run: func }, + Components.interfaces.nsIEventTarget.DISPATCH_NORMAL); + } else { + func(); + } + }, + + /** + * Create an adapter for the given interface. If passed, methods will be + * added to the template object, otherwise a new object will be returned. + * + * @param iface The interface to adapt, either using + * Components.interfaces or the name as a string. + * @param template (optional) A template object to extend + * @return If passed the adapted template object, otherwise a + * clean adapter. + * + * Currently supported interfaces are: + * - calIObserver + * - calICalendarManagerObserver + * - calIOperationListener + * - calICompositeObserver + */ + createAdapter: function(iface, template) { + let methods; + let adapter = template || {}; + switch (iface.name || iface) { + case "calIObserver": + methods = ["onStartBatch", "onEndBatch", "onLoad", "onAddItem", + "onModifyItem", "onDeleteItem", "onError", + "onPropertyChanged", "onPropertyDeleting"]; + break; + case "calICalendarManagerObserver": + methods = ["onCalendarRegistered", "onCalendarUnregistering", + "onCalendarDeleting"]; + break; + case "calIOperationListener": + methods = ["onGetResult", "onOperationComplete"]; + break; + case "calICompositeObserver": + methods = ["onCalendarAdded", "onCalendarRemoved", + "onDefaultCalendarChanged"]; + break; + default: + methods = []; + break; + } + + for (let method of methods) { + if (!(method in template)) { + adapter[method] = function() {}; + } + } + adapter.QueryInterface = XPCOMUtils.generateQI([iface]); + + return adapter; + }, + + get threadingEnabled() { + if (gCalThreadingEnabled === undefined) { + gCalThreadingEnabled = !Preferences.get("calendar.threading.disabled", false); + } + return gCalThreadingEnabled; + }, + + /* + * Checks whether a calendar supports events + * + * @param aCalendar + */ + isEventCalendar: function(aCalendar) { + return (aCalendar.getProperty("capabilities.events.supported") !== false); + }, + + /* + * Checks whether a calendar supports tasks + * + * @param aCalendar + */ + isTaskCalendar: function(aCalendar) { + return (aCalendar.getProperty("capabilities.tasks.supported") !== false); + }, + + /** + * Checks whether a timezone lacks a definition. + */ + isPhantomTimezone: function(timezone) { + return (!timezone.icalComponent && !timezone.isUTC && !timezone.isFloating); + }, + + /** + * Shifts an item by the given timely offset. + * + * @param item an item + * @param offset an offset (calIDuration) + */ + shiftItem: function(item, offset) { + // When modifying dates explicitly using the setters is important + // since those may triggers e.g. calIRecurrenceInfo::onStartDateChange + // or invalidate other properties. Moreover don't modify the date-time objects + // without cloning, because changes cannot be calculated if doing so. + if (cal.isEvent(item)) { + let date = item.startDate.clone(); + date.addDuration(offset); + item.startDate = date; + date = item.endDate.clone(); + date.addDuration(offset); + item.endDate = date; + } else /* isToDo */ { + if (item.entryDate) { + let date = item.entryDate.clone(); + date.addDuration(offset); + item.entryDate = date; + } + if (item.dueDate) { + let date = item.dueDate.clone(); + date.addDuration(offset); + item.dueDate = date; + } + } + }, + + /** + * Returns a copy of an event that + * - has a relation set to the original event + * - has the same organizer but + * - has any attendee removed + * Intended to get a copy of a normal event invitation that behaves as if the PUBLISH method + * was chosen instead. + * + * @param aItem original item + * @param aUid (optional) UID to use for the new item + */ + getPublishLikeItemCopy: function(aItem, aUid) { + // avoid changing aItem + let item = aItem.clone(); + // reset to a new UUID if applicable + item.id = aUid || cal.getUUID(); + // add a relation to the original item + let relation = cal.createRelation(); + relation.relId = aItem.id; + relation.relType = "SIBLING"; + item.addRelation(relation); + // remove attendees + item.removeAllAttendees(); + if (!aItem.isMutable) { + item = item.makeImmutable(); + } + return item; + }, + + /** + * Shortcut function to serialize an item (including all overridden items). + */ + getSerializedItem: function(aItem) { + let serializer = Components.classes["@mozilla.org/calendar/ics-serializer;1"] + .createInstance(Components.interfaces.calIIcsSerializer); + serializer.addItems([aItem], 1); + return serializer.serializeToString(); + }, + + /** + * Shortcut function to check whether an item is an invitation copy. + */ + isInvitation: function(aItem) { + let isInvitation = false; + let calendar = cal.wrapInstance(aItem.calendar, Components.interfaces.calISchedulingSupport); + if (calendar) { + isInvitation = calendar.isInvitation(aItem); + } + return isInvitation; + }, + + /** + * Returns a basically checked recipient list - malformed elements will be removed + * + * @param string aRecipients a comma-seperated list of e-mail addresses + * @return string a comma-seperated list of e-mail addresses + */ + validateRecipientList: function(aRecipients) { + let compFields = Components.classes["@mozilla.org/messengercompose/composefields;1"] + .createInstance(Components.interfaces.nsIMsgCompFields); + // Resolve the list considering also configured common names + let members = compFields.splitRecipients(aRecipients, false, {}); + let list = []; + let prefix = ""; + for (let member of members) { + if (prefix != "") { + // the previous member had no email address - this happens if a recipients CN + // contains a ',' or ';' (splitRecipients(..) behaves wrongly here and produces an + // additional member with only the first CN part of that recipient and no email + // address while the next has the second part of the CN and the according email + // address) - we still need to identify the original delimiter to append it to the + // prefix + let memberCnPart = member.match(/(.*) <.*>/); + if (memberCnPart) { + let pattern = new RegExp(prefix + "([;,] *)" + memberCnPart[1]); + let delimiter = aRecipients.match(pattern); + if (delimiter) { + prefix = prefix + delimiter[1]; + } + } + } + let parts = (prefix + member).match(/(.*)( <.*>)/); + if (parts) { + if (parts[2] == " <>") { + // CN but no email address - we keep the CN part to prefix the next member's CN + prefix = parts[1]; + } else { + // CN with email address + let commonName = parts[1].trim(); + // in case of any special characters in the CN string, we make sure to enclose + // it with dquotes - simple spaces don't require dquotes + if (commonName.match(/[\-\[\]{}()*+?.,;\\\^$|#\f\n\r\t\v]/)) { + commonName = '"' + commonName.replace(/\\"|"/, "").trim() + '"'; + } + list.push(commonName + parts[2]); + prefix = ""; + } + } else if (member.length) { + // email address only + list.push(member); + prefix = ""; + } + } + return list.join(", "); + }, + + /** + * Shortcut function to check whether an item is an invitation copy and + * has a participation status of either NEEDS-ACTION or TENTATIVE. + * + * @param aItem either calIAttendee or calIItemBase + */ + isOpenInvitation: function(aItem) { + let wrappedItem = cal.wrapInstance(aItem, Components.interfaces.calIAttendee); + if (!wrappedItem) { + aItem = cal.getInvitedAttendee(aItem); + } + if (aItem) { + switch (aItem.participationStatus) { + case "NEEDS-ACTION": + case "TENTATIVE": + return true; + } + } + return false; + }, + + /** + * Prepends a mailto: prefix to an email address like string + * + * @param {string} the string to prepend the prefix if not already there + * @return {string} the string with prefix + */ + prependMailTo: function(aId) { + return aId.replace(/^(?:mailto:)?(.*)@/i, "mailto:$1@"); + }, + + /** + * Removes an existing mailto: prefix from an attendee id + * + * @param {string} the string to remove the prefix from if any + * @return {string} the string without prefix + */ + removeMailTo: function(aId) { + return aId.replace(/^mailto:/i, ""); + }, + + /** + * Resolves delegated-to/delegated-from calusers for a given attendee to also include the + * respective CNs if available in a given set of attendees + * + * @param aAttendee {calIAttendee} The attendee to resolve the delegation information for + * @param aAttendees {Array} An array of calIAttendee objects to look up + * @return {Object} An object with string attributes for delegators and delegatees + */ + resolveDelegation: function(aAttendee, aAttendees) { + let attendees = aAttendees || [aAttendee]; + + // this will be replaced by a direct property getter in calIAttendee + let delegators = []; + let delegatees = []; + let delegatorProp = aAttendee.getProperty("DELEGATED-FROM"); + if (delegatorProp) { + delegators = typeof delegatorProp == "string" ? [delegatorProp] : delegatorProp; + } + let delegateeProp = aAttendee.getProperty("DELEGATED-TO"); + if (delegateeProp) { + delegatees = typeof delegateeProp == "string" ? [delegateeProp] : delegateeProp; + } + + for (let att of attendees) { + let resolveDelegation = function(e, i, a) { + if (e == att.id) { + a[i] = att.toString(); + } + }; + delegators.forEach(resolveDelegation); + delegatees.forEach(resolveDelegation); + } + return { + delegatees: delegatees.join(", "), + delegators: delegators.join(", ") + }; + }, + + /** + * Shortcut function to get the invited attendee of an item. + */ + getInvitedAttendee: function(aItem, aCalendar) { + if (!aCalendar) { + aCalendar = aItem.calendar; + } + let invitedAttendee = null; + let calendar = cal.wrapInstance(aCalendar, Components.interfaces.calISchedulingSupport); + if (calendar) { + invitedAttendee = calendar.getInvitedAttendee(aItem); + } + return invitedAttendee; + }, + + /** + * Returns all attendees from given set of attendees matching based on the attendee id + * or a sent-by parameter compared to the specified email address + * + * @param {Array} aAttendees An array of calIAttendee objects + * @param {String} aEmailAddress A string containing the email address for lookup + * @return {Array} Returns an array of matching attendees + */ + getAttendeesBySender: function(aAttendees, aEmailAddress) { + let attendees = []; + // we extract the email address to make it work also for a raw header value + let compFields = Components.classes["@mozilla.org/messengercompose/composefields;1"] + .createInstance(Components.interfaces.nsIMsgCompFields); + let addresses = compFields.splitRecipients(aEmailAddress, true, {}); + if (addresses.length == 1) { + let searchFor = cal.prependMailTo(addresses[0]); + aAttendees.forEach(aAttendee => { + if ([aAttendee.id, aAttendee.getProperty("SENT-BY")].includes(searchFor)) { + attendees.push(aAttendee); + } + }); + } else { + cal.WARN("No unique email address for lookup!"); + } + return attendees; + }, + + /** + * Returns a wellformed email string like 'attendee@example.net', + * 'Common Name <attendee@example.net>' or '"Name, Common" <attendee@example.net>' + * + * @param {calIAttendee} aAttendee - the attendee to check + * @param {boolean} aIncludeCn - whether or not to return also the CN if available + * @return {string} valid email string or an empty string in case of error + */ + getAttendeeEmail: function(aAttendee, aIncludeCn) { + // If the recipient id is of type urn, we need to figure out the email address, otherwise + // we fall back to the attendee id + let email = aAttendee.id.match(/^urn:/i) ? aAttendee.getProperty("EMAIL") || "" : aAttendee.id; + // Strip leading "mailto:" if it exists. + email = email.replace(/^mailto:/i, ""); + // We add the CN if requested and available + let commonName = aAttendee.commonName; + if (aIncludeCn && email.length > 0 && commonName && commonName.length > 0) { + if (commonName.match(/[,;]/)) { + commonName = '"' + commonName + '"'; + } + commonName = commonName + " <" + email + ">"; + if (cal.validateRecipientList(commonName) == commonName) { + email = commonName; + } + } + return email; + }, + + /** + * Provides a string to use in email "to" header for given attendees + * + * @param {array} aAttendees - array of calIAttendee's to check + * @return {string} Valid string to use in a 'to' header of an email + */ + getRecipientList: function(aAttendees) { + let cbEmail = function(aVal, aInd, aArr) { + let email = cal.getAttendeeEmail(aVal, true); + if (!email.length) { + cal.LOG("Dropping invalid recipient for email transport: " + aVal.toString()); + } + return email; + }; + return aAttendees.map(cbEmail) + .filter(aVal => aVal.length > 0) + .join(", "); + }, + + /** + * Returns the default transparency to apply for an event depending on whether its an all-day event + * + * @param aIsAllDay If true, the default transparency for all-day events is returned + */ + getEventDefaultTransparency: function(aIsAllDay) { + let transp = null; + if (aIsAllDay) { + transp = Preferences.get("calendar.events.defaultTransparency.allday.transparent", false) + ? "TRANSPARENT" + : "OPAQUE"; + } else { + transp = Preferences.get("calendar.events.defaultTransparency.standard.transparent", false) + ? "TRANSPARENT" + : "OPAQUE"; + } + return transp; + }, + + // The below functions will move to some different place once the + // unifinder tress are consolidated. + + compareNativeTime: function(a, b) { + if (a < b) { + return -1; + } else if (a > b) { + return 1; + } else { + return 0; + } + }, + + compareNativeTimeFilledAsc: function(a, b) { + if (a == b) { + return 0; + } + + // In this filter, a zero time (not set) is always at the end. + if (a == -62168601600000000) { // value for (0000/00/00 00:00:00) + return 1; + } + if (b == -62168601600000000) { // value for (0000/00/00 00:00:00) + return -1; + } + + return (a < b ? -1 : 1); + }, + + compareNativeTimeFilledDesc: function(a, b) { + if (a == b) { + return 0; + } + + // In this filter, a zero time (not set) is always at the end. + if (a == -62168601600000000) { // value for (0000/00/00 00:00:00) + return 1; + } + if (b == -62168601600000000) { // value for (0000/00/00 00:00:00) + return -1; + } + + return (a < b ? 1 : -1); + }, + + compareNumber: function(a, b) { + a = Number(a); + b = Number(b); + if (a < b) { + return -1; + } else if (a > b) { + return 1; + } else { + return 0; + } + }, + + sortEntryComparer: function(sortType, modifier) { + switch (sortType) { + case "number": + return function(sortEntryA, sortEntryB) { + let nsA = cal.sortEntryKey(sortEntryA); + let nsB = cal.sortEntryKey(sortEntryB); + return cal.compareNumber(nsA, nsB) * modifier; + }; + case "date": + return function(sortEntryA, sortEntryB) { + let nsA = cal.sortEntryKey(sortEntryA); + let nsB = cal.sortEntryKey(sortEntryB); + return cal.compareNativeTime(nsA, nsB) * modifier; + }; + case "date_filled": + return function(sortEntryA, sortEntryB) { + let nsA = cal.sortEntryKey(sortEntryA); + let nsB = cal.sortEntryKey(sortEntryB); + if (modifier == 1) { + return cal.compareNativeTimeFilledAsc(nsA, nsB); + } else { + return cal.compareNativeTimeFilledDesc(nsA, nsB); + } + }; + case "string": + return function(sortEntryA, sortEntryB) { + let seA = cal.sortEntryKey(sortEntryA); + let seB = cal.sortEntryKey(sortEntryB); + if (seA.length == 0 || seB.length == 0) { + // sort empty values to end (so when users first sort by a + // column, they can see and find the desired values in that + // column without scrolling past all the empty values). + return -(seA.length - seB.length) * modifier; + } + let collator = cal.createLocaleCollator(); + let comparison = collator.compareString(0, seA, seB); + return comparison * modifier; + }; + default: + return function(sortEntryA, sortEntryB) { + return 0; + }; + } + }, + + getItemSortKey: function(aItem, aKey, aStartTime) { + switch (aKey) { + case "priority": + return aItem.priority || 5; + + case "title": + return aItem.title || ""; + + case "entryDate": + return cal.nativeTime(aItem.entryDate); + + case "startDate": + return cal.nativeTime(aItem.startDate); + + case "dueDate": + return cal.nativeTime(aItem.dueDate); + + case "endDate": + return cal.nativeTime(aItem.endDate); + + case "completedDate": + return cal.nativeTime(aItem.completedDate); + + case "percentComplete": + return aItem.percentComplete; + + case "categories": + return aItem.getCategories({}).join(", "); + + case "location": + return aItem.getProperty("LOCATION") || ""; + + case "status": + if (cal.isToDo(aItem)) { + return ["NEEDS-ACTION", "IN-PROCESS", "COMPLETED", "CANCELLED"].indexOf(aItem.status); + } else { + return ["TENTATIVE", "CONFIRMED", "CANCELLED"].indexOf(aItem.status); + } + case "calendar": + return aItem.calendar.name || ""; + + default: + return null; + } + }, + + getSortTypeForSortKey: function(aSortKey) { + switch (aSortKey) { + case "title": + case "categories": + case "location": + case "calendar": + return "string"; + + // All dates use "date_filled" + case "completedDate": + case "startDate": + case "endDate": + case "dueDate": + case "entryDate": + return "date_filled"; + + case "priority": + case "percentComplete": + case "status": + return "number"; + default: + return "unknown"; + } + }, + + nativeTimeOrNow: function(calDateTime, sortStartedTime) { + // Treat null/0 as 'now' when sort started, so incomplete tasks stay current. + // Time is computed once per sort (just before sort) so sort is stable. + if (calDateTime == null) { + return sortStartedTime.nativeTime; + } + let nativeTime = calDateTime.nativeTime; + if (nativeTime == -62168601600000000) { // nativeTime value for (0000/00/00 00:00:00) + return sortStartedTime; + } + return nativeTime; + }, + + nativeTime: function(calDateTime) { + if (calDateTime == null) { + return -62168601600000000; // ns value for (0000/00/00 00:00:00) + } + return calDateTime.nativeTime; + }, + + /** + * Returns a calIDateTime corresponding to a javascript Date. + * + * @param aDate a javascript date + * @param aTimezone (optional) a timezone that should be enforced + * @returns a calIDateTime + * + * @warning Use of this function is strongly discouraged. calIDateTime should + * be used directly whenever possible. + * If you pass a timezone, then the passed jsDate's timezone will be ignored, + * but only its local time portions are be taken. + */ + jsDateToDateTime: function(aDate, aTimezone) { + let newDate = cal.createDateTime(); + if (aTimezone) { + newDate.resetTo(aDate.getFullYear(), + aDate.getMonth(), + aDate.getDate(), + aDate.getHours(), + aDate.getMinutes(), + aDate.getSeconds(), + aTimezone); + } else { + newDate.nativeTime = aDate.getTime() * 1000; + } + return newDate; + }, + + /** + * Convert a calIDateTime to a Javascript date object. This is the + * replacement for the former .jsDate property. + * + * @param cdt The calIDateTime instnace + * @return The Javascript date equivalent. + */ + dateTimeToJsDate: function(cdt) { + if (cdt.timezone.isFloating) { + return new Date(cdt.year, cdt.month, cdt.day, + cdt.hour, cdt.minute, cdt.second); + } else { + return new Date(cdt.nativeTime / 1000); + } + }, + + sortEntry: function(aItem) { + let key = cal.getItemSortKey(aItem, this.mSortKey, this.mSortStartedDate); + return { mSortKey: key, mItem: aItem }; + }, + + sortEntryItem: function(sortEntry) { + return sortEntry.mItem; + }, + + sortEntryKey: function(sortEntry) { + return sortEntry.mSortKey; + }, + + createLocaleCollator: function() { + return Components.classes["@mozilla.org/intl/collation-factory;1"] + .getService(Components.interfaces.nsICollationFactory) + .CreateCollation(Services.locale.getApplicationLocale()); + }, + + /** + * Sort an array of strings according to the current locale. + * Modifies aStringArray, returning it sorted. + */ + sortArrayByLocaleCollator: function(aStringArray) { + let localeCollator = cal.createLocaleCollator(); + function compare(a, b) { return localeCollator.compareString(0, a, b); } + aStringArray.sort(compare); + return aStringArray; + }, + + /** + * Gets the month name string in the right form depending on a base string. + * + * @param aMonthNum The month numer to get, 1-based. + * @param aBundleName The Bundle to get the string from + * @param aStringBase The base string name, .monthFormat will be appended + */ + formatMonth: function(aMonthNum, aBundleName, aStringBase) { + let monthForm = cal.calGetString(aBundleName, aStringBase + ".monthFormat") || "nominative"; + + if (monthForm == "nominative") { + // Fall back to the default name format + monthForm = "name"; + } + + return cal.calGetString("dateFormat", "month." + aMonthNum + "." + monthForm); + }, + + /** + * moves an item to another startDate + * + * @param aOldItem The Item to be modified + * @param aNewDate The date at which the new item is going to start + * @return The modified item + */ + moveItem: function(aOldItem, aNewDate) { + let newItem = aOldItem.clone(); + let start = (aOldItem[calGetStartDateProp(aOldItem)] || + aOldItem[calGetEndDateProp(aOldItem)]).clone(); + let isDate = start.isDate; + start.resetTo(aNewDate.year, aNewDate.month, aNewDate.day, + start.hour, start.minute, start.second, + start.timezone); + start.isDate = isDate; + if (newItem[calGetStartDateProp(newItem)]) { + newItem[calGetStartDateProp(newItem)] = start; + let oldDuration = aOldItem.duration; + if (oldDuration) { + let oldEnd = aOldItem[calGetEndDateProp(aOldItem)]; + let newEnd = start.clone(); + newEnd.addDuration(oldDuration); + newEnd = newEnd.getInTimezone(oldEnd.timezone); + newItem[calGetEndDateProp(newItem)] = newEnd; + } + } else if (newItem[calGetEndDateProp(newItem)]) { + newItem[calGetEndDateProp(newItem)] = start; + } + return newItem; + }, + + /** + * sets the 'isDate' property of an item + * + * @param aItem The Item to be modified + * @param aIsDate True or false indicating the new value of 'isDate' + * @return The modified item + */ + setItemToAllDay: function(aItem, aIsDate) { + let start = aItem[calGetStartDateProp(aItem)]; + let end = aItem[calGetEndDateProp(aItem)]; + if (start || end) { + let item = aItem.clone(); + if (start && (start.isDate != aIsDate)) { + start = start.clone(); + start.isDate = aIsDate; + item[calGetStartDateProp(item)] = start; + } + if (end && (end.isDate != aIsDate)) { + end = end.clone(); + end.isDate = aIsDate; + item[calGetEndDateProp(item)] = end; + } + return item; + } else { + return aItem; + } + }, + + /** + * checks if the mousepointer of an event resides over a XULBox during an event + * + * @param aMouseEvent The event eg. a 'mouseout' or 'mousedown' event + * @param aXULBox The xul element + * @return true or false depending on whether the mouse pointer + * resides over the xulelement + */ + isMouseOverBox: function(aMouseEvent, aXULElement) { + let boxObject = aXULElement.boxObject; + let boxWidth = boxObject.width; + let boxHeight = boxObject.height; + let boxScreenX = boxObject.screenX; + let boxScreenY = boxObject.screenY; + let mouseX = aMouseEvent.screenX; + let mouseY = aMouseEvent.screenY; + let xIsWithin = (mouseX >= boxScreenX) && + (mouseX <= (boxScreenX + boxWidth)); + let yIsWithin = (mouseY >= boxScreenY) && + (mouseY <= (boxScreenY + boxHeight)); + return (xIsWithin && yIsWithin); + }, + + /** + * removes those childnodes from a node that contain a specified attribute + * and where the value of this attribute matches a passed value + * @param aParentNode The parent node that contains the child nodes in question + * @param aAttribute The name of the attribute + * @param aAttribute The value of the attribute + */ + removeChildElementsByAttribute: function(aParentNode, aAttribute, aValue) { + let childNode = aParentNode.lastChild; + while (childNode) { + let prevChildNode = childNode.previousSibling; + if (!aAttribute || aAttribute === undefined) { + childNode.remove(); + } else if (!aValue || aValue === undefined) { + childNode.remove(); + } else if (childNode && childNode.hasAttribute(aAttribute) && + childNode.getAttribute(aAttribute) == aValue) { + childNode.remove(); + } + childNode = prevChildNode; + } + }, + + /** + * Returns the most recent calendar window in an application independent way + */ + getCalendarWindow: function() { + return Services.wm.getMostRecentWindow("calendarMainWindow") || + Services.wm.getMostRecentWindow("mail:3pane"); + }, + + /** + * Adds an observer listening for the topic. + * + * @param func function to execute on topic + * @param topic topic to listen for + * @param oneTime whether to listen only once + */ + addObserver: function(func, topic, oneTime) { + let observer = { // nsIObserver: + observe: function(subject, topic_, data) { + if (topic == topic_) { + if (oneTime) { + Services.obs.removeObserver(this, topic); + } + func(subject, topic, data); + } + } + }; + Services.obs.addObserver(observer, topic, false /* don't hold weakly */); + }, + + /** + * Wraps an instance. Replaces calInstanceOf from calUtils.js + * + * @param aObj the object under consideration + * @param aInterface the interface to be wrapped + * + * Use this function to QueryInterface the object to a particular interface. + * You may only expect the return value to be wrapped, not the original passed object. + * For example: + * // BAD USAGE: + * if (cal.wrapInstance(foo, Ci.nsIBar)) { + * foo.barMethod(); + * } + * // GOOD USAGE: + * foo = cal.wrapInstance(foo, Ci.nsIBar); + * if (foo) { + * foo.barMethod(); + * } + * + */ + wrapInstance: function(aObj, aInterface) { + if (!aObj) { + return null; + } + + try { + return aObj.QueryInterface(aInterface); + } catch (e) { + return null; + } + }, + + /** + * Adds an xpcom shutdown observer. + * + * @param func function to execute + */ + addShutdownObserver: function(func) { + cal.addObserver(func, "xpcom-shutdown", true /* one time */); + }, + + /** + * Due to wrapped js objects, some objects may have cyclic references. + * You can register properties of objects to be cleaned up on xpcom-shutdown. + * + * @param obj object + * @param prop property to be deleted on shutdown + * (if null, |object| will be deleted) + */ + registerForShutdownCleanup: shutdownCleanup +}; + +// local to this module; +// will be used to clean up global objects on shutdown +// some objects have cyclic references due to wrappers +function shutdownCleanup(obj, prop) { + if (!shutdownCleanup.mEntries) { + shutdownCleanup.mEntries = []; + cal.addShutdownObserver(() => { + for (let entry of shutdownCleanup.mEntries) { + if (entry.mProp) { + delete entry.mObj[entry.mProp]; + } else { + delete entry.mObj; + } + } + delete shutdownCleanup.mEntries; + }); + } + shutdownCleanup.mEntries.push({ mObj: obj, mProp: prop }); +} + +// local to this module; +// will be used to generate service accessor functions +function generateServiceAccessor(id, iface) { + // eslint-disable-next-line func-names + return function this_() { + if (!("mService" in this_)) { + this_.mService = Components.classes[id].getService(iface); + shutdownCleanup(this_, "mService"); + } + return this_.mService; + }; +} + +// Interim import of all symbols into cal: +// This should serve as a clean start for new code, e.g. new code could use +// cal.createDatetime instead of plain createDatetime NOW. +cal.loadScripts(["calUtils.js"], cal); +// Some functions in calUtils.js refer to other in the same file, thus include +// the code in global scope (although only visible to this module file), too: +cal.loadScripts(["calUtils.js"], Components.utils.getGlobalForObject(cal)); diff --git a/calendar/base/modules/calViewUtils.jsm b/calendar/base/modules/calViewUtils.jsm new file mode 100644 index 000000000..24934dc64 --- /dev/null +++ b/calendar/base/modules/calViewUtils.jsm @@ -0,0 +1,71 @@ +/* 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/. */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); + +this.EXPORTED_SYMBOLS = ["cal"]; +cal.view = { + /** + - * Item comparator for inserting items into dayboxes. + - * + - * @param a The first item + - * @param b The second item + - * @return The usual -1, 0, 1 + - */ + compareItems: function(a, b) { + if (!a) { + return -1; + } + if (!b) { + return 1; + } + + let aIsEvent = cal.isEvent(a); + let aIsTodo = cal.isToDo(a); + + let bIsEvent = cal.isEvent(b); + let bIsTodo = cal.isToDo(b); + + // sort todos before events + if (aIsTodo && bIsEvent) { + return -1; + } + if (aIsEvent && bIsTodo) { + return 1; + } + + // sort items of the same type according to date-time + let aStartDate = a.startDate || a.entryDate || a.dueDate; + let bStartDate = b.startDate || b.entryDate || b.dueDate; + let aEndDate = a.endDate || a.dueDate || a.entryDate; + let bEndDate = b.endDate || b.dueDate || b.entryDate; + if (!aStartDate || !bStartDate) { + return 0; + } + + // sort all day events before events with a duration + if (aStartDate.isDate && !bStartDate.isDate) { + return -1; + } + if (!aStartDate.isDate && bStartDate.isDate) { + return 1; + } + + let cmp = aStartDate.compare(bStartDate); + if (cmp != 0) { + return cmp; + } + + if (!aEndDate || !bEndDate) { + return 0; + } + cmp = aEndDate.compare(bEndDate); + if (cmp != 0) { + return cmp; + } + + cmp = (a.title > b.title) - (a.title < b.title); + return cmp; + } +}; diff --git a/calendar/base/modules/calXMLUtils.jsm b/calendar/base/modules/calXMLUtils.jsm new file mode 100644 index 000000000..d026ad946 --- /dev/null +++ b/calendar/base/modules/calXMLUtils.jsm @@ -0,0 +1,174 @@ +/* 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/. */ + +/** Helper functions for parsing and serializing XML */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); + +this.EXPORTED_SYMBOLS = ["cal"]; +cal.xml = {} || cal.xml; + +/** + * Evaluate an XPath query for the given node. Be careful with the return value + * here, as it may be: + * + * - null, if there are no results + * - a number, string or boolean value + * - an array of strings or DOM elements + * + * @param aNode The context node to search from + * @param aExpr The XPath expression to search for + * @param aResolver (optional) The namespace resolver to use for the expression + * @param aType (optional) Force a result type, must be an XPathResult constant + * @return The result, see above for details. + */ +cal.xml.evalXPath = function(aNode, aExpr, aResolver, aType) { + const XPR = Components.interfaces.nsIDOMXPathResult; + let doc = (aNode.ownerDocument ? aNode.ownerDocument : aNode); + let resolver = aResolver || doc.createNSResolver(doc.documentElement); + let resultType = aType || XPR.ANY_TYPE; + + let result = doc.evaluate(aExpr, aNode, resolver, resultType, null); + let returnResult, next; + switch (result.resultType) { + case XPR.NUMBER_TYPE: + returnResult = result.numberValue; + break; + case XPR.STRING_TYPE: + returnResult = result.stringValue; + break; + case XPR.BOOLEAN_TYPE: + returnResult = result.booleanValue; + break; + case XPR.UNORDERED_NODE_ITERATOR_TYPE: + case XPR.ORDERED_NODE_ITERATOR_TYPE: + returnResult = []; + while ((next = result.iterateNext())) { + if (next instanceof Components.interfaces.nsIDOMText) { + returnResult.push(next.wholeText); + } else if (next instanceof Components.interfaces.nsIDOMAttr) { + returnResult.push(next.value); + } else { + returnResult.push(next); + } + } + break; + case XPR.UNORDERED_NODE_SNAPSHOT_TYPE: + case XPR.ORDERED_NODE_SNAPSHOT_TYPE: + returnResult = []; + for (let i = 0; i < result.snapshotLength; i++) { + next = result.snapshotItem(i); + if (next instanceof Components.interfaces.nsIDOMText) { + returnResult.push(next.wholeText); + } else if (next instanceof Components.interfaces.nsIDOMAttr) { + returnResult.push(next.value); + } else { + returnResult.push(next); + } + } + break; + case XPR.ANY_UNORDERED_NODE_TYPE: + case XPR.FIRST_ORDERED_NODE_TYPE: + returnResult = result.singleNodeValue; + break; + default: + returnResult = null; + break; + } + + if (Array.isArray(returnResult) && returnResult.length == 0) { + returnResult = null; + } + + return returnResult; +}; + +/** + * Convenience function to evaluate an XPath expression and return null or the + * first result. Helpful if you just expect one value in a text() expression, + * but its possible that there will be more than one. The result may be: + * + * - null, if there are no results + * - A string, number, boolean or DOM Element value + * + * @param aNode The context node to search from + * @param aExpr The XPath expression to search for + * @param aResolver (optional) The namespace resolver to use for the expression + * @param aType (optional) Force a result type, must be an XPathResult constant + * @return The result, see above for details. + */ +cal.xml.evalXPathFirst = function(aNode, aExpr, aResolver, aType) { + let result = cal.xml.evalXPath(aNode, aExpr, aResolver, aType); + + if (Array.isArray(result)) { + return result[0]; + } else { + return result; + } +}; + +/** + * Parse the given string into a DOM tree + * + * @param str The string to parse + * @param docUri (optional) The document URI to use + * @param baseUri (optional) The base URI to use + * @return The parsed DOM Document + */ +cal.xml.parseString = function(str, docUri, baseUri) { + let parser = Components.classes["@mozilla.org/xmlextras/domparser;1"] + .createInstance(Components.interfaces.nsIDOMParser); + + parser.init(null, docUri, baseUri); + return parser.parseFromString(str, "application/xml"); +}; + +/** + * Read an XML file synchronously. This method should be avoided, consider + * rewriting the caller to be asynchronous. + * + * @param uri The URI to read. + * @return The DOM Document resulting from the file. + */ +cal.xml.parseFile = function(uri) { + let req = Components.classes["@mozilla.org/xmlextras/xmlhttprequest;1"] + .createInstance(Components.interfaces.nsIXMLHttpRequest); + + req.open("GET", uri, false); + req.overrideMimeType("text/xml"); + req.send(null); + return req.responseXML; +}; + +/** + * Serialize the DOM tree into a string. + * + * @param doc The DOM document to serialize + * @return The DOM document as a string. + */ +cal.xml.serializeDOM = function(doc) { + let serializer = Components.classes["@mozilla.org/xmlextras/xmlserializer;1"] + .createInstance(Components.interfaces.nsIDOMSerializer); + return serializer.serializeToString(doc); +}; + +/** + * Escape a string for use in XML + * + * @param str The string to escape + * @param isAttribute If true, " and ' are also escaped + * @return The escaped string + */ +cal.xml.escapeString = function(str, isAttribute) { + return str.replace(/[&<>'"]/g, (chr) => { + switch (chr) { + case "&": return "&"; + case "<": return "<"; + case ">": return ">"; + case '"': return (isAttribute ? """ : chr); + case "'": return (isAttribute ? "'" : chr); + default: return chr; + } + }); +}; diff --git a/calendar/base/modules/ical.js b/calendar/base/modules/ical.js new file mode 100644 index 000000000..5fb00b852 --- /dev/null +++ b/calendar/base/modules/ical.js @@ -0,0 +1,9354 @@ +/* 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 is ical.js from <https://github.com/mozilla-comm/ical.js>. + * + * If you would like to change anything in ical.js, it is required to do so + * upstream first. + * + * Current ical.js git revision: f4fb1b9564f990f7b718253f2251bb30f58c9a5a (v1.2.0) + */ + +var EXPORTED_SYMBOLS = ["ICAL", "unwrap", "unwrapSetter", "unwrapSingle", "wrapGetter"]; + +function wrapGetter(type, val) { + return val ? new type(val) : null; +} + +function unwrap(type, innerFunc) { + return function(val) { return unwrapSetter.call(this, type, val, innerFunc); }; +} + +function unwrapSetter(type, val, innerFunc, thisObj) { + return innerFunc.call(thisObj || this, unwrapSingle(type, val)); +} + +function unwrapSingle(type, val) { + if (!val || !val.wrappedJSObject) { + return null; + } else if (val.wrappedJSObject.innerObject instanceof type) { + return val.wrappedJSObject.innerObject; + } else { + Components.utils.import("resource://calendar/modules/calUtils.jsm"); + Components.utils.reportError("Unknown " + (type.icalclass || type) + " passed at " + cal.STACK(10)); + return null; + } +} + +// -- start ical.js -- + +/* 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/. + * Portions Copyright (C) Philipp Kewisch, 2011-2015 */ + + +/* istanbul ignore next */ +/* jshint ignore:start */ +if (typeof module === 'object') { + // CommonJS, where exports may be different each time. + ICAL = module.exports; +} else if (typeof ICAL !== 'object') {/* istanbul ignore next */ + /** @ignore */ + this.ICAL = {}; +} +/* jshint ignore:end */ + + +/** + * The number of characters before iCalendar line folding should occur + * @type {Number} + * @default 75 + */ +ICAL.foldLength = 75; + + +/** + * The character(s) to be used for a newline. The default value is provided by + * rfc5545. + * @type {String} + * @default "\r\n" + */ +ICAL.newLineChar = '\r\n'; + + +/** + * Helper functions used in various places within ical.js + * @namespace + */ +ICAL.helpers = { + /** + * Checks if the given type is of the number type and also NaN. + * + * @param {Number} number The number to check + * @return {Boolean} True, if the number is strictly NaN + */ + isStrictlyNaN: function(number) { + return typeof(number) === 'number' && isNaN(number); + }, + + /** + * Parses a string value that is expected to be an integer, when the valid is + * not an integer throws a decoration error. + * + * @param {String} string Raw string input + * @return {Number} Parsed integer + */ + strictParseInt: function(string) { + var result = parseInt(string, 10); + + if (ICAL.helpers.isStrictlyNaN(result)) { + throw new Error( + 'Could not extract integer from "' + string + '"' + ); + } + + return result; + }, + + /** + * Creates or returns a class instance of a given type with the initialization + * data if the data is not already an instance of the given type. + * + * @example + * var time = new ICAL.Time(...); + * var result = ICAL.helpers.formatClassType(time, ICAL.Time); + * + * (result instanceof ICAL.Time) + * // => true + * + * result = ICAL.helpers.formatClassType({}, ICAL.Time); + * (result isntanceof ICAL.Time) + * // => true + * + * + * @param {Object} data object initialization data + * @param {Object} type object type (like ICAL.Time) + * @return {?} An instance of the found type. + */ + formatClassType: function formatClassType(data, type) { + if (typeof(data) === 'undefined') { + return undefined; + } + + if (data instanceof type) { + return data; + } + return new type(data); + }, + + /** + * Identical to indexOf but will only match values when they are not preceded + * by a backslash character. + * + * @param {String} buffer String to search + * @param {String} search Value to look for + * @param {Number} pos Start position + * @return {Number} The position, or -1 if not found + */ + unescapedIndexOf: function(buffer, search, pos) { + while ((pos = buffer.indexOf(search, pos)) !== -1) { + if (pos > 0 && buffer[pos - 1] === '\\') { + pos += 1; + } else { + return pos; + } + } + return -1; + }, + + /** + * Find the index for insertion using binary search. + * + * @param {Array} list The list to search + * @param {?} seekVal The value to insert + * @param {function(?,?)} cmpfunc The comparison func, that can + * compare two seekVals + * @return {Number} The insert position + */ + binsearchInsert: function(list, seekVal, cmpfunc) { + if (!list.length) + return 0; + + var low = 0, high = list.length - 1, + mid, cmpval; + + while (low <= high) { + mid = low + Math.floor((high - low) / 2); + cmpval = cmpfunc(seekVal, list[mid]); + + if (cmpval < 0) + high = mid - 1; + else if (cmpval > 0) + low = mid + 1; + else + break; + } + + if (cmpval < 0) + return mid; // insertion is displacing, so use mid outright. + else if (cmpval > 0) + return mid + 1; + else + return mid; + }, + + /** + * Convenience function for debug output + * @private + */ + dumpn: /* istanbul ignore next */ function() { + if (!ICAL.debug) { + return; + } + + if (typeof (console) !== 'undefined' && 'log' in console) { + ICAL.helpers.dumpn = function consoleDumpn(input) { + console.log(input); + }; + } else { + ICAL.helpers.dumpn = function geckoDumpn(input) { + dump(input + '\n'); + }; + } + + ICAL.helpers.dumpn(arguments[0]); + }, + + /** + * Clone the passed object or primitive. By default a shallow clone will be + * executed. + * + * @param {*} aSrc The thing to clone + * @param {Boolean=} aDeep If true, a deep clone will be performed + * @return {*} The copy of the thing + */ + clone: function(aSrc, aDeep) { + if (!aSrc || typeof aSrc != "object") { + return aSrc; + } else if (aSrc instanceof Date) { + return new Date(aSrc.getTime()); + } else if ("clone" in aSrc) { + return aSrc.clone(); + } else if (Array.isArray(aSrc)) { + var arr = []; + for (var i = 0; i < aSrc.length; i++) { + arr.push(aDeep ? ICAL.helpers.clone(aSrc[i], true) : aSrc[i]); + } + return arr; + } else { + var obj = {}; + for (var name in aSrc) { + // uses prototype method to allow use of Object.create(null); + /* istanbul ignore else */ + if (Object.prototype.hasOwnProperty.call(aSrc, name)) { + if (aDeep) { + obj[name] = ICAL.helpers.clone(aSrc[name], true); + } else { + obj[name] = aSrc[name]; + } + } + } + return obj; + } + }, + + /** + * Performs iCalendar line folding. A line ending character is inserted and + * the next line begins with a whitespace. + * + * @example + * SUMMARY:This line will be fold + * ed right in the middle of a word. + * + * @param {String} aLine The line to fold + * @return {String} The folded line + */ + foldline: function foldline(aLine) { + var result = ""; + var line = aLine || ""; + + while (line.length) { + result += ICAL.newLineChar + " " + line.substr(0, ICAL.foldLength); + line = line.substr(ICAL.foldLength); + } + return result.substr(ICAL.newLineChar.length + 1); + }, + + /** + * Pads the given string or number with zeros so it will have at least two + * characters. + * + * @param {String|Number} data The string or number to pad + * @return {String} The number padded as a string + */ + pad2: function pad(data) { + if (typeof(data) !== 'string') { + // handle fractions. + if (typeof(data) === 'number') { + data = parseInt(data); + } + data = String(data); + } + + var len = data.length; + + switch (len) { + case 0: + return '00'; + case 1: + return '0' + data; + default: + return data; + } + }, + + /** + * Truncates the given number, correctly handling negative numbers. + * + * @param {Number} number The number to truncate + * @return {Number} The truncated number + */ + trunc: function trunc(number) { + return (number < 0 ? Math.ceil(number) : Math.floor(number)); + }, + + /** + * Poor-man's cross-browser inheritance for JavaScript. Doesn't support all + * the features, but enough for our usage. + * + * @param {Function} base The base class constructor function. + * @param {Function} child The child class constructor function. + * @param {Object} extra Extends the prototype with extra properties + * and methods + */ + inherits: function(base, child, extra) { + function F() {} + F.prototype = base.prototype; + child.prototype = new F(); + + if (extra) { + ICAL.helpers.extend(extra, child.prototype); + } + }, + + /** + * Poor-man's cross-browser object extension. Doesn't support all the + * features, but enough for our usage. Note that the target's properties are + * not overwritten with the source properties. + * + * @example + * var child = ICAL.helpers.extend(parent, { + * "bar": 123 + * }); + * + * @param {Object} source The object to extend + * @param {Object} target The object to extend with + * @return {Object} Returns the target. + */ + extend: function(source, target) { + for (var key in source) { + var descr = Object.getOwnPropertyDescriptor(source, key); + if (descr && !Object.getOwnPropertyDescriptor(target, key)) { + Object.defineProperty(target, key, descr); + } + } + return target; + } +}; +/* 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/. + * Portions Copyright (C) Philipp Kewisch, 2011-2015 */ + +/** @namespace ICAL */ + + +/** + * This symbol is further described later on + * @ignore + */ +ICAL.design = (function() { + 'use strict'; + + var FROM_ICAL_NEWLINE = /\\\\|\\;|\\,|\\[Nn]/g; + var TO_ICAL_NEWLINE = /\\|;|,|\n/g; + var FROM_VCARD_NEWLINE = /\\\\|\\,|\\[Nn]/g; + var TO_VCARD_NEWLINE = /\\|,|\n/g; + + function createTextType(fromNewline, toNewline) { + var result = { + matches: /.*/, + + fromICAL: function(aValue, structuredEscape) { + return replaceNewline(aValue, fromNewline, structuredEscape); + }, + + toICAL: function(aValue, structuredEscape) { + var regEx = toNewline; + if (structuredEscape) + regEx = new RegExp(regEx.source + '|' + structuredEscape); + return aValue.replace(regEx, function(str) { + switch (str) { + case "\\": + return "\\\\"; + case ";": + return "\\;"; + case ",": + return "\\,"; + case "\n": + return "\\n"; + /* istanbul ignore next */ + default: + return str; + } + }); + } + }; + return result; + } + + // default types used multiple times + var DEFAULT_TYPE_TEXT = { defaultType: "text" }; + var DEFAULT_TYPE_TEXT_MULTI = { defaultType: "text", multiValue: "," }; + var DEFAULT_TYPE_TEXT_STRUCTURED = { defaultType: "text", structuredValue: ";" }; + var DEFAULT_TYPE_INTEGER = { defaultType: "integer" }; + var DEFAULT_TYPE_DATETIME_DATE = { defaultType: "date-time", allowedTypes: ["date-time", "date"] }; + var DEFAULT_TYPE_DATETIME = { defaultType: "date-time" }; + var DEFAULT_TYPE_URI = { defaultType: "uri" }; + var DEFAULT_TYPE_UTCOFFSET = { defaultType: "utc-offset" }; + var DEFAULT_TYPE_RECUR = { defaultType: "recur" }; + var DEFAULT_TYPE_DATE_ANDOR_TIME = { defaultType: "date-and-or-time", allowedTypes: ["date-time", "date", "text"] }; + + function replaceNewlineReplace(string) { + switch (string) { + case "\\\\": + return "\\"; + case "\\;": + return ";"; + case "\\,": + return ","; + case "\\n": + case "\\N": + return "\n"; + /* istanbul ignore next */ + default: + return string; + } + } + + function replaceNewline(value, newline, structuredEscape) { + // avoid regex when possible. + if (value.indexOf('\\') === -1) { + return value; + } + if (structuredEscape) + newline = new RegExp(newline.source + '|\\\\' + structuredEscape); + return value.replace(newline, replaceNewlineReplace); + } + + var commonProperties = { + "categories": DEFAULT_TYPE_TEXT_MULTI, + "url": DEFAULT_TYPE_URI, + "version": DEFAULT_TYPE_TEXT, + "uid": DEFAULT_TYPE_TEXT + }; + + var commonValues = { + "boolean": { + values: ["TRUE", "FALSE"], + + fromICAL: function(aValue) { + switch (aValue) { + case 'TRUE': + return true; + case 'FALSE': + return false; + default: + //TODO: parser warning + return false; + } + }, + + toICAL: function(aValue) { + if (aValue) { + return 'TRUE'; + } + return 'FALSE'; + } + + }, + float: { + matches: /^[+-]?\d+\.\d+$/, + + fromICAL: function(aValue) { + var parsed = parseFloat(aValue); + if (ICAL.helpers.isStrictlyNaN(parsed)) { + // TODO: parser warning + return 0.0; + } + return parsed; + }, + + toICAL: function(aValue) { + return String(aValue); + } + }, + integer: { + fromICAL: function(aValue) { + var parsed = parseInt(aValue); + if (ICAL.helpers.isStrictlyNaN(parsed)) { + return 0; + } + return parsed; + }, + + toICAL: function(aValue) { + return String(aValue); + } + }, + "utc-offset": { + toICAL: function(aValue) { + if (aValue.length < 7) { + // no seconds + // -0500 + return aValue.substr(0, 3) + + aValue.substr(4, 2); + } else { + // seconds + // -050000 + return aValue.substr(0, 3) + + aValue.substr(4, 2) + + aValue.substr(7, 2); + } + }, + + fromICAL: function(aValue) { + if (aValue.length < 6) { + // no seconds + // -05:00 + return aValue.substr(0, 3) + ':' + + aValue.substr(3, 2); + } else { + // seconds + // -05:00:00 + return aValue.substr(0, 3) + ':' + + aValue.substr(3, 2) + ':' + + aValue.substr(5, 2); + } + }, + + decorate: function(aValue) { + return ICAL.UtcOffset.fromString(aValue); + }, + + undecorate: function(aValue) { + return aValue.toString(); + } + } + }; + + var icalParams = { + // Although the syntax is DQUOTE uri DQUOTE, I don't think we should + // enfoce anything aside from it being a valid content line. + // + // At least some params require - if multi values are used - DQUOTEs + // for each of its values - e.g. delegated-from="uri1","uri2" + // To indicate this, I introduced the new k/v pair + // multiValueSeparateDQuote: true + // + // "ALTREP": { ... }, + + // CN just wants a param-value + // "CN": { ... } + + "cutype": { + values: ["INDIVIDUAL", "GROUP", "RESOURCE", "ROOM", "UNKNOWN"], + allowXName: true, + allowIanaToken: true + }, + + "delegated-from": { + valueType: "cal-address", + multiValue: ",", + multiValueSeparateDQuote: true + }, + "delegated-to": { + valueType: "cal-address", + multiValue: ",", + multiValueSeparateDQuote: true + }, + // "DIR": { ... }, // See ALTREP + "encoding": { + values: ["8BIT", "BASE64"] + }, + // "FMTTYPE": { ... }, // See ALTREP + "fbtype": { + values: ["FREE", "BUSY", "BUSY-UNAVAILABLE", "BUSY-TENTATIVE"], + allowXName: true, + allowIanaToken: true + }, + // "LANGUAGE": { ... }, // See ALTREP + "member": { + valueType: "cal-address", + multiValue: ",", + multiValueSeparateDQuote: true + }, + "partstat": { + // TODO These values are actually different per-component + values: ["NEEDS-ACTION", "ACCEPTED", "DECLINED", "TENTATIVE", + "DELEGATED", "COMPLETED", "IN-PROCESS"], + allowXName: true, + allowIanaToken: true + }, + "range": { + values: ["THISLANDFUTURE"] + }, + "related": { + values: ["START", "END"] + }, + "reltype": { + values: ["PARENT", "CHILD", "SIBLING"], + allowXName: true, + allowIanaToken: true + }, + "role": { + values: ["REQ-PARTICIPANT", "CHAIR", + "OPT-PARTICIPANT", "NON-PARTICIPANT"], + allowXName: true, + allowIanaToken: true + }, + "rsvp": { + values: ["TRUE", "FALSE"] + }, + "sent-by": { + valueType: "cal-address" + }, + "tzid": { + matches: /^\// + }, + "value": { + // since the value here is a 'type' lowercase is used. + values: ["binary", "boolean", "cal-address", "date", "date-time", + "duration", "float", "integer", "period", "recur", "text", + "time", "uri", "utc-offset"], + allowXName: true, + allowIanaToken: true + } + }; + + // When adding a value here, be sure to add it to the parameter types! + var icalValues = ICAL.helpers.extend(commonValues, { + text: createTextType(FROM_ICAL_NEWLINE, TO_ICAL_NEWLINE), + + uri: { + // TODO + /* ... */ + }, + + "binary": { + decorate: function(aString) { + return ICAL.Binary.fromString(aString); + }, + + undecorate: function(aBinary) { + return aBinary.toString(); + } + }, + "cal-address": { + // needs to be an uri + }, + "date": { + decorate: function(aValue, aProp) { + return ICAL.Time.fromDateString(aValue, aProp); + }, + + /** + * undecorates a time object. + */ + undecorate: function(aValue) { + return aValue.toString(); + }, + + fromICAL: function(aValue) { + // from: 20120901 + // to: 2012-09-01 + return aValue.substr(0, 4) + '-' + + aValue.substr(4, 2) + '-' + + aValue.substr(6, 2); + }, + + toICAL: function(aValue) { + // from: 2012-09-01 + // to: 20120901 + + if (aValue.length > 11) { + //TODO: serialize warning? + return aValue; + } + + return aValue.substr(0, 4) + + aValue.substr(5, 2) + + aValue.substr(8, 2); + } + }, + "date-time": { + fromICAL: function(aValue) { + // from: 20120901T130000 + // to: 2012-09-01T13:00:00 + var result = aValue.substr(0, 4) + '-' + + aValue.substr(4, 2) + '-' + + aValue.substr(6, 2) + 'T' + + aValue.substr(9, 2) + ':' + + aValue.substr(11, 2) + ':' + + aValue.substr(13, 2); + + if (aValue[15] && aValue[15] === 'Z') { + result += 'Z'; + } + + return result; + }, + + toICAL: function(aValue) { + // from: 2012-09-01T13:00:00 + // to: 20120901T130000 + + if (aValue.length < 19) { + // TODO: error + return aValue; + } + + var result = aValue.substr(0, 4) + + aValue.substr(5, 2) + + // grab the (DDTHH) segment + aValue.substr(8, 5) + + // MM + aValue.substr(14, 2) + + // SS + aValue.substr(17, 2); + + if (aValue[19] && aValue[19] === 'Z') { + result += 'Z'; + } + + return result; + }, + + decorate: function(aValue, aProp) { + return ICAL.Time.fromDateTimeString(aValue, aProp); + }, + + undecorate: function(aValue) { + return aValue.toString(); + } + }, + duration: { + decorate: function(aValue) { + return ICAL.Duration.fromString(aValue); + }, + undecorate: function(aValue) { + return aValue.toString(); + } + }, + period: { + + fromICAL: function(string) { + var parts = string.split('/'); + parts[0] = icalValues['date-time'].fromICAL(parts[0]); + + if (!ICAL.Duration.isValueString(parts[1])) { + parts[1] = icalValues['date-time'].fromICAL(parts[1]); + } + + return parts; + }, + + toICAL: function(parts) { + parts[0] = icalValues['date-time'].toICAL(parts[0]); + + if (!ICAL.Duration.isValueString(parts[1])) { + parts[1] = icalValues['date-time'].toICAL(parts[1]); + } + + return parts.join("/"); + }, + + decorate: function(aValue, aProp) { + return ICAL.Period.fromJSON(aValue, aProp); + }, + + undecorate: function(aValue) { + return aValue.toJSON(); + } + }, + recur: { + fromICAL: function(string) { + return ICAL.Recur._stringToData(string, true); + }, + + toICAL: function(data) { + var str = ""; + for (var k in data) { + /* istanbul ignore if */ + if (!Object.prototype.hasOwnProperty.call(data, k)) { + continue; + } + var val = data[k]; + if (k == "until") { + if (val.length > 10) { + val = icalValues['date-time'].toICAL(val); + } else { + val = icalValues.date.toICAL(val); + } + } else if (k == "wkst") { + if (typeof val === 'number') { + val = ICAL.Recur.numericDayToIcalDay(val); + } + } else if (Array.isArray(val)) { + val = val.join(","); + } + str += k.toUpperCase() + "=" + val + ";"; + } + return str.substr(0, str.length - 1); + }, + + decorate: function decorate(aValue) { + return ICAL.Recur.fromData(aValue); + }, + + undecorate: function(aRecur) { + return aRecur.toJSON(); + } + }, + + time: { + fromICAL: function(aValue) { + // from: MMHHSS(Z)? + // to: HH:MM:SS(Z)? + if (aValue.length < 6) { + // TODO: parser exception? + return aValue; + } + + // HH::MM::SSZ? + var result = aValue.substr(0, 2) + ':' + + aValue.substr(2, 2) + ':' + + aValue.substr(4, 2); + + if (aValue[6] === 'Z') { + result += 'Z'; + } + + return result; + }, + + toICAL: function(aValue) { + // from: HH:MM:SS(Z)? + // to: MMHHSS(Z)? + if (aValue.length < 8) { + //TODO: error + return aValue; + } + + var result = aValue.substr(0, 2) + + aValue.substr(3, 2) + + aValue.substr(6, 2); + + if (aValue[8] === 'Z') { + result += 'Z'; + } + + return result; + } + } + }); + + var icalProperties = ICAL.helpers.extend(commonProperties, { + + "action": DEFAULT_TYPE_TEXT, + "attach": { defaultType: "uri" }, + "attendee": { defaultType: "cal-address" }, + "calscale": DEFAULT_TYPE_TEXT, + "class": DEFAULT_TYPE_TEXT, + "comment": DEFAULT_TYPE_TEXT, + "completed": DEFAULT_TYPE_DATETIME, + "contact": DEFAULT_TYPE_TEXT, + "created": DEFAULT_TYPE_DATETIME, + "description": DEFAULT_TYPE_TEXT, + "dtend": DEFAULT_TYPE_DATETIME_DATE, + "dtstamp": DEFAULT_TYPE_DATETIME, + "dtstart": DEFAULT_TYPE_DATETIME_DATE, + "due": DEFAULT_TYPE_DATETIME_DATE, + "duration": { defaultType: "duration" }, + "exdate": { + defaultType: "date-time", + allowedTypes: ["date-time", "date"], + multiValue: ',' + }, + "exrule": DEFAULT_TYPE_RECUR, + "freebusy": { defaultType: "period", multiValue: "," }, + "geo": { defaultType: "float", structuredValue: ";" }, + "last-modified": DEFAULT_TYPE_DATETIME, + "location": DEFAULT_TYPE_TEXT, + "method": DEFAULT_TYPE_TEXT, + "organizer": { defaultType: "cal-address" }, + "percent-complete": DEFAULT_TYPE_INTEGER, + "priority": DEFAULT_TYPE_INTEGER, + "prodid": DEFAULT_TYPE_TEXT, + "related-to": DEFAULT_TYPE_TEXT, + "repeat": DEFAULT_TYPE_INTEGER, + "rdate": { + defaultType: "date-time", + allowedTypes: ["date-time", "date", "period"], + multiValue: ',', + detectType: function(string) { + if (string.indexOf('/') !== -1) { + return 'period'; + } + return (string.indexOf('T') === -1) ? 'date' : 'date-time'; + } + }, + "recurrence-id": DEFAULT_TYPE_DATETIME_DATE, + "resources": DEFAULT_TYPE_TEXT_MULTI, + "request-status": DEFAULT_TYPE_TEXT_STRUCTURED, + "rrule": DEFAULT_TYPE_RECUR, + "sequence": DEFAULT_TYPE_INTEGER, + "status": DEFAULT_TYPE_TEXT, + "summary": DEFAULT_TYPE_TEXT, + "transp": DEFAULT_TYPE_TEXT, + "trigger": { defaultType: "duration", allowedTypes: ["duration", "date-time"] }, + "tzoffsetfrom": DEFAULT_TYPE_UTCOFFSET, + "tzoffsetto": DEFAULT_TYPE_UTCOFFSET, + "tzurl": DEFAULT_TYPE_URI, + "tzid": DEFAULT_TYPE_TEXT, + "tzname": DEFAULT_TYPE_TEXT + }); + + // When adding a value here, be sure to add it to the parameter types! + var vcardValues = ICAL.helpers.extend(commonValues, { + text: createTextType(FROM_VCARD_NEWLINE, TO_VCARD_NEWLINE), + uri: createTextType(FROM_VCARD_NEWLINE, TO_VCARD_NEWLINE), + + date: { + decorate: function(aValue) { + return ICAL.VCardTime.fromDateAndOrTimeString(aValue, "date"); + }, + undecorate: function(aValue) { + return aValue.toString(); + }, + fromICAL: function(aValue) { + if (aValue.length == 8) { + return icalValues.date.fromICAL(aValue); + } else if (aValue[0] == '-' && aValue.length == 6) { + return aValue.substr(0, 4) + '-' + aValue.substr(4); + } else { + return aValue; + } + }, + toICAL: function(aValue) { + if (aValue.length == 10) { + return icalValues.date.toICAL(aValue); + } else if (aValue[0] == '-' && aValue.length == 7) { + return aValue.substr(0, 4) + aValue.substr(5); + } else { + return aValue; + } + } + }, + + time: { + decorate: function(aValue) { + return ICAL.VCardTime.fromDateAndOrTimeString("T" + aValue, "time"); + }, + undecorate: function(aValue) { + return aValue.toString(); + }, + fromICAL: function(aValue) { + var splitzone = vcardValues.time._splitZone(aValue, true); + var zone = splitzone[0], value = splitzone[1]; + + //console.log("SPLIT: ",splitzone); + + if (value.length == 6) { + value = value.substr(0, 2) + ':' + + value.substr(2, 2) + ':' + + value.substr(4, 2); + } else if (value.length == 4 && value[0] != '-') { + value = value.substr(0, 2) + ':' + value.substr(2, 2); + } else if (value.length == 5) { + value = value.substr(0, 3) + ':' + value.substr(3, 2); + } + + if (zone.length == 5 && (zone[0] == '-' || zone[0] == '+')) { + zone = zone.substr(0, 3) + ':' + zone.substr(3); + } + + return value + zone; + }, + + toICAL: function(aValue) { + var splitzone = vcardValues.time._splitZone(aValue); + var zone = splitzone[0], value = splitzone[1]; + + if (value.length == 8) { + value = value.substr(0, 2) + + value.substr(3, 2) + + value.substr(6, 2); + } else if (value.length == 5 && value[0] != '-') { + value = value.substr(0, 2) + value.substr(3, 2); + } else if (value.length == 6) { + value = value.substr(0, 3) + value.substr(4, 2); + } + + if (zone.length == 6 && (zone[0] == '-' || zone[0] == '+')) { + zone = zone.substr(0, 3) + zone.substr(4); + } + + return value + zone; + }, + + _splitZone: function(aValue, isFromIcal) { + var lastChar = aValue.length - 1; + var signChar = aValue.length - (isFromIcal ? 5 : 6); + var sign = aValue[signChar]; + var zone, value; + + if (aValue[lastChar] == 'Z') { + zone = aValue[lastChar]; + value = aValue.substr(0, lastChar); + } else if (aValue.length > 6 && (sign == '-' || sign == '+')) { + zone = aValue.substr(signChar); + value = aValue.substr(0, signChar); + } else { + zone = ""; + value = aValue; + } + + return [zone, value]; + } + }, + + "date-time": { + decorate: function(aValue) { + return ICAL.VCardTime.fromDateAndOrTimeString(aValue, "date-time"); + }, + + undecorate: function(aValue) { + return aValue.toString(); + }, + + fromICAL: function(aValue) { + return vcardValues['date-and-or-time'].fromICAL(aValue); + }, + + toICAL: function(aValue) { + return vcardValues['date-and-or-time'].toICAL(aValue); + } + }, + + "date-and-or-time": { + decorate: function(aValue) { + return ICAL.VCardTime.fromDateAndOrTimeString(aValue, "date-and-or-time"); + }, + + undecorate: function(aValue) { + return aValue.toString(); + }, + + fromICAL: function(aValue) { + var parts = aValue.split('T'); + return (parts[0] ? vcardValues.date.fromICAL(parts[0]) : '') + + (parts[1] ? 'T' + vcardValues.time.fromICAL(parts[1]) : ''); + }, + + toICAL: function(aValue) { + var parts = aValue.split('T'); + return vcardValues.date.toICAL(parts[0]) + + (parts[1] ? 'T' + vcardValues.time.toICAL(parts[1]) : ''); + + } + }, + timestamp: icalValues['date-time'], + "language-tag": { + matches: /^[a-zA-Z0-9\-]+$/ // Could go with a more strict regex here + } + }); + + var vcardParams = { + "type": { + valueType: "text", + multiValue: "," + }, + "value": { + // since the value here is a 'type' lowercase is used. + values: ["text", "uri", "date", "time", "date-time", "date-and-or-time", + "timestamp", "boolean", "integer", "float", "utc-offset", + "language-tag"], + allowXName: true, + allowIanaToken: true + } + }; + + var vcardProperties = ICAL.helpers.extend(commonProperties, { + "adr": { defaultType: "text", structuredValue: ";", multiValue: "," }, + "anniversary": DEFAULT_TYPE_DATE_ANDOR_TIME, + "bday": DEFAULT_TYPE_DATE_ANDOR_TIME, + "caladruri": DEFAULT_TYPE_URI, + "caluri": DEFAULT_TYPE_URI, + "clientpidmap": DEFAULT_TYPE_TEXT_STRUCTURED, + "email": DEFAULT_TYPE_TEXT, + "fburl": DEFAULT_TYPE_URI, + "fn": DEFAULT_TYPE_TEXT, + "gender": DEFAULT_TYPE_TEXT_STRUCTURED, + "geo": DEFAULT_TYPE_URI, + "impp": DEFAULT_TYPE_URI, + "key": DEFAULT_TYPE_URI, + "kind": DEFAULT_TYPE_TEXT, + "lang": { defaultType: "language-tag" }, + "logo": DEFAULT_TYPE_URI, + "member": DEFAULT_TYPE_URI, + "n": { defaultType: "text", structuredValue: ";", multiValue: "," }, + "nickname": DEFAULT_TYPE_TEXT_MULTI, + "note": DEFAULT_TYPE_TEXT, + "org": { defaultType: "text", structuredValue: ";" }, + "photo": DEFAULT_TYPE_URI, + "related": DEFAULT_TYPE_URI, + "rev": { defaultType: "timestamp" }, + "role": DEFAULT_TYPE_TEXT, + "sound": DEFAULT_TYPE_URI, + "source": DEFAULT_TYPE_URI, + "tel": { defaultType: "uri", allowedTypes: ["uri", "text"] }, + "title": DEFAULT_TYPE_TEXT, + "tz": { defaultType: "text", allowedTypes: ["text", "utc-offset", "uri"] }, + "xml": DEFAULT_TYPE_TEXT + }); + + var vcard3Values = ICAL.helpers.extend(commonValues, { + binary: icalValues.binary, + date: vcardValues.date, + "date-time": vcardValues["date-time"], + "phone-number": { + // TODO + /* ... */ + }, + uri: icalValues.uri, + text: icalValues.text, + time: icalValues.time, + vcard: icalValues.text, + "utc-offset": { + toICAL: function(aValue) { + return aValue.substr(0, 7); + }, + + fromICAL: function(aValue) { + return aValue.substr(0, 7); + }, + + decorate: function(aValue) { + return ICAL.UtcOffset.fromString(aValue); + }, + + undecorate: function(aValue) { + return aValue.toString(); + } + } + }); + + var vcard3Params = { + "type": { + valueType: "text", + multiValue: "," + }, + "value": { + // since the value here is a 'type' lowercase is used. + values: ["text", "uri", "date", "date-time", "phone-number", "time", + "boolean", "integer", "float", "utc-offset", "vcard", "binary"], + allowXName: true, + allowIanaToken: true + } + }; + + var vcard3Properties = ICAL.helpers.extend(commonProperties, { + fn: DEFAULT_TYPE_TEXT, + n: { defaultType: "text", structuredValue: ";", multiValue: "," }, + nickname: DEFAULT_TYPE_TEXT_MULTI, + photo: { defaultType: "binary", allowedTypes: ["binary", "uri"] }, + bday: { + defaultType: "date-time", + allowedTypes: ["date-time", "date"], + detectType: function(string) { + return (string.indexOf('T') === -1) ? 'date' : 'date-time'; + } + }, + + adr: { defaultType: "text", structuredValue: ";", multiValue: "," }, + label: DEFAULT_TYPE_TEXT, + + tel: { defaultType: "phone-number" }, + email: DEFAULT_TYPE_TEXT, + mailer: DEFAULT_TYPE_TEXT, + + tz: { defaultType: "utc-offset", allowedTypes: ["utc-offset", "text"] }, + geo: { defaultType: "float", structuredValue: ";" }, + + title: DEFAULT_TYPE_TEXT, + role: DEFAULT_TYPE_TEXT, + logo: { defaultType: "binary", allowedTypes: ["binary", "uri"] }, + agent: { defaultType: "vcard", allowedTypes: ["vcard", "text", "uri"] }, + org: DEFAULT_TYPE_TEXT_STRUCTURED, + + note: DEFAULT_TYPE_TEXT_MULTI, + prodid: DEFAULT_TYPE_TEXT, + rev: { + defaultType: "date-time", + allowedTypes: ["date-time", "date"], + detectType: function(string) { + return (string.indexOf('T') === -1) ? 'date' : 'date-time'; + } + }, + "sort-string": DEFAULT_TYPE_TEXT, + sound: { defaultType: "binary", allowedTypes: ["binary", "uri"] }, + + class: DEFAULT_TYPE_TEXT, + key: { defaultType: "binary", allowedTypes: ["binary", "text"] } + }); + + /** + * iCalendar design set + * @type {ICAL.design.designSet} + */ + var icalSet = { + value: icalValues, + param: icalParams, + property: icalProperties + }; + + /** + * vCard 4.0 design set + * @type {ICAL.design.designSet} + */ + var vcardSet = { + value: vcardValues, + param: vcardParams, + property: vcardProperties + }; + + /** + * vCard 3.0 design set + * @type {ICAL.design.designSet} + */ + var vcard3Set = { + value: vcard3Values, + param: vcard3Params, + property: vcard3Properties + }; + + /** + * The design data, used by the parser to determine types for properties and + * other metadata needed to produce correct jCard/jCal data. + * + * @alias ICAL.design + * @namespace + */ + var design = { + /** + * A designSet describes value, parameter and property data. It is used by + * ther parser and stringifier in components and properties to determine they + * should be represented. + * + * @typedef {Object} designSet + * @memberOf ICAL.design + * @property {Object} value Definitions for value types, keys are type names + * @property {Object} param Definitions for params, keys are param names + * @property {Object} property Defintions for properties, keys are property names + */ + + + /** + * The default set for new properties and components if none is specified. + * @type {ICAL.design.designSet} + */ + defaultSet: icalSet, + + /** + * The default type for unknown properties + * @type {String} + */ + defaultType: 'unknown', + + /** + * Holds the design set for known top-level components + * + * @type {Object} + * @property {ICAL.design.designSet} vcard vCard VCARD + * @property {ICAL.design.designSet} vevent iCalendar VEVENT + * @property {ICAL.design.designSet} vtodo iCalendar VTODO + * @property {ICAL.design.designSet} vjournal iCalendar VJOURNAL + * @property {ICAL.design.designSet} valarm iCalendar VALARM + * @property {ICAL.design.designSet} vtimezone iCalendar VTIMEZONE + * @property {ICAL.design.designSet} daylight iCalendar DAYLIGHT + * @property {ICAL.design.designSet} standard iCalendar STANDARD + * + * @example + * var propertyName = 'fn'; + * var componentDesign = ICAL.design.components.vcard; + * var propertyDetails = componentDesign.property[propertyName]; + * if (propertyDetails.defaultType == 'text') { + * // Yep, sure is... + * } + */ + components: { + vcard: vcardSet, + vcard3: vcard3Set, + vevent: icalSet, + vtodo: icalSet, + vjournal: icalSet, + valarm: icalSet, + vtimezone: icalSet, + daylight: icalSet, + standard: icalSet + }, + + + /** + * The design set for iCalendar (rfc5545/rfc7265) components. + * @type {ICAL.design.designSet} + */ + icalendar: icalSet, + + /** + * The design set for vCard (rfc6350/rfc7095) components. + * @type {ICAL.design.designSet} + */ + vcard: vcardSet, + + /** + * The design set for vCard (rfc2425/rfc2426/rfc7095) components. + * @type {ICAL.design.designSet} + */ + vcard3: vcard3Set, + + /** + * Gets the design set for the given component name. + * + * @param {String} componentName The name of the component + * @return {ICAL.design.designSet} The design set for the component + */ + getDesignSet: function(componentName) { + var isInDesign = componentName && componentName in design.components; + return isInDesign ? design.components[componentName] : design.defaultSet; + } + }; + + return design; +}()); +/* 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/. + * Portions Copyright (C) Philipp Kewisch, 2011-2015 */ + + +/** + * Contains various functions to convert jCal and jCard data back into + * iCalendar and vCard. + * @namespace + */ +ICAL.stringify = (function() { + 'use strict'; + + var LINE_ENDING = '\r\n'; + var DEFAULT_VALUE_TYPE = 'unknown'; + + var design = ICAL.design; + var helpers = ICAL.helpers; + + /** + * Convert a full jCal/jCard array into a iCalendar/vCard string. + * + * @function ICAL.stringify + * @variation function + * @param {Array} jCal The jCal/jCard document + * @return {String} The stringified iCalendar/vCard document + */ + function stringify(jCal) { + if (typeof jCal[0] == "string") { + // This is a single component + jCal = [jCal]; + } + + var i = 0; + var len = jCal.length; + var result = ''; + + for (; i < len; i++) { + result += stringify.component(jCal[i]) + LINE_ENDING; + } + + return result; + } + + /** + * Converts an jCal component array into a ICAL string. + * Recursive will resolve sub-components. + * + * Exact component/property order is not saved all + * properties will come before subcomponents. + * + * @function ICAL.stringify.component + * @param {Array} component + * jCal/jCard fragment of a component + * @param {ICAL.design.designSet} designSet + * The design data to use for this component + * @return {String} The iCalendar/vCard string + */ + stringify.component = function(component, designSet) { + var name = component[0].toUpperCase(); + var result = 'BEGIN:' + name + LINE_ENDING; + + var props = component[1]; + var propIdx = 0; + var propLen = props.length; + + var designSetName = component[0]; + // rfc6350 requires that in vCard 4.0 the first component is the VERSION + // component with as value 4.0, note that 3.0 does not have this requirement. + if (designSetName === 'vcard' && component[1].length > 0 && + !(component[1][0][0] === "version" && component[1][0][3] === "4.0")) { + designSetName = "vcard3"; + } + designSet = designSet || design.getDesignSet(designSetName); + + for (; propIdx < propLen; propIdx++) { + result += stringify.property(props[propIdx], designSet) + LINE_ENDING; + } + + var comps = component[2]; + var compIdx = 0; + var compLen = comps.length; + + for (; compIdx < compLen; compIdx++) { + result += stringify.component(comps[compIdx], designSet) + LINE_ENDING; + } + + result += 'END:' + name; + return result; + }; + + /** + * Converts a single jCal/jCard property to a iCalendar/vCard string. + * + * @function ICAL.stringify.property + * @param {Array} property + * jCal/jCard property array + * @param {ICAL.design.designSet} designSet + * The design data to use for this property + * @param {Boolean} noFold + * If true, the line is not folded + * @return {String} The iCalendar/vCard string + */ + stringify.property = function(property, designSet, noFold) { + var name = property[0].toUpperCase(); + var jsName = property[0]; + var params = property[1]; + + var line = name; + + var paramName; + for (paramName in params) { + var value = params[paramName]; + + /* istanbul ignore else */ + if (params.hasOwnProperty(paramName)) { + var multiValue = (paramName in designSet.param) && designSet.param[paramName].multiValue; + if (multiValue && Array.isArray(value)) { + if (designSet.param[paramName].multiValueSeparateDQuote) { + multiValue = '"' + multiValue + '"'; + } + value = value.map(stringify._rfc6868Unescape); + value = stringify.multiValue(value, multiValue, "unknown", null, designSet); + } else { + value = stringify._rfc6868Unescape(value); + } + + + line += ';' + paramName.toUpperCase(); + line += '=' + stringify.propertyValue(value); + } + } + + if (property.length === 3) { + // If there are no values, we must assume a blank value + return line + ':'; + } + + var valueType = property[2]; + + if (!designSet) { + designSet = design.defaultSet; + } + + var propDetails; + var multiValue = false; + var structuredValue = false; + var isDefault = false; + + if (jsName in designSet.property) { + propDetails = designSet.property[jsName]; + + if ('multiValue' in propDetails) { + multiValue = propDetails.multiValue; + } + + if (('structuredValue' in propDetails) && Array.isArray(property[3])) { + structuredValue = propDetails.structuredValue; + } + + if ('defaultType' in propDetails) { + if (valueType === propDetails.defaultType) { + isDefault = true; + } + } else { + if (valueType === DEFAULT_VALUE_TYPE) { + isDefault = true; + } + } + } else { + if (valueType === DEFAULT_VALUE_TYPE) { + isDefault = true; + } + } + + // push the VALUE property if type is not the default + // for the current property. + if (!isDefault) { + // value will never contain ;/:/, so we don't escape it here. + line += ';VALUE=' + valueType.toUpperCase(); + } + + line += ':'; + + if (multiValue && structuredValue) { + line += stringify.multiValue( + property[3], structuredValue, valueType, multiValue, designSet, structuredValue + ); + } else if (multiValue) { + line += stringify.multiValue( + property.slice(3), multiValue, valueType, null, designSet, false + ); + } else if (structuredValue) { + line += stringify.multiValue( + property[3], structuredValue, valueType, null, designSet, structuredValue + ); + } else { + line += stringify.value(property[3], valueType, designSet, false); + } + + return noFold ? line : ICAL.helpers.foldline(line); + }; + + /** + * Handles escaping of property values that may contain: + * + * COLON (:), SEMICOLON (;), or COMMA (,) + * + * If any of the above are present the result is wrapped + * in double quotes. + * + * @function ICAL.stringify.propertyValue + * @param {String} value Raw property value + * @return {String} Given or escaped value when needed + */ + stringify.propertyValue = function(value) { + + if ((helpers.unescapedIndexOf(value, ',') === -1) && + (helpers.unescapedIndexOf(value, ':') === -1) && + (helpers.unescapedIndexOf(value, ';') === -1)) { + + return value; + } + + return '"' + value + '"'; + }; + + /** + * Converts an array of ical values into a single + * string based on a type and a delimiter value (like ","). + * + * @function ICAL.stringify.multiValue + * @param {Array} values List of values to convert + * @param {String} delim Used to join the values (",", ";", ":") + * @param {String} type Lowecase ical value type + * (like boolean, date-time, etc..) + * @param {?String} innerMulti If set, each value will again be processed + * Used for structured values + * @param {ICAL.design.designSet} designSet + * The design data to use for this property + * + * @return {String} iCalendar/vCard string for value + */ + stringify.multiValue = function(values, delim, type, innerMulti, designSet, structuredValue) { + var result = ''; + var len = values.length; + var i = 0; + + for (; i < len; i++) { + if (innerMulti && Array.isArray(values[i])) { + result += stringify.multiValue(values[i], innerMulti, type, null, designSet, structuredValue); + } else { + result += stringify.value(values[i], type, designSet, structuredValue); + } + + if (i !== (len - 1)) { + result += delim; + } + } + + return result; + }; + + /** + * Processes a single ical value runs the associated "toICAL" method from the + * design value type if available to convert the value. + * + * @function ICAL.stringify.value + * @param {String|Number} value A formatted value + * @param {String} type Lowercase iCalendar/vCard value type + * (like boolean, date-time, etc..) + * @return {String} iCalendar/vCard value for single value + */ + stringify.value = function(value, type, designSet, structuredValue) { + if (type in designSet.value && 'toICAL' in designSet.value[type]) { + return designSet.value[type].toICAL(value, structuredValue); + } + return value; + }; + + /** + * Internal helper for rfc6868. Exposing this on ICAL.stringify so that + * hackers can disable the rfc6868 parsing if the really need to. + * + * @param {String} val The value to unescape + * @return {String} The escaped value + */ + stringify._rfc6868Unescape = function(val) { + return val.replace(/[\n^"]/g, function(x) { + return RFC6868_REPLACE_MAP[x]; + }); + }; + var RFC6868_REPLACE_MAP = { '"': "^'", "\n": "^n", "^": "^^" }; + + return stringify; +}()); +/* 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/. + * Portions Copyright (C) Philipp Kewisch, 2011-2015 */ + + +/** + * Contains various functions to parse iCalendar and vCard data. + * @namespace + */ +ICAL.parse = (function() { + 'use strict'; + + var CHAR = /[^ \t]/; + var MULTIVALUE_DELIMITER = ','; + var VALUE_DELIMITER = ':'; + var PARAM_DELIMITER = ';'; + var PARAM_NAME_DELIMITER = '='; + var DEFAULT_VALUE_TYPE = 'unknown'; + var DEFAULT_PARAM_TYPE = 'text'; + + var design = ICAL.design; + var helpers = ICAL.helpers; + + /** + * An error that occurred during parsing. + * + * @param {String} message The error message + * @memberof ICAL.parse + * @extends {Error} + * @class + */ + function ParserError(message) { + this.message = message; + this.name = 'ParserError'; + + try { + throw new Error(); + } catch (e) { + if (e.stack) { + var split = e.stack.split('\n'); + split.shift(); + this.stack = split.join('\n'); + } + } + } + + ParserError.prototype = Error.prototype; + + /** + * Parses iCalendar or vCard data into a raw jCal object. Consult + * documentation on the {@tutorial layers|layers of parsing} for more + * details. + * + * @function ICAL.parse + * @variation function + * @todo Fix the API to be more clear on the return type + * @param {String} input The string data to parse + * @return {Object|Object[]} A single jCal object, or an array thereof + */ + function parser(input) { + var state = {}; + var root = state.component = []; + + state.stack = [root]; + + parser._eachLine(input, function(err, line) { + parser._handleContentLine(line, state); + }); + + + // when there are still items on the stack + // throw a fatal error, a component was not closed + // correctly in that case. + if (state.stack.length > 1) { + throw new ParserError( + 'invalid ical body. component began but did not end' + ); + } + + state = null; + + return (root.length == 1 ? root[0] : root); + } + + /** + * Parse an iCalendar property value into the jCal for a single property + * + * @function ICAL.parse.property + * @param {String} str + * The iCalendar property string to parse + * @param {ICAL.design.designSet=} designSet + * The design data to use for this property + * @return {Object} + * The jCal Object containing the property + */ + parser.property = function(str, designSet) { + var state = { + component: [[], []], + designSet: designSet || design.defaultSet + }; + parser._handleContentLine(str, state); + return state.component[1][0]; + }; + + /** + * Convenience method to parse a component. You can use ICAL.parse() directly + * instead. + * + * @function ICAL.parse.component + * @see ICAL.parse(function) + * @param {String} str The iCalendar component string to parse + * @return {Object} The jCal Object containing the component + */ + parser.component = function(str) { + return parser(str); + }; + + // classes & constants + parser.ParserError = ParserError; + + /** + * The state for parsing content lines from an iCalendar/vCard string. + * + * @private + * @memberof ICAL.parse + * @typedef {Object} parserState + * @property {ICAL.design.designSet} designSet The design set to use for parsing + * @property {ICAL.Component[]} stack The stack of components being processed + * @property {ICAL.Component} component The currently active component + */ + + + /** + * Handles a single line of iCalendar/vCard, updating the state. + * + * @private + * @function ICAL.parse._handleContentLine + * @param {String} line The content line to process + * @param {ICAL.parse.parserState} The current state of the line parsing + */ + parser._handleContentLine = function(line, state) { + // break up the parts of the line + var valuePos = line.indexOf(VALUE_DELIMITER); + var paramPos = line.indexOf(PARAM_DELIMITER); + + var lastParamIndex; + var lastValuePos; + + // name of property or begin/end + var name; + var value; + // params is only overridden if paramPos !== -1. + // we can't do params = params || {} later on + // because it sacrifices ops. + var params = {}; + + /** + * Different property cases + * + * + * 1. RRULE:FREQ=foo + * // FREQ= is not a param but the value + * + * 2. ATTENDEE;ROLE=REQ-PARTICIPANT; + * // ROLE= is a param because : has not happened yet + */ + // when the parameter delimiter is after the + // value delimiter then its not a parameter. + + if ((paramPos !== -1 && valuePos !== -1)) { + // when the parameter delimiter is after the + // value delimiter then its not a parameter. + if (paramPos > valuePos) { + paramPos = -1; + } + } + + var parsedParams; + if (paramPos !== -1) { + name = line.substring(0, paramPos).toLowerCase(); + parsedParams = parser._parseParameters(line.substring(paramPos), 0, state.designSet); + if (parsedParams[2] == -1) { + throw new ParserError("Invalid parameters in '" + line + "'"); + } + params = parsedParams[0]; + lastParamIndex = parsedParams[1].length + parsedParams[2] + paramPos; + if ((lastValuePos = + line.substring(lastParamIndex).indexOf(VALUE_DELIMITER)) !== -1) { + value = line.substring(lastParamIndex + lastValuePos + 1); + } else { + throw new ParserError("Missing parameter value in '" + line + "'"); + } + } else if (valuePos !== -1) { + // without parmeters (BEGIN:VCAENDAR, CLASS:PUBLIC) + name = line.substring(0, valuePos).toLowerCase(); + value = line.substring(valuePos + 1); + + if (name === 'begin') { + var newComponent = [value.toLowerCase(), [], []]; + if (state.stack.length === 1) { + state.component.push(newComponent); + } else { + state.component[2].push(newComponent); + } + state.stack.push(state.component); + state.component = newComponent; + if (!state.designSet) { + state.designSet = design.getDesignSet(state.component[0]); + } + return; + } else if (name === 'end') { + state.component = state.stack.pop(); + return; + } + // If its not begin/end, then this is a property with an empty value, + // which should be considered valid. + } else { + /** + * Invalid line. + * The rational to throw an error is we will + * never be certain that the rest of the file + * is sane and its unlikely that we can serialize + * the result correctly either. + */ + throw new ParserError( + 'invalid line (no token ";" or ":") "' + line + '"' + ); + } + + var valueType; + var multiValue = false; + var structuredValue = false; + var propertyDetails; + + if (name in state.designSet.property) { + propertyDetails = state.designSet.property[name]; + + if ('multiValue' in propertyDetails) { + multiValue = propertyDetails.multiValue; + } + + if ('structuredValue' in propertyDetails) { + structuredValue = propertyDetails.structuredValue; + } + + if (value && 'detectType' in propertyDetails) { + valueType = propertyDetails.detectType(value); + } + } + + // attempt to determine value + if (!valueType) { + if (!('value' in params)) { + if (propertyDetails) { + valueType = propertyDetails.defaultType; + } else { + valueType = DEFAULT_VALUE_TYPE; + } + } else { + // possible to avoid this? + valueType = params.value.toLowerCase(); + } + } + + delete params.value; + + /** + * Note on `var result` juggling: + * + * I observed that building the array in pieces has adverse + * effects on performance, so where possible we inline the creation. + * Its a little ugly but resulted in ~2000 additional ops/sec. + */ + + var result; + if (multiValue && structuredValue) { + value = parser._parseMultiValue(value, structuredValue, valueType, [], multiValue, state.designSet, structuredValue); + result = [name, params, valueType, value]; + } else if (multiValue) { + result = [name, params, valueType]; + parser._parseMultiValue(value, multiValue, valueType, result, null, state.designSet, false); + } else if (structuredValue) { + value = parser._parseMultiValue(value, structuredValue, valueType, [], null, state.designSet, structuredValue); + result = [name, params, valueType, value]; + } else { + value = parser._parseValue(value, valueType, state.designSet, false); + result = [name, params, valueType, value]; + } + // rfc6350 requires that in vCard 4.0 the first component is the VERSION + // component with as value 4.0, note that 3.0 does not have this requirement. + if (state.component[0] === 'vcard' && state.component[1].length === 0 && + !(name === 'version' && value === '4.0')) { + state.designSet = design.getDesignSet("vcard3"); + } + state.component[1].push(result); + }; + + /** + * Parse a value from the raw value into the jCard/jCal value. + * + * @private + * @function ICAL.parse._parseValue + * @param {String} value Original value + * @param {String} type Type of value + * @param {Object} designSet The design data to use for this value + * @return {Object} varies on type + */ + parser._parseValue = function(value, type, designSet, structuredValue) { + if (type in designSet.value && 'fromICAL' in designSet.value[type]) { + return designSet.value[type].fromICAL(value, structuredValue); + } + return value; + }; + + /** + * Parse parameters from a string to object. + * + * @function ICAL.parse._parseParameters + * @private + * @param {String} line A single unfolded line + * @param {Numeric} start Position to start looking for properties + * @param {Object} designSet The design data to use for this property + * @return {Object} key/value pairs + */ + parser._parseParameters = function(line, start, designSet) { + var lastParam = start; + var pos = 0; + var delim = PARAM_NAME_DELIMITER; + var result = {}; + var name, lcname; + var value, valuePos = -1; + var type, multiValue, mvdelim; + + // find the next '=' sign + // use lastParam and pos to find name + // check if " is used if so get value from "->" + // then increment pos to find next ; + + while ((pos !== false) && + (pos = helpers.unescapedIndexOf(line, delim, pos + 1)) !== -1) { + + name = line.substr(lastParam + 1, pos - lastParam - 1); + if (name.length == 0) { + throw new ParserError("Empty parameter name in '" + line + "'"); + } + lcname = name.toLowerCase(); + + if (lcname in designSet.param && designSet.param[lcname].valueType) { + type = designSet.param[lcname].valueType; + } else { + type = DEFAULT_PARAM_TYPE; + } + + if (lcname in designSet.param) { + multiValue = designSet.param[lcname].multiValue; + if (designSet.param[lcname].multiValueSeparateDQuote) { + mvdelim = parser._rfc6868Escape('"' + multiValue + '"'); + } + } + + var nextChar = line[pos + 1]; + if (nextChar === '"') { + valuePos = pos + 2; + pos = helpers.unescapedIndexOf(line, '"', valuePos); + if (multiValue && pos != -1) { + var extendedValue = true; + while (extendedValue) { + if (line[pos + 1] == multiValue && line[pos + 2] == '"') { + pos = helpers.unescapedIndexOf(line, '"', pos + 3); + } else { + extendedValue = false; + } + } + } + if (pos === -1) { + throw new ParserError( + 'invalid line (no matching double quote) "' + line + '"' + ); + } + value = line.substr(valuePos, pos - valuePos); + lastParam = helpers.unescapedIndexOf(line, PARAM_DELIMITER, pos); + if (lastParam === -1) { + pos = false; + } + } else { + valuePos = pos + 1; + + // move to next ";" + var nextPos = helpers.unescapedIndexOf(line, PARAM_DELIMITER, valuePos); + var propValuePos = helpers.unescapedIndexOf(line, VALUE_DELIMITER, valuePos); + if (propValuePos !== -1 && nextPos > propValuePos) { + // this is a delimiter in the property value, let's stop here + nextPos = propValuePos; + pos = false; + } else if (nextPos === -1) { + // no ";" + if (propValuePos === -1) { + nextPos = line.length; + } else { + nextPos = propValuePos; + } + pos = false; + } else { + lastParam = nextPos; + pos = nextPos; + } + + value = line.substr(valuePos, nextPos - valuePos); + } + + value = parser._rfc6868Escape(value); + if (multiValue) { + var delimiter = mvdelim || multiValue; + result[lcname] = parser._parseMultiValue(value, delimiter, type, [], null, designSet); + } else { + result[lcname] = parser._parseValue(value, type, designSet); + } + } + return [result, value, valuePos]; + }; + + /** + * Internal helper for rfc6868. Exposing this on ICAL.parse so that + * hackers can disable the rfc6868 parsing if the really need to. + * + * @function ICAL.parse._rfc6868Escape + * @param {String} val The value to escape + * @return {String} The escaped value + */ + parser._rfc6868Escape = function(val) { + return val.replace(/\^['n^]/g, function(x) { + return RFC6868_REPLACE_MAP[x]; + }); + }; + var RFC6868_REPLACE_MAP = { "^'": '"', "^n": "\n", "^^": "^" }; + + /** + * Parse a multi value string. This function is used either for parsing + * actual multi-value property's values, or for handling parameter values. It + * can be used for both multi-value properties and structured value properties. + * + * @private + * @function ICAL.parse._parseMultiValue + * @param {String} buffer The buffer containing the full value + * @param {String} delim The multi-value delimiter + * @param {String} type The value type to be parsed + * @param {Array.<?>} result The array to append results to, varies on value type + * @param {String} innerMulti The inner delimiter to split each value with + * @param {ICAL.design.designSet} designSet The design data for this value + * @return {?|Array.<?>} Either an array of results, or the first result + */ + parser._parseMultiValue = function(buffer, delim, type, result, innerMulti, designSet, structuredValue) { + var pos = 0; + var lastPos = 0; + var value; + if (delim.length === 0) { + return buffer; + } + + // split each piece + while ((pos = helpers.unescapedIndexOf(buffer, delim, lastPos)) !== -1) { + value = buffer.substr(lastPos, pos - lastPos); + if (innerMulti) { + value = parser._parseMultiValue(value, innerMulti, type, [], null, designSet, structuredValue); + } else { + value = parser._parseValue(value, type, designSet, structuredValue); + } + result.push(value); + lastPos = pos + delim.length; + } + + // on the last piece take the rest of string + value = buffer.substr(lastPos); + if (innerMulti) { + value = parser._parseMultiValue(value, innerMulti, type, [], null, designSet, structuredValue); + } else { + value = parser._parseValue(value, type, designSet, structuredValue); + } + result.push(value); + + return result.length == 1 ? result[0] : result; + }; + + /** + * Process a complete buffer of iCalendar/vCard data line by line, correctly + * unfolding content. Each line will be processed with the given callback + * + * @private + * @function ICAL.parse._eachLine + * @param {String} buffer The buffer to process + * @param {function(?String, String)} callback The callback for each line + */ + parser._eachLine = function(buffer, callback) { + var len = buffer.length; + var lastPos = buffer.search(CHAR); + var pos = lastPos; + var line; + var firstChar; + + var newlineOffset; + + do { + pos = buffer.indexOf('\n', lastPos) + 1; + + if (pos > 1 && buffer[pos - 2] === '\r') { + newlineOffset = 2; + } else { + newlineOffset = 1; + } + + if (pos === 0) { + pos = len; + newlineOffset = 0; + } + + firstChar = buffer[lastPos]; + + if (firstChar === ' ' || firstChar === '\t') { + // add to line + line += buffer.substr( + lastPos + 1, + pos - lastPos - (newlineOffset + 1) + ); + } else { + if (line) + callback(null, line); + // push line + line = buffer.substr( + lastPos, + pos - lastPos - newlineOffset + ); + } + + lastPos = pos; + } while (pos !== len); + + // extra ending line + line = line.trim(); + + if (line.length) + callback(null, line); + }; + + return parser; + +}()); +/* 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/. + * Portions Copyright (C) Philipp Kewisch, 2011-2015 */ + + +/** + * This symbol is further described later on + * @ignore + */ +ICAL.Component = (function() { + 'use strict'; + + var PROPERTY_INDEX = 1; + var COMPONENT_INDEX = 2; + var NAME_INDEX = 0; + + /** + * @classdesc + * Wraps a jCal component, adding convenience methods to add, remove and + * update subcomponents and properties. + * + * @class + * @alias ICAL.Component + * @param {Array|String} jCal Raw jCal component data OR name of new + * component + * @param {ICAL.Component} parent Parent component to associate + */ + function Component(jCal, parent) { + if (typeof(jCal) === 'string') { + // jCal spec (name, properties, components) + jCal = [jCal, [], []]; + } + + // mostly for legacy reasons. + this.jCal = jCal; + + this.parent = parent || null; + } + + Component.prototype = { + /** + * Hydrated properties are inserted into the _properties array at the same + * position as in the jCal array, so its possible the array contains + * undefined values for unhydrdated properties. To avoid iterating the + * array when checking if all properties have been hydrated, we save the + * count here. + * + * @type {Number} + * @private + */ + _hydratedPropertyCount: 0, + + /** + * The same count as for _hydratedPropertyCount, but for subcomponents + * + * @type {Number} + * @private + */ + _hydratedComponentCount: 0, + + /** + * The name of this component + * @readonly + */ + get name() { + return this.jCal[NAME_INDEX]; + }, + + /** + * The design set for this component, e.g. icalendar vs vcard + * + * @type {ICAL.design.designSet} + * @private + */ + get _designSet() { + var parentDesign = this.parent && this.parent._designSet; + return parentDesign || ICAL.design.getDesignSet(this.name); + }, + + _hydrateComponent: function(index) { + if (!this._components) { + this._components = []; + this._hydratedComponentCount = 0; + } + + if (this._components[index]) { + return this._components[index]; + } + + var comp = new Component( + this.jCal[COMPONENT_INDEX][index], + this + ); + + this._hydratedComponentCount++; + return (this._components[index] = comp); + }, + + _hydrateProperty: function(index) { + if (!this._properties) { + this._properties = []; + this._hydratedPropertyCount = 0; + } + + if (this._properties[index]) { + return this._properties[index]; + } + + var prop = new ICAL.Property( + this.jCal[PROPERTY_INDEX][index], + this + ); + + this._hydratedPropertyCount++; + return (this._properties[index] = prop); + }, + + /** + * Finds first sub component, optionally filtered by name. + * + * @param {String=} name Optional name to filter by + * @return {?ICAL.Component} The found subcomponent + */ + getFirstSubcomponent: function(name) { + if (name) { + var i = 0; + var comps = this.jCal[COMPONENT_INDEX]; + var len = comps.length; + + for (; i < len; i++) { + if (comps[i][NAME_INDEX] === name) { + var result = this._hydrateComponent(i); + return result; + } + } + } else { + if (this.jCal[COMPONENT_INDEX].length) { + return this._hydrateComponent(0); + } + } + + // ensure we return a value (strict mode) + return null; + }, + + /** + * Finds all sub components, optionally filtering by name. + * + * @param {String=} name Optional name to filter by + * @return {ICAL.Component[]} The found sub components + */ + getAllSubcomponents: function(name) { + var jCalLen = this.jCal[COMPONENT_INDEX].length; + var i = 0; + + if (name) { + var comps = this.jCal[COMPONENT_INDEX]; + var result = []; + + for (; i < jCalLen; i++) { + if (name === comps[i][NAME_INDEX]) { + result.push( + this._hydrateComponent(i) + ); + } + } + return result; + } else { + if (!this._components || + (this._hydratedComponentCount !== jCalLen)) { + for (; i < jCalLen; i++) { + this._hydrateComponent(i); + } + } + + return this._components || []; + } + }, + + /** + * Returns true when a named property exists. + * + * @param {String} name The property name + * @return {Boolean} True, when property is found + */ + hasProperty: function(name) { + var props = this.jCal[PROPERTY_INDEX]; + var len = props.length; + + var i = 0; + for (; i < len; i++) { + // 0 is property name + if (props[i][NAME_INDEX] === name) { + return true; + } + } + + return false; + }, + + /** + * Finds the first property, optionally with the given name. + * + * @param {String=} name Lowercase property name + * @return {?ICAL.Property} The found property + */ + getFirstProperty: function(name) { + if (name) { + var i = 0; + var props = this.jCal[PROPERTY_INDEX]; + var len = props.length; + + for (; i < len; i++) { + if (props[i][NAME_INDEX] === name) { + var result = this._hydrateProperty(i); + return result; + } + } + } else { + if (this.jCal[PROPERTY_INDEX].length) { + return this._hydrateProperty(0); + } + } + + return null; + }, + + /** + * Returns first property's value, if available. + * + * @param {String=} name Lowercase property name + * @return {?String} The found property value. + */ + getFirstPropertyValue: function(name) { + var prop = this.getFirstProperty(name); + if (prop) { + return prop.getFirstValue(); + } + + return null; + }, + + /** + * Get all properties in the component, optionally filtered by name. + * + * @param {String=} name Lowercase property name + * @return {ICAL.Property[]} List of properties + */ + getAllProperties: function(name) { + var jCalLen = this.jCal[PROPERTY_INDEX].length; + var i = 0; + + if (name) { + var props = this.jCal[PROPERTY_INDEX]; + var result = []; + + for (; i < jCalLen; i++) { + if (name === props[i][NAME_INDEX]) { + result.push( + this._hydrateProperty(i) + ); + } + } + return result; + } else { + if (!this._properties || + (this._hydratedPropertyCount !== jCalLen)) { + for (; i < jCalLen; i++) { + this._hydrateProperty(i); + } + } + + return this._properties || []; + } + }, + + _removeObjectByIndex: function(jCalIndex, cache, index) { + cache = cache || []; + // remove cached version + if (cache[index]) { + var obj = cache[index]; + if ("parent" in obj) { + obj.parent = null; + } + } + + cache.splice(index, 1); + + // remove it from the jCal + this.jCal[jCalIndex].splice(index, 1); + }, + + _removeObject: function(jCalIndex, cache, nameOrObject) { + var i = 0; + var objects = this.jCal[jCalIndex]; + var len = objects.length; + var cached = this[cache]; + + if (typeof(nameOrObject) === 'string') { + for (; i < len; i++) { + if (objects[i][NAME_INDEX] === nameOrObject) { + this._removeObjectByIndex(jCalIndex, cached, i); + return true; + } + } + } else if (cached) { + for (; i < len; i++) { + if (cached[i] && cached[i] === nameOrObject) { + this._removeObjectByIndex(jCalIndex, cached, i); + return true; + } + } + } + + return false; + }, + + _removeAllObjects: function(jCalIndex, cache, name) { + var cached = this[cache]; + + // Unfortunately we have to run through all children to reset their + // parent property. + var objects = this.jCal[jCalIndex]; + var i = objects.length - 1; + + // descending search required because splice + // is used and will effect the indices. + for (; i >= 0; i--) { + if (!name || objects[i][NAME_INDEX] === name) { + this._removeObjectByIndex(jCalIndex, cached, i); + } + } + }, + + /** + * Adds a single sub component. + * + * @param {ICAL.Component} component The component to add + * @return {ICAL.Component} The passed in component + */ + addSubcomponent: function(component) { + if (!this._components) { + this._components = []; + this._hydratedComponentCount = 0; + } + + if (component.parent) { + component.parent.removeSubcomponent(component); + } + + var idx = this.jCal[COMPONENT_INDEX].push(component.jCal); + this._components[idx - 1] = component; + this._hydratedComponentCount++; + component.parent = this; + return component; + }, + + /** + * Removes a single component by name or the instance of a specific + * component. + * + * @param {ICAL.Component|String} nameOrComp Name of component, or component + * @return {Boolean} True when comp is removed + */ + removeSubcomponent: function(nameOrComp) { + var removed = this._removeObject(COMPONENT_INDEX, '_components', nameOrComp); + if (removed) { + this._hydratedComponentCount--; + } + return removed; + }, + + /** + * Removes all components or (if given) all components by a particular + * name. + * + * @param {String=} name Lowercase component name + */ + removeAllSubcomponents: function(name) { + var removed = this._removeAllObjects(COMPONENT_INDEX, '_components', name); + this._hydratedComponentCount = 0; + return removed; + }, + + /** + * Adds an {@link ICAL.Property} to the component. + * + * @param {ICAL.Property} property The property to add + * @return {ICAL.Property} The passed in property + */ + addProperty: function(property) { + if (!(property instanceof ICAL.Property)) { + throw new TypeError('must instance of ICAL.Property'); + } + + if (!this._properties) { + this._properties = []; + this._hydratedPropertyCount = 0; + } + + if (property.parent) { + property.parent.removeProperty(property); + } + + var idx = this.jCal[PROPERTY_INDEX].push(property.jCal); + this._properties[idx - 1] = property; + this._hydratedPropertyCount++; + property.parent = this; + return property; + }, + + /** + * Helper method to add a property with a value to the component. + * + * @param {String} name Property name to add + * @param {String|Number|Object} value Property value + * @return {ICAL.Property} The created property + */ + addPropertyWithValue: function(name, value) { + var prop = new ICAL.Property(name); + prop.setValue(value); + + this.addProperty(prop); + + return prop; + }, + + /** + * Helper method that will update or create a property of the given name + * and sets its value. If multiple properties with the given name exist, + * only the first is updated. + * + * @param {String} name Property name to update + * @param {String|Number|Object} value Property value + * @return {ICAL.Property} The created property + */ + updatePropertyWithValue: function(name, value) { + var prop = this.getFirstProperty(name); + + if (prop) { + prop.setValue(value); + } else { + prop = this.addPropertyWithValue(name, value); + } + + return prop; + }, + + /** + * Removes a single property by name or the instance of the specific + * property. + * + * @param {String|ICAL.Property} nameOrProp Property name or instance to remove + * @return {Boolean} True, when deleted + */ + removeProperty: function(nameOrProp) { + var removed = this._removeObject(PROPERTY_INDEX, '_properties', nameOrProp); + if (removed) { + this._hydratedPropertyCount--; + } + return removed; + }, + + /** + * Removes all properties associated with this component, optionally + * filtered by name. + * + * @param {String=} name Lowercase property name + * @return {Boolean} True, when deleted + */ + removeAllProperties: function(name) { + var removed = this._removeAllObjects(PROPERTY_INDEX, '_properties', name); + this._hydratedPropertyCount = 0; + return removed; + }, + + /** + * Returns the Object representation of this component. The returned object + * is a live jCal object and should be cloned if modified. + * @return {Object} + */ + toJSON: function() { + return this.jCal; + }, + + /** + * The string representation of this component. + * @return {String} + */ + toString: function() { + return ICAL.stringify.component( + this.jCal, this._designSet + ); + } + }; + + /** + * Create an {@link ICAL.Component} by parsing the passed iCalendar string. + * + * @param {String} str The iCalendar string to parse + */ + Component.fromString = function(str) { + return new Component(ICAL.parse.component(str)); + }; + + return Component; +}()); +/* 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/. + * Portions Copyright (C) Philipp Kewisch, 2011-2015 */ + + +/** + * This symbol is further described later on + * @ignore + */ +ICAL.Property = (function() { + 'use strict'; + + var NAME_INDEX = 0; + var PROP_INDEX = 1; + var TYPE_INDEX = 2; + var VALUE_INDEX = 3; + + var design = ICAL.design; + + /** + * @classdesc + * Provides a layer on top of the raw jCal object for manipulating a single + * property, with its parameters and value. + * + * @description + * Its important to note that mutations done in the wrapper + * directly mutate the jCal object used to initialize. + * + * Can also be used to create new properties by passing + * the name of the property (as a String). + * + * @class + * @alias ICAL.Property + * @param {Array|String} jCal Raw jCal representation OR + * the new name of the property + * + * @param {ICAL.Component=} parent Parent component + */ + function Property(jCal, parent) { + this._parent = parent || null; + + if (typeof(jCal) === 'string') { + // We are creating the property by name and need to detect the type + this.jCal = [jCal, {}, design.defaultType]; + this.jCal[TYPE_INDEX] = this.getDefaultType(); + } else { + this.jCal = jCal; + } + this._updateType(); + } + + Property.prototype = { + + /** + * The value type for this property + * @readonly + * @type {String} + */ + get type() { + return this.jCal[TYPE_INDEX]; + }, + + /** + * The name of this property, in lowercase. + * @readonly + * @type {String} + */ + get name() { + return this.jCal[NAME_INDEX]; + }, + + /** + * The parent component for this property. + * @type {ICAL.Component} + */ + get parent() { + return this._parent; + }, + + set parent(p) { + // Before setting the parent, check if the design set has changed. If it + // has, we later need to update the type if it was unknown before. + var designSetChanged = !this._parent || (p && p._designSet != this._parent._designSet); + + this._parent = p; + + if (this.type == design.defaultType && designSetChanged) { + this.jCal[TYPE_INDEX] = this.getDefaultType(); + this._updateType(); + } + + return p; + }, + + /** + * The design set for this property, e.g. icalendar vs vcard + * + * @type {ICAL.design.designSet} + * @private + */ + get _designSet() { + return this.parent ? this.parent._designSet : design.defaultSet; + }, + + /** + * Updates the type metadata from the current jCal type and design set. + * + * @private + */ + _updateType: function() { + var designSet = this._designSet; + + if (this.type in designSet.value) { + var designType = designSet.value[this.type]; + + if ('decorate' in designSet.value[this.type]) { + this.isDecorated = true; + } else { + this.isDecorated = false; + } + + if (this.name in designSet.property) { + this.isMultiValue = ('multiValue' in designSet.property[this.name]); + this.isStructuredValue = ('structuredValue' in designSet.property[this.name]); + } + } + }, + + /** + * Hydrate a single value. The act of hydrating means turning the raw jCal + * value into a potentially wrapped object, for example {@link ICAL.Time}. + * + * @private + * @param {Number} index The index of the value to hydrate + * @return {Object} The decorated value. + */ + _hydrateValue: function(index) { + if (this._values && this._values[index]) { + return this._values[index]; + } + + // for the case where there is no value. + if (this.jCal.length <= (VALUE_INDEX + index)) { + return null; + } + + if (this.isDecorated) { + if (!this._values) { + this._values = []; + } + return (this._values[index] = this._decorate( + this.jCal[VALUE_INDEX + index] + )); + } else { + return this.jCal[VALUE_INDEX + index]; + } + }, + + /** + * Decorate a single value, returning its wrapped object. This is used by + * the hydrate function to actually wrap the value. + * + * @private + * @param {?} value The value to decorate + * @return {Object} The decorated value + */ + _decorate: function(value) { + return this._designSet.value[this.type].decorate(value, this); + }, + + /** + * Undecorate a single value, returning its raw jCal data. + * + * @private + * @param {Object} value The value to undecorate + * @return {?} The undecorated value + */ + _undecorate: function(value) { + return this._designSet.value[this.type].undecorate(value, this); + }, + + /** + * Sets the value at the given index while also hydrating it. The passed + * value can either be a decorated or undecorated value. + * + * @private + * @param {?} value The value to set + * @param {Number} index The index to set it at + */ + _setDecoratedValue: function(value, index) { + if (!this._values) { + this._values = []; + } + + if (typeof(value) === 'object' && 'icaltype' in value) { + // decorated value + this.jCal[VALUE_INDEX + index] = this._undecorate(value); + this._values[index] = value; + } else { + // undecorated value + this.jCal[VALUE_INDEX + index] = value; + this._values[index] = this._decorate(value); + } + }, + + /** + * Gets a parameter on the property. + * + * @param {String} name Property name (lowercase) + * @return {Array|String} Property value + */ + getParameter: function(name) { + if (name in this.jCal[PROP_INDEX]) { + return this.jCal[PROP_INDEX][name]; + } else { + return undefined; + } + }, + + /** + * Sets a parameter on the property. + * + * @param {String} name The parameter name + * @param {Array|String} value The parameter value + */ + setParameter: function(name, value) { + var lcname = name.toLowerCase(); + if (typeof value === "string" && + lcname in this._designSet.param && + 'multiValue' in this._designSet.param[lcname]) { + value = [value]; + } + this.jCal[PROP_INDEX][name] = value; + }, + + /** + * Removes a parameter + * + * @param {String} name The parameter name + */ + removeParameter: function(name) { + delete this.jCal[PROP_INDEX][name]; + }, + + /** + * Get the default type based on this property's name. + * + * @return {String} The default type for this property + */ + getDefaultType: function() { + var name = this.jCal[NAME_INDEX]; + var designSet = this._designSet; + + if (name in designSet.property) { + var details = designSet.property[name]; + if ('defaultType' in details) { + return details.defaultType; + } + } + return design.defaultType; + }, + + /** + * Sets type of property and clears out any existing values of the current + * type. + * + * @param {String} type New iCAL type (see design.*.values) + */ + resetType: function(type) { + this.removeAllValues(); + this.jCal[TYPE_INDEX] = type; + this._updateType(); + }, + + /** + * Finds the first property value. + * + * @return {String} First property value + */ + getFirstValue: function() { + return this._hydrateValue(0); + }, + + /** + * Gets all values on the property. + * + * NOTE: this creates an array during each call. + * + * @return {Array} List of values + */ + getValues: function() { + var len = this.jCal.length - VALUE_INDEX; + + if (len < 1) { + // its possible for a property to have no value. + return []; + } + + var i = 0; + var result = []; + + for (; i < len; i++) { + result[i] = this._hydrateValue(i); + } + + return result; + }, + + /** + * Removes all values from this property + */ + removeAllValues: function() { + if (this._values) { + this._values.length = 0; + } + this.jCal.length = 3; + }, + + /** + * Sets the values of the property. Will overwrite the existing values. + * This can only be used for multi-value properties. + * + * @param {Array} values An array of values + */ + setValues: function(values) { + if (!this.isMultiValue) { + throw new Error( + this.name + ': does not not support mulitValue.\n' + + 'override isMultiValue' + ); + } + + var len = values.length; + var i = 0; + this.removeAllValues(); + + if (len > 0 && + typeof(values[0]) === 'object' && + 'icaltype' in values[0]) { + this.resetType(values[0].icaltype); + } + + if (this.isDecorated) { + for (; i < len; i++) { + this._setDecoratedValue(values[i], i); + } + } else { + for (; i < len; i++) { + this.jCal[VALUE_INDEX + i] = values[i]; + } + } + }, + + /** + * Sets the current value of the property. If this is a multi-value + * property, all other values will be removed. + * + * @param {String|Object} value New property value. + */ + setValue: function(value) { + this.removeAllValues(); + if (typeof(value) === 'object' && 'icaltype' in value) { + this.resetType(value.icaltype); + } + + if (this.isDecorated) { + this._setDecoratedValue(value, 0); + } else { + this.jCal[VALUE_INDEX] = value; + } + }, + + /** + * Returns the Object representation of this component. The returned object + * is a live jCal object and should be cloned if modified. + * @return {Object} + */ + toJSON: function() { + return this.jCal; + }, + + /** + * The string representation of this component. + * @return {String} + */ + toICALString: function() { + return ICAL.stringify.property( + this.jCal, this._designSet, true + ); + } + }; + + /** + * Create an {@link ICAL.Property} by parsing the passed iCalendar string. + * + * @param {String} str The iCalendar string to parse + * @param {ICAL.design.designSet=} designSet The design data to use for this property + * @return {ICAL.Property} The created iCalendar property + */ + Property.fromString = function(str, designSet) { + return new Property(ICAL.parse.property(str, designSet)); + }; + + return Property; +}()); +/* 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/. + * Portions Copyright (C) Philipp Kewisch, 2011-2015 */ + + +/** + * This symbol is further described later on + * @ignore + */ +ICAL.UtcOffset = (function() { + + /** + * @classdesc + * This class represents the "duration" value type, with various calculation + * and manipulation methods. + * + * @class + * @alias ICAL.UtcOffset + * @param {Object} aData An object with members of the utc offset + * @param {Number=} aData.hours The hours for the utc offset + * @param {Number=} aData.minutes The minutes in the utc offset + * @param {Number=} aData.factor The factor for the utc-offset, either -1 or 1 + */ + function UtcOffset(aData) { + this.fromData(aData); + } + + UtcOffset.prototype = { + + /** + * The hours in the utc-offset + * @type {Number} + */ + hours: 0, + + /** + * The minutes in the utc-offset + * @type {Number} + */ + minutes: 0, + + /** + * The sign of the utc offset, 1 for positive offset, -1 for negative + * offsets. + * @type {Number} + */ + factor: 1, + + /** + * The type name, to be used in the jCal object. + * @constant + * @type {String} + * @default "utc-offset" + */ + icaltype: "utc-offset", + + /** + * Returns a clone of the utc offset object. + * + * @return {ICAL.UtcOffset} The cloned object + */ + clone: function() { + return ICAL.UtcOffset.fromSeconds(this.toSeconds()); + }, + + /** + * Sets up the current instance using members from the passed data object. + * + * @param {Object} aData An object with members of the utc offset + * @param {Number=} aData.hours The hours for the utc offset + * @param {Number=} aData.minutes The minutes in the utc offset + * @param {Number=} aData.factor The factor for the utc-offset, either -1 or 1 + */ + fromData: function(aData) { + if (aData) { + for (var key in aData) { + /* istanbul ignore else */ + if (aData.hasOwnProperty(key)) { + this[key] = aData[key]; + } + } + } + this._normalize(); + }, + + /** + * Sets up the current instance from the given seconds value. The seconds + * value is truncated to the minute. Offsets are wrapped when the world + * ends, the hour after UTC+14:00 is UTC-12:00. + * + * @param {Number} aSeconds The seconds to convert into an offset + */ + fromSeconds: function(aSeconds) { + var secs = Math.abs(aSeconds); + + this.factor = aSeconds < 0 ? -1 : 1; + this.hours = ICAL.helpers.trunc(secs / 3600); + + secs -= (this.hours * 3600); + this.minutes = ICAL.helpers.trunc(secs / 60); + return this; + }, + + /** + * Convert the current offset to a value in seconds + * + * @return {Number} The offset in seconds + */ + toSeconds: function() { + return this.factor * (60 * this.minutes + 3600 * this.hours); + }, + + /** + * Compare this utc offset with another one. + * + * @param {ICAL.UtcOffset} other The other offset to compare with + * @return {Number} -1, 0 or 1 for less/equal/greater + */ + compare: function icaltime_compare(other) { + var a = this.toSeconds(); + var b = other.toSeconds(); + return (a > b) - (b > a); + }, + + _normalize: function() { + // Range: 97200 seconds (with 1 hour inbetween) + var secs = this.toSeconds(); + var factor = this.factor; + while (secs < -43200) { // = UTC-12:00 + secs += 97200; + } + while (secs > 50400) { // = UTC+14:00 + secs -= 97200; + } + + this.fromSeconds(secs); + + // Avoid changing the factor when on zero seconds + if (secs == 0) { + this.factor = factor; + } + }, + + /** + * The iCalendar string representation of this utc-offset. + * @return {String} + */ + toICALString: function() { + return ICAL.design.icalendar.value['utc-offset'].toICAL(this.toString()); + }, + + /** + * The string representation of this utc-offset. + * @return {String} + */ + toString: function toString() { + return (this.factor == 1 ? "+" : "-") + + ICAL.helpers.pad2(this.hours) + ':' + + ICAL.helpers.pad2(this.minutes); + } + }; + + /** + * Creates a new {@link ICAL.UtcOffset} instance from the passed string. + * + * @param {String} aString The string to parse + * @return {ICAL.Duration} The created utc-offset instance + */ + UtcOffset.fromString = function(aString) { + // -05:00 + var options = {}; + //TODO: support seconds per rfc5545 ? + options.factor = (aString[0] === '+') ? 1 : -1; + options.hours = ICAL.helpers.strictParseInt(aString.substr(1, 2)); + options.minutes = ICAL.helpers.strictParseInt(aString.substr(4, 2)); + + return new ICAL.UtcOffset(options); + }; + + /** + * Creates a new {@link ICAL.UtcOffset} instance from the passed seconds + * value. + * + * @param {Number} aSeconds The number of seconds to convert + */ + UtcOffset.fromSeconds = function(aSeconds) { + var instance = new UtcOffset(); + instance.fromSeconds(aSeconds); + return instance; + }; + + return UtcOffset; +}()); +/* 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/. + * Portions Copyright (C) Philipp Kewisch, 2011-2015 */ + + +/** + * This symbol is further described later on + * @ignore + */ +ICAL.Binary = (function() { + + /** + * @classdesc + * Represents the BINARY value type, which contains extra methods for + * encoding and decoding. + * + * @class + * @alias ICAL.Binary + * @param {String} aValue The binary data for this value + */ + function Binary(aValue) { + this.value = aValue; + } + + Binary.prototype = { + /** + * The type name, to be used in the jCal object. + * @default "binary" + * @constant + */ + icaltype: "binary", + + /** + * Base64 decode the current value + * + * @return {String} The base64-decoded value + */ + decodeValue: function decodeValue() { + return this._b64_decode(this.value); + }, + + /** + * Encodes the passed parameter with base64 and sets the internal + * value to the result. + * + * @param {String} aValue The raw binary value to encode + */ + setEncodedValue: function setEncodedValue(aValue) { + this.value = this._b64_encode(aValue); + }, + + _b64_encode: function base64_encode(data) { + // http://kevin.vanzonneveld.net + // + original by: Tyler Akins (http://rumkin.com) + // + improved by: Bayron Guevara + // + improved by: Thunder.m + // + improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net) + // + bugfixed by: Pellentesque Malesuada + // + improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net) + // + improved by: Rafał Kukawski (http://kukawski.pl) + // * example 1: base64_encode('Kevin van Zonneveld'); + // * returns 1: 'S2V2aW4gdmFuIFpvbm5ldmVsZA==' + // mozilla has this native + // - but breaks in 2.0.0.12! + //if (typeof this.window['atob'] == 'function') { + // return atob(data); + //} + var b64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + "abcdefghijklmnopqrstuvwxyz0123456789+/="; + var o1, o2, o3, h1, h2, h3, h4, bits, i = 0, + ac = 0, + enc = "", + tmp_arr = []; + + if (!data) { + return data; + } + + do { // pack three octets into four hexets + o1 = data.charCodeAt(i++); + o2 = data.charCodeAt(i++); + o3 = data.charCodeAt(i++); + + bits = o1 << 16 | o2 << 8 | o3; + + h1 = bits >> 18 & 0x3f; + h2 = bits >> 12 & 0x3f; + h3 = bits >> 6 & 0x3f; + h4 = bits & 0x3f; + + // use hexets to index into b64, and append result to encoded string + tmp_arr[ac++] = b64.charAt(h1) + b64.charAt(h2) + b64.charAt(h3) + b64.charAt(h4); + } while (i < data.length); + + enc = tmp_arr.join(''); + + var r = data.length % 3; + + return (r ? enc.slice(0, r - 3) : enc) + '==='.slice(r || 3); + + }, + + _b64_decode: function base64_decode(data) { + // http://kevin.vanzonneveld.net + // + original by: Tyler Akins (http://rumkin.com) + // + improved by: Thunder.m + // + input by: Aman Gupta + // + improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net) + // + bugfixed by: Onno Marsman + // + bugfixed by: Pellentesque Malesuada + // + improved by: Kevin van Zonneveld (http://kevin.vanzonneveld.net) + // + input by: Brett Zamir (http://brett-zamir.me) + // + bugfixed by: Kevin van Zonneveld (http://kevin.vanzonneveld.net) + // * example 1: base64_decode('S2V2aW4gdmFuIFpvbm5ldmVsZA=='); + // * returns 1: 'Kevin van Zonneveld' + // mozilla has this native + // - but breaks in 2.0.0.12! + //if (typeof this.window['btoa'] == 'function') { + // return btoa(data); + //} + var b64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + "abcdefghijklmnopqrstuvwxyz0123456789+/="; + var o1, o2, o3, h1, h2, h3, h4, bits, i = 0, + ac = 0, + dec = "", + tmp_arr = []; + + if (!data) { + return data; + } + + data += ''; + + do { // unpack four hexets into three octets using index points in b64 + h1 = b64.indexOf(data.charAt(i++)); + h2 = b64.indexOf(data.charAt(i++)); + h3 = b64.indexOf(data.charAt(i++)); + h4 = b64.indexOf(data.charAt(i++)); + + bits = h1 << 18 | h2 << 12 | h3 << 6 | h4; + + o1 = bits >> 16 & 0xff; + o2 = bits >> 8 & 0xff; + o3 = bits & 0xff; + + if (h3 == 64) { + tmp_arr[ac++] = String.fromCharCode(o1); + } else if (h4 == 64) { + tmp_arr[ac++] = String.fromCharCode(o1, o2); + } else { + tmp_arr[ac++] = String.fromCharCode(o1, o2, o3); + } + } while (i < data.length); + + dec = tmp_arr.join(''); + + return dec; + }, + + /** + * The string representation of this value + * @return {String} + */ + toString: function() { + return this.value; + } + }; + + /** + * Creates a binary value from the given string. + * + * @param {String} aString The binary value string + * @return {ICAL.Binary} The binary value instance + */ + Binary.fromString = function(aString) { + return new Binary(aString); + }; + + return Binary; +}()); +/* 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/. + * Portions Copyright (C) Philipp Kewisch, 2011-2015 */ + + + +(function() { + /** + * @classdesc + * This class represents the "period" value type, with various calculation + * and manipulation methods. + * + * @description + * The passed data object cannot contain both and end date and a duration. + * + * @class + * @param {Object} aData An object with members of the period + * @param {ICAL.Time=} aData.start The start of the period + * @param {ICAL.Time=} aData.end The end of the period + * @param {ICAL.Duration=} aData.duration The duration of the period + */ + ICAL.Period = function icalperiod(aData) { + this.wrappedJSObject = this; + + if (aData && 'start' in aData) { + if (aData.start && !(aData.start instanceof ICAL.Time)) { + throw new TypeError('.start must be an instance of ICAL.Time'); + } + this.start = aData.start; + } + + if (aData && aData.end && aData.duration) { + throw new Error('cannot accept both end and duration'); + } + + if (aData && 'end' in aData) { + if (aData.end && !(aData.end instanceof ICAL.Time)) { + throw new TypeError('.end must be an instance of ICAL.Time'); + } + this.end = aData.end; + } + + if (aData && 'duration' in aData) { + if (aData.duration && !(aData.duration instanceof ICAL.Duration)) { + throw new TypeError('.duration must be an instance of ICAL.Duration'); + } + this.duration = aData.duration; + } + }; + + ICAL.Period.prototype = { + + /** + * The start of the period + * @type {ICAL.Time} + */ + start: null, + + /** + * The end of the period + * @type {ICAL.Time} + */ + end: null, + + /** + * The duration of the period + * @type {ICAL.Duration} + */ + duration: null, + + /** + * The class identifier. + * @constant + * @type {String} + * @default "icalperiod" + */ + icalclass: "icalperiod", + + /** + * The type name, to be used in the jCal object. + * @constant + * @type {String} + * @default "period" + */ + icaltype: "period", + + /** + * Returns a clone of the duration object. + * + * @return {ICAL.Period} The cloned object + */ + clone: function() { + return ICAL.Period.fromData({ + start: this.start ? this.start.clone() : null, + end: this.end ? this.end.clone() : null, + duration: this.duration ? this.duration.clone() : null + }); + }, + + /** + * Calculates the duration of the period, either directly or by subtracting + * start from end date. + * + * @return {ICAL.Duration} The calculated duration + */ + getDuration: function duration() { + if (this.duration) { + return this.duration; + } else { + return this.end.subtractDate(this.start); + } + }, + + /** + * Calculates the end date of the period, either directly or by adding + * duration to start date. + * + * @return {ICAL.Time} The calculated end date + */ + getEnd: function() { + if (this.end) { + return this.end; + } else { + var end = this.start.clone(); + end.addDuration(this.duration); + return end; + } + }, + + /** + * The string representation of this period. + * @return {String} + */ + toString: function toString() { + return this.start + "/" + (this.end || this.duration); + }, + + /** + * The jCal representation of this period type. + * @return {Object} + */ + toJSON: function() { + return [this.start.toString(), (this.end || this.duration).toString()]; + }, + + /** + * The iCalendar string representation of this period. + * @return {String} + */ + toICALString: function() { + return this.start.toICALString() + "/" + + (this.end || this.duration).toICALString(); + } + }; + + /** + * Creates a new {@link ICAL.Period} instance from the passed string. + * + * @param {String} str The string to parse + * @param {ICAL.Property} prop The property this period will be on + * @return {ICAL.Period} The created period instance + */ + ICAL.Period.fromString = function fromString(str, prop) { + var parts = str.split('/'); + + if (parts.length !== 2) { + throw new Error( + 'Invalid string value: "' + str + '" must contain a "/" char.' + ); + } + + var options = { + start: ICAL.Time.fromDateTimeString(parts[0], prop) + }; + + var end = parts[1]; + + if (ICAL.Duration.isValueString(end)) { + options.duration = ICAL.Duration.fromString(end); + } else { + options.end = ICAL.Time.fromDateTimeString(end, prop); + } + + return new ICAL.Period(options); + }; + + /** + * Creates a new {@link ICAL.Period} instance from the given data object. + * The passed data object cannot contain both and end date and a duration. + * + * @param {Object} aData An object with members of the period + * @param {ICAL.Time=} aData.start The start of the period + * @param {ICAL.Time=} aData.end The end of the period + * @param {ICAL.Duration=} aData.duration The duration of the period + * @return {ICAL.Period} The period instance + */ + ICAL.Period.fromData = function fromData(aData) { + return new ICAL.Period(aData); + }; + + /** + * Returns a new period instance from the given jCal data array. The first + * member is always the start date string, the second member is either a + * duration or end date string. + * + * @param {Array<String,String>} aData The jCal data array + * @param {ICAL.Property} aProp The property this jCal data is on + * @return {ICAL.Period} The period instance + */ + ICAL.Period.fromJSON = function(aData, aProp) { + if (ICAL.Duration.isValueString(aData[1])) { + return ICAL.Period.fromData({ + start: ICAL.Time.fromDateTimeString(aData[0], aProp), + duration: ICAL.Duration.fromString(aData[1]) + }); + } else { + return ICAL.Period.fromData({ + start: ICAL.Time.fromDateTimeString(aData[0], aProp), + end: ICAL.Time.fromDateTimeString(aData[1], aProp) + }); + } + }; +})(); +/* 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/. + * Portions Copyright (C) Philipp Kewisch, 2011-2015 */ + + + +(function() { + var DURATION_LETTERS = /([PDWHMTS]{1,1})/; + + /** + * @classdesc + * This class represents the "duration" value type, with various calculation + * and manipulation methods. + * + * @class + * @alias ICAL.Duration + * @param {Object} data An object with members of the duration + * @param {Number} data.weeks Duration in weeks + * @param {Number} data.days Duration in days + * @param {Number} data.hours Duration in hours + * @param {Number} data.minutes Duration in minutes + * @param {Number} data.seconds Duration in seconds + * @param {Boolean} data.isNegative If true, the duration is negative + */ + ICAL.Duration = function icalduration(data) { + this.wrappedJSObject = this; + this.fromData(data); + }; + + ICAL.Duration.prototype = { + /** + * The weeks in this duration + * @type {Number} + * @default 0 + */ + weeks: 0, + + /** + * The days in this duration + * @type {Number} + * @default 0 + */ + days: 0, + + /** + * The days in this duration + * @type {Number} + * @default 0 + */ + hours: 0, + + /** + * The minutes in this duration + * @type {Number} + * @default 0 + */ + minutes: 0, + + /** + * The seconds in this duration + * @type {Number} + * @default 0 + */ + seconds: 0, + + /** + * The seconds in this duration + * @type {Boolean} + * @default false + */ + isNegative: false, + + /** + * The class identifier. + * @constant + * @type {String} + * @default "icalduration" + */ + icalclass: "icalduration", + + /** + * The type name, to be used in the jCal object. + * @constant + * @type {String} + * @default "duration" + */ + icaltype: "duration", + + /** + * Returns a clone of the duration object. + * + * @return {ICAL.Duration} The cloned object + */ + clone: function clone() { + return ICAL.Duration.fromData(this); + }, + + /** + * The duration value expressed as a number of seconds. + * + * @return {Number} The duration value in seconds + */ + toSeconds: function toSeconds() { + var seconds = this.seconds + 60 * this.minutes + 3600 * this.hours + + 86400 * this.days + 7 * 86400 * this.weeks; + return (this.isNegative ? -seconds : seconds); + }, + + /** + * Reads the passed seconds value into this duration object. Afterwards, + * members like {@link ICAL.Duration#days days} and {@link ICAL.Duration#weeks weeks} will be set up + * accordingly. + * + * @param {Number} aSeconds The duration value in seconds + * @return {ICAL.Duration} Returns this instance + */ + fromSeconds: function fromSeconds(aSeconds) { + var secs = Math.abs(aSeconds); + + this.isNegative = (aSeconds < 0); + this.days = ICAL.helpers.trunc(secs / 86400); + + // If we have a flat number of weeks, use them. + if (this.days % 7 == 0) { + this.weeks = this.days / 7; + this.days = 0; + } else { + this.weeks = 0; + } + + secs -= (this.days + 7 * this.weeks) * 86400; + + this.hours = ICAL.helpers.trunc(secs / 3600); + secs -= this.hours * 3600; + + this.minutes = ICAL.helpers.trunc(secs / 60); + secs -= this.minutes * 60; + + this.seconds = secs; + return this; + }, + + /** + * Sets up the current instance using members from the passed data object. + * + * @param {Object} aData An object with members of the duration + * @param {Number} aData.weeks Duration in weeks + * @param {Number} aData.days Duration in days + * @param {Number} aData.hours Duration in hours + * @param {Number} aData.minutes Duration in minutes + * @param {Number} aData.seconds Duration in seconds + * @param {Boolean} aData.isNegative If true, the duration is negative + */ + fromData: function fromData(aData) { + var propsToCopy = ["weeks", "days", "hours", + "minutes", "seconds", "isNegative"]; + for (var key in propsToCopy) { + /* istanbul ignore if */ + if (!propsToCopy.hasOwnProperty(key)) { + continue; + } + var prop = propsToCopy[key]; + if (aData && prop in aData) { + this[prop] = aData[prop]; + } else { + this[prop] = 0; + } + } + }, + + /** + * Resets the duration instance to the default values, i.e. PT0S + */ + reset: function reset() { + this.isNegative = false; + this.weeks = 0; + this.days = 0; + this.hours = 0; + this.minutes = 0; + this.seconds = 0; + }, + + /** + * Compares the duration instance with another one. + * + * @param {ICAL.Duration} aOther The instance to compare with + * @return {Number} -1, 0 or 1 for less/equal/greater + */ + compare: function compare(aOther) { + var thisSeconds = this.toSeconds(); + var otherSeconds = aOther.toSeconds(); + return (thisSeconds > otherSeconds) - (thisSeconds < otherSeconds); + }, + + /** + * Normalizes the duration instance. For example, a duration with a value + * of 61 seconds will be normalized to 1 minute and 1 second. + */ + normalize: function normalize() { + this.fromSeconds(this.toSeconds()); + }, + + /** + * The string representation of this duration. + * @return {String} + */ + toString: function toString() { + if (this.toSeconds() == 0) { + return "PT0S"; + } else { + var str = ""; + if (this.isNegative) str += "-"; + str += "P"; + if (this.weeks) str += this.weeks + "W"; + if (this.days) str += this.days + "D"; + + if (this.hours || this.minutes || this.seconds) { + str += "T"; + if (this.hours) str += this.hours + "H"; + if (this.minutes) str += this.minutes + "M"; + if (this.seconds) str += this.seconds + "S"; + } + return str; + } + }, + + /** + * The iCalendar string representation of this duration. + * @return {String} + */ + toICALString: function() { + return this.toString(); + } + }; + + /** + * Returns a new ICAL.Duration instance from the passed seconds value. + * + * @param {Number} aSeconds The seconds to create the instance from + * @return {ICAL.Duration} The newly created duration instance + */ + ICAL.Duration.fromSeconds = function icalduration_from_seconds(aSeconds) { + return (new ICAL.Duration()).fromSeconds(aSeconds); + }; + + /** + * Internal helper function to handle a chunk of a duration. + * + * @param {String} letter type of duration chunk + * @param {String} number numeric value or -/+ + * @param {Object} dict target to assign values to + */ + function parseDurationChunk(letter, number, object) { + var type; + switch (letter) { + case 'P': + if (number && number === '-') { + object.isNegative = true; + } else { + object.isNegative = false; + } + // period + break; + case 'D': + type = 'days'; + break; + case 'W': + type = 'weeks'; + break; + case 'H': + type = 'hours'; + break; + case 'M': + type = 'minutes'; + break; + case 'S': + type = 'seconds'; + break; + default: + // Not a valid chunk + return 0; + } + + if (type) { + if (!number && number !== 0) { + throw new Error( + 'invalid duration value: Missing number before "' + letter + '"' + ); + } + var num = parseInt(number, 10); + if (ICAL.helpers.isStrictlyNaN(num)) { + throw new Error( + 'invalid duration value: Invalid number "' + number + '" before "' + letter + '"' + ); + } + object[type] = num; + } + + return 1; + } + + /** + * Checks if the given string is an iCalendar duration value. + * + * @param {String} value The raw ical value + * @return {Boolean} True, if the given value is of the + * duration ical type + */ + ICAL.Duration.isValueString = function(string) { + return (string[0] === 'P' || string[1] === 'P'); + }; + + /** + * Creates a new {@link ICAL.Duration} instance from the passed string. + * + * @param {String} aStr The string to parse + * @return {ICAL.Duration} The created duration instance + */ + ICAL.Duration.fromString = function icalduration_from_string(aStr) { + var pos = 0; + var dict = Object.create(null); + var chunks = 0; + + while ((pos = aStr.search(DURATION_LETTERS)) !== -1) { + var type = aStr[pos]; + var numeric = aStr.substr(0, pos); + aStr = aStr.substr(pos + 1); + + chunks += parseDurationChunk(type, numeric, dict); + } + + if (chunks < 2) { + // There must be at least a chunk with "P" and some unit chunk + throw new Error( + 'invalid duration value: Not enough duration components in "' + aStr + '"' + ); + } + + return new ICAL.Duration(dict); + }; + + /** + * Creates a new ICAL.Duration instance from the given data object. + * + * @param {Object} aData An object with members of the duration + * @param {Number} aData.weeks Duration in weeks + * @param {Number} aData.days Duration in days + * @param {Number} aData.hours Duration in hours + * @param {Number} aData.minutes Duration in minutes + * @param {Number} aData.seconds Duration in seconds + * @param {Boolean} aData.isNegative If true, the duration is negative + * @return {ICAL.Duration} The createad duration instance + */ + ICAL.Duration.fromData = function icalduration_from_data(aData) { + return new ICAL.Duration(aData); + }; +})(); +/* 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/. + * Portions Copyright (C) Philipp Kewisch, 2011-2012 */ + + + +(function() { + var OPTIONS = ["tzid", "location", "tznames", + "latitude", "longitude"]; + + /** + * @classdesc + * Timezone representation, created by passing in a tzid and component. + * + * @example + * var vcalendar; + * var timezoneComp = vcalendar.getFirstSubcomponent('vtimezone'); + * var tzid = timezoneComp.getFirstPropertyValue('tzid'); + * + * var timezone = new ICAL.Timezone({ + * component: timezoneComp, + * tzid + * }); + * + * @class + * @param {ICAL.Component|Object} data options for class + * @param {String|ICAL.Component} data.component + * If data is a simple object, then this member can be set to either a + * string containing the component data, or an already parsed + * ICAL.Component + * @param {String} data.tzid The timezone identifier + * @param {String} data.location The timezone locationw + * @param {String} data.tznames An alternative string representation of the + * timezone + * @param {Number} data.latitude The latitude of the timezone + * @param {Number} data.longitude The longitude of the timezone + */ + ICAL.Timezone = function icaltimezone(data) { + this.wrappedJSObject = this; + this.fromData(data); + }; + + ICAL.Timezone.prototype = { + + /** + * Timezone identifier + * @type {String} + */ + tzid: "", + + /** + * Timezone location + * @type {String} + */ + location: "", + + /** + * Alternative timezone name, for the string representation + * @type {String} + */ + tznames: "", + + /** + * The primary latitude for the timezone. + * @type {Number} + */ + latitude: 0.0, + + /** + * The primary longitude for the timezone. + * @type {Number} + */ + longitude: 0.0, + + /** + * The vtimezone component for this timezone. + * @type {ICAL.Component} + */ + component: null, + + /** + * The year this timezone has been expanded to. All timezone transition + * dates until this year are known and can be used for calculation + * + * @private + * @type {Number} + */ + expandedUntilYear: 0, + + /** + * The class identifier. + * @constant + * @type {String} + * @default "icaltimezone" + */ + icalclass: "icaltimezone", + + /** + * Sets up the current instance using members from the passed data object. + * + * @param {ICAL.Component|Object} aData options for class + * @param {String|ICAL.Component} aData.component + * If aData is a simple object, then this member can be set to either a + * string containing the component data, or an already parsed + * ICAL.Component + * @param {String} aData.tzid The timezone identifier + * @param {String} aData.location The timezone locationw + * @param {String} aData.tznames An alternative string representation of the + * timezone + * @param {Number} aData.latitude The latitude of the timezone + * @param {Number} aData.longitude The longitude of the timezone + */ + fromData: function fromData(aData) { + this.expandedUntilYear = 0; + this.changes = []; + + if (aData instanceof ICAL.Component) { + // Either a component is passed directly + this.component = aData; + } else { + // Otherwise the component may be in the data object + if (aData && "component" in aData) { + if (typeof aData.component == "string") { + // If a string was passed, parse it as a component + var jCal = ICAL.parse(aData.component); + this.component = new ICAL.Component(jCal); + } else if (aData.component instanceof ICAL.Component) { + // If it was a component already, then just set it + this.component = aData.component; + } else { + // Otherwise just null out the component + this.component = null; + } + } + + // Copy remaining passed properties + for (var key in OPTIONS) { + /* istanbul ignore else */ + if (OPTIONS.hasOwnProperty(key)) { + var prop = OPTIONS[key]; + if (aData && prop in aData) { + this[prop] = aData[prop]; + } + } + } + } + + // If we have a component but no TZID, attempt to get it from the + // component's properties. + if (this.component instanceof ICAL.Component && !this.tzid) { + this.tzid = this.component.getFirstPropertyValue('tzid'); + } + + return this; + }, + + /** + * Finds the utcOffset the given time would occur in this timezone. + * + * @param {ICAL.Time} tt The time to check for + * @return {Number} utc offset in seconds + */ + utcOffset: function utcOffset(tt) { + if (this == ICAL.Timezone.utcTimezone || this == ICAL.Timezone.localTimezone) { + return 0; + } + + this._ensureCoverage(tt.year); + + if (!this.changes.length) { + return 0; + } + + var tt_change = { + year: tt.year, + month: tt.month, + day: tt.day, + hour: tt.hour, + minute: tt.minute, + second: tt.second + }; + + var change_num = this._findNearbyChange(tt_change); + var change_num_to_use = -1; + var step = 1; + + // TODO: replace with bin search? + for (;;) { + var change = ICAL.helpers.clone(this.changes[change_num], true); + if (change.utcOffset < change.prevUtcOffset) { + ICAL.Timezone.adjust_change(change, 0, 0, 0, change.utcOffset); + } else { + ICAL.Timezone.adjust_change(change, 0, 0, 0, + change.prevUtcOffset); + } + + var cmp = ICAL.Timezone._compare_change_fn(tt_change, change); + + if (cmp >= 0) { + change_num_to_use = change_num; + } else { + step = -1; + } + + if (step == -1 && change_num_to_use != -1) { + break; + } + + change_num += step; + + if (change_num < 0) { + return 0; + } + + if (change_num >= this.changes.length) { + break; + } + } + + var zone_change = this.changes[change_num_to_use]; + var utcOffset_change = zone_change.utcOffset - zone_change.prevUtcOffset; + + if (utcOffset_change < 0 && change_num_to_use > 0) { + var tmp_change = ICAL.helpers.clone(zone_change, true); + ICAL.Timezone.adjust_change(tmp_change, 0, 0, 0, + tmp_change.prevUtcOffset); + + if (ICAL.Timezone._compare_change_fn(tt_change, tmp_change) < 0) { + var prev_zone_change = this.changes[change_num_to_use - 1]; + + var want_daylight = false; // TODO + + if (zone_change.is_daylight != want_daylight && + prev_zone_change.is_daylight == want_daylight) { + zone_change = prev_zone_change; + } + } + } + + // TODO return is_daylight? + return zone_change.utcOffset; + }, + + _findNearbyChange: function icaltimezone_find_nearby_change(change) { + // find the closest match + var idx = ICAL.helpers.binsearchInsert( + this.changes, + change, + ICAL.Timezone._compare_change_fn + ); + + if (idx >= this.changes.length) { + return this.changes.length - 1; + } + + return idx; + }, + + _ensureCoverage: function(aYear) { + if (ICAL.Timezone._minimumExpansionYear == -1) { + var today = ICAL.Time.now(); + ICAL.Timezone._minimumExpansionYear = today.year; + } + + var changesEndYear = aYear; + if (changesEndYear < ICAL.Timezone._minimumExpansionYear) { + changesEndYear = ICAL.Timezone._minimumExpansionYear; + } + + changesEndYear += ICAL.Timezone.EXTRA_COVERAGE; + + if (changesEndYear > ICAL.Timezone.MAX_YEAR) { + changesEndYear = ICAL.Timezone.MAX_YEAR; + } + + if (!this.changes.length || this.expandedUntilYear < aYear) { + var subcomps = this.component.getAllSubcomponents(); + var compLen = subcomps.length; + var compIdx = 0; + + for (; compIdx < compLen; compIdx++) { + this._expandComponent( + subcomps[compIdx], changesEndYear, this.changes + ); + } + + this.changes.sort(ICAL.Timezone._compare_change_fn); + this.expandedUntilYear = changesEndYear; + } + }, + + _expandComponent: function(aComponent, aYear, changes) { + if (!aComponent.hasProperty("dtstart") || + !aComponent.hasProperty("tzoffsetto") || + !aComponent.hasProperty("tzoffsetfrom")) { + return null; + } + + var dtstart = aComponent.getFirstProperty("dtstart").getFirstValue(); + var change; + + function convert_tzoffset(offset) { + return offset.factor * (offset.hours * 3600 + offset.minutes * 60); + } + + function init_changes() { + var changebase = {}; + changebase.is_daylight = (aComponent.name == "daylight"); + changebase.utcOffset = convert_tzoffset( + aComponent.getFirstProperty("tzoffsetto").getFirstValue() + ); + + changebase.prevUtcOffset = convert_tzoffset( + aComponent.getFirstProperty("tzoffsetfrom").getFirstValue() + ); + + return changebase; + } + + if (!aComponent.hasProperty("rrule") && !aComponent.hasProperty("rdate")) { + change = init_changes(); + change.year = dtstart.year; + change.month = dtstart.month; + change.day = dtstart.day; + change.hour = dtstart.hour; + change.minute = dtstart.minute; + change.second = dtstart.second; + + ICAL.Timezone.adjust_change(change, 0, 0, 0, + -change.prevUtcOffset); + changes.push(change); + } else { + var props = aComponent.getAllProperties("rdate"); + for (var rdatekey in props) { + /* istanbul ignore if */ + if (!props.hasOwnProperty(rdatekey)) { + continue; + } + var rdate = props[rdatekey]; + var time = rdate.getFirstValue(); + change = init_changes(); + + change.year = time.year; + change.month = time.month; + change.day = time.day; + + if (time.isDate) { + change.hour = dtstart.hour; + change.minute = dtstart.minute; + change.second = dtstart.second; + + if (dtstart.zone != ICAL.Timezone.utcTimezone) { + ICAL.Timezone.adjust_change(change, 0, 0, 0, + -change.prevUtcOffset); + } + } else { + change.hour = time.hour; + change.minute = time.minute; + change.second = time.second; + + if (time.zone != ICAL.Timezone.utcTimezone) { + ICAL.Timezone.adjust_change(change, 0, 0, 0, + -change.prevUtcOffset); + } + } + + changes.push(change); + } + + var rrule = aComponent.getFirstProperty("rrule"); + + if (rrule) { + rrule = rrule.getFirstValue(); + change = init_changes(); + + if (rrule.until && rrule.until.zone == ICAL.Timezone.utcTimezone) { + rrule.until.adjust(0, 0, 0, change.prevUtcOffset); + rrule.until.zone = ICAL.Timezone.localTimezone; + } + + var iterator = rrule.iterator(dtstart); + + var occ; + while ((occ = iterator.next())) { + change = init_changes(); + if (occ.year > aYear || !occ) { + break; + } + + change.year = occ.year; + change.month = occ.month; + change.day = occ.day; + change.hour = occ.hour; + change.minute = occ.minute; + change.second = occ.second; + change.isDate = occ.isDate; + + ICAL.Timezone.adjust_change(change, 0, 0, 0, + -change.prevUtcOffset); + changes.push(change); + } + } + } + + return changes; + }, + + /** + * The string representation of this timezone. + * @return {String} + */ + toString: function toString() { + return (this.tznames ? this.tznames : this.tzid); + } + }; + + ICAL.Timezone._compare_change_fn = function icaltimezone_compare_change_fn(a, b) { + if (a.year < b.year) return -1; + else if (a.year > b.year) return 1; + + if (a.month < b.month) return -1; + else if (a.month > b.month) return 1; + + if (a.day < b.day) return -1; + else if (a.day > b.day) return 1; + + if (a.hour < b.hour) return -1; + else if (a.hour > b.hour) return 1; + + if (a.minute < b.minute) return -1; + else if (a.minute > b.minute) return 1; + + if (a.second < b.second) return -1; + else if (a.second > b.second) return 1; + + return 0; + }; + + /** + * Convert the date/time from one zone to the next. + * + * @param {ICAL.Time} tt The time to convert + * @param {ICAL.Timezone} from_zone The source zone to convert from + * @param {ICAL.Timezone} to_zone The target zone to conver to + * @return {ICAL.Time} The converted date/time object + */ + ICAL.Timezone.convert_time = function icaltimezone_convert_time(tt, from_zone, to_zone) { + if (tt.isDate || + from_zone.tzid == to_zone.tzid || + from_zone == ICAL.Timezone.localTimezone || + to_zone == ICAL.Timezone.localTimezone) { + tt.zone = to_zone; + return tt; + } + + var utcOffset = from_zone.utcOffset(tt); + tt.adjust(0, 0, 0, - utcOffset); + + utcOffset = to_zone.utcOffset(tt); + tt.adjust(0, 0, 0, utcOffset); + + return null; + }; + + /** + * Creates a new ICAL.Timezone instance from the passed data object. + * + * @param {ICAL.Component|Object} aData options for class + * @param {String|ICAL.Component} aData.component + * If aData is a simple object, then this member can be set to either a + * string containing the component data, or an already parsed + * ICAL.Component + * @param {String} aData.tzid The timezone identifier + * @param {String} aData.location The timezone locationw + * @param {String} aData.tznames An alternative string representation of the + * timezone + * @param {Number} aData.latitude The latitude of the timezone + * @param {Number} aData.longitude The longitude of the timezone + */ + ICAL.Timezone.fromData = function icaltimezone_fromData(aData) { + var tt = new ICAL.Timezone(); + return tt.fromData(aData); + }; + + /** + * The instance describing the UTC timezone + * @type {ICAL.Timezone} + * @constant + * @instance + */ + ICAL.Timezone.utcTimezone = ICAL.Timezone.fromData({ + tzid: "UTC" + }); + + /** + * The instance describing the local timezone + * @type {ICAL.Timezone} + * @constant + * @instance + */ + ICAL.Timezone.localTimezone = ICAL.Timezone.fromData({ + tzid: "floating" + }); + + /** + * Adjust a timezone change object. + * @private + * @param {Object} change The timezone change object + * @param {Number} days The extra amount of days + * @param {Number} hours The extra amount of hours + * @param {Number} minutes The extra amount of minutes + * @param {Number} seconds The extra amount of seconds + */ + ICAL.Timezone.adjust_change = function icaltimezone_adjust_change(change, days, hours, minutes, seconds) { + return ICAL.Time.prototype.adjust.call( + change, + days, + hours, + minutes, + seconds, + change + ); + }; + + ICAL.Timezone._minimumExpansionYear = -1; + ICAL.Timezone.MAX_YEAR = 2035; // TODO this is because of time_t, which we don't need. Still usefull? + ICAL.Timezone.EXTRA_COVERAGE = 5; +})(); +/* 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/. + * Portions Copyright (C) Philipp Kewisch, 2011-2015 */ + + +/** + * This symbol is further described later on + * @ignore + */ +ICAL.TimezoneService = (function() { + var zones; + + /** + * @classdesc + * Singleton class to contain timezones. Right now its all manual registry in + * the future we may use this class to download timezone information or handle + * loading pre-expanded timezones. + * + * @namespace + * @alias ICAL.TimezoneService + */ + var TimezoneService = { + reset: function() { + zones = Object.create(null); + var utc = ICAL.Timezone.utcTimezone; + + zones.Z = utc; + zones.UTC = utc; + zones.GMT = utc; + }, + + /** + * Checks if timezone id has been registered. + * + * @param {String} tzid Timezone identifier (e.g. America/Los_Angeles) + * @return {Boolean} False, when not present + */ + has: function(tzid) { + return !!zones[tzid]; + }, + + /** + * Returns a timezone by its tzid if present. + * + * @param {String} tzid Timezone identifier (e.g. America/Los_Angeles) + * @return {?ICAL.Timezone} The timezone, or null if not found + */ + get: function(tzid) { + return zones[tzid]; + }, + + /** + * Registers a timezone object or component. + * + * @param {String=} name + * The name of the timezone. Defaults to the component's TZID if not + * passed. + * @param {ICAL.Component|ICAL.Timezone} zone + * The initialized zone or vtimezone. + */ + register: function(name, timezone) { + if (name instanceof ICAL.Component) { + if (name.name === 'vtimezone') { + timezone = new ICAL.Timezone(name); + name = timezone.tzid; + } + } + + if (timezone instanceof ICAL.Timezone) { + zones[name] = timezone; + } else { + throw new TypeError('timezone must be ICAL.Timezone or ICAL.Component'); + } + }, + + /** + * Removes a timezone by its tzid from the list. + * + * @param {String} tzid Timezone identifier (e.g. America/Los_Angeles) + * @return {?ICAL.Timezone} The removed timezone, or null if not registered + */ + remove: function(tzid) { + return (delete zones[tzid]); + } + }; + + // initialize defaults + TimezoneService.reset(); + + return TimezoneService; +}()); +/* 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/. + * Portions Copyright (C) Philipp Kewisch, 2011-2015 */ + + + +(function() { + + /** + * @classdesc + * iCalendar Time representation (similar to JS Date object). Fully + * independent of system (OS) timezone / time. Unlike JS Date, the month + * January is 1, not zero. + * + * @example + * var time = new ICAL.Time({ + * year: 2012, + * month: 10, + * day: 11 + * minute: 0, + * second: 0, + * isDate: false + * }); + * + * + * @alias ICAL.Time + * @class + * @param {Object} data Time initialization + * @param {Number=} data.year The year for this date + * @param {Number=} data.month The month for this date + * @param {Number=} data.day The day for this date + * @param {Number=} data.hour The hour for this date + * @param {Number=} data.minute The minute for this date + * @param {Number=} data.second The second for this date + * @param {Boolean=} data.isDate If true, the instance represents a date (as + * opposed to a date-time) + * @param {ICAL.Timezone} zone timezone this position occurs in + */ + ICAL.Time = function icaltime(data, zone) { + this.wrappedJSObject = this; + var time = this._time = Object.create(null); + + /* time defaults */ + time.year = 0; + time.month = 1; + time.day = 1; + time.hour = 0; + time.minute = 0; + time.second = 0; + time.isDate = false; + + this.fromData(data, zone); + }; + + ICAL.Time._dowCache = {}; + ICAL.Time._wnCache = {}; + + ICAL.Time.prototype = { + + /** + * The class identifier. + * @constant + * @type {String} + * @default "icaltime" + */ + icalclass: "icaltime", + _cachedUnixTime: null, + + /** + * The type name, to be used in the jCal object. This value may change and + * is strictly defined by the {@link ICAL.Time#isDate isDate} member. + * @readonly + * @type {String} + * @default "date-time" + */ + get icaltype() { + return this.isDate ? 'date' : 'date-time'; + }, + + /** + * The timezone for this time. + * @type {ICAL.Timezone} + */ + zone: null, + + /** + * Internal uses to indicate that a change has been made and the next read + * operation must attempt to normalize the value (for example changing the + * day to 33). + * + * @type {Boolean} + * @private + */ + _pendingNormalization: false, + + /** + * Returns a clone of the time object. + * + * @return {ICAL.Time} The cloned object + */ + clone: function() { + return new ICAL.Time(this._time, this.zone); + }, + + /** + * Reset the time instance to epoch time + */ + reset: function icaltime_reset() { + this.fromData(ICAL.Time.epochTime); + this.zone = ICAL.Timezone.utcTimezone; + }, + + /** + * Reset the time instance to the given date/time values. + * + * @param {Number} year The year to set + * @param {Number} month The month to set + * @param {Number} day The day to set + * @param {Number} hour The hour to set + * @param {Number} minute The minute to set + * @param {Number} second The second to set + * @param {ICAL.Timezone} timezone The timezone to set + */ + resetTo: function icaltime_resetTo(year, month, day, + hour, minute, second, timezone) { + this.fromData({ + year: year, + month: month, + day: day, + hour: hour, + minute: minute, + second: second, + zone: timezone + }); + }, + + /** + * Set up the current instance from the Javascript date value. + * + * @param {?Date} aDate The Javascript Date to read, or null to reset + * @param {Boolean} useUTC If true, the UTC values of the date will be used + */ + fromJSDate: function icaltime_fromJSDate(aDate, useUTC) { + if (!aDate) { + this.reset(); + } else { + if (useUTC) { + this.zone = ICAL.Timezone.utcTimezone; + this.year = aDate.getUTCFullYear(); + this.month = aDate.getUTCMonth() + 1; + this.day = aDate.getUTCDate(); + this.hour = aDate.getUTCHours(); + this.minute = aDate.getUTCMinutes(); + this.second = aDate.getUTCSeconds(); + } else { + this.zone = ICAL.Timezone.localTimezone; + this.year = aDate.getFullYear(); + this.month = aDate.getMonth() + 1; + this.day = aDate.getDate(); + this.hour = aDate.getHours(); + this.minute = aDate.getMinutes(); + this.second = aDate.getSeconds(); + } + } + this._cachedUnixTime = null; + return this; + }, + + /** + * Sets up the current instance using members from the passed data object. + * + * @param {Object} aData Time initialization + * @param {Number=} aData.year The year for this date + * @param {Number=} aData.month The month for this date + * @param {Number=} aData.day The day for this date + * @param {Number=} aData.hour The hour for this date + * @param {Number=} aData.minute The minute for this date + * @param {Number=} aData.second The second for this date + * @param {Boolean=} aData.isDate If true, the instance represents a date + * (as opposed to a date-time) + * @param {ICAL.Timezone=} aZone Timezone this position occurs in + */ + fromData: function fromData(aData, aZone) { + if (aData) { + for (var key in aData) { + /* istanbul ignore else */ + if (Object.prototype.hasOwnProperty.call(aData, key)) { + // ical type cannot be set + if (key === 'icaltype') continue; + this[key] = aData[key]; + } + } + } + + if (aZone) { + this.zone = aZone; + } + + if (aData && !("isDate" in aData)) { + this.isDate = !("hour" in aData); + } else if (aData && ("isDate" in aData)) { + this.isDate = aData.isDate; + } + + if (aData && "timezone" in aData) { + var zone = ICAL.TimezoneService.get( + aData.timezone + ); + + this.zone = zone || ICAL.Timezone.localTimezone; + } + + if (aData && "zone" in aData) { + this.zone = aData.zone; + } + + if (!this.zone) { + this.zone = ICAL.Timezone.localTimezone; + } + + this._cachedUnixTime = null; + return this; + }, + + /** + * Calculate the day of week. + * @return {ICAL.Time.weekDay} + */ + dayOfWeek: function icaltime_dayOfWeek() { + var dowCacheKey = (this.year << 9) + (this.month << 5) + this.day; + if (dowCacheKey in ICAL.Time._dowCache) { + return ICAL.Time._dowCache[dowCacheKey]; + } + + // Using Zeller's algorithm + var q = this.day; + var m = this.month + (this.month < 3 ? 12 : 0); + var Y = this.year - (this.month < 3 ? 1 : 0); + + var h = (q + Y + ICAL.helpers.trunc(((m + 1) * 26) / 10) + ICAL.helpers.trunc(Y / 4)); + /* istanbul ignore else */ + if (true /* gregorian */) { + h += ICAL.helpers.trunc(Y / 100) * 6 + ICAL.helpers.trunc(Y / 400); + } else { + h += 5; + } + + // Normalize to 1 = sunday + h = ((h + 6) % 7) + 1; + ICAL.Time._dowCache[dowCacheKey] = h; + return h; + }, + + /** + * Calculate the day of year. + * @return {Number} + */ + dayOfYear: function dayOfYear() { + var is_leap = (ICAL.Time.isLeapYear(this.year) ? 1 : 0); + var diypm = ICAL.Time.daysInYearPassedMonth; + return diypm[is_leap][this.month - 1] + this.day; + }, + + /** + * Returns a copy of the current date/time, rewound to the start of the + * week. The resulting ICAL.Time instance is of icaltype date, even if this + * is a date-time. + * + * @param {ICAL.Time.weekDay=} aWeekStart + * The week start weekday, defaults to SUNDAY + * @return {ICAL.Time} The start of the week (cloned) + */ + startOfWeek: function startOfWeek(aWeekStart) { + var firstDow = aWeekStart || ICAL.Time.SUNDAY; + var result = this.clone(); + result.day -= ((this.dayOfWeek() + 7 - firstDow) % 7); + result.isDate = true; + result.hour = 0; + result.minute = 0; + result.second = 0; + return result; + }, + + /** + * Returns a copy of the current date/time, shifted to the end of the week. + * The resulting ICAL.Time instance is of icaltype date, even if this is a + * date-time. + * + * @param {ICAL.Time.weekDay=} aWeekStart + * The week start weekday, defaults to SUNDAY + * @return {ICAL.Time} The end of the week (cloned) + */ + endOfWeek: function endOfWeek(aWeekStart) { + var firstDow = aWeekStart || ICAL.Time.SUNDAY; + var result = this.clone(); + result.day += (7 - this.dayOfWeek() + firstDow - ICAL.Time.SUNDAY) % 7; + result.isDate = true; + result.hour = 0; + result.minute = 0; + result.second = 0; + return result; + }, + + /** + * Returns a copy of the current date/time, rewound to the start of the + * month. The resulting ICAL.Time instance is of icaltype date, even if + * this is a date-time. + * + * @return {ICAL.Time} The start of the month (cloned) + */ + startOfMonth: function startOfMonth() { + var result = this.clone(); + result.day = 1; + result.isDate = true; + result.hour = 0; + result.minute = 0; + result.second = 0; + return result; + }, + + /** + * Returns a copy of the current date/time, shifted to the end of the + * month. The resulting ICAL.Time instance is of icaltype date, even if + * this is a date-time. + * + * @return {ICAL.Time} The end of the month (cloned) + */ + endOfMonth: function endOfMonth() { + var result = this.clone(); + result.day = ICAL.Time.daysInMonth(result.month, result.year); + result.isDate = true; + result.hour = 0; + result.minute = 0; + result.second = 0; + return result; + }, + + /** + * Returns a copy of the current date/time, rewound to the start of the + * year. The resulting ICAL.Time instance is of icaltype date, even if + * this is a date-time. + * + * @return {ICAL.Time} The start of the year (cloned) + */ + startOfYear: function startOfYear() { + var result = this.clone(); + result.day = 1; + result.month = 1; + result.isDate = true; + result.hour = 0; + result.minute = 0; + result.second = 0; + return result; + }, + + /** + * Returns a copy of the current date/time, shifted to the end of the + * year. The resulting ICAL.Time instance is of icaltype date, even if + * this is a date-time. + * + * @return {ICAL.Time} The end of the year (cloned) + */ + endOfYear: function endOfYear() { + var result = this.clone(); + result.day = 31; + result.month = 12; + result.isDate = true; + result.hour = 0; + result.minute = 0; + result.second = 0; + return result; + }, + + /** + * First calculates the start of the week, then returns the day of year for + * this date. If the day falls into the previous year, the day is zero or negative. + * + * @param {ICAL.Time.weekDay=} aFirstDayOfWeek + * The week start weekday, defaults to SUNDAY + * @return {Number} The calculated day of year + */ + startDoyWeek: function startDoyWeek(aFirstDayOfWeek) { + var firstDow = aFirstDayOfWeek || ICAL.Time.SUNDAY; + var delta = this.dayOfWeek() - firstDow; + if (delta < 0) delta += 7; + return this.dayOfYear() - delta; + }, + + /** + * Get the dominical letter for the current year. Letters range from A - G + * for common years, and AG to GF for leap years. + * + * @param {Number} yr The year to retrieve the letter for + * @return {String} The dominical letter. + */ + getDominicalLetter: function() { + return ICAL.Time.getDominicalLetter(this.year); + }, + + /** + * Finds the nthWeekDay relative to the current month (not day). The + * returned value is a day relative the month that this month belongs to so + * 1 would indicate the first of the month and 40 would indicate a day in + * the following month. + * + * @param {Number} aDayOfWeek Day of the week see the day name constants + * @param {Number} aPos Nth occurrence of a given week day values + * of 1 and 0 both indicate the first weekday of that type. aPos may + * be either positive or negative + * + * @return {Number} numeric value indicating a day relative + * to the current month of this time object + */ + nthWeekDay: function icaltime_nthWeekDay(aDayOfWeek, aPos) { + var daysInMonth = ICAL.Time.daysInMonth(this.month, this.year); + var weekday; + var pos = aPos; + + var start = 0; + + var otherDay = this.clone(); + + if (pos >= 0) { + otherDay.day = 1; + + // because 0 means no position has been given + // 1 and 0 indicate the same day. + if (pos != 0) { + // remove the extra numeric value + pos--; + } + + // set current start offset to current day. + start = otherDay.day; + + // find the current day of week + var startDow = otherDay.dayOfWeek(); + + // calculate the difference between current + // day of the week and desired day of the week + var offset = aDayOfWeek - startDow; + + + // if the offset goes into the past + // week we add 7 so its goes into the next + // week. We only want to go forward in time here. + if (offset < 0) + // this is really important otherwise we would + // end up with dates from in the past. + offset += 7; + + // add offset to start so start is the same + // day of the week as the desired day of week. + start += offset; + + // because we are going to add (and multiply) + // the numeric value of the day we subtract it + // from the start position so not to add it twice. + start -= aDayOfWeek; + + // set week day + weekday = aDayOfWeek; + } else { + + // then we set it to the last day in the current month + otherDay.day = daysInMonth; + + // find the ends weekday + var endDow = otherDay.dayOfWeek(); + + pos++; + + weekday = (endDow - aDayOfWeek); + + if (weekday < 0) { + weekday += 7; + } + + weekday = daysInMonth - weekday; + } + + weekday += pos * 7; + + return start + weekday; + }, + + /** + * Checks if current time is the nth weekday, relative to the current + * month. Will always return false when rule resolves outside of current + * month. + * + * @param {ICAL.Time.weekDay} aDayOfWeek Day of week to check + * @param {Number} aPos Relative position + * @return {Boolean} True, if its the nth weekday + */ + isNthWeekDay: function(aDayOfWeek, aPos) { + var dow = this.dayOfWeek(); + + if (aPos === 0 && dow === aDayOfWeek) { + return true; + } + + // get pos + var day = this.nthWeekDay(aDayOfWeek, aPos); + + if (day === this.day) { + return true; + } + + return false; + }, + + /** + * Calculates the ISO 8601 week number. The first week of a year is the + * week that contains the first Thursday. The year can have 53 weeks, if + * January 1st is a Friday. + * + * Note there are regions where the first week of the year is the one that + * starts on January 1st, which may offset the week number. Also, if a + * different week start is specified, this will also affect the week + * number. + * + * @see ICAL.Time.weekOneStarts + * @param {ICAL.Time.weekDay} aWeekStart The weekday the week starts with + * @return {Number} The ISO week number + */ + weekNumber: function weekNumber(aWeekStart) { + var wnCacheKey = (this.year << 12) + (this.month << 8) + (this.day << 3) + aWeekStart; + if (wnCacheKey in ICAL.Time._wnCache) { + return ICAL.Time._wnCache[wnCacheKey]; + } + // This function courtesty of Julian Bucknall, published under the MIT license + // http://www.boyet.com/articles/publishedarticles/calculatingtheisoweeknumb.html + // plus some fixes to be able to use different week starts. + var week1; + + var dt = this.clone(); + dt.isDate = true; + var isoyear = this.year; + + if (dt.month == 12 && dt.day > 25) { + week1 = ICAL.Time.weekOneStarts(isoyear + 1, aWeekStart); + if (dt.compare(week1) < 0) { + week1 = ICAL.Time.weekOneStarts(isoyear, aWeekStart); + } else { + isoyear++; + } + } else { + week1 = ICAL.Time.weekOneStarts(isoyear, aWeekStart); + if (dt.compare(week1) < 0) { + week1 = ICAL.Time.weekOneStarts(--isoyear, aWeekStart); + } + } + + var daysBetween = (dt.subtractDate(week1).toSeconds() / 86400); + var answer = ICAL.helpers.trunc(daysBetween / 7) + 1; + ICAL.Time._wnCache[wnCacheKey] = answer; + return answer; + }, + + /** + * Adds the duration to the current time. The instance is modified in + * place. + * + * @param {ICAL.Duration} aDuration The duration to add + */ + addDuration: function icaltime_add(aDuration) { + var mult = (aDuration.isNegative ? -1 : 1); + + // because of the duration optimizations it is much + // more efficient to grab all the values up front + // then set them directly (which will avoid a normalization call). + // So we don't actually normalize until we need it. + var second = this.second; + var minute = this.minute; + var hour = this.hour; + var day = this.day; + + second += mult * aDuration.seconds; + minute += mult * aDuration.minutes; + hour += mult * aDuration.hours; + day += mult * aDuration.days; + day += mult * 7 * aDuration.weeks; + + this.second = second; + this.minute = minute; + this.hour = hour; + this.day = day; + + this._cachedUnixTime = null; + }, + + /** + * Subtract the date details (_excluding_ timezone). Useful for finding + * the relative difference between two time objects excluding their + * timezone differences. + * + * @param {ICAL.Time} aDate The date to substract + * @return {ICAL.Duration} The difference as a duration + */ + subtractDate: function icaltime_subtract(aDate) { + var unixTime = this.toUnixTime() + this.utcOffset(); + var other = aDate.toUnixTime() + aDate.utcOffset(); + return ICAL.Duration.fromSeconds(unixTime - other); + }, + + /** + * Subtract the date details, taking timezones into account. + * + * @param {ICAL.Time} aDate The date to subtract + * @return {ICAL.Duration} The difference in duration + */ + subtractDateTz: function icaltime_subtract_abs(aDate) { + var unixTime = this.toUnixTime(); + var other = aDate.toUnixTime(); + return ICAL.Duration.fromSeconds(unixTime - other); + }, + + /** + * Compares the ICAL.Time instance with another one. + * + * @param {ICAL.Duration} aOther The instance to compare with + * @return {Number} -1, 0 or 1 for less/equal/greater + */ + compare: function icaltime_compare(other) { + var a = this.toUnixTime(); + var b = other.toUnixTime(); + + if (a > b) return 1; + if (b > a) return -1; + return 0; + }, + + /** + * Compares only the date part of this instance with another one. + * + * @param {ICAL.Duration} other The instance to compare with + * @param {ICAL.Timezone} tz The timezone to compare in + * @return {Number} -1, 0 or 1 for less/equal/greater + */ + compareDateOnlyTz: function icaltime_compareDateOnlyTz(other, tz) { + function cmp(attr) { + return ICAL.Time._cmp_attr(a, b, attr); + } + var a = this.convertToZone(tz); + var b = other.convertToZone(tz); + var rc = 0; + + if ((rc = cmp("year")) != 0) return rc; + if ((rc = cmp("month")) != 0) return rc; + if ((rc = cmp("day")) != 0) return rc; + + return rc; + }, + + /** + * Convert the instance into another timzone. The returned ICAL.Time + * instance is always a copy. + * + * @param {ICAL.Timezone} zone The zone to convert to + * @return {ICAL.Time} The copy, converted to the zone + */ + convertToZone: function convertToZone(zone) { + var copy = this.clone(); + var zone_equals = (this.zone.tzid == zone.tzid); + + if (!this.isDate && !zone_equals) { + ICAL.Timezone.convert_time(copy, this.zone, zone); + } + + copy.zone = zone; + return copy; + }, + + /** + * Calculates the UTC offset of the current date/time in the timezone it is + * in. + * + * @return {Number} UTC offset in seconds + */ + utcOffset: function utc_offset() { + if (this.zone == ICAL.Timezone.localTimezone || + this.zone == ICAL.Timezone.utcTimezone) { + return 0; + } else { + return this.zone.utcOffset(this); + } + }, + + /** + * Returns an RFC 5545 compliant ical representation of this object. + * + * @return {String} ical date/date-time + */ + toICALString: function() { + var string = this.toString(); + + if (string.length > 10) { + return ICAL.design.icalendar.value['date-time'].toICAL(string); + } else { + return ICAL.design.icalendar.value.date.toICAL(string); + } + }, + + /** + * The string representation of this date/time, in jCal form + * (including : and - separators). + * @return {String} + */ + toString: function toString() { + var result = this.year + '-' + + ICAL.helpers.pad2(this.month) + '-' + + ICAL.helpers.pad2(this.day); + + if (!this.isDate) { + result += 'T' + ICAL.helpers.pad2(this.hour) + ':' + + ICAL.helpers.pad2(this.minute) + ':' + + ICAL.helpers.pad2(this.second); + + if (this.zone === ICAL.Timezone.utcTimezone) { + result += 'Z'; + } + } + + return result; + }, + + /** + * Converts the current instance to a Javascript date + * @return {Date} + */ + toJSDate: function toJSDate() { + if (this.zone == ICAL.Timezone.localTimezone) { + if (this.isDate) { + return new Date(this.year, this.month - 1, this.day); + } else { + return new Date(this.year, this.month - 1, this.day, + this.hour, this.minute, this.second, 0); + } + } else { + return new Date(this.toUnixTime() * 1000); + } + }, + + _normalize: function icaltime_normalize() { + var isDate = this._time.isDate; + if (this._time.isDate) { + this._time.hour = 0; + this._time.minute = 0; + this._time.second = 0; + } + this.adjust(0, 0, 0, 0); + + return this; + }, + + /** + * Adjust the date/time by the given offset + * + * @param {Number} aExtraDays The extra amount of days + * @param {Number} aExtraHours The extra amount of hours + * @param {Number} aExtraMinutes The extra amount of minutes + * @param {Number} aExtraSeconds The extra amount of seconds + * @param {Number=} aTime The time to adjust, defaults to the + * current instance. + */ + adjust: function icaltime_adjust(aExtraDays, aExtraHours, + aExtraMinutes, aExtraSeconds, aTime) { + + var minutesOverflow, hoursOverflow, + daysOverflow = 0, yearsOverflow = 0; + + var second, minute, hour, day; + var daysInMonth; + + var time = aTime || this._time; + + if (!time.isDate) { + second = time.second + aExtraSeconds; + time.second = second % 60; + minutesOverflow = ICAL.helpers.trunc(second / 60); + if (time.second < 0) { + time.second += 60; + minutesOverflow--; + } + + minute = time.minute + aExtraMinutes + minutesOverflow; + time.minute = minute % 60; + hoursOverflow = ICAL.helpers.trunc(minute / 60); + if (time.minute < 0) { + time.minute += 60; + hoursOverflow--; + } + + hour = time.hour + aExtraHours + hoursOverflow; + + time.hour = hour % 24; + daysOverflow = ICAL.helpers.trunc(hour / 24); + if (time.hour < 0) { + time.hour += 24; + daysOverflow--; + } + } + + + // Adjust month and year first, because we need to know what month the day + // is in before adjusting it. + if (time.month > 12) { + yearsOverflow = ICAL.helpers.trunc((time.month - 1) / 12); + } else if (time.month < 1) { + yearsOverflow = ICAL.helpers.trunc(time.month / 12) - 1; + } + + time.year += yearsOverflow; + time.month -= 12 * yearsOverflow; + + // Now take care of the days (and adjust month if needed) + day = time.day + aExtraDays + daysOverflow; + + if (day > 0) { + for (;;) { + daysInMonth = ICAL.Time.daysInMonth(time.month, time.year); + if (day <= daysInMonth) { + break; + } + + time.month++; + if (time.month > 12) { + time.year++; + time.month = 1; + } + + day -= daysInMonth; + } + } else { + while (day <= 0) { + if (time.month == 1) { + time.year--; + time.month = 12; + } else { + time.month--; + } + + day += ICAL.Time.daysInMonth(time.month, time.year); + } + } + + time.day = day; + + this._cachedUnixTime = null; + return this; + }, + + /** + * Sets up the current instance from unix time, the number of seconds since + * January 1st, 1970. + * + * @param {Number} seconds The seconds to set up with + */ + fromUnixTime: function fromUnixTime(seconds) { + this.zone = ICAL.Timezone.utcTimezone; + var epoch = ICAL.Time.epochTime.clone(); + epoch.adjust(0, 0, 0, seconds); + + this.year = epoch.year; + this.month = epoch.month; + this.day = epoch.day; + this.hour = epoch.hour; + this.minute = epoch.minute; + this.second = Math.floor(epoch.second); + + this._cachedUnixTime = null; + }, + + /** + * Converts the current instance to seconds since January 1st 1970. + * + * @return {Number} Seconds since 1970 + */ + toUnixTime: function toUnixTime() { + if (this._cachedUnixTime !== null) { + return this._cachedUnixTime; + } + var offset = this.utcOffset(); + + // we use the offset trick to ensure + // that we are getting the actual UTC time + var ms = Date.UTC( + this.year, + this.month - 1, + this.day, + this.hour, + this.minute, + this.second - offset + ); + + // seconds + this._cachedUnixTime = ms / 1000; + return this._cachedUnixTime; + }, + + /** + * Converts time to into Object which can be serialized then re-created + * using the constructor. + * + * @example + * // toJSON will automatically be called + * var json = JSON.stringify(mytime); + * + * var deserialized = JSON.parse(json); + * + * var time = new ICAL.Time(deserialized); + * + * @return {Object} + */ + toJSON: function() { + var copy = [ + 'year', + 'month', + 'day', + 'hour', + 'minute', + 'second', + 'isDate' + ]; + + var result = Object.create(null); + + var i = 0; + var len = copy.length; + var prop; + + for (; i < len; i++) { + prop = copy[i]; + result[prop] = this[prop]; + } + + if (this.zone) { + result.timezone = this.zone.tzid; + } + + return result; + } + + }; + + (function setupNormalizeAttributes() { + // This needs to run before any instances are created! + function defineAttr(attr) { + Object.defineProperty(ICAL.Time.prototype, attr, { + get: function getTimeAttr() { + if (this._pendingNormalization) { + this._normalize(); + this._pendingNormalization = false; + } + + return this._time[attr]; + }, + set: function setTimeAttr(val) { + this._cachedUnixTime = null; + this._pendingNormalization = true; + this._time[attr] = val; + + return val; + } + }); + + } + + /* istanbul ignore else */ + if ("defineProperty" in Object) { + defineAttr("year"); + defineAttr("month"); + defineAttr("day"); + defineAttr("hour"); + defineAttr("minute"); + defineAttr("second"); + defineAttr("isDate"); + } + })(); + + /** + * Returns the days in the given month + * + * @param {Number} month The month to check + * @param {Number} year The year to check + * @return {Number} The number of days in the month + */ + ICAL.Time.daysInMonth = function icaltime_daysInMonth(month, year) { + var _daysInMonth = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + var days = 30; + + if (month < 1 || month > 12) return days; + + days = _daysInMonth[month]; + + if (month == 2) { + days += ICAL.Time.isLeapYear(year); + } + + return days; + }; + + /** + * Checks if the year is a leap year + * + * @param {Number} year The year to check + * @return {Boolean} True, if the year is a leap year + */ + ICAL.Time.isLeapYear = function isLeapYear(year) { + if (year <= 1752) { + return ((year % 4) == 0); + } else { + return (((year % 4 == 0) && (year % 100 != 0)) || (year % 400 == 0)); + } + }; + + /** + * Create a new ICAL.Time from the day of year and year. The date is returned + * in floating timezone. + * + * @param {Number} aDayOfYear The day of year + * @param {Number} aYear The year to create the instance in + * @return {ICAL.Time} The created instance with the calculated date + */ + ICAL.Time.fromDayOfYear = function icaltime_fromDayOfYear(aDayOfYear, aYear) { + var year = aYear; + var doy = aDayOfYear; + var tt = new ICAL.Time(); + tt.auto_normalize = false; + var is_leap = (ICAL.Time.isLeapYear(year) ? 1 : 0); + + if (doy < 1) { + year--; + is_leap = (ICAL.Time.isLeapYear(year) ? 1 : 0); + doy += ICAL.Time.daysInYearPassedMonth[is_leap][12]; + return ICAL.Time.fromDayOfYear(doy, year); + } else if (doy > ICAL.Time.daysInYearPassedMonth[is_leap][12]) { + is_leap = (ICAL.Time.isLeapYear(year) ? 1 : 0); + doy -= ICAL.Time.daysInYearPassedMonth[is_leap][12]; + year++; + return ICAL.Time.fromDayOfYear(doy, year); + } + + tt.year = year; + tt.isDate = true; + + for (var month = 11; month >= 0; month--) { + if (doy > ICAL.Time.daysInYearPassedMonth[is_leap][month]) { + tt.month = month + 1; + tt.day = doy - ICAL.Time.daysInYearPassedMonth[is_leap][month]; + break; + } + } + + tt.auto_normalize = true; + return tt; + }; + + /** + * Returns a new ICAL.Time instance from a date string, e.g 2015-01-02. + * + * @deprecated Use {@link ICAL.Time.fromDateString} instead + * @param {String} str The string to create from + * @return {ICAL.Time} The date/time instance + */ + ICAL.Time.fromStringv2 = function fromString(str) { + return new ICAL.Time({ + year: parseInt(str.substr(0, 4), 10), + month: parseInt(str.substr(5, 2), 10), + day: parseInt(str.substr(8, 2), 10), + isDate: true + }); + }; + + /** + * Returns a new ICAL.Time instance from a date string, e.g 2015-01-02. + * + * @param {String} aValue The string to create from + * @return {ICAL.Time} The date/time instance + */ + ICAL.Time.fromDateString = function(aValue) { + // Dates should have no timezone. + // Google likes to sometimes specify Z on dates + // we specifically ignore that to avoid issues. + + // YYYY-MM-DD + // 2012-10-10 + return new ICAL.Time({ + year: ICAL.helpers.strictParseInt(aValue.substr(0, 4)), + month: ICAL.helpers.strictParseInt(aValue.substr(5, 2)), + day: ICAL.helpers.strictParseInt(aValue.substr(8, 2)), + isDate: true + }); + }; + + /** + * Returns a new ICAL.Time instance from a date-time string, e.g + * 2015-01-02T03:04:05. If a property is specified, the timezone is set up + * from the property's TZID parameter. + * + * @param {String} aValue The string to create from + * @param {ICAL.Property=} prop The property the date belongs to + * @return {ICAL.Time} The date/time instance + */ + ICAL.Time.fromDateTimeString = function(aValue, prop) { + if (aValue.length < 19) { + throw new Error( + 'invalid date-time value: "' + aValue + '"' + ); + } + + var zone; + + if (aValue[19] && aValue[19] === 'Z') { + zone = 'Z'; + } else if (prop) { + zone = prop.getParameter('tzid'); + } + + // 2012-10-10T10:10:10(Z)? + var time = new ICAL.Time({ + year: ICAL.helpers.strictParseInt(aValue.substr(0, 4)), + month: ICAL.helpers.strictParseInt(aValue.substr(5, 2)), + day: ICAL.helpers.strictParseInt(aValue.substr(8, 2)), + hour: ICAL.helpers.strictParseInt(aValue.substr(11, 2)), + minute: ICAL.helpers.strictParseInt(aValue.substr(14, 2)), + second: ICAL.helpers.strictParseInt(aValue.substr(17, 2)), + timezone: zone + }); + + return time; + }; + + /** + * Returns a new ICAL.Time instance from a date or date-time string, + * + * @param {String} aValue The string to create from + * @return {ICAL.Time} The date/time instance + */ + ICAL.Time.fromString = function fromString(aValue) { + if (aValue.length > 10) { + return ICAL.Time.fromDateTimeString(aValue); + } else { + return ICAL.Time.fromDateString(aValue); + } + }; + + /** + * Creates a new ICAL.Time instance from the given Javascript Date. + * + * @param {?Date} aDate The Javascript Date to read, or null to reset + * @param {Boolean} useUTC If true, the UTC values of the date will be used + */ + ICAL.Time.fromJSDate = function fromJSDate(aDate, useUTC) { + var tt = new ICAL.Time(); + return tt.fromJSDate(aDate, useUTC); + }; + + /** + * Creates a new ICAL.Time instance from the the passed data object. + * + * @param {Object} aData Time initialization + * @param {Number=} aData.year The year for this date + * @param {Number=} aData.month The month for this date + * @param {Number=} aData.day The day for this date + * @param {Number=} aData.hour The hour for this date + * @param {Number=} aData.minute The minute for this date + * @param {Number=} aData.second The second for this date + * @param {Boolean=} aData.isDate If true, the instance represents a date + * (as opposed to a date-time) + * @param {ICAL.Timezone=} aZone Timezone this position occurs in + */ + ICAL.Time.fromData = function fromData(aData, aZone) { + var t = new ICAL.Time(); + return t.fromData(aData, aZone); + }; + + /** + * Creates a new ICAL.Time instance from the current moment. + * @return {ICAL.Time} + */ + ICAL.Time.now = function icaltime_now() { + return ICAL.Time.fromJSDate(new Date(), false); + }; + + /** + * Returns the date on which ISO week number 1 starts. + * + * @see ICAL.Time#weekNumber + * @param {Number} aYear The year to search in + * @param {ICAL.Time.weekDay=} aWeekStart The week start weekday, used for calculation. + * @return {ICAL.Time} The date on which week number 1 starts + */ + ICAL.Time.weekOneStarts = function weekOneStarts(aYear, aWeekStart) { + var t = ICAL.Time.fromData({ + year: aYear, + month: 1, + day: 1, + isDate: true + }); + + var dow = t.dayOfWeek(); + var wkst = aWeekStart || ICAL.Time.DEFAULT_WEEK_START; + if (dow > ICAL.Time.THURSDAY) { + t.day += 7; + } + if (wkst > ICAL.Time.THURSDAY) { + t.day -= 7; + } + + t.day -= dow - wkst; + + return t; + }; + + /** + * Get the dominical letter for the given year. Letters range from A - G for + * common years, and AG to GF for leap years. + * + * @param {Number} yr The year to retrieve the letter for + * @return {String} The dominical letter. + */ + ICAL.Time.getDominicalLetter = function(yr) { + var LTRS = "GFEDCBA"; + var dom = (yr + (yr / 4 | 0) + (yr / 400 | 0) - (yr / 100 | 0) - 1) % 7; + var isLeap = ICAL.Time.isLeapYear(yr); + if (isLeap) { + return LTRS[(dom + 6) % 7] + LTRS[dom]; + } else { + return LTRS[dom]; + } + }; + + /** + * January 1st, 1970 as an ICAL.Time. + * @type {ICAL.Time} + * @constant + * @instance + */ + ICAL.Time.epochTime = ICAL.Time.fromData({ + year: 1970, + month: 1, + day: 1, + hour: 0, + minute: 0, + second: 0, + isDate: false, + timezone: "Z" + }); + + ICAL.Time._cmp_attr = function _cmp_attr(a, b, attr) { + if (a[attr] > b[attr]) return 1; + if (a[attr] < b[attr]) return -1; + return 0; + }; + + /** + * The days that have passed in the year after a given month. The array has + * two members, one being an array of passed days for non-leap years, the + * other analog for leap years. + * @example + * var isLeapYear = ICAL.Time.isLeapYear(year); + * var passedDays = ICAL.Time.daysInYearPassedMonth[isLeapYear][month]; + * @type {Array.<Array.<Number>>} + */ + ICAL.Time.daysInYearPassedMonth = [ + [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365], + [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366] + ]; + + /** + * The weekday, 1 = SUNDAY, 7 = SATURDAY. Access via + * ICAL.Time.MONDAY, ICAL.Time.TUESDAY, ... + * + * @typedef {Number} weekDay + * @memberof ICAL.Time + */ + + ICAL.Time.SUNDAY = 1; + ICAL.Time.MONDAY = 2; + ICAL.Time.TUESDAY = 3; + ICAL.Time.WEDNESDAY = 4; + ICAL.Time.THURSDAY = 5; + ICAL.Time.FRIDAY = 6; + ICAL.Time.SATURDAY = 7; + + /** + * The default weekday for the WKST part. + * @constant + * @default ICAL.Time.MONDAY + */ + ICAL.Time.DEFAULT_WEEK_START = ICAL.Time.MONDAY; +})(); +/* 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/. + * Portions Copyright (C) Philipp Kewisch, 2015 */ + + + +(function() { + + /** + * Describes a vCard time, which has slight differences to the ICAL.Time. + * Properties can be null if not specified, for example for dates with + * reduced accuracy or truncation. + * + * Note that currently not all methods are correctly re-implemented for + * VCardTime. For example, comparison will have undefined results when some + * members are null. + * + * Also, normalization is not yet implemented for this class! + * + * @alias ICAL.VCardTime + * @class + * @extends {ICAL.Time} + * @param {Object} data The data for the time instance + * @param {Number=} data.year The year for this date + * @param {Number=} data.month The month for this date + * @param {Number=} data.day The day for this date + * @param {Number=} data.hour The hour for this date + * @param {Number=} data.minute The minute for this date + * @param {Number=} data.second The second for this date + * @param {ICAL.Timezone|ICAL.UtcOffset} zone The timezone to use + * @param {String} icaltype The type for this date/time object + */ + ICAL.VCardTime = function(data, zone, icaltype) { + this.wrappedJSObject = this; + var time = this._time = Object.create(null); + + time.year = null; + time.month = null; + time.day = null; + time.hour = null; + time.minute = null; + time.second = null; + + this.icaltype = icaltype || "date-and-or-time"; + + this.fromData(data, zone); + }; + ICAL.helpers.inherits(ICAL.Time, ICAL.VCardTime, /** @lends ICAL.VCardTime */ { + + /** + * The class identifier. + * @constant + * @type {String} + * @default "vcardtime" + */ + icalclass: "vcardtime", + + /** + * The type name, to be used in the jCal object. + * @type {String} + * @default "date-and-or-time" + */ + icaltype: "date-and-or-time", + + /** + * The timezone. This can either be floating, UTC, or an instance of + * ICAL.UtcOffset. + * @type {ICAL.Timezone|ICAL.UtcOFfset} + */ + zone: null, + + /** + * Returns a clone of the vcard date/time object. + * + * @return {ICAL.VCardTime} The cloned object + */ + clone: function() { + return new ICAL.VCardTime(this._time, this.zone, this.icaltype); + }, + + _normalize: function() { + return this; + }, + + /** + * @inheritdoc + */ + utcOffset: function() { + if (this.zone instanceof ICAL.UtcOffset) { + return this.zone.toSeconds(); + } else { + return ICAL.Time.prototype.utcOffset.apply(this, arguments); + } + }, + + /** + * Returns an RFC 6350 compliant representation of this object. + * + * @return {String} vcard date/time string + */ + toICALString: function() { + return ICAL.design.vcard.value[this.icaltype].toICAL(this.toString()); + }, + + /** + * The string representation of this date/time, in jCard form + * (including : and - separators). + * @return {String} + */ + toString: function toString() { + var p2 = ICAL.helpers.pad2; + var y = this.year, m = this.month, d = this.day; + var h = this.hour, mm = this.minute, s = this.second; + + var hasYear = y !== null, hasMonth = m !== null, hasDay = d !== null; + var hasHour = h !== null, hasMinute = mm !== null, hasSecond = s !== null; + + var datepart = (hasYear ? p2(y) + (hasMonth || hasDay ? '-' : '') : (hasMonth || hasDay ? '--' : '')) + + (hasMonth ? p2(m) : '') + + (hasDay ? '-' + p2(d) : ''); + var timepart = (hasHour ? p2(h) : '-') + (hasHour && hasMinute ? ':' : '') + + (hasMinute ? p2(mm) : '') + (!hasHour && !hasMinute ? '-' : '') + + (hasMinute && hasSecond ? ':' : '') + + (hasSecond ? p2(s) : ''); + + var zone; + if (this.zone === ICAL.Timezone.utcTimezone) { + zone = 'Z'; + } else if (this.zone instanceof ICAL.UtcOffset) { + zone = this.zone.toString(); + } else if (this.zone === ICAL.Timezone.localTimezone) { + zone = ''; + } else if (this.zone instanceof ICAL.Timezone) { + var offset = ICAL.UtcOffset.fromSeconds(this.zone.utcOffset(this)); + zone = offset.toString(); + } else { + zone = ''; + } + + switch (this.icaltype) { + case "time": + return timepart + zone; + case "date-and-or-time": + case "date-time": + return datepart + (timepart == '--' ? '' : 'T' + timepart + zone); + case "date": + return datepart; + } + return null; + } + }); + + /** + * Returns a new ICAL.VCardTime instance from a date and/or time string. + * + * @param {String} aValue The string to create from + * @param {String} aIcalType The type for this instance, e.g. date-and-or-time + * @return {ICAL.VCardTime} The date/time instance + */ + ICAL.VCardTime.fromDateAndOrTimeString = function(aValue, aIcalType) { + function part(v, s, e) { + return v ? ICAL.helpers.strictParseInt(v.substr(s, e)) : null; + } + var parts = aValue.split('T'); + var dt = parts[0], tmz = parts[1]; + var splitzone = tmz ? ICAL.design.vcard.value.time._splitZone(tmz) : []; + var zone = splitzone[0], tm = splitzone[1]; + + var stoi = ICAL.helpers.strictParseInt; + var dtlen = dt ? dt.length : 0; + var tmlen = tm ? tm.length : 0; + + var hasDashDate = dt && dt[0] == '-' && dt[1] == '-'; + var hasDashTime = tm && tm[0] == '-'; + + var o = { + year: hasDashDate ? null : part(dt, 0, 4), + month: hasDashDate && (dtlen == 4 || dtlen == 7) ? part(dt, 2, 2) : dtlen == 7 ? part(dt, 5, 2) : dtlen == 10 ? part(dt, 5, 2) : null, + day: dtlen == 5 ? part(dt, 3, 2) : dtlen == 7 && hasDashDate ? part(dt, 5, 2) : dtlen == 10 ? part(dt, 8, 2) : null, + + hour: hasDashTime ? null : part(tm, 0, 2), + minute: hasDashTime && tmlen == 3 ? part(tm, 1, 2) : tmlen > 4 ? hasDashTime ? part(tm, 1, 2) : part(tm, 3, 2) : null, + second: tmlen == 4 ? part(tm, 2, 2) : tmlen == 6 ? part(tm, 4, 2) : tmlen == 8 ? part(tm, 6, 2) : null + }; + + if (zone == 'Z') { + zone = ICAL.Timezone.utcTimezone; + } else if (zone && zone[3] == ':') { + zone = ICAL.UtcOffset.fromString(zone); + } else { + zone = null; + } + + return new ICAL.VCardTime(o, zone, aIcalType); + }; +})(); +/* 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/. + * Portions Copyright (C) Philipp Kewisch, 2011-2015 */ + + + +(function() { + var DOW_MAP = { + SU: ICAL.Time.SUNDAY, + MO: ICAL.Time.MONDAY, + TU: ICAL.Time.TUESDAY, + WE: ICAL.Time.WEDNESDAY, + TH: ICAL.Time.THURSDAY, + FR: ICAL.Time.FRIDAY, + SA: ICAL.Time.SATURDAY + }; + + var REVERSE_DOW_MAP = {}; + for (var key in DOW_MAP) { + /* istanbul ignore else */ + if (DOW_MAP.hasOwnProperty(key)) { + REVERSE_DOW_MAP[DOW_MAP[key]] = key; + } + } + + var COPY_PARTS = ["BYSECOND", "BYMINUTE", "BYHOUR", "BYDAY", + "BYMONTHDAY", "BYYEARDAY", "BYWEEKNO", + "BYMONTH", "BYSETPOS"]; + + /** + * @classdesc + * This class represents the "recur" value type, with various calculation + * and manipulation methods. + * + * @class + * @alias ICAL.Recur + * @param {Object} data An object with members of the recurrence + * @param {ICAL.Recur.frequencyValues} freq The frequency value + * @param {Number=} data.interval The INTERVAL value + * @param {ICAL.Time.weekDay=} data.wkst The week start value + * @param {ICAL.Time=} data.until The end of the recurrence set + * @param {Number=} data.count The number of occurrences + * @param {Array.<Number>=} data.bysecond The seconds for the BYSECOND part + * @param {Array.<Number>=} data.byminute The minutes for the BYMINUTE part + * @param {Array.<Number>=} data.byhour The hours for the BYHOUR part + * @param {Array.<String>=} data.byday The BYDAY values + * @param {Array.<Number>=} data.bymonthday The days for the BYMONTHDAY part + * @param {Array.<Number>=} data.byyearday The days for the BYYEARDAY part + * @param {Array.<Number>=} data.byweekno The weeks for the BYWEEKNO part + * @param {Array.<Number>=} data.bymonth The month for the BYMONTH part + * @param {Array.<Number>=} data.bysetpos The positionals for the BYSETPOS part + */ + ICAL.Recur = function icalrecur(data) { + this.wrappedJSObject = this; + this.parts = {}; + + if (data && typeof(data) === 'object') { + this.fromData(data); + } + }; + + ICAL.Recur.prototype = { + /** + * An object holding the BY-parts of the recurrence rule + * @type {Object} + */ + parts: null, + + /** + * The interval value for the recurrence rule. + * @type {Number} + */ + interval: 1, + + /** + * The week start day + * + * @type {ICAL.Time.weekDay} + * @default ICAL.Time.MONDAY + */ + wkst: ICAL.Time.MONDAY, + + /** + * The end of the recurrence + * @type {?ICAL.Time} + */ + until: null, + + /** + * The maximum number of occurrences + * @type {?Number} + */ + count: null, + + /** + * The frequency value. + * @type {ICAL.Recur.frequencyValues} + */ + freq: null, + + /** + * The class identifier. + * @constant + * @type {String} + * @default "icalrecur" + */ + icalclass: "icalrecur", + + /** + * The type name, to be used in the jCal object. + * @constant + * @type {String} + * @default "recur" + */ + icaltype: "recur", + + /** + * Create a new iterator for this recurrence rule. The passed start date + * must be the start date of the event, not the start of the range to + * search in. + * + * @example + * var recur = comp.getFirstPropertyValue('rrule'); + * var dtstart = comp.getFirstPropertyValue('dtstart'); + * var iter = recur.iterator(dtstart); + * for (var next = iter.next(); next; next = iter.next()) { + * if (next.compare(rangeStart) < 0) { + * continue; + * } + * console.log(next.toString()); + * } + * + * @param {ICAL.Time} aStart The item's start date + * @return {ICAL.RecurIterator} The recurrence iterator + */ + iterator: function(aStart) { + return new ICAL.RecurIterator({ + rule: this, + dtstart: aStart + }); + }, + + /** + * Returns a clone of the recurrence object. + * + * @return {ICAL.Recur} The cloned object + */ + clone: function clone() { + return new ICAL.Recur(this.toJSON()); + }, + + /** + * Checks if the current rule is finite, i.e. has a count or until part. + * + * @return {Boolean} True, if the rule is finite + */ + isFinite: function isfinite() { + return !!(this.count || this.until); + }, + + /** + * Checks if the current rule has a count part, and not limited by an until + * part. + * + * @return {Boolean} True, if the rule is by count + */ + isByCount: function isbycount() { + return !!(this.count && !this.until); + }, + + /** + * Adds a component (part) to the recurrence rule. This is not a component + * in the sense of {@link ICAL.Component}, but a part of the recurrence + * rule, i.e. BYMONTH. + * + * @param {String} aType The name of the component part + * @param {Array|String} aValue The component value + */ + addComponent: function addPart(aType, aValue) { + var ucname = aType.toUpperCase(); + if (ucname in this.parts) { + this.parts[ucname].push(aValue); + } else { + this.parts[ucname] = [aValue]; + } + }, + + /** + * Sets the component value for the given by-part. + * + * @param {String} aType The component part name + * @param {Array} aValues The component values + */ + setComponent: function setComponent(aType, aValues) { + this.parts[aType.toUpperCase()] = aValues.slice(); + }, + + /** + * Gets (a copy) of the requested component value. + * + * @param {String} aType The component part name + * @return {Array} The component part value + */ + getComponent: function getComponent(aType) { + var ucname = aType.toUpperCase(); + return (ucname in this.parts ? this.parts[ucname].slice() : []); + }, + + /** + * Retrieves the next occurrence after the given recurrence id. See the + * guide on {@tutorial terminology} for more details. + * + * NOTE: Currently, this method iterates all occurrences from the start + * date. It should not be called in a loop for performance reasons. If you + * would like to get more than one occurrence, you can iterate the + * occurrences manually, see the example on the + * {@link ICAL.Recur#iterator iterator} method. + * + * @param {ICAL.Time} aStartTime The start of the event series + * @param {ICAL.Time} aRecurrenceId The date of the last occurrence + * @return {ICAL.Time} The next occurrence after + */ + getNextOccurrence: function getNextOccurrence(aStartTime, aRecurrenceId) { + var iter = this.iterator(aStartTime); + var next, cdt; + + do { + next = iter.next(); + } while (next && next.compare(aRecurrenceId) <= 0); + + if (next && aRecurrenceId.zone) { + next.zone = aRecurrenceId.zone; + } + + return next; + }, + + /** + * Sets up the current instance using members from the passed data object. + * + * @param {Object} data An object with members of the recurrence + * @param {ICAL.Recur.frequencyValues} freq The frequency value + * @param {Number=} data.interval The INTERVAL value + * @param {ICAL.Time.weekDay=} data.wkst The week start value + * @param {ICAL.Time=} data.until The end of the recurrence set + * @param {Number=} data.count The number of occurrences + * @param {Array.<Number>=} data.bysecond The seconds for the BYSECOND part + * @param {Array.<Number>=} data.byminute The minutes for the BYMINUTE part + * @param {Array.<Number>=} data.byhour The hours for the BYHOUR part + * @param {Array.<String>=} data.byday The BYDAY values + * @param {Array.<Number>=} data.bymonthday The days for the BYMONTHDAY part + * @param {Array.<Number>=} data.byyearday The days for the BYYEARDAY part + * @param {Array.<Number>=} data.byweekno The weeks for the BYWEEKNO part + * @param {Array.<Number>=} data.bymonth The month for the BYMONTH part + * @param {Array.<Number>=} data.bysetpos The positionals for the BYSETPOS part + */ + fromData: function(data) { + for (var key in data) { + var uckey = key.toUpperCase(); + + if (uckey in partDesign) { + if (Array.isArray(data[key])) { + this.parts[uckey] = data[key]; + } else { + this.parts[uckey] = [data[key]]; + } + } else { + this[key] = data[key]; + } + } + + if (this.wkst && typeof this.wkst != "number") { + this.wkst = ICAL.Recur.icalDayToNumericDay(this.wkst); + } + + if (this.until && !(this.until instanceof ICAL.Time)) { + this.until = ICAL.Time.fromString(this.until); + } + }, + + /** + * The jCal representation of this recurrence type. + * @return {Object} + */ + toJSON: function() { + var res = Object.create(null); + res.freq = this.freq; + + if (this.count) { + res.count = this.count; + } + + if (this.interval > 1) { + res.interval = this.interval; + } + + for (var k in this.parts) { + /* istanbul ignore if */ + if (!this.parts.hasOwnProperty(k)) { + continue; + } + var kparts = this.parts[k]; + if (Array.isArray(kparts) && kparts.length == 1) { + res[k.toLowerCase()] = kparts[0]; + } else { + res[k.toLowerCase()] = ICAL.helpers.clone(this.parts[k]); + } + } + + if (this.until) { + res.until = this.until.toString(); + } + if ('wkst' in this && this.wkst !== ICAL.Time.DEFAULT_WEEK_START) { + res.wkst = ICAL.Recur.numericDayToIcalDay(this.wkst); + } + return res; + }, + + /** + * The string representation of this recurrence rule. + * @return {String} + */ + toString: function icalrecur_toString() { + // TODO retain order + var str = "FREQ=" + this.freq; + if (this.count) { + str += ";COUNT=" + this.count; + } + if (this.interval > 1) { + str += ";INTERVAL=" + this.interval; + } + for (var k in this.parts) { + /* istanbul ignore else */ + if (this.parts.hasOwnProperty(k)) { + str += ";" + k + "=" + this.parts[k]; + } + } + if (this.until) { + str += ';UNTIL=' + this.until.toString(); + } + if ('wkst' in this && this.wkst !== ICAL.Time.DEFAULT_WEEK_START) { + str += ';WKST=' + ICAL.Recur.numericDayToIcalDay(this.wkst); + } + return str; + } + }; + + function parseNumericValue(type, min, max, value) { + var result = value; + + if (value[0] === '+') { + result = value.substr(1); + } + + result = ICAL.helpers.strictParseInt(result); + + if (min !== undefined && value < min) { + throw new Error( + type + ': invalid value "' + value + '" must be > ' + min + ); + } + + if (max !== undefined && value > max) { + throw new Error( + type + ': invalid value "' + value + '" must be < ' + min + ); + } + + return result; + } + + /** + * Convert an ical representation of a day (SU, MO, etc..) + * into a numeric value of that day. + * + * @param {String} string The iCalendar day name + * @return {Number} Numeric value of given day + */ + ICAL.Recur.icalDayToNumericDay = function toNumericDay(string) { + //XXX: this is here so we can deal + // with possibly invalid string values. + + return DOW_MAP[string]; + }; + + /** + * Convert a numeric day value into its ical representation (SU, MO, etc..) + * + * @param {Number} num Numeric value of given day + * @return {String} The ICAL day value, e.g SU,MO,... + */ + ICAL.Recur.numericDayToIcalDay = function toIcalDay(num) { + //XXX: this is here so we can deal with possibly invalid number values. + // Also, this allows consistent mapping between day numbers and day + // names for external users. + return REVERSE_DOW_MAP[num]; + }; + + var VALID_DAY_NAMES = /^(SU|MO|TU|WE|TH|FR|SA)$/; + var VALID_BYDAY_PART = /^([+-])?(5[0-3]|[1-4][0-9]|[1-9])?(SU|MO|TU|WE|TH|FR|SA)$/; + + /** + * Possible frequency values for the FREQ part + * (YEARLY, MONTHLY, WEEKLY, DAILY, HOURLY, MINUTELY, SECONDLY) + * + * @typedef {String} frequencyValues + * @memberof ICAL.Recur + */ + + var ALLOWED_FREQ = ['SECONDLY', 'MINUTELY', 'HOURLY', + 'DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY']; + + var optionDesign = { + FREQ: function(value, dict, fmtIcal) { + // yes this is actually equal or faster then regex. + // upside here is we can enumerate the valid values. + if (ALLOWED_FREQ.indexOf(value) !== -1) { + dict.freq = value; + } else { + throw new Error( + 'invalid frequency "' + value + '" expected: "' + + ALLOWED_FREQ.join(', ') + '"' + ); + } + }, + + COUNT: function(value, dict, fmtIcal) { + dict.count = ICAL.helpers.strictParseInt(value); + }, + + INTERVAL: function(value, dict, fmtIcal) { + dict.interval = ICAL.helpers.strictParseInt(value); + if (dict.interval < 1) { + // 0 or negative values are not allowed, some engines seem to generate + // it though. Assume 1 instead. + dict.interval = 1; + } + }, + + UNTIL: function(value, dict, fmtIcal) { + if (fmtIcal) { + if (value.length > 10) { + dict.until = ICAL.design.icalendar.value['date-time'].fromICAL(value); + } else { + dict.until = ICAL.design.icalendar.value.date.fromICAL(value); + } + } else { + dict.until = ICAL.Time.fromString(value); + } + }, + + WKST: function(value, dict, fmtIcal) { + if (VALID_DAY_NAMES.test(value)) { + dict.wkst = ICAL.Recur.icalDayToNumericDay(value); + } else { + throw new Error('invalid WKST value "' + value + '"'); + } + } + }; + + var partDesign = { + BYSECOND: parseNumericValue.bind(this, 'BYSECOND', 0, 60), + BYMINUTE: parseNumericValue.bind(this, 'BYMINUTE', 0, 59), + BYHOUR: parseNumericValue.bind(this, 'BYHOUR', 0, 23), + BYDAY: function(value) { + if (VALID_BYDAY_PART.test(value)) { + return value; + } else { + throw new Error('invalid BYDAY value "' + value + '"'); + } + }, + BYMONTHDAY: parseNumericValue.bind(this, 'BYMONTHDAY', -31, 31), + BYYEARDAY: parseNumericValue.bind(this, 'BYYEARDAY', -366, 366), + BYWEEKNO: parseNumericValue.bind(this, 'BYWEEKNO', -53, 53), + BYMONTH: parseNumericValue.bind(this, 'BYMONTH', 0, 12), + BYSETPOS: parseNumericValue.bind(this, 'BYSETPOS', -366, 366) + }; + + + /** + * Creates a new {@link ICAL.Recur} instance from the passed string. + * + * @param {String} string The string to parse + * @return {ICAL.Recur} The created recurrence instance + */ + ICAL.Recur.fromString = function(string) { + var data = ICAL.Recur._stringToData(string, false); + return new ICAL.Recur(data); + }; + + /** + * Creates a new {@link ICAL.Recur} instance using members from the passed + * data object. + * + * @param {Object} aData An object with members of the recurrence + * @param {ICAL.Recur.frequencyValues} freq The frequency value + * @param {Number=} aData.interval The INTERVAL value + * @param {ICAL.Time.weekDay=} aData.wkst The week start value + * @param {ICAL.Time=} aData.until The end of the recurrence set + * @param {Number=} aData.count The number of occurrences + * @param {Array.<Number>=} aData.bysecond The seconds for the BYSECOND part + * @param {Array.<Number>=} aData.byminute The minutes for the BYMINUTE part + * @param {Array.<Number>=} aData.byhour The hours for the BYHOUR part + * @param {Array.<String>=} aData.byday The BYDAY values + * @param {Array.<Number>=} aData.bymonthday The days for the BYMONTHDAY part + * @param {Array.<Number>=} aData.byyearday The days for the BYYEARDAY part + * @param {Array.<Number>=} aData.byweekno The weeks for the BYWEEKNO part + * @param {Array.<Number>=} aData.bymonth The month for the BYMONTH part + * @param {Array.<Number>=} aData.bysetpos The positionals for the BYSETPOS part + */ + ICAL.Recur.fromData = function(aData) { + return new ICAL.Recur(aData); + }; + + /** + * Converts a recurrence string to a data object, suitable for the fromData + * method. + * + * @param {String} string The string to parse + * @param {Boolean} fmtIcal If true, the string is considered to be an + * iCalendar string + * @return {ICAL.Recur} The recurrence instance + */ + ICAL.Recur._stringToData = function(string, fmtIcal) { + var dict = Object.create(null); + + // split is slower in FF but fast enough. + // v8 however this is faster then manual split? + var values = string.split(';'); + var len = values.length; + + for (var i = 0; i < len; i++) { + var parts = values[i].split('='); + var ucname = parts[0].toUpperCase(); + var lcname = parts[0].toLowerCase(); + var name = (fmtIcal ? lcname : ucname); + var value = parts[1]; + + if (ucname in partDesign) { + var partArr = value.split(','); + var partArrIdx = 0; + var partArrLen = partArr.length; + + for (; partArrIdx < partArrLen; partArrIdx++) { + partArr[partArrIdx] = partDesign[ucname](partArr[partArrIdx]); + } + dict[name] = (partArr.length == 1 ? partArr[0] : partArr); + } else if (ucname in optionDesign) { + optionDesign[ucname](value, dict, fmtIcal); + } else { + // Don't swallow unknown values. Just set them as they are. + dict[lcname] = value; + } + } + + return dict; + }; +})(); +/* 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/. + * Portions Copyright (C) Philipp Kewisch, 2011-2015 */ + + +/** + * This symbol is further described later on + * @ignore + */ +ICAL.RecurIterator = (function() { + + /** + * @classdesc + * An iterator for a single recurrence rule. This class usually doesn't have + * to be instanciated directly, the convenience method + * {@link ICAL.Recur#iterator} can be used. + * + * @description + * The options object may contain additional members when resuming iteration from a previous run + * + * @description + * The options object may contain additional members when resuming iteration + * from a previous run. + * + * @class + * @alias ICAL.RecurIterator + * @param {Object} options The iterator options + * @param {ICAL.Recur} options.rule The rule to iterate. + * @param {ICAL.Time} options.dtstart The start date of the event. + * @param {Boolean=} options.initialized When true, assume that options are + * from a previously constructed iterator. Initialization will not be + * repeated. + */ + function icalrecur_iterator(options) { + this.fromData(options); + } + + icalrecur_iterator.prototype = { + + /** + * True when iteration is finished. + * @type {Boolean} + */ + completed: false, + + /** + * The rule that is being iterated + * @type {ICAL.Recur} + */ + rule: null, + + /** + * The start date of the event being iterated. + * @type {ICAL.Time} + */ + dtstart: null, + + /** + * The last occurrence that was returned from the + * {@link ICAL.RecurIterator#next} method. + * @type {ICAL.Time} + */ + last: null, + + /** + * The sequence number from the occurrence + * @type {Number} + */ + occurrence_number: 0, + + /** + * The indices used for the {@link ICAL.RecurIterator#by_data} object. + * @type {Object} + * @private + */ + by_indices: null, + + /** + * If true, the iterator has already been initialized + * @type {Boolean} + * @private + */ + initialized: false, + + /** + * The initializd by-data. + * @type {Object} + * @private + */ + by_data: null, + + /** + * The expanded yeardays + * @type {Array} + * @private + */ + days: null, + + /** + * The index in the {@link ICAL.RecurIterator#days} array. + * @type {Number} + * @private + */ + days_index: 0, + + /** + * Initialize the recurrence iterator from the passed data object. This + * method is usually not called directly, you can initialize the iterator + * through the constructor. + * + * @param {Object} options The iterator options + * @param {ICAL.Recur} options.rule The rule to iterate. + * @param {ICAL.Time} options.dtstart The start date of the event. + * @param {Boolean=} options.initialized When true, assume that options are + * from a previously constructed iterator. Initialization will not be + * repeated. + */ + fromData: function(options) { + this.rule = ICAL.helpers.formatClassType(options.rule, ICAL.Recur); + + if (!this.rule) { + throw new Error('iterator requires a (ICAL.Recur) rule'); + } + + this.dtstart = ICAL.helpers.formatClassType(options.dtstart, ICAL.Time); + + if (!this.dtstart) { + throw new Error('iterator requires a (ICAL.Time) dtstart'); + } + + if (options.by_data) { + this.by_data = options.by_data; + } else { + this.by_data = ICAL.helpers.clone(this.rule.parts, true); + } + + if (options.occurrence_number) + this.occurrence_number = options.occurrence_number; + + this.days = options.days || []; + if (options.last) { + this.last = ICAL.helpers.formatClassType(options.last, ICAL.Time); + } + + this.by_indices = options.by_indices; + + if (!this.by_indices) { + this.by_indices = { + "BYSECOND": 0, + "BYMINUTE": 0, + "BYHOUR": 0, + "BYDAY": 0, + "BYMONTH": 0, + "BYWEEKNO": 0, + "BYMONTHDAY": 0 + }; + } + + this.initialized = options.initialized || false; + + if (!this.initialized) { + this.init(); + } + }, + + /** + * Intialize the iterator + * @private + */ + init: function icalrecur_iterator_init() { + this.initialized = true; + this.last = this.dtstart.clone(); + var parts = this.by_data; + + if ("BYDAY" in parts) { + // libical does this earlier when the rule is loaded, but we postpone to + // now so we can preserve the original order. + this.sort_byday_rules(parts.BYDAY, this.rule.wkst); + } + + // If the BYYEARDAY appares, no other date rule part may appear + if ("BYYEARDAY" in parts) { + if ("BYMONTH" in parts || "BYWEEKNO" in parts || + "BYMONTHDAY" in parts || "BYDAY" in parts) { + throw new Error("Invalid BYYEARDAY rule"); + } + } + + // BYWEEKNO and BYMONTHDAY rule parts may not both appear + if ("BYWEEKNO" in parts && "BYMONTHDAY" in parts) { + throw new Error("BYWEEKNO does not fit to BYMONTHDAY"); + } + + // For MONTHLY recurrences (FREQ=MONTHLY) neither BYYEARDAY nor + // BYWEEKNO may appear. + if (this.rule.freq == "MONTHLY" && + ("BYYEARDAY" in parts || "BYWEEKNO" in parts)) { + throw new Error("For MONTHLY recurrences neither BYYEARDAY nor BYWEEKNO may appear"); + } + + // For WEEKLY recurrences (FREQ=WEEKLY) neither BYMONTHDAY nor + // BYYEARDAY may appear. + if (this.rule.freq == "WEEKLY" && + ("BYYEARDAY" in parts || "BYMONTHDAY" in parts)) { + throw new Error("For WEEKLY recurrences neither BYMONTHDAY nor BYYEARDAY may appear"); + } + + // BYYEARDAY may only appear in YEARLY rules + if (this.rule.freq != "YEARLY" && "BYYEARDAY" in parts) { + throw new Error("BYYEARDAY may only appear in YEARLY rules"); + } + + this.last.second = this.setup_defaults("BYSECOND", "SECONDLY", this.dtstart.second); + this.last.minute = this.setup_defaults("BYMINUTE", "MINUTELY", this.dtstart.minute); + this.last.hour = this.setup_defaults("BYHOUR", "HOURLY", this.dtstart.hour); + this.last.day = this.setup_defaults("BYMONTHDAY", "DAILY", this.dtstart.day); + this.last.month = this.setup_defaults("BYMONTH", "MONTHLY", this.dtstart.month); + + if (this.rule.freq == "WEEKLY") { + if ("BYDAY" in parts) { + var bydayParts = this.ruleDayOfWeek(parts.BYDAY[0]); + var pos = bydayParts[0]; + var dow = bydayParts[1]; + var wkdy = dow - this.last.dayOfWeek(); + if ((this.last.dayOfWeek() < dow && wkdy >= 0) || wkdy < 0) { + // Initial time is after first day of BYDAY data + this.last.day += wkdy; + } + } else { + var dayName = ICAL.Recur.numericDayToIcalDay(this.dtstart.dayOfWeek()); + parts.BYDAY = [dayName]; + } + } + + if (this.rule.freq == "YEARLY") { + for (;;) { + this.expand_year_days(this.last.year); + if (this.days.length > 0) { + break; + } + this.increment_year(this.rule.interval); + } + + this._nextByYearDay(); + } + + if (this.rule.freq == "MONTHLY" && this.has_by_data("BYDAY")) { + var tempLast = null; + var initLast = this.last.clone(); + var daysInMonth = ICAL.Time.daysInMonth(this.last.month, this.last.year); + + // Check every weekday in BYDAY with relative dow and pos. + for (var i in this.by_data.BYDAY) { + /* istanbul ignore if */ + if (!this.by_data.BYDAY.hasOwnProperty(i)) { + continue; + } + this.last = initLast.clone(); + var bydayParts = this.ruleDayOfWeek(this.by_data.BYDAY[i]); + var pos = bydayParts[0]; + var dow = bydayParts[1]; + var dayOfMonth = this.last.nthWeekDay(dow, pos); + + // If |pos| >= 6, the byday is invalid for a monthly rule. + if (pos >= 6 || pos <= -6) { + throw new Error("Malformed values in BYDAY part"); + } + + // If a Byday with pos=+/-5 is not in the current month it + // must be searched in the next months. + if (dayOfMonth > daysInMonth || dayOfMonth <= 0) { + // Skip if we have already found a "last" in this month. + if (tempLast && tempLast.month == initLast.month) { + continue; + } + while (dayOfMonth > daysInMonth || dayOfMonth <= 0) { + this.increment_month(); + daysInMonth = ICAL.Time.daysInMonth(this.last.month, this.last.year); + dayOfMonth = this.last.nthWeekDay(dow, pos); + } + } + + this.last.day = dayOfMonth; + if (!tempLast || this.last.compare(tempLast) < 0) { + tempLast = this.last.clone(); + } + } + this.last = tempLast.clone(); + + //XXX: This feels like a hack, but we need to initialize + // the BYMONTHDAY case correctly and byDayAndMonthDay handles + // this case. It accepts a special flag which will avoid incrementing + // the initial value without the flag days that match the start time + // would be missed. + if (this.has_by_data('BYMONTHDAY')) { + this._byDayAndMonthDay(true); + } + + if (this.last.day > daysInMonth || this.last.day == 0) { + throw new Error("Malformed values in BYDAY part"); + } + + } else if (this.has_by_data("BYMONTHDAY")) { + if (this.last.day < 0) { + var daysInMonth = ICAL.Time.daysInMonth(this.last.month, this.last.year); + this.last.day = daysInMonth + this.last.day + 1; + } + } + + }, + + /** + * Retrieve the next occurrence from the iterator. + * @return {ICAL.Time} + */ + next: function icalrecur_iterator_next() { + var before = (this.last ? this.last.clone() : null); + + if ((this.rule.count && this.occurrence_number >= this.rule.count) || + (this.rule.until && this.last.compare(this.rule.until) > 0)) { + + //XXX: right now this is just a flag and has no impact + // we can simplify the above case to check for completed later. + this.completed = true; + + return null; + } + + if (this.occurrence_number == 0 && this.last.compare(this.dtstart) >= 0) { + // First of all, give the instance that was initialized + this.occurrence_number++; + return this.last; + } + + + var valid; + do { + valid = 1; + + switch (this.rule.freq) { + case "SECONDLY": + this.next_second(); + break; + case "MINUTELY": + this.next_minute(); + break; + case "HOURLY": + this.next_hour(); + break; + case "DAILY": + this.next_day(); + break; + case "WEEKLY": + this.next_week(); + break; + case "MONTHLY": + valid = this.next_month(); + break; + case "YEARLY": + this.next_year(); + break; + + default: + return null; + } + } while (!this.check_contracting_rules() || + this.last.compare(this.dtstart) < 0 || + !valid); + + // TODO is this valid? + if (this.last.compare(before) == 0) { + throw new Error("Same occurrence found twice, protecting " + + "you from death by recursion"); + } + + if (this.rule.until && this.last.compare(this.rule.until) > 0) { + this.completed = true; + return null; + } else { + this.occurrence_number++; + return this.last; + } + }, + + next_second: function next_second() { + return this.next_generic("BYSECOND", "SECONDLY", "second", "minute"); + }, + + increment_second: function increment_second(inc) { + return this.increment_generic(inc, "second", 60, "minute"); + }, + + next_minute: function next_minute() { + return this.next_generic("BYMINUTE", "MINUTELY", + "minute", "hour", "next_second"); + }, + + increment_minute: function increment_minute(inc) { + return this.increment_generic(inc, "minute", 60, "hour"); + }, + + next_hour: function next_hour() { + return this.next_generic("BYHOUR", "HOURLY", "hour", + "monthday", "next_minute"); + }, + + increment_hour: function increment_hour(inc) { + this.increment_generic(inc, "hour", 24, "monthday"); + }, + + next_day: function next_day() { + var has_by_day = ("BYDAY" in this.by_data); + var this_freq = (this.rule.freq == "DAILY"); + + if (this.next_hour() == 0) { + return 0; + } + + if (this_freq) { + this.increment_monthday(this.rule.interval); + } else { + this.increment_monthday(1); + } + + return 0; + }, + + next_week: function next_week() { + var end_of_data = 0; + + if (this.next_weekday_by_week() == 0) { + return end_of_data; + } + + if (this.has_by_data("BYWEEKNO")) { + var idx = ++this.by_indices.BYWEEKNO; + + if (this.by_indices.BYWEEKNO == this.by_data.BYWEEKNO.length) { + this.by_indices.BYWEEKNO = 0; + end_of_data = 1; + } + + // HACK should be first month of the year + this.last.month = 1; + this.last.day = 1; + + var week_no = this.by_data.BYWEEKNO[this.by_indices.BYWEEKNO]; + + this.last.day += 7 * week_no; + + if (end_of_data) { + this.increment_year(1); + } + } else { + // Jump to the next week + this.increment_monthday(7 * this.rule.interval); + } + + return end_of_data; + }, + + /** + * Normalize each by day rule for a given year/month. + * Takes into account ordering and negative rules + * + * @private + * @param {Number} year Current year. + * @param {Number} month Current month. + * @param {Array} rules Array of rules. + * + * @return {Array} sorted and normalized rules. + * Negative rules will be expanded to their + * correct positive values for easier processing. + */ + normalizeByMonthDayRules: function(year, month, rules) { + var daysInMonth = ICAL.Time.daysInMonth(month, year); + + // XXX: This is probably bad for performance to allocate + // a new array for each month we scan, if possible + // we should try to optimize this... + var newRules = []; + + var ruleIdx = 0; + var len = rules.length; + var rule; + + for (; ruleIdx < len; ruleIdx++) { + rule = rules[ruleIdx]; + + // if this rule falls outside of given + // month discard it. + if (Math.abs(rule) > daysInMonth) { + continue; + } + + // negative case + if (rule < 0) { + // we add (not subtract its a negative number) + // one from the rule because 1 === last day of month + rule = daysInMonth + (rule + 1); + } else if (rule === 0) { + // skip zero its invalid. + continue; + } + + // only add unique items... + if (newRules.indexOf(rule) === -1) { + newRules.push(rule); + } + + } + + // unique and sort + return newRules.sort(function(a, b) { return a - b; }); + }, + + /** + * NOTES: + * We are given a list of dates in the month (BYMONTHDAY) (23, etc..) + * Also we are given a list of days (BYDAY) (MO, 2SU, etc..) when + * both conditions match a given date (this.last.day) iteration stops. + * + * @private + * @param {Boolean=} isInit When given true will not increment the + * current day (this.last). + */ + _byDayAndMonthDay: function(isInit) { + var byMonthDay; // setup in initMonth + var byDay = this.by_data.BYDAY; + + var date; + var dateIdx = 0; + var dateLen; // setup in initMonth + var dayLen = byDay.length; + + // we are not valid by default + var dataIsValid = 0; + + var daysInMonth; + var self = this; + // we need a copy of this, because a DateTime gets normalized + // automatically if the day is out of range. At some points we + // set the last day to 0 to start counting. + var lastDay = this.last.day; + + function initMonth() { + daysInMonth = ICAL.Time.daysInMonth( + self.last.month, self.last.year + ); + + byMonthDay = self.normalizeByMonthDayRules( + self.last.year, + self.last.month, + self.by_data.BYMONTHDAY + ); + + dateLen = byMonthDay.length; + + // For the case of more than one occurrence in one month + // we have to be sure to start searching after the last + // found date or at the last BYMONTHDAY, unless we are + // initializing the iterator because in this case we have + // to consider the last found date too. + while (byMonthDay[dateIdx] <= lastDay && + !(isInit && byMonthDay[dateIdx] == lastDay) && + dateIdx < dateLen - 1) { + dateIdx++; + } + } + + function nextMonth() { + // since the day is incremented at the start + // of the loop below, we need to start at 0 + lastDay = 0; + self.increment_month(); + dateIdx = 0; + initMonth(); + } + + initMonth(); + + // should come after initMonth + if (isInit) { + lastDay -= 1; + } + + // Use a counter to avoid an infinite loop with malformed rules. + // Stop checking after 4 years so we consider also a leap year. + var monthsCounter = 48; + + while (!dataIsValid && monthsCounter) { + monthsCounter--; + // increment the current date. This is really + // important otherwise we may fall into the infinite + // loop trap. The initial date takes care of the case + // where the current date is the date we are looking + // for. + date = lastDay + 1; + + if (date > daysInMonth) { + nextMonth(); + continue; + } + + // find next date + var next = byMonthDay[dateIdx++]; + + // this logic is dependant on the BYMONTHDAYS + // being in order (which is done by #normalizeByMonthDayRules) + if (next >= date) { + // if the next month day is in the future jump to it. + lastDay = next; + } else { + // in this case the 'next' monthday has past + // we must move to the month. + nextMonth(); + continue; + } + + // Now we can loop through the day rules to see + // if one matches the current month date. + for (var dayIdx = 0; dayIdx < dayLen; dayIdx++) { + var parts = this.ruleDayOfWeek(byDay[dayIdx]); + var pos = parts[0]; + var dow = parts[1]; + + this.last.day = lastDay; + if (this.last.isNthWeekDay(dow, pos)) { + // when we find the valid one we can mark + // the conditions as met and break the loop. + // (Because we have this condition above + // it will also break the parent loop). + dataIsValid = 1; + break; + } + } + + // Its completely possible that the combination + // cannot be matched in the current month. + // When we reach the end of possible combinations + // in the current month we iterate to the next one. + // since dateIdx is incremented right after getting + // "next", we don't need dateLen -1 here. + if (!dataIsValid && dateIdx === dateLen) { + nextMonth(); + continue; + } + } + + if (monthsCounter <= 0) { + // Checked 4 years without finding a Byday that matches + // a Bymonthday. Maybe the rule is not correct. + throw new Error("Malformed values in BYDAY combined with BYMONTHDAY parts"); + } + + + return dataIsValid; + }, + + next_month: function next_month() { + var this_freq = (this.rule.freq == "MONTHLY"); + var data_valid = 1; + + if (this.next_hour() == 0) { + return data_valid; + } + + if (this.has_by_data("BYDAY") && this.has_by_data("BYMONTHDAY")) { + data_valid = this._byDayAndMonthDay(); + } else if (this.has_by_data("BYDAY")) { + var daysInMonth = ICAL.Time.daysInMonth(this.last.month, this.last.year); + var setpos = 0; + var setpos_total = 0; + + if (this.has_by_data("BYSETPOS")) { + var last_day = this.last.day; + for (var day = 1; day <= daysInMonth; day++) { + this.last.day = day; + if (this.is_day_in_byday(this.last)) { + setpos_total++; + if (day <= last_day) { + setpos++; + } + } + } + this.last.day = last_day; + } + + data_valid = 0; + for (var day = this.last.day + 1; day <= daysInMonth; day++) { + this.last.day = day; + + if (this.is_day_in_byday(this.last)) { + if (!this.has_by_data("BYSETPOS") || + this.check_set_position(++setpos) || + this.check_set_position(setpos - setpos_total - 1)) { + + data_valid = 1; + break; + } + } + } + + if (day > daysInMonth) { + this.last.day = 1; + this.increment_month(); + + if (this.is_day_in_byday(this.last)) { + if (!this.has_by_data("BYSETPOS") || this.check_set_position(1)) { + data_valid = 1; + } + } else { + data_valid = 0; + } + } + } else if (this.has_by_data("BYMONTHDAY")) { + this.by_indices.BYMONTHDAY++; + + if (this.by_indices.BYMONTHDAY >= this.by_data.BYMONTHDAY.length) { + this.by_indices.BYMONTHDAY = 0; + this.increment_month(); + } + + var daysInMonth = ICAL.Time.daysInMonth(this.last.month, this.last.year); + var day = this.by_data.BYMONTHDAY[this.by_indices.BYMONTHDAY]; + + if (day < 0) { + day = daysInMonth + day + 1; + } + + if (day > daysInMonth) { + this.last.day = 1; + data_valid = this.is_day_in_byday(this.last); + } else { + this.last.day = day; + } + + } else { + this.increment_month(); + var daysInMonth = ICAL.Time.daysInMonth(this.last.month, this.last.year); + if (this.by_data.BYMONTHDAY[0] > daysInMonth) { + data_valid = 0; + } else { + this.last.day = this.by_data.BYMONTHDAY[0]; + } + } + + return data_valid; + }, + + next_weekday_by_week: function next_weekday_by_week() { + var end_of_data = 0; + + if (this.next_hour() == 0) { + return end_of_data; + } + + if (!this.has_by_data("BYDAY")) { + return 1; + } + + for (;;) { + var tt = new ICAL.Time(); + this.by_indices.BYDAY++; + + if (this.by_indices.BYDAY == Object.keys(this.by_data.BYDAY).length) { + this.by_indices.BYDAY = 0; + end_of_data = 1; + } + + var coded_day = this.by_data.BYDAY[this.by_indices.BYDAY]; + var parts = this.ruleDayOfWeek(coded_day); + var dow = parts[1]; + + dow -= this.rule.wkst; + + if (dow < 0) { + dow += 7; + } + + tt.year = this.last.year; + tt.month = this.last.month; + tt.day = this.last.day; + + var startOfWeek = tt.startDoyWeek(this.rule.wkst); + + if (dow + startOfWeek < 1) { + // The selected date is in the previous year + if (!end_of_data) { + continue; + } + } + + var next = ICAL.Time.fromDayOfYear(startOfWeek + dow, + this.last.year); + + /** + * The normalization horrors below are due to + * the fact that when the year/month/day changes + * it can effect the other operations that come after. + */ + this.last.year = next.year; + this.last.month = next.month; + this.last.day = next.day; + + return end_of_data; + } + }, + + next_year: function next_year() { + + if (this.next_hour() == 0) { + return 0; + } + + if (++this.days_index == this.days.length) { + this.days_index = 0; + do { + this.increment_year(this.rule.interval); + this.expand_year_days(this.last.year); + } while (this.days.length == 0); + } + + this._nextByYearDay(); + + return 1; + }, + + _nextByYearDay: function _nextByYearDay() { + var doy = this.days[this.days_index]; + var year = this.last.year; + if (doy < 1) { + // Time.fromDayOfYear(doy, year) indexes relative to the + // start of the given year. That is different from the + // semantics of BYYEARDAY where negative indexes are an + // offset from the end of the given year. + doy += 1; + year += 1; + } + var next = ICAL.Time.fromDayOfYear(doy, year); + this.last.day = next.day; + this.last.month = next.month; + }, + + ruleDayOfWeek: function ruleDayOfWeek(dow) { + var matches = dow.match(/([+-]?[0-9])?(MO|TU|WE|TH|FR|SA|SU)/); + if (matches) { + var pos = parseInt(matches[1] || 0, 10); + dow = ICAL.Recur.icalDayToNumericDay(matches[2]); + return [pos, dow]; + } else { + return [0, 0]; + } + }, + + next_generic: function next_generic(aRuleType, aInterval, aDateAttr, + aFollowingAttr, aPreviousIncr) { + var has_by_rule = (aRuleType in this.by_data); + var this_freq = (this.rule.freq == aInterval); + var end_of_data = 0; + + if (aPreviousIncr && this[aPreviousIncr]() == 0) { + return end_of_data; + } + + if (has_by_rule) { + this.by_indices[aRuleType]++; + var idx = this.by_indices[aRuleType]; + var dta = this.by_data[aRuleType]; + + if (this.by_indices[aRuleType] == dta.length) { + this.by_indices[aRuleType] = 0; + end_of_data = 1; + } + this.last[aDateAttr] = dta[this.by_indices[aRuleType]]; + } else if (this_freq) { + this["increment_" + aDateAttr](this.rule.interval); + } + + if (has_by_rule && end_of_data && this_freq) { + this["increment_" + aFollowingAttr](1); + } + + return end_of_data; + }, + + increment_monthday: function increment_monthday(inc) { + for (var i = 0; i < inc; i++) { + var daysInMonth = ICAL.Time.daysInMonth(this.last.month, this.last.year); + this.last.day++; + + if (this.last.day > daysInMonth) { + this.last.day -= daysInMonth; + this.increment_month(); + } + } + }, + + increment_month: function increment_month() { + this.last.day = 1; + if (this.has_by_data("BYMONTH")) { + this.by_indices.BYMONTH++; + + if (this.by_indices.BYMONTH == this.by_data.BYMONTH.length) { + this.by_indices.BYMONTH = 0; + this.increment_year(1); + } + + this.last.month = this.by_data.BYMONTH[this.by_indices.BYMONTH]; + } else { + if (this.rule.freq == "MONTHLY") { + this.last.month += this.rule.interval; + } else { + this.last.month++; + } + + this.last.month--; + var years = ICAL.helpers.trunc(this.last.month / 12); + this.last.month %= 12; + this.last.month++; + + if (years != 0) { + this.increment_year(years); + } + } + }, + + increment_year: function increment_year(inc) { + this.last.year += inc; + }, + + increment_generic: function increment_generic(inc, aDateAttr, + aFactor, aNextIncrement) { + this.last[aDateAttr] += inc; + var nextunit = ICAL.helpers.trunc(this.last[aDateAttr] / aFactor); + this.last[aDateAttr] %= aFactor; + if (nextunit != 0) { + this["increment_" + aNextIncrement](nextunit); + } + }, + + has_by_data: function has_by_data(aRuleType) { + return (aRuleType in this.rule.parts); + }, + + expand_year_days: function expand_year_days(aYear) { + var t = new ICAL.Time(); + this.days = []; + + // We need our own copy with a few keys set + var parts = {}; + var rules = ["BYDAY", "BYWEEKNO", "BYMONTHDAY", "BYMONTH", "BYYEARDAY"]; + for (var p in rules) { + /* istanbul ignore else */ + if (rules.hasOwnProperty(p)) { + var part = rules[p]; + if (part in this.rule.parts) { + parts[part] = this.rule.parts[part]; + } + } + } + + if ("BYMONTH" in parts && "BYWEEKNO" in parts) { + var valid = 1; + var validWeeks = {}; + t.year = aYear; + t.isDate = true; + + for (var monthIdx = 0; monthIdx < this.by_data.BYMONTH.length; monthIdx++) { + var month = this.by_data.BYMONTH[monthIdx]; + t.month = month; + t.day = 1; + var first_week = t.weekNumber(this.rule.wkst); + t.day = ICAL.Time.daysInMonth(month, aYear); + var last_week = t.weekNumber(this.rule.wkst); + for (monthIdx = first_week; monthIdx < last_week; monthIdx++) { + validWeeks[monthIdx] = 1; + } + } + + for (var weekIdx = 0; weekIdx < this.by_data.BYWEEKNO.length && valid; weekIdx++) { + var weekno = this.by_data.BYWEEKNO[weekIdx]; + if (weekno < 52) { + valid &= validWeeks[weekIdx]; + } else { + valid = 0; + } + } + + if (valid) { + delete parts.BYMONTH; + } else { + delete parts.BYWEEKNO; + } + } + + var partCount = Object.keys(parts).length; + + if (partCount == 0) { + var t1 = this.dtstart.clone(); + t1.year = this.last.year; + this.days.push(t1.dayOfYear()); + } else if (partCount == 1 && "BYMONTH" in parts) { + for (var monthkey in this.by_data.BYMONTH) { + /* istanbul ignore if */ + if (!this.by_data.BYMONTH.hasOwnProperty(monthkey)) { + continue; + } + var t2 = this.dtstart.clone(); + t2.year = aYear; + t2.month = this.by_data.BYMONTH[monthkey]; + t2.isDate = true; + this.days.push(t2.dayOfYear()); + } + } else if (partCount == 1 && "BYMONTHDAY" in parts) { + for (var monthdaykey in this.by_data.BYMONTHDAY) { + /* istanbul ignore if */ + if (!this.by_data.BYMONTHDAY.hasOwnProperty(monthdaykey)) { + continue; + } + var t3 = this.dtstart.clone(); + var day_ = this.by_data.BYMONTHDAY[monthdaykey]; + if (day_ < 0) { + var daysInMonth = ICAL.Time.daysInMonth(t3.month, aYear); + day_ = day_ + daysInMonth + 1; + } + t3.day = day_; + t3.year = aYear; + t3.isDate = true; + this.days.push(t3.dayOfYear()); + } + } else if (partCount == 2 && + "BYMONTHDAY" in parts && + "BYMONTH" in parts) { + for (var monthkey in this.by_data.BYMONTH) { + /* istanbul ignore if */ + if (!this.by_data.BYMONTH.hasOwnProperty(monthkey)) { + continue; + } + var month_ = this.by_data.BYMONTH[monthkey]; + var daysInMonth = ICAL.Time.daysInMonth(month_, aYear); + for (var monthdaykey in this.by_data.BYMONTHDAY) { + /* istanbul ignore if */ + if (!this.by_data.BYMONTHDAY.hasOwnProperty(monthdaykey)) { + continue; + } + var day_ = this.by_data.BYMONTHDAY[monthdaykey]; + if (day_ < 0) { + day_ = day_ + daysInMonth + 1; + } + t.day = day_; + t.month = month_; + t.year = aYear; + t.isDate = true; + + this.days.push(t.dayOfYear()); + } + } + } else if (partCount == 1 && "BYWEEKNO" in parts) { + // TODO unimplemented in libical + } else if (partCount == 2 && + "BYWEEKNO" in parts && + "BYMONTHDAY" in parts) { + // TODO unimplemented in libical + } else if (partCount == 1 && "BYDAY" in parts) { + this.days = this.days.concat(this.expand_by_day(aYear)); + } else if (partCount == 2 && "BYDAY" in parts && "BYMONTH" in parts) { + for (var monthkey in this.by_data.BYMONTH) { + /* istanbul ignore if */ + if (!this.by_data.BYMONTH.hasOwnProperty(monthkey)) { + continue; + } + var month = this.by_data.BYMONTH[monthkey]; + var daysInMonth = ICAL.Time.daysInMonth(month, aYear); + + t.year = aYear; + t.month = this.by_data.BYMONTH[monthkey]; + t.day = 1; + t.isDate = true; + + var first_dow = t.dayOfWeek(); + var doy_offset = t.dayOfYear() - 1; + + t.day = daysInMonth; + var last_dow = t.dayOfWeek(); + + if (this.has_by_data("BYSETPOS")) { + var set_pos_counter = 0; + var by_month_day = []; + for (var day = 1; day <= daysInMonth; day++) { + t.day = day; + if (this.is_day_in_byday(t)) { + by_month_day.push(day); + } + } + + for (var spIndex = 0; spIndex < by_month_day.length; spIndex++) { + if (this.check_set_position(spIndex + 1) || + this.check_set_position(spIndex - by_month_day.length)) { + this.days.push(doy_offset + by_month_day[spIndex]); + } + } + } else { + for (var daycodedkey in this.by_data.BYDAY) { + /* istanbul ignore if */ + if (!this.by_data.BYDAY.hasOwnProperty(daycodedkey)) { + continue; + } + var coded_day = this.by_data.BYDAY[daycodedkey]; + var bydayParts = this.ruleDayOfWeek(coded_day); + var pos = bydayParts[0]; + var dow = bydayParts[1]; + var month_day; + + var first_matching_day = ((dow + 7 - first_dow) % 7) + 1; + var last_matching_day = daysInMonth - ((last_dow + 7 - dow) % 7); + + if (pos == 0) { + for (var day = first_matching_day; day <= daysInMonth; day += 7) { + this.days.push(doy_offset + day); + } + } else if (pos > 0) { + month_day = first_matching_day + (pos - 1) * 7; + + if (month_day <= daysInMonth) { + this.days.push(doy_offset + month_day); + } + } else { + month_day = last_matching_day + (pos + 1) * 7; + + if (month_day > 0) { + this.days.push(doy_offset + month_day); + } + } + } + } + } + // Return dates in order of occurrence (1,2,3,...) instead + // of by groups of weekdays (1,8,15,...,2,9,16,...). + this.days.sort(function(a, b) { return a - b; }); // Comparator function allows to sort numbers. + } else if (partCount == 2 && "BYDAY" in parts && "BYMONTHDAY" in parts) { + var expandedDays = this.expand_by_day(aYear); + + for (var daykey in expandedDays) { + /* istanbul ignore if */ + if (!expandedDays.hasOwnProperty(daykey)) { + continue; + } + var day = expandedDays[daykey]; + var tt = ICAL.Time.fromDayOfYear(day, aYear); + if (this.by_data.BYMONTHDAY.indexOf(tt.day) >= 0) { + this.days.push(day); + } + } + } else if (partCount == 3 && + "BYDAY" in parts && + "BYMONTHDAY" in parts && + "BYMONTH" in parts) { + var expandedDays = this.expand_by_day(aYear); + + for (var daykey in expandedDays) { + /* istanbul ignore if */ + if (!expandedDays.hasOwnProperty(daykey)) { + continue; + } + var day = expandedDays[daykey]; + var tt = ICAL.Time.fromDayOfYear(day, aYear); + + if (this.by_data.BYMONTH.indexOf(tt.month) >= 0 && + this.by_data.BYMONTHDAY.indexOf(tt.day) >= 0) { + this.days.push(day); + } + } + } else if (partCount == 2 && "BYDAY" in parts && "BYWEEKNO" in parts) { + var expandedDays = this.expand_by_day(aYear); + + for (var daykey in expandedDays) { + /* istanbul ignore if */ + if (!expandedDays.hasOwnProperty(daykey)) { + continue; + } + var day = expandedDays[daykey]; + var tt = ICAL.Time.fromDayOfYear(day, aYear); + var weekno = tt.weekNumber(this.rule.wkst); + + if (this.by_data.BYWEEKNO.indexOf(weekno)) { + this.days.push(day); + } + } + } else if (partCount == 3 && + "BYDAY" in parts && + "BYWEEKNO" in parts && + "BYMONTHDAY" in parts) { + // TODO unimplemted in libical + } else if (partCount == 1 && "BYYEARDAY" in parts) { + this.days = this.days.concat(this.by_data.BYYEARDAY); + } else { + this.days = []; + } + return 0; + }, + + expand_by_day: function expand_by_day(aYear) { + + var days_list = []; + var tmp = this.last.clone(); + + tmp.year = aYear; + tmp.month = 1; + tmp.day = 1; + tmp.isDate = true; + + var start_dow = tmp.dayOfWeek(); + + tmp.month = 12; + tmp.day = 31; + tmp.isDate = true; + + var end_dow = tmp.dayOfWeek(); + var end_year_day = tmp.dayOfYear(); + + for (var daykey in this.by_data.BYDAY) { + /* istanbul ignore if */ + if (!this.by_data.BYDAY.hasOwnProperty(daykey)) { + continue; + } + var day = this.by_data.BYDAY[daykey]; + var parts = this.ruleDayOfWeek(day); + var pos = parts[0]; + var dow = parts[1]; + + if (pos == 0) { + var tmp_start_doy = ((dow + 7 - start_dow) % 7) + 1; + + for (var doy = tmp_start_doy; doy <= end_year_day; doy += 7) { + days_list.push(doy); + } + + } else if (pos > 0) { + var first; + if (dow >= start_dow) { + first = dow - start_dow + 1; + } else { + first = dow - start_dow + 8; + } + + days_list.push(first + (pos - 1) * 7); + } else { + var last; + pos = -pos; + + if (dow <= end_dow) { + last = end_year_day - end_dow + dow; + } else { + last = end_year_day - end_dow + dow - 7; + } + + days_list.push(last - (pos - 1) * 7); + } + } + return days_list; + }, + + is_day_in_byday: function is_day_in_byday(tt) { + for (var daykey in this.by_data.BYDAY) { + /* istanbul ignore if */ + if (!this.by_data.BYDAY.hasOwnProperty(daykey)) { + continue; + } + var day = this.by_data.BYDAY[daykey]; + var parts = this.ruleDayOfWeek(day); + var pos = parts[0]; + var dow = parts[1]; + var this_dow = tt.dayOfWeek(); + + if ((pos == 0 && dow == this_dow) || + (tt.nthWeekDay(dow, pos) == tt.day)) { + return 1; + } + } + + return 0; + }, + + /** + * Checks if given value is in BYSETPOS. + * + * @private + * @param {Numeric} aPos position to check for. + * @return {Boolean} false unless BYSETPOS rules exist + * and the given value is present in rules. + */ + check_set_position: function check_set_position(aPos) { + if (this.has_by_data('BYSETPOS')) { + var idx = this.by_data.BYSETPOS.indexOf(aPos); + // negative numbers are not false-y + return idx !== -1; + } + return false; + }, + + sort_byday_rules: function icalrecur_sort_byday_rules(aRules, aWeekStart) { + for (var i = 0; i < aRules.length; i++) { + for (var j = 0; j < i; j++) { + var one = this.ruleDayOfWeek(aRules[j])[1]; + var two = this.ruleDayOfWeek(aRules[i])[1]; + one -= aWeekStart; + two -= aWeekStart; + if (one < 0) one += 7; + if (two < 0) two += 7; + + if (one > two) { + var tmp = aRules[i]; + aRules[i] = aRules[j]; + aRules[j] = tmp; + } + } + } + }, + + check_contract_restriction: function check_contract_restriction(aRuleType, v) { + var indexMapValue = icalrecur_iterator._indexMap[aRuleType]; + var ruleMapValue = icalrecur_iterator._expandMap[this.rule.freq][indexMapValue]; + var pass = false; + + if (aRuleType in this.by_data && + ruleMapValue == icalrecur_iterator.CONTRACT) { + + var ruleType = this.by_data[aRuleType]; + + for (var bydatakey in ruleType) { + /* istanbul ignore else */ + if (ruleType.hasOwnProperty(bydatakey)) { + if (ruleType[bydatakey] == v) { + pass = true; + break; + } + } + } + } else { + // Not a contracting byrule or has no data, test passes + pass = true; + } + return pass; + }, + + check_contracting_rules: function check_contracting_rules() { + var dow = this.last.dayOfWeek(); + var weekNo = this.last.weekNumber(this.rule.wkst); + var doy = this.last.dayOfYear(); + + return (this.check_contract_restriction("BYSECOND", this.last.second) && + this.check_contract_restriction("BYMINUTE", this.last.minute) && + this.check_contract_restriction("BYHOUR", this.last.hour) && + this.check_contract_restriction("BYDAY", ICAL.Recur.numericDayToIcalDay(dow)) && + this.check_contract_restriction("BYWEEKNO", weekNo) && + this.check_contract_restriction("BYMONTHDAY", this.last.day) && + this.check_contract_restriction("BYMONTH", this.last.month) && + this.check_contract_restriction("BYYEARDAY", doy)); + }, + + setup_defaults: function setup_defaults(aRuleType, req, deftime) { + var indexMapValue = icalrecur_iterator._indexMap[aRuleType]; + var ruleMapValue = icalrecur_iterator._expandMap[this.rule.freq][indexMapValue]; + + if (ruleMapValue != icalrecur_iterator.CONTRACT) { + if (!(aRuleType in this.by_data)) { + this.by_data[aRuleType] = [deftime]; + } + if (this.rule.freq != req) { + return this.by_data[aRuleType][0]; + } + } + return deftime; + }, + + /** + * Convert iterator into a serialize-able object. Will preserve current + * iteration sequence to ensure the seamless continuation of the recurrence + * rule. + * @return {Object} + */ + toJSON: function() { + var result = Object.create(null); + + result.initialized = this.initialized; + result.rule = this.rule.toJSON(); + result.dtstart = this.dtstart.toJSON(); + result.by_data = this.by_data; + result.days = this.days; + result.last = this.last.toJSON(); + result.by_indices = this.by_indices; + result.occurrence_number = this.occurrence_number; + + return result; + } + }; + + icalrecur_iterator._indexMap = { + "BYSECOND": 0, + "BYMINUTE": 1, + "BYHOUR": 2, + "BYDAY": 3, + "BYMONTHDAY": 4, + "BYYEARDAY": 5, + "BYWEEKNO": 6, + "BYMONTH": 7, + "BYSETPOS": 8 + }; + + icalrecur_iterator._expandMap = { + "SECONDLY": [1, 1, 1, 1, 1, 1, 1, 1], + "MINUTELY": [2, 1, 1, 1, 1, 1, 1, 1], + "HOURLY": [2, 2, 1, 1, 1, 1, 1, 1], + "DAILY": [2, 2, 2, 1, 1, 1, 1, 1], + "WEEKLY": [2, 2, 2, 2, 3, 3, 1, 1], + "MONTHLY": [2, 2, 2, 2, 2, 3, 3, 1], + "YEARLY": [2, 2, 2, 2, 2, 2, 2, 2] + }; + icalrecur_iterator.UNKNOWN = 0; + icalrecur_iterator.CONTRACT = 1; + icalrecur_iterator.EXPAND = 2; + icalrecur_iterator.ILLEGAL = 3; + + return icalrecur_iterator; + +}()); +/* 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/. + * Portions Copyright (C) Philipp Kewisch, 2011-2015 */ + + +/** + * This symbol is further described later on + * @ignore + */ +ICAL.RecurExpansion = (function() { + function formatTime(item) { + return ICAL.helpers.formatClassType(item, ICAL.Time); + } + + function compareTime(a, b) { + return a.compare(b); + } + + function isRecurringComponent(comp) { + return comp.hasProperty('rdate') || + comp.hasProperty('rrule') || + comp.hasProperty('recurrence-id'); + } + + /** + * @classdesc + * Primary class for expanding recurring rules. Can take multiple rrules, + * rdates, exdate(s) and iterate (in order) over each next occurrence. + * + * Once initialized this class can also be serialized saved and continue + * iteration from the last point. + * + * NOTE: it is intended that this class is to be used + * with ICAL.Event which handles recurrence exceptions. + * + * @example + * // assuming event is a parsed ical component + * var event; + * + * var expand = new ICAL.RecurExpansion({ + * component: event, + * dtstart: event.getFirstPropertyValue('dtstart') + * }); + * + * // remember there are infinite rules + * // so its a good idea to limit the scope + * // of the iterations then resume later on. + * + * // next is always an ICAL.Time or null + * var next; + * + * while (someCondition && (next = expand.next())) { + * // do something with next + * } + * + * // save instance for later + * var json = JSON.stringify(expand); + * + * //... + * + * // NOTE: if the component's properties have + * // changed you will need to rebuild the + * // class and start over. This only works + * // when the component's recurrence info is the same. + * var expand = new ICAL.RecurExpansion(JSON.parse(json)); + * + * @description + * The options object can be filled with the specified initial values. It can + * also contain additional members, as a result of serializing a previous + * expansion state, as shown in the example. + * + * @class + * @alias ICAL.RecurExpansion + * @param {Object} options + * Recurrence expansion options + * @param {ICAL.Time} options.dtstart + * Start time of the event + * @param {ICAL.Component=} options.component + * Component for expansion, required if not resuming. + */ + function RecurExpansion(options) { + this.ruleDates = []; + this.exDates = []; + this.fromData(options); + } + + RecurExpansion.prototype = { + /** + * True when iteration is fully completed. + * @type {Boolean} + */ + complete: false, + + /** + * Array of rrule iterators. + * + * @type {ICAL.RecurIterator[]} + * @private + */ + ruleIterators: null, + + /** + * Array of rdate instances. + * + * @type {ICAL.Time[]} + * @private + */ + ruleDates: null, + + /** + * Array of exdate instances. + * + * @type {ICAL.Time[]} + * @private + */ + exDates: null, + + /** + * Current position in ruleDates array. + * @type {Number} + * @private + */ + ruleDateInc: 0, + + /** + * Current position in exDates array + * @type {Number} + * @private + */ + exDateInc: 0, + + /** + * Current negative date. + * + * @type {ICAL.Time} + * @private + */ + exDate: null, + + /** + * Current additional date. + * + * @type {ICAL.Time} + * @private + */ + ruleDate: null, + + /** + * Start date of recurring rules. + * + * @type {ICAL.Time} + */ + dtstart: null, + + /** + * Last expanded time + * + * @type {ICAL.Time} + */ + last: null, + + /** + * Initialize the recurrence expansion from the data object. The options + * object may also contain additional members, see the + * {@link ICAL.RecurExpansion constructor} for more details. + * + * @param {Object} options + * Recurrence expansion options + * @param {ICAL.Time} options.dtstart + * Start time of the event + * @param {ICAL.Component=} options.component + * Component for expansion, required if not resuming. + */ + fromData: function(options) { + var start = ICAL.helpers.formatClassType(options.dtstart, ICAL.Time); + + if (!start) { + throw new Error('.dtstart (ICAL.Time) must be given'); + } else { + this.dtstart = start; + } + + if (options.component) { + this._init(options.component); + } else { + this.last = formatTime(options.last) || start.clone(); + + if (!options.ruleIterators) { + throw new Error('.ruleIterators or .component must be given'); + } + + this.ruleIterators = options.ruleIterators.map(function(item) { + return ICAL.helpers.formatClassType(item, ICAL.RecurIterator); + }); + + this.ruleDateInc = options.ruleDateInc; + this.exDateInc = options.exDateInc; + + if (options.ruleDates) { + this.ruleDates = options.ruleDates.map(formatTime); + this.ruleDate = this.ruleDates[this.ruleDateInc]; + } + + if (options.exDates) { + this.exDates = options.exDates.map(formatTime); + this.exDate = this.exDates[this.exDateInc]; + } + + if (typeof(options.complete) !== 'undefined') { + this.complete = options.complete; + } + } + }, + + /** + * Retrieve the next occurrence in the series. + * @return {ICAL.Time} + */ + next: function() { + var iter; + var ruleOfDay; + var next; + var compare; + + var maxTries = 500; + var currentTry = 0; + + while (true) { + if (currentTry++ > maxTries) { + throw new Error( + 'max tries have occured, rule may be impossible to forfill.' + ); + } + + next = this.ruleDate; + iter = this._nextRecurrenceIter(this.last); + + // no more matches + // because we increment the rule day or rule + // _after_ we choose a value this should be + // the only spot where we need to worry about the + // end of events. + if (!next && !iter) { + // there are no more iterators or rdates + this.complete = true; + break; + } + + // no next rule day or recurrence rule is first. + if (!next || (iter && next.compare(iter.last) > 0)) { + // must be cloned, recur will reuse the time element. + next = iter.last.clone(); + // move to next so we can continue + iter.next(); + } + + // if the ruleDate is still next increment it. + if (this.ruleDate === next) { + this._nextRuleDay(); + } + + this.last = next; + + // check the negative rules + if (this.exDate) { + compare = this.exDate.compare(this.last); + + if (compare < 0) { + this._nextExDay(); + } + + // if the current rule is excluded skip it. + if (compare === 0) { + this._nextExDay(); + continue; + } + } + + //XXX: The spec states that after we resolve the final + // list of dates we execute exdate this seems somewhat counter + // intuitive to what I have seen most servers do so for now + // I exclude based on the original date not the one that may + // have been modified by the exception. + return this.last; + } + }, + + /** + * Converts object into a serialize-able format. This format can be passed + * back into the expansion to resume iteration. + * @return {Object} + */ + toJSON: function() { + function toJSON(item) { + return item.toJSON(); + } + + var result = Object.create(null); + result.ruleIterators = this.ruleIterators.map(toJSON); + + if (this.ruleDates) { + result.ruleDates = this.ruleDates.map(toJSON); + } + + if (this.exDates) { + result.exDates = this.exDates.map(toJSON); + } + + result.ruleDateInc = this.ruleDateInc; + result.exDateInc = this.exDateInc; + result.last = this.last.toJSON(); + result.dtstart = this.dtstart.toJSON(); + result.complete = this.complete; + + return result; + }, + + /** + * Extract all dates from the properties in the given component. The + * properties will be filtered by the property name. + * + * @private + * @param {ICAL.Component} component The component to search in + * @param {String} propertyName The property name to search for + * @return {ICAL.Time[]} The extracted dates. + */ + _extractDates: function(component, propertyName) { + function handleProp(prop) { + idx = ICAL.helpers.binsearchInsert( + result, + prop, + compareTime + ); + + // ordered insert + result.splice(idx, 0, prop); + } + + var result = []; + var props = component.getAllProperties(propertyName); + var len = props.length; + var i = 0; + var prop; + + var idx; + + for (; i < len; i++) { + props[i].getValues().forEach(handleProp); + } + + return result; + }, + + /** + * Initialize the recurrence expansion. + * + * @private + * @param {ICAL.Component} component The component to initialize from. + */ + _init: function(component) { + this.ruleIterators = []; + + this.last = this.dtstart.clone(); + + // to provide api consistency non-recurring + // events can also use the iterator though it will + // only return a single time. + if (!isRecurringComponent(component)) { + this.ruleDate = this.last.clone(); + this.complete = true; + return; + } + + if (component.hasProperty('rdate')) { + this.ruleDates = this._extractDates(component, 'rdate'); + + // special hack for cases where first rdate is prior + // to the start date. We only check for the first rdate. + // This is mostly for google's crazy recurring date logic + // (contacts birthdays). + if ((this.ruleDates[0]) && + (this.ruleDates[0].compare(this.dtstart) < 0)) { + + this.ruleDateInc = 0; + this.last = this.ruleDates[0].clone(); + } else { + this.ruleDateInc = ICAL.helpers.binsearchInsert( + this.ruleDates, + this.last, + compareTime + ); + } + + this.ruleDate = this.ruleDates[this.ruleDateInc]; + } + + if (component.hasProperty('rrule')) { + var rules = component.getAllProperties('rrule'); + var i = 0; + var len = rules.length; + + var rule; + var iter; + + for (; i < len; i++) { + rule = rules[i].getFirstValue(); + iter = rule.iterator(this.dtstart); + this.ruleIterators.push(iter); + + // increment to the next occurrence so future + // calls to next return times beyond the initial iteration. + // XXX: I find this suspicious might be a bug? + iter.next(); + } + } + + if (component.hasProperty('exdate')) { + this.exDates = this._extractDates(component, 'exdate'); + // if we have a .last day we increment the index to beyond it. + this.exDateInc = ICAL.helpers.binsearchInsert( + this.exDates, + this.last, + compareTime + ); + + this.exDate = this.exDates[this.exDateInc]; + } + }, + + /** + * Advance to the next exdate + * @private + */ + _nextExDay: function() { + this.exDate = this.exDates[++this.exDateInc]; + }, + + /** + * Advance to the next rule date + * @private + */ + _nextRuleDay: function() { + this.ruleDate = this.ruleDates[++this.ruleDateInc]; + }, + + /** + * Find and return the recurrence rule with the most recent event and + * return it. + * + * @private + * @return {?ICAL.RecurIterator} Found iterator. + */ + _nextRecurrenceIter: function() { + var iters = this.ruleIterators; + + if (iters.length === 0) { + return null; + } + + var len = iters.length; + var iter; + var iterTime; + var iterIdx = 0; + var chosenIter; + + // loop through each iterator + for (; iterIdx < len; iterIdx++) { + iter = iters[iterIdx]; + iterTime = iter.last; + + // if iteration is complete + // then we must exclude it from + // the search and remove it. + if (iter.completed) { + len--; + if (iterIdx !== 0) { + iterIdx--; + } + iters.splice(iterIdx, 1); + continue; + } + + // find the most recent possible choice + if (!chosenIter || chosenIter.last.compare(iterTime) > 0) { + // that iterator is saved + chosenIter = iter; + } + } + + // the chosen iterator is returned but not mutated + // this iterator contains the most recent event. + return chosenIter; + } + }; + + return RecurExpansion; +}()); +/* 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/. + * Portions Copyright (C) Philipp Kewisch, 2011-2015 */ + + +/** + * This symbol is further described later on + * @ignore + */ +ICAL.Event = (function() { + + /** + * @classdesc + * ICAL.js is organized into multiple layers. The bottom layer is a raw jCal + * object, followed by the component/property layer. The highest level is the + * event representation, which this class is part of. See the + * {@tutorial layers} guide for more details. + * + * @class + * @alias ICAL.Event + * @param {ICAL.Component=} component The ICAL.Component to base this event on + * @param {Object} options Options for this event + * @param {Boolean} options.strictExceptions + * When true, will verify exceptions are related by their UUID + * @param {Array<ICAL.Component|ICAL.Event>} options.exceptions + * Exceptions to this event, either as components or events + */ + function Event(component, options) { + if (!(component instanceof ICAL.Component)) { + options = component; + component = null; + } + + if (component) { + this.component = component; + } else { + this.component = new ICAL.Component('vevent'); + } + + this._rangeExceptionCache = Object.create(null); + this.exceptions = Object.create(null); + this.rangeExceptions = []; + + if (options && options.strictExceptions) { + this.strictExceptions = options.strictExceptions; + } + + if (options && options.exceptions) { + options.exceptions.forEach(this.relateException, this); + } + } + + Event.prototype = { + + THISANDFUTURE: 'THISANDFUTURE', + + /** + * List of related event exceptions. + * + * @type {ICAL.Event[]} + */ + exceptions: null, + + /** + * When true, will verify exceptions are related by their UUID. + * + * @type {Boolean} + */ + strictExceptions: false, + + /** + * Relates a given event exception to this object. If the given component + * does not share the UID of this event it cannot be related and will throw + * an exception. + * + * If this component is an exception it cannot have other exceptions + * related to it. + * + * @param {ICAL.Component|ICAL.Event} obj Component or event + */ + relateException: function(obj) { + if (this.isRecurrenceException()) { + throw new Error('cannot relate exception to exceptions'); + } + + if (obj instanceof ICAL.Component) { + obj = new ICAL.Event(obj); + } + + if (this.strictExceptions && obj.uid !== this.uid) { + throw new Error('attempted to relate unrelated exception'); + } + + var id = obj.recurrenceId.toString(); + + // we don't sort or manage exceptions directly + // here the recurrence expander handles that. + this.exceptions[id] = obj; + + // index RANGE=THISANDFUTURE exceptions so we can + // look them up later in getOccurrenceDetails. + if (obj.modifiesFuture()) { + var item = [ + obj.recurrenceId.toUnixTime(), id + ]; + + // we keep them sorted so we can find the nearest + // value later on... + var idx = ICAL.helpers.binsearchInsert( + this.rangeExceptions, + item, + compareRangeException + ); + + this.rangeExceptions.splice(idx, 0, item); + } + }, + + /** + * Checks if this record is an exception and has the RANGE=THISANDFUTURE + * value. + * + * @return {Boolean} True, when exception is within range + */ + modifiesFuture: function() { + var range = this.component.getFirstPropertyValue('range'); + return range === this.THISANDFUTURE; + }, + + /** + * Finds the range exception nearest to the given date. + * + * @param {ICAL.Time} time usually an occurrence time of an event + * @return {?ICAL.Event} the related event/exception or null + */ + findRangeException: function(time) { + if (!this.rangeExceptions.length) { + return null; + } + + var utc = time.toUnixTime(); + var idx = ICAL.helpers.binsearchInsert( + this.rangeExceptions, + [utc], + compareRangeException + ); + + idx -= 1; + + // occurs before + if (idx < 0) { + return null; + } + + var rangeItem = this.rangeExceptions[idx]; + + /* istanbul ignore next: sanity check only */ + if (utc < rangeItem[0]) { + return null; + } + + return rangeItem[1]; + }, + + /** + * This object is returned by {@link ICAL.Event#getOccurrenceDetails getOccurrenceDetails} + * + * @typedef {Object} occurrenceDetails + * @memberof ICAL.Event + * @property {ICAL.Time} recurrenceId The passed in recurrence id + * @property {ICAL.Event} item The occurrence + * @property {ICAL.Time} startDate The start of the occurrence + * @property {ICAL.Time} endDate The end of the occurrence + */ + + /** + * Returns the occurrence details based on its start time. If the + * occurrence has an exception will return the details for that exception. + * + * NOTE: this method is intend to be used in conjunction + * with the {@link ICAL.Event#iterator iterator} method. + * + * @param {ICAL.Time} occurrence time occurrence + * @return {ICAL.Event.occurrenceDetails} Information about the occurrence + */ + getOccurrenceDetails: function(occurrence) { + var id = occurrence.toString(); + var utcId = occurrence.convertToZone(ICAL.Timezone.utcTimezone).toString(); + var item; + var result = { + //XXX: Clone? + recurrenceId: occurrence + }; + + if (id in this.exceptions) { + item = result.item = this.exceptions[id]; + result.startDate = item.startDate; + result.endDate = item.endDate; + result.item = item; + } else if (utcId in this.exceptions) { + item = this.exceptions[utcId]; + result.startDate = item.startDate; + result.endDate = item.endDate; + result.item = item; + } else { + // range exceptions (RANGE=THISANDFUTURE) have a + // lower priority then direct exceptions but + // must be accounted for first. Their item is + // always the first exception with the range prop. + var rangeExceptionId = this.findRangeException( + occurrence + ); + var end; + + if (rangeExceptionId) { + var exception = this.exceptions[rangeExceptionId]; + + // range exception must modify standard time + // by the difference (if any) in start/end times. + result.item = exception; + + var startDiff = this._rangeExceptionCache[rangeExceptionId]; + + if (!startDiff) { + var original = exception.recurrenceId.clone(); + var newStart = exception.startDate.clone(); + + // zones must be same otherwise subtract may be incorrect. + original.zone = newStart.zone; + startDiff = newStart.subtractDate(original); + + this._rangeExceptionCache[rangeExceptionId] = startDiff; + } + + var start = occurrence.clone(); + start.zone = exception.startDate.zone; + start.addDuration(startDiff); + + end = start.clone(); + end.addDuration(exception.duration); + + result.startDate = start; + result.endDate = end; + } else { + // no range exception standard expansion + end = occurrence.clone(); + end.addDuration(this.duration); + + result.endDate = end; + result.startDate = occurrence; + result.item = this; + } + } + + return result; + }, + + /** + * Builds a recur expansion instance for a specific point in time (defaults + * to startDate). + * + * @param {ICAL.Time} startTime Starting point for expansion + * @return {ICAL.RecurExpansion} Expansion object + */ + iterator: function(startTime) { + return new ICAL.RecurExpansion({ + component: this.component, + dtstart: startTime || this.startDate + }); + }, + + /** + * Checks if the event is recurring + * + * @return {Boolean} True, if event is recurring + */ + isRecurring: function() { + var comp = this.component; + return comp.hasProperty('rrule') || comp.hasProperty('rdate'); + }, + + /** + * Checks if the event describes a recurrence exception. See + * {@tutorial terminology} for details. + * + * @return {Boolean} True, if the even describes a recurrence exception + */ + isRecurrenceException: function() { + return this.component.hasProperty('recurrence-id'); + }, + + /** + * Returns the types of recurrences this event may have. + * + * Returned as an object with the following possible keys: + * + * - YEARLY + * - MONTHLY + * - WEEKLY + * - DAILY + * - MINUTELY + * - SECONDLY + * + * @return {Object.<ICAL.Recur.frequencyValues, Boolean>} + * Object of recurrence flags + */ + getRecurrenceTypes: function() { + var rules = this.component.getAllProperties('rrule'); + var i = 0; + var len = rules.length; + var result = Object.create(null); + + for (; i < len; i++) { + var value = rules[i].getFirstValue(); + result[value.freq] = true; + } + + return result; + }, + + /** + * The uid of this event + * @type {String} + */ + get uid() { + return this._firstProp('uid'); + }, + + set uid(value) { + this._setProp('uid', value); + }, + + /** + * The start date + * @type {ICAL.Time} + */ + get startDate() { + return this._firstProp('dtstart'); + }, + + set startDate(value) { + this._setTime('dtstart', value); + }, + + /** + * The end date. This can be the result directly from the property, or the + * end date calculated from start date and duration. + * @type {ICAL.Time} + */ + get endDate() { + var endDate = this._firstProp('dtend'); + if (!endDate) { + var duration = this._firstProp('duration'); + endDate = this.startDate.clone(); + if (duration) { + endDate.addDuration(duration); + } else if (endDate.isDate) { + endDate.day += 1; + } + } + return endDate; + }, + + set endDate(value) { + this._setTime('dtend', value); + }, + + /** + * The duration. This can be the result directly from the property, or the + * duration calculated from start date and end date. + * @type {ICAL.Duration} + * @readonly + */ + get duration() { + var duration = this._firstProp('duration'); + if (!duration) { + return this.endDate.subtractDate(this.startDate); + } + return duration; + }, + + /** + * The location of the event. + * @type {String} + */ + get location() { + return this._firstProp('location'); + }, + + set location(value) { + return this._setProp('location', value); + }, + + /** + * The attendees in the event + * @type {ICAL.Property[]} + * @readonly + */ + get attendees() { + //XXX: This is way lame we should have a better + // data structure for this later. + return this.component.getAllProperties('attendee'); + }, + + + /** + * The event summary + * @type {String} + */ + get summary() { + return this._firstProp('summary'); + }, + + set summary(value) { + this._setProp('summary', value); + }, + + /** + * The event description. + * @type {String} + */ + get description() { + return this._firstProp('description'); + }, + + set description(value) { + this._setProp('description', value); + }, + + /** + * The organizer value as an uri. In most cases this is a mailto: uri, but + * it can also be something else, like urn:uuid:... + * @type {String} + */ + get organizer() { + return this._firstProp('organizer'); + }, + + set organizer(value) { + this._setProp('organizer', value); + }, + + /** + * The sequence value for this event. Used for scheduling + * see {@tutorial terminology}. + * @type {Number} + */ + get sequence() { + return this._firstProp('sequence'); + }, + + set sequence(value) { + this._setProp('sequence', value); + }, + + /** + * The recurrence id for this event. See {@tutorial terminology} for details. + * @type {ICAL.Time} + */ + get recurrenceId() { + return this._firstProp('recurrence-id'); + }, + + set recurrenceId(value) { + this._setProp('recurrence-id', value); + }, + + /** + * Set/update a time property's value. + * This will also update the TZID of the property. + * + * TODO: this method handles the case where we are switching + * from a known timezone to an implied timezone (one without TZID). + * This does _not_ handle the case of moving between a known + * (by TimezoneService) timezone to an unknown timezone... + * + * We will not add/remove/update the VTIMEZONE subcomponents + * leading to invalid ICAL data... + * @private + * @param {String} propName The property name + * @param {ICAL.Time} time The time to set + */ + _setTime: function(propName, time) { + var prop = this.component.getFirstProperty(propName); + + if (!prop) { + prop = new ICAL.Property(propName); + this.component.addProperty(prop); + } + + // utc and local don't get a tzid + if ( + time.zone === ICAL.Timezone.localTimezone || + time.zone === ICAL.Timezone.utcTimezone + ) { + // remove the tzid + prop.removeParameter('tzid'); + } else { + prop.setParameter('tzid', time.zone.tzid); + } + + prop.setValue(time); + }, + + _setProp: function(name, value) { + this.component.updatePropertyWithValue(name, value); + }, + + _firstProp: function(name) { + return this.component.getFirstPropertyValue(name); + }, + + /** + * The string representation of this event. + * @return {String} + */ + toString: function() { + return this.component.toString(); + } + + }; + + function compareRangeException(a, b) { + if (a[0] > b[0]) return 1; + if (b[0] > a[0]) return -1; + return 0; + } + + return Event; +}()); +/* 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/. + * Portions Copyright (C) Philipp Kewisch, 2011-2015 */ + + +/** + * This symbol is further described later on + * @ignore + */ +ICAL.ComponentParser = (function() { + /** + * @classdesc + * The ComponentParser is used to process a String or jCal Object, + * firing callbacks for various found components, as well as completion. + * + * @example + * var options = { + * // when false no events will be emitted for type + * parseEvent: true, + * parseTimezone: true + * }; + * + * var parser = new ICAL.ComponentParser(options); + * + * parser.onevent(eventComponent) { + * //... + * } + * + * // ontimezone, etc... + * + * parser.oncomplete = function() { + * + * }; + * + * parser.process(stringOrComponent); + * + * @class + * @alias ICAL.ComponentParser + * @param {Object=} options Component parser options + * @param {Boolean} options.parseEvent Whether events should be parsed + * @param {Boolean} options.parseTimezeone Whether timezones should be parsed + */ + function ComponentParser(options) { + if (typeof(options) === 'undefined') { + options = {}; + } + + var key; + for (key in options) { + /* istanbul ignore else */ + if (options.hasOwnProperty(key)) { + this[key] = options[key]; + } + } + } + + ComponentParser.prototype = { + + /** + * When true, parse events + * + * @type {Boolean} + */ + parseEvent: true, + + /** + * When true, parse timezones + * + * @type {Boolean} + */ + parseTimezone: true, + + + /* SAX like events here for reference */ + + /** + * Fired when parsing is complete + * @callback + */ + oncomplete: /* istanbul ignore next */ function() {}, + + /** + * Fired if an error occurs during parsing. + * + * @callback + * @param {Error} err details of error + */ + onerror: /* istanbul ignore next */ function(err) {}, + + /** + * Fired when a top level component (VTIMEZONE) is found + * + * @callback + * @param {ICAL.Timezone} component Timezone object + */ + ontimezone: /* istanbul ignore next */ function(component) {}, + + /** + * Fired when a top level component (VEVENT) is found. + * + * @callback + * @param {ICAL.Event} component Top level component + */ + onevent: /* istanbul ignore next */ function(component) {}, + + /** + * Process a string or parse ical object. This function itself will return + * nothing but will start the parsing process. + * + * Events must be registered prior to calling this method. + * + * @param {ICAL.Component|String|Object} ical The component to process, + * either in its final form, as a jCal Object, or string representation + */ + process: function(ical) { + //TODO: this is sync now in the future we will have a incremental parser. + if (typeof(ical) === 'string') { + ical = ICAL.parse(ical); + } + + if (!(ical instanceof ICAL.Component)) { + ical = new ICAL.Component(ical); + } + + var components = ical.getAllSubcomponents(); + var i = 0; + var len = components.length; + var component; + + for (; i < len; i++) { + component = components[i]; + + switch (component.name) { + case 'vtimezone': + if (this.parseTimezone) { + var tzid = component.getFirstPropertyValue('tzid'); + if (tzid) { + this.ontimezone(new ICAL.Timezone({ + tzid: tzid, + component: component + })); + } + } + break; + case 'vevent': + if (this.parseEvent) { + this.onevent(new ICAL.Event(component)); + } + break; + default: + continue; + } + } + + //XXX: ideally we should do a "nextTick" here + // so in all cases this is actually async. + this.oncomplete(); + } + }; + + return ComponentParser; +}()); diff --git a/calendar/base/modules/moz.build b/calendar/base/modules/moz.build new file mode 100644 index 000000000..a99d53419 --- /dev/null +++ b/calendar/base/modules/moz.build @@ -0,0 +1,23 @@ +# vim: set filetype=python: +# 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/. + +EXTRA_JS_MODULES += [ + 'calAlarmUtils.jsm', + 'calAsyncUtils.jsm', + 'calAuthUtils.jsm', + 'calExtract.jsm', + 'calHashedArray.jsm', + 'calItemUtils.jsm', + 'calIteratorUtils.jsm', + 'calItipUtils.jsm', + 'calPrintUtils.jsm', + 'calProviderUtils.jsm', + 'calRecurrenceUtils.jsm', + 'calUtils.jsm', + 'calViewUtils.jsm', + 'calXMLUtils.jsm', + 'ical.js', +] + diff --git a/calendar/base/moz.build b/calendar/base/moz.build new file mode 100644 index 000000000..697027271 --- /dev/null +++ b/calendar/base/moz.build @@ -0,0 +1,59 @@ +# vim: set filetype=python: +# 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/. + +DIRS = [ + 'public', + 'src', + 'modules', +] + +JAR_MANIFESTS += ['jar.mn'] + +if CONFIG['MOZ_WIDGET_TOOLKIT'] == 'windows': + DEFINES['THEME'] = 'windows' +else: + DEFINES['THEME'] = 'linux' + +# Window icons are not needed on mac +if CONFIG['MOZ_WIDGET_TOOLKIT'] in ('windows', 'gtk2', 'gtk3'): + icon_path = 'themes/common/icons/' + window_icons = [ + 'calendar-alarm-dialog', + 'calendar-event-dialog', + 'calendar-event-summary-dialog', + 'calendar-task-dialog', + 'calendar-task-summary-dialog', + ] + + # Set up the icon suffix to differ between windows and linux + if CONFIG['MOZ_WIDGET_TOOLKIT'] == 'windows': + icon_suffix = '.ico' + else: + icon_suffix = '.png' + + FINAL_TARGET_FILES.chrome.icons.default += [ + '%s%s%s' % (icon_path, icon, icon_suffix) for icon in window_icons + ] + +with Files('content/**'): + BUG_COMPONENT = ('Calendar', 'Calendar Views') + +with Files('content/preferences/**'): + BUG_COMPONENT = ('Calendar', 'Preferences') + +with Files('content/dialogs/**'): + BUG_COMPONENT = ('Calendar', 'Dialogs') + +with Files('content/*task*'): + BUG_COMPONENT = ('Calendar', 'Tasks') + +with Files('content/dialogs/*alarm*'): + BUG_COMPONENT = ('Calendar', 'Alarms') + +with Files('content/widgets/*alarm*'): + BUG_COMPONENT = ('Calendar', 'Alarms') + +with Files('themes/**'): + BUG_COMPONENT = ('Calendar', 'Calendar Views') diff --git a/calendar/base/public/calBaseCID.h b/calendar/base/public/calBaseCID.h new file mode 100644 index 000000000..a8c47efc5 --- /dev/null +++ b/calendar/base/public/calBaseCID.h @@ -0,0 +1,71 @@ +/* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* 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/. */ + +#ifndef CALBASECID_H_ +#define CALBASECID_H_ + +/* C++ */ +#define CAL_DATETIME_CID \ + { 0x85475b45, 0x110a, 0x443c, { 0xaf, 0x3f, 0xb6, 0x63, 0x98, 0xa5, 0xa7, 0xcd } } +#define CAL_DATETIME_CONTRACTID \ + "@mozilla.org/calendar/datetime;1" + +#define CAL_DURATION_CID \ + { 0x63513139, 0x51cb, 0x4f5b, { 0x9a, 0x52, 0x49, 0xac, 0xcc, 0x5c, 0xae, 0x17 } } +#define CAL_DURATION_CONTRACTID \ + "@mozilla.org/calendar/duration;1" + +#define CAL_PERIOD_CID \ + { 0x12fdd72b, 0xc5b6, 0x4720, { 0x81, 0x66, 0x2d, 0xec, 0xa1, 0x33, 0x82, 0xf5 } } +#define CAL_PERIOD_CONTRACTID \ + "@mozilla.org/calendar/period;1" + +#define CAL_ICSSERVICE_CID \ + { 0xae4ca6c3, 0x981b, 0x4f66, { 0xa0, 0xce, 0x2f, 0x2c, 0x21, 0x8a, 0xd9, 0xe3 } } +#define CAL_ICSSERVICE_CONTRACTID \ + "@mozilla.org/calendar/ics-service;1" + +#define CAL_ICALPROPERTY_CID \ + { 0x17349a10, 0x5d80, 0x47fa, { 0x9b, 0xea, 0xf2, 0x29, 0x57, 0x35, 0x76, 0x75 } } +#define CAL_ICALCOMPONENT_CID \ + { 0xc4637c40, 0x3c4c, 0x4ecd, { 0xb8, 0x02, 0x8b, 0x5b, 0x46, 0xbd, 0xf5, 0xa4 } } + +#define CAL_TIMEZONESERVICE_CID \ + { 0x1a23ace4, 0xa0dd, 0x43b4, { 0x96, 0xa8, 0xb3, 0xcd, 0x41, 0x9a, 0x14, 0xa5 } } +#define CAL_TIMEZONESERVICE_CONTRACTID \ + "@mozilla.org/calendar/timezone-service;1" + +#define CAL_RECURRENCERULE_CID \ + { 0xd9560bf9, 0x3065, 0x404a, { 0x90, 0x4c, 0xc8, 0x82, 0xfc, 0x9c, 0x9b, 0x74 } } +#define CAL_RECURRENCERULE_CONTRACTID \ + "@mozilla.org/calendar/recurrence-rule;1" + +/* JS -- Update these from calItemModule.js */ +#define CAL_EVENT_CID \ + { 0x974339d5, 0xab86, 0x4491, { 0xaa, 0xaf, 0x2b, 0x2c, 0xa1, 0x77, 0xc1, 0x2b } } +#define CAL_EVENT_CONTRACTID \ + "@mozilla.org/calendar/event;1" + +#define CAL_TODO_CID \ + { 0x7af51168, 0x6abe, 0x4a31, { 0x98, 0x4d, 0x6f, 0x8a, 0x39, 0x89, 0x21, 0x2d } } +#define CAL_TODO_CONTRACTID \ + "@mozilla.org/calendar/todo;1" + +#define CAL_ATTENDEE_CID \ + { 0x5c8dcaa3, 0x170c, 0x4a73, { 0x81, 0x42, 0xd5, 0x31, 0x15, 0x6f, 0x66, 0x4d } } +#define CAL_ATTENDEE_CONTRACTID \ + "@mozilla.org/calendar/attendee;1" + +#define CAL_RECURRENCEINFO_CID \ + { 0x04027036, 0x5884, 0x4a30, { 0xb4, 0xaf, 0xf2, 0xca, 0xd7, 0x9f, 0x6e, 0xdf } } +#define CAL_RECURRENCEINFO_CONTRACTID \ + "@mozilla.org/calendar/recurrence-info;1" + +#define NS_ERROR_CALENDAR_WRONG_COMPONENT_TYPE NS_ERROR_GENERATE_FAILURE(NS_ERROR_MODULE_CALENDAR, 1) +// Until extensible xpconnect error mapping works +// #define NS_ERROR_CALENDAR_IMMUTABLE NS_ERROR_GENERATE_FAILURE(NS_ERROR_MODULE_CALENDAR, 2) +#define NS_ERROR_CALENDAR_IMMUTABLE NS_ERROR_OBJECT_IS_IMMUTABLE + +#endif /* CALBASECID_H_ */ diff --git a/calendar/base/public/calIAlarm.idl b/calendar/base/public/calIAlarm.idl new file mode 100644 index 000000000..4a51d62ea --- /dev/null +++ b/calendar/base/public/calIAlarm.idl @@ -0,0 +1,159 @@ +/* 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/. */ + +#include "nsISupports.idl" + +interface nsIVariant; +interface nsISimpleEnumerator; + +interface calIAttachment; +interface calIAttendee; +interface calIDateTime; +interface calIDuration; +interface calIItemBase; +interface calIIcalComponent; + +[scriptable, uuid(b8db7c7f-c168-4e11-becb-f26c1c4f5f8f)] +interface calIAlarm : nsISupports +{ + /** + * Returns true if this alarm is able to be modified + */ + readonly attribute boolean isMutable; + + /** + * Makes this alarm immutable. + */ + void makeImmutable(); + + /** + * Make a copy of this alarm. The returned alarm will be mutable. + */ + calIAlarm clone(); + + /** + * How this alarm is shown. Special values as described in rfc2445 are + * AUDIO, DISPLAY, EMAIL + * In addition, custom actions may be defined as an X-Prop, i.e + * X-SMS. + * + * Note that aside from setting this action, the frontend must be able to + * handle the specified action. Unknown actions WILL NOT be notified for. + */ + attribute AUTF8String action; + + /** + * The offset between the item's date and the alarm time. + * This will be null for absolute alarms. + */ + attribute calIDuration offset; + + /** + * The absolute date and time the alarm should fire. + * This will be null for relative alarms. + */ + attribute calIDateTime alarmDate; + + /** + * One of the ALARM_RELATED constants below. + */ + attribute unsigned long related; + + /** + * The alarm is absolute and is therefore not related to either. + */ + const unsigned long ALARM_RELATED_ABSOLUTE = 0; + + /** + * The alarm's offset should be based off of the startDate or + * entryDate (for events and tasks, respectively) + */ + const unsigned long ALARM_RELATED_START = 1; + + /** + * the alarm's offset should be based off of the endDate or + * dueDate (for events and tasks, respectively) + */ + const unsigned long ALARM_RELATED_END = 2; + + /** + * Times the alarm should be repeated. This value is the number of + * ADDITIONAL alarms, aside from the actual alarm. + * + * For the alarm to be valid, if repeat is specified, the repeatOffset + * attribute MUST also be specified. + */ + attribute unsigned long repeat; + + /** + * The duration between the alarm and each subsequent repeat + * + * For the alarm to be valid, if repeatOffset is specified, the repeat + * attribute MUST also be specified. + */ + attribute calIDuration repeatOffset; + + /** + * If repeat is specified, this helper returns the first DATETIME the alarm + * should be repeated on. + * This will be null for relative alarms. + */ + readonly attribute calIDateTime repeatDate; + + /** + * The description of the alarm. Not valid for AUDIO alarms. + */ + attribute AUTF8String description; + + /** + * The summary of the alarm. Not valid for AUDIO and DISPLAY alarms. + */ + attribute AUTF8String summary; + + /** + * Manage Attendee for this alarm. Not valid for AUDIO and DISPLAY alarms. + */ + void addAttendee(in calIAttendee aAttendee); + void deleteAttendee(in calIAttendee aAttendee); + void clearAttendees(); + void getAttendees(out uint32_t count, + [array,size_is(count),retval] out calIAttendee attendees); + + /** + * Manage Attachments for this alarm. + * For EMAIL alarms, more than one attachment can be specified. + * For AUDIO alarms, one Attachment can be specified. + * For DISPLAY alarms, attachments are invalid. + */ + void addAttachment(in calIAttachment aAttachment); + void deleteAttachment(in calIAttachment aAttachment); + void clearAttachments(); + void getAttachments(out uint32_t count, + [array,size_is(count),retval] out calIAttachment attachments); + + /** + * The human readable representation of this alarm. Uses locale strings. + * + * @param aItem The item to base the string on. Defaults to an event. + */ + AUTF8String toString([optional] in calIItemBase aItem); + + /** + * The ical representation of this VALARM + */ + attribute AUTF8String icalString; + + /** + * The ical component of this VALARM + */ + attribute calIIcalComponent icalComponent; + + // Property bag + boolean hasProperty(in AUTF8String name); + nsIVariant getProperty(in AUTF8String name); + void setProperty(in AUTF8String name, in nsIVariant value); + void deleteProperty(in AUTF8String name); + + readonly attribute nsISimpleEnumerator propertyEnumerator; +}; diff --git a/calendar/base/public/calIAlarmService.idl b/calendar/base/public/calIAlarmService.idl new file mode 100644 index 000000000..adce944cf --- /dev/null +++ b/calendar/base/public/calIAlarmService.idl @@ -0,0 +1,110 @@ +/* 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/. */ + +#include "nsISupports.idl" + +interface calIItemBase; +interface calICalendar; +interface calIDuration; +interface calITimezone; +interface calIAlarm; +interface calIOperation; + +[scriptable,uuid(dc96dd04-d2dd-448e-b307-8c8ff39c72af)] +interface calIAlarmServiceObserver : nsISupports +{ + /** + * Gets called when an alarm has fired. Depending on type of alarm, an + * observer could bring up a dialog or play a sound. + */ + void onAlarm(in calIItemBase item, in calIAlarm alarm); + + /** + * Called if alarm(s) of a specific item are to be removed from + * the alarm window. + * + * @param aItem corresponding item, maybe master item of recurring + * series (then all alarms belonging to this item are to + * be removed) + */ + void onRemoveAlarmsByItem(in calIItemBase item); + + /** + * Called if all alarms of a specific calendar are to be removed. + */ + void onRemoveAlarmsByCalendar(in calICalendar calendar); + + /** + * Called when all alarms of a specific calendar are loaded. + */ + void onAlarmsLoaded(in calICalendar calendar); +}; + +[scriptable,uuid(42cfa9ce-49d6-11e5-b88c-5b90eedc1c47)] +interface calIAlarmService : nsISupports +{ + /** + * Upper limit for the snooze period for an alarm. To avoid performance issues, don't change this + * to a value larger then 1 at least until bug 861594 or a similar concept is implemented. + */ + const unsigned long MAX_SNOOZE_MONTHS = 1; + + /** + * This is the timezone that all-day events will be converted to in order to + * determine when their alarms should fire. + */ + attribute calITimezone timezone; + + /** + * Will return true while the alarm service is in the process of loading alarms + */ + attribute boolean isLoading; + + /** + * Cause the alarm service to start up, create a list of upcoming + * alarms in all registered calendars, add observers to watch for + * calendar registration and unregistration, and setup a timer to + * maintain that list and fire alarms. + * + * @note Will throw NS_ERROR_NOT_INITIALIZED if you have not previously set + * the timezone attribute. + */ + void startup(); + + /** + * Shuts down the alarm service, canceling all timers and removing all + * alarms. + */ + void shutdown(); + + /* add and remove observers that will be notified when an + alarm has gone off. It is up to the application to display + the alarm. + */ + void addObserver(in calIAlarmServiceObserver observer); + void removeObserver(in calIAlarmServiceObserver observer); + + /** + * Call to reschedule an alarm to be notified at a later point. The alarm will + * instead fire at "now + duration" This will cause an event to be scheduled + * even if it was not previously scheduled. + * + * @param item The item the alarm belongs to. + * @param alarm The alarm to snooze. + * @param duration The duration in minutes to snooze for. + * @return The operation that modifies the item to snooze the + * alarm. + */ + calIOperation snoozeAlarm(in calIItemBase item, in calIAlarm alarm, in calIDuration duration); + + /** + * Dismisses the given alarm for the passed occurrence. + * + * @param item The item the alarm belongs to. + * @param alarm The alarm to dismiss. + * @return The operation that modifies the item to dismiss the + * alarm. + */ + calIOperation dismissAlarm(in calIItemBase item, in calIAlarm alarm); +}; diff --git a/calendar/base/public/calIAttachment.idl b/calendar/base/public/calIAttachment.idl new file mode 100644 index 000000000..7ca054d5a --- /dev/null +++ b/calendar/base/public/calIAttachment.idl @@ -0,0 +1,57 @@ +/* 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/. */ + +#include "nsISupports.idl" + +interface nsIURI; +interface calIIcalProperty; +interface calIItemBase; + +[scriptable,uuid(7a17d45d-1c0e-4877-baa3-0eb67e770498)] +interface calIAttachment : nsISupports +{ + /** + * The hash id is used to identify this attachment and compare it to others. + */ + readonly attribute AUTF8String hashId; + + /** + * An nsIURI object that points to the file (local or remote) + */ + attribute nsIURI uri; + + /** + * Raw attachment data, in case its not an uri + */ + attribute AUTF8String rawData; + + /** + * The type of file that this attachment refers to + */ + attribute AString formatType; + + /** + * The encoding the (local) file should be encoded with. + */ + attribute AUTF8String encoding; + + /** + * The calIIcalProperty corresponding to this object. Can be used for + * serializing/unserializing from ics files. + */ + attribute calIIcalProperty icalProperty; + attribute AUTF8String icalString; + + /** + * For accessing additional parameters, such as x-params. + */ + AUTF8String getParameter(in AString name); + void setParameter(in AString name, in AUTF8String value); + void deleteParameter(in AString name); + + /** + * Clone this calIAttachment instance into a new object. + */ + calIAttachment clone(); +}; diff --git a/calendar/base/public/calIAttendee.idl b/calendar/base/public/calIAttendee.idl new file mode 100644 index 000000000..dea621f42 --- /dev/null +++ b/calendar/base/public/calIAttendee.idl @@ -0,0 +1,80 @@ +/* -*- Mode: idl; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. */ + +#include "nsISupports.idl" + +interface calIIcalProperty; +interface nsISimpleEnumerator; + +[scriptable,uuid(73a074ad-8812-4055-af75-14b509b8c5fe)] +interface calIAttendee : nsISupports +{ + readonly attribute boolean isMutable; + + // makes this item immutable + void makeImmutable(); + + // clone always returns a mutable event + calIAttendee clone(); + + attribute AUTF8String id; + attribute AUTF8String commonName; + attribute AUTF8String rsvp; + + /** + * If true, indicates that this is not a standard attendee, but rather this + * icalProperty corresponds to the organizer of the event (rfc2445 Sec 4.8.4.3) + */ + attribute boolean isOrganizer; + + /** + * CHAIR + * REQ-PARTICIPANT + * OPT-PARTICIPANT + * NON-PARTICIPANT + */ + attribute AUTF8String role; + + /** + * NEEDS-ACTION + * ACCEPTED + * DECLINED + * TENTATIVE + * DELEGATED + * COMPLETED + * IN-PROCESS + */ + attribute AUTF8String participationStatus; + + /** + * INDIVIDUAL + * GROUP + * RESOURCE + * ROOM + * UNKNOWN + */ + attribute AUTF8String userType; + + readonly attribute nsISimpleEnumerator propertyEnumerator; + + // If you use the has/get/set/deleteProperty + // methods, property names are case-insensitive. + // + // For purposes of ICS serialization, all property names in + // the hashbag are in uppercase. + AUTF8String getProperty(in AString name); + void setProperty(in AString name, in AUTF8String value); + void deleteProperty(in AString name); + + attribute calIIcalProperty icalProperty; + attribute AUTF8String icalString; + + /** + * The display name of the attendee. If the attendee has a common name, this + * is used. Otherwise, the attendee id is displayed (often an email), with the + * mailto: prefix dropped. + */ + AUTF8String toString(); +}; diff --git a/calendar/base/public/calICalendar.idl b/calendar/base/public/calICalendar.idl new file mode 100644 index 000000000..288a16ee7 --- /dev/null +++ b/calendar/base/public/calICalendar.idl @@ -0,0 +1,641 @@ +/* -*- Mode: IDL; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. */ + +#include "nsISupports.idl" + +// decls for stuff from other files +interface nsIURI; +interface calIItemBase; +interface nsIVariant; +interface nsISimpleEnumerator; + +// forward decls for this file +interface calICalendarACLManager; +interface calICalendarACLEntry; +interface calIObserver; +interface calIOperationListener; +interface calIRange; +interface calIDateTime; +interface calIOperation; +interface calIStatusObserver; +interface nsIDOMChromeWindow; + + +[scriptable, uuid(b18782c0-6557-4e8e-931d-4bf052f0a31e)] +interface calICalendar : nsISupports +{ + /** + * Unique ID of this calendar. Only the calendar manager is allowed to set + * this attribute. For everybody else, it should be considered to be + * read-only. + * The id is null for unregistered calendars. + */ + attribute AUTF8String id; + + /** + * Name of the calendar + * Notes: Can only be set after the calendar is registered with the calendar manager. + */ + attribute AUTF8String name; + + /** + * Type of the calendar + * 'memory', 'storage', 'caldav', etc + */ + readonly attribute AUTF8String type; + + /** + * If this calendar is provided by an extension, this attribute should return + * the extension's id, otherwise null. + */ + readonly attribute AString providerID; + + /** + * Returns the acl manager for the calendar, based on the "aclManagerClass" + * property. If this property is not defined, the default manager is used + */ + readonly attribute calICalendarACLManager aclManager; + + /** + * Returns the acl entry associated to the calendar. + */ + readonly attribute calICalendarACLEntry aclEntry; + + /** + * Multiple calendar instances may be composited, logically acting as a + * single calendar, e.g. for caching puorposing. + * This attribute determines the topmost calendar that returned items should + * belong to. If the current instance is the topmost calendar, then it should + * be returned directly. + * + * @see calIItemBase::calendar + */ + attribute calICalendar superCalendar; + + /** + * Setting this URI causes the calendar to be (re)loaded. + * This is not an unique identifier! It is also not unchangeable. Don't + * use it to identify a calendar, use the id attribute for that purpose. + */ + attribute nsIURI uri; + + /** + * Is this calendar read-only? Used by the UI to decide whether or not + * widgetry should allow editing. + */ + attribute boolean readOnly; + + /** + * Whether or not it makes sense to call refresh() on this calendar. + */ + readonly attribute boolean canRefresh; + + /** + * Setting this attribute to true will prevent the calendar to make calendar properties + * persistent, which is useful if you would like to set properties on unregistered + * calendar instances. + */ + attribute boolean transientProperties; + + /** + * Gets a calendar property. + * The call returns null in case the property is not known; + * callers should use a sensible default in that case. + * + * It's up to the provider where to store properties, + * e.g. on the server or in local prefs. + * + * Currently known properties are: + * [boolean] disabled + * [boolean] auto-enabled If true, the calendar will be enabled on next startup. + * [boolean] force-disabled If true, the calendar cannot be enabled (transient). + * [boolean] calendar-main-in-composite + * [string] name + * [boolean] readOnly + * [boolean] requiresNetwork If false, the calendar does not require + * network access at all. This is mainy used + * as a UI hint. + * [boolean] suppressAlarms If true, alarms of this calendar are not minded. + * [boolean] cache.supported If true, the calendar should to be cached, + * e.g. this generally applies to network calendars; + * default is true (if not present). + * [boolean] cache.enabled If true, the calendar is cached; default is false. + * [boolean] cache.always If true, the cache will always be enabled + * and the user cannot turn it off. For + * backward compatibility, return true for + * cache.enabled too. + * + * [nsresult] currentStatus The current error status of the calendar (transient). + * + * [calIItipTransport] itip.transport If the provider implements a custom calIItipTransport (transient) + * If null, then Email Scheduling will effectively be + * disabled. This means for example, the calendar will + * not show up in the list of calendars to store an + * invitation in. + * [boolean] itip.disableRevisionChecks If true, the iTIP handling code disables revision checks + * against SEQUENCE and DTSTAMP, and will never reject an + * iTIP message as outdated + * [nsIMsgIdentity] imip.identity If provided, this is the email identity used for + * scheduling purposes + * [boolean] imip.identity.disabled If true, this calendar doesn't support switching imip + * identities. This for example means that the + * dropdown of identities will not be shown in the + * calendar properties dialog. (transient) + * scheduling purposes + * [nsIMsgAccount] imip.account If provided, this is the email account used for + * scheduling purposes + * [string] imip.identity.key If provided, this is the email internal identity key used to + * get the above + * + * [string] organizerId If provided, this is the preset organizer id on creating + * scheduling appointments (transient) + * [string] organizerCN If provided, this is the preset organizer common name on creating + * scheduling appointments (transient) + * + * The following calendar capabilities can be used to inform the UI or backend + * that certain features are not supported. If not otherwise mentioned, not + * specifying these capabilities assumes a default value of true + * capabilities.alarms.popup.supported Supports popup alarms + * capabilities.alarms.oninviations.supported Supports alarms on inviations. + * capabilities.alarms.maxCount Maximum number of alarms supported per event + * capabilities.attachments.supported Supports attachments + * capabilities.categories.maxCount Maximum number of supported categories. + * -1 means infinite, 0 means disabled. + * capabilities.privacy.supported Supports a privacy state + * capabilities.priority.supported Supports the priority field + * capabilities.events.supported Supports tasks + * capabilities.tasks.supported Supports events + * capabilities.timezones.floating.supported Supports local time + * capabilities.timezones.UTC.supported Supports UTC/GMT timezone + * capabilities.autoschedule.supported Supports caldav schedule properties in + * icalendar (SCHEDULE-AGENT, SCHEDULE-STATUS...) + * + * The following capabilities are used to restrict the values for specific + * fields. An array should be specified with the values, the default + * values are specified here. Extensions using this need to take care of + * adding any UI elements needed in an overlay. To make sure the correct + * elements are shown, those elements should additionally specify an attribute + * "provider", with the type of the provider. + * + * capabilities.privacy.values = ["PUBLIC", "CONFIDENTIAL", "PRIVATE"]; + * + * The following special capability disables rewriting the WWW-Authenticate + * header on HTTP requests to include the calendar name. The default value + * is false, i.e rewriting is NOT disabled. + * + * capabilities.realmrewrite.disabled = false + * + * The following capability describes if the calendar can be permanently + * deleted, or just unsubscribed. If this property is not specified, then + * only unsubscribing is allowed. If an empty array is specified, neither + * deleting nor unsubscribing is presented in the UI. + * + * capabilities.removeModes = ["delete", "unsubscribe"] + * + * @param aName property name + * @return value (string, integer and boolean values are supported), + * else null + */ + nsIVariant getProperty(in AUTF8String aName); + + /** + * Sets a calendar property. + * This will (only) cause a notification onPropertyChanged() in case + * the value has changed. + * + * It's up to the provider where to store properties, + * e.g. on the server or in local prefs. + * + * @param aName property name + * @param aValue value + * (string, integer and boolean values are supported) + */ + void setProperty(in AUTF8String aName, in nsIVariant aValue); + + /** + * Deletes a calendar property. + * + * It's up to the provider where to store properties, + * e.g. on the server or in local prefs. + * + * @param aName property name + */ + void deleteProperty(in AUTF8String aName); + + /** + * In combination with the other parameters to getItems(), these + * constants provide for a very basic filtering mechanisms for use + * in getting and observing items. At some point fairly soon, we're + * going to need to generalize this mechanism significantly (so we + * can allow boolean logic, categories, etc.). + * + * When adding item filters (bits which, when not set to 1, reduce the + * scope of the results), use bit positions <= 15, so that + * ITEM_FILTER_ALL_ITEMS remains compatible for components that have the + * constant compiled in. + * + * XXX the naming here is questionable; adding a filter (setting a bit, in + * this case) usually _reduces_ the set of items that pass the set of + * filters, rather than adding to it. + */ + const unsigned long ITEM_FILTER_COMPLETED_YES = 1 << 0; + const unsigned long ITEM_FILTER_COMPLETED_NO = 1 << 1; + const unsigned long ITEM_FILTER_COMPLETED_ALL = (ITEM_FILTER_COMPLETED_YES | + ITEM_FILTER_COMPLETED_NO); + + const unsigned long ITEM_FILTER_TYPE_TODO = 1 << 2; + const unsigned long ITEM_FILTER_TYPE_EVENT = 1 << 3; + const unsigned long ITEM_FILTER_TYPE_JOURNAL = 1 << 4; + const unsigned long ITEM_FILTER_TYPE_ALL = (ITEM_FILTER_TYPE_TODO | + ITEM_FILTER_TYPE_EVENT | + ITEM_FILTER_TYPE_JOURNAL); + + const unsigned long ITEM_FILTER_ALL_ITEMS = 0xFFFF; + + /** + * If set, return calIItemBase occurrences for all the appropriate instances, + * as determined by an item's recurrenceInfo. All of these occurrences will + * have their parentItem set to the recurrence parent. If not set, will + * return only calIItemBase parent items. + */ + const unsigned long ITEM_FILTER_CLASS_OCCURRENCES = 1 << 16; + + /** + * Scope: Attendee + * Filter items that correspond to an invitation from another + * user and the current user has not replied to it yet. + */ + const unsigned long ITEM_FILTER_REQUEST_NEEDS_ACTION = 1 << 17; + + /** + * Flags for items that have been created, modified or deleted while + * offline. + * ITEM_FILTER_OFFLINE_DELETED is a particular case in that elements *must* + * be excluded from searches when not specified in the filter mask. + */ + const unsigned long ITEM_FILTER_OFFLINE_CREATED = 1 << 29; + const unsigned long ITEM_FILTER_OFFLINE_MODIFIED = 1 << 30; + const unsigned long ITEM_FILTER_OFFLINE_DELETED = 1 << 31; + + void addObserver( in calIObserver observer ); + void removeObserver( in calIObserver observer ); + + /** + * The following five "Item" functions are all asynchronous, and return + * their results to a calIOperationListener object. + * + */ + + /** + * addItem adds the given calIItemBase to the calendar. + * + * @param aItem item to add + * @param aListener where to call back the results + * @return optional operation handle to track the operation + * + * - If aItem already has an ID, that ID is used when adding. + * - If aItem is mutable and has no ID, the calendar is expected + * to generate an ID for the item. + * - If aItem is immutable and has no ID, an error is thrown. + * + * The results of the operation are reported through an + * onOperationComplete call on the listener, with the following + * parameters: + * + * - aOperationType: calIOperationListener::ADD + * - aId: the ID of the newly added item + * - aDetail: the calIItemBase corresponding to the immutable + * version of the newly added item + * + * If an item with a given ID already exists in the calendar, + * onOperationComplete is called with an aStatus of NS_ERROR_XXXXX, + * and aDetail set with the calIItemBase of the internal already + * existing item. + */ + calIOperation addItem(in calIItemBase aItem, + in calIOperationListener aListener); + + /** + * adoptItem adds the given calIItemBase to the calendar, but doesn't + * clone it. It adopts the item as-is. This is generally for use in + * performance-critical situations where there is no danger of the caller + * using the item after making the call. + * + * @see addItem + */ + calIOperation adoptItem(in calIItemBase aItem, + in calIOperationListener aListener); + + /** + * modifyItem takes a modified item and modifies the + * calendar's internal version of the item to match. The item is + * expected to have an ID that already exists in the calendar; if it + * doesn't, or there is no id, onOperationComplete is called with a + * status of NS_ERROR_XXXXX. If the item is immutable, + * onOperationComplete is called with a status of NS_ERROR_XXXXX. + * + * If the generation of the given aNewItem does not match the generation + * of the internal item (indicating that someone else modified the + * item), onOperationComplete is called with a status of NS_ERROR_XXXXX + * and aDetail is set to the latest-version internal immutable item. + * + * If you would like to disable revision checks, pass null as aOldItem. This + * will overwrite the item on the server. + * + * @param aNewItem new version to replace the old one + * @param aOldItem caller's view of the item to be changed, as it is now + * @param aListener where to call back the results + * @return optional operation handle to track the operation + * + * The results of the operation are reported through an + * onOperationComplete call on the listener, with the following + * parameters: + * + * - aOperationType: calIOperationListener::MODIFY + * - aId: the ID of the modified item + * - aDetail: the calIItemBase corresponding to the newly-updated + * immutable version of the modified item + */ + calIOperation modifyItem(in calIItemBase aNewItem, + in calIItemBase aOldItem, + in calIOperationListener aListener); + + /** + * deleteItem takes an item that is to be deleted. The item is + * expected to have an ID that already exists in the calendar; if it + * doesn't, or there is no id, onOperationComplete is called with + * a status of NS_ERROR_XXXXX. + * + * @param aItem item to delete + * @param aListener where to call back the results + * @return optional operation handle to track the operation + * + * The results of the operation are reported through an + * onOperationComplete call on the listener, with the following + * parameters: + * + * - aOperationType: calIOperationListener::DELETE + * - aId: the ID of the deleted item + * - aDetail: the calIItemBase corresponding to the immutable version + * of the deleted item + */ + calIOperation deleteItem(in calIItemBase aItem, + in calIOperationListener aListener); + + /** + * Get a single event. The event will be typed as one of the subclasses + * of calIItemBase (whichever concrete type is most appropriate). + * + * @param aId UID of the event + * @param aListener listener to which this event will be called back. + * @return optional operation handle to track the operation + * + * The results of the operation are reported through the listener, + * via zero or one onGetResult calls (with aCount set to 1) + * followed by an onOperationComplete. + * + * The parameters to onOperationComplete will be: + * + * - aOperationType: calIOperationListener::GET + * - aId: the ID of the requested item + * - aDetail: null (? we can also pass the item back here as well,..) + */ + calIOperation getItem(in string aId, in calIOperationListener aListener); + + /** + * XXX As mentioned above, this method isn't suitably general. It's just + * placeholder until it gets supplanted by something more SQL or RDF-like. + * + * Ordering: This method is currently guaranteed to return lists ordered + * as follows to make for the least amount of pain when + * migrating existing frontend code: + * + * The events are sorted based on the order of their next occurrence + * if they recur in the future or their last occurrence in the past + * otherwise. Here's a presentation of the sort criteria using the + * time axis: + * + * -----(Last occurrence of Event1)---(Last occurrence of Event2)----(Now)----(Next occurrence of Event3)----> + * + * (Note that Event1 and Event2 will not recur in the future.) + * + * We should probably be able get rid of this ordering constraint + * at some point in the future. + * + * Note that the range is intended to act as a mask on the + * occurrences, not just the initial recurring items. So if a + * getItems() call without ITEM_FILTER_CLASS_occurrenceS is made, all + * events and todos which have occurrences inside the range should + * be returned, even if some of those events or todos themselves + * live outside the range. + * + * @param aItemFilter ITEM_FILTER flags, or-ed together + * @param aCount Maximum number of items to return, or 0 for + * an unbounded query. + * @param aRangeStart Items starting at this time or after should be + * returned. If invalid, assume "since the beginning + * of time". + * @param aRangeEndEx Items starting before (not including) aRangeEndEx should be + * returned. If null, assume "until the end of time". + * @param aListener The results will be called back through this interface. + * @return optional operation handle to track the operation + * + * + * The results of the operation are reported through the listener, + * via zero or more onGetResult calls followed by an onOperationComplete. + * + * The parameters to onOperationComplete will be: + * + * - aOperationType: calIOperationListener::GET + * - aId: null + * - aDetail: null + */ + calIOperation getItems(in unsigned long aItemFilter, + in unsigned long aCount, + in calIDateTime aRangeStart, + in calIDateTime aRangeEndEx, + in calIOperationListener aListener); + + /** + * Refresh the datasource, and call the observers for any changes found. + * If the provider doesn't know the details of the changes it must call + * onLoad on its observers. + * + * @return optional operation handle to track the operation + */ + calIOperation refresh(); + + /** + * Turn on batch mode. Observers will get a notification of this. + * They will still get notified for every individual change, but they are + * free to ignore those notifications. + * Use this when a lot of changes are about to happen, and it would be + * useless to refresh the display (or the backend store) for every change. + * Caller must make sure to also call endBatchMode. Make sure all errors + * are caught! + */ + void startBatch(); + + /** + * Turn off batch mode. + */ + void endBatch(); +}; + +/** + * Used to allow multiple calendars (eg work and home) to be easily queried + * and displayed as a single unit. All calendars are referenced by ID, i.e. + * calendars need to have an ID when being added. + */ +[scriptable, uuid(6748fa00-79b5-4728-84f3-20dd47e0b031)] +interface calICompositeCalendar : calICalendar +{ + /** + * Adds a calendar to the composite, if not already part of it. + * + * @param aCalendar the calendar to be added + */ + void addCalendar(in calICalendar aCalendar); + + /** + * Remove a calendar from the composite + * + * @param aCalendar the calendar to be removed + */ + void removeCalendar(in calICalendar aCalendar); + + /** + * If a calendar for the given ID exists in the CompositeCalendar, + * return it; otherwise return null. + * + * @param aId id of calendar + * @return calendar, or null if none + */ + calICalendar getCalendarById(in AUTF8String aId); + + /* return a list of all calendars currently registered */ + void getCalendars(out uint32_t count, + [array, size_is(count), retval] out calICalendar aCalendars); + + /** + * In order for addItem() to be called on this object, it is first necessary + * to set this attribute to specify which underlying calendar the item is + * to be added to. + */ + attribute calICalendar defaultCalendar; + + /** + * If set, the composite will initialize itself from calICalendarManager + * prefs keyed off of the provided prefPrefix, and update those prefs to + * track changes in calendar membership and default calendar. + */ + attribute ACString prefPrefix; + + /** + * If returns true there is a process running that needs to displayed + * by the statusObserver + */ + readonly attribute boolean statusDisplayed; + + /** + * Sets a statusobserver for status notifications like startMeteors() and StopMeteors(). + */ + void setStatusObserver(in calIStatusObserver aStatusObserver, in nsIDOMChromeWindow aWindow); +}; + +/** + * Make a more general nsIObserverService2 and friends to support + * nsISupports data and use that instead? + * + * NOTE: When adding methods here, please also add them in calUtils.jsm's + * createAdapter() method. + */ +[scriptable, uuid(2953c9b2-2c73-11d9-80b6-00045ace3b8d)] +interface calIObserver : nsISupports +{ + void onStartBatch(); + void onEndBatch(); + void onLoad( in calICalendar aCalendar ); + void onAddItem( in calIItemBase aItem ); + void onModifyItem( in calIItemBase aNewItem, in calIItemBase aOldItem ); + void onDeleteItem( in calIItemBase aDeletedItem ); + void onError( in calICalendar aCalendar, in nsresult aErrNo, in AUTF8String aMessage ); + + /// Called after a property is changed. + void onPropertyChanged(in calICalendar aCalendar, + in AUTF8String aName, + in nsIVariant aValue, + in nsIVariant aOldValue); + + /// Called before the property is deleted. + void onPropertyDeleting(in calICalendar aCalendar, + in AUTF8String aName); +}; + +/** + * calICompositeObserver interface adds things to observe changes to + * a calICompositeCalendar + */ +[scriptable, uuid(a3584c92-b8eb-4aa8-a638-e46a2e11d6a9)] +interface calICompositeObserver : calIObserver +{ + void onCalendarAdded( in calICalendar aCalendar ); + void onCalendarRemoved( in calICalendar aCalendar ); + void onDefaultCalendarChanged( in calICalendar aNewDefaultCalendar ); +}; + +/** + * Async operations are called back via this interface. If you know that your + * object is not going to get called back for either of these methods, having + * them return NS_ERROR_NOT_IMPLEMENTED is reasonable. + * + * NOTE: When adding methods here, please also add them in calUtils.jsm's + * createAdapter() method. + */ +[scriptable, uuid(ed3d87d8-2c77-11d9-8f5f-00045ace3b8d)] +interface calIOperationListener : nsISupports +{ + /** + * For add, modify, and delete. + * + * @param aCalendar the calICalendar on which the operation took place + * @param aStatus status code summarizing what happened + * @param aOperationType type of operation that was completed + * @param aId UUID of element that was changed + * @param aDetail not yet fully specified. If aStatus is an error + * result, this will probably be an extended error + * string (eg one returned by a server). + */ + void onOperationComplete(in calICalendar aCalendar, + in nsresult aStatus, + in unsigned long aOperationType, + in string aId, + in nsIVariant aDetail); + const unsigned long ADD = 1; + const unsigned long MODIFY = 2; + const unsigned long DELETE = 3; + const unsigned long GET = 4; + + /** + * For getItem and getItems. + * + * @param aStatus status code summarizing what happened. + * @param aItemType type of interface returned in the array (@see + * calICalendar::GetItems). + * @param aDetail not yet fully specified. If aStatus is an error + * result, this will probably be an extended error + * string (eg one returned by a server). + * @param aCount size of array returned, in items + * @param aItems array of immutable items + * + * Multiple onGetResults might be called + */ + void onGetResult (in calICalendar aCalendar, + in nsresult aStatus, + in nsIIDRef aItemType, + in nsIVariant aDetail, + in uint32_t aCount, + [array, size_is(aCount), iid_is(aItemType)] in nsQIResult aItems ); +}; diff --git a/calendar/base/public/calICalendarACLManager.idl b/calendar/base/public/calICalendarACLManager.idl new file mode 100644 index 000000000..67b772411 --- /dev/null +++ b/calendar/base/public/calICalendarACLManager.idl @@ -0,0 +1,89 @@ +/* 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/. */ + +#include "nsISupports.idl" + +interface nsIMsgIdentity; +interface nsIURI; + +interface calICalendar; +interface calIItemBase; +interface calIOperationListener; + +interface calIItemACLEntry; + +/** + */ +[scriptable, uuid(a64bd8a0-e9f0-4f64-928a-1c98861e4703)] +interface calICalendarACLManager : nsISupports +{ + /* Gets the calICalendarACLEntry of the current user for the specified + calendar. */ + void getCalendarEntry(in calICalendar aCalendar, + in calIOperationListener aListener); + + /* Gets the calIItemACLEntry of the current user for the specified + calendar item. Depending on the implementation, each item can have + different permissions based on specific attributes. + (TODO: should be made asynchronous one day) */ + calIItemACLEntry getItemEntry(in calIItemBase aItem); +}; + +[scriptable, uuid(f3da7954-52a4-45a9-bd7d-96c518133d0c)] +interface calICalendarACLEntry : nsISupports +{ + /* The calICalendarACLManager instance that generated this entry. */ + readonly attribute calICalendarACLManager aclManager; + + /* Whether the underlying calendar does have access control. */ + readonly attribute boolean hasAccessControl; + + /* Whether the user accessing the calendar is its owner. */ + readonly attribute boolean userIsOwner; + + /* Whether the user accessing the calendar can add items to it. */ + readonly attribute boolean userCanAddItems; + + /* Whether the user accessing the calendar can remove items from it. */ + readonly attribute boolean userCanDeleteItems; + + /* Returns the list of user ids matching the user accessing the + calendar. */ + void getUserAddresses(out uint32_t aCount, + [array, size_is(aCount), retval] out wstring aAddresses); + + /* Returns the list of instantiated identities for the user accessing the + calendar. */ + void getUserIdentities(out uint32_t aCount, + [array, size_is(aCount), retval] out nsIMsgIdentity aIdentities); + /* Returns the list of instantiated identities for the user representing + the calendar owner. */ + void getOwnerIdentities(out uint32_t aCount, + [array, size_is(aCount), retval] out nsIMsgIdentity aIdentities); + + /* Helper method that forces a cleanup of any cache and a reload of the + current entry. + (TODO: should be made asynchronous one day) */ + void refresh(); +}; + +[scriptable, uuid(4d0b7ced-8c57-4efa-87e7-8dd5b7481312)] +interface calIItemACLEntry : nsISupports +{ + /* The parent calICalendarACLEntry instance. */ + readonly attribute calICalendarACLEntry calendarEntry; + + /* Whether the active user can fully modify the item. */ + readonly attribute boolean userCanModify; + + /* Whether the active user can respond to this item, if it is an invitation. */ + readonly attribute boolean userCanRespond; + + /* Whether the active user can view all the item properties. */ + readonly attribute boolean userCanViewAll; + + /* Whether the active user can only see when this item occurs without + knowing any details. */ + readonly attribute boolean userCanViewDateAndTime; +}; diff --git a/calendar/base/public/calICalendarManager.idl b/calendar/base/public/calICalendarManager.idl new file mode 100644 index 000000000..ac4908688 --- /dev/null +++ b/calendar/base/public/calICalendarManager.idl @@ -0,0 +1,115 @@ +/* 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/. */ + +#include "nsISupports.idl" + +interface calICalendar; +interface calIObserver; +interface nsIURI; +interface nsIVariant; + +interface calICalendarManagerObserver; + +[scriptable, uuid(fd8a2565-cb0f-4ecc-945d-760d75ab16d8)] +interface calICalendarManager : nsISupports +{ + /** + * Gives the number of registered calendars that require network access. + */ + readonly attribute uint32_t networkCalendarCount; + + /*** + * Gives the number of registered readonly calendars. + */ + readonly attribute uint32_t readOnlyCalendarCount; + + /** + * Gives the number of registered calendars + */ + readonly attribute uint32_t calendarCount; + /* + * create a new calendar + * aType is the type ("caldav", "storage", etc) + */ + calICalendar createCalendar(in AUTF8String aType, in nsIURI aURL); + + /* register a newly created calendar with the calendar service */ + void registerCalendar(in calICalendar aCalendar); + + /* unregister a calendar */ + void unregisterCalendar(in calICalendar aCalendar); + + /** @deprecated This method has been replaced by ::removeCalendar */ + void deleteCalendar(in calICalendar aCalendar); + + /** Remove the calendar following the calendar's capabilities.removeModes. */ + const unsigned short REMOVE_AUTO = 0; + + /** Just unsubscribe from the calendar, do not delete it. */ + const unsigned short REMOVE_NO_DELETE = 1; + + /** Passing this flag will cause the call to fail if the calendar is registered */ + const unsigned short REMOVE_NO_UNREGISTER = 2; + + /** + * Unregister and delete the calendar from the calendar manager. By default + * the calendar will be removed based on the capabilities.removeModes + * property of the calendar. + * + * WARNING: If the calendar supports deletion, the calendar will be + * permanently deleted. You can prevent this with the REMOVE_NO_DELETE flag. + * + * @param aCalendar The calendar to remove. + * @param aMode A combination of the above mode flags. + */ + void removeCalendar(in calICalendar aCalendar, [optional] in uint8_t aMode); + + /* get a calendar by its id */ + calICalendar getCalendarById(in AUTF8String aId); + + /* return a list of all calendars currently registered */ + void getCalendars(out uint32_t count, + [array, size_is(count), retval] out calICalendar aCalendars); + + /** Add an observer for the calendar manager, i.e when calendars are registered */ + void addObserver(in calICalendarManagerObserver aObserver); + /** Remove an observer for the calendar manager */ + void removeObserver(in calICalendarManagerObserver aObserver); + + /** Add an observer to handle changes to all calendars (even disabled or unchecked ones) */ + void addCalendarObserver(in calIObserver aObserver); + /** Remove an observer to handle changes to all calendars */ + void removeCalendarObserver(in calIObserver aObserver); + + /* XXX private, don't use: + will vanish as soon as providers will directly read/write from moz prefs + */ + nsIVariant getCalendarPref_(in calICalendar aCalendar, + in AUTF8String aName); + void setCalendarPref_(in calICalendar aCalendar, + in nsIVariant aName, + in nsIVariant aValue); + void deleteCalendarPref_(in calICalendar aCalendar, + in AUTF8String aName); + +}; + +/** + * Observer to handle actions done by the calendar manager + * + * NOTE: When adding methods here, please also add them in calUtils.jsm's + * createAdapter() method. + */ +[scriptable, uuid(383f36f1-e669-4ca4-be7f-06b43910f44a)] +interface calICalendarManagerObserver : nsISupports +{ + /** Called after the calendar is registered */ + void onCalendarRegistered(in calICalendar aCalendar); + + /** Called before the unregister actually takes place */ + void onCalendarUnregistering(in calICalendar aCalendar); + + /** Called before the delete actually takes place */ + void onCalendarDeleting(in calICalendar aCalendar); +}; diff --git a/calendar/base/public/calICalendarProvider.idl b/calendar/base/public/calICalendarProvider.idl new file mode 100644 index 000000000..79567db0c --- /dev/null +++ b/calendar/base/public/calICalendarProvider.idl @@ -0,0 +1,80 @@ +/* -*- Mode: IDL; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. */ + +#include "nsISupports.idl" + +interface nsIURI; +interface calICalendar; +interface nsIVariant; +interface calIProviderListener; + +/** + * High-level interface to allow providers to be plugable. + */ +[scriptable, uuid(30e22db4-9f13-11d9-80d6-000b7d081f44)] +interface calICalendarProvider : nsISupports +{ + /** + * XUL overlay for configuring a calendar of this type. + */ + readonly attribute nsIURI prefChromeOverlay; + + /** + * The way to refer to this provider in UI for the end-user + * (eg "Shared ICS File"). + */ + readonly attribute AUTF8String displayName; + + /** + * Create a new empty calendar. This will typically create a new empty + * file, and then call getCalendar() + * + * @param aName the display name of the calendar to be created + * @param aURL URL of the calendar to be created. + * @param aListener where to call the results back to + */ + void createCalendar(in AUTF8String aName, in nsIURI aURL, + in calIProviderListener aListener); + + /** + * Delete a calendar. Deletes the actual underlying calendar, which + * could be (for example) a file or a calendar on a server + * + * @param aCalendar the calendar to delete + * @param aListener where to call the results back to + */ + void deleteCalendar(in calICalendar aCalendar, + in calIProviderListener aListener); + + /** + * Get a new calendar object with existing calendar data + * + * @param aURL URL of the calendar to be created. + */ + calICalendar getCalendar(in nsIURI aURL); +}; + +[scriptable, uuid(0eebe99e-a22d-11d9-87a6-000b7d081f44)] +interface calIProviderListener : nsISupports +{ + /** + * @param aStatus status code summarizing what happened + * @param aDetail not yet fully specified. If aStatus is an error + * result, this will probably be an extended error + * string (eg one returned by a server). + */ + void onCreateCalendar(in calICalendar aCalendar, in nsresult aStatus, + in nsIVariant aDetail); + + /** + * @param aStatus status code summarizing what happened + * @param aDetail not yet fully specified. If aStatus is an error + * result, this will probably be an extended error + * string (eg one returned by a server). + */ + void onDeleteCalendar(in calICalendar aCalendar, in nsresult aStatus, + in nsIVariant aDetail); +}; + diff --git a/calendar/base/public/calICalendarSearchProvider.idl b/calendar/base/public/calICalendarSearchProvider.idl new file mode 100644 index 000000000..96dc36810 --- /dev/null +++ b/calendar/base/public/calICalendarSearchProvider.idl @@ -0,0 +1,65 @@ +/* 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/. */ + +#include "nsISupports.idl" + +interface calIOperation; +interface calIGenericOperationListener; + +[scriptable, uuid(306DA1C9-DB54-4ef3-B27E-FEA709F638FF)] +interface calICalendarSearchProvider : nsISupports +{ + /** + * Specifies whether the search string should exactly match. + */ + const unsigned long HINT_EXACT_MATCH = 1; + + /* ...possibly more to come... */ + + /** + * Searches for calendars matching the specified search string. + * It's up to the search provider what properties of a calendar + * it takes into account for the search. The passed hints serve + * for optimization purposes. Callers need to keep in mind that + * providers may not be able to implement all of the stated hints + * passed, thus are required to filter further if necessary. + * Results are notified to the passed listener interface. + * + * @param aString search string to match + * @param aHints search hints + * @param aMaxResults maximum number of results + * (0 denotes provider specific maximum) + * @param aListener called with an array of calICalendar objects + * @return optional operation handle to track the operation + */ + calIOperation searchForCalendars(in AUTF8String aString, + in unsigned long aHints, + in unsigned long aMaxResults, + in calIGenericOperationListener aListener); +}; + +/** + * This service acts as a central access point for calendar lookup. + * A search request will be multiplexed to all added search providers. + * Adding a search provider is transient. + */ +[scriptable, uuid(2F2055CA-F558-4dc8-A1D4-11384A00E85C)] +interface calICalendarSearchService : calICalendarSearchProvider +{ + /** + * Gets the currently registered set of search providers. + */ + void getProviders(out uint32_t aCount, + [array, size_is(aCount), retval] out calICalendarSearchProvider aProviders); + + /** + * Adds a new search provider. + */ + void addProvider(in calICalendarSearchProvider aProvider); + + /** + * Removes a search provider. + */ + void removeProvider(in calICalendarSearchProvider aProvider); +}; diff --git a/calendar/base/public/calICalendarView.idl b/calendar/base/public/calICalendarView.idl new file mode 100644 index 000000000..3550b4617 --- /dev/null +++ b/calendar/base/public/calICalendarView.idl @@ -0,0 +1,233 @@ +/* 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/. */ + + +#include "nsISupports.idl" + +interface calICalendar; +interface calIDateTime; +interface calICalendarViewController; +interface calIItemBase; + +/** + * An interface for view widgets containing calendaring data. + * + * @note Code that implements this interface is intended to be pure + * widgetry and thus not have any preference dependencies. + */ + +[scriptable, uuid(0e392744-4b2e-4b64-8862-2fb707d900a7)] +interface calICalendarView : nsISupports +{ + + /** + * Oftentimes other elements in the DOM in which a calIDecoratedView is + * used want to be aware of whether or not the view is selected. An element + * whose ID is observerID can be included in that DOM, and will be set to be + * enabled or disabled depending on whether the view is selected. + */ + readonly attribute AUTF8String observerID; + + /** + * Generally corresponds to whether or not the view has been previously shown. + * Strictly speaking, it reports whether displayCalendar, startDay and endDay + * are all non-null. + */ + readonly attribute boolean initialized; + + /** + * the calendar that this view is displaying + */ + attribute calICalendar displayCalendar; + + /** + * the controller for this view + */ + attribute calICalendarViewController controller; + + /** + * If true, the view supports workdays only + */ + readonly attribute boolean supportsWorkdaysOnly; + + /** + * If this is set to 'true', the view should not display days specified to be + * non-workdays. The implementor is responsible for obtaining what those + * days are on its own. + */ + attribute boolean workdaysOnly; + + /** + * Whether or not tasks are to be displayed in the calICalendarView + */ + attribute boolean tasksInView; + + /** + * If true, the view is rotatable + */ + readonly attribute boolean supportsRotation; + + /** + * If set, the view will be rotated (i.e time on top, date at left) + */ + attribute boolean rotated; + + /** + * If true, the view is zoomable + */ + readonly attribute boolean supportsZoom; + + /** + * Zoom view in one level. Defaults to one level. + */ + void zoomIn([optional] in uint32_t level); + + /** + * Zoom view out one level. Defaults to one level. + */ + void zoomOut([optional] in uint32_t level); + + /** + * Reset view zoom. + */ + void zoomReset(); + + /** + * Whether or not completed tasks are shown in the calICalendarView + */ + attribute boolean showCompleted; + + /** + * Ensure that the given date is visible; the view is free + * to show more dates than the given date (e.g. week view + * would show the entire week). + */ + void showDate(in calIDateTime aDate); + + /** + * Set a date range for the view to display, from aStartDate + * to aEndDate, inclusive. + * + * Some views may decide to utilize the time portion of these + * calIDateTimes; pass in calIDateTimes that are dates if you + * want to make sure this doesn't happen. + */ + void setDateRange(in calIDateTime aStartDate, in calIDateTime aEndDate); + + /** + * The start date of the view's display. If the view is displaying + * disjoint dates, this will be the earliest date that's displayed. + */ + readonly attribute calIDateTime startDate; + + /** + * The end date of the view's display. If the view is displaying + * disjoint dates, this will be the latest date that's displayed. + * + * Note that this won't be equivalent to the aEndDate passed to + * setDateRange, because that date isn't actually displayed! + */ + readonly attribute calIDateTime endDate; + + /** + * The first day shown in the embedded view + */ + readonly attribute calIDateTime startDay; + + /** + * The last day shown in the embedded view + */ + readonly attribute calIDateTime endDay; + + /** + * True if this view supports disjoint dates + */ + readonly attribute boolean supportsDisjointDates; + + /** + * True if this view currently has a disjoint date set. + */ + readonly attribute boolean hasDisjointDates; + + /** + * Returns the list of dates being shown by this calendar. + * If a date range is set, it will expand out the date range by + * day and return the full set. + */ + void getDateList(out unsigned long aCount, [array,size_is(aCount),retval] out calIDateTime aDates); + + /** + * Get the items currently selected in this view. + * + * @param aCount a variable to hold the number of items in this array + * + * @return the array of items currently selected in this. + */ + void getSelectedItems(out unsigned long aCount, + [array,size_is(aCount),retval] out calIItemBase aItems); + + /** + * Select an array of items in the view. Items outside the view's current + * display range will be ignored. + * + * @param aCount the number of items to select + * @param aItems an array of items to select + * @param aSuppressEvent if true, the 'itemselect' event will not be fired. + */ + void setSelectedItems(in unsigned long aCount, + [array,size_is(aCount)] in calIItemBase aItems, + in boolean aSuppressEvent); + + /** + * Make as many of the selected items as possible are visible in the view. + */ + void centerSelectedItems(); + + /** + * Get or set the selected day. + */ + attribute calIDateTime selectedDay; + + /** + * Get or set the timezone that the view's elements should be displayed in. + * Setting this does not refresh the view. + */ + attribute AUTF8String timezone; + + /** + * Ensures that the given date is visible, and that the view is centered + * around this date. aDate becomes the selectedDay of the view. Calling + * this function with the current selectedDay effectively refreshes the view + * + * @param aDate the date that must be shown in the view and becomes + * the selected day + */ + void goToDay(in calIDateTime aDate); + + /** + * Moves the view a specific number of pages. Negative numbers correspond to + * moving the view backwards. Note that it is up to the view to determine + * how the selected day ought to move as well. + * + * @param aNumber the number of pages to move the view + */ + void moveView(in long aNumber); + + /** + * gets the description of the range displayed by the view + */ + AString getRangeDescription(); + + /** + * The type of the view e.g "day", "week", "multiweek" or "month" that refers + * to the displayed time period. + */ + readonly attribute string type; + /** + * removes the dropshadows that are inserted into childelements during a + * drag and drop session + */ + + void removeDropShadows(); +}; diff --git a/calendar/base/public/calICalendarViewController.idl b/calendar/base/public/calICalendarViewController.idl new file mode 100644 index 000000000..caadb8569 --- /dev/null +++ b/calendar/base/public/calICalendarViewController.idl @@ -0,0 +1,71 @@ +/* -*- Mode: IDL; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. */ + + +#include "nsISupports.idl" + +interface calICalendar; +interface calIDateTime; +interface calIEvent; +interface calIItemBase; + +[scriptable, uuid(40430501-a666-4c24-b234-eeac5ccb70f6)] +interface calICalendarViewController : nsISupports +{ + /** + * Create an event, with an optional start time and optional end + * time in the given Calendar. The Calendar will be the + * displayCalendar set on the View which invokes this method + * on the controller, or null, if the views wish to delegate the + * choice of the calendar to the controller. + * + * If neither aStartTime or aEndTime are given, the user wants to + * create a generic event with no information prefilled. + * + * If aStartTime is given and is a date, the user wants to + * create an all day event, optionally a multi-all-day event if + * aEndTime is given (and is also a date). + * + * If aStartTime is given and is a time, but no aEndTime is + * given, the user wants to create an event starting at + * aStartTime and of the default duration. The controller has the + * option of creating this event automatically or via the dialog. + * + * If both aStartTime and aEndTime are given as times, then + * the user wants to create an event going from aStartTime + * to aEndTime. + */ + void createNewEvent (in calICalendar aCalendar, + in calIDateTime aStartTime, + in calIDateTime aEndTime); + + /** + * Modify aOccurrence. If aNewStartTime and aNewEndTime are given, + * update the event to those times. If aNewTitle is given, modify the title + * of the item. If no parameters are given, ask the user to modify. + */ + void modifyOccurrence (in calIItemBase aOccurrence, + in calIDateTime aNewStartTime, + in calIDateTime aNewEndTime, + in AString aNewTitle); + /** + * Delete all events in the given array. If more than one event is passed, + * this will prompt whether to delete just this occurrence or all occurrences. + * All passed events will be handled in one transaction, i.e undoing this will + * make all events reappear. + * + * @param aCount The number of events in the array + * @param aOccurrences An array of Items/Occurrences to delete + * @param aUseParentItems If set, each occurrence will have its parent item + * deleted. + * @param aDoNotConfirm If set, the events will be deleted without + * confirmation. + */ + void deleteOccurrences (in uint32_t aCount, + [array, size_is(aCount)] in calIItemBase aOccurrences, + in boolean aUseParentItems, + in boolean aDoNotConfirm); +}; + diff --git a/calendar/base/public/calIChangeLog.idl b/calendar/base/public/calIChangeLog.idl new file mode 100644 index 000000000..973fe2c61 --- /dev/null +++ b/calendar/base/public/calIChangeLog.idl @@ -0,0 +1,147 @@ +/* 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/. */ + +#include "calICalendar.idl" + +interface calIGenericOperationListener; +interface calIOperation; + +/** + * Interface for managing offline flags in offline storage + * (calStorageCalendar), in particular from calICachedCalendar. + */ +[scriptable, uuid(36dc2c93-5851-40d2-9ba9-b1f6e682c75c)] +interface calIOfflineStorage : calICalendar { + /** + * Mark the item of which the id is passed as parameter as new. + * + * @param aItem the item to add + * @param aListener where to call back the results + */ + void addOfflineItem(in calIItemBase aItem, in calIOperationListener aListener); + + /** + * Mark the item of which the id is passed as parameter as modified. + * + * @param aItem the item to modify + * @param aListener where to call back the results + */ + void modifyOfflineItem(in calIItemBase aItem, in calIOperationListener aListener); + + /** + * Mark the item of which the id is passed as parameter as deleted. + * + * @param aItem the item to delete + * @param aListener where to call back the results + */ + void deleteOfflineItem(in calIItemBase aItem, in calIOperationListener aListener); + + /** + * Retrieves the offline flag for the given item. The flag is returned using the + * detail parameter of the onOperationComplete function in calIOperationLIstener. + * + * @param aItem the item to reset + * @param aListener where to call back the results + */ + void getItemOfflineFlag(in calIItemBase aItem, in calIOperationListener aListener); + + /** + * Remove any offline flag from the item record. + * + * @param aItem the item to reset + * @param aListener where to call back the results + */ + void resetItemOfflineFlag(in calIItemBase aItem, in calIOperationListener aListener); +}; + +/** + * Interface for synchronously working providers on storing items, + * e.g. storage, memory. All modifying commands return after the + * modification has been performed. + * + * @note + * This interface is used in conjunction with changelog-based synchronization + * and additionally offers storing meta-data for items for this purpose. + * The meta data is stored as long as the corresponding items persist in + * the calendar and automatically cleanup up once the item is deleted from + * the calendar, but is not altered when an item is modified (modifyItem). + * Meta data can be fetched/stored per (master) item, i.e. if you need to + * store meta data for individual overridden items, you need to store it + * along with the master item's meta data. + * Finally, keep in mind that the meta data is "calendar local" and not + * automatically transferred when storing the item on another calISyncWriteCalendar. + */ +[scriptable, uuid(651e137b-2f3a-4595-af89-da51b6a37f85)] +interface calISyncWriteCalendar : calICalendar { + /** + * Adds or replaces meta data of an item. + * + * @param id an item id + * @param value an arbitrary string + */ + void setMetaData(in AUTF8String id, + in AUTF8String value); + + /** + * Deletes meta data of an item. + * + * @param id an item id + */ + void deleteMetaData(in AUTF8String id); + + /** + * Gets meta data of an item or null if there's none or the item id is invalid. + * + * @param id an item id + */ + AUTF8String getMetaData(in AUTF8String id); + + /** + * Gets all meta data. The returned arrays are of the same length. + */ + void getAllMetaData(out uint32_t count, + [array, size_is(count)] out wstring ids, + [array, size_is(count)] out wstring values); +}; + +/** + * Calendar implementing this interface have improved means of replaying their + * changelog data. This could for example mean, that the provider can retrieve + * changes between now and the last sync. + * + * Not implementing this interface is perfectly valid for calendars, that need + * to do a full sync each time anyway (i.e ics) + */ +[scriptable, uuid(0bf4c6a2-b4c7-4cae-993a-4408d8bded3e)] +interface calIChangeLog : nsISupports { + + // To denote no offline flag, use null + const long OFFLINE_FLAG_CREATED_RECORD = 1; + const long OFFLINE_FLAG_MODIFIED_RECORD = 2; + const long OFFLINE_FLAG_DELETED_RECORD = 4; + + /** + * Enable the changelog calendar to retrieve offline data right after instanciation. + */ + attribute calISyncWriteCalendar offlineStorage; + + /** + * Resets the changelog. This is used if the cache should be refreshed. + */ + void resetLog(); + + /** + * Instructs the calendar to replay remote changes into the above offlineStorage + * calendar. The calendar itself is responsible for storing anything needed + * to keep track of what items need updating. + * + * TODO: We might reconsider to replay on calICalendar, + * but this complicates implementing this interface + * enormously for providers. + * + * @param aDestination The calendar to sync changes into + * @param aListener The listener to notify when the operation completes. + */ + calIOperation replayChangesOn(in calIGenericOperationListener aListener); +}; diff --git a/calendar/base/public/calIDateTime.idl b/calendar/base/public/calIDateTime.idl new file mode 100644 index 000000000..d6c94052b --- /dev/null +++ b/calendar/base/public/calIDateTime.idl @@ -0,0 +1,232 @@ +/* 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/. */ + +#include "nsISupports.idl" + +interface calIDuration; +interface calITimezone; + +[scriptable, uuid(fe3e9a58-2938-4b2c-9085-4989d5f7244f)] +interface calIDateTime : nsISupports +{ + /** + * isMutable is true if this instance is modifiable. + * If isMutable is false, any attempts to modify + * the object will throw NS_ERROR_OBJECT_IS_IMMUTABLE. + */ + readonly attribute boolean isMutable; + + /** + * Make this calIDateTime instance immutable. + */ + void makeImmutable(); + + /** + * Clone this calIDateTime instance into a new + * mutable object. + */ + calIDateTime clone(); + + /** + * valid is true if this object contains a valid + * time/date. + */ + // true if this thing is set/valid + readonly attribute boolean isValid; + + /** + * nativeTime contains this instance's PRTime value relative + * to the UTC epoch, regardless of the timezone that's set + * on this instance. If nativeTime is set, the given UTC PRTime + * value is exploded into year/month/etc, forcing the timezone + * setting to UTC. + * + * @warning: When the timezone is set to 'floating', this will return + * the nativeTime as-if the timezone was UTC. Take this into account + * when comparing values. + * + * @note on objects that are pinned to a timezone and have isDate set, + * nativeTime will be 00:00:00 in the timezone of that date, not 00:00:00 in + * UTC. + */ + attribute PRTime nativeTime; + + /** + * Full 4-digit year value (e.g. "1989", "2004") + */ + attribute short year; + + /** + * Month, 0-11, 0 = January + */ + attribute short month; + + /** + * Day of month, 1-[28,29,30,31] + */ + attribute short day; + + /** + * Hour, 0-23 + */ + attribute short hour; + + /** + * Minute, 0-59 + */ + attribute short minute; + + /** + * Second, 0-59 + */ + attribute short second; + + /** + * Gets or sets the timezone of this calIDateTime instance. + * Setting the timezone does not change the actual date/time components; + * to convert between timezones, use getInTimezone(). + * + * @throws NS_ERROR_INVALID_ARG if null is passed in. + */ + attribute calITimezone timezone; + + /** + * Resets the datetime object. + * + * @param year full 4-digit year value (e.g. "1989", "2004") + * @param month month, 0-11, 0 = January + * @param day day of month, 1-[28,29,31] + * @param hour hour, 0-23 + * @param minute minute, 0-59 + * @param second decond, 0-59 + * @param timezone timezone + * + * The passed datetime will be normalized, e.g. a minute value of 60 will + * increase the hour. + * + * @throws NS_ERROR_INVALID_ARG if no timezone is passed in. + */ + void resetTo(in short year, + in short month, + in short day, + in short hour, + in short minute, + in short second, + in calITimezone timezone); + + /** + * The offset of the timezone this datetime is in, relative to UTC, in + * seconds. A positive number means that the timezone is ahead of UTC. + */ + readonly attribute long timezoneOffset; + + /** + * isDate indicates that this calIDateTime instance represents a date + * (a whole day), and not a specific time on that day. If isDate is set, + * accessing the hour/minute/second fields will return 0, and and setting + * them is an illegal operation. + */ + attribute boolean isDate; + + /* + * computed values + */ + + /** + * Day of the week. 0-6, with Sunday = 0. + */ + readonly attribute short weekday; + + /** + * Day of the year, 1-[365,366]. + */ + readonly attribute short yearday; + + /* + * Methods + */ + + /** + * Resets this instance to Jan 1, 1970 00:00:00 UTC. + */ + void reset(); + + /** + * Return a string representation of this instance. + */ + AUTF8String toString(); + + /** + * Return a new calIDateTime instance that's the result of + * converting this one into the given timezone. Valid values + * for aTimezone are the same as the timezone field. If + * the "floating" timezone is given, then this object + * is just cloned, and the timezone is set to floating. + */ + calIDateTime getInTimezone(in calITimezone aTimezone); + + // add the given calIDateTime, treating it as a duration, to + // this item. + // XXX will change + void addDuration (in calIDuration aDuration); + + // Subtract two dates and return a duration + // returns duration of this - aOtherDate + // if aOtherDate is > this the duration will be negative + calIDuration subtractDate (in calIDateTime aOtherDate); + + /** + * Compare this calIDateTime instance to aOther. Returns -1, 0, 1 to + * indicate if this < aOther, this == aOther, or this > aOther, + * respectively. + * + * This comparison is timezone-aware; the given values are converted + * to a common timezone before comparing. If either this or aOther is + * floating, both objects are treated as floating for the comparison. + * + * If either this or aOther has isDate set, then only the date portion is + * compared. + * + * @exception calIErrors.INVALID_TIMEZONE bad timezone on this object + * (not the argument object) + */ + long compare (in calIDateTime aOther); + + // + // Some helper getters for calculating useful ranges + // + + /** + * Returns SUNDAY of the given datetime object's week. + */ + readonly attribute calIDateTime startOfWeek; + + /** + * Returns SATURDAY of the datetime object's week. + */ + readonly attribute calIDateTime endOfWeek; + + // the start/end of the current object's month + readonly attribute calIDateTime startOfMonth; + readonly attribute calIDateTime endOfMonth; + + // the start/end of the current object's year + readonly attribute calIDateTime startOfYear; + readonly attribute calIDateTime endOfYear; + + /** + * This object as either an iCalendar DATE or DATETIME string, as + * appropriate and sets the timezone to either UTC or floating. + */ + attribute ACString icalString; +}; + +/** Libical specific interfaces */ + +[ptr] native icaltimetypeptr(struct icaltimetype); +[scriptable, uuid(04139dff-a6f0-446d-9aec-2062df887ef2)] +interface calIDateTimeLibical : calIDateTime +{ + [noscript,notxpcom] void toIcalTime(in icaltimetypeptr itt); +}; diff --git a/calendar/base/public/calIDateTimeFormatter.idl b/calendar/base/public/calIDateTimeFormatter.idl new file mode 100644 index 000000000..6812d4df3 --- /dev/null +++ b/calendar/base/public/calIDateTimeFormatter.idl @@ -0,0 +1,162 @@ +/* -*- Mode: idl; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. */ + +#include "nsISupports.idl" + +interface calIDateTime; +interface calIItemBase; + +[scriptable, uuid(69741510-5f5d-11e4-9803-0800200c9a66)] +interface calIDateTimeFormatter : nsISupports +{ + /** + * Format a date in either short or long format, depending on the + * users preference + * + * @see + * formatDateShort + * formatDateLong + */ + AString formatDate(in calIDateTime aDate); + + /** + * Format a date into a short format, for example + * "12/17/2005" + * + * @param aDate + * the datetime to format + * @returns + * a string representing the date part of the datetime + */ + AString formatDateShort(in calIDateTime aDate); + + /** + * Format a date into a long format, for example + * "Sat Dec 17 2005" + * + * @param aDate + * the datetime to format + * @returns + * a string representing the date part of the datetime + */ + AString formatDateLong(in calIDateTime aDate); + + /** + * Format a date into a short format without mentioning the year, for + * example "Dec 17" + * + * @param aDate + * the datetime to format + * @returns + * a string representing the date part of the datetime + */ + AString formatDateWithoutYear(in calIDateTime aDate); + + /** + * Format a time into the format specified by the OS settings. + * Will omit the seconds from the output. + * + * @param aDate + * the datetime to format + * @returns + * a string representing the time part of the datetime + */ + AString formatTime(in calIDateTime aDate); + + /** + * Format a datetime into the format specified by the OS settings. + * Will omit the seconds from the output. + * + * @param aDateTime + * the datetime to format + * @returns + * a string representing the datetime + */ + AString formatDateTime(in calIDateTime aDate); + + /** + * Format a time interval that is defined by an item with the default + * timezone Internally it calls "formatInterval" after retrieving + * the start/entry and end/due date of the item. + * + * @param aItem + * The item describing the interval + */ + AUTF8String formatItemInterval(in calIItemBase aItem); + + /** + * Format a time interval like formatItemInterval, but only show times. + * + * @param aItem The item providing the interval + * @return The string describing the interval + */ + AUTF8String formatItemTimeInterval(in calIItemBase aItem); + + /** + * Format a date/time interval. The returned string may assume that the + * dates are so close to each other, that it can leave out some parts of the + * part string denoting the end date. + * + * @param aStartDate The start of the interval + * @param aEndDate The end of the interval + * @return A String describing the interval in a legible form + */ + AUTF8String formatInterval(in calIDateTime aStartDate, + in calIDateTime aEndDate); + + /** + * Format a time interval like formatInterval, but show only the time. + * + * @param aStartDate The start of the interval. + * @param aEndDate The end of the interval. + * @return The formatted time interval. + */ + AUTF8String formatTimeInterval(in calIDateTime aStartTime, + in calIDateTime aEndTime); + + /** + * Get the monthday followed by its ordinal symbol in the current locale. + * e.g. monthday 1 -> 1st + * monthday 2 -> 2nd etc. + * + * @param aMonthdayIndex + * a number from 1 to 31 + * @returns + * the monthday number in ordinal format in the current locale + */ + AUTF8String formatDayWithOrdinal(in unsigned long aMonthdayIndex); + + /** + * Get the month name + * + * @param aMonthIndex + * zero-based month number (0 is january, 11 is december) + * @returns + * the month name in the current locale + */ + AString monthName(in unsigned long aMonthIndex); + + /** + * Get the abbrevation of the month name + * + * @see monthName + */ + AString shortMonthName(in unsigned long aMonthIndex); + + /** + * Get the day name + * @param aMonthIndex + * zero-based month number (0 is sunday, 6 is saturday) + * @returns + * the day name in the current locale + */ + AString dayName(in unsigned long aDayIndex); + + /** + * Get the abbrevation of the day name + * @see dayName + */ + AString shortDayName(in unsigned long aDayIndex); +}; diff --git a/calendar/base/public/calIDecoratedView.idl b/calendar/base/public/calIDecoratedView.idl new file mode 100644 index 000000000..2aae7584f --- /dev/null +++ b/calendar/base/public/calIDecoratedView.idl @@ -0,0 +1,140 @@ +/* -*- Mode: IDL; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. */ + + +#include "nsISupports.idl" + +interface calICalendar; +interface calIDateTime; +interface calICalendarViewController; +interface calIItemBase; +/** + * calIDecoratedView is an interface for modifying/extending the standard + * calICalendarView, typically to add specific navigation functions while + * removing the unnecessary code duplication this would generally require. + * Because it contains a calICalendarView as an anonymous node, not easily + * accessible, it must therefore expose several of that interface's methods and + * attributes, as well as its own. + * + * @note Implementations of this interface are intended to be the home + * for view preference handling. The lower-level views (ie + * implementers of calICalendarView) are intended to be pure widgetry, + * and this sort of view should be pluggable, meaning that callers + * shouldn't need to know about view-specific preferences. + */ + +[scriptable, uuid(7ba617eb-f19b-400b-9a7d-4156b7c6f028)] +interface calIDecoratedView : nsISupports +{ + /** + * Oftentimes other elements in the DOM in which a calIDecoratedView is + * used want to be aware of whether or not the view is selected. An element + * whose ID is observerID can be included in that DOM, and will be set to be + * enabled or disabled depending on whether the view is selected. + */ + readonly attribute AUTF8String observerID; + + /** + * Generally corresponds to whether or not the view has been previously shown. + * Strictly speaking, it reports whether displayCalendar, startDay and endDay + * are all non-null. + */ + readonly attribute boolean initialized; + + /** + * The displayCalendar of the embedded calICalendarView. This *must* be set + * prior to calling goToDay the first time. + */ + attribute calICalendar displayCalendar; + + /** + * The controller of the calICalendarView that is embedded + */ + attribute calICalendarViewController controller; + + /** + * If this is set to 'true', the view should not display days specified to be + * non-workdays. The implementor is responsible for obtaining what those + * days are on its own. + */ + attribute boolean workdaysOnly; + + /** + * Whether or not tasks are to be displayed in the calICalendarView + */ + attribute boolean tasksInView; + + /** + * If set, the view will be rotated (i.e time on top, date at left) + */ + attribute boolean rotated; + + /** + * Whether or not completed tasks are shown in the calICalendarView + */ + attribute boolean showCompleted; + + /** + * See calICalendarView.idl for the description of these functions. + */ + void getSelectedItems(out unsigned long aCount, + [array,size_is(aCount),retval] out calIItemBase aItems); + void setSelectedItems(in unsigned long aCount, + [array,size_is(aCount)] in calIItemBase aItems, + in boolean aSuppressEvent); + + /** + * The selectedDay in the embedded view. Use the goToDay function to set a + * particular day to be selected. + */ + readonly attribute calIDateTime selectedDay; + + /** + * The first day shown in the embedded view + */ + readonly attribute calIDateTime startDay; + + /** + * The last day shown in the embedded view + */ + readonly attribute calIDateTime endDay; + + /** + * Get or set the timezone that the view's elements should be displayed in. + * Setting this does not refresh the view. + */ + attribute AUTF8String timezone; + + /** + * Ensures that the given date is visible, and that the view is centered + * around this date. aDate becomes the selectedDay of the view. Calling + * this function with the current selectedDay effectively refreshes the view + * + * @param aDate the date that must be shown in the view and becomes + * the selected day + */ + void goToDay(in calIDateTime aDate); + + /** + * Moves the view a specific number of pages. Negative numbers correspond to + * moving the view backwards. Note that it is up to the view to determine + * how the selected day ought to move as well. + * + * @param aNumber the number of pages to move the view + */ + void moveView(in long aNumber); + + /** + * gets the description of the range displayed by the view + */ + AString getRangeDescription(); + + /** + * The type of the view e.g "day", "week", "multiweek" or "month" that refers + * to the displayed time period. + */ + readonly attribute string type; + +}; diff --git a/calendar/base/public/calIDeletedItems.idl b/calendar/base/public/calIDeletedItems.idl new file mode 100644 index 000000000..8045fa24b --- /dev/null +++ b/calendar/base/public/calIDeletedItems.idl @@ -0,0 +1,26 @@ +/* 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/. */ + +#include "nsISupports.idl" + +interface calIDateTime; + +[scriptable, uuid(2414729b-37dc-456e-ba72-f9c33891e6ee)] +interface calIDeletedItems : nsISupports +{ + /** + * Clean the database of all deleted items older than an internal threshold. + */ + void flush(); + + /** + * Gets the time the item with given id was deleted at. If passed, the + * search will be restricted to a certain calendar + * + * @param aId The ID of the item to search for. + * @param aCalId The calendar id to restrict the search to. + * @return The date/time the item was deleted, or null if not found. + */ + calIDateTime getDeletedDate(in AUTF8String aId, [optional] in AUTF8String aCalId); +}; diff --git a/calendar/base/public/calIDuration.idl b/calendar/base/public/calIDuration.idl new file mode 100644 index 000000000..993a82c19 --- /dev/null +++ b/calendar/base/public/calIDuration.idl @@ -0,0 +1,113 @@ +/* -*- Mode: idl; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. */ + +#include "nsISupports.idl" + +[scriptable, uuid(78537f21-fd5c-4e02-ab26-8ff6a3d946cb)] +interface calIDuration : nsISupports +{ + /** + * isMutable is true if this instance is modifiable. + * If isMutable is false, any attempts to modify + * the object will throw CAL_ERROR_ITEM_IS_MUTABLE. + */ + readonly attribute boolean isMutable; + + /** + * Make this calIDuration instance immutable. + */ + void makeImmutable(); + + /** + * Clone this calIDuration instance into a new + * mutable object. + */ + calIDuration clone(); + + /** + * Is Negative + */ + attribute boolean isNegative; + + /** + * Weeks + */ + attribute short weeks; + + /** + * Days + */ + attribute short days; + + /** + * Hours + */ + attribute short hours; + + /** + * Minutes + */ + attribute short minutes; + + /** + * Seconds + */ + attribute short seconds; + + /** + * total duration in seconds + */ + attribute long inSeconds; + + /* + * Methods + */ + + /** + * Add a duration + */ + void addDuration(in calIDuration aDuration); + + /** + * Compare with another duration + * + * @param aOther to be compared with this object + * + * @return -1, 0, 1 if this < aOther, this == aOther, or this > aOther, + * respectively. + */ + long compare(in calIDuration aOther); + + /** + * Reset this duration to 0 + */ + void reset(); + + /** + * Normalize the duration + */ + void normalize(); + + /** + * Return a string representation of this instance. + */ + AUTF8String toString(); + + attribute jsval icalDuration; + + /** + * This object as an iCalendar DURATION string + */ + attribute ACString icalString; +}; + +/** Libical specific interfaces */ + +[ptr] native icaldurationtypeptr(struct icaldurationtype); +[scriptable, uuid(f5e1c987-e722-4dec-bf91-93d4062b504a)] +interface calIDurationLibical : calIDuration +{ + [noscript,notxpcom] void toIcalDuration(in icaldurationtypeptr idt); +}; diff --git a/calendar/base/public/calIErrors.idl b/calendar/base/public/calIErrors.idl new file mode 100644 index 000000000..238322bf0 --- /dev/null +++ b/calendar/base/public/calIErrors.idl @@ -0,0 +1,118 @@ +/* -*- Mode: IDL; tab-width: 50; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. */ + +#include "nsISupports.idl" + +[scriptable, uuid(404c7d78-bec7-474c-aa2a-82c0d0563bb6)] +interface calIErrors : nsISupports +{ + /** + * The first two constants are copied from nsError.h, but named slightly + * differently, because if they're named the same, the names collide and + * the compiler can't deal. + */ + const unsigned long CAL_ERROR_MODULE_CALENDAR = 5; + const unsigned long CAL_ERROR_MODULE_BASE_OFFSET = 0x45; + + /** + * The beginning of this set of error codes, also copied from the macros + * in nsError.h. + */ + const unsigned long ERROR_BASE = (1<<31) | + (CAL_ERROR_MODULE_CALENDAR + CAL_ERROR_MODULE_BASE_OFFSET) << 16; + + /* Onto the actual errors! */ + + /** + * An invalid or nonexistent timezone was encountered. + */ + const unsigned long INVALID_TIMEZONE = ERROR_BASE + 1; + + /** + * Attempted to modify a readOnly calendar. + */ + const unsigned long CAL_IS_READONLY = ERROR_BASE + 2; + + /** + * Error while decoding an (ics) file from utf8 + */ + const unsigned long CAL_UTF8_DECODING_FAILED = ERROR_BASE + 3; + + /** + * Tried to add an item to a calendar in which an item with the + * same ID already existed + */ + const unsigned long DUPLICATE_ID = ERROR_BASE + 4; + + /** + * Operation has been cancelled. + */ + const unsigned long OPERATION_CANCELLED = ERROR_BASE + 5; + + /** + * Creation of calendar object failed + */ + const unsigned long PROVIDER_CREATION_FAILED = ERROR_BASE + 6; + + /** + * Profile data has newer schema than we know in this calendar version. + */ + const unsigned long STORAGE_UNKNOWN_SCHEMA_ERROR = ERROR_BASE + 7; + + /** + * Profile data may refer to newer timezones than we know. + */ + const unsigned long STORAGE_UNKNOWN_TIMEZONES_ERROR = ERROR_BASE + 8; + + /** + * The calendar could not be accessed for reading. + */ + const unsigned long READ_FAILED = ERROR_BASE + 9; + + /** + * The calendar could not be accessed for modification. + */ + const unsigned long MODIFICATION_FAILED = ERROR_BASE + 10; + + /* ICS specific errors */ + const unsigned long ICS_ERROR_BASE = ERROR_BASE + 0x100; + + /** + * ICS errors, copied from icalerror.h. + * The numbers (minus ICS_ERROR_BASE) should match with the enum + * values from icalerror.h + */ + const unsigned long ICS_NO_ERROR = ICS_ERROR_BASE + 0; + const unsigned long ICS_BADARG = ICS_ERROR_BASE + 1; + const unsigned long ICS_NEWFAILED = ICS_ERROR_BASE + 2; + const unsigned long ICS_ALLOCATION = ICS_ERROR_BASE + 3; + const unsigned long ICS_MALFORMEDDATA = ICS_ERROR_BASE + 4; + const unsigned long ICS_PARSE = ICS_ERROR_BASE + 5; + const unsigned long ICS_INTERNAL = ICS_ERROR_BASE + 6; + const unsigned long ICS_FILE = ICS_ERROR_BASE + 7; + const unsigned long ICS_USAGE = ICS_ERROR_BASE + 8; + const unsigned long ICS_UNIMPLEMENTED = ICS_ERROR_BASE + 9; + const unsigned long ICS_UNKNOWN = ICS_ERROR_BASE + 10; + + /** + * WCAP specific errors, defined in + * calendar/providers/wcap/public/calIWcapErrors.idl + * Range claimed is [ERROR_BASE + 0x200, ERROR_BASE + 0x300) + */ + const unsigned long WCAP_ERROR_BASE = ERROR_BASE + 0x200; + + /** + * (Cal)DAV specific errors + * Range is [ERROR_BASE + 0x301, ERROR_BASE + 0x399] + */ + const unsigned long DAV_ERROR_BASE = ERROR_BASE + 0x301; + const unsigned long DAV_NOT_DAV = DAV_ERROR_BASE + 0; + const unsigned long DAV_DAV_NOT_CALDAV = DAV_ERROR_BASE + 1; + const unsigned long DAV_NO_PROPS = DAV_ERROR_BASE + 2; + const unsigned long DAV_PUT_ERROR = DAV_ERROR_BASE + 3; + const unsigned long DAV_REMOVE_ERROR = DAV_ERROR_BASE + 4; + const unsigned long DAV_REPORT_ERROR = DAV_ERROR_BASE + 5; +}; + diff --git a/calendar/base/public/calIEvent.idl b/calendar/base/public/calIEvent.idl new file mode 100644 index 000000000..d9bbd5ef8 --- /dev/null +++ b/calendar/base/public/calIEvent.idl @@ -0,0 +1,42 @@ +/* -*- Mode: idl; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. */ + +#include "calIItemBase.idl" + +interface calIDuration; + +// +// calIEvent +// +// An interface for an event (analogous to a VEVENT) +// + +[scriptable, uuid(5ab15c1c-e295-4d8e-a9a9-ba5bc848b59a)] +interface calIEvent : calIItemBase +{ + // these attributes are marked readonly, as the calIDates are owned + // by the event; however, the actual calIDate objects are not read + // only and are intended to be manipulated to adjust dates. + + /** + * The (inclusive) start of the event. + */ + attribute calIDateTime startDate; + + /** + * The (non-inclusive) end of the event. + * Note that for all-day events, non-inclusive means that this will be set + * to the day after the last day of the event. + * If startDate.isDate is set, endDate.isDate must also be set. + */ + attribute calIDateTime endDate; + + /** + * The duration of the event. + * equal to endDate - startDate + */ + readonly attribute calIDuration duration; + +}; diff --git a/calendar/base/public/calIFreeBusyProvider.idl b/calendar/base/public/calIFreeBusyProvider.idl new file mode 100644 index 000000000..5215f5499 --- /dev/null +++ b/calendar/base/public/calIFreeBusyProvider.idl @@ -0,0 +1,109 @@ +/* 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/. */ + +#include "nsISupports.idl" + +interface calIDateTime; +interface calIPeriod; +interface calIOperation; +interface calIGenericOperationListener; + +[scriptable, uuid(EB24424C-DD22-4306-9379-FA098C61F5AF)] +interface calIFreeBusyProvider : nsISupports +{ + /** + * Gets free/busy intervals. + * Results are notified to the passed listener interface. + * + * @param aCalId calid or MAILTO:rfc822addr + * @param aRangeStart start time of free-busy search + * @param aRangeEnd end time of free-busy search + * @param aBusyTypes what free-busy intervals should be returned + * @param aListener called with an array of calIFreeBusyInterval objects + * @return optional operation handle to track the operation + */ + calIOperation getFreeBusyIntervals(in AUTF8String aCalId, + in calIDateTime aRangeStart, + in calIDateTime aRangeEnd, + in unsigned long aBusyTypes, + in calIGenericOperationListener aListener); +}; + +/** + * This interface reflects a free or busy interval in time. + * Referring to RFC 2445, section 4.2.9, for the different types. + */ +[scriptable, uuid(CCBEAF5E-DB87-4bc9-8BB7-24754B76BCB5)] +interface calIFreeBusyInterval : nsISupports +{ + /** + * The calId this free-busy period belongs to. + */ + readonly attribute AUTF8String calId; + + /** + * The free-busy time interval. + */ + readonly attribute calIPeriod interval; + + /** + * The value UNKNOWN indicates that the free-busy information for the time interval is + * not known. + */ + const unsigned long UNKNOWN = 0; + + /** + * The value FREE indicates that the time interval is free for scheduling. + */ + const unsigned long FREE = 1; + + /** + * The value BUSY indicates that the time interval is busy because one + * or more events have been scheduled for that interval. + */ + const unsigned long BUSY = 1 << 1; + + /** + * The value BUSY_UNAVAILABLE indicates that the time interval is busy + * and that the interval can not be scheduled. + */ + const unsigned long BUSY_UNAVAILABLE = 1 << 2; + + /** + * The value BUSY_TENTATIVE indicates that the time interval is busy because + * one or more events have been tentatively scheduled for that interval. + */ + const unsigned long BUSY_TENTATIVE = 1 << 3; + + /** + * All BUSY* states. + */ + const unsigned long BUSY_ALL = (BUSY | + BUSY_UNAVAILABLE | + BUSY_TENTATIVE); + + /** + * One of the above types. + */ + readonly attribute unsigned long freeBusyType; +}; + +/** + * This service acts as a central access point for free-busy lookup. + * A free-busy request will be multiplexed to all added free-busy providers. + * Adding a free-busy provider is transient. + */ +[scriptable, uuid(BE1796CF-CB53-482e-8942-D6CAA0A11BAA)] +interface calIFreeBusyService : calIFreeBusyProvider +{ + /** + * Adds a new free-busy provider. + */ + void addProvider(in calIFreeBusyProvider aProvider); + + /** + * Removes a free-busy provider. + */ + void removeProvider(in calIFreeBusyProvider aProvider); +}; diff --git a/calendar/base/public/calIICSService.idl b/calendar/base/public/calIICSService.idl new file mode 100644 index 000000000..454d1a4fc --- /dev/null +++ b/calendar/base/public/calIICSService.idl @@ -0,0 +1,280 @@ +/* -*- Mode: idl; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* 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 use strings for kind values instead of enumerated constants? + + +#include "nsISupports.idl" + +interface calIItemBase; +interface calIDateTime; +interface calIDuration; +interface calITimezone; +interface calITimezoneProvider; + +interface calIIcalProperty; +interface nsIUTF8StringEnumerator; +interface nsIInputStream; + +/** + * General notes: + * + * As with libical, use of getNextFoo(footype) is only valid if there have been + * no intervening getNextFoo(otherfootype)s, or removeFoo()s, or addFoo()s. In + * general, you want to do as little manipulation of your FooContainers as + * possible while iterating over them. + */ +[scriptable,uuid(59132cf2-e48c-4807-ab53-779f414a7fbc)] +interface calIIcalComponent : nsISupports +{ + /** + * The parent ical property + */ + readonly attribute calIIcalComponent parent; + + /** + * Access to the inner ical.js objects. Only use these if you know what you + * are doing. + */ + attribute jsval icalComponent; + attribute jsval icalTimezone; + + /** + * This is the value that an integer-valued getter will provide if + * there is no such property on the wrapped ical structure. + */ + const int32_t INVALID_VALUE = -1; + + /** + * @param kind ANY, XROOT, VCALENDAR, VEVENT, etc. + */ + calIIcalComponent getFirstSubcomponent(in AUTF8String componentType); + calIIcalComponent getNextSubcomponent(in AUTF8String componentType); + + readonly attribute AUTF8String componentType; + + attribute AUTF8String uid; + attribute AUTF8String prodid; + attribute AUTF8String version; + + /** + * PUBLISH, REQUEST, REPLY, etc. + */ + attribute AUTF8String method; + + /** + * TENTATIVE, CONFIRMED, CANCELLED, etc. + */ + attribute AUTF8String status; + + attribute AUTF8String summary; + attribute AUTF8String description; + attribute AUTF8String location; + attribute AUTF8String categories; + attribute AUTF8String URL; + + attribute int32_t priority; + + attribute calIDateTime startTime; + attribute calIDateTime endTime; + readonly attribute calIDuration duration; + attribute calIDateTime dueTime; + attribute calIDateTime stampTime; + + attribute calIDateTime createdTime; + attribute calIDateTime completedTime; + attribute calIDateTime lastModified; + + /** + * The recurrence ID, a.k.a. DTSTART-of-calculated-occurrence, + * or null if this isn't an occurrence. + */ + attribute calIDateTime recurrenceId; + + AUTF8String serializeToICS(); + + /** + * Return a string representation of this instance. + */ + AUTF8String toString(); + + /** + * Serializes this component (and subcomponents) directly to an + * input stream. Typically used for performance to avoid + * unnecessary conversions and XPConnect traversals. + * + * @result an input stream which can be read to get the serialized + * version of this component, encoded in UTF-8. Implements + * nsISeekableStream so that it can be used with + * nsIUploadChannel. + */ + nsIInputStream serializeToICSStream(); + + void addSubcomponent(in calIIcalComponent comp); +// If you add then remove a property/component, the referenced +// timezones won't get purged out. There's currently no client code. +// void removeSubcomponent(in calIIcalComponent comp); + + /** + * @param kind ANY, ATTENDEE, X-WHATEVER, etc. + */ + calIIcalProperty getFirstProperty(in AUTF8String kind); + calIIcalProperty getNextProperty(in AUTF8String kind); + void addProperty(in calIIcalProperty prop); +// If you add then remove a property/component, the referenced +// timezones won't get purged out. There's currently no client code. +// void removeProperty(in calIIcalProperty prop); + + /** + * Timezones need special handling, as they must be + * emitted as children of VCALENDAR, but can be referenced by + * any sub component. + * Adding a second timezone (of the same TZID) will remove the + * first one. + */ + void addTimezoneReference(in calITimezone aTimezone); + + /** + * Returns an array of VTIMEZONE components. + * These are the timezones that are in use by this + * component and its children. + */ + void getReferencedTimezones(out uint32_t aCount, + [array,size_is(aCount),retval] out calITimezone aTimezones); + + /** + * Clones the component. The cloned component is decoupled from any parent. + * @return cloned component + */ + calIIcalComponent clone(); +}; + +[scriptable,uuid(5b13a69c-53d3-44a0-9203-f89f7e5e1604)] +interface calIIcalProperty : nsISupports +{ + /** + * The whole property as an ical string. + * @exception Any libical error will be thrown as an calIError::ICS_ error. + */ + readonly attribute AUTF8String icalString; + + /** + * Access to the inner ical.js objects. Only use these if you know what you + * are doing. + */ + attribute jsval icalProperty; + + /** + * The parent component containing this property + */ + readonly attribute calIIcalComponent parent; + + /** + * Return a string representation of this instance. + */ + AUTF8String toString(); + + /** + * The value of the property as string. + * The exception for properties of TEXT or X- type, those will be unescaped + * when getting, and also expects an unescaped string when setting. + * Datetime, numeric and other non-text types are represented as ical string + */ + attribute AUTF8String value; + + /** + * The value of the property in (escaped) ical format. + */ + attribute AUTF8String valueAsIcalString; + + /** + * The value of the property as date/datetime value, keeping + * track of the used timezone referenced in the owning component. + */ + attribute calIDateTime valueAsDatetime; + + // XXX attribute AUTF8String stringValueWithParams; ? + readonly attribute AUTF8String propertyName; + + AUTF8String getParameter(in AUTF8String paramname); + void setParameter(in AUTF8String paramname, in AUTF8String paramval); + + AUTF8String getFirstParameterName(); + AUTF8String getNextParameterName(); + + void removeParameter(in AUTF8String paramname); + void clearXParameters(); +}; + +[scriptable,uuid(eda9565f-f9bb-4846-b134-1e0653b2e767)] +interface calIIcsComponentParsingListener : nsISupports +{ + /** + * Called when the parsing has completed. + * + * @param rc The result code of parsing + * @param rootComp The root ical component that was parsed + */ + void onParsingComplete(in nsresult rc, in calIIcalComponent rootComp); +}; + +[scriptable,uuid(31e7636b-5a64-4d15-bc60-67b67cd85176)] +interface calIICSService : nsISupports +{ + /** + * Parses an ICS string and uses the passed tzProvider instance to + * resolve timezones not contained withing the VCALENDAR. + * + * @param serialized an ICS string + * @param tzProvider timezone provider used to resolve TZIDs + * not contained within the VCALENDAR; + * if null is passed, parsing falls back to + * using the timezone service + */ + calIIcalComponent parseICS(in AUTF8String serialized, + in calITimezoneProvider tzProvider); + + /** + * Asynchronously parse an ICS string + * + * @param serialized an ICS string + * @param tzProvider timezone provider used to resolve TZIDs + * not contained within the VCALENDAR; + * if null is passed, parsing falls back to + * using the timezone service + * @param listener The listener that notifies the root component + */ + void parseICSAsync(in AUTF8String serialized, + in calITimezoneProvider tzProvider, + in calIIcsComponentParsingListener listener); + + calIIcalComponent createIcalComponent(in AUTF8String kind); + calIIcalProperty createIcalProperty(in AUTF8String kind); + calIIcalProperty createIcalPropertyFromString(in AUTF8String str); + /* I wish I could write this function atop libical! + boolean isLegalParameterValue(in AUTF8String paramKind, + in AUTF8String paramValue); + */ +}; + +/** Libical specific interfaces */ + +[ptr] native icalpropertyptr(struct icalproperty_impl); +[ptr] native icalcomponentptr(struct icalcomponent_impl); +[ptr] native icaltimezoneptr(struct _icaltimezone); + +[scriptable,uuid(d2fc0264-191e-435e-8ef2-b2ab1fa81ca9)] +interface calIIcalComponentLibical : calIIcalComponent +{ + [noscript,notxpcom] icalcomponentptr getLibicalComponent(); + [noscript,notxpcom] icaltimezoneptr getLibicalTimezone(); +}; + +[scriptable,uuid(e0b9067f-0a53-4724-9c69-63599681877e)] +interface calIIcalPropertyLibical : calIIcalProperty +{ + [noscript,notxpcom] icalpropertyptr getLibicalProperty(); + [noscript,notxpcom] icalcomponentptr getLibicalComponent(); +}; diff --git a/calendar/base/public/calIIcsParser.idl b/calendar/base/public/calIIcsParser.idl new file mode 100644 index 000000000..dd2c3ed7d --- /dev/null +++ b/calendar/base/public/calIIcsParser.idl @@ -0,0 +1,112 @@ +/* -*- Mode: idl; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* 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/. */ + +#include "nsISupports.idl" + +interface calIIcalProperty; +interface calIIcalComponent; +interface calIItemBase; +interface nsIInputStream; +interface calITimezoneProvider; +interface calIIcsParser; + +/** + * Listener being called once asynchronous parsing is done. + */ +[scriptable, uuid(d22527da-b0e2-41b7-b6f4-ee9c243cd285)] +interface calIIcsParsingListener : nsISupports +{ + void onParsingComplete(in nsresult rc, in calIIcsParser parser); +}; + +/** + * An interface for parsing an ics string or stream into its items. + * Note that this is not a service. A new instance must be created for every new + * string or stream to be parsed. + */ +[scriptable, uuid(83e9befe-5e9e-49de-8bc2-d882f464f7e7)] +interface calIIcsParser : nsISupports +{ + /** + * Parse an ics string into its items, and store top-level properties and + * components that are not interpreted. + * + * @param aICSString + * The ICS string to parse + * @param optional aTzProvider + * The timezone provider used to resolve timezones not contained in the + * parent VCALENDAR or null (falls back to timezone service) + * @param optional aAsyncParsing + * If non-null, parsing will be performed on a worker thread, + * and the passed listener is called when it's done + */ + void parseString(in AString aICSString, + [optional] in calITimezoneProvider aTzProvider, + [optional] in calIIcsParsingListener aAsyncParsing); + + /** + * Parse an input stream. + * + * @see parseString + * @param aICSString + * The stream to parse + * @param optional aTzProvider + * The timezone provider used to resolve timezones not contained in the + * parent VCALENDAR or null (falls back to timezone service) + * @param optional aAsyncParsing + * If non-null, parsing will be performed on a worker thread, + * and the passed listener is called when it's done + */ + void parseFromStream(in nsIInputStream aStream, + [optional] in calITimezoneProvider aTzProvider, + [optional] in calIIcsParsingListener aAsyncParsing); + + /** + * Get the items that were in the string or stream. In case an item represents a + * recurring series, the (unexpanded) parent item is returned only. + * Please keep in mind that any parentless items (see below) are not contained + * in the returned set of items. + * + * @param aCount + * Will hold the number of items that were parsed + * @param aItems + * The items + */ + void getItems(out uint32_t aCount, + [array,size_is(aCount),retval] out calIItemBase aItems); + + /** + * Get the parentless items that may have occurred, i.e. overridden items of a + * recurring series (having a RECURRENCE-ID) missing their parent item in the + * parsed content. + * + * @param aCount + * Will hold the number of items that were parsed + * @param aItems + * The items + */ + void getParentlessItems(out uint32_t aCount, + [array,size_is(aCount),retval] out calIItemBase aItems); + + /** + * Get the top-level properties that were not interpreted as anything special + * @param aCount + * Will hold the number of properties that were found + * @param aProperties + * The properties + */ + void getProperties(out uint32_t aCount, + [array,size_is(aCount),retval] out calIIcalProperty aProperties); + + /** + * Get the top-level components that were not interpreted as anything special + * @param aCount + * Will hold the number of components that were found + * @param aComponents + * The components + */ + void getComponents(out uint32_t aCount, + [array,size_is(aCount),retval] out calIIcalComponent aComponents); +}; diff --git a/calendar/base/public/calIIcsSerializer.idl b/calendar/base/public/calIIcsSerializer.idl new file mode 100644 index 000000000..73c33d929 --- /dev/null +++ b/calendar/base/public/calIIcsSerializer.idl @@ -0,0 +1,76 @@ +/* -*- Mode: idl; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* 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/. */ + +#include "nsISupports.idl" + +interface calIIcalProperty; +interface calIIcalComponent; +interface calIItemBase; +interface nsIOutputStream; +interface nsIInputStream; + +/** + * An interface for serializing calendar items into an ICS string. + * Note that this is not a service. A new instance must be created for every new + * set of items to be serialized. + */ +[scriptable, uuid(4dcf6b4e-7322-4a61-a191-8d8cc1aea42e)] +interface calIIcsSerializer : nsISupports +{ + /** + * Add some items to the items that are to be serialized. Can be called + * multiple times, and appends to the set on every call. + * + * @param aItems + * The items to be added + * @param aCount + * The number of items to add + */ + void addItems([array, size_is(aCount)] in calIItemBase aItems, + in unsigned long aCount); + + /** + * Add a property to the top-level properties to be added on serializing. Can + * be called multiple times, and appends to the set on every call. + * + * @param aProperty + * The property to be added + */ + void addProperty(in calIIcalProperty aProperty); + + /** + * Add a component to the top-level components to be added on serializing. Can + * be called multiple times, and appends to the set on every call. + * + * @param aComponent + * The component to be added + */ + void addComponent(in calIIcalComponent aComponent); + + /** + * Serialize the added items, properties and components into an ICS string + * + * @returns + * A string containing the serialized items, properties and components. + */ + AString serializeToString(); + + /** + * Serialize the added items, properties and components into an ICS stream + * + * @returns + * A stream containing the serialized items, properties and components. + */ + nsIInputStream serializeToInputStream(); + + /** + * Serialize the added items, properties and components into an ICS stream + * + * @param aStream + * A stream into which the serialized items, properties and components + * will be written. + */ + void serializeToStream(in nsIOutputStream aStream); +}; diff --git a/calendar/base/public/calIImportExport.idl b/calendar/base/public/calIImportExport.idl new file mode 100644 index 000000000..49bd77274 --- /dev/null +++ b/calendar/base/public/calIImportExport.idl @@ -0,0 +1,64 @@ +/* -*- Mode: IDL; tab-width: 20; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. */ + + +#include "nsISupports.idl" + +interface calIItemBase; +interface nsIInputStream; +interface nsIOutputStream; + +[scriptable, uuid(efef8333-e995-4f45-bdf7-bfcabbd9793e)] +interface calIFileType : nsISupports +{ + /** + * The default extension that should be associated + * with files of this type. + */ + readonly attribute AString defaultExtension; + + /** + * The extension filter to use in the filepicker's filter list. + * Separate multiple extensions with semicolon and space. + * For example "*.html; *.htm". + */ + readonly attribute AString extensionFilter; + + /** + * The description to show to the user in the filter list. + */ + readonly attribute AString description; +}; + +[scriptable, uuid(dbe262ca-d6c6-4691-8d46-e7f6bbe632ec)] +interface calIImporter : nsISupports +{ + void getFileTypes(out unsigned long aCount, + [retval, array, size_is(aCount)] out calIFileType aTypes); + + void importFromStream(in nsIInputStream aStream, + out unsigned long aCount, + [retval, array, size_is(aCount)] out calIItemBase aItems); +}; + +[scriptable, uuid(18c75bb3-6309-4c33-903f-6055fec39d07)] +interface calIExporter : nsISupports +{ + void getFileTypes(out unsigned long aCount, + [retval, array, size_is(aCount)] out calIFileType aTypes); + + /** + * Export the items into the stream + * + * @param aStream the stream to put the data into + * @param aCount the number of items being exported + * @param aItems an array of items to be exported + * @param aTitle a title the exporter can choose to use + */ + void exportToStream(in nsIOutputStream aStream, + in unsigned long aCount, + [array, size_is(aCount)] in calIItemBase aItems, + in AString aTitle); +}; diff --git a/calendar/base/public/calIItemBase.idl b/calendar/base/public/calIItemBase.idl new file mode 100644 index 000000000..6641f684e --- /dev/null +++ b/calendar/base/public/calIItemBase.idl @@ -0,0 +1,352 @@ +/* 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/. */ + +#include "nsISupports.idl" + +interface nsISimpleEnumerator; +interface nsIVariant; + +interface nsIPropertyBag; + +interface calIItemACLEntry; +interface calIAlarm; +interface calIAttachment; +interface calIAttendee; +interface calICalendar; +interface calIDateTime; +interface calIDuration; +interface calIIcalComponent; +interface calIRecurrenceInfo; +interface calIRelation; + +// +// calIItemBase +// +// Base for Events, Todos, Journals, etc. +// + +[scriptable, uuid(9c988b8d-af45-4046-b05e-34417bba9058)] +interface calIItemBase : nsISupports +{ + // returns true if this thing is able to be modified; + // if the item is not mutable, attempts to modify + // any data will throw CAL_ERROR_ITEM_IS_IMMUTABLE + readonly attribute boolean isMutable; + + // makes this item immutable + void makeImmutable(); + + // clone always returns a mutable event + calIItemBase clone(); + + /** + * Hash Id that incorporates the item's UID, RECURRENCE-ID and calendar.id + * to be used for lookup of items that come from different calendars. + * Setting either id, recurrenceId or the calendar attribute leads to + * a recomputation of hashId. + * + * @attention Individual implementors of calIItemBase must stick to the + * same algorithm that base/src/calItemBase.js uses. + */ + readonly attribute AUTF8String hashId; + + /** + * Checks whether the argument object refers the same calendar item as + * this one, by testing both the id and recurrenceId property. This + * + * @arg aItem the item to compare against this one + * + * @return true if both ids match, false otherwise + */ + boolean hasSameIds(in calIItemBase aItem); + + /** + * Returns the acl entry associated to the item. + */ + readonly attribute calIItemACLEntry aclEntry; + + // + // the generation number of this item + // + attribute uint32_t generation; + + // the time when this item was created + readonly attribute calIDateTime creationDate; + + // last time any attribute was modified on this item, in UTC + readonly attribute calIDateTime lastModifiedTime; + + // last time a "significant change" was made to this item + readonly attribute calIDateTime stampTime; + + // the calICalendar to which this event belongs + attribute calICalendar calendar; + + // the ID of this event + attribute AUTF8String id; + + // event title + attribute AUTF8String title; + + // event priority + attribute short priority; + attribute AUTF8String privacy; + + // status of the event + attribute AUTF8String status; + + // ical interop; writing this means parsing + // the ical string into this event + attribute AUTF8String icalString; + + // an icalComponent for this item, suitable for serialization. + // the icalComponent returned is not live: changes in it or this + // item will not be reflected in the other. + attribute calIIcalComponent icalComponent; + + // + // alarms + // + + /** + * Get all alarms assigned to this item + * + * @param count The number of alarms + * @param aAlarms The array of calIAlarms + */ + void getAlarms(out uint32_t count, [array, size_is(count), retval] out calIAlarm aAlarms); + + /** + * Add an alarm to the item + * + * @param aAlarm The calIAlarm to add + */ + void addAlarm(in calIAlarm aAlarm); + + /** + * Delete an alarm from the item + * + * @param aAlarm The calIAlarm to delete + */ + void deleteAlarm(in calIAlarm aAlarm); + + /** + * Clear all alarms from the item + */ + void clearAlarms(); + + // The last time this alarm was fired and acknowledged by the user; coerced to UTC. + attribute calIDateTime alarmLastAck; + + // + // recurrence + // + attribute calIRecurrenceInfo recurrenceInfo; + readonly attribute calIDateTime recurrenceStartDate; + + // + // All event properties are stored in a property bag; + // some number of these are "promoted" to top-level + // accessor attributes. For example, "SUMMARY" is + // promoted to the top-level "title" attribute. + // + // If you use the has/get/set/deleteProperty + // methods, property names are case-insensitive. + // + // For purposes of ICS serialization, all property names in + // the hashbag are in uppercase. + // + // The isPropertyPromoted() attribute can will indicate + // if a particular property is promoted or not, for + // serialization purposes. + // + + // Note that if this item is a proxy, then any requests for + // non-existant properties will be forward to the parent item. + + // some other properties that may exist: + // + // 'description' - description (string) + // 'location' - location (string) + // 'categories' - categories (string) + // 'syncId' - sync id (string) + // 'inviteEmailAddress' - string + // alarmLength/alarmUnits/alarmEmailAddress/lastAlarmAck + // recurInterval/recurCount/recurWeekdays/recurWeeknumber + + // these forward to an internal property bag; implemented here, so we can + // do access control on set/delete to have control over mutability. + readonly attribute nsISimpleEnumerator propertyEnumerator; + boolean hasProperty(in AString name); + + /** + * Gets a particular property. + * Objects passed back are still owned by the item, e.g. if callers need to + * store or modify a calIDateTime they must clone it. + */ + nsIVariant getProperty(in AString name); + + /** + * Sets a particular property. + * Ownership of objects gets passed to the item, e.g. callers must not + * modify a calIDateTime after it's been passed to an item. + * + * @warning this reflects the current implementation + * xxx todo: rethink whether it's more sensible to store + * clones in calItemBase. + */ + void setProperty(in AString name, in nsIVariant value); + + // will not throw an error if you delete a property that doesn't exist + void deleteProperty(in AString name); + + // returns true if the given property is promoted to some + // top-level attribute (e.g. id or title) + boolean isPropertyPromoted(in AString name); + + /** + * Returns a particular parameter value for a property, or null if the + * parameter does not exist. If the property does not exist, throws. + * + * @param aPropertyName the name of the property + * @param aParameterName the name of the parameter on the property + */ + AString getPropertyParameter(in AString aPropertyName, + in AString aParameterName); + + /** + * Checks if the given property has the given parameter. + * + * @param aPropertyName The name of the property. + * @param aParameterName The name of the parameter on the property. + * @return True, if the parameter exists on the property + */ + boolean hasPropertyParameter(in AString aPropertyName, + in AString aParameterName); + + /** + * Sets a particular parameter value for a property, or unsets if null is + * passed. If the property does not exist, throws. + * + * @param aPropertyName The name of the property + * @param aParameterName The name of the parameter on the property + * @param aParameterValue The value of the parameter to set + */ + void setPropertyParameter(in AString aPropertyName, + in AString aParameterName, + in AUTF8String aParameterValue); + + /** + * Returns a property parameter enumerator for the given property name + * + * @param aPropertyName The name of the property. + * @return The parameter enumerator. + */ + nsISimpleEnumerator getParameterEnumerator(in AString aPropertyName); + + /** + * The organizer (originator) of the item. We will likely not + * honour or preserve all fields in the calIAttendee passed around here. + * A base class like calIPerson might be more appropriate here, if we ever + * grow one. + */ + attribute calIAttendee organizer; + + // + // Attendees + // + + // The array returned here is not live; it will not reflect calls to + // removeAttendee/addAttendee that follow the call to getAttendees. + void getAttendees(out uint32_t count, + [array,size_is(count),retval] out calIAttendee attendees); + + /** + * getAttendeeById's matching is done in a case-insensitive manner to handle + * places where "MAILTO:" or similar properties are capitalized arbitrarily + * by different calendar clients. + */ + calIAttendee getAttendeeById(in AUTF8String id); + void addAttendee(in calIAttendee attendee); + void removeAttendee(in calIAttendee attendee); + void removeAllAttendees(); + + // + // Attachments + // + void getAttachments(out uint32_t count, + [array,size_is(count),retval] out calIAttachment attachments); + void addAttachment(in calIAttachment attachment); + void removeAttachment(in calIAttachment attachment); + void removeAllAttachments(); + + // + // Categories + // + + /** + * Gets the array of categories this item belongs to. + */ + void getCategories(out uint32_t aCount, + [array, size_is(aCount), retval] out wstring aCategories); + + /** + * Sets the array of categories this item belongs to. + */ + void setCategories(in uint32_t aCount, + [array, size_is(aCount)] in wstring aCategories); + + // + // Relations + // + + /** + * This gives back every relation where the item is neighter the owner of the + * relation nor the referred relation + */ + void getRelations(out uint32_t count, + [array,size_is(count),retval] out calIRelation relations); + + /** + * Adds a relation to the item + */ + void addRelation(in calIRelation relation); + + /** + * Removes the relation for this item and the referred item + */ + void removeRelation(in calIRelation relation); + + /** + * Removes every relation for this item (in this items and also where it is referred + */ + void removeAllRelations(); + + // Occurrence querying + // + + /** + * Return a list of occurrences of this item between the given dates. The items + * returned are the same type as this one, as proxies. + */ + void getOccurrencesBetween (in calIDateTime aStartDate, in calIDateTime aEndDate, + out uint32_t aCount, + [array,size_is(aCount),retval] out calIItemBase aOccurrences); + + /** + * If this item is a proxy or overridden item, parentItem will point upwards + * to our parent. Otherwise, it will point to this. + * parentItem can thus always be used for modifyItem() calls + * to providers. + */ + attribute calIItemBase parentItem; + + /** + * The recurrence ID, a.k.a. DTSTART-of-calculated-occurrence, + * or null if this isn't an occurrence. + * Be conservative about setting this. It isn't marked as such, but + * consider it as readonly. + */ + attribute calIDateTime recurrenceId; +}; diff --git a/calendar/base/public/calIItipItem.idl b/calendar/base/public/calIItipItem.idl new file mode 100644 index 000000000..c53e6854c --- /dev/null +++ b/calendar/base/public/calIItipItem.idl @@ -0,0 +1,114 @@ +/* -*- Mode: idl; tab-width: 20; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* 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/. */ + +#include "nsISupports.idl" + +interface calIItemBase; +interface calICalendar; +interface nsISimpleEnumerator; + +/** + * calIItipItem is an interface used to carry information between the mime + * parser, the imip-bar UI, and the iTIP processor. It encapsulates a list of + * calIItemBase objects and provides specialized iTIP methods for those items. + */ +[scriptable, uuid(7539c158-c30d-41d0-90e9-41d315ac3eb1)] +interface calIItipItem : nsISupports +{ + /** + * Initializes the item with an ics string + * @param - in parameter - AString of ical Data + */ + void init(in AUTF8String icalData); + + /** + * Creates a new calItipItem with the same attributes as the one that + * clone() is called upon. + */ + calIItipItem clone(); + + /** + * Attribute: isSend - set to TRUE when sending this item to initiate an + * iMIP communication. This will be used by the iTIP processor to route + * the item directly to the email subsystem so that communication can be + * initiated. For example, if you are Sending a REQUEST, you would set + * this flag, and send the iTIP Item into the iTIP processor, which would + * handle everything else. + */ + attribute boolean isSend; + + /** + * Attribute: sender - set to the email address of the sender if part of an + * iMIP communication. + */ + attribute AUTF8String sender; + + /** + * Attribute: receivedMethod - method the iTIP item had upon reciept + */ + attribute AUTF8String receivedMethod; + + /** + * Attribute: responseMethod - method that the protocol handler (or the + * user) decides to use to respond to the iTIP item (could be COUNTER, + * REPLY, DECLINECOUNTER, etc) + */ + attribute AUTF8String responseMethod; + + /** + * Attribute: autoResponse Set to one of the three constants below + */ + attribute unsigned long autoResponse; + + /** + * Used to tell the iTIP processor to use an automatic response when + * handling this iTIP item + */ + const unsigned long AUTO = 0; + + /** + * Used to tell the iTIP processor to allow the user to edit the response + */ + const unsigned long USER = 1; + + /** + * Used to tell the iTIP processor not to respond at all. + */ + const unsigned long NONE = 2; + + /** + * Attribute: targetCalendar - the calendar that this thing should be + * stored in, if it should be stored onto a calendar. This is a calendar ID + */ + attribute calICalendar targetCalendar; + + /** + * The identity this item was received on. Helps to determine which + * attendee to manipulate. This should be the full email address of the + * attendee that is considered to be the local user. + */ + attribute AUTF8String identity; + + /** + * localStatus: The response that the user has made to the invitation in + * this ItipItem. + */ + attribute AUTF8String localStatus; + + /** + * Get the list of items that are encapsulated in this calIItipItem + * @returns An array of calIItemBase items that are inside this + * calIItipItem + */ + void getItemList(out unsigned long itemCount, + [retval, array, size_is(itemCount)] out calIItemBase items); + + /** + * Modifies the state of the given attendee in the item's ics + * @param attendeeId - AString containing attendee address + * @param status - AString containing the new attendee status + */ + void setAttendeeStatus(in AString attendeeId, in AString status); +}; diff --git a/calendar/base/public/calIItipTransport.idl b/calendar/base/public/calIItipTransport.idl new file mode 100644 index 000000000..76934bd54 --- /dev/null +++ b/calendar/base/public/calIItipTransport.idl @@ -0,0 +1,48 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "nsISupports.idl" + +interface calIItipItem; +interface calIAttendee; +interface calIDateTime; + +/** + * calIItipTransport is a generic transport interface that is implemented + * by transports (eg: email, XMPP, etc.) wishing to send calIItipItems + */ +[scriptable, uuid(caedabb9-d886-4814-ada5-a5636d2fb939)] +interface calIItipTransport : nsISupports +{ + /** + * Scheme to be used to prefix attendees. For example, the Email transport + * should return "mailto". + */ + readonly attribute AUTF8String scheme; + + /** + * Sending identity. This can be set to change the "sender" identity from + * defaultIdentity above. + */ + attribute AUTF8String senderAddress; + + /** + * Type of the transport: email, xmpp, etc. + */ + readonly attribute AUTF8String type; + + /** + * Sends a calIItipItem to the recipients using the specified title and + * alternative representation. If a calIItipItem is attached, then an ICS + * representation of those objects are generated and attached to the email. + * If the calIItipItem is null, then the item(s) is sent without any + * text/calendar mime part. + * @param count size of recipient array + * @param recipientArray array of recipients + * @param calIItipItem set of calIItems encapsulated as calIItipItems + */ + boolean sendItems(in uint32_t count, + [array, size_is(count)] in calIAttendee recipientArray, + in calIItipItem item); +}; diff --git a/calendar/base/public/calIOperation.idl b/calendar/base/public/calIOperation.idl new file mode 100644 index 000000000..92f3e8bb8 --- /dev/null +++ b/calendar/base/public/calIOperation.idl @@ -0,0 +1,46 @@ +/* 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/. */ + +#include "nsIVariant.idl" + +[scriptable, uuid(B96C2997-7AAA-4619-AD48-B7EBD9236C93)] +interface calIOperation : nsISupports +{ + /** + * Id for easy management of pending requests. + */ + readonly attribute AUTF8String id; + + /** + * Determines whether the request is pending, i.e. has not been completed. + */ + readonly attribute boolean isPending; + + /** + * Status of the request, e.g. NS_OK while pending or after successful + * completion, or NS_ERROR_FAILED when failed. + */ + readonly attribute nsIVariant status; + + /** + * Cancels a pending request and changes status. + * @param aStatus operation status to be set; + * defaults to calIErrors.OPERATION_CANCELLED if null + */ + void cancel([optional] in nsIVariant aStatus); +}; + +[scriptable, uuid(1FA39726-63D2-440c-A464-296D2822B9DA)] +interface calIGenericOperationListener : nsISupports +{ + /** + * Generic callback receiving result. + * Results may appear in multiple calls, i.e. callees have to collect + * until isPending is false. + * + * @param aOperation operation object + * @param aResult result or null in case of an error + */ + void onResult(in calIOperation aOperation, in nsIVariant aResult); +}; diff --git a/calendar/base/public/calIPeriod.idl b/calendar/base/public/calIPeriod.idl new file mode 100644 index 000000000..714b1f080 --- /dev/null +++ b/calendar/base/public/calIPeriod.idl @@ -0,0 +1,67 @@ +/* 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/. */ + +#include "nsISupports.idl" + +interface calIDateTime; +interface calIDuration; + +[scriptable,uuid(ace2a74c-bd08-476f-be8b-6565abc50339)] +interface calIPeriod : nsISupports +{ + attribute jsval icalPeriod; + + /** + * isMutable is true if this instance is modifiable. + * If isMutable is false, any attempts to modify + * the object will throw NS_ERROR_OBJECT_IS_IMMUTABLE. + */ + readonly attribute boolean isMutable; + + /** + * Make this calIPeriod instance immutable. + */ + void makeImmutable(); + + /** + * Clone this calIPeriod instance into a new + * mutable object. + */ + calIPeriod clone(); + + /** + * The start datetime of this period + */ + attribute calIDateTime start; + + /** + * The end datetime of this period + */ + attribute calIDateTime end; + + /** + * The duration, equal to end-start + */ + readonly attribute calIDuration duration; + + + /** + * Return a string representation of this instance. + */ + AUTF8String toString(); + + /** + * This object as an iCalendar DURATION string + */ + attribute ACString icalString; +}; + +/** Libical specific interfaces */ + +[ptr] native icalperiodtypeptr(struct icalperiodtype); +[scriptable,uuid(04ee525f-96db-4731-8d61-688e754df24f)] +interface calIPeriodLibical : calIPeriod +{ + [noscript,notxpcom] void toIcalPeriod(in icalperiodtypeptr idt); +}; diff --git a/calendar/base/public/calIPrintFormatter.idl b/calendar/base/public/calIPrintFormatter.idl new file mode 100644 index 000000000..08952b8e0 --- /dev/null +++ b/calendar/base/public/calIPrintFormatter.idl @@ -0,0 +1,44 @@ +/* -*- Mode: IDL; tab-width: 20; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. */ + + +#include "nsISupports.idl" + +interface calIItemBase; +interface calIDateTime; +interface nsIOutputStream; + +[scriptable, uuid(014dea21-90cd-4563-b1bd-13b842a465e0)] +interface calIPrintFormatter : nsISupports +{ + /** + * The name of this layout. Implementers should make sure this string + * is localizable, ie uses nsIStringBundle + */ + readonly attribute AString name; + + /** + * Format the items into the stream, as html code. + * May assume that all the items are inside the given daterange. + * The user requested to show all the days in the daterange, so unless + * there is a special reason, all the days should be shown. + * aStart and aEnd may be null, in which case the implementation can + * show the minimal days needed to show all the events. It can skip + * months without events, for example. + * + * @param aStream the stream to put the html data into + * @param aStart the first date that should be printed + * @param aEnd the last date that should be printed + * @param aCount the number of items being printed + * @param aItems the items to print + * @param aTitle a title for the HTML page + */ + void formatToHtml(in nsIOutputStream aStream, + in calIDateTime aStart, + in calIDateTime aEnd, + in unsigned long aCount, + [array, size_is(aCount)] in calIItemBase aItems, + in AString aTitle); +}; diff --git a/calendar/base/public/calIRecurrenceDate.idl b/calendar/base/public/calIRecurrenceDate.idl new file mode 100644 index 000000000..33f1d4b48 --- /dev/null +++ b/calendar/base/public/calIRecurrenceDate.idl @@ -0,0 +1,24 @@ +/* -*- Mode: idl; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. */ + +#include "nsISupports.idl" + +#include "calIRecurrenceItem.idl" + +interface calIItemBase; +interface calIDateTime; + +interface calIIcalProperty; + +// an interface implementing a RDATE or EXDATE + +[scriptable, uuid(c5b331d4-b470-475b-9497-db9e2731e559)] +interface calIRecurrenceDate : calIRecurrenceItem +{ + // + // recurrence date set + // + attribute calIDateTime date; +}; diff --git a/calendar/base/public/calIRecurrenceInfo.idl b/calendar/base/public/calIRecurrenceInfo.idl new file mode 100644 index 000000000..85515f3d6 --- /dev/null +++ b/calendar/base/public/calIRecurrenceInfo.idl @@ -0,0 +1,180 @@ +/* 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/. */ + +#include "nsISupports.idl" + +interface calIItemBase; +interface calIDateTime; + +interface calIRecurrenceItem; + +interface calIIcalProperty; + +[scriptable, uuid(8ca5db89-2583-4f0c-b845-4a6d2f229efd)] +interface calIRecurrenceInfo : nsISupports +{ + // returns true if this thing is able to be modified; + // if the item is not mutable, attempts to modify + // any data will throw CAL_ERROR_ITEM_IS_IMMUTABLE + readonly attribute boolean isMutable; + + // makes this item immutable + void makeImmutable(); + + // clone always returns a mutable event + calIRecurrenceInfo clone(); + + // initialize this with the item for which this recurrence + // applies, so that the start date can be tracked + attribute calIItemBase item; + + /** + * The start date of an item is directly referenced by parts of calIRecurrenceInfo, + * thus changing the former without adjusting the latter would break the internal structure. + * This method provides the necessary functionality. There's no need to call it manually + * after writing to the start date of an item, since it's called automatically in the + * appropriate setter of an item. + */ + void onStartDateChange(in calIDateTime aNewStartTime, in calIDateTime aOldStartTime); + + /** + * If the base item's UID changes, this implicitly has to change all overridden items' UID, too. + * + * @param id new UID + */ + void onIdChange(in AUTF8String aNewId); + + /* + * Set of recurrence items; the order of these matters. + */ + + void getRecurrenceItems(out unsigned long aCount, [array,size_is(aCount),retval] out calIRecurrenceItem aItems); + void setRecurrenceItems(in unsigned long aCount, [array,size_is(aCount)] in calIRecurrenceItem aItems); + + unsigned long countRecurrenceItems(); + void clearRecurrenceItems(); + void appendRecurrenceItem(in calIRecurrenceItem aItem); + + calIRecurrenceItem getRecurrenceItemAt(in unsigned long aIndex); + void deleteRecurrenceItemAt(in unsigned long aIndex); + void deleteRecurrenceItem(in calIRecurrenceItem aItem); + // inserts the item at the given index, pushing the item that was previously there forward + void insertRecurrenceItemAt(in calIRecurrenceItem aItem, in unsigned long aIndex); + + /** + * isFinite is true if the recurrence items specify a finite number + * of occurrences. This is useful for UI and for possibly other users. + */ + readonly attribute boolean isFinite; + + /** + * This is a shortcut to appending or removing a single negative date + * assertion. aRecurrenceId needs to be a normal recurrence id, it may not be + * RDATE. + */ + void removeOccurrenceAt(in calIDateTime aRecurrenceId); + void restoreOccurrenceAt(in calIDateTime aRecurrenceId); + + /* + * exceptions + */ + + /** + * Modify an a particular occurrence with the given exception proxy + * item. If the recurrenceId isn't an already existing exception item, + * a new exception is added. Otherwise, the existing exception + * is modified. + * + * The item's parentItem must be equal to this RecurrenceInfo's + * item. <-- XXX check this, compare by calendar/id only + * + * @param anItem exceptional/overridden item + * @param aTakeOverOwnership whether the recurrence info object can take over + * the item or needs to clone it + */ + void modifyException(in calIItemBase anItem, in boolean aTakeOverOwnership); + + /** + * Return an existing exception item for the given recurrence ID. + * If an exception does not exist, null is returned. + */ + calIItemBase getExceptionFor(in calIDateTime aRecurrenceId); + + /** + * Removes an exception item for the given recurrence ID, if + * any exist. + */ + void removeExceptionFor(in calIDateTime aRecurrenceId); + + /** + * Returns a list of all recurrence ids that have exceptions. + */ + void getExceptionIds(out unsigned long aCount, [array,size_is(aCount),retval] out calIDateTime aIds); + + /* + * Recurrence calculation + */ + + /* + * Get the occurrence at the given recurrence ID; if there is no + * exception, then create a new proxy object with the normal occurrence. + * Otherwise, return the exception. + * + * @param aRecurrenceId The recurrence ID to get the occurrence for. + * @return The occurrence or exception corresponding to the id + */ + calIItemBase getOccurrenceFor(in calIDateTime aRecurrenceId); + + /** + * Return the chronologically next occurrence after aTime. This takes + * exceptions and EXDATE/RDATEs into account. + * + * @param aTime The (exclusive) date to start searching. + * @return The next occurrence, or null if there is none. + */ + calIItemBase getNextOccurrence(in calIDateTime aTime); + + /** + * Return the chronologically previous occurrence after aTime. This takes + * exceptions and EXDATE/RDATEs into account. + * + * @param aTime The (exclusive) date to start searching. + * @return The previous occurrence, or null if there is none. + */ + calIItemBase getPreviousOccurrence(in calIDateTime aTime); + + /** + * Return an array of calIDateTime representing all start times of this event + * between start (inclusive) and end (non-inclusive). Exceptions are taken + * into account. + * + * @param aRangeStart The (inclusive) date to start searching. + * @param aRangeEnd The (exclusive) date to end searching. + * @param aMaxCount The maximum number of dates to return + * + * @param aCount The number of dates returned. + * @return The array of dates. + */ + void getOccurrenceDates(in calIDateTime aRangeStart, + in calIDateTime aRangeEnd, + in unsigned long aMaxCount, + out unsigned long aCount, [array,size_is(aCount),retval] out calIDateTime aDates); + + /** + * Return an array of calIItemBase representing all occurrences of this event + * between start (inclusive) and end (non-inclusive). Exceptions are taken + * into account. + * + * @param aRangeStart The (inclusive) date to start searching. + * @param aRangeEnd The (exclusive) date to end searching. + * @param aMaxCount The maximum number of occurrences to return + * + * @param aCount The number of occurrences returned. + * @return The array of occurrences. + */ + void getOccurrences(in calIDateTime aRangeStart, + in calIDateTime aRangeEnd, + in unsigned long aMaxCount, + out unsigned long aCount, [array,size_is(aCount),retval] out calIItemBase aItems); +}; diff --git a/calendar/base/public/calIRecurrenceItem.idl b/calendar/base/public/calIRecurrenceItem.idl new file mode 100644 index 000000000..58261cfb5 --- /dev/null +++ b/calendar/base/public/calIRecurrenceItem.idl @@ -0,0 +1,56 @@ +/* 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/. */ + +#include "nsISupports.idl" + +interface calIItemBase; +interface calIDateTime; + +interface calIIcalProperty; + +[scriptable, uuid(918a243b-d887-41b0-8b4b-9cd56a9dd55f)] +interface calIRecurrenceItem : nsISupports +{ + // returns true if this thing is able to be modified; + // if the item is not mutable, attempts to modify + // any data will throw CAL_ERROR_ITEM_IS_IMMUTABLE + readonly attribute boolean isMutable; + + // makes this item immutable + void makeImmutable(); + + // clone always returns a mutable event + calIRecurrenceItem clone(); + + // defaults to false; if true, this item is to be interpreted + // as a negative rule (e.g. exceptions instead of rdates) + attribute boolean isNegative; + + // returns whether this item has a finite number of dates + // or not (e.g. a rule with no end date) + readonly attribute boolean isFinite; + + /** + * Search for the next occurrence after aTime and return its recurrence id. + * aRecurrenceId must be the recurrence id of an occurrence to search after. + * + * @require (aTime >= aRecurrenceId) + * @param aRecurrenceId The recurrence id to start searching at. + * @param aTime The earliest time to find the occurrence after. + */ + calIDateTime getNextOccurrence(in calIDateTime aRecurrenceId, + in calIDateTime aTime); + + // return array of calIDateTime of the start of all occurrences of + // this event starting at aStartTime, between rangeStart and an + // optional rangeEnd + void getOccurrences (in calIDateTime aStartTime, + in calIDateTime aRangeStart, + in calIDateTime aRangeEnd, + in unsigned long aMaxCount, + out unsigned long aCount, [array,size_is(aCount),retval] out calIDateTime aDates); + + attribute calIIcalProperty icalProperty; + attribute AUTF8String icalString; +}; diff --git a/calendar/base/public/calIRecurrenceRule.idl b/calendar/base/public/calIRecurrenceRule.idl new file mode 100644 index 000000000..5aba51069 --- /dev/null +++ b/calendar/base/public/calIRecurrenceRule.idl @@ -0,0 +1,59 @@ +/* -*- Mode: idl; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. */ + +#include "nsISupports.idl" + +#include "calIRecurrenceItem.idl" + +interface calIItemBase; +interface calIDateTime; + +// an interface implementing a RRULE + +[scriptable, uuid(e965a91a-49fa-41b5-b668-1a824a73bdbf)] +interface calIRecurrenceRule : calIRecurrenceItem +{ + // + // rule-based recurrence + // + + // null/"", "SECONDLY", "MINUTELY", "HOURLY", "DAILY", "WEEKLY", + // "MONTHLY", "YEARLY" + attribute AUTF8String type; + + // repeat every N of type + // XXX Please mind an implementation detail: + // the underlying libical currently only supports C short values for interval, + // i.e. commonly 16 bits on most platforms, thus please use only 0 <= interval <= 0x7fff. + // It is open whether we go with IDL short here or tweak libical to support at least 32 bits. + attribute long interval; + + // These two are mutually exclusive; whichever is set + // invalidates the other. It's only valid to read the one + // that was set; the other will throw NS_ERROR_FAILURE. Use + // isByCount to figure out whether count or untilDate is valid. + // Setting count to -1 or untilDate to null indicates infinite + // recurrence. + attribute long count; + attribute calIDateTime untilDate; + + // if this isn't infinite recurrence, this flag indicates whether + // it was set by count or not + readonly attribute boolean isByCount; + + // The week start for this rule, used for certain calculations. This is a + // value from 0=Sunday to 6=Saturday. + attribute short weekStart; + + // the components defining the recurrence + // "BYSECOND", "BYMINUTE", "BYHOUR", "BYDAY", + // "BYMONTHDAY", "BYYEARDAY", "BYWEEKNO", "BYMONTH", + // "BYSETPOS" + void getComponent (in AUTF8String aComponentType, + out unsigned long aCount, [array,size_is(aCount),retval] out short aValues); + void setComponent (in AUTF8String aComponentType, + in unsigned long aCount, [array,size_is(aCount)] in short aValues); + +}; diff --git a/calendar/base/public/calIRelation.idl b/calendar/base/public/calIRelation.idl new file mode 100644 index 000000000..ee73dc9aa --- /dev/null +++ b/calendar/base/public/calIRelation.idl @@ -0,0 +1,45 @@ +/* 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/. */ + +#include "nsISupports.idl" + +interface calIIcalProperty; +interface calIItemBase; + +[scriptable,uuid(77f0820a-2b49-4c8e-86bf-2b6bda46e391)] +interface calIRelation : nsISupports +{ + /** + * The type of the relation between the items: + * PARENT + * CHILD + * SIBLING + */ + attribute AUTF8String relType; + + /** + * The id of the related item + **/ + + attribute AUTF8String relId; + + /** + * The calIIcalProperty corresponding to this object. Can be used for + * serializing/unserializing from ics files. + */ + attribute calIIcalProperty icalProperty; + attribute AUTF8String icalString; + + /** + * For accessing additional parameters, such as x-params. + */ + AUTF8String getParameter(in AString name); + void setParameter(in AString name, in AUTF8String value); + void deleteParameter(in AString name); + + /** + * Clone this calIRelation instance into a new object. + */ + calIRelation clone(); +}; diff --git a/calendar/base/public/calISchedulingSupport.idl b/calendar/base/public/calISchedulingSupport.idl new file mode 100644 index 000000000..123ca3519 --- /dev/null +++ b/calendar/base/public/calISchedulingSupport.idl @@ -0,0 +1,47 @@ +/* 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/. */ + +#include "nsISupports.idl" +interface calIItemBase; +interface calIAttendee; + +/** + * Accesses scheduling specific information of calendar items. + * Implementation by providers is optional. + */ +[scriptable, uuid(9221e243-c97e-4c5f-9e00-5d7d3521bb44)] +interface calISchedulingSupport : nsISupports +{ + /** + * Tests whether the passed item corresponds to an invitation, e.g. + * the CUA or server has placed it in the calendar. + * + * @param aItem Item to be tested. + * @return Whether the passed item corresponds to an invitation. + */ + boolean isInvitation(in calIItemBase aItem); + + /** + * Gets the invited attendee if the passed item corresponds to + * an invitation. UI code will use that attendee to modify e.g. PARTSTAT. + * If isInvitation returns true, getInvitedAttendee must return + * an attendee. If isInvitation is false, getInvitedAttendee may return + * an attendee in case the organizer (and owner of the calendar) has + * invited himself. + * + * @param aItem Invitation item. + * @return Attendee object, or null. + */ + calIAttendee getInvitedAttendee(in calIItemBase aItem); + + /** + * Checks whether the provider keeps track of sending out the proper + * iTIP/iMIP message for a particular item. + * + * @param aMethod a iTIP method + * @param aItem an item that has been modified/deleted etc. + * @return true, if the provider keeps track of sending out passed message + */ + boolean canNotify(in AUTF8String aMethod, in calIItemBase aItem); +}; diff --git a/calendar/base/public/calIStartupService.idl b/calendar/base/public/calIStartupService.idl new file mode 100644 index 000000000..c387bf5a4 --- /dev/null +++ b/calendar/base/public/calIStartupService.idl @@ -0,0 +1,30 @@ +/* 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/. */ + +#include "nsISupports.idl" +#include "calIOperation.idl" + +/** + * Interface that can be used on services that need to be started up and shut + * down. The service needs to be registered within calStartupService.js, so this + * is only useful from within calendar code. If you want calendar code to be + * fully initialized, listen to "calendar-startup-done" via nsIObserverService. + */ +[scriptable, uuid(99d52094-37f9-4c81-9c55-32fbeb6a79cf)] +interface calIStartupService: nsISupports +{ + /** + * Function called when the service should be started + * + * @param completeListener The listener to call on startup completion. + */ + void startup(in calIGenericOperationListener completeListener); + + /** + * Function called when the service should be shut down. + * + * @param completeListener The listener to call on shutdown completion. + */ + void shutdown(in calIGenericOperationListener completeListener); +}; diff --git a/calendar/base/public/calIStatusObserver.idl b/calendar/base/public/calIStatusObserver.idl new file mode 100644 index 000000000..2c803ce37 --- /dev/null +++ b/calendar/base/public/calIStatusObserver.idl @@ -0,0 +1,60 @@ +/* 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/. */ + +#include "nsISupports.idl" +interface calICalendar; +interface nsIDOMChromeWindow; + +[scriptable, uuid(60160f68-4514-41b4-a19d-2f2cf0143426)] +interface calIStatusObserver : nsISupports +{ + + void initialize(in nsIDOMChromeWindow aWindow); + + /** + * Starts the display of an operation to check a series of calendars + * This operation may either be determined or undetermined + * @param aProgressMode An integer value that can accept DETERMINED_PROGRESS, + * UNDETERMINED_PROGRESS or NO_PROGRESS + * @param aCalendarsCount If the first parameter is DETERMINED_PROGRESS + * aCalendarCount is the number of Calendars + * which completion is to be displayed + */ + void startMeteors(in unsigned long aProgressMode, in unsigned long aCalendarCount); + + /** + * stops the display of an progressed operation + */ + void stopMeteors(); + + /** + * increments the display value denoting that a calendar has been processed + */ + void calendarCompleted(in calICalendar aCalendar); + + /** + * @return An integer value denoting wheter a progress is running or not; + * if it returns DETERMINED_PROGRESS a determined progress + is running; + * if it returns UNDETERMINED_PROGRESS an undetermined progress + is running; + * if it returns NO_PROGRESS no Progress is running. + */ + readonly attribute unsigned long spinning; + + /** + * A constant that denotes that no operation is running + */ + const unsigned long NO_PROGRESS = 0; + + /** + * A constant that refers to whether an operation is determined + */ + const unsigned long DETERMINED_PROGRESS = 1; + + /** + * A constant that refers to whether an operation is undetermined + */ + const unsigned long UNDETERMINED_PROGRESS = 2; +}; diff --git a/calendar/base/public/calITimezone.idl b/calendar/base/public/calITimezone.idl new file mode 100644 index 000000000..a291d8746 --- /dev/null +++ b/calendar/base/public/calITimezone.idl @@ -0,0 +1,60 @@ +/* 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/. */ + +#include "nsISupports.idl" + +interface calIIcalComponent; +interface calITimezoneProvider; + +[scriptable, uuid(D79161E7-0DB9-427d-A0C3-27E0DB3B030F)] +interface calITimezone : nsISupports +{ + /** + * The timezone provider this timezone belongs to, if any. + */ + readonly attribute calITimezoneProvider provider; + + /** + * VTIMEZONE ical component, null if floating or UTC. + */ + readonly attribute calIIcalComponent icalComponent; + + /** + * The TZID of this timezone. + */ + readonly attribute AUTF8String tzid; + + /** + * Whether this timezone is the "floating" timezone. + */ + readonly attribute boolean isFloating; + + /** + * Whether this is the "UTC" timezone. + */ + readonly attribute boolean isUTC; + + /** + * Latitude of timezone or void/null if unknown. + */ + readonly attribute AUTF8String latitude; + + /** + * Longitude of timezone or void/null if unknown. + */ + readonly attribute AUTF8String longitude; + + /** + * Localized name of the timezone; falls back to TZID if unknown. + */ + readonly attribute AString displayName; + + /** + * For debugging purposes. + * + * @return "UTC", "floating" or component's ical representation + */ + AUTF8String toString(); +}; + diff --git a/calendar/base/public/calITimezoneProvider.idl b/calendar/base/public/calITimezoneProvider.idl new file mode 100644 index 000000000..95bfc03ce --- /dev/null +++ b/calendar/base/public/calITimezoneProvider.idl @@ -0,0 +1,47 @@ +/* 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/. */ + +#include "nsISupports.idl" + +interface nsIUTF8StringEnumerator; +interface calITimezone; + +[scriptable, uuid(331a7c6d-805a-4926-940b-2d78dcd90554)] +interface calITimezoneProvider : nsISupports +{ + readonly attribute nsIUTF8StringEnumerator timezoneIds; + readonly attribute nsIUTF8StringEnumerator aliasIds; + + /** + * Gets a timezone defintion passing a TZID. + * Returns null in case of an unknown TZID. + * + * @param tzid a TZID to be resolved + * @return a timezone object or null + */ + calITimezone getTimezone(in AUTF8String tzid); +}; + +/** + * This service acts as a central access point for the up to date set + * of Olson timezone definitions. + */ +[scriptable, uuid(AB1BFE6A-EE95-4038-B594-34AEEDA9911A)] +interface calITimezoneService : calITimezoneProvider +{ + readonly attribute calITimezone floating; + readonly attribute calITimezone UTC; + + /** + * Provides the version of the underlying timezone database. + */ + readonly attribute AString version; + + /** + * Returns the default timezone from calendar.timezone.local. If no timezone + * has been set, a best guess is taken from the operating system and the + * timezone is saved into the above mentioned pref. + */ + readonly attribute calITimezone defaultTimezone; +}; diff --git a/calendar/base/public/calITodo.idl b/calendar/base/public/calITodo.idl new file mode 100644 index 000000000..1ed322829 --- /dev/null +++ b/calendar/base/public/calITodo.idl @@ -0,0 +1,72 @@ +/* -*- Mode: idl; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. */ + +#include "calIItemBase.idl" + +// +// calITodo +// +// An interface for a todo item (analogous to a VTODO) +// + +[scriptable, uuid(0a93fdad-8a5c-44e9-8f90-16a6df819e03)] +interface calITodo : calIItemBase +{ + const long CAL_TODO_STATUS_NEEDSACTION = 4; + const long CAL_TODO_STATUS_COMPLETED = 5; + const long CAL_TODO_STATUS_INPROCESS = 6; + + // as per the rather broken RFC2445, + + // entryDate maps to DTSTART, which is the day + // this todo shows up on, if set. (optional). + // + // dueDate maps to DUE, which is the day + // this todo is due, if set. (optional). + // + // If neither DUE nor DTSTART are set, then + // the todo appears "today" until it is completed. + // + // The completeDate is the date the todo was completed, + // or null if it hasn't been completed yet. + + attribute calIDateTime entryDate; + attribute calIDateTime dueDate; + attribute calIDateTime completedDate; + attribute short percentComplete; + + // A todo isCompleted if any of the following is true: + // - percentComplete is 100, or + // - completedDate is non-null, or + // - status is COMPLETED. + // Setting isCompleted to true will + // - set percentComplete to 100, and + // - set completedDate to the current time, if it is not already set, and + // - set status to COMPLETED. + // Setting isCompleted to false will remove percentComplete, completedDate, + // and status properties. (This returns the todo to its state at creation, + // in terms of completion-relevant properties.) + // + // If you would like to take advantage of the full, confusing disaster that + // is the RFC2445 VTODO status state space, you can feel free to set the + // fields individually, instead of setting isCompleted directly. (And then + // hope that whatever else you're talking to has the same set of rules for + // determining if something is completed or not.) + // + // Setting percentComplete, completedDate, or status individually does not + // affect any of the others at present. (E.g., setting the percentComplete + // from 100 to 50 doesn't clear completedDate, or change status to + // IN-PROCESS.) It's not clear that we want any more magic than a simple + // property to control "all complete" vs "not complete in any way". + attribute boolean isCompleted; + + /** + * The duration of the todo, which is either set or defined as + * dueDate - entryDate. + * Please note that null is returned if there is no duration set and entry + * Date or dueDate don't exist. + */ + attribute calIDuration duration; +}; diff --git a/calendar/base/public/calITransactionManager.idl b/calendar/base/public/calITransactionManager.idl new file mode 100644 index 000000000..2dac89023 --- /dev/null +++ b/calendar/base/public/calITransactionManager.idl @@ -0,0 +1,81 @@ +/* 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/. */ + +#include "nsISupports.idl" + +interface nsITransactionManager; +interface calICalendar; +interface calIItemBase; +interface calIOperationListener; + +/** + * calITransactionManager is a service designed to handle nsITransactions + * regarding the calendar. It is here as a service so that we can keep the + * transactions around without holding onto the whole global js scope+window. + */ +[scriptable, uuid(40a1ccf4-5f54-4815-b842-abf06f84dbfd)] +interface calITransactionManager : nsISupports +{ + /** + * @param aAction The Action to execute. This can be one of: + * add Adds an item + * modify Modfifies an item + * delete Deletes an item + * move Move an item from one calendar to the + * next. With this operation, aCalendar is + * the calendar that the event should be + * moved to. + * @param aCalendar The Calendar to execute the transaction on + * @param aItem The changed item for this transaction. This item + * should be immutable + * @param aOldItem The Item in its original form. Only needed for + * modify. + * @param aListener The listener to call when the transaction has + * completed. This parameter can be null. + */ + void createAndCommitTxn(in AUTF8String aAction, + in calIItemBase aItem, + in calICalendar aCalendar, + in calIItemBase aOldItem, + in calIOperationListener aListener); + + /** + * Signals the transaction manager that a series of transactions are going + * to be performed, but that, for the purposes of undo and redo, they should + * all be regarded as a single transaction. See also + * nsITransactionManager::beginBatch + */ + void beginBatch(); + + /** + * Ends the batch transaction in process. See also + * nsITransactionManager::endBatch + */ + void endBatch(); + + /** + * Undo the last transaction in the transaction manager's stack + */ + void undo(); + + /** + * Returns true if it is possible to undo a transaction at this time + */ + boolean canUndo(); + + /** + * Redo the last transaction + */ + void redo(); + + /** + * Returns true if it is possible to redo a transaction at this time + */ + boolean canRedo(); + + /** + * A reference to the transaction manager for calendar operations + */ + readonly attribute nsITransactionManager transactionManager; +}; diff --git a/calendar/base/public/calIWeekInfoService.idl b/calendar/base/public/calIWeekInfoService.idl new file mode 100644 index 000000000..46693e52c --- /dev/null +++ b/calendar/base/public/calIWeekInfoService.idl @@ -0,0 +1,50 @@ +/* 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/. */ + +#include "nsISupports.idl" + +interface calIDateTime; + +/** + * This interface will calculate a week title from a given datetime. This + * will depends on the users preferences. + * Extensions might override the default implementation, in order to + * generate week titles aimed at special cases (like weeknumbers for a + * schoolyear) + */ +[scriptable, uuid(650fd33b-ebf4-46fa-b9ca-dd80b2451498)] +interface calIWeekInfoService: nsISupports +{ + /** + * Return the week title. It's meant to be displayed. + * (Usually, will return a weeknumber, but might return a string like Q1W4) + * + * @param dateTime + * The dateTime to get the weektitle for + * @returns + * A string, representing the week title. Will usually be the + * week number. Every week (7 days) should get a different string, + * but the switch from one week to the next isn't necessarily + * on sunday. + */ + AString getWeekTitle(in calIDateTime dateTime); + + /** + * Gets the first day of a week of a passed day under consideration + * of the preference setting "calendar.week.start" + * + * @param aDate The dateTime to get get the start of the week for + * @return A dateTime-object denoting the first day of the week + */ + calIDateTime getStartOfWeek(in calIDateTime dateTime); + + /** + * Gets the last day of a week of a passed day under consideration + * of the preference setting "calendar.week.start" + * + * @param aDate The dateTime to get get the last day of the week for + * @return A dateTime-object denoting the last day of the week + */ + calIDateTime getEndOfWeek(in calIDateTime dateTime); +}; diff --git a/calendar/base/public/moz.build b/calendar/base/public/moz.build new file mode 100644 index 000000000..9ba1d9a03 --- /dev/null +++ b/calendar/base/public/moz.build @@ -0,0 +1,61 @@ +# vim: set filetype=python: +# 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/. + +XPIDL_SOURCES += [ + 'calIAlarm.idl', + 'calIAlarmService.idl', + 'calIAttachment.idl', + 'calIAttendee.idl', + 'calICalendar.idl', + 'calICalendarACLManager.idl', + 'calICalendarManager.idl', + 'calICalendarProvider.idl', + 'calICalendarSearchProvider.idl', + 'calICalendarView.idl', + 'calICalendarViewController.idl', + 'calIChangeLog.idl', + 'calIDateTime.idl', + 'calIDateTimeFormatter.idl', + 'calIDeletedItems.idl', + 'calIDuration.idl', + 'calIErrors.idl', + 'calIEvent.idl', + 'calIFreeBusyProvider.idl', + 'calIIcsParser.idl', + 'calIIcsSerializer.idl', + 'calIICSService.idl', + 'calIImportExport.idl', + 'calIItemBase.idl', + 'calIItipItem.idl', + 'calIItipTransport.idl', + 'calIOperation.idl', + 'calIPeriod.idl', + 'calIPrintFormatter.idl', + 'calIRecurrenceDate.idl', + 'calIRecurrenceInfo.idl', + 'calIRecurrenceItem.idl', + 'calIRecurrenceRule.idl', + 'calIRelation.idl', + 'calISchedulingSupport.idl', + 'calIStartupService.idl', + 'calIStatusObserver.idl', + 'calITimezone.idl', + 'calITimezoneProvider.idl', + 'calITodo.idl', + 'calITransactionManager.idl', + 'calIWeekInfoService.idl', +] + +XPIDL_MODULE = 'calbase' + +EXPORTS += [ + 'calBaseCID.h', +] + +with Files('**'): + BUG_COMPONENT = ('Calendar', 'Internal Components') + +with Files('calIAlarm*'): + BUG_COMPONENT = ('Calendar', 'Alarms') diff --git a/calendar/base/src/WindowsNTToZoneInfoTZId.properties b/calendar/base/src/WindowsNTToZoneInfoTZId.properties new file mode 100644 index 000000000..124ffce22 --- /dev/null +++ b/calendar/base/src/WindowsNTToZoneInfoTZId.properties @@ -0,0 +1,98 @@ +# 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/. + +# Mapping +# from: Microsoft Windows NT/2K/XP/Vista registry timezone subkey (not localized name) +# to: ZoneInfo timezone identifier (Eggert & Olson) +# +Afghanistan Standard Time: Asia/Kabul +Alaskan Standard Time: America/Anchorage +Arab Standard Time: Asia/Riyadh +Arabian Standard Time: Asia/Muscat +Arabic Standard Time: Asia/Baghdad +Armenian Standard Time: Asia/Yerevan +Atlantic Standard Time: America/Halifax +AUS Central Standard Time: Australia/Darwin +AUS Eastern Standard Time: Australia/Sydney +Azerbaijan Standard Time: Asia/Baku +Azores Standard Time: Atlantic/Azores +Bangkok Standard Time: Asia/Bangkok +Canada Central Standard Time: America/Regina +Cape Verde Standard Time: Atlantic/Cape_Verde +Caucasus Standard Time: Asia/Yerevan +Cen. Australia Standard Time: Australia/Adelaide +Central America Standard Time: America/El_Salvador +Central Asia Standard Time: Asia/Dhaka +Central Brazilian Standard Time: America/Manaus +Central Europe Standard Time: Europe/Prague +Central European Standard Time: Europe/Belgrade +Central Pacific Standard Time: Pacific/Guadalcanal +Central Standard Time: America/Chicago +Central Standard Time (Mexico): America/Mexico_City +China Standard Time: Asia/Shanghai +Dateline Standard Time: Pacific/Kwajalein +E. Africa Standard Time: Africa/Nairobi +E. Australia Standard Time: Australia/Brisbane +E. Europe Standard Time: Europe/Bucharest +E. South America Standard Time: America/Sao_Paulo +Eastern Standard Time: America/New_York +Egypt Standard Time: Africa/Cairo +Ekaterinburg Standard Time: Asia/Yekaterinburg +Fiji Standard Time: Pacific/Fiji +FLE Standard Time: Europe/Helsinki +Georgian Standard Time: Asia/Tbilisi +GFT Standard Time: Europe/Athens +GMT Standard Time: Europe/London +Greenland Standard Time: America/Godthab +Greenwich Standard Time: Africa/Casablanca +GTB Standard Time: Europe/Athens +Hawaiian Standard Time: Pacific/Honolulu +India Standard Time: Asia/Calcutta +Iran Standard Time: Asia/Tehran +Israel Standard Time: Asia/Jerusalem +Jordan Standard Time: Asia/Amman +Korea Standard Time: Asia/Seoul +Mexico Standard Time: America/Mexico_City +Mid-Atlantic Standard Time: Atlantic/South_Georgia +Middle East Standard Time: Asia/Beirut +Montevideo Standard Time: America/Montevideo +Mountain Standard Time: America/Denver +Mountain Standard Time (Mexico): America/Chihuahua +Myanmar Standard Time: Asia/Rangoon +N. Central Asia Standard Time: Asia/Novosibirsk +Namibia Standard Time: Africa/Windhoek +Nepal Standard Time: Asia/Katmandu +New Zealand Standard Time: Pacific/Auckland +Newfoundland Standard Time: America/St_Johns +North Asia East Standard Time: Asia/Ulaanbaatar +North Asia Standard Time: Asia/Krasnoyarsk +Pacific SA Standard Time: America/Santiago +Pacific Standard Time: America/Los_Angeles +Pacific Standard Time (Mexico): America/Tijuana +Romance Standard Time: Europe/Paris +Russian Standard Time: Europe/Moscow +SA Eastern Standard Time: America/Argentina/Buenos_Aires +SA Pacific Standard Time: America/Bogota +SA Western Standard Time: America/Caracas +Samoa Standard Time: Pacific/Apia +Saudi Arabia Standard Time: Asia/Riyadh +SE Asia Standard Time: Asia/Bangkok +Singapore Standard Time: Asia/Singapore +South Africa Standard Time: Africa/Johannesburg +Sri Lanka Standard Time: Asia/Colombo +Sydney Standard Time: Australia/Sydney +Taipei Standard Time: Asia/Taipei +Tasmania Standard Time: Australia/Hobart +Tokyo Standard Time: Asia/Tokyo +Tonga Standard Time: Pacific/Tongatapu +US Eastern Standard Time: America/Indiana/Indianapolis +US Mountain Standard Time: America/Phoenix +Vladivostok Standard Time: Asia/Vladivostok +W. Australia Standard Time: Australia/Perth +W. Central Africa Standard Time: Africa/Kinshasa +W. Europe Standard Time: Europe/Berlin +West Asia Standard Time: Asia/Karachi +West Pacific Standard Time: Pacific/Guam +Western Brazilian Standard Time: America/Rio_Branco +Yakutsk Standard Time: Asia/Yakutsk diff --git a/calendar/base/src/calAlarm.js b/calendar/base/src/calAlarm.js new file mode 100644 index 000000000..11e9c12f8 --- /dev/null +++ b/calendar/base/src/calAlarm.js @@ -0,0 +1,698 @@ +/* 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/. */ + +Components.utils.import("resource://gre/modules/PluralForm.jsm"); +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + +var ALARM_RELATED_ABSOLUTE = Components.interfaces.calIAlarm.ALARM_RELATED_ABSOLUTE; +var ALARM_RELATED_START = Components.interfaces.calIAlarm.ALARM_RELATED_START; +var ALARM_RELATED_END = Components.interfaces.calIAlarm.ALARM_RELATED_END; + +function calAlarm() { + this.wrappedJSObject = this; + this.mProperties = new calPropertyBag(); + this.mPropertyParams = {}; + this.mAttendees = []; + this.mAttachments = []; +} + +var calAlarmClassID = Components.ID("{b8db7c7f-c168-4e11-becb-f26c1c4f5f8f}"); +var calAlarmInterfaces = [Components.interfaces.calIAlarm]; +calAlarm.prototype = { + + mProperties: null, + mPropertyParams: null, + mAction: null, + mAbsoluteDate: null, + mOffset: null, + mDuration: null, + mAttendees: null, + mAttachments: null, + mSummary: null, + mDescription: null, + mLastAck: null, + mImmutable: false, + mRelated: 0, + mRepeat: 0, + + classID: calAlarmClassID, + QueryInterface: XPCOMUtils.generateQI(calAlarmInterfaces), + classInfo: XPCOMUtils.generateCI({ + classID: calAlarmClassID, + contractID: "@mozilla.org/calendar/alarm;1", + classDescription: "Describes a VALARM", + interfaces: calAlarmInterfaces + }), + + /** + * calIAlarm + */ + + ensureMutable: function() { + if (this.mImmutable) { + throw Components.results.NS_ERROR_OBJECT_IS_IMMUTABLE; + } + }, + + get isMutable() { + return !this.mImmutable; + }, + + makeImmutable: function() { + if (this.mImmutable) { + return; + } + + const objectMembers = ["mAbsoluteDate", + "mOffset", + "mDuration", + "mLastAck"]; + for (let member of objectMembers) { + if (this[member] && this[member].isMutable) { + this[member].makeImmutable(); + } + } + + // Properties + let e = this.mProperties.enumerator; + while (e.hasMoreElements()) { + let prop = e.getNext(); + + if (prop.value instanceof Components.interfaces.calIDateTime) { + if (prop.value.isMutable) { + prop.value.makeImmutable(); + } + } + } + + this.mImmutable = true; + }, + + clone: function() { + let cloned = new calAlarm(); + + cloned.mImmutable = false; + + const simpleMembers = ["mAction", + "mSummary", + "mDescription", + "mRelated", + "mRepeat"]; + + const arrayMembers = ["mAttendees", + "mAttachments"]; + + const objectMembers = ["mAbsoluteDate", + "mOffset", + "mDuration", + "mLastAck"]; + + for (let member of simpleMembers) { + cloned[member] = this[member]; + } + + for (let member of arrayMembers) { + let newArray = []; + for (let oldElem of this[member]) { + newArray.push(oldElem.clone()); + } + cloned[member] = newArray; + } + + for (let member of objectMembers) { + if (this[member] && this[member].clone) { + cloned[member] = this[member].clone(); + } else { + cloned[member] = this[member]; + } + } + + // X-Props + cloned.mProperties = new calPropertyBag(); + for (let [name, value] of this.mProperties) { + if (value instanceof Components.interfaces.calIDateTime) { + value = value.clone(); + } + + cloned.mProperties.setProperty(name, value); + + let propBucket = this.mPropertyParams[name]; + if (propBucket) { + let newBucket = {}; + for (let param in propBucket) { + newBucket[param] = propBucket[param]; + } + cloned.mPropertyParams[name] = newBucket; + } + } + return cloned; + }, + + + get related() { + return this.mRelated; + }, + set related(aValue) { + this.ensureMutable(); + switch (aValue) { + case ALARM_RELATED_ABSOLUTE: + this.mOffset = null; + break; + case ALARM_RELATED_START: + case ALARM_RELATED_END: + this.mAbsoluteDate = null; + break; + } + + return (this.mRelated = aValue); + }, + + get action() { + return this.mAction || "DISPLAY"; + }, + set action(aValue) { + this.ensureMutable(); + return (this.mAction = aValue); + }, + + get description() { + if (this.action == "AUDIO") { + return null; + } + return this.mDescription; + }, + set description(aValue) { + this.ensureMutable(); + return (this.mDescription = aValue); + }, + + get summary() { + if (this.mAction == "DISPLAY" || + this.mAction == "AUDIO") { + return null; + } + return this.mSummary; + }, + set summary(aValue) { + this.ensureMutable(); + return (this.mSummary = aValue); + }, + + get offset() { + return this.mOffset; + }, + set offset(aValue) { + if (aValue && !(aValue instanceof Components.interfaces.calIDuration)) { + throw Components.results.NS_ERROR_INVALID_ARG; + } + if (this.related != ALARM_RELATED_START && + this.related != ALARM_RELATED_END) { + throw Components.results.NS_ERROR_FAILURE; + } + this.ensureMutable(); + return (this.mOffset = aValue); + }, + + get alarmDate() { + return this.mAbsoluteDate; + }, + set alarmDate(aValue) { + if (aValue && !(aValue instanceof Components.interfaces.calIDateTime)) { + throw Components.results.NS_ERROR_INVALID_ARG; + } + if (this.related != ALARM_RELATED_ABSOLUTE) { + throw Components.results.NS_ERROR_FAILURE; + } + this.ensureMutable(); + return (this.mAbsoluteDate = aValue); + }, + + get repeat() { + if ((this.mRepeat != 0) ^ (this.mDuration != null)) { + return 0; + } + return this.mRepeat || 0; + }, + set repeat(aValue) { + this.ensureMutable(); + if (aValue === null) { + this.mRepeat = null; + } else { + this.mRepeat = parseInt(aValue, 10); + if (isNaN(this.mRepeat)) { + throw Components.results.NS_ERROR_INVALID_ARG; + } + } + return aValue; + }, + + get repeatOffset() { + if ((this.mRepeat != 0) ^ (this.mDuration != null)) { + return null; + } + return this.mDuration; + }, + set repeatOffset(aValue) { + this.ensureMutable(); + if (aValue !== null && + !(aValue instanceof Components.interfaces.calIDuration)) { + throw Components.results.NS_ERROR_INVALID_ARG; + } + return (this.mDuration = aValue); + }, + + get repeatDate() { + if (this.related != ALARM_RELATED_ABSOLUTE || + !this.mAbsoluteDate || + !this.mRepeat || + !this.mDuration) { + return null; + } + + let alarmDate = this.mAbsoluteDate.clone(); + + // All Day events are handled as 00:00:00 + alarmDate.isDate = false; + alarmDate.addDuration(this.mDuration); + return alarmDate; + }, + + getAttendees: function(aCount) { + let attendees; + if (this.action == "AUDIO" || this.action == "DISPLAY") { + attendees = []; + } else { + attendees = this.mAttendees.concat([]); + } + aCount.value = attendees.length; + return attendees; + }, + + addAttendee: function(aAttendee) { + // Make sure its not duplicate + this.deleteAttendee(aAttendee); + + // Now check if its valid + if (this.action == "AUDIO" || this.action == "DISPLAY") { + throw new Error("Alarm type AUDIO/DISPLAY may not have attendees"); + } + + // And add it (again) + this.mAttendees.push(aAttendee); + }, + + deleteAttendee: function(aAttendee) { + let deleteId = aAttendee.id; + for (let i = 0; i < this.mAttendees.length; i++) { + if (this.mAttendees[i].id == deleteId) { + this.mAttendees.splice(i, 1); + break; + } + } + }, + + clearAttendees: function() { + this.mAttendees = []; + }, + + getAttachments: function(aCount) { + let attachments; + if (this.action == "AUDIO") { + attachments = (this.mAttachments.length ? [this.mAttachments[0]] : []); + } else if (this.action == "DISPLAY") { + attachments = []; + } else { + attachments = this.mAttachments.concat([]); + } + aCount.value = attachments.length; + return attachments; + }, + + addAttachment: function(aAttachment) { + // Make sure its not duplicate + this.deleteAttachment(aAttachment); + + // Now check if its valid + if (this.action == "AUDIO" && this.mAttachments.length) { + throw new Error("Alarm type AUDIO may only have one attachment"); + } else if (this.action == "DISPLAY") { + throw new Error("Alarm type DISPLAY may not have attachments"); + } + + // And add it (again) + this.mAttachments.push(aAttachment); + }, + + deleteAttachment: function(aAttachment) { + let deleteHash = aAttachment.hashId; + for (let i = 0; i < this.mAttachments.length; i++) { + if (this.mAttachments[i].hashId == deleteHash) { + this.mAttachments.splice(i, 1); + break; + } + } + }, + + clearAttachments: function() { + this.mAttachments = []; + }, + + get icalString() { + let comp = this.icalComponent; + return (comp ? comp.serializeToICS() : ""); + }, + set icalString(val) { + this.ensureMutable(); + return (this.icalComponent = getIcsService().parseICS(val, null)); + }, + + promotedProps: { + "ACTION": "action", + "TRIGGER": "offset", + "REPEAT": "repeat", + "DURATION": "duration", + "SUMMARY": "summary", + "DESCRIPTION": "description", + "X-MOZ-LASTACK": "lastAck" + }, + + get icalComponent() { + let icssvc = getIcsService(); + let comp = icssvc.createIcalComponent("VALARM"); + + // Set up action (REQUIRED) + let actionProp = icssvc.createIcalProperty("ACTION"); + actionProp.value = this.action; + comp.addProperty(actionProp); + + // Set up trigger (REQUIRED) + let triggerProp = icssvc.createIcalProperty("TRIGGER"); + if (this.related == ALARM_RELATED_ABSOLUTE && this.mAbsoluteDate) { + // Set the trigger to a specific datetime + triggerProp.setParameter("VALUE", "DATE-TIME"); + triggerProp.valueAsDatetime = this.mAbsoluteDate.getInTimezone(cal.UTC()); + } else if (this.related != ALARM_RELATED_ABSOLUTE && this.mOffset) { + triggerProp.valueAsIcalString = this.mOffset.icalString; + if (this.related == ALARM_RELATED_END) { + // An alarm related to the end of the event. + triggerProp.setParameter("RELATED", "END"); + } + } else { + // No offset or absolute date is not valid. + throw Components.results.NS_ERROR_NOT_INITIALIZED; + } + comp.addProperty(triggerProp); + + // Set up repeat and duration (OPTIONAL, but if one exists, the other + // MUST also exist) + if (this.repeat && this.repeatOffset) { + let repeatProp = icssvc.createIcalProperty("REPEAT"); + let durationProp = icssvc.createIcalProperty("DURATION"); + + repeatProp.value = this.repeat; + durationProp.valueAsIcalString = this.repeatOffset.icalString; + + comp.addProperty(repeatProp); + comp.addProperty(durationProp); + } + + // Set up attendees (REQUIRED for EMAIL action) + /* TODO should we be strict here? + if (this.action == "EMAIL" && !this.getAttendees({}).length) { + throw Components.results.NS_ERROR_NOT_INITIALIZED; + } */ + for (let attendee of this.getAttendees({})) { + comp.addProperty(attendee.icalProperty); + } + + /* TODO should we be strict here? + if (this.action == "EMAIL" && !this.attachments.length) { + throw Components.results.NS_ERROR_NOT_INITIALIZED; + } */ + + for (let attachment of this.getAttachments({})) { + comp.addProperty(attachment.icalProperty); + } + + // Set up summary (REQUIRED for EMAIL) + if (this.summary || this.action == "EMAIL") { + let summaryProp = icssvc.createIcalProperty("SUMMARY"); + // Summary needs to have a non-empty value + summaryProp.value = this.summary || + calGetString("calendar", "alarmDefaultSummary"); + comp.addProperty(summaryProp); + } + + // Set up the description (REQUIRED for DISPLAY and EMAIL) + if (this.description || + this.action == "DISPLAY" || + this.action == "EMAIL") { + let descriptionProp = icssvc.createIcalProperty("DESCRIPTION"); + // description needs to have a non-empty value + descriptionProp.value = this.description || + calGetString("calendar", "alarmDefaultDescription"); + comp.addProperty(descriptionProp); + } + + // Set up lastAck + if (this.lastAck) { + let lastAckProp = icssvc.createIcalProperty("X-MOZ-LASTACK"); + lastAckProp.value = this.lastAck; + comp.addProperty(lastAckProp); + } + + // Set up X-Props. mProperties contains only non-promoted props + // eslint-disable-next-line array-bracket-spacing + for (let [propName, ] of this.mProperties) { + let icalprop = icssvc.createIcalProperty(propName); + icalprop.value = this.mProperties.getProperty(propName); + + // Add parameters + let propBucket = this.mPropertyParams[propName]; + if (propBucket) { + for (let paramName in propBucket) { + try { + icalprop.setParameter(paramName, + propBucket[paramName]); + } catch (e) { + if (e.result == Components.results.NS_ERROR_ILLEGAL_VALUE) { + // Illegal values should be ignored, but we could log them if + // the user has enabled logging. + cal.LOG("Warning: Invalid alarm parameter value " + paramName + "=" + propBucket[paramName]); + } else { + throw e; + } + } + } + } + comp.addProperty(icalprop); + } + return comp; + }, + set icalComponent(aComp) { + this.ensureMutable(); + if (!aComp || aComp.componentType != "VALARM") { + // Invalid Component + throw Components.results.NS_ERROR_INVALID_ARG; + } + + let actionProp = aComp.getFirstProperty("ACTION"); + let triggerProp = aComp.getFirstProperty("TRIGGER"); + let repeatProp = aComp.getFirstProperty("REPEAT"); + let durationProp = aComp.getFirstProperty("DURATION"); + let summaryProp = aComp.getFirstProperty("SUMMARY"); + let descriptionProp = aComp.getFirstProperty("DESCRIPTION"); + let lastAckProp = aComp.getFirstProperty("X-MOZ-LASTACK"); + + if (actionProp) { + this.action = actionProp.value; + } else { + throw Components.results.NS_ERROR_INVALID_ARG; + } + + if (triggerProp) { + if (triggerProp.getParameter("VALUE") == "DATE-TIME") { + this.mAbsoluteDate = triggerProp.valueAsDatetime; + this.related = ALARM_RELATED_ABSOLUTE; + } else { + this.mOffset = cal.createDuration(triggerProp.valueAsIcalString); + + let related = triggerProp.getParameter("RELATED"); + this.related = (related == "END" ? ALARM_RELATED_END : ALARM_RELATED_START); + } + } else { + throw Components.results.NS_ERROR_INVALID_ARG; + } + + if (durationProp && repeatProp) { + this.repeatOffset = cal.createDuration(durationProp.valueAsIcalString); + this.repeat = repeatProp.value; + } else if (durationProp || repeatProp) { + throw Components.results.NS_ERROR_INVALID_ARG; + } else { + this.repeatOffset = null; + this.repeat = 0; + } + + // Set up attendees + this.clearAttendees(); + for (let attendeeProp of cal.ical.propertyIterator(aComp, "ATTENDEE")) { + let attendee = cal.createAttendee(); + attendee.icalProperty = attendeeProp; + this.addAttendee(attendee); + } + + // Set up attachments + this.clearAttachments(); + for (let attachProp of cal.ical.propertyIterator(aComp, "ATTACH")) { + let attach = cal.createAttachment(); + attach.icalProperty = attachProp; + this.addAttachment(attach); + } + + // Set up summary + this.summary = (summaryProp ? summaryProp.value : null); + + // Set up description + this.description = (descriptionProp ? descriptionProp.value : null); + + // Set up the alarm lastack. We can't use valueAsDatetime here since + // the default for an X-Prop is TEXT and in older versions we didn't set + // VALUE=DATE-TIME. + this.lastAck = (lastAckProp ? cal.createDateTime(lastAckProp.valueAsIcalString) : null); + + this.mProperties = new calPropertyBag(); + this.mPropertyParams = {}; + + // Other properties + for (let prop of cal.ical.propertyIterator(aComp)) { + if (!this.promotedProps[prop.propertyName]) { + this.setProperty(prop.propertyName, prop.value); + + for (let [paramName, param] of cal.ical.paramIterator(prop)) { + if (!(prop.propertyName in this.mPropertyParams)) { + this.mPropertyParams[prop.propertyName] = {}; + } + this.mPropertyParams[prop.propertyName][paramName] = param; + } + } + } + return aComp; + }, + + hasProperty: function(aName) { + return (this.getProperty(aName.toUpperCase()) != null); + }, + + getProperty: function(aName) { + let name = aName.toUpperCase(); + if (name in this.promotedProps) { + return this[this.promotedProps[name]]; + } else { + return this.mProperties.getProperty(name); + } + }, + + setProperty: function(aName, aValue) { + this.ensureMutable(); + let name = aName.toUpperCase(); + if (name in this.promotedProps) { + this[this.promotedProps[name]] = aValue; + } else { + this.mProperties.setProperty(name, aValue); + } + return aValue; + }, + + deleteProperty: function(aName) { + this.ensureMutable(); + let name = aName.toUpperCase(); + if (name in this.promotedProps) { + this[this.promotedProps[name]] = null; + } else { + this.mProperties.deleteProperty(name); + } + }, + + get propertyEnumerator() { + return this.mProperties.enumerator; + }, + + toString: function(aItem) { + function getItemBundleStringName(aPrefix) { + if (!aItem || isEvent(aItem)) { + return aPrefix + "Event"; + } else if (isToDo(aItem)) { + return aPrefix + "Task"; + } else { + return aPrefix; + } + } + + if (this.related == ALARM_RELATED_ABSOLUTE && this.mAbsoluteDate) { + // this is an absolute alarm. Use the calendar default timezone and + // format it. + let formatter = cal.getDateFormatter(); + let formatDate = this.mAbsoluteDate.getInTimezone(cal.calendarDefaultTimezone()); + return formatter.formatDateTime(formatDate); + } else if (this.related != ALARM_RELATED_ABSOLUTE && this.mOffset) { + // Relative alarm length + let alarmlen = Math.abs(this.mOffset.inSeconds / 60); + if (alarmlen == 0) { + // No need to get the other information if the alarm is at the start + // of the event/task. + if (this.related == ALARM_RELATED_START) { + return calGetString("calendar-alarms", + getItemBundleStringName("reminderTitleAtStart")); + } else if (this.related == ALARM_RELATED_END) { + return calGetString("calendar-alarms", + getItemBundleStringName("reminderTitleAtEnd")); + } + } + + let unit; + if (alarmlen % 1440 == 0) { + // Alarm is in days + unit = "unitDays"; + alarmlen /= 1440; + } else if (alarmlen % 60 == 0) { + unit = "unitHours"; + alarmlen /= 60; + } else { + unit = "unitMinutes"; + } + let localeUnitString = cal.calGetString("calendar", unit); + let unitString = PluralForm.get(alarmlen, localeUnitString) + .replace("#1", alarmlen); + let originStringName = "reminderCustomOrigin"; + + // Origin + switch (this.related) { + case ALARM_RELATED_START: + originStringName += "Begin"; + break; + case ALARM_RELATED_END: + originStringName += "End"; + break; + } + + if (this.offset.isNegative) { + originStringName += "Before"; + } else { + originStringName += "After"; + } + + let originString = calGetString("calendar-alarms", + getItemBundleStringName(originStringName)); + return calGetString("calendar-alarms", + "reminderCustomTitle", + [unitString, originString]); + } else { + // This is an incomplete alarm, but then again we should never reach + // this state. + return "[Incomplete calIAlarm]"; + } + } +}; diff --git a/calendar/base/src/calAlarmMonitor.js b/calendar/base/src/calAlarmMonitor.js new file mode 100644 index 000000000..9b224dc1f --- /dev/null +++ b/calendar/base/src/calAlarmMonitor.js @@ -0,0 +1,179 @@ +/* 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/. */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); +Components.utils.import("resource://gre/modules/Preferences.jsm"); + +function peekAlarmWindow() { + return Services.wm.getMostRecentWindow("Calendar:AlarmWindow"); +} + +/** + * The alarm monitor takes care of playing the alarm sound and opening one copy + * of the calendar-alarm-dialog. Both depend on their respective prefs to be + * set. This monitor is only used for DISPLAY type alarms. + */ +function calAlarmMonitor() { + this.wrappedJSObject = this; + this.mAlarms = []; + + this.mSound = Components.classes["@mozilla.org/sound;1"] + .createInstance(Components.interfaces.nsISound); +} + +var calAlarmMonitorClassID = Components.ID("{4b7ae030-ed79-11d9-8cd6-0800200c9a66}"); +var calAlarmMonitorInterfaces = [ + Components.interfaces.nsIObserver, + Components.interfaces.calIAlarmServiceObserver +]; +calAlarmMonitor.prototype = { + mAlarms: null, + + // This is a work-around for the fact that there is a delay between when + // we call openWindow and when it appears via getMostRecentWindow. If an + // alarm is fired in that time-frame, it will actually end up in another window. + mWindowOpening: null, + + // nsISound instance used for playing all sounds + mSound: null, + + classID: calAlarmMonitorClassID, + QueryInterface: XPCOMUtils.generateQI(calAlarmMonitorInterfaces), + classInfo: XPCOMUtils.generateCI({ + contractID: "@mozilla.org/calendar/alarm-monitor;1", + classDescription: "Calendar Alarm Monitor", + classID: calAlarmMonitorClassID, + interfaces: calAlarmMonitorInterfaces, + flags: Components.interfaces.nsIClassInfo.SINGLETON + }), + + /** + * nsIObserver + */ + observe: function(aSubject, aTopic, aData) { + let alarmService = Components.classes["@mozilla.org/calendar/alarm-service;1"] + .getService(Components.interfaces.calIAlarmService); + switch (aTopic) { + case "alarm-service-startup": + alarmService.addObserver(this); + break; + case "alarm-service-shutdown": + alarmService.removeObserver(this); + break; + } + }, + + /** + * calIAlarmServiceObserver + */ + onAlarm: function(aItem, aAlarm) { + if (aAlarm.action != "DISPLAY") { + // This monitor only looks for DISPLAY alarms. + return; + } + + this.mAlarms.push([aItem, aAlarm]); + + if (Preferences.get("calendar.alarms.playsound", true)) { + // We want to make sure the user isn't flooded with alarms so we + // limit this using a preference. For example, if the user has 20 + // events that fire an alarm in the same minute, then the alarm + // sound will only play 5 times. All alarms will be shown in the + // dialog nevertheless. + let maxAlarmSoundCount = Preferences.get("calendar.alarms.maxsoundsperminute", 5); + let now = new Date(); + + if (!this.mLastAlarmSoundDate || + (now - this.mLastAlarmSoundDate >= 60000)) { + // Last alarm was long enough ago, reset counters. Note + // subtracting JSDate results in microseconds. + this.mAlarmSoundCount = 0; + this.mLastAlarmSoundDate = now; + } else { + // Otherwise increase the counter + this.mAlarmSoundCount++; + } + + if (maxAlarmSoundCount > this.mAlarmSoundCount) { + // Only ring the alarm sound if we haven't hit the max count. + try { + let soundURL = Preferences.get("calendar.alarms.soundURL", null); + if (soundURL && soundURL.length > 0) { + soundURL = makeURL(soundURL); + this.mSound.play(soundURL); + } else { + this.mSound.beep(); + } + } catch (exc) { + cal.ERROR("Error playing alarm sound: " + exc); + } + } + } + + if (!Preferences.get("calendar.alarms.show", true)) { + return; + } + + let calAlarmWindow = peekAlarmWindow(); + if (!calAlarmWindow && (!this.mWindowOpening || + this.mWindowOpening.closed)) { + this.mWindowOpening = Services.ww.openWindow( + null, + "chrome://calendar/content/calendar-alarm-dialog.xul", + "_blank", + "chrome,dialog=yes,all,resizable", + this); + } + if (!this.mWindowOpening) { + calAlarmWindow.addWidgetFor(aItem, aAlarm); + } + }, + + window_onLoad: function() { + let calAlarmWindow = this.mWindowOpening; + this.mWindowOpening = null; + if (this.mAlarms.length > 0) { + for (let [item, alarm] of this.mAlarms) { + calAlarmWindow.addWidgetFor(item, alarm); + } + } else { + // Uh oh, it seems the alarms were removed even before the window + // finished loading. Looks like we can close it again + calAlarmWindow.closeIfEmpty(); + } + }, + + onRemoveAlarmsByItem: function(aItem) { + let calAlarmWindow = peekAlarmWindow(); + this.mAlarms = this.mAlarms.filter(([thisItem, alarm]) => { + let ret = (aItem.hashId != thisItem.hashId); + if (!ret && calAlarmWindow) { // window is open + calAlarmWindow.removeWidgetFor(thisItem, alarm); + } + return ret; + }); + }, + + onRemoveAlarmsByCalendar: function(calendar) { + let calAlarmWindow = peekAlarmWindow(); + this.mAlarms = this.mAlarms.filter(([thisItem, alarm]) => { + let ret = (calendar.id != thisItem.calendar.id); + + if (!ret && calAlarmWindow) { // window is open + calAlarmWindow.removeWidgetFor(thisItem, alarm); + } + return ret; + }); + }, + + onAlarmsLoaded: function(aCalendar) { + // the alarm dialog won't close while alarms are loading, check again now + let calAlarmWindow = peekAlarmWindow(); + if (calAlarmWindow && this.mAlarms.length == 0) { + calAlarmWindow.closeIfEmpty(); + } + } +}; diff --git a/calendar/base/src/calAlarmService.js b/calendar/base/src/calAlarmService.js new file mode 100644 index 000000000..c00195f88 --- /dev/null +++ b/calendar/base/src/calAlarmService.js @@ -0,0 +1,581 @@ +/* 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/. */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://calendar/modules/calAlarmUtils.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); +Components.utils.import("resource://gre/modules/Preferences.jsm"); +Components.utils.import("resource://gre/modules/Promise.jsm"); +Components.utils.import("resource://gre/modules/PromiseUtils.jsm"); + +var kHoursBetweenUpdates = 6; + +function nowUTC() { + return cal.jsDateToDateTime(new Date()).getInTimezone(cal.UTC()); +} + +function newTimerWithCallback(aCallback, aDelay, aRepeating) { + let timer = Components.classes["@mozilla.org/timer;1"] + .createInstance(Components.interfaces.nsITimer); + + timer.initWithCallback(aCallback, + aDelay, + (aRepeating ? timer.TYPE_REPEATING_PRECISE : timer.TYPE_ONE_SHOT)); + return timer; +} + +function calAlarmService() { + this.wrappedJSObject = this; + + this.mLoadedCalendars = {}; + this.mTimerMap = {}; + this.mObservers = new calListenerBag(Components.interfaces.calIAlarmServiceObserver); + + this.calendarObserver = { + alarmService: this, + + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calIObserver]), + + // calIObserver: + onStartBatch: function() { }, + onEndBatch: function() { }, + onLoad: function(calendar) { + // ignore any onLoad events until initial getItems() call of startup has finished: + if (calendar && this.alarmService.mLoadedCalendars[calendar.id]) { + // a refreshed calendar signals that it has been reloaded + // (and cannot notify detailed changes), thus reget all alarms of it: + this.alarmService.initAlarms([calendar]); + } + }, + + onAddItem: function(aItem) { + this.alarmService.addAlarmsForOccurrences(aItem); + }, + onModifyItem: function(aNewItem, aOldItem) { + if (!aNewItem.recurrenceId) { + // deleting an occurrence currently calls modifyItem(newParent, *oldOccurrence*) + aOldItem = aOldItem.parentItem; + } + + this.onDeleteItem(aOldItem); + this.onAddItem(aNewItem); + }, + onDeleteItem: function(aDeletedItem) { + this.alarmService.removeAlarmsForOccurrences(aDeletedItem); + }, + onError: function(aCalendar, aErrNo, aMessage) {}, + onPropertyChanged: function(aCalendar, aName, aValue, aOldValue) { + switch (aName) { + case "suppressAlarms": + case "disabled": + this.alarmService.initAlarms([aCalendar]); + break; + } + }, + onPropertyDeleting: function(aCalendar, aName) { + this.onPropertyChanged(aCalendar, aName); + } + }; + + this.calendarManagerObserver = { + alarmService: this, + + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calICalendarManagerObserver]), + + onCalendarRegistered: function(aCalendar) { + this.alarmService.observeCalendar(aCalendar); + // initial refresh of alarms for new calendar: + this.alarmService.initAlarms([aCalendar]); + }, + onCalendarUnregistering: function(aCalendar) { + // XXX todo: we need to think about calendar unregistration; + // there may still be dangling items (-> alarm dialog), + // dismissing those alarms may write data... + this.alarmService.unobserveCalendar(aCalendar); + }, + onCalendarDeleting: function(aCalendar) {} + }; +} + +var calAlarmServiceClassID = Components.ID("{7a9200dd-6a64-4fff-a798-c5802186e2cc}"); +var calAlarmServiceInterfaces = [ + Components.interfaces.calIAlarmService, + Components.interfaces.nsIObserver +]; +calAlarmService.prototype = { + mRangeStart: null, + mRangeEnd: null, + mUpdateTimer: null, + mStarted: false, + mTimerMap: null, + mObservers: null, + mTimezone: null, + + classID: calAlarmServiceClassID, + QueryInterface: XPCOMUtils.generateQI(calAlarmServiceInterfaces), + classInfo: XPCOMUtils.generateCI({ + classID: calAlarmServiceClassID, + contractID: "@mozilla.org/calendar/alarm-service;1", + classDescription: "Calendar Alarm Service", + interfaces: calAlarmServiceInterfaces, + flags: Components.interfaces.nsIClassInfo.SINGLETON + }), + + /** + * nsIObserver + */ + observe: function(aSubject, aTopic, aData) { + // This will also be called on app-startup, but nothing is done yet, to + // prevent unwanted dialogs etc. See bug 325476 and 413296 + if (aTopic == "profile-after-change" || aTopic == "wake_notification") { + this.shutdown(); + this.startup(); + } + if (aTopic == "xpcom-shutdown") { + this.shutdown(); + } + }, + + /** + * calIAlarmService APIs + */ + get timezone() { + // TODO Do we really need this? Do we ever set the timezone to something + // different than the default timezone? + return this.mTimezone || calendarDefaultTimezone(); + }, + + set timezone(aTimezone) { + return (this.mTimezone = aTimezone); + }, + + snoozeAlarm: function(aItem, aAlarm, aDuration) { + // Right now we only support snoozing all alarms for the given item for + // aDuration. + + // Make sure we're working with the parent, otherwise we'll accidentally + // create an exception + let newEvent = aItem.parentItem.clone(); + let alarmTime = nowUTC(); + + // Set the last acknowledged time to now. + newEvent.alarmLastAck = alarmTime; + + alarmTime = alarmTime.clone(); + alarmTime.addDuration(aDuration); + + if (aItem.parentItem == aItem) { + newEvent.setProperty("X-MOZ-SNOOZE-TIME", alarmTime.icalString); + } else { + // This is the *really* hard case where we've snoozed a single + // instance of a recurring event. We need to not only know that + // there was a snooze, but also which occurrence was snoozed. Part + // of me just wants to create a local db of snoozes here... + newEvent.setProperty("X-MOZ-SNOOZE-TIME-" + aItem.recurrenceId.nativeTime, + alarmTime.icalString); + } + // calling modifyItem will cause us to get the right callback + // and update the alarm properly + return newEvent.calendar.modifyItem(newEvent, aItem.parentItem, null); + }, + + dismissAlarm: function(aItem, aAlarm) { + let now = nowUTC(); + // We want the parent item, otherwise we're going to accidentally create an + // exception. We've relnoted (for 0.1) the slightly odd behavior this can + // cause if you move an event after dismissing an alarm + let oldParent = aItem.parentItem; + let newParent = oldParent.clone(); + newParent.alarmLastAck = now; + // Make sure to clear out any snoozes that were here. + if (aItem.recurrenceId) { + newParent.deleteProperty("X-MOZ-SNOOZE-TIME-" + aItem.recurrenceId.nativeTime); + } else { + newParent.deleteProperty("X-MOZ-SNOOZE-TIME"); + } + return newParent.calendar.modifyItem(newParent, oldParent, null); + }, + + addObserver: function(aObserver) { + this.mObservers.add(aObserver); + }, + + removeObserver: function(aObserver) { + this.mObservers.remove(aObserver); + }, + + startup: function() { + if (this.mStarted) { + return; + } + + Services.obs.addObserver(this, "profile-after-change", false); + Services.obs.addObserver(this, "xpcom-shutdown", false); + Services.obs.addObserver(this, "wake_notification", false); + + /* Tell people that we're alive so they can start monitoring alarms. + */ + let notifier = Components.classes["@mozilla.org/embedcomp/appstartup-notifier;1"] + .getService(Components.interfaces.nsIObserver); + notifier.observe(null, "alarm-service-startup", null); + + getCalendarManager().addObserver(this.calendarManagerObserver); + + for (let calendar of getCalendarManager().getCalendars({})) { + this.observeCalendar(calendar); + } + + /* set up a timer to update alarms every N hours */ + let timerCallback = { + alarmService: this, + notify: function() { + let now = nowUTC(); + let start; + if (this.alarmService.mRangeEnd) { + // This is a subsequent search, so we got all the past alarms before + start = this.alarmService.mRangeEnd.clone(); + } else { + // This is our first search for alarms. We're going to look for + // alarms +/- 1 month from now. If someone sets an alarm more than + // a month ahead of an event, or doesn't start Lightning + // for a month, they'll miss some, but that's a slim chance + start = now.clone(); + start.month -= Components.interfaces.calIAlarmService.MAX_SNOOZE_MONTHS; + this.alarmService.mRangeStart = start.clone(); + } + let until = now.clone(); + until.month += Components.interfaces.calIAlarmService.MAX_SNOOZE_MONTHS; + + // We don't set timers for every future alarm, only those within 6 hours + let end = now.clone(); + end.hour += kHoursBetweenUpdates; + this.alarmService.mRangeEnd = end.getInTimezone(UTC()); + + this.alarmService.findAlarms(getCalendarManager().getCalendars({}), + start, until); + } + }; + timerCallback.notify(); + + this.mUpdateTimer = newTimerWithCallback(timerCallback, kHoursBetweenUpdates * 3600000, true); + + this.mStarted = true; + }, + + shutdown: function() { + if (!this.mStarted) { + return; + } + + /* tell people that we're no longer running */ + let notifier = Components.classes["@mozilla.org/embedcomp/appstartup-notifier;1"] + .getService(Components.interfaces.nsIObserver); + notifier.observe(null, "alarm-service-shutdown", null); + + if (this.mUpdateTimer) { + this.mUpdateTimer.cancel(); + this.mUpdateTimer = null; + } + + let calmgr = cal.getCalendarManager(); + calmgr.removeObserver(this.calendarManagerObserver); + + // Stop observing all calendars. This will also clear the timers. + for (let calendar of calmgr.getCalendars({})) { + this.unobserveCalendar(calendar); + } + + this.mRangeEnd = null; + + Services.obs.removeObserver(this, "profile-after-change"); + Services.obs.removeObserver(this, "xpcom-shutdown"); + Services.obs.removeObserver(this, "wake_notification"); + + this.mStarted = false; + }, + + observeCalendar: function(calendar) { + calendar.addObserver(this.calendarObserver); + }, + + unobserveCalendar: function(calendar) { + calendar.removeObserver(this.calendarObserver); + this.disposeCalendarTimers([calendar]); + this.mObservers.notify("onRemoveAlarmsByCalendar", [calendar]); + }, + + addAlarmsForItem: function(aItem) { + if (cal.isToDo(aItem) && aItem.isCompleted) { + // If this is a task and it is completed, don't add the alarm. + return; + } + + let showMissed = Preferences.get("calendar.alarms.showmissed", true); + + let alarms = aItem.getAlarms({}); + for (let alarm of alarms) { + let alarmDate = cal.alarms.calculateAlarmDate(aItem, alarm); + + if (!alarmDate || alarm.action != "DISPLAY") { + // Only take care of DISPLAY alarms with an alarm date. + continue; + } + + // Handle all day events. This is kinda weird, because they don't have + // a well defined startTime. We just consider the start/end to be + // midnight in the user's timezone. + if (alarmDate.isDate) { + alarmDate = alarmDate.getInTimezone(this.timezone); + alarmDate.isDate = false; + } + alarmDate = alarmDate.getInTimezone(UTC()); + + // Check for snooze + let snoozeDate; + if (aItem.parentItem == aItem) { + snoozeDate = aItem.getProperty("X-MOZ-SNOOZE-TIME"); + } else { + snoozeDate = aItem.parentItem.getProperty("X-MOZ-SNOOZE-TIME-" + aItem.recurrenceId.nativeTime); + } + + if (snoozeDate && !(snoozeDate instanceof Components.interfaces.calIDateTime)) { + snoozeDate = cal.createDateTime(snoozeDate); + } + + // an alarm can only be snoozed to a later time, if earlier it's from another alarm. + if (snoozeDate && snoozeDate.compare(alarmDate) > 0) { + // If the alarm was snoozed, the snooze time is more important. + alarmDate = snoozeDate; + } + + let now = nowUTC(); + if (alarmDate.timezone.isFloating) { + now = cal.now(); + now.timezone = floating(); + } + + if (alarmDate.compare(now) >= 0) { + // We assume that future alarms haven't been acknowledged + // Delay is in msec, so don't forget to multiply + let timeout = alarmDate.subtractDate(now).inSeconds * 1000; + + // No sense in keeping an extra timeout for an alarm thats past + // our range. + let timeUntilRefresh = this.mRangeEnd.subtractDate(now).inSeconds * 1000; + if (timeUntilRefresh < timeout) { + continue; + } + + this.addTimer(aItem, alarm, timeout); + } else if (showMissed) { + // This alarm is in the past. See if it has been previously ack'd. + let lastAck = aItem.parentItem.alarmLastAck; + if (lastAck && lastAck.compare(alarmDate) >= 0) { + // The alarm was previously dismissed or snoozed, no further + // action required. + continue; + } else { + // The alarm was not snoozed or dismissed, fire it now. + this.alarmFired(aItem, alarm); + } + } + } + }, + + removeAlarmsForItem: function(aItem) { + // make sure already fired alarms are purged out of the alarm window: + this.mObservers.notify("onRemoveAlarmsByItem", [aItem]); + // Purge alarms specifically for this item (i.e exception) + for (let alarm of aItem.getAlarms({})) { + this.removeTimer(aItem, alarm); + } + }, + + getOccurrencesInRange: function(aItem) { + // We search 1 month in each direction for alarms. Therefore, + // we need occurrences between initial start date and 1 month from now + let until = nowUTC(); + until.month += 1; + + if (aItem && aItem.recurrenceInfo) { + return aItem.recurrenceInfo.getOccurrences(this.mRangeStart, until, 0, {}); + } else { + return cal.checkIfInRange(aItem, this.mRangeStart, until) ? [aItem] : []; + } + }, + + addAlarmsForOccurrences: function(aParentItem) { + let occs = this.getOccurrencesInRange(aParentItem); + + // Add an alarm for each occurrence + occs.forEach(this.addAlarmsForItem, this); + }, + + removeAlarmsForOccurrences: function(aParentItem) { + let occs = this.getOccurrencesInRange(aParentItem); + + // Remove alarm for each occurrence + occs.forEach(this.removeAlarmsForItem, this); + }, + + addTimer: function(aItem, aAlarm, aTimeout) { + this.mTimerMap[aItem.calendar.id] = + this.mTimerMap[aItem.calendar.id] || {}; + this.mTimerMap[aItem.calendar.id][aItem.hashId] = + this.mTimerMap[aItem.calendar.id][aItem.hashId] || {}; + + let self = this; + let alarmTimerCallback = { + notify: function() { + self.alarmFired(aItem, aAlarm); + } + }; + + let timer = newTimerWithCallback(alarmTimerCallback, aTimeout, false); + this.mTimerMap[aItem.calendar.id][aItem.hashId][aAlarm.icalString] = timer; + }, + + removeTimer: function(aItem, aAlarm) { + /* Is the calendar in the timer map */ + if (aItem.calendar.id in this.mTimerMap && + /* ...and is the item in the calendar map */ + aItem.hashId in this.mTimerMap[aItem.calendar.id] && + /* ...and is the alarm in the item map ? */ + aAlarm.icalString in this.mTimerMap[aItem.calendar.id][aItem.hashId]) { + // First cancel the existing timer + let timer = this.mTimerMap[aItem.calendar.id][aItem.hashId][aAlarm.icalString]; + timer.cancel(); + + // Remove the alarm from the item map + delete this.mTimerMap[aItem.calendar.id][aItem.hashId][aAlarm.icalString]; + + // If the item map is empty, remove it from the calendar map + if (this.mTimerMap[aItem.calendar.id][aItem.hashId].toSource() == "({})") { + delete this.mTimerMap[aItem.calendar.id][aItem.hashId]; + } + + // If the calendar map is empty, remove it from the timer map + if (this.mTimerMap[aItem.calendar.id].toSource() == "({})") { + delete this.mTimerMap[aItem.calendar.id]; + } + } + }, + + disposeCalendarTimers: function(aCalendars) { + for (let calendar of aCalendars) { + if (calendar.id in this.mTimerMap) { + for (let hashId in this.mTimerMap[calendar.id]) { + let itemTimerMap = this.mTimerMap[calendar.id][hashId]; + for (let icalString in itemTimerMap) { + let timer = itemTimerMap[icalString]; + timer.cancel(); + } + } + delete this.mTimerMap[calendar.id]; + } + } + }, + + findAlarms: function(aCalendars, aStart, aUntil) { + let getListener = { + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calIOperationListener]), + alarmService: this, + addRemovePromise: PromiseUtils.defer(), + batchCount: 0, + results: false, + onOperationComplete: function(aCalendar, aStatus, aOperationType, aId, aDetail) { + this.addRemovePromise.promise.then((aValue) => { + // calendar has been loaded, so until now, onLoad events can be ignored: + this.alarmService.mLoadedCalendars[aCalendar.id] = true; + + // notify observers that the alarms for the calendar have been loaded + this.alarmService.mObservers.notify("onAlarmsLoaded", [aCalendar]); + }, (aReason) => { + Components.utils.reportError("Promise was rejected: " + aReason); + this.alarmService.mLoadedCalendars[aCalendar.id] = true; + this.alarmService.mObservers.notify("onAlarmsLoaded", [aCalendar]); + }); + + // if no results were returned we still need to resolve the promise + if (!this.results) { + this.addRemovePromise.resolve(); + } + }, + onGetResult: function(aCalendar, aStatus, aItemType, aDetail, aCount, aItems) { + let promise = this.addRemovePromise; + this.batchCount++; + this.results = true; + + cal.forEach(aItems, (item) => { + try { + this.alarmService.removeAlarmsForItem(item); + this.alarmService.addAlarmsForItem(item); + } catch (ex) { + promise.reject(ex); + } + }, () => { + if (--this.batchCount <= 0) { + promise.resolve(); + } + }); + } + }; + + const calICalendar = Components.interfaces.calICalendar; + let filter = calICalendar.ITEM_FILTER_COMPLETED_ALL | + calICalendar.ITEM_FILTER_CLASS_OCCURRENCES | + calICalendar.ITEM_FILTER_TYPE_ALL; + + for (let calendar of aCalendars) { + // assuming that suppressAlarms does not change anymore until refresh: + if (!calendar.getProperty("suppressAlarms") && + !calendar.getProperty("disabled")) { + this.mLoadedCalendars[calendar.id] = false; + calendar.getItems(filter, 0, aStart, aUntil, getListener); + } else { + this.mLoadedCalendars[calendar.id] = true; + this.mObservers.notify("onAlarmsLoaded", [calendar]); + } + } + }, + + initAlarms: function(aCalendars) { + // Purge out all alarm timers belonging to the refreshed/loaded calendars + this.disposeCalendarTimers(aCalendars); + + // Purge out all alarms from dialog belonging to the refreshed/loaded calendars + for (let calendar of aCalendars) { + this.mLoadedCalendars[calendar.id] = false; + this.mObservers.notify("onRemoveAlarmsByCalendar", [calendar]); + } + + // Total refresh similar to startup. We're going to look for + // alarms +/- 1 month from now. If someone sets an alarm more than + // a month ahead of an event, or doesn't start Lightning + // for a month, they'll miss some, but that's a slim chance + let start = nowUTC(); + let until = start.clone(); + start.month -= Components.interfaces.calIAlarmService.MAX_SNOOZE_MONTHS; + until.month += Components.interfaces.calIAlarmService.MAX_SNOOZE_MONTHS; + this.findAlarms(aCalendars, start, until); + }, + + alarmFired: function(aItem, aAlarm) { + if (!aItem.calendar.getProperty("suppressAlarms") && + !aItem.calendar.getProperty("disabled") && + aItem.getProperty("STATUS") != "CANCELLED") { + this.mObservers.notify("onAlarm", [aItem, aAlarm]); + } + }, + + get isLoading() { + for (let calId in this.mLoadedCalendars) { + if (!this.mLoadedCalendars[calId]) { + return true; + } + } + return false; + } +}; diff --git a/calendar/base/src/calApplicationUtils.js b/calendar/base/src/calApplicationUtils.js new file mode 100644 index 000000000..033d4b165 --- /dev/null +++ b/calendar/base/src/calApplicationUtils.js @@ -0,0 +1,43 @@ +/* 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 launchBrowser */ + +Components.utils.import("resource://gre/modules/Services.jsm"); + +/** + * Launch the given url (string) in the external browser. If an event is passed, + * then this is only done on left click and the event propagation is stopped. + * + * @param url The URL to open, as a string + * @param event (optional) The event that caused the URL to open + */ +function launchBrowser(url, event) { + // Bail out if there is no url set, or an event was passed without left-click + if (!url || (event && event.button != 0)) { + return; + } + + // 0. Prevent people from trying to launch URLs such as javascript:foo(); + // by only allowing URLs starting with http or https. + // XXX: We likely will want to do this using nsIURLs in the future to + // prevent sneaky nasty escaping issues, but this is fine for now. + if (!url.startsWith("http")) { + Components.utils.reportError("launchBrowser: " + + "Invalid URL provided: " + url + + " Only http:// and https:// URLs are valid."); + return; + } + + Components.classes["@mozilla.org/uriloader/external-protocol-service;1"] + .getService(Components.interfaces.nsIExternalProtocolService) + .loadUrl(Services.io.newURI(url, null, null)); + + // Make sure that any default click handlers don't do anything, we have taken + // care of all processing + if (event) { + event.stopPropagation(); + event.preventDefault(); + } +} diff --git a/calendar/base/src/calAttachment.js b/calendar/base/src/calAttachment.js new file mode 100644 index 000000000..fcd30cfc2 --- /dev/null +++ b/calendar/base/src/calAttachment.js @@ -0,0 +1,179 @@ +/* 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/. */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://calendar/modules/calIteratorUtils.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + +// +// calAttachment.js +// +function calAttachment() { + this.wrappedJSObject = this; + this.mProperties = new cal.calPropertyBag(); +} + +var calAttachmentClassID = Components.ID("{5f76b352-ab75-4c2b-82c9-9206dbbf8571}"); +var calAttachmentInterfaces = [Components.interfaces.calIAttachment]; +calAttachment.prototype = { + mData: null, + mHashId: null, + + classID: calAttachmentClassID, + QueryInterface: XPCOMUtils.generateQI(calAttachmentInterfaces), + classInfo: XPCOMUtils.generateCI({ + classID: calAttachmentClassID, + contractID: "@mozilla.org/calendar/attachment;1", + classDescription: "Calendar Item Attachment", + interfaces: calAttachmentInterfaces + }), + + get hashId() { + if (!this.mHashId) { + let cryptoHash = Components.classes["@mozilla.org/security/hash;1"] + .createInstance(Components.interfaces.nsICryptoHash); + + let converter = Components.classes["@mozilla.org/intl/scriptableunicodeconverter"] + .createInstance(Components.interfaces.nsIScriptableUnicodeConverter); + converter.charset = "UTF-8"; + let data = converter.convertToByteArray(this.rawData, {}); + + cryptoHash.init(cryptoHash.MD5); + cryptoHash.update(data, data.length); + this.mHashId = cryptoHash.finish(true); + } + return this.mHashId; + }, + + /** + * calIAttachment + */ + + get uri() { + let uri = null; + if (this.getParameter("VALUE") != "BINARY") { + // If this is not binary data, its likely an uri. Attempt to convert + // and throw otherwise. + try { + uri = makeURL(this.mData); + } catch (e) { + // Its possible that the uri contains malformed data. Often + // callers don't expect an exception here, so we just catch + // it and return null. + } + } + + return uri; + }, + set uri(aUri) { + // An uri is the default format, remove any value type parameters + this.deleteParameter("VALUE"); + this.setData(aUri.spec); + return aUri; + }, + + get rawData() { + return this.mData; + }, + set rawData(aData) { + // Setting the raw data lets us assume this is binary data. Make sure + // the value parameter is set + this.setParameter("VALUE", "BINARY"); + return this.setData(aData); + }, + + get formatType() { + return this.getParameter("FMTTYPE"); + }, + set formatType(aType) { + return this.setParameter("FMTTYPE", aType); + }, + + get encoding() { + return this.getParameter("ENCODING"); + }, + set encoding(aValue) { + return this.setParameter("ENCODING", aValue); + }, + + get icalProperty() { + let icalatt = cal.getIcsService().createIcalProperty("ATTACH"); + + for (let [key, value] of this.mProperties) { + try { + icalatt.setParameter(key, value); + } catch (e) { + if (e.result == Components.results.NS_ERROR_ILLEGAL_VALUE) { + // Illegal values should be ignored, but we could log them if + // the user has enabled logging. + cal.LOG("Warning: Invalid attachment parameter value " + key + "=" + value); + } else { + throw e; + } + } + } + + if (this.mData) { + icalatt.value = this.mData; + } + return icalatt; + }, + + set icalProperty(attProp) { + // Reset the property bag for the parameters, it will be re-initialized + // from the ical property. + this.mProperties = new cal.calPropertyBag(); + this.setData(attProp.value); + + for (let [name, value] of cal.ical.paramIterator(attProp)) { + this.setParameter(name, value); + } + }, + + get icalString() { + let comp = this.icalProperty; + return (comp ? comp.icalString : ""); + }, + set icalString(val) { + let prop = cal.getIcsService().createIcalPropertyFromString(val); + if (prop.propertyName != "ATTACH") { + throw Components.results.NS_ERROR_ILLEGAL_VALUE; + } + this.icalProperty = prop; + return val; + }, + + getParameter: function(aName) { + return this.mProperties.getProperty(aName); + }, + + setParameter: function(aName, aValue) { + if (aValue || aValue === 0) { + return this.mProperties.setProperty(aName, aValue); + } else { + return this.mProperties.deleteProperty(aName); + } + }, + + deleteParameter: function(aName) { + this.mProperties.deleteProperty(aName); + }, + + clone: function() { + let newAttachment = new calAttachment(); + newAttachment.mData = this.mData; + newAttachment.mHashId = this.mHashId; + for (let [name, value] of this.mProperties) { + newAttachment.mProperties.setProperty(name, value); + } + return newAttachment; + }, + + setData: function(aData) { + // Sets the data and invalidates the hash so it will be recalculated + this.mHashId = null; + this.mData = aData; + return this.mData; + } +}; diff --git a/calendar/base/src/calAttendee.js b/calendar/base/src/calAttendee.js new file mode 100644 index 000000000..b3b9e8673 --- /dev/null +++ b/calendar/base/src/calAttendee.js @@ -0,0 +1,205 @@ +/* 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/. */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://calendar/modules/calIteratorUtils.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + +function calAttendee() { + this.wrappedJSObject = this; + this.mProperties = new calPropertyBag(); +} + +var calAttendeeClassID = Components.ID("{5c8dcaa3-170c-4a73-8142-d531156f664d}"); +var calAttendeeInterfaces = [Components.interfaces.calIAttendee]; +calAttendee.prototype = { + classID: calAttendeeClassID, + QueryInterface: XPCOMUtils.generateQI(calAttendeeInterfaces), + classInfo: XPCOMUtils.generateCI({ + classID: calAttendeeClassID, + contractID: "@mozilla.org/calendar/attendee;1", + classDescription: "Calendar Attendee", + interfaces: calAttendeeInterfaces + }), + + mImmutable: false, + get isMutable() { return !this.mImmutable; }, + + modify: function() { + if (this.mImmutable) { + throw Components.results.NS_ERROR_OBJECT_IS_IMMUTABLE; + } + }, + + makeImmutable: function() { + this.mImmutable = true; + }, + + clone: function() { + let a = new calAttendee(); + + if (this.mIsOrganizer) { + a.isOrganizer = true; + } + + const allProps = ["id", "commonName", "rsvp", "role", + "participationStatus", "userType"]; + for (let prop of allProps) { + a[prop] = this[prop]; + } + + for (let [key, value] of this.mProperties) { + a.setProperty(key, value); + } + + return a; + }, + // XXX enforce legal values for our properties; + + icalAttendeePropMap: [ + { cal: "rsvp", ics: "RSVP" }, + { cal: "commonName", ics: "CN" }, + { cal: "participationStatus", ics: "PARTSTAT" }, + { cal: "userType", ics: "CUTYPE" }, + { cal: "role", ics: "ROLE" } + ], + + mIsOrganizer: false, + get isOrganizer() { return this.mIsOrganizer; }, + set isOrganizer(bool) { this.mIsOrganizer = bool; }, + + // icalatt is a calIcalProperty of type attendee + set icalProperty(icalatt) { + this.modify(); + this.id = icalatt.valueAsIcalString; + this.mIsOrganizer = (icalatt.propertyName == "ORGANIZER"); + + let promotedProps = { }; + for (let prop of this.icalAttendeePropMap) { + this[prop.cal] = icalatt.getParameter(prop.ics); + // Don't copy these to the property bag. + promotedProps[prop.ics] = true; + } + + // Reset the property bag for the parameters, it will be re-initialized + // from the ical property. + this.mProperties = new calPropertyBag(); + + for (let [name, value] of cal.ical.paramIterator(icalatt)) { + if (!promotedProps[name]) { + this.setProperty(name, value); + } + } + }, + + get icalProperty() { + let icssvc = cal.getIcsService(); + let icalatt; + if (this.mIsOrganizer) { + icalatt = icssvc.createIcalProperty("ORGANIZER"); + } else { + icalatt = icssvc.createIcalProperty("ATTENDEE"); + } + + if (!this.id) { + throw Components.results.NS_ERROR_NOT_INITIALIZED; + } + icalatt.valueAsIcalString = this.id; + for (let i = 0; i < this.icalAttendeePropMap.length; i++) { + let prop = this.icalAttendeePropMap[i]; + if (this[prop.cal]) { + try { + icalatt.setParameter(prop.ics, this[prop.cal]); + } catch (e) { + if (e.result == Components.results.NS_ERROR_ILLEGAL_VALUE) { + // Illegal values should be ignored, but we could log them if + // the user has enabled logging. + cal.LOG("Warning: Invalid attendee parameter value " + prop.ics + "=" + this[prop.cal]); + } else { + throw e; + } + } + } + } + for (let [key, value] of this.mProperties) { + try { + icalatt.setParameter(key, value); + } catch (e) { + if (e.result == Components.results.NS_ERROR_ILLEGAL_VALUE) { + // Illegal values should be ignored, but we could log them if + // the user has enabled logging. + cal.LOG("Warning: Invalid attendee parameter value " + key + "=" + value); + } else { + throw e; + } + } + } + return icalatt; + }, + + get icalString() { + let comp = this.icalProperty; + return (comp ? comp.icalString : ""); + }, + set icalString(val) { + let prop = cal.getIcsService().createIcalPropertyFromString(val); + if (prop.propertyName != "ORGANIZER" && prop.propertyName != "ATTENDEE") { + throw Components.results.NS_ERROR_ILLEGAL_VALUE; + } + this.icalProperty = prop; + return val; + }, + + get propertyEnumerator() { return this.mProperties.enumerator; }, + + // The has/get/set/deleteProperty methods are case-insensitive. + getProperty: function(aName) { + return this.mProperties.getProperty(aName.toUpperCase()); + }, + setProperty: function(aName, aValue) { + this.modify(); + if (aValue || !isNaN(parseInt(aValue, 10))) { + this.mProperties.setProperty(aName.toUpperCase(), aValue); + } else { + this.mProperties.deleteProperty(aName.toUpperCase()); + } + }, + deleteProperty: function(aName) { + this.modify(); + this.mProperties.deleteProperty(aName.toUpperCase()); + }, + + mId: null, + get id() { + return this.mId; + }, + set id(aId) { + this.modify(); + // RFC 1738 para 2.1 says we should be using lowercase mailto: urls + // we enforce prepending the mailto prefix for email type ids as migration code bug 1199942 + return (this.mId = (aId ? cal.prependMailTo(aId) : null)); + }, + + toString: function() { + const emailRE = new RegExp("^mailto:", "i"); + let stringRep = (this.id || "").replace(emailRE, ""); + let commonName = this.commonName; + + if (commonName) { + stringRep = commonName + " <" + stringRep + ">"; + } + + return stringRep; + } +}; + +var makeMemberAttr; +if (makeMemberAttr) { + makeMemberAttr(calAttendee, "mCommonName", null, "commonName"); + makeMemberAttr(calAttendee, "mRsvp", null, "rsvp"); + makeMemberAttr(calAttendee, "mRole", null, "role"); + makeMemberAttr(calAttendee, "mParticipationStatus", "NEEDS-ACTION", + "participationStatus"); + makeMemberAttr(calAttendee, "mUserType", "INDIVIDUAL", "userType"); +} diff --git a/calendar/base/src/calCachedCalendar.js b/calendar/base/src/calCachedCalendar.js new file mode 100644 index 000000000..f9eb2d858 --- /dev/null +++ b/calendar/base/src/calCachedCalendar.js @@ -0,0 +1,884 @@ +/* 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/. */ + +Components.utils.import("resource://calendar/modules/calProviderUtils.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); +Components.utils.import("resource://gre/modules/Preferences.jsm"); + +var calICalendar = Components.interfaces.calICalendar; +var cICL = Components.interfaces.calIChangeLog; +var cIOL = Components.interfaces.calIOperationListener; + +var gNoOpListener = { + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calIOperationListener]), + onGetResult: function(calendar, status, itemType, detail, count, items) { + }, + + onOperationComplete: function(calendar, status, opType, id, detail) { + } +}; + +/** + * Returns true if the exception passed is one that should cause the cache + * layer to retry the operation. This is usually a network error or other + * temporary error. + * + * @param result The result code to check. + * @return True, if the result code means server unavailability. + */ +function isUnavailableCode(result) { + // Stolen from nserror.h + const NS_ERROR_MODULE_NETWORK = 6; + function NS_ERROR_GET_MODULE(code) { + return (((code >> 16) - 0x45) & 0x1fff); + } + + if (NS_ERROR_GET_MODULE(result) == NS_ERROR_MODULE_NETWORK && + !Components.isSuccessCode(result)) { + // This is a network error, which most likely means we should + // retry it some time. + return true; + } + + // Other potential errors we want to retry with + switch (result) { + case Components.results.NS_ERROR_NOT_AVAILABLE: + return true; + default: + return false; + } +} + +function calCachedCalendarObserverHelper(home, isCachedObserver) { + this.home = home; + this.isCachedObserver = isCachedObserver; +} +calCachedCalendarObserverHelper.prototype = { + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calIObserver]), + isCachedObserver: false, + + onStartBatch: function() { + this.home.mObservers.notify("onStartBatch"); + }, + + onEndBatch: function() { + this.home.mObservers.notify("onEndBatch"); + }, + + onLoad: function(calendar) { + if (this.isCachedObserver) { + this.home.mObservers.notify("onLoad", [this.home]); + } else { + // start sync action after uncached calendar has been loaded. + // xxx todo, think about: + // although onAddItem et al have been called, we need to fire + // an additional onLoad completing the refresh call (->composite) + let home = this.home; + home.synchronize((status) => { + home.mObservers.notify("onLoad", [home]); + }); + } + }, + + onAddItem: function(aItem) { + if (this.isCachedObserver) { + this.home.mObservers.notify("onAddItem", arguments); + } + }, + + onModifyItem: function(aNewItem, aOldItem) { + if (this.isCachedObserver) { + this.home.mObservers.notify("onModifyItem", arguments); + } + }, + + onDeleteItem: function(aItem) { + if (this.isCachedObserver) { + this.home.mObservers.notify("onDeleteItem", arguments); + } + }, + + onError: function(aCalendar, aErrNo, aMessage) { + this.home.mObservers.notify("onError", arguments); + }, + + onPropertyChanged: function(aCalendar, aName, aValue, aOldValue) { + if (!this.isCachedObserver) { + this.home.mObservers.notify("onPropertyChanged", [this.home, aName, aValue, aOldValue]); + } + }, + + onPropertyDeleting: function(aCalendar, aName) { + if (!this.isCachedObserver) { + this.home.mObservers.notify("onPropertyDeleting", [this.home, aName]); + } + } +}; + +function calCachedCalendar(uncachedCalendar) { + this.wrappedJSObject = this; + this.mSyncQueue = []; + this.mObservers = new cal.ObserverBag(Components.interfaces.calIObserver); + uncachedCalendar.superCalendar = this; + uncachedCalendar.addObserver(new calCachedCalendarObserverHelper(this, false)); + this.mUncachedCalendar = uncachedCalendar; + this.setupCachedCalendar(); + if (this.supportsChangeLog) { + uncachedCalendar.offlineStorage = this.mCachedCalendar; + } + this.offlineCachedItems = {}; + this.offlineCachedItemFlags = {}; +} +calCachedCalendar.prototype = { + QueryInterface: function(aIID) { + if (aIID.equals(Components.interfaces.calISchedulingSupport) && + this.mUncachedCalendar.QueryInterface(aIID)) { + // check whether uncached calendar supports it: + return this; + } else if (aIID.equals(Components.interfaces.calICalendar) || + aIID.equals(Components.interfaces.nsISupports)) { + return this; + } else { + throw Components.results.NS_ERROR_NO_INTERFACE; + } + }, + + mCachedCalendar: null, + mCachedObserver: null, + mUncachedCalendar: null, + mObservers: null, + mSuperCalendar: null, + offlineCachedItems: null, + offlineCachedItemFlags: null, + + onCalendarUnregistering: function() { + if (this.mCachedCalendar) { + let self = this; + this.mCachedCalendar.removeObserver(this.mCachedObserver); + // TODO put changes into a different calendar and delete + // afterwards. + + let listener = { + onDeleteCalendar: function(aCalendar, aStatus, aDetail) { + self.mCachedCalendar = null; + } + }; + + this.mCachedCalendar.QueryInterface(Components.interfaces.calICalendarProvider) + .deleteCalendar(this.mCachedCalendar, listener); + } + }, + + setupCachedCalendar: function() { + try { + if (this.mCachedCalendar) { // this is actually a resetupCachedCalendar: + // Although this doesn't really follow the spec, we know the + // storage calendar's deleteCalendar method is synchronous. + // TODO put changes into a different calendar and delete + // afterwards. + this.mCachedCalendar.QueryInterface(Components.interfaces.calICalendarProvider) + .deleteCalendar(this.mCachedCalendar, null); + if (this.supportsChangeLog) { + // start with full sync: + this.mUncachedCalendar.resetLog(); + } + } else { + let calType = Preferences.get("calendar.cache.type", "storage"); + // While technically, the above deleteCalendar should delete the + // whole calendar, this is nothing more than deleting all events + // todos and properties. Therefore the initialization can be + // skipped. + let cachedCalendar = Components.classes["@mozilla.org/calendar/calendar;1?type=" + calType] + .createInstance(Components.interfaces.calICalendar); + switch (calType) { + case "memory": { + if (this.supportsChangeLog) { + // start with full sync: + this.mUncachedCalendar.resetLog(); + } + break; + } + case "storage": { + let file = getCalendarDirectory(); + file.append("cache.sqlite"); + cachedCalendar.uri = Services.io.newFileURI(file); + cachedCalendar.id = this.id; + break; + } + default: { + throw new Error("unsupported cache calendar type: " + calType); + } + } + cachedCalendar.transientProperties = true; + cachedCalendar.setProperty("relaxedMode", true); + cachedCalendar.superCalendar = this; + if (!this.mCachedObserver) { + this.mCachedObserver = new calCachedCalendarObserverHelper(this, true); + } + cachedCalendar.addObserver(this.mCachedObserver); + this.mCachedCalendar = cachedCalendar; + } + } catch (exc) { + Components.utils.reportError(exc); + } + }, + + getOfflineAddedItems: function(callbackFunc) { + let self = this; + self.offlineCachedItems = {}; + let getListener = { + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calIOperationListener]), + onGetResult: function(aCalendar, aStatus, aItemType, aDetail, aCount, aItems) { + for (let item of aItems) { + self.offlineCachedItems[item.hashId] = item; + self.offlineCachedItemFlags[item.hashId] = cICL.OFFLINE_FLAG_CREATED_RECORD; + } + }, + + onOperationComplete: function(aCalendar, aStatus, aOpType, aId, aDetail) { + self.getOfflineModifiedItems(callbackFunc); + } + }; + this.mCachedCalendar.getItems(calICalendar.ITEM_FILTER_ALL_ITEMS | calICalendar.ITEM_FILTER_OFFLINE_CREATED, + 0, null, null, getListener); + }, + + getOfflineModifiedItems: function(callbackFunc) { + let self = this; + let getListener = { + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calIOperationListener]), + onGetResult: function(aCalendar, aStatus, aItemType, aDetail, aCount, aItems) { + for (let item of aItems) { + self.offlineCachedItems[item.hashId] = item; + self.offlineCachedItemFlags[item.hashId] = cICL.OFFLINE_FLAG_MODIFIED_RECORD; + } + }, + + onOperationComplete: function(aCalendar, aStatus, aOpType, aId, aDetail) { + self.getOfflineDeletedItems(callbackFunc); + } + }; + this.mCachedCalendar.getItems(calICalendar.ITEM_FILTER_OFFLINE_MODIFIED | calICalendar.ITEM_FILTER_ALL_ITEMS, + 0, null, null, getListener); + }, + + getOfflineDeletedItems: function(callbackFunc) { + let self = this; + let getListener = { + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calIOperationListener]), + onGetResult: function(aCalendar, aStatus, aItemType, aDetail, aCount, aItems) { + for (let item of aItems) { + self.offlineCachedItems[item.hashId] = item; + self.offlineCachedItemFlags[item.hashId] = cICL.OFFLINE_FLAG_DELETED_RECORD; + } + }, + + onOperationComplete: function(aCalendar, aStatus, aOpType, aId, aDetail) { + if (callbackFunc) { + callbackFunc(); + } + } + }; + this.mCachedCalendar.getItems(calICalendar.ITEM_FILTER_OFFLINE_DELETED | calICalendar.ITEM_FILTER_ALL_ITEMS, + 0, null, null, getListener); + }, + + mPendingSync: null, + mSyncQueue: null, + synchronize: function(respFunc) { + let self = this; + if (this.getProperty("disabled")) { + return emptyQueue(Components.results.NS_OK); + } + + this.mSyncQueue.push(respFunc); + if (this.mSyncQueue.length > 1) { // don't use mPendingSync here + cal.LOG("[calCachedCalendar] sync in action/pending."); + return this.mPendingSync; + } + + function emptyQueue(status) { + let queue = self.mSyncQueue; + self.mSyncQueue = []; + function execResponseFunc(func) { + try { + func(status); + } catch (exc) { + cal.ASSERT(false, exc); + } + } + queue.forEach(execResponseFunc); + cal.LOG("[calCachedCalendar] sync queue empty."); + let operation = self.mPendingSync; + self.mPendingSync = null; + return operation; + } + + if (this.offline) { + return emptyQueue(Components.results.NS_OK); + } + + if (this.supportsChangeLog) { + cal.LOG("[calCachedCalendar] Doing changelog based sync for calendar " + this.uri.spec); + let opListener = { + onResult: function(operation, result) { + if (!operation || !operation.isPending) { + let status = (operation ? operation.status : Components.results.NS_OK); + if (!Components.isSuccessCode(status)) { + cal.ERROR("[calCachedCalendar] replay action failed: " + + (operation ? operation.id : "<unknown>") + ", uri=" + + self.uri.spec + ", result=" + + result + ", operation=" + operation); + } + cal.LOG("[calCachedCalendar] replayChangesOn finished."); + emptyQueue(status); + } + } + }; + this.mPendingSync = this.mUncachedCalendar.replayChangesOn(opListener); + return this.mPendingSync; + } + + cal.LOG("[calCachedCalendar] Doing full sync for calendar " + this.uri.spec); + let completeListener = { + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calIOperationListener]), + modifiedTimes: {}, + hasRenewedCalendar: false, + getsCompleted: 0, + getsReceived: 0, + opCompleted: false, + + onGetResult: function(aCalendar, aStatus, aItemType, aDetail, aCount, aItems) { + if (Components.isSuccessCode(aStatus)) { + if (!this.hasRenewedCalendar) { + // TODO instead of deleting the calendar and creating a new + // one, maybe we want to do a "real" sync between the + // existing local calendar and the remote calendar. + self.setupCachedCalendar(); + this.hasRenewedCalendar = true; + } + + this.getsReceived++; + cal.forEach(aItems, (item) => { + // Adding items recd from the Memory Calendar + // These may be different than what the cache has + completeListener.modifiedTimes[item.id] = item.lastModifiedTime; + self.mCachedCalendar.addItem(item, null); + }, () => { + completeListener.getsCompleted++; + if (completeListener.opCompleted) { + // onOperationComplete was called, but we were not ready yet. call it now. + completeListener.onOperationComplete(...completeListener.opCompleted); + completeListener.opCompleted = false; + } + }); + } + }, + + onOperationComplete: function(aCalendar, aStatus, aOpType, aId, aDetail) { + if (this.getsCompleted < this.getsReceived) { + // If not all of our gets have been processed, then save the + // arguments and finish processing later. + this.opCompleted = Array.slice(arguments); + return; + } + + if (Components.isSuccessCode(aStatus)) { + cal.forEach(self.offlineCachedItems, (item) => { + switch (self.offlineCachedItemFlags[item.hashId]) { + case cICL.OFFLINE_FLAG_CREATED_RECORD: + // Created items are not present on the server, so its safe to adopt them + self.adoptOfflineItem(item.clone(), null); + break; + case cICL.OFFLINE_FLAG_MODIFIED_RECORD: + // Two Cases Here: + if (item.id in completeListener.modifiedTimes) { + // The item is still on the server, we just retrieved it in the listener above. + if (item.lastModifiedTime.compare(completeListener.modifiedTimes[item.id]) < 0) { + // The item on the server has been modified, ask to overwrite + cal.WARN("[calCachedCalendar] Item '" + item.title + "' at the server seems to be modified recently."); + self.promptOverwrite("modify", item, null, null); + } else { + // Our item is newer, just modify the item + self.modifyOfflineItem(item, null, null); + } + } else { + // The item has been deleted from the server, ask if it should be added again + cal.WARN("[calCachedCalendar] Item '" + item.title + "' has been deleted from the server"); + if (cal.promptOverwrite("modify", item, null, null)) { + self.adoptOfflineItem(item.clone(), null); + } + } + break; + case cICL.OFFLINE_FLAG_DELETED_RECORD: + if (item.id in completeListener.modifiedTimes) { + // The item seems to exist on the server... + if (item.lastModifiedTime.compare(completeListener.modifiedTimes[item.id]) < 0) { + // ...and has been modified on the server. Ask to overwrite + cal.WARN("[calCachedCalendar] Item '" + item.title + "' at the server seems to be modified recently."); + self.promptOverwrite("delete", item, null, null); + } else { + // ...and has not been modified. Delete it now. + self.deleteOfflineItem(item, null); + } + } else { + // Item has already been deleted from the server, no need to change anything. + } + break; + } + }, () => { + self.offlineCachedItems = {}; + self.offlineCachedItemFlags = {}; + self.playbackOfflineItems(() => emptyQueue(aStatus)); + }); + } else { + self.playbackOfflineItems(() => self.mCachedObserver.onLoad(self.mCachedCalendar)); + emptyQueue(aStatus); + } + } + }; + + this.getOfflineAddedItems(() => { + this.mPendingSync = this.mUncachedCalendar.getItems(Components.interfaces.calICalendar.ITEM_FILTER_ALL_ITEMS, + 0, null, null, completeListener); + }); + return this.mPendingSync; + }, + + onOfflineStatusChanged: function(aNewState) { + if (aNewState) { + // Going offline: (XXX get items before going offline?) => we may ask the user to stay online a bit longer + } else { + // Going online (start replaying changes to the remote calendar) + this.refresh(); + } + }, + + // aOldItem is already in the cache + promptOverwrite: function(aMethod, aItem, aListener, aOldItem) { + let overwrite = cal.promptOverwrite(aMethod, aItem, aListener, aOldItem); + if (overwrite) { + if (aMethod == "modify") { + this.modifyOfflineItem(aItem, aOldItem, aListener); + } else { + this.deleteOfflineItem(aItem, aListener); + } + } + }, + + /* + * Asynchronously performs playback operations of items added, modified, or deleted offline + * + * @param aCallback (optional) The function to be callled when playback is complete. + * @param aPlaybackType (optional) The starting operation type. This function will be + * called recursively through playback operations in the order of + * add, modify, delete. By default playback will start with the add + * operation. Valid values for this parameter are defined as + * OFFLINE_FLAG_XXX constants in the calIChangeLog interface. + */ + playbackOfflineItems: function(aCallback, aPlaybackType) { + let self = this; + let storage = this.mCachedCalendar.QueryInterface(Components.interfaces.calIOfflineStorage); + + let resetListener = gNoOpListener; + let itemQueue = []; + let debugOp; + let nextCallback; + let uncachedOp; + let listenerOp; + let filter; + + aPlaybackType = aPlaybackType || cICL.OFFLINE_FLAG_CREATED_RECORD; + switch (aPlaybackType) { + case cICL.OFFLINE_FLAG_CREATED_RECORD: + debugOp = "add"; + nextCallback = this.playbackOfflineItems.bind(this, aCallback, cICL.OFFLINE_FLAG_MODIFIED_RECORD); + uncachedOp = this.mUncachedCalendar.addItem.bind(this.mUncachedCalendar); + listenerOp = cIOL.ADD; + filter = calICalendar.ITEM_FILTER_OFFLINE_CREATED; + break; + case cICL.OFFLINE_FLAG_MODIFIED_RECORD: + debugOp = "modify"; + nextCallback = this.playbackOfflineItems.bind(this, aCallback, cICL.OFFLINE_FLAG_DELETED_RECORD); + uncachedOp = function(item, listener) { self.mUncachedCalendar.modifyItem(item, item, listener); }; + listenerOp = cIOL.MODIFY; + filter = calICalendar.ITEM_FILTER_OFFLINE_MODIFIED; + break; + case cICL.OFFLINE_FLAG_DELETED_RECORD: + debugOp = "delete"; + nextCallback = aCallback; + uncachedOp = this.mUncachedCalendar.deleteItem.bind(this.mUncachedCalendar); + listenerOp = cIOL.MODIFY; + filter = calICalendar.ITEM_FILTER_OFFLINE_DELETED; + break; + default: + cal.ERROR("[calCachedCalendar] Invalid playback type: " + aPlaybackType); + return; + } + + let opListener = { + onGetResult: function(calendar, status, itemType, detail, count, items) {}, + onOperationComplete: function(calendar, status, opType, id, detail) { + if (Components.isSuccessCode(status)) { + if (aPlaybackType == cICL.OFFLINE_FLAG_DELETED_RECORD) { + self.mCachedCalendar.deleteItem(detail, resetListener); + } else { + storage.resetItemOfflineFlag(detail, resetListener); + } + } else { + // If the playback action could not be performed, then there + // is no need for further action. The item still has the + // offline flag, so it will be taken care of next time. + cal.WARN("[calCachedCalendar] Unable to perform playback action " + debugOp + + " to the server, will try again next time (" + id + "," + detail + ")"); + } + + // move on to the next item in the queue + popItemQueue(); + } + }; + + function popItemQueue() { + if (!itemQueue || itemQueue.length == 0) { + // no items left in the queue, move on to the next operation + if (nextCallback) { + nextCallback(); + } + } else { + // perform operation on the next offline item in the queue + let item = itemQueue.pop(); + try { + uncachedOp(item, opListener); + } catch (e) { + cal.ERROR("[calCachedCalendar] Could not perform playback operation " + debugOp + + " for item " + (item.title || " (none) ") + ": " + e); + opListener.onOperationComplete(self, e.result, listenerOp, item.id, e.message); + } + } + } + + let getListener = { + onGetResult: function(calendar, status, itemType, detail, count, items) { + itemQueue = itemQueue.concat(items); + }, + onOperationComplete: function(calendar, status, opType, id, detail) { + if (self.offline) { + cal.LOG("[calCachedCalendar] back to offline mode, reconciliation aborted"); + if (aCallback) { + aCallback(); + } + } else { + cal.LOG("[calCachedCalendar] Performing playback operation " + debugOp + + " on " + itemQueue.length + " items to " + self.name); + + // start the first operation + popItemQueue(); + } + } + }; + + this.mCachedCalendar.getItems(calICalendar.ITEM_FILTER_ALL_ITEMS | filter, + 0, null, null, getListener); + }, + + get superCalendar() { + return (this.mSuperCalendar && this.mSuperCalendar.superCalendar) || this; + }, + set superCalendar(val) { + return (this.mSuperCalendar = val); + }, + + get offline() { + return Services.io.offline; + }, + get supportsChangeLog() { + return (cal.wrapInstance(this.mUncachedCalendar, Components.interfaces.calIChangeLog) != null); + }, + + get canRefresh() { // enable triggering sync using the reload button + return true; + }, + + getProperty: function(aName) { + switch (aName) { + case "cache.enabled": + if (this.mUncachedCalendar.getProperty("cache.always")) { + return true; + } + break; + } + + return this.mUncachedCalendar.getProperty(aName); + }, + refresh: function() { + if (this.offline) { + this.downstreamRefresh(); + } else if (this.supportsChangeLog) { + /* we first ensure that any remaining offline items are reconciled with the calendar server */ + this.playbackOfflineItems(this.downstreamRefresh.bind(this)); + } else { + this.downstreamRefresh(); + } + }, + downstreamRefresh: function() { + if (this.mUncachedCalendar.canRefresh && !this.offline) { + return this.mUncachedCalendar.refresh(); // will trigger synchronize once the calendar is loaded + } else { + return this.synchronize((status) => { // fire completing onLoad for this refresh call + this.mCachedObserver.onLoad(this.mCachedCalendar); + }); + } + }, + + addObserver: function(aObserver) { + this.mObservers.add(aObserver); + }, + removeObserver: function(aObserver) { + this.mObservers.remove(aObserver); + }, + + addItem: function(item, listener) { + return this.adoptItem(item.clone(), listener); + }, + adoptItem: function(item, listener) { + // Forwarding add/modify/delete to the cached calendar using the calIObserver + // callbacks would be advantageous, because the uncached provider could implement + // a true push mechanism firing without being triggered from within the program. + // But this would mean the uncached provider fires on the passed + // calIOperationListener, e.g. *before* it fires on calIObservers + // (because that order is undefined). Firing onOperationComplete before onAddItem et al + // would result in this facade firing onOperationComplete even though the modification + // hasn't yet been performed on the cached calendar (which happens in onAddItem et al). + // Result is that we currently stick to firing onOperationComplete if the cached calendar + // has performed the modification, see below: + let self = this; + let cacheListener = { + onGetResult: function(calendar, status, itemType, detail, count, items) { + cal.ASSERT(false, "unexpected!"); + }, + onOperationComplete: function(calendar, status, opType, id, detail) { + if (isUnavailableCode(status)) { + // The item couldn't be added to the (remote) location, + // this is like being offline. Add the item to the cached + // calendar instead. + cal.LOG("[calCachedCalendar] Calendar " + calendar.name + " is unavailable, adding item offline"); + self.adoptOfflineItem(item, listener); + } else if (Components.isSuccessCode(status)) { + // On success, add the item to the cache. + self.mCachedCalendar.addItem(detail, listener); + } else if (listener) { + // Either an error occurred or this is a successful add + // to a cached calendar. Forward the call to the listener + listener.onOperationComplete(self, status, opType, id, detail); + } + } + }; + + if (this.offline) { + // If we are offline, don't even try to add the item + this.adoptOfflineItem(item, listener); + } else { + // Otherwise ask the provider to add the item now. + this.mUncachedCalendar.adoptItem(item, cacheListener); + } + }, + adoptOfflineItem: function(item, listener) { + let self = this; + let opListener = { + onGetResult: function(calendar, status, itemType, detail, count, items) { + cal.ASSERT(false, "unexpected!"); + }, + onOperationComplete: function(calendar, status, opType, id, detail) { + if (Components.isSuccessCode(status)) { + let storage = self.mCachedCalendar.QueryInterface(Components.interfaces.calIOfflineStorage); + storage.addOfflineItem(detail, listener); + } else if (listener) { + listener.onOperationComplete(self, status, opType, id, detail); + } + } + }; + this.mCachedCalendar.adoptItem(item, opListener); + }, + + modifyItem: function(newItem, oldItem, listener) { + let self = this; + + // First of all, we should find out if the item to modify is + // already an offline item or not. + let flagListener = { + onGetResult: function() {}, + onOperationComplete: function(calendar, status, opType, id, offline_flag) { + if (offline_flag == cICL.OFFLINE_FLAG_CREATED_RECORD || + offline_flag == cICL.OFFLINE_FLAG_MODIFIED_RECORD) { + // The item is already offline, just modify it in the cache + self.modifyOfflineItem(newItem, oldItem, listener); + } else { + // Not an offline item, attempt to modify using provider + self.mUncachedCalendar.modifyItem(newItem, oldItem, cacheListener); + } + } + }; + + /* Forwarding add/modify/delete to the cached calendar using the calIObserver + * callbacks would be advantageous, because the uncached provider could implement + * a true push mechanism firing without being triggered from within the program. + * But this would mean the uncached provider fires on the passed + * calIOperationListener, e.g. *before* it fires on calIObservers + * (because that order is undefined). Firing onOperationComplete before onAddItem et al + * would result in this facade firing onOperationComplete even though the modification + * hasn't yet been performed on the cached calendar (which happens in onAddItem et al). + * Result is that we currently stick to firing onOperationComplete if the cached calendar + * has performed the modification, see below: */ + let cacheListener = { + onGetResult: function() {}, + onOperationComplete: function(calendar, status, opType, id, detail) { + if (isUnavailableCode(status)) { + // The item couldn't be modified at the (remote) location, + // this is like being offline. Add the item to the cache + // instead. + cal.LOG("[calCachedCalendar] Calendar " + calendar.name + " is unavailable, modifying item offline"); + self.modifyOfflineItem(newItem, oldItem, listener); + } else if (Components.isSuccessCode(status)) { + // On success, modify the item in the cache + self.mCachedCalendar.modifyItem(detail, oldItem, listener); + } else if (listener) { + // This happens on error, forward the error through the listener + listener.onOperationComplete(self, status, opType, id, detail); + } + } + }; + + if (this.offline) { + // If we are offline, don't even try to modify the item + this.modifyOfflineItem(newItem, oldItem, listener); + } else { + // Otherwise, get the item flags, the listener will further + // process the item. + this.mCachedCalendar.getItemOfflineFlag(oldItem, flagListener); + } + }, + + modifyOfflineItem: function(newItem, oldItem, listener) { + let self = this; + let opListener = { + onGetResult: function(calendar, status, itemType, detail, count, items) { + cal.ASSERT(false, "unexpected!"); + }, + onOperationComplete: function(calendar, status, opType, id, detail) { + if (Components.isSuccessCode(status)) { + // Modify the offline item in the storage, passing the + // listener will make sure its notified + let storage = self.mCachedCalendar.QueryInterface(Components.interfaces.calIOfflineStorage); + storage.modifyOfflineItem(detail, listener); + } else if (listener) { + // If there was not a success, then we need to notify the + // listener ourselves + listener.onOperationComplete(self, status, opType, id, detail); + } + } + }; + + this.mCachedCalendar.modifyItem(newItem, oldItem, opListener); + }, + + deleteItem: function(item, listener) { + let self = this; + + // First of all, we should find out if the item to delete is + // already an offline item or not. + let flagListener = { + onGetResult: function() {}, + onOperationComplete: function(calendar, status, opType, id, offline_flag) { + if (offline_flag == cICL.OFFLINE_FLAG_CREATED_RECORD || + offline_flag == cICL.OFFLINE_FLAG_MODIFIED_RECORD) { + // The item is already offline, just mark it deleted it in + // the cache + self.deleteOfflineItem(item, listener); + } else { + // Not an offline item, attempt to delete using provider + self.mUncachedCalendar.deleteItem(item, cacheListener); + } + } + }; + // Forwarding add/modify/delete to the cached calendar using the calIObserver + // callbacks would be advantageous, because the uncached provider could implement + // a true push mechanism firing without being triggered from within the program. + // But this would mean the uncached provider fires on the passed + // calIOperationListener, e.g. *before* it fires on calIObservers + // (because that order is undefined). Firing onOperationComplete before onAddItem et al + // would result in this facade firing onOperationComplete even though the modification + // hasn't yet been performed on the cached calendar (which happens in onAddItem et al). + // Result is that we currently stick to firing onOperationComplete if the cached calendar + // has performed the modification, see below: + let cacheListener = { + onGetResult: function() {}, + onOperationComplete: function(calendar, status, opType, id, detail) { + if (isUnavailableCode(status)) { + // The item couldn't be deleted at the (remote) location, + // this is like being offline. Mark the item deleted in the + // cache instead. + cal.LOG("[calCachedCalendar] Calendar " + calendar.name + " is unavailable, deleting item offline"); + self.deleteOfflineItem(item, listener); + } else if (Components.isSuccessCode(status)) { + // On success, delete the item from the cache + self.mCachedCalendar.deleteItem(item, listener); + + // Also, remove any meta data associated with the item + try { + let storage = self.mCachedCalendar.QueryInterface(Components.interfaces.calISyncWriteCalendar); + storage.deleteMetaData(item.id); + } catch (e) { + cal.LOG("[calCachedCalendar] Offline storage doesn't support metadata"); + } + } else if (listener) { + // This happens on error, forward the error through the listener + listener.onOperationComplete(self, status, opType, id, detail); + } + } + }; + + if (this.offline) { + // If we are offline, don't even try to delete the item + this.deleteOfflineItem(item, listener); + } else { + // Otherwise, get the item flags, the listener will further + // process the item. + this.mCachedCalendar.getItemOfflineFlag(item, flagListener); + } + }, + deleteOfflineItem: function(item, listener) { + /* We do not delete the item from the cache, as we will need it when reconciling the cache content and the server content. */ + let storage = this.mCachedCalendar.QueryInterface(Components.interfaces.calIOfflineStorage); + storage.deleteOfflineItem(item, listener); + } +}; +(function() { + function defineForwards(proto, targetName, functions, getters, gettersAndSetters) { + function defineForwardGetter(attr) { + proto.__defineGetter__(attr, function() { return this[targetName][attr]; }); + } + function defineForwardGetterAndSetter(attr) { + defineForwardGetter(attr); + proto.__defineSetter__(attr, function(value) { return (this[targetName][attr] = value); }); + } + function defineForwardFunction(funcName) { + proto[funcName] = function(...args) { + let obj = this[targetName]; + return obj[funcName](...args); + }; + } + functions.forEach(defineForwardFunction); + getters.forEach(defineForwardGetter); + gettersAndSetters.forEach(defineForwardGetterAndSetter); + } + + defineForwards(calCachedCalendar.prototype, "mUncachedCalendar", + ["setProperty", "deleteProperty", + "isInvitation", "getInvitedAttendee", "canNotify"], + ["type", "aclManager", "aclEntry"], + ["id", "name", "uri", "readOnly"]); + defineForwards(calCachedCalendar.prototype, "mCachedCalendar", + ["getItem", "getItems", "startBatch", "endBatch"], [], []); +})(); diff --git a/calendar/base/src/calCalendarManager.js b/calendar/base/src/calCalendarManager.js new file mode 100644 index 000000000..6800da45a --- /dev/null +++ b/calendar/base/src/calCalendarManager.js @@ -0,0 +1,1123 @@ +/* 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/. */ + +Components.utils.import("resource://gre/modules/AddonManager.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); +Components.utils.import("resource://gre/modules/Preferences.jsm"); +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://calendar/modules/calProviderUtils.jsm"); + +var REGISTRY_BRANCH = "calendar.registry."; +var DB_SCHEMA_VERSION = 10; +var MAX_INT = Math.pow(2, 31) - 1; +var MIN_INT = -MAX_INT; + +function calCalendarManager() { + this.wrappedJSObject = this; + this.mObservers = new calListenerBag(Components.interfaces.calICalendarManagerObserver); + this.mCalendarObservers = new calListenerBag(Components.interfaces.calIObserver); +} + +var calCalendarManagerClassID = Components.ID("{f42585e7-e736-4600-985d-9624c1c51992}"); +var calCalendarManagerInterfaces = [ + Components.interfaces.calICalendarManager, + Components.interfaces.calIStartupService, + Components.interfaces.nsIObserver, +]; +calCalendarManager.prototype = { + classID: calCalendarManagerClassID, + QueryInterface: XPCOMUtils.generateQI(calCalendarManagerInterfaces), + classInfo: XPCOMUtils.generateCI({ + classID: calCalendarManagerClassID, + contractID: "@mozilla.org/calendar/manager;1", + classDescription: "Calendar Manager", + interfaces: calCalendarManagerInterfaces, + flags: Components.interfaces.nsIClassInfo.SINGLETON + }), + + get networkCalendarCount() { return this.mNetworkCalendarCount; }, + get readOnlyCalendarCount() { return this.mReadonlyCalendarCount; }, + get calendarCount() { return this.mCalendarCount; }, + + // calIStartupService: + startup: function(aCompleteListener) { + AddonManager.addAddonListener(gCalendarManagerAddonListener); + this.checkAndMigrateDB(); + this.mCache = null; + this.mCalObservers = null; + this.mRefreshTimer = {}; + this.setupOfflineObservers(); + this.mNetworkCalendarCount = 0; + this.mReadonlyCalendarCount = 0; + this.mCalendarCount = 0; + + Services.obs.addObserver(this, "http-on-modify-request", false); + + // We only add the observer if the pref is set and only check for the + // pref on startup to avoid checking for every http request + if (Preferences.get("calendar.network.multirealm", false)) { + Services.obs.addObserver(this, "http-on-examine-response", false); + } + + aCompleteListener.onResult(null, Components.results.NS_OK); + }, + + shutdown: function(aCompleteListener) { + for (let id in this.mCache) { + let calendar = this.mCache[id]; + calendar.removeObserver(this.mCalObservers[calendar.id]); + } + + this.cleanupOfflineObservers(); + + Services.obs.removeObserver(this, "http-on-modify-request"); + + AddonManager.removeAddonListener(gCalendarManagerAddonListener); + + // Remove the observer if the pref is set. This might fail when the + // user flips the pref, but we assume he is going to restart anyway + // afterwards. + if (Preferences.get("calendar.network.multirealm", false)) { + Services.obs.removeObserver(this, "http-on-examine-response"); + } + + aCompleteListener.onResult(null, Components.results.NS_OK); + }, + + + setupOfflineObservers: function() { + Services.obs.addObserver(this, "network:offline-status-changed", false); + }, + + cleanupOfflineObservers: function() { + Services.obs.removeObserver(this, "network:offline-status-changed"); + }, + + observe: function(aSubject, aTopic, aData) { + switch (aTopic) { + case "timer-callback": { + // Refresh all the calendars that can be refreshed. + for (let calendar of this.getCalendars({})) { + if (!calendar.getProperty("disabled") && calendar.canRefresh) { + calendar.refresh(); + } + } + break; + } + case "network:offline-status-changed": { + for (let id in this.mCache) { + let calendar = this.mCache[id]; + if (calendar instanceof calCachedCalendar) { + calendar.onOfflineStatusChanged(aData == "offline"); + } + } + break; + } + case "http-on-examine-response": { + try { + let channel = aSubject.QueryInterface(Components.interfaces.nsIHttpChannel); + if (channel.notificationCallbacks) { + // We use the notification callbacks to get the calendar interface, + // which likely works for our requests since getInterface is called + // from the calendar provider context. + let authHeader = channel.getResponseHeader("WWW-Authenticate"); + let calendar = channel.notificationCallbacks + .getInterface(Components.interfaces.calICalendar); + if (calendar && !calendar.getProperty("capabilities.realmrewrite.disabled")) { + // The provider may choose to explicitly disable the + // rewriting, for example if all calendars on a + // domain have the same credentials + let escapedName = calendar.name.replace(/\\/g, "\\\\") + .replace(/"/g, '\\"'); + authHeader = appendToRealm(authHeader, "(" + escapedName + ")"); + channel.setResponseHeader("WWW-Authenticate", authHeader, false); + } + } + } catch (e) { + if (e.result != Components.results.NS_NOINTERFACE && + e.result != Components.results.NS_ERROR_NOT_AVAILABLE) { + throw e; + } + // Possible reasons we got here: + // - Its not a http channel (wtf? Oh well) + // - The owner is not a calICalendar (looks like its not our deal) + // - The WWW-Authenticate header is missing (thats ok) + } + break; + } + case "http-on-modify-request": { + // Unfortunately, the ability to do this with a general pref has + // been removed. Calendar servers might still want to know what + // client is used for access, so add our UA String to each + // request. + let httpChannel = aSubject.QueryInterface(Components.interfaces.nsIHttpChannel); + try { + // NOTE: For some reason, this observer call doesn't have + // the "cal" namespace defined + let userAgent = httpChannel.getRequestHeader("User-Agent"); + let calUAString = Preferences.get("calendar.useragent.extra", "").trim(); + + // Don't add an empty string or an already included token. + if (calUAString && !userAgent.includes(calUAString)) { + // User-Agent is not a mergeable header. We need to + // merge the user agent ourselves. + httpChannel.setRequestHeader("User-Agent", + userAgent + " " + calUAString, + false); + } + } catch (e) { + if (e.result != Components.results.NS_ERROR_NOT_AVAILABLE) { + throw e; + } + // We swallow this error since it means the User Agent + // header is not set. We don't want to force it to be set. + } + break; + } + } + }, + + // + // DB migration code begins here + // + + upgradeDB: function(oldVersion, db) { + if (oldVersion < 6) { + dump("**** Upgrading calCalendarManager schema to 6\n"); + + // Schema changes in v6: + // + // - Change all STRING columns to TEXT to avoid SQLite's + // "feature" where it will automatically convert strings to + // numbers (ex: 10e4 -> 10000). See bug 333688. + + // Create the new tables. + + try { + db.executeSimpleSQL("DROP TABLE cal_calendars_v6; DROP TABLE cal_calendars_prefs_v6;"); + } catch (e) { + // We should get exceptions for trying to drop tables + // that don't (shouldn't) exist. + } + + db.executeSimpleSQL("CREATE TABLE cal_calendars_v6 " + + "(id INTEGER PRIMARY KEY," + + " type TEXT," + + " uri TEXT);"); + + db.executeSimpleSQL("CREATE TABLE cal_calendars_prefs_v6 " + + "(id INTEGER PRIMARY KEY," + + " calendar INTEGER," + + " name TEXT," + + " value TEXT);"); + + // Copy in the data. + let calendarCols = ["id", "type", "uri"]; + let calendarPrefsCols = ["id", "calendar", "name", "value"]; + + db.executeSimpleSQL("INSERT INTO cal_calendars_v6(" + calendarCols.join(",") + ") " + + " SELECT " + calendarCols.join(",") + + " FROM cal_calendars"); + + db.executeSimpleSQL("INSERT INTO cal_calendars_prefs_v6(" + calendarPrefsCols.join(",") + ") " + + " SELECT " + calendarPrefsCols.join(",") + + " FROM cal_calendars_prefs"); + + // Delete each old table and rename the new ones to use the + // old tables' names. + let tableNames = ["cal_calendars", "cal_calendars_prefs"]; + + for (let i in tableNames) { + db.executeSimpleSQL("DROP TABLE " + tableNames[i] + ";" + + "ALTER TABLE " + tableNames[i] + "_v6 " + + " RENAME TO " + tableNames[i] + ";"); + } + + oldVersion = 8; + } + + if (oldVersion < DB_SCHEMA_VERSION) { + dump("**** Upgrading calCalendarManager schema to 9/10\n"); + + if (db.tableExists("cal_calmgr_schema_version")) { + // Set only once the last time to v10, so the version check works in calendar 0.8. + // In calendar 0.9 and following, the provider itself will check its version + // on initialization and notify the calendar whether it's usable or not. + db.executeSimpleSQL("UPDATE cal_calmgr_schema_version SET version = " + DB_SCHEMA_VERSION + ";"); + } else { + // Schema changes in v9: + // + // - Decouple schema version from storage calendar + // Create the new tables. + db.executeSimpleSQL("CREATE TABLE cal_calmgr_schema_version (version INTEGER);"); + db.executeSimpleSQL("INSERT INTO cal_calmgr_schema_version VALUES(" + DB_SCHEMA_VERSION + ")"); + } + } + }, + + migrateDB: function(db) { + let selectCalendars = db.createStatement("SELECT * FROM cal_calendars"); + let selectPrefs = db.createStatement("SELECT name, value FROM cal_calendars_prefs WHERE calendar = :calendar"); + try { + let sortOrder = {}; + + while (selectCalendars.executeStep()) { + let id = cal.getUUID(); // use fresh uuids + Preferences.set(getPrefBranchFor(id) + "type", selectCalendars.row.type); + Preferences.set(getPrefBranchFor(id) + "uri", selectCalendars.row.uri); + // the former id served as sort position: + sortOrder[selectCalendars.row.id] = id; + // move over prefs: + selectPrefs.params.calendar = selectCalendars.row.id; + while (selectPrefs.executeStep()) { + let name = selectPrefs.row.name.toLowerCase(); // may come lower case, so force it to be + let value = selectPrefs.row.value; + switch (name) { + case "readonly": + Preferences.set(getPrefBranchFor(id) + "readOnly", value == "true"); + break; + case "relaxedmode": + Preferences.set(getPrefBranchFor(id) + "relaxedMode", value == "true"); + break; + case "suppressalarms": + Preferences.set(getPrefBranchFor(id) + "suppressAlarms", value == "true"); + break; + case "disabled": + case "cache.supported": + case "auto-enabled": + case "cache.enabled": + case "lightning-main-in-composite": + case "calendar-main-in-composite": + case "lightning-main-default": + case "calendar-main-default": + Preferences.set(getPrefBranchFor(id) + name, value == "true"); + break; + case "backup-time": + case "uniquenum": + // These preference names were migrated due to bug 979262. + Preferences.set(getPrefBranchFor(id) + name + "2", "bignum:" + value); + break; + default: // keep as string + Preferences.set(getPrefBranchFor(id) + name, value); + break; + } + } + selectPrefs.reset(); + } + + let sortOrderAr = []; + for (let id in sortOrder) { + sortOrderAr.push(sortOrder[id]); + } + Preferences.set("calendar.list.sortOrder", sortOrderAr.join(" ")); + flushPrefs(); + } finally { + selectPrefs.reset(); + selectCalendars.reset(); + } + }, + + checkAndMigrateDB: function() { + let storageSdb = Services.dirsvc.get("ProfD", Components.interfaces.nsILocalFile); + storageSdb.append("storage.sdb"); + let db = Services.storage.openDatabase(storageSdb); + + db.beginTransactionAs(Components.interfaces.mozIStorageConnection.TRANSACTION_EXCLUSIVE); + try { + if (db.tableExists("cal_calendars_prefs")) { + // Check if we need to upgrade: + let version = this.getSchemaVersion(db); + if (version < DB_SCHEMA_VERSION) { + this.upgradeDB(version, db); + } + + this.migrateDB(db); + + db.executeSimpleSQL("DROP TABLE cal_calendars; " + + "DROP TABLE cal_calendars_prefs; " + + "DROP TABLE cal_calmgr_schema_version;"); + } + + if (db.tableExists("cal_calendars")) { + db.rollbackTransaction(); + } else { + // create dummy cal_calendars, so previous versions (pre 1.0pre) run into the schema check: + db.createTable("cal_calendars", "id INTEGER"); + // let schema checks always fail, we cannot take the shared cal_calendar_schema_version: + db.createTable("cal_calmgr_schema_version", "version INTEGER"); + db.executeSimpleSQL("INSERT INTO cal_calmgr_schema_version VALUES(" + (DB_SCHEMA_VERSION + 1) + ")"); + db.commitTransaction(); + } + } catch (exc) { + db.rollbackTransaction(); + throw exc; + } finally { + db.close(); + } + }, + + /** + * @return db schema version + * @exception various, depending on error + */ + getSchemaVersion: function(db) { + let stmt; + let version = null; + + let table; + if (db.tableExists("cal_calmgr_schema_version")) { + table = "cal_calmgr_schema_version"; + } else { + // Fall back to the old schema table + table = "cal_calendar_schema_version"; + } + + try { + stmt = db.createStatement("SELECT version FROM " + table + " LIMIT 1"); + if (stmt.executeStep()) { + version = stmt.row.version; + } + stmt.reset(); + + if (version !== null) { + // This is the only place to leave this function gracefully. + return version; + } + } catch (e) { + if (stmt) { + stmt.reset(); + } + cal.ERROR("++++++++++++ calMgrGetSchemaVersion() error: " + db.lastErrorString); + Components.utils.reportError("Error getting calendar schema version! DB Error: " + db.lastErrorString); + throw e; + } + + throw table + " SELECT returned no results"; + }, + + // + // / DB migration code ends here + // + + alertAndQuit: function() { + // We want to include the extension name in the error message rather + // than blaming Thunderbird. + let hostAppName = calGetString("brand", "brandShortName", null, "branding"); + let calAppName = calGetString("lightning", "brandShortName", null, "lightning"); + let errorBoxTitle = calGetString("calendar", "tooNewSchemaErrorBoxTitle", [calAppName]); + let errorBoxText = calGetString("calendar", "tooNewSchemaErrorBoxTextLightning", [calAppName, hostAppName]); + let errorBoxButtonLabel = calGetString("calendar", "tooNewSchemaButtonRestart", [hostAppName]); + + let promptSvc = Services.prompt; + + let errorBoxButtonFlags = promptSvc.BUTTON_POS_0 * + promptSvc.BUTTON_TITLE_IS_STRING + + promptSvc.BUTTON_POS_0_DEFAULT; + + promptSvc.confirmEx(null, + errorBoxTitle, + errorBoxText, + errorBoxButtonFlags, + errorBoxButtonLabel, + null, // No second button text + null, // No third button text + null, // No checkbox + { value: false }); // Unnecessary checkbox state + + // Disable Lightning + AddonManager.getAddonByID("{e2fda1a4-762b-4020-b5ad-a41df1933103}", (aAddon) => { + aAddon.userDisabled = true; + Services.startup.quit(Components.interfaces.nsIAppStartup.eRestart | + Components.interfaces.nsIAppStartup.eForceQuit); + }); + }, + + /** + * calICalendarManager interface + */ + createCalendar: function(type, uri) { + try { + if (!Components.classes["@mozilla.org/calendar/calendar;1?type=" + type]) { + // Don't notify the user with an extra dialog if the provider + // interface is missing. + return null; + } + let calendar = Components.classes["@mozilla.org/calendar/calendar;1?type=" + type] + .createInstance(Components.interfaces.calICalendar); + calendar.uri = uri; + return calendar; + } catch (ex) { + let rc = ex; + let uiMessage = ex; + if (ex instanceof Components.interfaces.nsIException) { + rc = ex.result; + uiMessage = ex.message; + } + switch (rc) { + case Components.interfaces.calIErrors.STORAGE_UNKNOWN_SCHEMA_ERROR: + // For now we alert and quit on schema errors like we've done before: + this.alertAndQuit(); + return null; + case Components.interfaces.calIErrors.STORAGE_UNKNOWN_TIMEZONES_ERROR: + uiMessage = calGetString("calendar", "unknownTimezonesError", [uri.spec]); + break; + default: + uiMessage = calGetString("calendar", "unableToCreateProvider", [uri.spec]); + break; + } + // Log the original exception via error console to provide more debug info + cal.ERROR(ex); + + // Log the possibly translated message via the UI. + let paramBlock = Components.classes["@mozilla.org/embedcomp/dialogparam;1"] + .createInstance(Components.interfaces.nsIDialogParamBlock); + paramBlock.SetNumberStrings(3); + paramBlock.SetString(0, uiMessage); + paramBlock.SetString(1, "0x" + rc.toString(0x10)); + paramBlock.SetString(2, ex); + Services.ww.openWindow(null, + "chrome://calendar/content/calendar-error-prompt.xul", + "_blank", + "chrome,dialog=yes,alwaysRaised=yes", + paramBlock); + return null; + } + }, + + registerCalendar: function(calendar) { + this.assureCache(); + + // If the calendar is already registered, bail out + cal.ASSERT(!calendar.id || !(calendar.id in this.mCache), + "[calCalendarManager::registerCalendar] calendar already registered!", + true); + + if (!calendar.id) { + calendar.id = cal.getUUID(); + } + + Preferences.set(getPrefBranchFor(calendar.id) + "type", calendar.type); + Preferences.set(getPrefBranchFor(calendar.id) + "uri", calendar.uri.spec); + + if ((calendar.getProperty("cache.supported") !== false) && + (calendar.getProperty("cache.enabled") || + calendar.getProperty("cache.always"))) { + calendar = new calCachedCalendar(calendar); + } + + this.setupCalendar(calendar); + flushPrefs(); + + if (!calendar.getProperty("disabled") && calendar.canRefresh) { + calendar.refresh(); + } + + this.notifyObservers("onCalendarRegistered", [calendar]); + }, + + setupCalendar: function(calendar) { + this.mCache[calendar.id] = calendar; + + // Add an observer to track readonly-mode triggers + let newObserver = new calMgrCalendarObserver(calendar, this); + calendar.addObserver(newObserver); + this.mCalObservers[calendar.id] = newObserver; + + // Set up statistics + if (calendar.getProperty("requiresNetwork") !== false) { + this.mNetworkCalendarCount++; + } + if (calendar.readOnly) { + this.mReadonlyCalendarCount++; + } + this.mCalendarCount++; + + // Set up the refresh timer + this.setupRefreshTimer(calendar); + }, + + setupRefreshTimer: function(aCalendar) { + // Add the refresh timer for this calendar + let refreshInterval = aCalendar.getProperty("refreshInterval"); + if (refreshInterval === null) { + // Default to 30 minutes, in case the value is missing + refreshInterval = 30; + } + + this.clearRefreshTimer(aCalendar); + + if (refreshInterval > 0) { + this.mRefreshTimer[aCalendar.id] = + Components.classes["@mozilla.org/timer;1"] + .createInstance(Components.interfaces.nsITimer); + + this.mRefreshTimer[aCalendar.id] + .initWithCallback(new timerCallback(aCalendar), + refreshInterval * 60000, + Components.interfaces.nsITimer.TYPE_REPEATING_SLACK); + } + }, + + clearRefreshTimer: function(aCalendar) { + if (aCalendar.id in this.mRefreshTimer && + this.mRefreshTimer[aCalendar.id]) { + this.mRefreshTimer[aCalendar.id].cancel(); + delete this.mRefreshTimer[aCalendar.id]; + } + }, + + unregisterCalendar: function(calendar) { + this.notifyObservers("onCalendarUnregistering", [calendar]); + + // calendar may be a calICalendar wrapper: + if (calendar.wrappedJSObject instanceof calCachedCalendar) { + calendar.wrappedJSObject.onCalendarUnregistering(); + } + + calendar.removeObserver(this.mCalObservers[calendar.id]); + Services.prefs.deleteBranch(getPrefBranchFor(calendar.id)); + flushPrefs(); + + if (this.mCache) { + delete this.mCache[calendar.id]; + } + + if (calendar.readOnly) { + this.mReadonlyCalendarCount--; + } + + if (calendar.getProperty("requiresNetwork") !== false) { + this.mNetworkCalendarCount--; + } + this.mCalendarCount--; + + this.clearRefreshTimer(calendar); + }, + + // Delete this method for Lightning 4.7 at latest + deleteCalendar: function(calendar) { + if (!this.deleteCalendar.warningIssued) { + cal.WARN("Use of calICalendarManager::deleteCalendar is deprecated" + + " and will be removed with the next release. Use" + + " ::removeCalendar instead.\n" + cal.STACK(10)); + this.deleteCalendar.warningIssued = true; + } + + const cICM = Components.interfaces.calICalendarManager; + this.removeCalendar(calendar, cICM.REMOVE_NO_UNREGISTER); + }, + + removeCalendar: function(calendar, mode=0) { + const cICM = Components.interfaces.calICalendarManager; + + let removeModes = new Set(calendar.getProperty("capabilities.removeModes") || ["unsubscribe"]); + if (!removeModes.has("unsubscribe") && !removeModes.has("delete")) { + // Removing is not allowed + return; + } + + if ((mode & cICM.REMOVE_NO_UNREGISTER) && this.mCache && + (calendar.id in this.mCache)) { + throw new Components.Exception("Can't remove a registered calendar"); + } else if (!(mode & cICM.REMOVE_NO_UNREGISTER)) { + this.unregisterCalendar(calendar); + } + + // This observer notification needs to be fired for both unsubscribe + // and delete, we don't differ this at the moment. + this.notifyObservers("onCalendarDeleting", [calendar]); + + // For deleting, we also call the deleteCalendar method from the provider. + if (removeModes.has("delete") && (mode & cICM.REMOVE_NO_DELETE) == 0) { + let wrappedCalendar = cal.wrapInstance(calendar, Components.interfaces.calICalendarProvider); + if (!wrappedCalendar) { + throw new Components.Exception("Calendar is missing a provider implementation for delete"); + } + + wrappedCalendar.deleteCalendar(calendar, null); + } + }, + + getCalendarById: function(aId) { + if (aId in this.mCache) { + return this.mCache[aId]; + } else { + return null; + } + }, + + getCalendars: function(count) { + this.assureCache(); + let calendars = []; + for (let id in this.mCache) { + let calendar = this.mCache[id]; + calendars.push(calendar); + } + count.value = calendars.length; + return calendars; + }, + + assureCache: function() { + if (!this.mCache) { + this.mCache = {}; + this.mCalObservers = {}; + + let allCals = {}; + for (let key of Services.prefs.getChildList(REGISTRY_BRANCH)) { // merge down all keys + allCals[key.substring(0, key.indexOf(".", REGISTRY_BRANCH.length))] = true; + } + + for (let calBranch in allCals) { + let id = calBranch.substring(REGISTRY_BRANCH.length); + let ctype = Preferences.get(calBranch + ".type", null); + let curi = Preferences.get(calBranch + ".uri", null); + + try { + if (!ctype || !curi) { // sanity check + Services.prefs.deleteBranch(calBranch + "."); + continue; + } + + let uri = cal.makeURL(curi); + let calendar = this.createCalendar(ctype, uri); + if (calendar) { + calendar.id = id; + if (calendar.getProperty("auto-enabled")) { + calendar.deleteProperty("disabled"); + calendar.deleteProperty("auto-enabled"); + } + + if ((calendar.getProperty("cache.supported") !== false) && + (calendar.getProperty("cache.enabled") || + calendar.getProperty("cache.always"))) { + calendar = new calCachedCalendar(calendar); + } + } else { // create dummy calendar that stays disabled for this run: + calendar = new calDummyCalendar(ctype); + calendar.id = id; + calendar.uri = uri; + // try to enable on next startup if calendar has been enabled: + if (!calendar.getProperty("disabled")) { + calendar.setProperty("auto-enabled", true); + } + calendar.setProperty("disabled", true); + } + + this.setupCalendar(calendar); + } catch (exc) { + cal.ERROR("Can't create calendar for " + id + " (" + ctype + ", " + curi + "): " + exc); + } + } + + // do refreshing in a second step, when *all* calendars are already available + // via getCalendars(): + for (let id in this.mCache) { + let calendar = this.mCache[id]; + if (!calendar.getProperty("disabled") && calendar.canRefresh) { + calendar.refresh(); + } + } + } + }, + + getCalendarPref_: function(calendar, name) { + cal.ASSERT(calendar, "Invalid Calendar!"); + cal.ASSERT(calendar.id !== null, "Calendar id needs to be set!"); + cal.ASSERT(name && name.length > 0, "Pref Name must be non-empty!"); + + let branch = getPrefBranchFor(calendar.id) + name; + let value = Preferences.get(branch, null); + + if (typeof value == "string" && value.startsWith("bignum:")) { + let converted = Number(value.substr(7)); + if (!isNaN(converted)) { + value = converted; + } + } + return value; + }, + + setCalendarPref_: function(calendar, name, value) { + cal.ASSERT(calendar, "Invalid Calendar!"); + cal.ASSERT(calendar.id !== null, "Calendar id needs to be set!"); + cal.ASSERT(name && name.length > 0, "Pref Name must be non-empty!"); + + let branch = getPrefBranchFor(calendar.id) + name; + + if (typeof value == "number" && (value > MAX_INT || value < MIN_INT || !Number.isInteger(value))) { + // This is something the preferences service can't store directly. + // Convert to string and tag it so we know how to handle it. + value = "bignum:" + value; + } + + // Delete before to allow pref-type changes, then set the pref. + Services.prefs.deleteBranch(branch); + if (value !== null && value !== undefined) { + Preferences.set(branch, value); + } + }, + + deleteCalendarPref_: function(calendar, name) { + cal.ASSERT(calendar, "Invalid Calendar!"); + cal.ASSERT(calendar.id !== null, "Calendar id needs to be set!"); + cal.ASSERT(name && name.length > 0, "Pref Name must be non-empty!"); + Services.prefs.deleteBranch(getPrefBranchFor(calendar.id) + name); + }, + + mObservers: null, + addObserver: function(aObserver) { return this.mObservers.add(aObserver); }, + removeObserver: function(aObserver) { return this.mObservers.remove(aObserver); }, + notifyObservers: function(functionName, args) { return this.mObservers.notify(functionName, args); }, + + mCalendarObservers: null, + addCalendarObserver: function(aObserver) { return this.mCalendarObservers.add(aObserver); }, + removeCalendarObserver: function(aObserver) { return this.mCalendarObservers.remove(aObserver); }, + notifyCalendarObservers: function(functionName, args) { return this.mCalendarObservers.notify(functionName, args); } +}; + +function equalMessage(msg1, msg2) { + if (msg1.GetString(0) == msg2.GetString(0) && + msg1.GetString(1) == msg2.GetString(1) && + msg1.GetString(2) == msg2.GetString(2)) { + return true; + } + return false; +} + +function calMgrCalendarObserver(calendar, calMgr) { + this.calendar = calendar; + // We compare this to determine if the state actually changed. + this.storedReadOnly = calendar.readOnly; + this.announcedMessages = []; + this.calMgr = calMgr; +} + +calMgrCalendarObserver.prototype = { + calendar: null, + storedReadOnly: null, + calMgr: null, + + QueryInterface: XPCOMUtils.generateQI([ + Components.interfaces.nsIWindowMediatorListener, + Components.interfaces.calIObserver + ]), + + // calIObserver: + onStartBatch: function() { return this.calMgr.notifyCalendarObservers("onStartBatch", arguments); }, + onEndBatch: function() { return this.calMgr.notifyCalendarObservers("onEndBatch", arguments); }, + onLoad: function(calendar) { return this.calMgr.notifyCalendarObservers("onLoad", arguments); }, + onAddItem: function(aItem) { return this.calMgr.notifyCalendarObservers("onAddItem", arguments); }, + onModifyItem: function(aNewItem, aOldItem) { return this.calMgr.notifyCalendarObservers("onModifyItem", arguments); }, + onDeleteItem: function(aDeletedItem) { return this.calMgr.notifyCalendarObservers("onDeleteItem", arguments); }, + onError: function(aCalendar, aErrNo, aMessage) { + this.calMgr.notifyCalendarObservers("onError", arguments); + this.announceError(aCalendar, aErrNo, aMessage); + }, + + onPropertyChanged: function(aCalendar, aName, aValue, aOldValue) { + this.calMgr.notifyCalendarObservers("onPropertyChanged", arguments); + switch (aName) { + case "requiresNetwork": + this.calMgr.mNetworkCalendarCount += (aValue ? 1 : -1); + break; + case "readOnly": + this.calMgr.mReadonlyCalendarCount += (aValue ? 1 : -1); + break; + case "refreshInterval": + this.calMgr.setupRefreshTimer(aCalendar); + break; + case "cache.enabled": + this.changeCalendarCache(...arguments); + break; + case "disabled": + if (!aValue && aCalendar.canRefresh) { + aCalendar.refresh(); + } + break; + } + }, + + changeCalendarCache: function(aCalendar, aName, aValue, aOldValue) { + const cICM = Components.interfaces.calICalendarManager; + aOldValue = aOldValue || false; + aValue = aValue || false; + + // hack for bug 1182264 to deal with calendars, which have set cache.enabled, but in fact do + // not support caching (like storage calendars) - this also prevents enabling cache again + if (aCalendar.getProperty("cache.supported") === false) { + if (aCalendar.getProperty("cache.enabled") === true) { + aCalendar.deleteProperty("cache.enabled"); + } + return; + } + + if (aOldValue != aValue) { + // Try to find the current sort order + let sortOrderPref = Preferences.get("calendar.list.sortOrder", "").split(" "); + let initialSortOrderPos = null; + for (let i = 0; i < sortOrderPref.length; ++i) { + if (sortOrderPref[i] == aCalendar.id) { + initialSortOrderPos = i; + } + } + // Enabling or disabling cache on a calendar re-creates + // it so the registerCalendar call can wrap/unwrap the + // calCachedCalendar facade saving the user the need to + // restart Thunderbird and making sure a new Id is used. + this.calMgr.removeCalendar(aCalendar, cICM.REMOVE_NO_DELETE); + let newCal = this.calMgr.createCalendar(aCalendar.type, aCalendar.uri); + newCal.name = aCalendar.name; + + // TODO: if properties get added this list will need to be adjusted, + // ideally we should add a "getProperties" method to calICalendar.idl + // to retrieve all non-transient properties for a calendar. + let propsToCopy = [ + "color", + "disabled", + "auto-enabled", + "cache.enabled", + "refreshInterval", + "suppressAlarms", + "calendar-main-in-composite", + "calendar-main-default", + "readOnly", + "imip.identity.key" + ]; + for (let prop of propsToCopy) { + newCal.setProperty(prop, aCalendar.getProperty(prop)); + } + + if (initialSortOrderPos != null) { + newCal.setProperty("initialSortOrderPos", + initialSortOrderPos); + } + this.calMgr.registerCalendar(newCal); + } else if (aCalendar.wrappedJSObject instanceof calCachedCalendar) { + // any attempt to switch this flag will reset the cached calendar; + // could be useful for users in case the cache may be corrupted. + aCalendar.wrappedJSObject.setupCachedCalendar(); + } + }, + + onPropertyDeleting: function(aCalendar, aName) { + this.onPropertyChanged(aCalendar, aName, false, true); + }, + + // Error announcer specific functions + announceError: function(aCalendar, aErrNo, aMessage) { + let paramBlock = Components.classes["@mozilla.org/embedcomp/dialogparam;1"] + .createInstance(Components.interfaces.nsIDialogParamBlock); + let props = Services.strings.createBundle("chrome://calendar/locale/calendar.properties"); + let errMsg; + paramBlock.SetNumberStrings(3); + if (!this.storedReadOnly && this.calendar.readOnly) { + // Major errors change the calendar to readOnly + errMsg = props.formatStringFromName("readOnlyMode", [this.calendar.name], 1); + } else if (!this.storedReadOnly && !this.calendar.readOnly) { + // Minor errors don't, but still tell the user something went wrong + errMsg = props.formatStringFromName("minorError", [this.calendar.name], 1); + } else { + // The calendar was already in readOnly mode, but still tell the user + errMsg = props.formatStringFromName("stillReadOnlyError", [this.calendar.name], 1); + } + + // When possible, change the error number into its name, to + // make it slightly more readable. + let errCode = "0x" + aErrNo.toString(16); + const calIErrors = Components.interfaces.calIErrors; + // Check if it is worth enumerating all the error codes. + if (aErrNo & calIErrors.ERROR_BASE) { + for (let err in calIErrors) { + if (calIErrors[err] == aErrNo) { + errCode = err; + } + } + } + + let message; + switch (aErrNo) { + case calIErrors.CAL_UTF8_DECODING_FAILED: + message = props.GetStringFromName("utf8DecodeError"); + break; + case calIErrors.ICS_MALFORMEDDATA: + message = props.GetStringFromName("icsMalformedError"); + break; + case calIErrors.MODIFICATION_FAILED: + errMsg = calGetString("calendar", "errorWriting", [aCalendar.name]); + // falls through + default: + message = aMessage; + } + + + paramBlock.SetString(0, errMsg); + paramBlock.SetString(1, errCode); + paramBlock.SetString(2, message); + + this.storedReadOnly = this.calendar.readOnly; + let errorCode = calGetString("calendar", "errorCode", [errCode]); + let errorDescription = calGetString("calendar", "errorDescription", [message]); + let summary = errMsg + " " + errorCode + ". " + errorDescription; + + // Log warnings in error console. + // Report serious errors in both error console and in prompt window. + if (aErrNo == calIErrors.MODIFICATION_FAILED) { + Components.utils.reportError(summary); + this.announceParamBlock(paramBlock); + } else { + cal.WARN(summary); + } + }, + + announceParamBlock: function(paramBlock) { + function awaitLoad(event) { + promptWindow.removeEventListener("load", awaitLoad, false); + promptWindow.addEventListener("unload", awaitUnload, false); + } + let awaitUnload = (event) => { + promptWindow.removeEventListener("unload", awaitUnload, false); + // unloaded (user closed prompt window), + // remove paramBlock and unload listener. + try { + // remove the message that has been shown from + // the list of all announced messages. + this.announcedMessages = this.announcedMessages.filter((msg) => { + return !equalMessage(msg, paramBlock); + }); + } catch (e) { + Components.utils.reportError(e); + } + }; + + // silently don't do anything if this message already has been + // announced without being acknowledged. + if (this.announcedMessages.some(equalMessage.bind(null, paramBlock))) { + return; + } + + // this message hasn't been announced recently, remember the details of + // the message for future reference. + this.announcedMessages.push(paramBlock); + + // Will remove paramBlock from announced messages when promptWindow is + // closed. (Closing fires unloaded event, but promptWindow is also + // unloaded [to clean it?] before loading, so wait for detected load + // event before detecting unload event that signifies user closed this + // prompt window.) + let promptUrl = "chrome://calendar/content/calendar-error-prompt.xul"; + let features = "chrome,dialog=yes,alwaysRaised=yes"; + let promptWindow = Services.ww.openWindow(null, promptUrl, "_blank", features, paramBlock); + promptWindow.addEventListener("load", awaitLoad, false); + } +}; + +function calDummyCalendar(type) { + this.initProviderBase(); + this.type = type; +} +calDummyCalendar.prototype = { + __proto__: cal.ProviderBase.prototype, + + getProperty: function(aName) { + switch (aName) { + case "force-disabled": + return true; + default: + return this.__proto__.__proto__.getProperty.apply(this, arguments); + } + } +}; + +function getPrefBranchFor(id) { + return REGISTRY_BRANCH + id + "."; +} + +/** + * Helper function to flush the preferences file. If the application crashes + * after a calendar has been created using the prefs registry, then the calendar + * won't show up. Writing the prefs helps counteract. + */ +function flushPrefs() { + Services.prefs.savePrefFile(null); +} + +/** + * Callback object for the refresh timer. Should be called as an object, i.e + * let foo = new timerCallback(calendar); + * + * @param aCalendar The calendar to refresh on notification + */ +function timerCallback(aCalendar) { + this.notify = function(aTimer) { + if (!aCalendar.getProperty("disabled") && aCalendar.canRefresh) { + aCalendar.refresh(); + } + }; +} + +var gCalendarManagerAddonListener = { + onDisabling: function(aAddon, aNeedsRestart) { + if (!this.queryUninstallProvider(aAddon)) { + // If the addon should not be disabled, then re-enable it. + aAddon.userDisabled = false; + } + }, + + onUninstalling: function(aAddon, aNeedsRestart) { + if (!this.queryUninstallProvider(aAddon)) { + // If the addon should not be uninstalled, then cancel the uninstall. + aAddon.cancelUninstall(); + } + }, + + queryUninstallProvider: function(aAddon) { + const uri = "chrome://calendar/content/calendar-providerUninstall-dialog.xul"; + const features = "chrome,titlebar,resizable,modal"; + let calMgr = cal.getCalendarManager(); + let affectedCalendars = + calMgr.getCalendars({}).filter(calendar => calendar.providerID == aAddon.id); + if (!affectedCalendars.length) { + // If no calendars are affected, then everything is fine. + return true; + } + + let args = { shouldUninstall: false, extension: aAddon }; + + // Now find a window. The best choice would be the most recent + // addons window, otherwise the most recent calendar window, or we + // create a new toplevel window. + let win = Services.wm.getMostRecentWindow("Extension:Manager") || + cal.getCalendarWindow(); + if (win) { + win.openDialog(uri, "CalendarProviderUninstallDialog", features, args); + } else { + // Use the window watcher to open a parentless window. + Services.ww.openWindow(null, uri, "CalendarProviderUninstallWindow", features, args); + } + + // Now that we are done, check if the dialog was accepted or canceled. + return args.shouldUninstall; + } +}; + +function appendToRealm(authHeader, appendStr) { + let isEscaped = false; + let idx = authHeader.search(/realm="(.*?)(\\*)"/); + if (idx > -1) { + let remain = authHeader.substr(idx + 7); idx += 7; + while (remain.length && !isEscaped) { + let match = remain.match(/(.*?)(\\*)"/); + idx += match[0].length; + + isEscaped = ((match[2].length % 2) == 0); + if (!isEscaped) { + remain = remain.substr(match[0].length); + } + } + return authHeader.substr(0, idx - 1) + " " + + appendStr + authHeader.substr(idx - 1); + } else { + return authHeader; + } +} diff --git a/calendar/base/src/calCalendarSearchService.js b/calendar/base/src/calCalendarSearchService.js new file mode 100644 index 000000000..2abecf562 --- /dev/null +++ b/calendar/base/src/calCalendarSearchService.js @@ -0,0 +1,97 @@ +/* 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/. */ + +function calCalendarSearchListener(numOperations, finalListener) { + this.mFinalListener = finalListener; + this.mNumOperations = numOperations; + this.mResults = []; + + this.opGroup = new calOperationGroup(() => { + this.notifyResult(null); + }); +} +calCalendarSearchListener.prototype = { + mFinalListener: null, + mNumOperations: 0, + opGroup: null, + + notifyResult: function(result) { + let listener = this.mFinalListener; + if (listener) { + if (!this.opGroup.isPending) { + this.mFinalListener = null; + } + listener.onResult(this.opGroup, result); + } + }, + + // calIGenericOperationListener: + onResult: function(aOperation, aResult) { + if (this.mFinalListener) { + if (!aOperation || !aOperation.isPending) { + --this.mNumOperations; + if (this.mNumOperations == 0) { + this.opGroup.notifyCompleted(); + } + } + if (aResult) { + this.notifyResult(aResult); + } + } + } +}; + +function calCalendarSearchService() { + this.wrappedJSObject = this; + this.mProviders = new calInterfaceBag(Components.interfaces.calICalendarSearchProvider); +} +var calCalendarSearchServiceClassID = Components.ID("{f5f743cd-8997-428e-bc1b-644e73f61203}"); +var calCalendarSearchServiceInterfaces = [ + Components.interfaces.calICalendarSearchProvider, + Components.interfaces.calICalendarSearchService +]; +calCalendarSearchService.prototype = { + mProviders: null, + + classID: calCalendarSearchServiceClassID, + QueryInterface: XPCOMUtils.generateQI(calCalendarSearchServiceInterfaces), + classInfo: XPCOMUtils.generateCI({ + classID: calCalendarSearchServiceClassID, + contractID: "@mozilla.org/calendar/calendarsearch-service;1", + classDescription: "Calendar Search Service", + interfaces: calCalendarSearchServiceInterfaces, + flags: Components.interfaces.nsIClassInfo.SINGLETON + }), + + // calICalendarSearchProvider: + searchForCalendars: function(aString, aHints, aMaxResults, aListener) { + let groupListener = new calCalendarSearchListener(this.mProviders.size, aListener); + function searchForCalendars_(provider) { + try { + groupListener.opGroup.add(provider.searchForCalendars(aString, + aHints, + aMaxResults, + groupListener)); + } catch (exc) { + Components.utils.reportError(exc); + groupListener.onResult(null, []); // dummy to adopt mNumOperations + } + } + this.mProviders.forEach(searchForCalendars_); + return groupListener.opGroup; + }, + + // calICalendarSearchService: + getProviders: function(out_aCount) { + let ret = this.mProviders.interfaceArray; + out_aCount.value = ret.length; + return ret; + }, + addProvider: function(aProvider) { + this.mProviders.add(aProvider); + }, + removeProvider: function(aProvider) { + this.mProviders.remove(aProvider); + } +}; diff --git a/calendar/base/src/calDateTimeFormatter.js b/calendar/base/src/calDateTimeFormatter.js new file mode 100644 index 000000000..23c06af3f --- /dev/null +++ b/calendar/base/src/calDateTimeFormatter.js @@ -0,0 +1,296 @@ +/* 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/. */ + +Components.utils.import("resource://gre/modules/Services.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); +Components.utils.import("resource://gre/modules/Preferences.jsm"); + +var nsIScriptableDateFormat = Components.interfaces.nsIScriptableDateFormat; + +function calDateTimeFormatter() { + this.wrappedJSObject = this; + this.mDateStringBundle = Services.strings.createBundle("chrome://calendar/locale/dateFormat.properties"); + + this.mDateService = + Components.classes["@mozilla.org/intl/scriptabledateformat;1"] + .getService(nsIScriptableDateFormat); + + // Do does the month or day come first in this locale? + this.mMonthFirst = false; + + // If LONG FORMATTED DATE is same as short formatted date, + // then OS has poor extended/long date config, so use workaround. + this.mUseLongDateService = true; + let probeDate = + Components.classes["@mozilla.org/calendar/datetime;1"] + .createInstance(Components.interfaces.calIDateTime); + probeDate.timezone = UTC(); + probeDate.year = 2002; + probeDate.month = 3; + probeDate.day = 5; + try { + // We're try/catching the calls to nsScriptableDateFormat since it's + // outside this module. We're also reusing probeDate rather than + // creating 3 discrete calDateTimes for performance. + let probeStringA = this.formatDateShort(probeDate); + let longProbeString = this.formatDateLong(probeDate); + probeDate.month = 4; + let probeStringB = this.formatDateShort(probeDate); + probeDate.month = 3; + probeDate.day = 6; + let probeStringC = this.formatDateShort(probeDate); + + // Compare the index of the first differing character between + // probeStringA to probeStringB and probeStringA to probeStringC. + for (let i = 0; i < probeStringA.length; i++) { + if (probeStringA[i] != probeStringB[i]) { + this.mMonthFirst = true; + break; + } else if (probeStringA[i] != probeStringC[i]) { + this.mMonthFirst = false; + break; + } + } + + // On Unix extended/long date format may be created using %Ex instead + // of %x. Some systems may not support it and return "Ex" or same as + // short string. In that case, don't use long date service, use a + // workaround hack instead. + if (longProbeString == null || + longProbeString.length < 4 || + longProbeString == probeStringA) { + this.mUseLongDateService = false; + } + } catch (e) { + this.mUseLongDateService = false; + } +} +var calDateTimeFormatterClassID = Components.ID("{4123da9a-f047-42da-a7d0-cc4175b9f36a}"); +var calDateTimeFormatterInterfaces = [Components.interfaces.calIDateTimeFormatter]; +calDateTimeFormatter.prototype = { + classID: calDateTimeFormatterClassID, + QueryInterface: XPCOMUtils.generateQI(calDateTimeFormatterInterfaces), + classInfo: XPCOMUtils.generateCI({ + classID: calDateTimeFormatterClassID, + contractID: "@mozilla.org/calendar/datetime-formatter;1", + classDescription: "Formats Dates and Times", + interfaces: calDateTimeFormatterInterfaces, + }), + + formatDate: function(aDate) { + // Format the date using user's format preference (long or short) + let format = Preferences.get("calendar.date.format", 0); + return (format == 0 ? this.formatDateLong(aDate) : this.formatDateShort(aDate)); + }, + + formatDateShort: function(aDate) { + return this.mDateService.FormatDate("", + nsIScriptableDateFormat.dateFormatShort, + aDate.year, + aDate.month + 1, + aDate.day); + }, + + formatDateLong: function(aDate) { + let longDate; + if (this.mUseLongDateService) { + longDate = this.mDateService.FormatDate("", + nsIScriptableDateFormat.dateFormatLong, + aDate.year, + aDate.month + 1, + aDate.day); + // check whether weekday name appears as in Lightning localization. if not, this is + // probably a minority language without OS support, so we should fall back to compose + // longDate on our own. May be not needed anymore once bug 441167 is fixed. + if (!longDate.includes(this.dayName(aDate.weekday)) && + !longDate.includes(this.shortDayName(aDate.weekday))) { + longDate = null; + this.mUseLongDateService = false; + } + } + if (longDate == null) { + // HACK We are probably on Linux or have a minority localization and want a string in + // long format. dateService.dateFormatLong on Linux may return a short string, so + // build our own. + longDate = cal.calGetString("calendar", "formatDateLong", + [this.shortDayName(aDate.weekday), + this.formatDayWithOrdinal(aDate.day), + this.shortMonthName(aDate.month), + aDate.year]); + } + return longDate; + }, + + formatDateWithoutYear: function(aDate) { + // Doing this the hard way, because nsIScriptableDateFormat doesn't + // have a way to not include the year. + if (this.mMonthFirst) { + return this.shortMonthName(aDate.month) + " " + this.formatDayWithOrdinal(aDate.day); + } else { + return this.formatDayWithOrdinal(aDate.day) + " " + this.shortMonthName(aDate.month); + } + }, + + formatTime: function(aDate) { + if (aDate.isDate) { + return this.mDateStringBundle.GetStringFromName("AllDay"); + } + + return this.mDateService.FormatTime("", + nsIScriptableDateFormat.timeFormatNoSeconds, + aDate.hour, + aDate.minute, + 0); + }, + + formatDateTime: function(aDate) { + let formattedDate = this.formatDate(aDate); + let formattedTime = this.formatTime(aDate); + + let timeBeforeDate = Preferences.get("calendar.date.formatTimeBeforeDate", false); + if (timeBeforeDate) { + return formattedTime + " " + formattedDate; + } else { + return formattedDate + " " + formattedTime; + } + }, + + formatTimeInterval: function(aStartDate, aEndDate) { + if (!aStartDate && aEndDate) { + return this.formatTime(aEndDate); + } + if (!aEndDate && aStartDate) { + return this.formatTime(aStartDate); + } + if (!aStartDate && !aEndDate) { + return ""; + } + + // TODO do we need l10n for this? + // TODO should we check for the same day? The caller should know what + // he is doing... + return this.formatTime(aStartDate) + "\u2013" + this.formatTime(aEndDate); + }, + + formatInterval: function(aStartDate, aEndDate) { + // Check for tasks without start and/or due date + if (aEndDate == null && aStartDate == null) { + return calGetString("calendar", "datetimeIntervalTaskWithoutDate"); + } else if (aEndDate == null) { + let startDateString = this.formatDate(aStartDate); + let startTime = this.formatTime(aStartDate); + return calGetString("calendar", "datetimeIntervalTaskWithoutDueDate", [startDateString, startTime]); + } else if (aStartDate == null) { + let endDateString = this.formatDate(aEndDate); + let endTime = this.formatTime(aEndDate); + return calGetString("calendar", "datetimeIntervalTaskWithoutStartDate", [endDateString, endTime]); + } + // Here there are only events or tasks with both start and due date. + // make sure start and end use the same timezone when formatting intervals: + let endDate = aEndDate.getInTimezone(aStartDate.timezone); + let testdate = aStartDate.clone(); + testdate.isDate = true; + let sameDay = (testdate.compare(endDate) == 0); + if (aStartDate.isDate) { + // All-day interval, so we should leave out the time part + if (sameDay) { + return this.formatDateLong(aStartDate); + } else { + let startDay = this.formatDayWithOrdinal(aStartDate.day); + let startYear = aStartDate.year; + let endDay = this.formatDayWithOrdinal(endDate.day); + let endYear = endDate.year; + if (aStartDate.year != endDate.year) { + let startMonthName = cal.formatMonth(aStartDate.month + 1, "calendar", "daysIntervalBetweenYears"); + let endMonthName = cal.formatMonth(aEndDate.month + 1, "calendar", "daysIntervalBetweenYears"); + return cal.calGetString("calendar", "daysIntervalBetweenYears", [startMonthName, startDay, startYear, endMonthName, endDay, endYear]); + } else if (aStartDate.month == endDate.month) { + let startMonthName = cal.formatMonth(aStartDate.month + 1, "calendar", "daysIntervalInMonth"); + return cal.calGetString("calendar", "daysIntervalInMonth", [startMonthName, startDay, endDay, endYear]); + } else { + let startMonthName = cal.formatMonth(aStartDate.month + 1, "calendar", "daysIntervalBetweenMonths"); + let endMonthName = cal.formatMonth(aEndDate.month + 1, "calendar", "daysIntervalBetweenMonths"); + return cal.calGetString("calendar", "daysIntervalBetweenMonths", [startMonthName, startDay, endMonthName, endDay, endYear]); + } + } + } else { + let startDateString = this.formatDate(aStartDate); + let startTime = this.formatTime(aStartDate); + let endDateString = this.formatDate(endDate); + let endTime = this.formatTime(endDate); + // non-allday, so need to return date and time + if (sameDay) { + // End is on the same day as start, so we can leave out the end date + if (startTime == endTime) { + // End time is on the same time as start, so we can leave out the end time + // "5 Jan 2006 13:00" + return calGetString("calendar", "datetimeIntervalOnSameDateTime", [startDateString, startTime]); + } else { + // still include end time + // "5 Jan 2006 13:00 - 17:00" + return calGetString("calendar", "datetimeIntervalOnSameDay", [startDateString, startTime, endTime]); + } + } else { + // Spanning multiple days, so need to include date and time + // for start and end + // "5 Jan 2006 13:00 - 7 Jan 2006 9:00" + return calGetString("calendar", "datetimeIntervalOnSeveralDays", [startDateString, startTime, endDateString, endTime]); + } + } + }, + + formatDayWithOrdinal: function(aDay) { + let ordinalSymbols = this.mDateStringBundle.GetStringFromName("dayOrdinalSymbol").split(","); + let dayOrdinalSymbol = ordinalSymbols[aDay - 1] || ordinalSymbols[0]; + return aDay + dayOrdinalSymbol; + }, + + _getItemDates: function(aItem) { + let start = aItem[calGetStartDateProp(aItem)]; + let end = aItem[calGetEndDateProp(aItem)]; + let kDefaultTimezone = calendarDefaultTimezone(); + // Check for tasks without start and/or due date + if (start) { + start = start.getInTimezone(kDefaultTimezone); + } + if (end) { + end = end.getInTimezone(kDefaultTimezone); + } + // EndDate is exclusive. For all-day events, we ened to substract one day, + // to get into a format that's understandable. + if (start && start.isDate && end) { + end.day -= 1; + } + + return [start, end]; + }, + + formatItemInterval: function(aItem) { + return this.formatInterval(...this._getItemDates(aItem)); + }, + + formatItemTimeInterval: function(aItem) { + return this.formatTimeInterval(...this._getItemDates(aItem)); + }, + + monthName: function(aMonthIndex) { + let oneBasedMonthIndex = aMonthIndex + 1; + return this.mDateStringBundle.GetStringFromName("month." + oneBasedMonthIndex + ".name"); + }, + + shortMonthName: function(aMonthIndex) { + let oneBasedMonthIndex = aMonthIndex + 1; + return this.mDateStringBundle.GetStringFromName("month." + oneBasedMonthIndex + ".Mmm"); + }, + + dayName: function(aDayIndex) { + let oneBasedDayIndex = aDayIndex + 1; + return this.mDateStringBundle.GetStringFromName("day." + oneBasedDayIndex + ".name"); + }, + + shortDayName: function(aDayIndex) { + let oneBasedDayIndex = aDayIndex + 1; + return this.mDateStringBundle.GetStringFromName("day." + oneBasedDayIndex + ".Mmm"); + } +}; diff --git a/calendar/base/src/calDefaultACLManager.js b/calendar/base/src/calDefaultACLManager.js new file mode 100644 index 000000000..dc512be54 --- /dev/null +++ b/calendar/base/src/calDefaultACLManager.js @@ -0,0 +1,122 @@ +/* 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/. */ + +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); + +/* calDefaultACLManager */ +function calDefaultACLManager() { + this.mCalendarEntries = {}; +} + +var calDefaultACLManagerClassID = Components.ID("{7463258c-6ef3-40a2-89a9-bb349596e927}"); +var calDefaultACLManagerInterfaces = [Components.interfaces.calICalendarACLManager]; +calDefaultACLManager.prototype = { + mCalendarEntries: null, + + /* nsISupports, nsIClassInfo */ + classID: calDefaultACLManagerClassID, + QueryInterface: XPCOMUtils.generateQI(calDefaultACLManagerInterfaces), + classInfo: XPCOMUtils.generateCI({ + classID: calDefaultACLManagerClassID, + contractID: "@mozilla.org/calendar/acl-manager;1?type=default", + classDescription: "Default Calendar ACL Provider", + interfaces: calDefaultACLManagerInterfaces, + flags: Components.interfaces.nsIClassInfo.SINGLETON + }), + + /* calICalendarACLManager */ + _getCalendarEntryCached: function(aCalendar) { + let calUri = aCalendar.uri.spec; + if (!(calUri in this.mCalendarEntries)) { + this.mCalendarEntries[calUri] = new calDefaultCalendarACLEntry(this, aCalendar); + } + + return this.mCalendarEntries[calUri]; + }, + getCalendarEntry: function(aCalendar, aListener) { + let entry = this._getCalendarEntryCached(aCalendar); + aListener.onOperationComplete(aCalendar, Components.results.NS_OK, + Components.interfaces.calIOperationListener.GET, + null, + entry); + }, + getItemEntry: function(aItem) { + let calEntry = this._getCalendarEntryCached(aItem.calendar); + return new calDefaultItemACLEntry(calEntry); + }, + +}; + +function calDefaultCalendarACLEntry(aMgr, aCalendar) { + this.mACLManager = aMgr; + this.mCalendar = aCalendar; +} + +calDefaultCalendarACLEntry.prototype = { + mACLManager: null, + + /* nsISupports */ + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calICalendarACLEntry]), + + /* calICalendarACLCalendarEntry */ + get aclManager() { + return this.mACLManager; + }, + + hasAccessControl: false, + userIsOwner: true, + userCanAddItems: true, + userCanDeleteItems: true, + + _getIdentities: function(aCount) { + let identities = []; + cal.calIterateEmailIdentities(id => identities.push(id)); + aCount.value = identities.length; + return identities; + }, + + getUserAddresses: function(aCount) { + let identities = this.getUserIdentities(aCount); + let addresses = identities.map(id => id.email); + return addresses; + }, + + getUserIdentities: function(aCount) { + let identity = cal.getEmailIdentityOfCalendar(this.mCalendar); + if (identity) { + aCount.value = 1; + return [identity]; + } else { + return this._getIdentities(aCount); + } + }, + getOwnerIdentities: function(aCount) { + return this._getIdentities(aCount); + }, + + refresh: function() { + } +}; + +function calDefaultItemACLEntry(aCalendarEntry) { + this.calendarEntry = aCalendarEntry; +} + +calDefaultItemACLEntry.prototype = { + /* nsISupports */ + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calIItemACLEntry]), + + /* calIItemACLEntry */ + calendarEntry: null, + userCanModify: true, + userCanRespond: true, + userCanViewAll: true, + userCanViewDateAndTime: true, +}; + +/** Module Registration */ +this.NSGetFactory = XPCOMUtils.generateNSGetFactory([calDefaultACLManager]); diff --git a/calendar/base/src/calDefaultACLManager.manifest b/calendar/base/src/calDefaultACLManager.manifest new file mode 100644 index 000000000..038f2f72e --- /dev/null +++ b/calendar/base/src/calDefaultACLManager.manifest @@ -0,0 +1,2 @@ +component {7463258c-6ef3-40a2-89a9-bb349596e927} calDefaultACLManager.js +contract @mozilla.org/calendar/acl-manager;1?type=default {7463258c-6ef3-40a2-89a9-bb349596e927} diff --git a/calendar/base/src/calDeletedItems.js b/calendar/base/src/calDeletedItems.js new file mode 100644 index 000000000..73c05ae85 --- /dev/null +++ b/calendar/base/src/calDeletedItems.js @@ -0,0 +1,199 @@ +/* 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/. */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); +Components.utils.import("resource://gre/modules/FileUtils.jsm"); + +/** + * Handles remembering deleted items. + * + * This is (currently) not a real trashcan. Only ids and time deleted is stored. + * Note also that the code doesn't strictly check the calendar of the item, + * except when a calendar id is passed to getDeletedDate. + */ +function calDeletedItems() { + this.wrappedJSObject = this; + + this.completedNotifier = { + handleResult: function() {}, + handleError: function() {}, + handleCompletion: function() {}, + }; +} + +var calDeletedItemsClassID = Components.ID("{8e6799af-e7e9-4e6c-9a82-a2413e86d8c3}"); +var calDeletedItemsInterfaces = [ + Components.interfaces.calIDeletedItems, + Components.interfaces.nsIObserver, + Components.interfaces.calIObserver +]; +calDeletedItems.prototype = { + + classID: calDeletedItemsClassID, + QueryInterface: XPCOMUtils.generateQI(calDeletedItemsInterfaces), + classInfo: XPCOMUtils.generateCI({ + classID: calDeletedItemsClassID, + contractID: "@mozilla.org/calendar/deleted-items-manager;1", + classDescription: "Database containing information about deleted items", + interfaces: calDeletedItemsInterfaces, + flags: Components.interfaces.nsIClassInfo.SINGLETON + }), + + DB_SCHEMA_VERSION: 1, + STALE_TIME: 30 * 24 * 60 * 60 / 1000, /* 30 days */ + + // To make the tests more failsafe, we have an internal notifier function. + // As the deleted items store is just meant to be a hint, this should not + // be used in real code. + completedNotifier: null, + + flush: function() { + this.ensureStatements(); + this.stmtFlush.params.stale_time = cal.now().nativeTime - this.STALE_TIME; + this.stmtFlush.executeAsync(this.completedNotifier); + }, + + getDeletedDate: function(aId, aCalId) { + this.ensureStatements(); + let stmt; + if (aCalId) { + stmt = this.stmtGetWithCal; + stmt.params.calId = aCalId; + } else { + stmt = this.stmtGet; + } + + stmt.params.id = aId; + try { + if (stmt.executeStep()) { + let date = cal.createDateTime(); + date.nativeTime = stmt.row.time_deleted; + return date.getInTimezone(cal.calendarDefaultTimezone()); + } + } catch (e) { + cal.ERROR(e); + } finally { + stmt.reset(); + } + return null; + }, + + markDeleted: function(aItem) { + this.ensureStatements(); + this.stmtMarkDelete.params.calId = aItem.calendar.id; + this.stmtMarkDelete.params.id = aItem.id; + this.stmtMarkDelete.params.time = cal.now().nativeTime; + this.stmtMarkDelete.params.rid = (aItem.recurrenceId && aItem.recurrenceId.nativeTime) || ""; + this.stmtMarkDelete.executeAsync(this.completedNotifier); + }, + + unmarkDeleted: function(aItem) { + this.ensureStatements(); + this.stmtUnmarkDelete.params.id = aItem.id; + this.stmtUnmarkDelete.executeAsync(this.completedNotifier); + }, + + initDB: function() { + if (this.mDB) { + // Looks like we've already initialized, exit early + return; + } + + let file = FileUtils.getFile("ProfD", ["calendar-data", "deleted.sqlite"]); + this.mDB = Services.storage.openDatabase(file); + + // If this database needs changing, please start using a real schema + // management, i.e using PRAGMA user_version and upgrading + if (!this.mDB.tableExists("cal_deleted_items")) { + const v1_schema = "cal_id TEXT, id TEXT, time_deleted INTEGER, recurrence_id INTEGER"; + const v1_index = "CREATE INDEX idx_deleteditems ON cal_deleted_items(id,cal_id,recurrence_id)"; + + this.mDB.createTable("cal_deleted_items", v1_schema); + this.mDB.executeSimpleSQL(v1_index); + this.mDB.executeSimpleSQL("PRAGMA user_version = 1"); + } + + // We will not init the statements now, we can still do that the + // first time this interface is used. What we should do though is + // to clean up at shutdown + cal.addShutdownObserver(this.shutdown.bind(this)); + }, + + observe: function(aSubject, aTopic, aData) { + if (aTopic == "profile-after-change") { + // Make sure to observe calendar changes so we know when things are + // deleted. We don't initialize the statements until first use. + cal.getCalendarManager().addCalendarObserver(this); + } + }, + + ensureStatements: function() { + if (!this.mDB) { + this.initDB(); + } + + if (!this.stmtMarkDelete) { + let stmt = "INSERT OR REPLACE INTO cal_deleted_items (cal_id, id, time_deleted, recurrence_id) VALUES(:calId, :id, :time, :rid)"; + this.stmtMarkDelete = this.mDB.createStatement(stmt); + } + if (!this.stmtUnmarkDelete) { + let stmt = "DELETE FROM cal_deleted_items WHERE id = :id"; + this.stmtUnmarkDelete = this.mDB.createStatement(stmt); + } + if (!this.stmtGetWithCal) { + let stmt = "SELECT time_deleted FROM cal_deleted_items WHERE cal_id = :calId AND id = :id"; + this.stmtGetWithCal = this.mDB.createStatement(stmt); + } + if (!this.stmtGet) { + let stmt = "SELECT time_deleted FROM cal_deleted_items WHERE id = :id"; + this.stmtGet = this.mDB.createStatement(stmt); + } + if (!this.stmtFlush) { + let stmt = "DELETE FROM cal_deleted_items WHERE time_deleted < :stale_time"; + this.stmtFlush = this.mDB.createStatement(stmt); + } + }, + + shutdown: function() { + try { + let stmts = [ + this.stmtMarkDelete, this.stmtUnmarkDelete, this.stmtGet, + this.stmtGetWithCal, this.stmtFlush + ]; + for (let stmt of stmts) { + stmt.finalize(); + } + + if (this.mDB) { + this.mDB.asyncClose(); + this.mDB = null; + } + } catch (e) { + cal.ERROR("Error closing deleted items database: " + e); + } + + cal.getCalendarManager().removeCalendarObserver(this); + }, + + // calIObserver + onStartBatch: function() {}, + onEndBatch: function() {}, + onModifyItem: function() {}, + onError: function() {}, + onPropertyChanged: function() {}, + onPropertyDeleting: function() {}, + + onAddItem: function(aItem) { + this.unmarkDeleted(aItem); + }, + + onDeleteItem: function(aItem) { + this.markDeleted(aItem); + }, + + onLoad: function() { + this.flush(); + } +}; diff --git a/calendar/base/src/calEvent.js b/calendar/base/src/calEvent.js new file mode 100644 index 000000000..9d33e1c87 --- /dev/null +++ b/calendar/base/src/calEvent.js @@ -0,0 +1,208 @@ +/* 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/. */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + +// +// constructor +// +function calEvent() { + this.initItemBase(); + + this.eventPromotedProps = { + DTSTART: true, + DTEND: true, + __proto__: this.itemBasePromotedProps + }; +} +var calEventClassID = Components.ID("{974339d5-ab86-4491-aaaf-2b2ca177c12b}"); +var calEventInterfaces = [ + Components.interfaces.calIItemBase, + Components.interfaces.calIEvent, + Components.interfaces.calIInternalShallowCopy +]; +calEvent.prototype = { + __proto__: calItemBase.prototype, + + classID: calEventClassID, + QueryInterface: XPCOMUtils.generateQI(calEventInterfaces), + classInfo: XPCOMUtils.generateCI({ + classID: calEventClassID, + contractID: "@mozilla.org/calendar/event;1", + classDescription: "Calendar Event", + interfaces: calEventInterfaces + }), + + cloneShallow: function(aNewParent) { + let cloned = new calEvent(); + this.cloneItemBaseInto(cloned, aNewParent); + return cloned; + }, + + createProxy: function(aRecurrenceId) { + cal.ASSERT(!this.mIsProxy, "Tried to create a proxy for an existing proxy!", true); + + let proxy = new calEvent(); + + // override proxy's DTSTART/DTEND/RECURRENCE-ID + // before master is set (and item might get immutable): + let endDate = aRecurrenceId.clone(); + endDate.addDuration(this.duration); + proxy.endDate = endDate; + proxy.startDate = aRecurrenceId; + + proxy.initializeProxy(this, aRecurrenceId); + proxy.mDirty = false; + + return proxy; + }, + + makeImmutable: function() { + this.makeItemBaseImmutable(); + }, + + get duration() { + if (this.endDate && this.startDate) { + return this.endDate.subtractDate(this.startDate); + } else { + // Return a null-duration if we don't have an end date + return cal.createDuration(); + } + }, + + get recurrenceStartDate() { + return this.startDate; + }, + + icsEventPropMap: [ + { cal: "DTSTART", ics: "startTime" }, + { cal: "DTEND", ics: "endTime" }], + + set icalString(value) { + this.icalComponent = getIcsService().parseICS(value, null); + }, + + get icalString() { + let calcomp = getIcsService().createIcalComponent("VCALENDAR"); + calSetProdidVersion(calcomp); + calcomp.addSubcomponent(this.icalComponent); + return calcomp.serializeToICS(); + }, + + get icalComponent() { + let icssvc = getIcsService(); + let icalcomp = icssvc.createIcalComponent("VEVENT"); + this.fillIcalComponentFromBase(icalcomp); + this.mapPropsToICS(icalcomp, this.icsEventPropMap); + + let bagenum = this.propertyEnumerator; + while (bagenum.hasMoreElements()) { + let iprop = bagenum.getNext() + .QueryInterface(Components.interfaces.nsIProperty); + try { + if (!this.eventPromotedProps[iprop.name]) { + let icalprop = icssvc.createIcalProperty(iprop.name); + icalprop.value = iprop.value; + let propBucket = this.mPropertyParams[iprop.name]; + if (propBucket) { + for (let paramName in propBucket) { + try { + icalprop.setParameter(paramName, propBucket[paramName]); + } catch (e) { + if (e.result == Components.results.NS_ERROR_ILLEGAL_VALUE) { + // Illegal values should be ignored, but we could log them if + // the user has enabled logging. + cal.LOG("Warning: Invalid event parameter value " + paramName + "=" + propBucket[paramName]); + } else { + throw e; + } + } + } + } + icalcomp.addProperty(icalprop); + } + } catch (e) { + cal.ERROR("failed to set " + iprop.name + " to " + iprop.value + ": " + e + "\n"); + } + } + return icalcomp; + }, + + eventPromotedProps: null, + + set icalComponent(event) { + this.modify(); + if (event.componentType != "VEVENT") { + event = event.getFirstSubcomponent("VEVENT"); + if (!event) { + throw Components.results.NS_ERROR_INVALID_ARG; + } + } + + this.mEndDate = undefined; + this.setItemBaseFromICS(event); + this.mapPropsFromICS(event, this.icsEventPropMap); + + this.importUnpromotedProperties(event, this.eventPromotedProps); + + // Importing didn't really change anything + this.mDirty = false; + }, + + isPropertyPromoted: function(name) { + // avoid strict undefined property warning + return this.eventPromotedProps[name] || false; + }, + + set startDate(value) { + this.modify(); + + // We're about to change the start date of an item which probably + // could break the associated calIRecurrenceInfo. We're calling + // the appropriate method here to adjust the internal structure in + // order to free clients from worrying about such details. + if (this.parentItem == this) { + let rec = this.recurrenceInfo; + if (rec) { + rec.onStartDateChange(value, this.startDate); + } + } + + return this.setProperty("DTSTART", value); + }, + + get startDate() { + return this.getProperty("DTSTART"); + }, + + mEndDate: undefined, + get endDate() { + let endDate = this.mEndDate; + if (endDate === undefined) { + endDate = this.getProperty("DTEND"); + if (!endDate && this.startDate) { + endDate = this.startDate.clone(); + let dur = this.getProperty("DURATION"); + if (dur) { + // If there is a duration set on the event, calculate the right end time. + endDate.addDuration(cal.createDuration(dur)); + } else if (endDate.isDate) { + // If the start time is a date-time the event ends on the same calendar + // date and time of day. If the start time is a date the events + // non-inclusive end is the end of the calendar date. + endDate.day += 1; + } + } + this.mEndDate = endDate; + } + return endDate; + }, + + set endDate(value) { + this.deleteProperty("DURATION"); // setting endDate once removes DURATION + this.setProperty("DTEND", value); + return (this.mEndDate = value); + } +}; diff --git a/calendar/base/src/calFilter.js b/calendar/base/src/calFilter.js new file mode 100644 index 000000000..e06c8ca50 --- /dev/null +++ b/calendar/base/src/calFilter.js @@ -0,0 +1,911 @@ +/* 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/. */ + +Components.utils.import("resource://gre/modules/Preferences.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); +Components.utils.import("resource://calendar/modules/calUtils.jsm"); + +/** + * Object that contains a set of filter properties that may be used by a calFilter object + * to filter a set of items. + * Supported filter properties: + * start, end: Specifies the relative date range to use when calculating the filter date + * range. The relative date range may relative to the current date and time, the + * currently selected date, or the dates range of the current view. The actual + * date range used to filter items will be calculated by the calFilter object + * by using the updateFilterDates function, which may be called multiple times + * to reflect changes in the current date and time, and changes to the view. + * + * + * The properties may be set to one of the folowing values: + * - FILTER_DATE_ALL: An unbound date range. + * - FILTER_DATE_XXX: One of the defined relative date ranges. + * - A string that may be converted to a calIDuration object that will be used + * as an offset to the current date and time. + * + * The start and end properties may have values representing different relative + * date ranges, in which case the filter start date will be calculated as the start + * of the relative range specified by the start property, while the filter end date + * will be calculated as the end of the relative range specified by the end + * property. + * + * due: Specifies the filter property for the due date of tasks. This filter has no + * effect when filtering events. + * + * The property has a bit field value, with the FILTER_DUE_XXX bit flags set + * to indicate that tasks with the corresponding due property value should match + * the filter. + * + * If the value is set to null the due date will not be considered when filtering. + * + * status: Specifies the filter property for the status of tasks. This filter has no + * effect when filtering events. + * + * The property has a bit field value, with the FILTER_STATUS_XXX bit flags set + * to indicate that tasks with the corresponding status property value should match + * the filter. + * + * If the value is set to null the status will not be considered when filtering. + * + * category: Specifies the filter property for the item category. + * + * The property may be set to one of the folowing values: + * - null: The item category will not be considered when filtering. + * - A string: The item will match the filter if any of it's categories match the + * category specified by the property. + * - An array: The item will match the filter if any of it's categories match any + * of the categories contained in the Array specified by the property. + * + * occurrences: Specifies the filter property for returning occurrences of repeating items. + * + * The property may be set to one of the folowing values: + * - null, FILTER_OCCURRENCES_BOUND: The default occurrence handling. Occurrences + * will be returned only for date ranges with a bound end date. + * - FILTER_OCCURRENCES_NONE: Only the parent items will be returned. + * - FILTER_OCCURRENCES_PAST_AND_NEXT: Returns past occurrences and the next future + * matching occurrence if one is found. + * + * onfilter: A callback function that may be used to apply additional custom filter + * constraints. If specified, the callback function will be called after any other + * specified filter properties are tested. + * + * The callback function will be called with the following parameters: + * - function(aItem, aResults, aFilterProperties, aFilter) + * @param aItem The item being tested. + * @param aResults The results of the test of the other specified + * filter properties. + * @param aFilterProperties The current filter properties being tested. + * @param aFilter The calFilter object performing the filter test. + * + * If specified, the callback function is responsible for returning a value that + * can be converted to true if the item should match the filter, or a value that + * can be converted to false otherwise. The return value will override the results + * of the testing of any other specified filter properties. + */ +function calFilterProperties() { + this.wrappedJSObject = this; +} + +calFilterProperties.prototype = { + FILTER_DATE_ALL: 0, + FILTER_DATE_VIEW: 1, + FILTER_DATE_SELECTED: 2, + FILTER_DATE_SELECTED_OR_NOW: 3, + FILTER_DATE_NOW: 4, + FILTER_DATE_TODAY: 5, + FILTER_DATE_CURRENT_WEEK: 6, + FILTER_DATE_CURRENT_MONTH: 7, + FILTER_DATE_CURRENT_YEAR: 8, + + FILTER_STATUS_INCOMPLETE: 1, + FILTER_STATUS_IN_PROGRESS: 2, + FILTER_STATUS_COMPLETED_TODAY: 4, + FILTER_STATUS_COMPLETED_BEFORE: 8, + FILTER_STATUS_ALL: 15, + + FILTER_DUE_PAST: 1, + FILTER_DUE_TODAY: 2, + FILTER_DUE_FUTURE: 4, + FILTER_DUE_NONE: 8, + FILTER_DUE_ALL: 15, + + FILTER_OCCURRENCES_BOUND: 0, + FILTER_OCCURRENCES_NONE: 1, + FILTER_OCCURRENCES_PAST_AND_NEXT: 2, + + start: null, + end: null, + due: null, + status: null, + category: null, + occurrences: null, + + onfilter: null, + + equals: function(aFilterProps) { + if (!(aFilterProps instanceof calFilterProperties)) { + return false; + } + let props = ["start", "end", "due", "status", "category", "occurrences", "onfilter"]; + return props.every(function(prop) { + return (this[prop] == aFilterProps[prop]); + }, this); + }, + + clone: function() { + let cloned = new calFilterProperties(); + let props = ["start", "end", "due", "status", "category", "occurrences", "onfilter"]; + props.forEach(function(prop) { + cloned[prop] = this[prop]; + }, this); + + return cloned; + }, + + LOG: function(aString) { + cal.LOG("[calFilterProperties] " + + (aString || "") + + " start=" + this.start + + " end=" + this.end + + " status=" + this.status + + " due=" + this.due + + " category=" + this.category); + } +}; + +/** + * Object that allows filtering of a set of items using a set of filter properties. A set + * of property filters may be defined by a filter name, which may then be used to apply + * the defined filter properties. A set of commonly used property filters are predefined. + */ +function calFilter() { + this.wrappedJSObject = this; + this.mFilterProperties = new calFilterProperties(); + this.initDefinedFilters(); + this.mMaxIterations = Preferences.get("calendar.filter.maxiterations", 50); +} + +calFilter.prototype = { + mStartDate: null, + mEndDate: null, + mSelectedDate: null, + mFilterText: "", + mDefinedFilters: {}, + mFilterProperties: null, + mToday: null, + mTomorrow: null, + mMaxIterations: 50, + + /** + * Initializes the predefined filters. + */ + initDefinedFilters: function() { + let filters = ["all", "notstarted", "overdue", "open", "completed", "throughcurrent", + "throughtoday", "throughsevendays", "today", "thisCalendarMonth", + "future", "current", "currentview"]; + filters.forEach(function(filter) { + if (!(filter in this.mDefinedFilters)) { + this.defineFilter(filter, this.getPreDefinedFilterProperties(filter)); + } + }, this); + }, + + /** + * Gets the filter properties for a predefined filter. + * + * @param aFilter The name of the filter to retrieve the filter properties for. + * @result The filter properties for the specified filter, or null if the filter + * not predefined. + */ + getPreDefinedFilterProperties: function(aFilter) { + let props = new calFilterProperties(); + + if (!aFilter) { + return props; + } + + switch (aFilter) { + + // Predefined Task filters + case "notstarted": + props.status = props.FILTER_STATUS_INCOMPLETE; + props.due = props.FILTER_DUE_ALL; + props.start = props.FILTER_DATE_ALL; + props.end = props.FILTER_DATE_SELECTED_OR_NOW; + break; + case "overdue": + props.status = props.FILTER_STATUS_INCOMPLETE | props.FILTER_STATUS_IN_PROGRESS; + props.due = props.FILTER_DUE_PAST; + props.start = props.FILTER_DATE_ALL; + props.end = props.FILTER_DATE_SELECTED_OR_NOW; + break; + case "open": + props.status = props.FILTER_STATUS_INCOMPLETE | props.FILTER_STATUS_IN_PROGRESS; + props.due = props.FILTER_DUE_ALL; + props.start = props.FILTER_DATE_ALL; + props.end = props.FILTER_DATE_ALL; + props.occurrences = props.FILTER_OCCURRENCES_PAST_AND_NEXT; + break; + case "completed": + props.status = props.FILTER_STATUS_COMPLETED_TODAY | props.FILTER_STATUS_COMPLETED_BEFORE; + props.due = props.FILTER_DUE_ALL; + props.start = props.FILTER_DATE_ALL; + props.end = props.FILTER_DATE_SELECTED_OR_NOW; + break; + case "throughcurrent": + props.status = props.FILTER_STATUS_INCOMPLETE | props.FILTER_STATUS_IN_PROGRESS | + props.FILTER_STATUS_COMPLETED_TODAY; + props.due = props.FILTER_DUE_ALL; + props.start = props.FILTER_DATE_ALL; + props.end = props.FILTER_DATE_SELECTED_OR_NOW; + break; + case "throughtoday": + props.status = props.FILTER_STATUS_INCOMPLETE | props.FILTER_STATUS_IN_PROGRESS | + props.FILTER_STATUS_COMPLETED_TODAY; + props.due = props.FILTER_DUE_ALL; + props.start = props.FILTER_DATE_ALL; + props.end = props.FILTER_DATE_TODAY; + break; + case "throughsevendays": + props.status = props.FILTER_STATUS_INCOMPLETE | props.FILTER_STATUS_IN_PROGRESS | + props.FILTER_STATUS_COMPLETED_TODAY; + props.due = props.FILTER_DUE_ALL; + props.start = props.FILTER_DATE_ALL; + props.end = "P7D"; + break; + + // Predefined Event filters + case "today": + props.start = props.FILTER_DATE_TODAY; + props.end = props.FILTER_DATE_TODAY; + break; + case "thisCalendarMonth": + props.start = props.FILTER_DATE_CURRENT_MONTH; + props.end = props.FILTER_DATE_CURRENT_MONTH; + break; + case "future": + props.start = props.FILTER_DATE_NOW; + props.end = props.FILTER_DATE_ALL; + break; + case "current": + props.start = props.FILTER_DATE_SELECTED; + props.end = props.FILTER_DATE_SELECTED; + break; + case "currentview": + props.start = props.FILTER_DATE_VIEW; + props.end = props.FILTER_DATE_VIEW; + break; + + case "all": + default: + props.status = props.FILTER_STATUS_ALL; + props.due = props.FILTER_DUE_ALL; + props.start = props.FILTER_DATE_ALL; + props.end = props.FILTER_DATE_ALL; + } + + return props; + }, + + /** + * Defines a set of filter properties so that they may be applied by the filter name. If + * the specified filter name is already defined, it's associated filter properties will be + * replaced. + * + * @param aFilterName The name to define the filter properties as. + * @param aFilterProperties The filter properties to define. + */ + defineFilter: function(aFilterName, aFilterProperties) { + if (!(aFilterProperties instanceof calFilterProperties)) { + return; + } + + this.mDefinedFilters[aFilterName] = aFilterProperties; + }, + + /** + * Returns the set of filter properties that were previously defined by a filter name. + * + * @param aFilter The filter name of the defined filter properties. + * @return The properties defined by the filter name, or null if + * the filter name was not previously defined. + */ + getDefinedFilterProperties: function(aFilter) { + if (aFilter in this.mDefinedFilters) { + return this.mDefinedFilters[aFilter].clone(); + } else { + return null; + } + }, + + /** + * Returns the filter name that a set of filter properties were previously defined as. + * + * @param aFilterProperties The filter properties previously defined. + * @return The name of the first filter name that the properties + * were defined as, or null if the filter properties were + * not previously defined. + */ + getDefinedFilterName: function(aFilterProperties) { + for (filter in this.mDefinedFilters) { + if (this.mDefinedFilters[filter].equals(aFilterProperties)) { + return filter; + } + } + return null; + }, + + /** + * Checks if the item matches the current filter text + * + * @param aItem The item to check. + * @return Returns true if the item matches the filter text or no + * filter text has been set, false otherwise. + */ + textFilter: function(aItem) { + if (!this.mFilterText) { + return true; + } + + let searchText = this.mFilterText.toLowerCase(); + + if (!searchText.length || searchText.match(/^\s*$/)) { + return true; + } + + // TODO: Support specifying which fields to search on + for (let field of ["SUMMARY", "DESCRIPTION", "LOCATION", "URL"]) { + let val = aItem.getProperty(field); + if (val && val.toLowerCase().includes(searchText)) { + return true; + } + } + + return aItem.getCategories({}).some(cat => cat.toLowerCase().includes(searchText)); + }, + + /** + * Checks if the item matches the current filter date range. + * + * @param aItem The item to check. + * @return Returns true if the item falls within the date range + * specified by mStartDate and mEndDate, false otherwise. + */ + dateRangeFilter: function(aItem) { + return checkIfInRange(aItem, this.mStartDate, this.mEndDate); + }, + + /** + * Checks if the item matches the currently applied filter properties. Filter properties + * with a value of null or that are not applicable to the item's type are not tested. + * + * @param aItem The item to check. + * @return Returns true if the item matches the filter properties + * currently applied, false otherwise. + */ + propertyFilter: function(aItem) { + let result; + let props = this.mFilterProperties; + if (!props) { + return false; + } + + // the today and tomorrow properties are precalculated in the updateFilterDates function + // for better performance when filtering batches of items. + let today = this.mToday; + if (!today) { + today = cal.now(); + today.isDate = true; + } + + let tomorrow = this.mTomorrow; + if (!tomorrow) { + tomorrow = today.clone(); + tomorrow.day++; + } + + // test the date range of the applied filter. + result = this.dateRangeFilter(aItem); + + // test the category property. If the property value is an array, only one category must + // match. + if (result && props.category) { + let cats = []; + + if (typeof props.category == "string") { + cats.push(props.category); + } else if (Array.isArray(props.category)) { + cats = props.category; + } + result = cats.some(cat => aItem.getCategories({}).includes(cat)); + } + + // test the status property. Only applies to tasks. + if (result && props.status != null && cal.isToDo(aItem)) { + let completed = aItem.isCompleted; + let current = !aItem.completedDate || today.compare(aItem.completedDate) <= 0; + let percent = aItem.percentComplete || 0; + + result = ((props.status & props.FILTER_STATUS_INCOMPLETE) || + !(!completed && (percent == 0))) && + ((props.status & props.FILTER_STATUS_IN_PROGRESS) || + !(!completed && (percent > 0))) && + ((props.status & props.FILTER_STATUS_COMPLETED_TODAY) || + !(completed && current)) && + ((props.status & props.FILTER_STATUS_COMPLETED_BEFORE) || + !(completed && !current)); + } + + // test the due property. Only applies to tasks. + if (result && props.due != null && cal.isToDo(aItem)) { + let due = aItem.dueDate; + let now = cal.now(); + + result = ((props.due & props.FILTER_DUE_PAST) || + !(due && (due.compare(now) < 0))) && + ((props.due & props.FILTER_DUE_TODAY) || + !(due && (due.compare(now) >= 0) && (due.compare(tomorrow) < 0))) && + ((props.due & props.FILTER_DUE_FUTURE) || + !(due && (due.compare(tomorrow) >= 0))) && + ((props.due & props.FILTER_DUE_NONE) || + !(due == null)); + } + + // Call the filter properties onfilter callback if set. The return value of the + // callback function will override the result of this function. + if (props.onfilter && typeof props.onfilter == "function") { + return props.onfilter(aItem, result, props, this); + } + + return result; + }, + + /** + * Calculates the date from a date filter property. + * + * @param prop The value of the date filter property to calculate for. May + * be a constant specifying a relative date range, or a string + * representing a duration offset from the current date time. + * @param start If true, the function will return the date value for the + * start of the relative date range, otherwise it will return the + * date value for the end of the date range. + * @return The calculated date for the property. + */ + getDateForProperty: function(prop, start) { + let props = this.mFilterProperties || new calFilterProperties(); + let result = null; + let selectedDate = this.mSelectedDate || currentView().selectedDay || cal.now(); + let nowDate = cal.now(); + + if (typeof prop == "string") { + let duration = cal.createDuration(prop); + if (duration) { + result = nowDate; + result.addDuration(duration); + } + } else { + switch (prop) { + case props.FILTER_DATE_ALL: + result = null; + break; + case props.FILTER_DATE_VIEW: + result = start ? currentView().startDay.clone() + : currentView().endDay.clone(); + break; + case props.FILTER_DATE_SELECTED: + result = selectedDate.clone(); + result.isDate = true; + break; + case props.FILTER_DATE_SELECTED_OR_NOW: { + result = selectedDate.clone(); + let resultJSDate = cal.dateTimeToJsDate(result); + let nowJSDate = cal.dateTimeToJsDate(nowDate); + if ((start && resultJSDate > nowJSDate) || + (!start && resultJSDate < nowJSDate)) { + result = nowDate; + } + result.isDate = true; + break; + } + case props.FILTER_DATE_NOW: + result = nowDate; + break; + case props.FILTER_DATE_TODAY: + result = nowDate; + result.isDate = true; + break; + case props.FILTER_DATE_CURRENT_WEEK: + result = start ? nowDate.startOfWeek : nowDate.endOfWeek; + break; + case props.FILTER_DATE_CURRENT_MONTH: + result = start ? nowDate.startOfMonth : nowDate.endOfMonth; + break; + case props.FILTER_DATE_CURRENT_YEAR: + result = start ? nowDate.startOfYear : nowDate.endOfYear; + break; + } + + // date ranges are inclusive, so we need to include the day for the end date + if (!start && result && prop != props.FILTER_DATE_NOW) { + result.day++; + } + } + + return result; + }, + + /** + * Calculates the current start and end dates for the currently applied filter. + * + * @return The current [startDate, endDate] for the applied filter. + */ + getDatesForFilter: function() { + let startDate = null; + let endDate = null; + + if (this.mFilterProperties) { + startDate = this.getDateForProperty(this.mFilterProperties.start, true); + endDate = this.getDateForProperty(this.mFilterProperties.end, false); + + // swap the start and end dates if necessary + if (startDate && endDate && startDate.compare(endDate) > 0) { + let swap = startDate; + endDate = startDate; + startDate = swap; + } + } + + return [startDate, endDate]; + }, + + /** + * Gets the start date for the current filter date range. + * + * @return: The start date of the current filter date range, or null if + * the date range has an unbound start date. + */ + get startDate() { + return this.mStartDate; + }, + + /** + * Sets the start date for the current filter date range. This will override the date range + * calculated from the filter properties by the getDatesForFilter function. + */ + set startDate(aStartDate) { + return (this.mStartDate = aStartDate); + }, + + /** + * Gets the end date for the current filter date range. + * + * @return: The end date of the current filter date range, or null if + * the date range has an unbound end date. + */ + get endDate() { + return this.mEndDate; + }, + + /** + * Sets the end date for the current filter date range. This will override the date range + * calculated from the filter properties by the getDatesForFilter function. + */ + set endDate(aEndDate) { + return (this.mEndDate = aEndDate); + }, + + /** + * Gets the value used to perform the text filter. + */ + get filterText() { + return this.mFilterText; + }, + + /** + * Sets the value used to perform the text filter. + * + * @param aValue The string value to use for the text filter. + */ + set filterText(aValue) { + return (this.mFilterText = aValue); + }, + + /** + * Gets the selected date used by the getDatesForFilter function to calculate date ranges + * that are relative to the selected date. + */ + get selectedDate() { + return this.mSelectedDate; + }, + + /** + * Sets the selected date used by the getDatesForFilter function to calculate date ranges + * that are relative to the selected date. + */ + set selectedDate(aSelectedDate) { + return (this.mSelectedDate = aSelectedDate); + }, + + /** + * Gets the currently applied filter properties. + * + * @return The currently applied filter properties. + */ + get filterProperties() { + return this.mFilterProperties ? this.mFilterProperties.clone() : null; + }, + + /** + * Gets the name of the currently applied filter. + * + * @return The current defined name of the currently applied filter + * properties, or null if the current properties were not + * previously defined. + */ + get filterName() { + if (!this.mFilterProperties) { + return null; + } + + return this.getDefinedFilterName(this.mFilterProperties); + }, + + /** + * Applies the specified filter. + * + * @param aFilter The filter to apply. May be one of the following types: + * - a calFilterProperties object specifying the filter properties + * - a String representing a previously defined filter name + * - a String representing a duration offset from now + * - a Function to use for the onfilter callback for a custom filter + */ + applyFilter: function(aFilter) { + this.mFilterProperties = null; + + if (typeof aFilter == "string") { + if (aFilter in this.mDefinedFilters) { + this.mFilterProperties = this.getDefinedFilterProperties(aFilter); + } else { + let dur = cal.createDuration(aFilter); + if (dur.inSeconds > 0) { + this.mFilterProperties = new calFilterProperties(); + this.mFilterProperties.start = this.mFilterProperties.FILTER_DATE_NOW; + this.mFilterProperties.end = aFilter; + } + } + } else if (typeof aFilter == "object" && (aFilter instanceof calFilterProperties)) { + this.mFilterProperties = aFilter; + } else if (typeof aFilter == "function") { + this.mFilterProperties = new calFilterProperties(); + this.mFilterProperties.onfilter = aFilter; + } else { + this.mFilterProperties = new calFilterProperties(); + } + + if (this.mFilterProperties) { + this.updateFilterDates(); + // this.mFilterProperties.LOG("Applying filter:"); + } else { + cal.WARN("[calFilter] Unable to apply filter " + aFilter); + } + }, + + /** + * Calculates the current start and end dates for the currently applied filter, and updates + * the current filter start and end dates. This function can be used to update the date range + * for date range filters that are relative to the selected date or current date and time. + * + * @return The current [startDate, endDate] for the applied filter. + */ + updateFilterDates: function() { + let [startDate, endDate] = this.getDatesForFilter(); + this.mStartDate = startDate; + this.mEndDate = endDate; + + // the today and tomorrow properties are precalculated here + // for better performance when filtering batches of items. + this.mToday = cal.now(); + this.mToday.isDate = true; + + this.mTomorrow = this.mToday.clone(); + this.mTomorrow.day++; + + return [startDate, endDate]; + }, + + /** + * Filters an array of items, returning a new array containing the items that match + * the currently applied filter properties and text filter. + * + * @param aItems The array of items to check. + * @param aCallback An optional callback function to be called with each item and + * the result of it's filter test. + * @return A new array containing the items that match the filters, or + * null if no filter has been applied. + */ + filterItems: function(aItems, aCallback) { + if (!this.mFilterProperties) { + return null; + } + + return aItems.filter(function(aItem) { + let result = this.propertyFilter(aItem) && this.textFilter(aItem); + + if (aCallback && typeof aCallback == "function") { + aCallback(aItem, result, this.mFilterProperties, this); + } + + return result; + }, this); + }, + + /** + * Checks if the item matches the currently applied filter properties and text filter. + * + * @param aItem The item to check. + * @return Returns true if the item matches the filters, + * false otherwise. + */ + isItemInFilters: function(aItem) { + return this.propertyFilter(aItem) && this.textFilter(aItem); + }, + + /** + * Finds the next occurrence of a repeating item that matches the currently applied + * filter properties. + * + * @param aItem The parent item to find the next occurrence of. + * @return Returns the next occurrence that matches the filters, + * or null if no match is found. + */ + getNextOccurrence: function(aItem) { + if (!aItem.recurrenceInfo) { + return this.isItemInFilters(aItem) ? aItem : null; + } + + let count = 0; + let start = cal.now(); + + // If the base item matches the filter, we need to check each future occurrence. + // Otherwise, we only need to check the exceptions. + if (this.isItemInFilters(aItem)) { + while (count++ < this.mMaxIterations) { + let next = aItem.recurrenceInfo.getNextOccurrence(start); + if (!next) { + // there are no more occurrences + return null; + } + if (this.isItemInFilters(next)) { + return next; + } + start = next.startDate || next.entryDate; + } + + // we've hit the maximum number of iterations without finding a match + cal.WARN("[calFilter] getNextOccurrence: reached maximum iterations for " + aItem.title); + return null; + } else { + // the parent item doesn't match the filter, we can return the first future exception + // that matches the filter + let exMatch = null; + aItem.recurrenceInfo.getExceptionIds({}).forEach(function(rID) { + let ex = aItem.recurrenceInfo.getExceptionFor(rID); + if (ex && cal.now().compare(ex.startDate || ex.entryDate) < 0 && + this.isItemInFilters(ex)) { + exMatch = ex; + } + }, this); + return exMatch; + } + }, + + /** + * Gets the occurrences of a repeating item that match the currently applied + * filter properties and date range. + * + * @param aItem The parent item to find occurrence of. + * @return Returns an array containing the occurrences that + * match the filters, an empty array if there are no + * matches, or null if the filter is not initialized. + */ + getOccurrences: function(aItem) { + if (!this.mFilterProperties) { + return null; + } + let props = this.mFilterProperties; + let occs; + + if (!aItem.recurrenceInfo || (!props.occurrences && !this.mEndDate) || + props.occurrences == props.FILTER_OCCURRENCES_NONE) { + // either this isn't a repeating item, the occurrence filter specifies that + // we don't want occurrences, or we have a default occurrence filter with an + // unbound date range, so we return just the unexpanded item. + occs = [aItem]; + } else { + occs = aItem.getOccurrencesBetween(this.mStartDate || cal.createDateTime(), + this.mEndDate || cal.now(), {}); + if ((props.occurrences == props.FILTER_OCCURRENCES_PAST_AND_NEXT) && + !this.mEndDate) { + // we have an unbound date range and the occurrence filter specifies + // that we also want the next matching occurrence if available. + let next = this.getNextOccurrence(aItem); + if (next) { + occs.push(next); + } + } + } + + return this.filterItems(occs); + }, + + /** + * Gets the items matching the currently applied filter properties from a calendar. + * This function is asynchronous, and returns results to a calIOperationListener object. + * + * @param aCalendar The calendar to get items from. + * @param aItemType The type of items to get, as defined by the calICalendar + * interface ITEM_FILTER_TYPE_XXX constants. + * @param aListener The calIOperationListener object to return results to. + * @return the calIOperation handle to track the operation. + */ + getItems: function(aCalendar, aItemType, aListener) { + if (!this.mFilterProperties) { + return null; + } + let props = this.mFilterProperties; + + // we use a local proxy listener for the calICalendar.getItems() call, and use it + // to handle occurrence expansion and filter the results before forwarding them to + // the listener passed in the aListener argument. + let self = this; + let listener = { + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calIOperationListener]), + onOperationComplete: aListener.onOperationComplete.bind(aListener), + + onGetResult: function(aOpCalendar, aStatus, aOpItemType, aDetail, aCount, aItems) { + let items; + if (props.occurrences == props.FILTER_OCCURRENCES_PAST_AND_NEXT) { + // with the FILTER_OCCURRENCES_PAST_AND_NEXT occurrence filter we will + // get parent items returned here, so we need to let the getOccurrences + // function handle occurrence expansion. + items = []; + for (let item of aItems) { + items = items.concat(self.getOccurrences(item)); + } + } else { + // with other occurrence filters the calICalendar.getItems() function will + // return expanded occurrences appropriately, we only need to filter them. + items = self.filterItems(aItems); + } + + aListener.onGetResult(aOpCalendar, aStatus, aOpItemType, aDetail, items.length, items); + } + }; + + // build the filter argument for calICalendar.getItems() from the filter properties + let filter = aItemType || aCalendar.FILTER_TYPE_ALL; + if (!props.status || (props.status & (props.FILTER_STATUS_COMPLETED_TODAY | + props.FILTER_STATUS_COMPLETED_BEFORE))) { + filter |= aCalendar.ITEM_FILTER_COMPLETED_YES; + } + if (!props.status || (props.status & (props.FILTER_STATUS_INCOMPLETE | + props.FILTER_STATUS_IN_PROGRESS))) { + filter |= aCalendar.ITEM_FILTER_COMPLETED_NO; + } + + let startDate = this.startDate; + let endDate = this.endDate; + + // we only want occurrences returned from calICalendar.getItems() with a default + // occurence filter property and a bound date range, otherwise the local listener + // will handle occurrence expansion. + if (!props.occurrences && this.endDate) { + filter |= aCalendar.ITEM_FILTER_CLASS_OCCURRENCES; + startDate = startDate || cal.createDateTime(); + endDate = endDate || cal.now(); + } + + return aCalendar.getItems(filter, 0, startDate, endDate, listener); + } +}; diff --git a/calendar/base/src/calFreeBusyService.js b/calendar/base/src/calFreeBusyService.js new file mode 100644 index 000000000..5e40b01f3 --- /dev/null +++ b/calendar/base/src/calFreeBusyService.js @@ -0,0 +1,94 @@ +/* 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/. */ + +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + +function calFreeBusyListener(numOperations, finalListener) { + this.mFinalListener = finalListener; + this.mNumOperations = numOperations; + + this.opGroup = new calOperationGroup(() => { + this.notifyResult(null); + }); +} +calFreeBusyListener.prototype = { + mFinalListener: null, + mNumOperations: 0, + opGroup: null, + + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calIGenericOperationListener]), + + notifyResult: function(result) { + let listener = this.mFinalListener; + if (listener) { + if (!this.opGroup.isPending) { + this.mFinalListener = null; + } + listener.onResult(this.opGroup, result); + } + }, + + // calIGenericOperationListener: + onResult: function(aOperation, aResult) { + if (this.mFinalListener) { + if (!aOperation || !aOperation.isPending) { + --this.mNumOperations; + if (this.mNumOperations == 0) { + this.opGroup.notifyCompleted(); + } + } + let opStatus = aOperation ? aOperation.status : Components.results.NS_OK; + if (Components.isSuccessCode(opStatus) && + aResult && Array.isArray(aResult)) { + this.notifyResult(aResult); + } else { + this.notifyResult([]); + } + } + } +}; + +function calFreeBusyService() { + this.wrappedJSObject = this; + this.mProviders = new calInterfaceBag(Components.interfaces.calIFreeBusyProvider); +} +var calFreeBusyServiceClassID = Components.ID("{29c56cd5-d36e-453a-acde-0083bd4fe6d3}"); +var calFreeBusyServiceInterfaces = [ + Components.interfaces.calIFreeBusyProvider, + Components.interfaces.calIFreeBusyService +]; +calFreeBusyService.prototype = { + mProviders: null, + + classID: calFreeBusyServiceClassID, + QueryInterface: XPCOMUtils.generateQI(calFreeBusyServiceInterfaces), + classInfo: XPCOMUtils.generateCI({ + classID: calFreeBusyServiceClassID, + contractID: "@mozilla.org/calendar/freebusy-service;1", + classDescription: "Calendar FreeBusy Service", + interfaces: calFreeBusyServiceInterfaces, + flags: Components.interfaces.nsIClassInfo.SINGLETON + }), + + // calIFreeBusyProvider: + getFreeBusyIntervals: function(aCalId, aRangeStart, aRangeEnd, aBusyTypes, aListener) { + let groupListener = new calFreeBusyListener(this.mProviders.size, aListener); + for (let provider of this.mProviders) { + let operation = provider.getFreeBusyIntervals(aCalId, aRangeStart, + aRangeEnd, + aBusyTypes, + groupListener); + groupListener.opGroup.add(operation); + } + return groupListener.opGroup; + }, + + // calIFreeBusyService: + addProvider: function(aProvider) { + this.mProviders.add(aProvider); + }, + removeProvider: function(aProvider) { + this.mProviders.remove(aProvider); + } +}; diff --git a/calendar/base/src/calIcsParser.js b/calendar/base/src/calIcsParser.js new file mode 100644 index 000000000..b72aea3b5 --- /dev/null +++ b/calendar/base/src/calIcsParser.js @@ -0,0 +1,342 @@ +/* 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/. */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://calendar/modules/calIteratorUtils.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + +function calIcsParser() { + this.wrappedJSObject = this; + this.mItems = []; + this.mParentlessItems = []; + this.mComponents = []; + this.mProperties = []; +} +var calIcsParserClassID = Components.ID("{6fe88047-75b6-4874-80e8-5f5800f14984}"); +var calIcsParserInterfaces = [Components.interfaces.calIIcsParser]; +calIcsParser.prototype = { + classID: calIcsParserClassID, + QueryInterface: XPCOMUtils.generateQI(calIcsParserInterfaces), + classInfo: XPCOMUtils.generateCI({ + classID: calIcsParserClassID, + contractID: "@mozilla.org/calendar/ics-parser;1", + classDescription: "Calendar ICS Parser", + interfaces: calIcsParserInterfaces, + flags: Components.interfaces.nsIClassInfo.THREADSAFE + }), + + processIcalComponent: function(rootComp, aAsyncParsing) { + let calComp; + // libical returns the vcalendar component if there is just one vcalendar. + // If there are multiple vcalendars, it returns an xroot component, with + // vcalendar children. We need to handle both cases. + if (rootComp) { + if (rootComp.componentType == "VCALENDAR") { + calComp = rootComp; + } else { + calComp = rootComp.getFirstSubcomponent("VCALENDAR"); + } + } + + if (!calComp) { + let message = "Parser Error. Could not find 'VCALENDAR' component.\n"; + try { + // we try to also provide the parsed component - if that fails due to an error in + // libical, we append the error message of the caught exception, which includes + // already a stack trace. + cal.ERROR(message + rootComp + "\n" + cal.STACK(10)); + } catch (e) { + cal.ERROR(message + e); + } + } + + let self = this; + let state = new parserState(this, aAsyncParsing); + + while (calComp) { + // Get unknown properties from the VCALENDAR + for (let prop of cal.ical.propertyIterator(calComp)) { + if (prop.propertyName != "VERSION" && prop.propertyName != "PRODID") { + this.mProperties.push(prop); + } + } + + for (let subComp of cal.ical.subcomponentIterator(calComp)) { + state.submit(subComp); + } + calComp = rootComp.getNextSubcomponent("VCALENDAR"); + } + + state.join(() => { + let fakedParents = {}; + // tag "exceptions", i.e. items with rid: + for (let item of state.excItems) { + let parent = state.uid2parent[item.id]; + + if (!parent) { // a parentless one, fake a master and override it's occurrence + parent = isEvent(item) ? createEvent() : createTodo(); + parent.id = item.id; + parent.setProperty("DTSTART", item.recurrenceId); + parent.setProperty("X-MOZ-FAKED-MASTER", "1"); // this tag might be useful in the future + parent.recurrenceInfo = cal.createRecurrenceInfo(parent); + fakedParents[item.id] = true; + state.uid2parent[item.id] = parent; + state.items.push(parent); + } + if (item.id in fakedParents) { + let rdate = Components.classes["@mozilla.org/calendar/recurrence-date;1"] + .createInstance(Components.interfaces.calIRecurrenceDate); + rdate.date = item.recurrenceId; + parent.recurrenceInfo.appendRecurrenceItem(rdate); + // we'll keep the parentless-API until we switch over using itip-process for import (e.g. in dnd code) + self.mParentlessItems.push(item); + } + + parent.recurrenceInfo.modifyException(item, true); + } + + if (Object.keys(state.tzErrors).length > 0) { + // Use an alert rather than a prompt because problems may appear in + // remote subscribed calendars the user cannot change. + if (Components.classes["@mozilla.org/alerts-service;1"]) { + let notifier = Components.classes["@mozilla.org/alerts-service;1"] + .getService(Components.interfaces.nsIAlertsService); + let title = calGetString("calendar", "TimezoneErrorsAlertTitle"); + let text = calGetString("calendar", "TimezoneErrorsSeeConsole"); + try { + notifier.showAlertNotification("", title, text, false, null, null, title); + } catch (e) { + // The notifier may not be available, e.g. on xpcshell tests + } + } + } + + // We are done, push the items to the parser and notify the listener + self.mItems = self.mItems.concat(state.items); + self.mComponents = self.mComponents.concat(state.extraComponents); + + if (aAsyncParsing) { + aAsyncParsing.onParsingComplete(Components.results.NS_OK, self); + } + }); + }, + + parseString: function(aICSString, aTzProvider, aAsyncParsing) { + if (aAsyncParsing) { + let self = this; + + // We are using two types of very similar listeners here: + // aAsyncParsing is a calIcsParsingListener that returns the ics + // parser containing the processed items. + // The listener passed to parseICSAsync is a calICsComponentParsingListener + // required by the ics service, that receives the parsed root component. + cal.getIcsService().parseICSAsync(aICSString, aTzProvider, { + onParsingComplete: function(rc, rootComp) { + if (Components.isSuccessCode(rc)) { + self.processIcalComponent(rootComp, aAsyncParsing); + } else { + cal.ERROR("Error Parsing ICS: " + rc); + aAsyncParsing.onParsingComplete(rc, self); + } + } + }); + } else { + this.processIcalComponent(cal.getIcsService().parseICS(aICSString, aTzProvider)); + } + }, + + parseFromStream: function(aStream, aTzProvider, aAsyncParsing) { + // Read in the string. Note that it isn't a real string at this point, + // because likely, the file is utf8. The multibyte chars show up as multiple + // 'chars' in this string. So call it an array of octets for now. + + let octetArray = []; + let binaryIS = Components.classes["@mozilla.org/binaryinputstream;1"] + .createInstance(Components.interfaces.nsIBinaryInputStream); + binaryIS.setInputStream(aStream); + octetArray = binaryIS.readByteArray(binaryIS.available()); + + // Some other apps (most notably, sunbird 0.2) happily splits an UTF8 + // character between the octets, and adds a newline and space between them, + // for ICS folding. Unfold manually before parsing the file as utf8.This is + // UTF8 safe, because octets with the first bit 0 are always one-octet + // characters. So the space or the newline never can be part of a multi-byte + // char. + for (let i = octetArray.length - 2; i >= 0; i--) { + if (octetArray[i] == "\n" && octetArray[i + 1] == " ") { + octetArray = octetArray.splice(i, 2); + } + } + + // Interpret the byte-array as a UTF8-string, and convert into a + // javascript string. + let unicodeConverter = Components.classes["@mozilla.org/intl/scriptableunicodeconverter"] + .createInstance(Components.interfaces.nsIScriptableUnicodeConverter); + // ICS files are always UTF8 + unicodeConverter.charset = "UTF-8"; + let stringData = unicodeConverter.convertFromByteArray(octetArray, octetArray.length); + + this.parseString(stringData, aTzProvider, aAsyncParsing); + }, + + getItems: function(aCount) { + aCount.value = this.mItems.length; + return this.mItems.concat([]); + }, + + getParentlessItems: function(aCount) { + aCount.value = this.mParentlessItems.length; + return this.mParentlessItems.concat([]); + }, + + getProperties: function(aCount) { + aCount.value = this.mProperties.length; + return this.mProperties.concat([]); + }, + + getComponents: function(aCount) { + aCount.value = this.mComponents.length; + return this.mComponents.concat([]); + } +}; + +/** + * The parser state, which helps process ical components without clogging up the + * event queue. + * + * @param aParser The parser that is using this state + */ +function parserState(aParser, aListener) { + this.parser = aParser; + this.listener = aListener; + + this.extraComponents = []; + this.items = []; + this.uid2parent = {}; + this.excItems = []; + this.tzErrors = {}; +} + +parserState.prototype = { + parser: null, + joinFunc: null, + threadCount: 0, + + extraComponents: null, + items: null, + uid2parent: null, + excItems: null, + tzErrors: null, + listener: null, + + /** + * Checks if the timezones are missing and notifies the user via error console + * + * @param item The item to check for + * @param date The datetime object to check with + */ + checkTimezone: function(item, date) { + if (date && cal.isPhantomTimezone(date.timezone)) { + let tzid = date.timezone.tzid; + let hid = item.hashId + "#" + tzid; + if (!(hid in this.tzErrors)) { + // For now, publish errors to console and alert user. + // In future, maybe make them available through an interface method + // so this UI code can be removed from the parser, and caller can + // choose whether to alert, or show user the problem items and ask + // for fixes, or something else. + let msgArgs = [tzid, item.title, cal.getDateFormatter().formatDateTime(date)]; + let msg = calGetString("calendar", "unknownTimezoneInItem", msgArgs); + + cal.ERROR(msg + "\n" + item.icalString); + this.tzErrors[hid] = true; + } + } + }, + + /** + * Submit processing of a subcomponent to the event queue + * + * @param subComp The component to process + */ + submit: function(subComp) { + let self = this; + let runner = { + run: function() { + let item = null; + switch (subComp.componentType) { + case "VEVENT": + item = cal.createEvent(); + item.icalComponent = subComp; + self.checkTimezone(item, item.startDate); + self.checkTimezone(item, item.endDate); + break; + case "VTODO": + item = cal.createTodo(); + item.icalComponent = subComp; + self.checkTimezone(item, item.entryDate); + self.checkTimezone(item, item.dueDate); + // completed is defined to be in UTC + break; + case "VTIMEZONE": + // this should already be attached to the relevant + // events in the calendar, so there's no need to + // do anything with it here. + break; + default: + self.extraComponents.push(subComp); + break; + } + + if (item) { + let rid = item.recurrenceId; + if (rid) { + self.excItems.push(item); + } else { + self.items.push(item); + if (item.recurrenceInfo) { + self.uid2parent[item.id] = item; + } + } + } + self.threadCount--; + self.checkCompletion(); + } + }; + + this.threadCount++; + if (this.listener) { + // If we have a listener, we are doing this asynchronously. Go ahead + // and use the thread manager to dispatch the above runner + Services.tm.currentThread.dispatch(runner, Components.interfaces.nsIEventTarget.DISPATCH_NORMAL); + } else { + // No listener means synchonous. Just run the runner instead + runner.run(); + } + }, + + /** + * Checks if the processing of all events has completed. If a join function + * has been set, this function is called. + * + * @return True, if all tasks have been completed + */ + checkCompletion: function() { + if (this.joinFunc && this.threadCount == 0) { + this.joinFunc(); + return true; + } + return false; + }, + + /** + * Sets a join function that is called when all tasks have been completed + * + * @param joinFunc The join function to call + */ + join: function(joinFunc) { + this.joinFunc = joinFunc; + this.checkCompletion(); + } +}; diff --git a/calendar/base/src/calIcsSerializer.js b/calendar/base/src/calIcsSerializer.js new file mode 100644 index 000000000..e645c4e7e --- /dev/null +++ b/calendar/base/src/calIcsSerializer.js @@ -0,0 +1,83 @@ +/* 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/. */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://calendar/modules/calIteratorUtils.jsm"); + +function calIcsSerializer() { + this.wrappedJSObject = this; + this.mItems = []; + this.mProperties = []; + this.mComponents = []; +} +var calIcsSerializerClassID = Components.ID("{207a6682-8ff1-4203-9160-729ec28c8766}"); +var calIcsSerializerInterfaces = [Components.interfaces.calIIcsSerializer]; +calIcsSerializer.prototype = { + classID: calIcsSerializerClassID, + QueryInterface: XPCOMUtils.generateQI(calIcsSerializerInterfaces), + classInfo: XPCOMUtils.generateCI({ + classID: calIcsSerializerClassID, + contractID: "@mozilla.org/calendar/ics-serializer;1", + classDescription: "Calendar ICS Serializer", + interfaces: calIcsSerializerInterfaces, + }), + + addItems: function(aItems, aCount) { + if (aCount > 0) { + this.mItems = this.mItems.concat(aItems); + } + }, + + addProperty: function(aProperty) { + this.mProperties.push(aProperty); + }, + + addComponent: function(aComponent) { + this.mComponents.push(aComponent); + }, + + serializeToString: function() { + let calComp = this.getIcalComponent(); + return calComp.serializeToICS(); + }, + + serializeToInputStream: function(aStream) { + let calComp = this.getIcalComponent(); + return calComp.serializeToICSStream(); + }, + + serializeToStream: function(aStream) { + let str = this.serializeToString(); + + // Convert the javascript string to an array of bytes, using the + // UTF8 encoder + let convStream = Components.classes["@mozilla.org/intl/converter-output-stream;1"] + .createInstance(Components.interfaces.nsIConverterOutputStream); + convStream.init(aStream, "UTF-8", 0, 0x0000); + + convStream.writeString(str); + convStream.close(); + }, + + getIcalComponent: function() { + let calComp = getIcsService().createIcalComponent("VCALENDAR"); + calSetProdidVersion(calComp); + + // xxx todo: think about that the below code doesn't clone the properties/components, + // thus ownership is moved to returned VCALENDAR... + + for (let prop of this.mProperties) { + calComp.addProperty(prop); + } + for (let comp of this.mComponents) { + calComp.addSubcomponent(comp); + } + + for (let item of cal.itemIterator(this.mItems)) { + calComp.addSubcomponent(item.icalComponent); + } + + return calComp; + } +}; diff --git a/calendar/base/src/calInternalInterfaces.idl b/calendar/base/src/calInternalInterfaces.idl new file mode 100644 index 000000000..2170f3a58 --- /dev/null +++ b/calendar/base/src/calInternalInterfaces.idl @@ -0,0 +1,29 @@ +/* -*- Mode: idl; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* 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/. */ + +/** Don't use these if you're not the calendar glue code! **/ + +#include "nsISupports.idl" + +interface calIItemBase; +interface calIDateTime; + +[scriptable, uuid(1903648f-a0ee-4ae1-84b0-d8e8d0b10506)] +interface calIInternalShallowCopy : nsISupports +{ + /** + * create a proxy for this item; the returned item + * proxy will have parentItem set to this instance. + * + * @param aRecurrenceId RECURRENCE-ID of the proxy to be created + */ + calIItemBase createProxy(in calIDateTime aRecurrenceId); + + // used by recurrenceInfo when cloning proxy objects to + // avoid an infinite loop. aNewParent is optional, and is + // used to set the parent of the new item; it should be null + // if no new parent is passed in. + calIItemBase cloneShallow(in calIItemBase aNewParent); +}; diff --git a/calendar/base/src/calItemBase.js b/calendar/base/src/calItemBase.js new file mode 100644 index 000000000..bd113ca29 --- /dev/null +++ b/calendar/base/src/calItemBase.js @@ -0,0 +1,1135 @@ +/* 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/. */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://calendar/modules/calIteratorUtils.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + +/** + * calItemBase prototype definition + * + * @implements calIItemBase + * @constructor + */ +function calItemBase() { + cal.ASSERT(false, "Inheriting objects call initItemBase()!"); +} + +calItemBase.prototype = { + mProperties: null, + mPropertyParams: null, + + mIsProxy: false, + mHashId: null, + mImmutable: false, + mDirty: false, + mCalendar: null, + mParentItem: null, + mRecurrenceInfo: null, + mOrganizer: null, + + mAlarms: null, + mAlarmLastAck: null, + + mAttendees: null, + mAttachments: null, + mRelations: null, + mCategories: null, + + mACLEntry: null, + + /** + * Initialize the base item's attributes. Can be called from inheriting + * objects in their constructor. + */ + initItemBase: function() { + this.wrappedJSObject = this; + this.mProperties = new calPropertyBag(); + this.mPropertyParams = {}; + this.mProperties.setProperty("CREATED", cal.jsDateToDateTime(new Date())); + }, + + /** + * @see nsISupports + */ + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.calIItemBase]), + + /** + * @see calIItemBase + */ + get aclEntry() { + let aclEntry = this.mACLEntry; + let aclManager = this.calendar && this.calendar.superCalendar.aclManager; + + if (!aclEntry && aclManager) { + this.mACLEntry = aclManager.getItemEntry(this); + aclEntry = this.mACLEntry; + } + + if (!aclEntry && this.parentItem != this) { + // No ACL entry on this item, check the parent + aclEntry = this.parentItem.aclEntry; + } + + return aclEntry; + }, + + // readonly attribute AUTF8String hashId; + get hashId() { + if (this.mHashId === null) { + let rid = this.recurrenceId; + let calendar = this.calendar; + // some unused delim character: + this.mHashId = [encodeURIComponent(this.id), + rid ? rid.getInTimezone(UTC()).icalString : "", + calendar ? encodeURIComponent(calendar.id) : ""].join("#"); + } + return this.mHashId; + }, + + // attribute AUTF8String id; + get id() { + return this.getProperty("UID"); + }, + set id(uid) { + this.mHashId = null; // recompute hashId + this.setProperty("UID", uid); + if (this.mRecurrenceInfo) { + this.mRecurrenceInfo.onIdChange(uid); + } + return uid; + }, + + // attribute calIDateTime recurrenceId; + get recurrenceId() { + return this.getProperty("RECURRENCE-ID"); + }, + set recurrenceId(rid) { + this.mHashId = null; // recompute hashId + return this.setProperty("RECURRENCE-ID", rid); + }, + + // attribute calIRecurrenceInfo recurrenceInfo; + get recurrenceInfo() { + return this.mRecurrenceInfo; + }, + set recurrenceInfo(value) { + this.modify(); + return (this.mRecurrenceInfo = calTryWrappedJSObject(value)); + }, + + // attribute calIItemBase parentItem; + get parentItem() { + return this.mParentItem || this; + }, + set parentItem(value) { + if (this.mImmutable) { + throw Components.results.NS_ERROR_OBJECT_IS_IMMUTABLE; + } + return (this.mParentItem = calTryWrappedJSObject(value)); + }, + + /** + * Initializes the base item to be an item proxy. Used by inheriting + * objects createProxy() method. + * + * XXXdbo Explain proxy a bit better, either here or in + * calIInternalShallowCopy. + * + * @see calIInternalShallowCopy + * @param aParentItem The parent item to initialize the proxy on. + * @param aRecurrenceId The recurrence id to initialize the proxy for. + */ + initializeProxy: function(aParentItem, aRecurrenceId) { + this.mIsProxy = true; + + aParentItem = calTryWrappedJSObject(aParentItem); + this.mParentItem = aParentItem; + this.mCalendar = aParentItem.mCalendar; + this.recurrenceId = aRecurrenceId; + + // Make sure organizer is unset, as the getter checks for this. + this.mOrganizer = undefined; + + this.mImmutable = aParentItem.mImmutable; + }, + + // readonly attribute boolean isMutable; + get isMutable() { return !this.mImmutable; }, + + /** + * This function should be called by all members that modify the item. It + * checks if the item is immutable and throws accordingly, and sets the + * mDirty property. + */ + modify: function() { + if (this.mImmutable) { + throw Components.results.NS_ERROR_OBJECT_IS_IMMUTABLE; + } + this.mDirty = true; + }, + + /** + * Makes sure the item is not dirty. If the item is dirty, properties like + * LAST-MODIFIED and DTSTAMP are set to now. + */ + ensureNotDirty: function() { + if (this.mDirty) { + let now = cal.jsDateToDateTime(new Date()); + this.setProperty("LAST-MODIFIED", now); + this.setProperty("DTSTAMP", now); + this.mDirty = false; + } + }, + + /** + * Makes all properties of the base item immutable. Can be called by + * inheriting objects' makeImmutable method. + */ + makeItemBaseImmutable: function() { + if (this.mImmutable) { + return; + } + + // make all our components immutable + if (this.mRecurrenceInfo) { + this.mRecurrenceInfo.makeImmutable(); + } + + if (this.mOrganizer) { + this.mOrganizer.makeImmutable(); + } + if (this.mAttendees) { + for (let att of this.mAttendees) { + att.makeImmutable(); + } + } + + for (let [, propValue] of this.mProperties) { + if (propValue instanceof Components.interfaces.calIDateTime && + propValue.isMutable) { + propValue.makeImmutable(); + } + } + + if (this.mAlarms) { + for (let alarm of this.mAlarms) { + alarm.makeImmutable(); + } + } + + if (this.mAlarmLastAck) { + this.mAlarmLastAck.makeImmutable(); + } + + this.ensureNotDirty(); + this.mImmutable = true; + }, + + // boolean hasSameIds(in calIItemBase aItem); + hasSameIds: function(that) { + return that && this.id == that.id && + (this.recurrenceId == that.recurrenceId || // both null + (this.recurrenceId && that.recurrenceId && + this.recurrenceId.compare(that.recurrenceId) == 0)); + }, + + // calIItemBase clone(); + clone: function() { + return this.cloneShallow(this.mParentItem); + }, + + /** + * Clones the base item's properties into the passed object, potentially + * setting a new parent item. + * + * @param m The item to clone this item into + * @param aNewParent (optional) The new parent item to set on m. + */ + cloneItemBaseInto: function(cloned, aNewParent) { + cloned.mImmutable = false; + cloned.mACLEntry = this.mACLEntry; + cloned.mIsProxy = this.mIsProxy; + cloned.mParentItem = calTryWrappedJSObject(aNewParent) || this.mParentItem; + cloned.mHashId = this.mHashId; + cloned.mCalendar = this.mCalendar; + if (this.mRecurrenceInfo) { + cloned.mRecurrenceInfo = calTryWrappedJSObject(this.mRecurrenceInfo.clone()); + cloned.mRecurrenceInfo.item = cloned; + } + + let org = this.organizer; + if (org) { + org = org.clone(); + } + cloned.mOrganizer = org; + + cloned.mAttendees = []; + for (let att of this.getAttendees({})) { + cloned.mAttendees.push(att.clone()); + } + + cloned.mProperties = new calPropertyBag(); + for (let [name, value] of this.mProperties) { + if (value instanceof Components.interfaces.calIDateTime) { + value = value.clone(); + } + + cloned.mProperties.setProperty(name, value); + + let propBucket = this.mPropertyParams[name]; + if (propBucket) { + let newBucket = {}; + for (let param in propBucket) { + newBucket[param] = propBucket[param]; + } + cloned.mPropertyParams[name] = newBucket; + } + } + + cloned.mAttachments = []; + for (let att of this.getAttachments({})) { + cloned.mAttachments.push(att.clone()); + } + + cloned.mRelations = []; + for (let rel of this.getRelations({})) { + cloned.mRelations.push(rel.clone()); + } + + cloned.mCategories = this.getCategories({}); + + cloned.mAlarms = []; + for (let alarm of this.getAlarms({})) { + // Clone alarms into new item, assume the alarms from the old item + // are valid and don't need validation. + cloned.mAlarms.push(alarm.clone()); + } + + let alarmLastAck = this.alarmLastAck; + if (alarmLastAck) { + alarmLastAck = alarmLastAck.clone(); + } + cloned.mAlarmLastAck = alarmLastAck; + + cloned.mDirty = this.mDirty; + + return cloned; + }, + + // attribute calIDateTime alarmLastAck; + get alarmLastAck() { + return this.mAlarmLastAck; + }, + set alarmLastAck(aValue) { + this.modify(); + if (aValue && !aValue.timezone.isUTC) { + aValue = aValue.getInTimezone(UTC()); + } + return (this.mAlarmLastAck = aValue); + }, + + // readonly attribute calIDateTime lastModifiedTime; + get lastModifiedTime() { + this.ensureNotDirty(); + return this.getProperty("LAST-MODIFIED"); + }, + + // readonly attribute calIDateTime stampTime; + get stampTime() { + this.ensureNotDirty(); + return this.getProperty("DTSTAMP"); + }, + + // readonly attribute nsISimpleEnumerator propertyEnumerator; + get propertyEnumerator() { + if (this.mIsProxy) { + cal.ASSERT(this.parentItem != this); + return { // nsISimpleEnumerator: + mProxyEnum: this.mProperties.enumerator, + mParentEnum: this.mParentItem.propertyEnumerator, + mHandledProps: { }, + mCurrentProp: null, + + hasMoreElements: function() { + if (this.mCurrentProp) { + return true; + } + if (this.mProxyEnum) { + while (this.mProxyEnum.hasMoreElements()) { + let prop = this.mProxyEnum.getNext(); + this.mHandledProps[prop.name] = true; + if (prop.value !== null) { + this.mCurrentProp = prop; + return true; + } // else skip the deleted properties + } + this.mProxyEnum = null; + } + while (this.mParentEnum.hasMoreElements()) { + let prop = this.mParentEnum.getNext(); + if (!this.mHandledProps[prop.name]) { + this.mCurrentProp = prop; + return true; + } + } + return false; + }, + + getNext: function() { + if (!this.hasMoreElements()) { // hasMoreElements is called by intention to skip yet deleted properties + cal.ASSERT(false, Components.results.NS_ERROR_UNEXPECTED); + throw Components.results.NS_ERROR_UNEXPECTED; + } + let ret = this.mCurrentProp; + this.mCurrentProp = null; + return ret; + } + }; + } else { + return this.mProperties.enumerator; + } + }, + + // nsIVariant getProperty(in AString name); + getProperty: function(aName) { + aName = aName.toUpperCase(); + let aValue = this.mProperties.getProperty_(aName); + if (aValue === undefined) { + aValue = (this.mIsProxy ? this.mParentItem.getProperty(aName) : null); + } + return aValue; + }, + + // boolean hasProperty(in AString name); + hasProperty: function(aName) { + return (this.getProperty(aName.toUpperCase()) != null); + }, + + // void setProperty(in AString name, in nsIVariant value); + setProperty: function(aName, aValue) { + this.modify(); + aName = aName.toUpperCase(); + if (aValue || !isNaN(parseInt(aValue, 10))) { + this.mProperties.setProperty(aName, aValue); + } else { + this.deleteProperty(aName); + } + if (aName == "LAST-MODIFIED") { + // setting LAST-MODIFIED cleans/undirties the item, we use this for preserving DTSTAMP + this.mDirty = false; + } + }, + + // void deleteProperty(in AString name); + deleteProperty: function(aName) { + this.modify(); + aName = aName.toUpperCase(); + if (this.mIsProxy) { + // deleting a proxy's property will mark the bag's item as null, so we could + // distinguish it when enumerating/getting properties from the undefined ones. + this.mProperties.setProperty(aName, null); + } else { + this.mProperties.deleteProperty(aName); + } + delete this.mPropertyParams[aName]; + }, + + // AString getPropertyParameter(in AString aPropertyName, + // in AString aParameterName); + getPropertyParameter: function(aPropName, aParamName) { + let propName = aPropName.toUpperCase(); + let paramName = aParamName.toUpperCase(); + if (propName in this.mPropertyParams && paramName in this.mPropertyParams[propName]) { + // If the property is not in mPropertyParams, then this just means + // there are no properties set. + return this.mPropertyParams[propName][paramName]; + } + return null; + }, + + // boolean hasPropertyParameter(in AString aPropertyName, + // in AString aParameterName); + hasPropertyParameter: function(aPropName, aParamName) { + let propName = aPropName.toUpperCase(); + let paramName = aParamName.toUpperCase(); + return (propName in this.mPropertyParams) && + (paramName in this.mPropertyParams[propName]); + }, + + // void setPropertyParameter(in AString aPropertyName, + // in AString aParameterName, + // in AUTF8String aParameterValue); + setPropertyParameter: function(aPropName, aParamName, aParamValue) { + let propName = aPropName.toUpperCase(); + let paramName = aParamName.toUpperCase(); + this.modify(); + if (!(propName in this.mPropertyParams)) { + if (this.hasProperty(propName)) { + this.mPropertyParams[propName] = {}; + } else { + throw "Property " + aPropName + " not set"; + } + } + if (aParamValue || !isNaN(parseInt(aParamValue, 10))) { + this.mPropertyParams[propName][paramName] = aParamValue; + } else { + delete this.mPropertyParams[propName][paramName]; + } + return aParamValue; + }, + + // nsISimpleEnumerator getParameterEnumerator(in AString aPropertyName); + getParameterEnumerator: function(aPropName) { + let propName = aPropName.toUpperCase(); + if (!(propName in this.mPropertyParams)) { + throw "Property " + aPropName + " not set"; + } + let parameters = this.mPropertyParams[propName]; + return { // nsISimpleEnumerator + mParamNames: Object.keys(parameters), + hasMoreElements: function() { + return (this.mParamNames.length > 0); + }, + + getNext: function() { + let paramName = this.mParamNames.pop(); + return { // nsIProperty + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.nsIProperty]), + name: paramName, + value: parameters[paramName] + }; + } + }; + }, + + // void getAttendees(out PRUint32 count, + // [array,size_is(count),retval] out calIAttendee attendees); + getAttendees: function(countObj) { + if (!this.mAttendees && this.mIsProxy) { + this.mAttendees = this.mParentItem.getAttendees(countObj); + } + if (this.mAttendees) { + countObj.value = this.mAttendees.length; + return this.mAttendees.concat([]); // clone + } else { + countObj.value = 0; + return []; + } + }, + + // calIAttendee getAttendeeById(in AUTF8String id); + getAttendeeById: function(id) { + let attendees = this.getAttendees({}); + let lowerCaseId = id.toLowerCase(); + for (let attendee of attendees) { + // This match must be case insensitive to deal with differing + // cases of things like MAILTO: + if (attendee.id.toLowerCase() == lowerCaseId) { + return attendee; + } + } + return null; + }, + + // void removeAttendee(in calIAttendee attendee); + removeAttendee: function(attendee) { + this.modify(); + let found = false, newAttendees = []; + let attendees = this.getAttendees({}); + let attIdLowerCase = attendee.id.toLowerCase(); + + for (let i = 0; i < attendees.length; i++) { + if (attendees[i].id.toLowerCase() == attIdLowerCase) { + found = true; + } else { + newAttendees.push(attendees[i]); + } + } + if (found) { + this.mAttendees = newAttendees; + } + }, + + // void removeAllAttendees(); + removeAllAttendees: function() { + this.modify(); + this.mAttendees = []; + }, + + // void addAttendee(in calIAttendee attendee); + addAttendee: function(attendee) { + // the duplicate check is migration code for bug 1204255 + let exists = this.getAttendeeById(attendee.id); + if (exists) { + cal.LOG("Ignoring attendee duplicate for item " + this.id + + " (" + this.title + "): " + exists.id); + if (exists.participationStatus == "NEEDS-ACTION" || + attendee.participationStatus == "DECLINED") { + this.removeAttendee(exists); + } else { + attendee = null; + } + } + if (attendee) { + if (attendee.commonName) { + // migration code for bug 1209399 to remove leading/training double quotes in + let commonName = attendee.commonName.replace(/^["]*([^"]*)["]*$/, "$1"); + if (commonName.length == 0) { + commonName = null; + } + if (commonName != attendee.commonName) { + if (attendee.isMutable) { + attendee.commonName = commonName; + } else { + cal.LOG("Failed to cleanup malformed commonName for immutable attendee " + + attendee.toString() + "\n" + cal.STACK(20)); + } + } + } + this.modify(); + this.mAttendees = this.getAttendees({}); + this.mAttendees.push(attendee); + } + }, + + // void getAttachments(out PRUint32 count, + // [array,size_is(count),retval] out calIAttachment attachments); + getAttachments: function(aCount) { + if (!this.mAttachments && this.mIsProxy) { + this.mAttachments = this.mParentItem.getAttachments(aCount); + } + if (this.mAttachments) { + aCount.value = this.mAttachments.length; + return this.mAttachments.concat([]); // clone + } else { + aCount.value = 0; + return []; + } + }, + + // void removeAttachment(in calIAttachment attachment); + removeAttachment: function(aAttachment) { + this.modify(); + for (let attIndex in this.mAttachments) { + if (cal.compareObjects(this.mAttachments[attIndex], aAttachment, Components.interfaces.calIAttachment)) { + this.modify(); + this.mAttachments.splice(attIndex, 1); + break; + } + } + }, + + // void addAttachment(in calIAttachment attachment); + addAttachment: function(attachment) { + this.modify(); + this.mAttachments = this.getAttachments({}); + if (!this.mAttachments.some(x => x.hashId == attachment.hashId)) { + this.mAttachments.push(attachment); + } + }, + + // void removeAllAttachments(); + removeAllAttachments: function() { + this.modify(); + this.mAttachments = []; + }, + + // void getRelations(out PRUint32 count, + // [array,size_is(count),retval] out calIRelation relations); + getRelations: function(aCount) { + if (!this.mRelations && this.mIsProxy) { + this.mRelations = this.mParentItem.getRelations(aCount); + } + if (this.mRelations) { + aCount.value = this.mRelations.length; + return this.mRelations.concat([]); + } else { + aCount.value = 0; + return []; + } + }, + + // void removeRelation(in calIRelation relation); + removeRelation: function(aRelation) { + this.modify(); + for (let attIndex in this.mRelations) { + // Could we have the same item as parent and as child ? + if (this.mRelations[attIndex].relId == aRelation.relId && + this.mRelations[attIndex].relType == aRelation.relType) { + this.modify(); + this.mRelations.splice(attIndex, 1); + break; + } + } + }, + + // void addRelation(in calIRelation relation); + addRelation: function(aRelation) { + this.modify(); + this.mRelations = this.getRelations({}); + this.mRelations.push(aRelation); + // XXX ensure that the relation isn't already there? + }, + + // void removeAllRelations(); + removeAllRelations: function() { + this.modify(); + this.mRelations = []; + }, + + // attribute calICalendar calendar; + get calendar() { + if (!this.mCalendar && (this.parentItem != this)) { + return this.parentItem.calendar; + } else { + return this.mCalendar; + } + }, + set calendar(calendar) { + if (this.mImmutable) { + throw Components.results.NS_ERROR_OBJECT_IS_IMMUTABLE; + } + this.mHashId = null; // recompute hashId + this.mCalendar = calendar; + }, + + // attribute calIAttendee organizer; + get organizer() { + if (this.mIsProxy && (this.mOrganizer === undefined)) { + return this.mParentItem.organizer; + } else { + return this.mOrganizer; + } + }, + set organizer(organizer) { + this.modify(); + this.mOrganizer = organizer; + }, + + // void getCategories(out PRUint32 aCount, + // [array, size_is(aCount), retval] out wstring aCategories); + getCategories: function(aCount) { + if (!this.mCategories && this.mIsProxy) { + this.mCategories = this.mParentItem.getCategories(aCount); + } + if (this.mCategories) { + aCount.value = this.mCategories.length; + return this.mCategories.concat([]); // clone + } else { + aCount.value = 0; + return []; + } + }, + + // void setCategories(in PRUint32 aCount, + // [array, size_is(aCount)] in wstring aCategories); + setCategories: function(aCount, aCategories) { + this.modify(); + this.mCategories = aCategories.concat([]); + }, + + // attribute AUTF8String icalString; + get icalString() { + throw Components.results.NS_ERROR_NOT_IMPLEMENTED; + }, + set icalString(str) { + throw Components.results.NS_ERROR_NOT_IMPLEMENTED; + }, + + /** + * The map of promoted properties is a list of those properties that are + * represented directly by getters/setters. + * All of these property names must be in upper case isPropertyPromoted to + * function correctly. The has/get/set/deleteProperty interfaces + * are case-insensitive, but these are not. + */ + itemBasePromotedProps: { + "CREATED": true, + "UID": true, + "LAST-MODIFIED": true, + "SUMMARY": true, + "PRIORITY": true, + "STATUS": true, + "DTSTAMP": true, + "RRULE": true, + "EXDATE": true, + "RDATE": true, + "ATTENDEE": true, + "ATTACH": true, + "CATEGORIES": true, + "ORGANIZER": true, + "RECURRENCE-ID": true, + "X-MOZ-LASTACK": true, + "RELATED-TO": true + }, + + /** + * A map of properties that need translation between the ical component + * property and their ICS counterpart. + */ + icsBasePropMap: [ + { cal: "CREATED", ics: "createdTime" }, + { cal: "LAST-MODIFIED", ics: "lastModified" }, + { cal: "DTSTAMP", ics: "stampTime" }, + { cal: "UID", ics: "uid" }, + { cal: "SUMMARY", ics: "summary" }, + { cal: "PRIORITY", ics: "priority" }, + { cal: "STATUS", ics: "status" }, + { cal: "RECURRENCE-ID", ics: "recurrenceId" } + ], + + /** + * Walks through the propmap and sets all properties on this item from the + * given icalcomp. + * + * @param icalcomp The calIIcalComponent to read from. + * @param propmap The property map to walk through. + */ + mapPropsFromICS: function(icalcomp, propmap) { + for (let i = 0; i < propmap.length; i++) { + let prop = propmap[i]; + let val = icalcomp[prop.ics]; + if (val != null && val != Components.interfaces.calIIcalComponent.INVALID_VALUE) { + this.setProperty(prop.cal, val); + } + } + }, + + /** + * Walks through the propmap and sets all properties on the given icalcomp + * from the properties set on this item. + * given icalcomp. + * + * @param icalcomp The calIIcalComponent to write to. + * @param propmap The property map to walk through. + */ + mapPropsToICS: function(icalcomp, propmap) { + for (let i = 0; i < propmap.length; i++) { + let prop = propmap[i]; + let val = this.getProperty(prop.cal); + if (val != null && val != Components.interfaces.calIIcalComponent.INVALID_VALUE) { + icalcomp[prop.ics] = val; + } + } + }, + + + /** + * Reads an ical component and sets up the base item's properties to match + * it. + * + * @param icalcomp The ical component to read. + */ + setItemBaseFromICS: function(icalcomp) { + this.modify(); + + // re-initializing from scratch -- no light proxy anymore: + this.mIsProxy = false; + this.mProperties = new calPropertyBag(); + this.mPropertyParams = {}; + + this.mapPropsFromICS(icalcomp, this.icsBasePropMap); + + this.mAttendees = []; // don't inherit anything from parent + for (let attprop of cal.ical.propertyIterator(icalcomp, "ATTENDEE")) { + let att = new calAttendee(); + att.icalProperty = attprop; + this.addAttendee(att); + } + + this.mAttachments = []; // don't inherit anything from parent + for (let attprop of cal.ical.propertyIterator(icalcomp, "ATTACH")) { + let att = new calAttachment(); + att.icalProperty = attprop; + this.addAttachment(att); + } + + this.mRelations = []; // don't inherit anything from parent + for (let relprop of cal.ical.propertyIterator(icalcomp, "RELATED-TO")) { + let rel = new calRelation(); + rel.icalProperty = relprop; + this.addRelation(rel); + } + + let org = null; + let orgprop = icalcomp.getFirstProperty("ORGANIZER"); + if (orgprop) { + org = new calAttendee(); + org.icalProperty = orgprop; + org.isOrganizer = true; + } + this.mOrganizer = org; + + this.mCategories = []; + for (let catprop of cal.ical.propertyIterator(icalcomp, "CATEGORIES")) { + this.mCategories.push(catprop.value); + } + + // find recurrence properties + let rec = null; + if (!this.recurrenceId) { + for (let recprop of cal.ical.propertyIterator(icalcomp)) { + let ritem = null; + switch (recprop.propertyName) { + case "RRULE": + case "EXRULE": + ritem = cal.createRecurrenceRule(); + break; + case "RDATE": + case "EXDATE": + ritem = cal.createRecurrenceDate(); + break; + default: + continue; + } + ritem.icalProperty = recprop; + + if (!rec) { + rec = cal.createRecurrenceInfo(this); + } + rec.appendRecurrenceItem(ritem); + } + } + this.mRecurrenceInfo = rec; + + this.mAlarms = []; // don't inherit anything from parent + for (let alarmComp of cal.ical.subcomponentIterator(icalcomp, "VALARM")) { + let alarm = cal.createAlarm(); + try { + alarm.icalComponent = alarmComp; + this.addAlarm(alarm, true); + } catch (e) { + cal.ERROR("Invalid alarm for item: " + + this.id + " (" + + alarmComp.serializeToICS() + ")" + + " exception: " + e); + } + } + + let lastAck = icalcomp.getFirstProperty("X-MOZ-LASTACK"); + this.mAlarmLastAck = null; + if (lastAck) { + this.mAlarmLastAck = cal.createDateTime(lastAck.value); + } + + this.mDirty = false; + }, + + /** + * Import all properties not in the promoted map into this item's extended + * properties bag. + * + * @param icalcomp The ical component to read. + * @param promoted The map of promoted properties. + */ + importUnpromotedProperties: function(icalcomp, promoted) { + for (let prop of cal.ical.propertyIterator(icalcomp)) { + let propName = prop.propertyName; + if (!promoted[propName]) { + this.setProperty(propName, prop.value); + for (let [paramName, paramValue] of cal.ical.paramIterator(prop)) { + if (!(propName in this.mPropertyParams)) { + this.mPropertyParams[propName] = {}; + } + this.mPropertyParams[propName][paramName] = paramValue; + } + } + } + }, + + // boolean isPropertyPromoted(in AString name); + isPropertyPromoted: function(name) { + return this.itemBasePromotedProps[name.toUpperCase()]; + }, + + // attribute calIIcalComponent icalComponent; + get icalComponent() { + throw Components.results.NS_ERROR_NOT_IMPLEMENTED; + }, + set icalComponent(val) { + throw Components.results.NS_ERROR_NOT_IMPLEMENTED; + }, + + // attribute PRUint32 generation; + get generation() { + let gen = this.getProperty("X-MOZ-GENERATION"); + return (gen ? parseInt(gen, 10) : 0); + }, + set generation(aValue) { + return this.setProperty("X-MOZ-GENERATION", String(aValue)); + }, + + /** + * Fills the passed ical component with the base item's properties. + * + * @param icalcomp The ical component to write to. + */ + fillIcalComponentFromBase: function(icalcomp) { + this.ensureNotDirty(); + let icssvc = cal.getIcsService(); + + this.mapPropsToICS(icalcomp, this.icsBasePropMap); + + let org = this.organizer; + if (org) { + icalcomp.addProperty(org.icalProperty); + } + + for (let attendee of this.getAttendees({})) { + icalcomp.addProperty(attendee.icalProperty); + } + + for (let attachment of this.getAttachments({})) { + icalcomp.addProperty(attachment.icalProperty); + } + + for (let relation of this.getRelations({})) { + icalcomp.addProperty(relation.icalProperty); + } + + if (this.mRecurrenceInfo) { + for (let ritem of this.mRecurrenceInfo.getRecurrenceItems({})) { + icalcomp.addProperty(ritem.icalProperty); + } + } + + for (let cat of this.getCategories({})) { + let catprop = icssvc.createIcalProperty("CATEGORIES"); + catprop.value = cat; + icalcomp.addProperty(catprop); + } + + if (this.mAlarms) { + for (let alarm of this.mAlarms) { + icalcomp.addSubcomponent(alarm.icalComponent); + } + } + + let alarmLastAck = this.alarmLastAck; + if (alarmLastAck) { + let lastAck = cal.getIcsService().createIcalProperty("X-MOZ-LASTACK"); + // - should we further ensure that those are UTC or rely on calAlarmService doing so? + lastAck.value = alarmLastAck.icalString; + icalcomp.addProperty(lastAck); + } + }, + + // void getAlarms(out PRUint32 count, [array, size_is(count), retval] out calIAlarm aAlarms); + getAlarms: function(aCount) { + if (typeof aCount != "object") { + throw Components.results.NS_ERROR_XPC_NEED_OUT_OBJECT; + } + + if (!this.mAlarms && this.mIsProxy) { + this.mAlarms = this.mParentItem.getAlarms(aCount); + } + if (this.mAlarms) { + aCount.value = this.mAlarms.length; + return this.mAlarms.concat([]); // clone + } else { + aCount.value = 0; + return []; + } + }, + + /** + * Adds an alarm. The second parameter is for internal use only, i.e not + * provided on the interface. + * + * @see calIItemBase + * @param aDoNotValidate Don't serialize the component to check for + * errors. + */ + addAlarm: function(aAlarm, aDoNotValidate) { + if (!aDoNotValidate) { + try { + // Trigger the icalComponent getter to make sure the alarm is valid. + aAlarm.icalComponent; // eslint-disable-line no-unused-expressions + } catch (e) { + throw Components.results.NS_ERROR_INVALID_ARG; + } + } + + this.modify(); + this.mAlarms = this.getAlarms({}); + this.mAlarms.push(aAlarm); + }, + + // void deleteAlarm(in calIAlarm aAlarm); + deleteAlarm: function(aAlarm) { + this.modify(); + this.mAlarms = this.getAlarms({}); + for (let i = 0; i < this.mAlarms.length; i++) { + if (cal.compareObjects(this.mAlarms[i], aAlarm, Components.interfaces.calIAlarm)) { + this.mAlarms.splice(i, 1); + break; + } + } + }, + + // void clearAlarms(); + clearAlarms: function() { + this.modify(); + this.mAlarms = []; + }, + + // void getOccurrencesBetween (in calIDateTime aStartDate, in calIDateTime aEndDate, + // out PRUint32 aCount, + // [array,size_is(aCount),retval] out calIItemBase aOccurrences); + getOccurrencesBetween: function(aStartDate, aEndDate, aCount) { + if (this.recurrenceInfo) { + return this.recurrenceInfo.getOccurrences(aStartDate, aEndDate, 0, aCount); + } + + if (checkIfInRange(this, aStartDate, aEndDate)) { + aCount.value = 1; + return [this]; + } + + aCount.value = 0; + return []; + } +}; + +makeMemberAttr(calItemBase, "CREATED", null, "creationDate", true); +makeMemberAttr(calItemBase, "SUMMARY", null, "title", true); +makeMemberAttr(calItemBase, "PRIORITY", 0, "priority", true); +makeMemberAttr(calItemBase, "CLASS", "PUBLIC", "privacy", true); +makeMemberAttr(calItemBase, "STATUS", null, "status", true); +makeMemberAttr(calItemBase, "ALARMTIME", null, "alarmTime", true); + +makeMemberAttr(calItemBase, "mProperties", null, "properties"); + +/** + * Helper function to add a member attribute on the given prototype + * + * @param ctor The constructor function of the prototype + * @param varname The local variable name to get/set, or the property in + * case asProperty is true. + * @param dflt The default value in case none is set + * @param attr The attribute name to be used + * @param asProperty If true, getProperty will be used to get/set the + * member. + */ +function makeMemberAttr(ctor, varname, dflt, attr, asProperty) { + // XXX handle defaults! + let getter = function() { + if (asProperty) { + return this.getProperty(varname); + } else { + return (varname in this ? this[varname] : undefined); + } + }; + let setter = function(value) { + this.modify(); + if (asProperty) { + return this.setProperty(varname, value); + } else { + return (this[varname] = value); + } + }; + ctor.prototype.__defineGetter__(attr, getter); + ctor.prototype.__defineSetter__(attr, setter); +} diff --git a/calendar/base/src/calItemModule.js b/calendar/base/src/calItemModule.js new file mode 100644 index 000000000..d78905eb7 --- /dev/null +++ b/calendar/base/src/calItemModule.js @@ -0,0 +1,67 @@ +/* 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/. */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); + +var scriptLoadOrder = [ + "calItemBase.js", + "calUtils.js", + "calCachedCalendar.js", + + "calAlarm.js", + "calAlarmService.js", + "calAlarmMonitor.js", + "calAttendee.js", + "calAttachment.js", + "calCalendarManager.js", + "calCalendarSearchService.js", + "calDateTimeFormatter.js", + "calDeletedItems.js", + "calEvent.js", + "calFreeBusyService.js", + "calIcsParser.js", + "calIcsSerializer.js", + "calItipItem.js", + "calProtocolHandler.js", + "calRecurrenceDate.js", + "calRecurrenceInfo.js", + "calRelation.js", + "calStartupService.js", + "calTransactionManager.js", + "calTodo.js", + "calWeekInfoService.js" +]; + +function getComponents() { + Components.classes["@mozilla.org/calendar/backend-loader;1"].getService(); + + return [ + calAlarm, + calAlarmService, + calAlarmMonitor, + calAttendee, + calAttachment, + calCalendarManager, + calCalendarSearchService, + calDateTimeFormatter, + calDeletedItems, + calEvent, + calFreeBusyService, + calIcsParser, + calIcsSerializer, + calItipItem, + calProtocolHandlerWebcal, + calProtocolHandlerWebcals, + calRecurrenceDate, + calRecurrenceInfo, + calRelation, + calStartupService, + calTransaction, + calTransactionManager, + calTodo, + calWeekInfoService, + ]; +} + +this.NSGetFactory = cal.loadingNSGetFactory(scriptLoadOrder, getComponents, this); diff --git a/calendar/base/src/calItemModule.manifest b/calendar/base/src/calItemModule.manifest new file mode 100644 index 000000000..4826e4ed0 --- /dev/null +++ b/calendar/base/src/calItemModule.manifest @@ -0,0 +1,75 @@ +component {b8db7c7f-c168-4e11-becb-f26c1c4f5f8f} calItemModule.js +contract @mozilla.org/calendar/alarm;1 {b8db7c7f-c168-4e11-becb-f26c1c4f5f8f} + +component {7a9200dd-6a64-4fff-a798-c5802186e2cc} calItemModule.js +contract @mozilla.org/calendar/alarm-service;1 {7a9200dd-6a64-4fff-a798-c5802186e2cc} + +component {4b7ae030-ed79-11d9-8cd6-0800200c9a66} calItemModule.js +contract @mozilla.org/calendar/alarm-monitor;1 {4b7ae030-ed79-11d9-8cd6-0800200c9a66} +category alarm-service-startup calendar-alarm-monitor service,@mozilla.org/calendar/alarm-monitor;1 +category alarm-service-shutdown calendar-alarm-monitor service,@mozilla.org/calendar/alarm-monitor;1 + +component {5c8dcaa3-170c-4a73-8142-d531156f664d} calItemModule.js +contract @mozilla.org/calendar/attendee;1 {5c8dcaa3-170c-4a73-8142-d531156f664d} + +component {5f76b352-ab75-4c2b-82c9-9206dbbf8571} calItemModule.js +contract @mozilla.org/calendar/attachment;1 {5f76b352-ab75-4c2b-82c9-9206dbbf8571} + +component {f42585e7-e736-4600-985d-9624c1c51992} calItemModule.js +contract @mozilla.org/calendar/manager;1 {f42585e7-e736-4600-985d-9624c1c51992} + +component {f5f743cd-8997-428e-bc1b-644e73f61203} calItemModule.js +contract @mozilla.org/calendar/calendarsearch-service;1 {f5f743cd-8997-428e-bc1b-644e73f61203} + +component {4123da9a-f047-42da-a7d0-cc4175b9f36a} calItemModule.js +contract @mozilla.org/calendar/datetime-formatter;1 {4123da9a-f047-42da-a7d0-cc4175b9f36a} + +component {8e6799af-e7e9-4e6c-9a82-a2413e86d8c3} calItemModule.js +contract @mozilla.org/calendar/deleted-items-manager;1 {8e6799af-e7e9-4e6c-9a82-a2413e86d8c3} +category profile-after-change deleted-items-manager @mozilla.org/calendar/deleted-items-manager;1 + +component {974339d5-ab86-4491-aaaf-2b2ca177c12b} calItemModule.js +contract @mozilla.org/calendar/event;1 {974339d5-ab86-4491-aaaf-2b2ca177c12b} + +component {29c56cd5-d36e-453a-acde-0083bd4fe6d3} calItemModule.js +contract @mozilla.org/calendar/freebusy-service;1 {29c56cd5-d36e-453a-acde-0083bd4fe6d3} + +component {6fe88047-75b6-4874-80e8-5f5800f14984} calItemModule.js +contract @mozilla.org/calendar/ics-parser;1 {6fe88047-75b6-4874-80e8-5f5800f14984} + +component {207a6682-8ff1-4203-9160-729ec28c8766} calItemModule.js +contract @mozilla.org/calendar/ics-serializer;1 {207a6682-8ff1-4203-9160-729ec28c8766} + +component {f41392ab-dcad-4bad-818f-b3d1631c4d93} calItemModule.js +contract @mozilla.org/calendar/itip-item;1 {f41392ab-dcad-4bad-818f-b3d1631c4d93} + +component {1153c73a-39be-46aa-9ba9-656d188865ca} calItemModule.js +contract @mozilla.org/network/protocol;1?name=webcal {1153c73a-39be-46aa-9ba9-656d188865ca} + +component {bdf71224-365d-4493-856a-a7e74026f766} calItemModule.js +contract @mozilla.org/network/protocol;1?name=webcals {bdf71224-365d-4493-856a-a7e74026f766} + +component {806b6423-3aaa-4b26-afa3-de60563e9cec} calItemModule.js +contract @mozilla.org/calendar/recurrence-date;1 {806b6423-3aaa-4b26-afa3-de60563e9cec} + +component {04027036-5884-4a30-b4af-f2cad79f6edf} calItemModule.js +contract @mozilla.org/calendar/recurrence-info;1 {04027036-5884-4a30-b4af-f2cad79f6edf} + +component {76810fae-abad-4019-917a-08e95d5bbd68} calItemModule.js +contract @mozilla.org/calendar/relation;1 {76810fae-abad-4019-917a-08e95d5bbd68} + +component {2547331f-34c0-4a4b-b93c-b503538ba6d6} calItemModule.js +contract @mozilla.org/calendar/startup-service;1 {2547331f-34c0-4a4b-b93c-b503538ba6d6} +category profile-after-change calendar-startup-service @mozilla.org/calendar/startup-service;1 + +component {fcb54c82-2fb9-42cb-bf44-1e197a55e520} calItemModule.js +contract @mozilla.org/calendar/transaction;1 {fcb54c82-2fb9-42cb-bf44-1e197a55e520} + +component {40a1ccf4-5f54-4815-b842-abf06f84dbfd} calItemModule.js +contract @mozilla.org/calendar/transactionmanager;1 {40a1ccf4-5f54-4815-b842-abf06f84dbfd} + +component {7af51168-6abe-4a31-984d-6f8a3989212d} calItemModule.js +contract @mozilla.org/calendar/todo;1 {7af51168-6abe-4a31-984d-6f8a3989212d} + +component {6877bbdd-f336-46f5-98ce-fe86d0285cc1} calItemModule.js +contract @mozilla.org/calendar/weekinfo-service;1 {6877bbdd-f336-46f5-98ce-fe86d0285cc1} diff --git a/calendar/base/src/calItipItem.js b/calendar/base/src/calItipItem.js new file mode 100644 index 000000000..1a25f2a4c --- /dev/null +++ b/calendar/base/src/calItipItem.js @@ -0,0 +1,215 @@ +/* -*- Mode: javascript; tab-width: 20; indent-tabs-mode: nil; c-basic-offset: 4 -*- */ +/* 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/. */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://calendar/modules/calIteratorUtils.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + +/** + * Constructor of calItipItem object + */ +function calItipItem() { + this.wrappedJSObject = this; + this.mCurrentItemIndex = 0; +} +var calItipItemClassID = Components.ID("{f41392ab-dcad-4bad-818f-b3d1631c4d93}"); +var calItipItemInterfaces = [Components.interfaces.calIItipItem]; +calItipItem.prototype = { + mIsInitialized: false, + + classID: calItipItemClassID, + QueryInterface: XPCOMUtils.generateQI(calItipItemInterfaces), + classInfo: XPCOMUtils.generateCI({ + classID: calItipItemClassID, + contractID: "@mozilla.org/calendar/itip-item;1", + classDescription: "Calendar iTIP item", + interfaces: calItipItemInterfaces + }), + + mSender: null, + get sender() { + return this.mSender; + }, + set sender(aValue) { + return (this.mSender = aValue); + }, + + mIsSend: false, + get isSend() { + return this.mIsSend; + }, + set isSend(aValue) { + return (this.mIsSend = aValue); + }, + + mReceivedMethod: "REQUEST", + get receivedMethod() { + return this.mReceivedMethod; + }, + set receivedMethod(aMethod) { + return (this.mReceivedMethod = aMethod.toUpperCase()); + }, + + mResponseMethod: "REPLY", + get responseMethod() { + if (!this.mIsInitialized) { + throw Components.results.NS_ERROR_NOT_INITIALIZED; + } + return this.mResponseMethod; + }, + set responseMethod(aMethod) { + return (this.mResponseMethod = aMethod.toUpperCase()); + }, + + mAutoResponse: null, + get autoResponse() { + return this.mAutoResponse; + }, + set autoResponse(aValue) { + return (this.mAutoResponse = aValue); + }, + + mTargetCalendar: null, + get targetCalendar() { + return this.mTargetCalendar; + }, + set targetCalendar(aValue) { + return (this.mTargetCalendar = aValue); + }, + + mIdentity: null, + get identity() { + return this.mIdentity; + }, + set identity(aValue) { + return (this.mIdentity = aValue); + }, + + mLocalStatus: null, + get localStatus() { + return this.mLocalStatus; + }, + set localStatus(aValue) { + return (this.mLocalStatus = aValue); + }, + + mItemList: {}, + + init: function(aIcalString) { + let parser = Components.classes["@mozilla.org/calendar/ics-parser;1"] + .createInstance(Components.interfaces.calIIcsParser); + parser.parseString(aIcalString, null); + + // - User specific alarms as well as X-MOZ- properties are irrelevant w.r.t. iTIP messages, + // should not be sent out and should not be relevant for incoming messages + // - faked master items + // so clean them out: + + function cleanItem(item) { + // the following changes will bump LAST-MODIFIED/DTSTAMP, we want to preserve the originals: + let stamp = item.stampTime; + let lastModified = item.lastModifiedTime; + item.clearAlarms(); + item.alarmLastAck = null; + item.deleteProperty("RECEIVED-SEQUENCE"); + item.deleteProperty("RECEIVED-DTSTAMP"); + let propEnum = item.propertyEnumerator; + while (propEnum.hasMoreElements()) { + let prop = propEnum.getNext().QueryInterface(Components.interfaces.nsIProperty); + let pname = prop.name; + if (pname != "X-MOZ-FAKED-MASTER" && pname.substr(0, "X-MOZ-".length) == "X-MOZ-") { + item.deleteProperty(prop.name); + } + } + // never publish an organizer's RECEIVED params: + item.getAttendees({}).forEach((att) => { + att.deleteProperty("RECEIVED-SEQUENCE"); + att.deleteProperty("RECEIVED-DTSTAMP"); + }); + item.setProperty("DTSTAMP", stamp); + item.setProperty("LAST-MODIFIED", lastModified); // need to be last to undirty the item + } + + this.mItemList = []; + for (let item of cal.itemIterator(parser.getItems({}))) { + cleanItem(item); + // only push non-faked master items or + // the overridden instances of faked master items + // to the list: + if (item == item.parentItem) { + if (!item.hasProperty("X-MOZ-FAKED-MASTER")) { + this.mItemList.push(item); + } + } else if (item.parentItem.hasProperty("X-MOZ-FAKED-MASTER")) { + this.mItemList.push(item); + } + } + + // We set both methods now for safety's sake. It's the ItipProcessor's + // responsibility to properly ascertain what the correct response + // method is (using user feedback, prefs, etc.) for the given + // receivedMethod. The RFC tells us to treat items without a METHOD + // as if they were METHOD:REQUEST. + for (let prop of parser.getProperties({})) { + if (prop.propertyName == "METHOD") { + this.mReceivedMethod = prop.value; + this.mResponseMethod = prop.value; + break; + } + } + + this.mIsInitialized = true; + }, + + clone: function() { + let newItem = new calItipItem(); + newItem.mItemList = this.mItemList.map(item => item.clone()); + newItem.mReceivedMethod = this.mReceivedMethod; + newItem.mResponseMethod = this.mResponseMethod; + newItem.mAutoResponse = this.mAutoResponse; + newItem.mTargetCalendar = this.mTargetCalendar; + newItem.mIdentity = this.mIdentity; + newItem.mLocalStatus = this.mLocalStatus; + newItem.mSender = this.mSender; + newItem.mIsSend = this.mIsSend; + newItem.mIsInitialized = this.mIsInitialized; + return newItem; + }, + + /** + * This returns both the array and the number of items. An easy way to + * call it is: let itemArray = itipItem.getItemList({ }); + */ + getItemList: function(itemCountRef) { + if (!this.mIsInitialized) { + throw Components.results.NS_ERROR_NOT_INITIALIZED; + } + itemCountRef.value = this.mItemList.length; + return this.mItemList; + }, + + /** + * Note that this code forces the user to respond to all items in the same + * way, which is a current limitation of the spec. + */ + setAttendeeStatus: function(aAttendeeId, aStatus) { + // Append "mailto:" to the attendee if it is missing it. + if (!aAttendeeId.match(/^mailto:/i)) { + aAttendeeId = "mailto:" + aAttendeeId; + } + + for (let item of this.mItemList) { + let attendee = item.getAttendeeById(aAttendeeId); + if (attendee) { + // Replies should not have the RSVP property. + // XXX BUG 351589: workaround for updating an attendee + item.removeAttendee(attendee); + attendee = attendee.clone(); + attendee.rsvp = null; + item.addAttendee(attendee); + } + } + } +}; diff --git a/calendar/base/src/calProtocolHandler.js b/calendar/base/src/calProtocolHandler.js new file mode 100644 index 000000000..95a16af4a --- /dev/null +++ b/calendar/base/src/calProtocolHandler.js @@ -0,0 +1,96 @@ +/* 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/. */ + +Components.utils.import("resource://gre/modules/Services.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + +/** Constructor for webcal: protocol handler */ +function calProtocolHandlerWebcal() { + calProtocolHandler.call(this, "webcal"); +} + +/** Constructor for webcals: protocl handler */ +function calProtocolHandlerWebcals() { + calProtocolHandler.call(this, "webcals"); +} + +/** + * Generic webcal constructor + * + * @param scheme The scheme to init for (webcal, webcals) + */ +function calProtocolHandler(scheme) { + this.scheme = scheme; + this.mHttpProtocol = Services.io.getProtocolHandler(this.scheme == "webcal" ? "http" : "https"); + this.wrappedJSObject = this; +} + +calProtocolHandler.prototype = { + get defaultPort() { return this.mHttpProtocol.defaultPort; }, + get protocolFlags() { return this.mHttpProtocol.protocolFlags; }, + + newURI: function(aSpec, anOriginalCharset, aBaseURI) { + let uri = Components.classes["@mozilla.org/network/standard-url;1"] + .createInstance(Components.interfaces.nsIStandardURL); + uri.init(Components.interfaces.nsIStandardURL.URLTYPE_STANDARD, + this.mHttpProtocol.defaultPort, aSpec, anOriginalCharset, aBaseURI); + return uri; + }, + + newChannel: function(aUri) { + return this.newChannel2(aUri, null); + }, + + newChannel2: function(aUri, aLoadInfo) { + // make sure to clone the uri, because we are about to change + // it, and we don't want to change the original uri. + let uri = aUri.clone(); + uri.scheme = this.mHttpProtocol.scheme; + + let channel; + if (aLoadInfo) { + channel = Services.io.newChannelFromURIWithLoadInfo(uri, aLoadInfo); + } else { + channel = Services.io.newChannelFromURI2(uri, + null, + Services.scriptSecurityManager.getSystemPrincipal(), + null, + Components.interfaces.nsILoadInfo.SEC_NORMAL, + Components.interfaces.nsIContentPolicy.TYPE_OTHER); + } + channel.originalURI = aUri; + return channel; + }, + + // We are not overriding any special ports + allowPort: function(aPort, aScheme) { return false; } +}; + +var calProtocolHandlerWebcalClassID = Components.ID("{1153c73a-39be-46aa-9ba9-656d188865ca}"); +var calProtocolHandlerWebcalInterfaces = [Components.interfaces.nsIProtocolHandler]; +calProtocolHandlerWebcal.prototype = { + __proto__: calProtocolHandler.prototype, + classID: calProtocolHandlerWebcalClassID, + QueryInterface: XPCOMUtils.generateQI(calProtocolHandlerWebcalInterfaces), + classInfo: XPCOMUtils.generateCI({ + classID: calProtocolHandlerWebcalClassID, + contractID: "@mozilla.org/network/protocol;1?name=webcal", + classDescription: "Calendar webcal protocal handler", + interfaces: calProtocolHandlerWebcalInterfaces + }), +}; + +var calProtocolHandlerWebcalsClassID = Components.ID("{bdf71224-365d-4493-856a-a7e74026f766}"); +var calProtocolHandlerWebcalsInterfaces = [Components.interfaces.nsIProtocolHandler]; +calProtocolHandlerWebcals.prototype = { + __proto__: calProtocolHandler.prototype, + classID: calProtocolHandlerWebcalsClassID, + QueryInterface: XPCOMUtils.generateQI(calProtocolHandlerWebcalsInterfaces), + classInfo: XPCOMUtils.generateCI({ + classID: calProtocolHandlerWebcalsClassID, + contractID: "@mozilla.org/network/protocol;1?name=webcals", + classDescription: "Calendar webcals protocal handler", + interfaces: calProtocolHandlerWebcalsInterfaces + }), +}; diff --git a/calendar/base/src/calRecurrenceDate.js b/calendar/base/src/calRecurrenceDate.js new file mode 100644 index 000000000..62532c513 --- /dev/null +++ b/calendar/base/src/calRecurrenceDate.js @@ -0,0 +1,116 @@ +/* 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/. */ + +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); +Components.utils.import("resource://calendar/modules/calUtils.jsm"); + +function calRecurrenceDate() { + this.wrappedJSObject = this; +} + +var calRecurrenceDateClassID = Components.ID("{806b6423-3aaa-4b26-afa3-de60563e9cec}"); +var calRecurrenceDateInterfaces = [Components.interfaces.calIRecurrenceDate]; +calRecurrenceDate.prototype = { + isMutable: true, + + mIsNegative: false, + mDate: null, + + classID: calRecurrenceDateClassID, + QueryInterface: XPCOMUtils.generateQI(calRecurrenceDateInterfaces), + classInfo: XPCOMUtils.generateCI({ + classID: calRecurrenceDateClassID, + contractID: "@mozilla.org/calendar/recurrence-date;1", + classDescription: "The date of an occurrence of a recurring item", + interfaces: calRecurrenceDateInterfaces + }), + makeImmutable: function() { + this.isMutable = false; + }, + + ensureMutable: function() { + if (!this.isMutable) { + throw Components.results.NS_ERROR_OBJECT_IS_MUTABLE; + } + }, + + clone: function() { + let other = new calRecurrenceDate(); + other.mDate = (this.mDate ? this.mDate.clone() : null); + other.mIsNegative = this.mIsNegative; + return other; + }, + + get isNegative() { return this.mIsNegative; }, + set isNegative(val) { + this.ensureMutable(); + return (this.mIsNegative = val); + }, + + get isFinite() { return true; }, + + get date() { return this.mDate; }, + set date(val) { + this.ensureMutable(); + return (this.mDate = val); + }, + + getNextOccurrence: function(aStartTime, aOccurrenceTime) { + if (this.mDate && this.mDate.compare(aStartTime) > 0) { + return this.mDate; + } else { + return null; + } + }, + + getOccurrences: function(aStartTime, aRangeStart, aRangeEnd, aMaxCount, aCount) { + if (this.mDate && + this.mDate.compare(aRangeStart) >= 0 && + (!aRangeEnd || this.mDate.compare(aRangeEnd) < 0)) { + aCount.value = 1; + return [this.mDate]; + } else { + aCount.value = 0; + return []; + } + }, + + get icalString() { + let comp = this.icalProperty; + return (comp ? comp.icalString : ""); + }, + set icalString(val) { + let prop = cal.getIcsService().createIcalPropertyFromString(val); + let propName = prop.propertyName; + if (propName != "RDATE" && propName != "EXDATE") { + throw Components.results.NS_ERROR_ILLEGAL_VALUE; + } + + this.icalProperty = prop; + return val; + }, + + get icalProperty() { + let prop = cal.getIcsService().createIcalProperty(this.mIsNegative ? "EXDATE" : "RDATE"); + prop.valueAsDatetime = this.mDate; + return prop; + }, + set icalProperty(prop) { + if (prop.propertyName == "RDATE") { + this.mIsNegative = false; + if (prop.getParameter("VALUE") == "PERIOD") { + let period = Components.classes["@mozilla.org/calendar/period;1"] + .createInstance(Components.interfaces.calIPeriod); + period.icalString = prop.valueAsIcalString; + this.mDate = period.start; + } else { + this.mDate = prop.valueAsDatetime; + } + } else if (prop.propertyName == "EXDATE") { + this.mIsNegative = true; + this.mDate = prop.valueAsDatetime; + } + return prop; + } +}; diff --git a/calendar/base/src/calRecurrenceInfo.js b/calendar/base/src/calRecurrenceInfo.js new file mode 100644 index 000000000..e09818501 --- /dev/null +++ b/calendar/base/src/calRecurrenceInfo.js @@ -0,0 +1,807 @@ +/* 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/. */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + +function getRidKey(date) { + if (!date) { + return null; + } + let timezone = date.timezone; + if (!timezone.isUTC && !timezone.isFloating) { + date = date.getInTimezone(UTC()); + } + return date.icalString; +} + +function calRecurrenceInfo() { + this.mRecurrenceItems = []; + this.mExceptionMap = {}; + + this.wrappedJSObject = this; +} + +var calRecurrenceInfoClassID = Components.ID("{04027036-5884-4a30-b4af-f2cad79f6edf}"); +var calRecurrenceInfoInterfaces = [Components.interfaces.calIRecurrenceInfo]; +calRecurrenceInfo.prototype = { + mImmutable: false, + mBaseItem: null, + mRecurrenceItems: null, + mPositiveRules: null, + mNegativeRules: null, + mExceptionMap: null, + + classID: calRecurrenceInfoClassID, + QueryInterface: XPCOMUtils.generateQI(calRecurrenceInfoInterfaces), + classInfo: XPCOMUtils.generateCI({ + classID: calRecurrenceInfoClassID, + contractID: "@mozilla.org/calendar/recurrence-info;1", + classDescription: "Calendar Recurrence Info", + interfaces: calRecurrenceInfoInterfaces, + }), + + /** + * Helpers + */ + ensureBaseItem: function() { + if (!this.mBaseItem) { + throw Components.results.NS_ERROR_NOT_INITIALIZED; + } + }, + ensureMutable: function() { + if (this.mImmutable) { + throw Components.results.NS_ERROR_OBJECT_IS_IMMUTABLE; + } + }, + ensureSortedRecurrenceRules: function() { + if (!this.mPositiveRules || !this.mNegativeRules) { + this.mPositiveRules = []; + this.mNegativeRules = []; + for (let ritem of this.mRecurrenceItems) { + if (ritem.isNegative) { + this.mNegativeRules.push(ritem); + } else { + this.mPositiveRules.push(ritem); + } + } + } + }, + + /** + * Mutability bits + */ + get isMutable() { + return !this.mImmutable; + }, + makeImmutable: function() { + if (this.mImmutable) { + return; + } + + for (let ritem of this.mRecurrenceItems) { + if (ritem.isMutable) { + ritem.makeImmutable(); + } + } + + for (let ex in this.mExceptionMap) { + let item = this.mExceptionMap[ex]; + if (item.isMutable) { + item.makeImmutable(); + } + } + + this.mImmutable = true; + }, + + clone: function() { + let cloned = new calRecurrenceInfo(); + cloned.mBaseItem = this.mBaseItem; + + let clonedItems = []; + for (let ritem of this.mRecurrenceItems) { + clonedItems.push(ritem.clone()); + } + cloned.mRecurrenceItems = clonedItems; + + let clonedExceptions = {}; + for (let exitem in this.mExceptionMap) { + clonedExceptions[exitem] = this.mExceptionMap[exitem].cloneShallow(this.mBaseItem); + } + cloned.mExceptionMap = clonedExceptions; + + return cloned; + }, + + /* + * calIRecurrenceInfo + */ + get item() { + return this.mBaseItem; + }, + set item(value) { + this.ensureMutable(); + + value = calTryWrappedJSObject(value); + this.mBaseItem = value; + // patch exception's parentItem: + for (let ex in this.mExceptionMap) { + let exitem = this.mExceptionMap[ex]; + exitem.parentItem = value; + } + }, + + get isFinite() { + this.ensureBaseItem(); + + for (let ritem of this.mRecurrenceItems) { + if (!ritem.isFinite) { + return false; + } + } + return true; + }, + + getRecurrenceItems: function(aCount) { + this.ensureBaseItem(); + + aCount.value = this.mRecurrenceItems.length; + return this.mRecurrenceItems; + }, + + setRecurrenceItems: function(aCount, aItems) { + this.ensureBaseItem(); + this.ensureMutable(); + + // XXX should we clone these? + this.mRecurrenceItems = aItems; + this.mPositiveRules = null; + this.mNegativeRules = null; + }, + + countRecurrenceItems: function() { + this.ensureBaseItem(); + + return this.mRecurrenceItems.length; + }, + + getRecurrenceItemAt: function(aIndex) { + this.ensureBaseItem(); + + if (aIndex < 0 || aIndex >= this.mRecurrenceItems.length) { + throw Components.results.NS_ERROR_INVALID_ARG; + } + + return this.mRecurrenceItems[aIndex]; + }, + + appendRecurrenceItem: function(aItem) { + this.ensureBaseItem(); + this.ensureMutable(); + this.ensureSortedRecurrenceRules(); + + this.mRecurrenceItems.push(aItem); + if (aItem.isNegative) { + this.mNegativeRules.push(aItem); + } else { + this.mPositiveRules.push(aItem); + } + }, + + deleteRecurrenceItemAt: function(aIndex) { + this.ensureBaseItem(); + this.ensureMutable(); + + if (aIndex < 0 || aIndex >= this.mRecurrenceItems.length) { + throw Components.results.NS_ERROR_INVALID_ARG; + } + + if (this.mRecurrenceItems[aIndex].isNegative) { + this.mNegativeRules = null; + } else { + this.mPositiveRules = null; + } + + this.mRecurrenceItems.splice(aIndex, 1); + }, + + deleteRecurrenceItem: function(aItem) { + // Because xpcom objects can be wrapped in various ways, testing for + // mere == sometimes returns false even when it should be true. Use + // the interface pointer returned by sip to avoid that problem. + let sip1 = Components.classes["@mozilla.org/supports-interface-pointer;1"] + .createInstance(Components.interfaces.nsISupportsInterfacePointer); + sip1.data = aItem; + sip1.dataIID = Components.interfaces.calIRecurrenceItem; + + let pos; + if ((pos = this.mRecurrenceItems.indexOf(sip1.data)) > -1) { + this.deleteRecurrenceItemAt(pos); + } else { + throw Components.results.NS_ERROR_INVALID_ARG; + } + }, + + insertRecurrenceItemAt: function(aItem, aIndex) { + this.ensureBaseItem(); + this.ensureMutable(); + this.ensureSortedRecurrenceRules(); + + if (aIndex < 0 || aIndex > this.mRecurrenceItems.length) { + throw Components.results.NS_ERROR_INVALID_ARG; + } + + if (aItem.isNegative) { + this.mNegativeRules.push(aItem); + } else { + this.mPositiveRules.push(aItem); + } + + this.mRecurrenceItems.splice(aIndex, 0, aItem); + }, + + clearRecurrenceItems: function() { + this.ensureBaseItem(); + this.ensureMutable(); + + this.mRecurrenceItems = []; + this.mPositiveRules = []; + this.mNegativeRules = []; + }, + + /* + * calculations + */ + getNextOccurrence: function(aTime) { + this.ensureBaseItem(); + this.ensureSortedRecurrenceRules(); + + let startDate = this.mBaseItem.recurrenceStartDate; + let nextOccurrences = []; + let invalidOccurrences; + let negMap = {}; + let minOccRid; + + // Go through all negative rules to create a map of occurrences that + // should be skipped when going through occurrences. + for (let ritem of this.mNegativeRules) { + // TODO Infinite rules (i.e EXRULE) are not taken into account, + // because its very performance hungry and could potentially + // lead to a deadlock (i.e RRULE is canceled out by an EXRULE). + // This is ok for now, since EXRULE is deprecated anyway. + if (ritem.isFinite) { + // Get all occurrences starting at our recurrence start date. + // This is fine, since there will never be an EXDATE that + // occurrs before the event started and its illegal to EXDATE an + // RDATE. + let rdates = ritem.getOccurrences(startDate, + startDate, + null, + 0, + {}); + // Map all negative dates. + for (let rdate of rdates) { + negMap[getRidKey(rdate)] = true; + } + } else { + WARN("Item '" + this.mBaseItem.title + "'" + + (this.mBaseItem.calendar ? " (" + this.mBaseItem.calendar.name + ")" : "") + + " has an infinite negative rule (EXRULE)"); + } + } + + let bailCounter = 0; + do { + invalidOccurrences = 0; + // Go through all positive rules and get the next recurrence id + // according to that rule. If for all rules the rid is "invalid", + // (i.e an EXDATE removed it, or an exception moved it somewhere + // else), then get the respective next rid. + // + // If in a loop at least one rid is valid (i.e not an exception, not + // an exdate, is after aTime), then remember the lowest one. + for (let i = 0; i < this.mPositiveRules.length; i++) { + let rDateInstance = cal.wrapInstance(this.mPositiveRules[i], Components.interfaces.calIRecurrenceDate); + let rRuleInstance = cal.wrapInstance(this.mPositiveRules[i], Components.interfaces.calIRecurrenceRule); + if (rDateInstance) { + // RDATEs are special. there is only one date in this rule, + // so no need to search anything. + let rdate = rDateInstance.date; + if (!nextOccurrences[i] && rdate.compare(aTime) > 0) { + // The RDATE falls into range, save it. + nextOccurrences[i] = rdate; + } else { + // The RDATE doesn't fall into range. This rule will + // always be invalid, since it can't give out a date. + nextOccurrences[i] = null; + invalidOccurrences++; + } + } else if (rRuleInstance) { + // RRULEs must not start searching before |startDate|, since + // the pattern is only valid afterwards. If an occurrence + // was found in a previous round, we can go ahead and start + // searching from that occurrence. + let searchStart = nextOccurrences[i] || startDate; + + // Search for the next occurrence after aTime. If the last + // round was invalid, then in this round we need to search + // after nextOccurrences[i] to make sure getNextOccurrence() + // doesn't find the same occurrence again. + let searchDate = nextOccurrences[i] && nextOccurrences[i].compare(aTime) > 0 + ? nextOccurrences[i] + : aTime; + + nextOccurrences[i] = rRuleInstance + .getNextOccurrence(searchStart, searchDate); + } + + // As decided in bug 734245, an EXDATE of type DATE shall also match a DTSTART of type DATE-TIME + let nextKey = getRidKey(nextOccurrences[i]); + let isInExceptionMap = nextKey && (this.mExceptionMap[nextKey.substring(0, 8)] || + this.mExceptionMap[nextKey]); + let isInNegMap = nextKey && (negMap[nextKey.substring(0, 8)] || + negMap[nextKey]); + if (nextKey && (isInNegMap || isInExceptionMap)) { + // If the found recurrence id points to either an exception + // (will handle later) or an EXDATE, then nextOccurrences[i] + // is invalid and we might need to try again next round. + invalidOccurrences++; + } else if (nextOccurrences[i]) { + // We have a valid recurrence id (not an exception, not an + // EXDATE, falls into range). We only need to save the + // earliest occurrence after aTime (checking for aTime is + // not needed, since getNextOccurrence() above returns only + // occurrences after aTime). + if (!minOccRid || minOccRid.compare(nextOccurrences[i]) > 0) { + minOccRid = nextOccurrences[i]; + } + } + } + + // To make sure users don't just report bugs like "the application + // hangs", bail out after 100 runs. If this happens, it is most + // likely a bug. + if (bailCounter++ > 100) { + ERROR("Could not find next occurrence after 100 runs!"); + return null; + } + + // We counted how many positive rules found out that their next + // candidate is invalid. If all rules produce invalid next + // occurrences, a second round is needed. + } while (invalidOccurrences == this.mPositiveRules.length); + + // Since we need to compare occurrences by date, save the rid found + // above also as a date. This works out because above we skipped + // exceptions. + let minOccDate = minOccRid; + + // Scan exceptions for any dates earlier than the above found + // minOccDate, but still after aTime. + for (let ex in this.mExceptionMap) { + let exc = this.mExceptionMap[ex]; + let start = exc.recurrenceStartDate; + if (start.compare(aTime) > 0 && + (!minOccDate || start.compare(minOccDate) <= 0)) { + // This exception is earlier, save its rid (for getting the + // occurrence later on) and its date (for comparing to other + // exceptions). + minOccRid = exc.recurrenceId; + minOccDate = start; + } + } + + // If we found a recurrence id any time above, then return the + // occurrence for it. + return (minOccRid ? this.getOccurrenceFor(minOccRid) : null); + }, + + getPreviousOccurrence: function(aTime) { + // TODO libical currently does not provide us with easy means of + // getting the previous occurrence. This could be fixed to improve + // performance greatly. Filed as libical feature request 1944020. + + // HACK We never know how early an RDATE might be before the actual + // recurrence start. Since rangeStart cannot be null for recurrence + // items like calIRecurrenceRule, we need to work around by supplying a + // very early date. Again, this might have a high performance penalty. + let early = createDateTime(); + early.icalString = "00000101T000000Z"; + + let rids = this.calculateDates(early, + aTime, + 0); + // The returned dates are sorted, so the last one is a good + // candidate, if it exists. + return (rids.length > 0 ? this.getOccurrenceFor(rids[rids.length - 1].id) : null); + }, + + // internal helper function; + calculateDates: function(aRangeStart, aRangeEnd, aMaxCount) { + this.ensureBaseItem(); + this.ensureSortedRecurrenceRules(); + + function ridDateSortComptor(a, b) { + return a.rstart.compare(b.rstart); + } + + // workaround for UTC- timezones + let rangeStart = ensureDateTime(aRangeStart); + let rangeEnd = ensureDateTime(aRangeEnd); + + // If aRangeStart falls in the middle of an occurrence, libical will + // not return that occurrence when we go and ask for an + // icalrecur_iterator_new. This actually seems fairly rational, so + // instead of hacking libical, I'm going to move aRangeStart back far + // enough to make sure we get the occurrences we might miss. + let searchStart = rangeStart.clone(); + let baseDuration = this.mBaseItem.duration; + if (baseDuration) { + let duration = baseDuration.clone(); + duration.isNegative = true; + searchStart.addDuration(duration); + } + + let startDate = this.mBaseItem.recurrenceStartDate; + if (startDate == null) { + // Todo created by other apps may have a saved recurrence but + // start and due dates disabled. Since no recurrenceStartDate, + // treat as undated task. + return []; + } + + let dates = []; + + // toss in exceptions first. Save a map of all exceptions ids, so we + // don't add the wrong occurrences later on. + let occurrenceMap = {}; + for (let ex in this.mExceptionMap) { + let item = this.mExceptionMap[ex]; + let occDate = checkIfInRange(item, aRangeStart, aRangeEnd, true); + occurrenceMap[ex] = true; + if (occDate) { + binaryInsert(dates, { id: item.recurrenceId, rstart: occDate }, ridDateSortComptor); + } + } + + // DTSTART/DUE is always part of the (positive) expanded set: + // DTSTART always equals RECURRENCE-ID for items expanded from RRULE + let baseOccDate = checkIfInRange(this.mBaseItem, aRangeStart, aRangeEnd, true); + let baseOccDateKey = getRidKey(baseOccDate); + if (baseOccDate && !occurrenceMap[baseOccDateKey]) { + occurrenceMap[baseOccDateKey] = true; + binaryInsert(dates, { id: baseOccDate, rstart: baseOccDate }, ridDateSortComptor); + } + + // if both range start and end are specified, we ask for all of the occurrences, + // to make sure we catch all possible exceptions. If aRangeEnd isn't specified, + // then we have to ask for aMaxCount, and hope for the best. + let maxCount; + if (rangeStart && rangeEnd) { + maxCount = 0; + } else { + maxCount = aMaxCount; + } + + // Apply positive rules + for (let ritem of this.mPositiveRules) { + let cur_dates = ritem.getOccurrences(startDate, + searchStart, + rangeEnd, + maxCount, {}); + if (cur_dates.length == 0) { + continue; + } + + // if positive, we just add these date to the existing set, + // but only if they're not already there + + let index = 0; + let len = cur_dates.length; + + // skip items before rangeStart due to searchStart libical hack: + if (rangeStart && baseDuration) { + for (; index < len; ++index) { + let date = cur_dates[index].clone(); + date.addDuration(baseDuration); + if (rangeStart.compare(date) < 0) { + break; + } + } + } + for (; index < len; ++index) { + let date = cur_dates[index]; + let dateKey = getRidKey(date); + if (occurrenceMap[dateKey]) { + // Don't add occurrences twice (i.e exception was + // already added before) + continue; + } + // TODO if cur_dates[] is also sorted, then this binary + // search could be optimized further + binaryInsert(dates, { id: date, rstart: date }, ridDateSortComptor); + occurrenceMap[dateKey] = true; + } + } + + // Apply negative rules + for (let ritem of this.mNegativeRules) { + let cur_dates = ritem.getOccurrences(startDate, + searchStart, + rangeEnd, + maxCount, {}); + if (cur_dates.length == 0) { + continue; + } + + // XXX: i'm pretty sure negative dates can't really have exceptions + // (like, you can't make a date "real" by defining an RECURRENCE-ID which + // is an EXDATE, and then giving it a real DTSTART) -- so we don't + // check exceptions here + for (let dateToRemove of cur_dates) { + let dateToRemoveKey = getRidKey(dateToRemove); + if (dateToRemove.isDate) { + // As decided in bug 734245, an EXDATE of type DATE shall also match a DTSTART of type DATE-TIME + let toRemove = []; + for (let occurenceKey in occurrenceMap) { + if (occurrenceMap[occurenceKey] && occurenceKey.substring(0, 8) == dateToRemoveKey) { + dates = dates.filter(date => date.id.compare(dateToRemove) != 0); + toRemove.push(occurenceKey); + } + } + for (let i = 0; i < toRemove.length; i++) { + delete occurrenceMap[toRemove[i]]; + } + } else if (occurrenceMap[dateToRemoveKey]) { + // TODO PERF Theoretically we could use occurrence map + // to construct the array of occurrences. Right now I'm + // just using the occurrence map to skip the filter + // action if the occurrence isn't there anyway. + dates = dates.filter(date => date.id.compare(dateToRemove) != 0); + delete occurrenceMap[dateToRemoveKey]; + } + } + } + + // The list was already sorted above, chop anything over aMaxCount, if + // specified. + if (aMaxCount && dates.length > aMaxCount) { + dates = dates.slice(0, aMaxCount); + } + + return dates; + }, + + getOccurrenceDates: function(aRangeStart, aRangeEnd, aMaxCount, aCount) { + let dates = this.calculateDates(aRangeStart, aRangeEnd, aMaxCount); + dates = dates.map(date => date.rstart); + aCount.value = dates.length; + return dates; + }, + + getOccurrences: function(aRangeStart, aRangeEnd, aMaxCount, aCount) { + let results = []; + let dates = this.calculateDates(aRangeStart, aRangeEnd, aMaxCount); + if (dates.length) { + let count; + if (aMaxCount) { + count = Math.min(aMaxCount, dates.length); + } else { + count = dates.length; + } + + for (let i = 0; i < count; i++) { + results.push(this.getOccurrenceFor(dates[i].id)); + } + } + + aCount.value = results.length; + return results; + }, + + getOccurrenceFor: function(aRecurrenceId) { + let proxy = this.getExceptionFor(aRecurrenceId); + if (!proxy) { + return this.item.createProxy(aRecurrenceId); + } + return proxy; + }, + + removeOccurrenceAt: function(aRecurrenceId) { + this.ensureBaseItem(); + this.ensureMutable(); + + let rdate = Components.classes["@mozilla.org/calendar/recurrence-date;1"] + .createInstance(Components.interfaces.calIRecurrenceDate); + rdate.isNegative = true; + rdate.date = aRecurrenceId.clone(); + + this.removeExceptionFor(rdate.date); + + this.appendRecurrenceItem(rdate); + }, + + restoreOccurrenceAt: function(aRecurrenceId) { + this.ensureBaseItem(); + this.ensureMutable(); + this.ensureSortedRecurrenceRules(); + + for (let i = 0; i < this.mRecurrenceItems.length; i++) { + let rdate = cal.wrapInstance(this.mRecurrenceItems[i], Components.interfaces.calIRecurrenceDate); + if (rdate) { + if (rdate.isNegative && rdate.date.compare(aRecurrenceId) == 0) { + return this.deleteRecurrenceItemAt(i); + } + } + } + + throw Components.results.NS_ERROR_INVALID_ARG; + }, + + // + // exceptions + // + + // + // Some notes: + // + // The way I read ICAL, RECURRENCE-ID is used to specify a + // particular instance of a recurring event, according to the + // RRULEs/RDATEs/etc. specified in the base event. If one of + // these is to be changed ("an exception"), then it can be + // referenced via the UID of the original event, and a + // RECURRENCE-ID of the start time of the instance to change. + // This, to me, means that an event where one of the instances has + // changed to a different time has a RECURRENCE-ID of the original + // start time, and a DTSTART/DTEND representing the new time. + // + // ITIP, however, seems to want something different -- you're + // supposed to use UID/RECURRENCE-ID to select from the current + // set of occurrences of an event. If you change the DTSTART for + // an instance, you're supposed to use the old (original) DTSTART + // as the RECURRENCE-ID, and put the new time as the DTSTART. + // However, after that change, to refer to that instance in the + // future, you have to use the modified DTSTART as the + // RECURRENCE-ID. This madness is described in ITIP end of + // section 3.7.1. + // + // This implementation does the first approach (RECURRENCE-ID will + // never change even if DTSTART for that instance changes), which + // I think is the right thing to do for CalDAV; I don't know what + // we'll do for incoming ITIP events though. + // + modifyException: function(anItem, aTakeOverOwnership) { + this.ensureBaseItem(); + + anItem = calTryWrappedJSObject(anItem); + + if (anItem.parentItem.calendar != this.mBaseItem.calendar && + anItem.parentItem.id != this.mBaseItem.id) { + ERROR("recurrenceInfo::addException: item parentItem != this.mBaseItem (calendar/id)!"); + throw Components.results.NS_ERROR_INVALID_ARG; + } + + if (anItem.recurrenceId == null) { + ERROR("recurrenceInfo::addException: item with null recurrenceId!"); + throw Components.results.NS_ERROR_INVALID_ARG; + } + + let itemtoadd; + if (aTakeOverOwnership && anItem.isMutable) { + itemtoadd = anItem; + itemtoadd.parentItem = this.mBaseItem; + } else { + itemtoadd = anItem.cloneShallow(this.mBaseItem); + } + + // we're going to assume that the recurrenceId is valid here, + // because presumably the item came from one of our functions + + let exKey = getRidKey(itemtoadd.recurrenceId); + this.mExceptionMap[exKey] = itemtoadd; + }, + + getExceptionFor: function(aRecurrenceId) { + this.ensureBaseItem(); + // Interface calIRecurrenceInfo specifies result be null if not found. + // To avoid strict "reference to undefined property" warning, appending + // "|| null" gives explicit result in case where property undefined + // (or false, 0, null, or "", but here it should never be those values). + return this.mExceptionMap[getRidKey(aRecurrenceId)] || null; + }, + + removeExceptionFor: function(aRecurrenceId) { + this.ensureBaseItem(); + delete this.mExceptionMap[getRidKey(aRecurrenceId)]; + }, + + getExceptionIds: function(aCount) { + this.ensureBaseItem(); + + let ids = []; + for (let ex in this.mExceptionMap) { + let item = this.mExceptionMap[ex]; + ids.push(item.recurrenceId); + } + + aCount.value = ids.length; + return ids; + }, + + // changing the startdate of an item needs to take exceptions into account. + // in case we're about to modify a parentItem (aka 'folded' item), we need + // to modify the recurrenceId's of all possibly existing exceptions as well. + onStartDateChange: function(aNewStartTime, aOldStartTime) { + // passing null for the new starttime would indicate an error condition, + // since having a recurrence without a starttime is invalid. + cal.ASSERT(aNewStartTime, "invalid arg!", true); + + // no need to check for changes if there's no previous starttime. + if (!aOldStartTime) { + return; + } + + // convert both dates to UTC since subtractDate is not timezone aware. + let timeDiff = aNewStartTime.getInTimezone(UTC()).subtractDate(aOldStartTime.getInTimezone(UTC())); + + let rdates = {}; + + // take RDATE's and EXDATE's into account. + const kCalIRecurrenceDate = Components.interfaces.calIRecurrenceDate; + let ritems = this.getRecurrenceItems({}); + for (let ritem of ritems) { + let rDateInstance = cal.wrapInstance(ritem, kCalIRecurrenceDate); + let rRuleInstance = cal.wrapInstance(ritem, Components.interfaces.calIRecurrenceRule); + if (rDateInstance) { + ritem = rDateInstance; + let date = ritem.date; + date.addDuration(timeDiff); + if (!ritem.isNegative) { + rdates[getRidKey(date)] = date; + } + ritem.date = date; + } else if (rRuleInstance) { + ritem = rRuleInstance; + if (!ritem.isByCount) { + let untilDate = ritem.untilDate; + if (untilDate) { + untilDate.addDuration(timeDiff); + ritem.untilDate = untilDate; + } + } + } + } + + let startTimezone = aNewStartTime.timezone; + let modifiedExceptions = []; + for (let exid of this.getExceptionIds({})) { + let ex = this.getExceptionFor(exid); + if (ex) { + ex = ex.clone(); + // track RECURRENCE-IDs in DTSTART's or RDATE's timezone, + // otherwise those won't match any longer w.r.t DST: + let rid = ex.recurrenceId; + let rdate = rdates[getRidKey(rid)]; + rid = rid.getInTimezone(rdate ? rdate.timezone : startTimezone); + rid.addDuration(timeDiff); + ex.recurrenceId = rid; + cal.shiftItem(ex, timeDiff); + modifiedExceptions.push(ex); + this.removeExceptionFor(exid); + } + } + for (let modifiedEx of modifiedExceptions) { + this.modifyException(modifiedEx, true); + } + }, + + onIdChange: function(aNewId) { + // patch all overridden items' id: + for (let ex in this.mExceptionMap) { + let item = this.mExceptionMap[ex]; + item.id = aNewId; + } + } +}; diff --git a/calendar/base/src/calRelation.js b/calendar/base/src/calRelation.js new file mode 100644 index 000000000..65b156361 --- /dev/null +++ b/calendar/base/src/calRelation.js @@ -0,0 +1,131 @@ +/* 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/. */ + +Components.utils.import("resource://calendar/modules/calIteratorUtils.jsm"); +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + +/** + * calRelation prototype definition + * + * @implements calIRelation + * @constructor + */ +function calRelation() { + this.wrappedJSObject = this; + this.mProperties = new calPropertyBag(); +} +var calRelationClassID = Components.ID("{76810fae-abad-4019-917a-08e95d5bbd68}"); +var calRelationInterfaces = [Components.interfaces.calIRelation]; +calRelation.prototype = { + mType: null, + mId: null, + + classID: calRelationClassID, + QueryInterface: XPCOMUtils.generateQI(calRelationInterfaces), + classInfo: XPCOMUtils.generateCI({ + classID: calRelationClassID, + contractID: "@mozilla.org/calendar/relation;1", + classDescription: "Calendar Item Relation", + interfaces: calRelationInterfaces + }), + + /** + * @see calIRelation + */ + + get relType() { + return this.mType; + }, + set relType(aType) { + return (this.mType = aType); + }, + + get relId() { + return this.mId; + }, + set relId(aRelId) { + return (this.mId = aRelId); + }, + + get icalProperty() { + let icssvc = getIcsService(); + let icalatt = icssvc.createIcalProperty("RELATED-TO"); + if (this.mId) { + icalatt.value = this.mId; + } + + if (this.mType) { + icalatt.setParameter("RELTYPE", this.mType); + } + + for (let [key, value] of this.mProperties) { + try { + icalatt.setParameter(key, value); + } catch (e) { + if (e.result == Components.results.NS_ERROR_ILLEGAL_VALUE) { + // Illegal values should be ignored, but we could log them if + // the user has enabled logging. + cal.LOG("Warning: Invalid relation property value " + key + "=" + value); + } else { + throw e; + } + } + } + return icalatt; + }, + + set icalProperty(attProp) { + // Reset the property bag for the parameters, it will be re-initialized + // from the ical property. + this.mProperties = new calPropertyBag(); + + if (attProp.value) { + this.mId = attProp.value; + } + for (let [name, value] of cal.ical.paramIterator(attProp)) { + if (name == "RELTYPE") { + this.mType = value; + continue; + } + + this.setParameter(name, value); + } + }, + + get icalString() { + let comp = this.icalProperty; + return (comp ? comp.icalString : ""); + }, + set icalString(val) { + let prop = cal.getIcsService().createIcalPropertyFromString(val); + if (prop.propertyName != "RELATED-TO") { + throw Components.results.NS_ERROR_ILLEGAL_VALUE; + } + this.icalProperty = prop; + return val; + }, + + getParameter: function(aName) { + return this.mProperties.getProperty(aName); + }, + + setParameter: function(aName, aValue) { + return this.mProperties.setProperty(aName, aValue); + }, + + deleteParameter: function(aName) { + return this.mProperties.deleteProperty(aName); + }, + + clone: function() { + let newRelation = new calRelation(); + newRelation.mId = this.mId; + newRelation.mType = this.mType; + for (let [name, value] of this.mProperties) { + newRelation.mProperties.setProperty(name, value); + } + return newRelation; + } +}; diff --git a/calendar/base/src/calSleepMonitor.js b/calendar/base/src/calSleepMonitor.js new file mode 100644 index 000000000..c98c5017c --- /dev/null +++ b/calendar/base/src/calSleepMonitor.js @@ -0,0 +1,71 @@ +/* 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/. */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + +var calSleepMonitorClassID = Components.ID("9b987a8d-c2ef-4cb9-9602-1261b4b2f6fa"); +var calSleepMonitorInterfaces = [Components.interfaces.nsIObserver]; + +function calSleepMonitor() { +} + +calSleepMonitor.prototype = { + classID: calSleepMonitorClassID, + QueryInterface: XPCOMUtils.generateQI(calSleepMonitorInterfaces), + classInfo: XPCOMUtils.generateCI({ + classID: calSleepMonitorClassID, + contractID: "@mozilla.org/calendar/sleep-monitor;1", + classDescription: "Calendar Sleep Monitor", + interfaces: calSleepMonitorInterfaces, + flags: Components.interfaces.nsIClassInfo.SINGLETON + }), + + interval: 60000, + timer: null, + expected: null, + tolerance: 1000, + + callback: function() { + let now = Date.now(); + if (now - this.expected > this.tolerance) { + cal.LOG("[calSleepMonitor] Sleep cycle detected, notifying observers."); + Services.obs.notifyObservers(null, "wake_notification", null); + } + this.expected = now + this.interval; + }, + start: function() { + this.stop(); + this.expected = Date.now() + this.interval; + this.timer = Components.classes["@mozilla.org/timer;1"].createInstance(Components.interfaces.nsITimer); + this.timer.initWithCallback(this.callback.bind(this), this.interval, Components.interfaces.nsITimer.TYPE_REPEATING_PRECISE); + }, + stop: function() { + if (this.timer) { + this.timer.cancel(); + this.timer = null; + } + }, + + // nsIObserver: + observe: function(aSubject, aTopic, aData) { + // calSleepMonitor is not used on Windows. + if (Services.appinfo.OS == "WINNT") { + return; + } + + if (aTopic == "profile-after-change") { + cal.LOG("[calSleepMonitor] Starting sleep monitor."); + this.start(); + + Services.obs.addObserver(this, "quit-application", false); + } else if (aTopic == "quit-application") { + cal.LOG("[calSleepMonitor] Stopping sleep monitor."); + this.stop(); + } + } +}; + +this.NSGetFactory = XPCOMUtils.generateNSGetFactory([calSleepMonitor]); diff --git a/calendar/base/src/calSleepMonitor.manifest b/calendar/base/src/calSleepMonitor.manifest new file mode 100644 index 000000000..7089b5445 --- /dev/null +++ b/calendar/base/src/calSleepMonitor.manifest @@ -0,0 +1,3 @@ +component {9b987a8d-c2ef-4cb9-9602-1261b4b2f6fa} calSleepMonitor.js +contract @mozilla.org/calendar/sleep-monitor;1 {9b987a8d-c2ef-4cb9-9602-1261b4b2f6fa} +category profile-after-change calSleepMonitor @mozilla.org/calendar/sleep-monitor;1 diff --git a/calendar/base/src/calStartupService.js b/calendar/base/src/calStartupService.js new file mode 100644 index 000000000..9af0cf70d --- /dev/null +++ b/calendar/base/src/calStartupService.js @@ -0,0 +1,113 @@ +/* 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/. */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + +/** + * Helper function to asynchronously call a certain method on the objects passed + * in 'services' in order (i.e wait until the first completes before calling the + * second + * + * @param method The method name to call. Usually startup/shutdown. + * @param services The array of service objects to call on. + */ +function callOrderedServices(method, services) { + let service = services.shift(); + if (service) { + service[method]({ + onResult: function() { + callOrderedServices(method, services); + } + }); + } +} + +function calStartupService() { + this.wrappedJSObject = this; + this.setupObservers(); +} + +var calStartupServiceInterfaces = [Components.interfaces.nsIObserver]; +var calStartupServiceClassID = Components.ID("{2547331f-34c0-4a4b-b93c-b503538ba6d6}"); +calStartupService.prototype = { + QueryInterface: XPCOMUtils.generateQI(calStartupServiceInterfaces), + classID: calStartupServiceClassID, + classInfo: XPCOMUtils.generateCI({ + contractID: "@mozilla.org/calendar/startup-service;1", + classDescription: "Calendar Startup Service", + classID: calStartupServiceClassID, + interfaces: calStartupServiceInterfaces, + flags: Components.interfaces.nsIClassInfo.SINGLETON + }), + + // Startup Service Methods + + /** + * Sets up the needed observers for noticing startup/shutdown + */ + setupObservers: function() { + Services.obs.addObserver(this, "profile-after-change", false); + Services.obs.addObserver(this, "profile-before-change", false); + Services.obs.addObserver(this, "xpcom-shutdown", false); + }, + + started: false, + + /** + * Gets the startup order of services. This is an array of service objects + * that should be called in order at startup. + * + * @return The startup order as an array. + */ + getStartupOrder: function() { + let self = this; + let tzService = Components.classes["@mozilla.org/calendar/timezone-service;1"] + .getService(Components.interfaces.calITimezoneService); + let calMgr = Components.classes["@mozilla.org/calendar/manager;1"] + .getService(Components.interfaces.calICalendarManager); + + // Notification object + let notify = { + startup: function(aCompleteListener) { + self.started = true; + Services.obs.notifyObservers(null, "calendar-startup-done", null); + aCompleteListener.onResult(null, Components.results.NS_OK); + }, + shutdown: function(aCompleteListener) { + // Argh, it would have all been so pretty! Since we just reverse + // the array, the shutdown notification would happen before the + // other shutdown calls. For lack of pretty code, I'm + // leaving this out! Users can still listen to xpcom-shutdown. + self.started = false; + aCompleteListener.onResult(null, Components.results.NS_OK); + } + }; + + // We need to spin up the timezone service before the calendar manager + // to ensure we have the timezones initialized. Make sure "notify" is + // last in this array! + return [tzService, calMgr, notify]; + }, + + /** + * Observer notification callback + */ + observe: function(aSubject, aTopic, aData) { + switch (aTopic) { + case "profile-after-change": + callOrderedServices("startup", this.getStartupOrder()); + break; + case "profile-before-change": + callOrderedServices("shutdown", this.getStartupOrder().reverse()); + break; + case "xpcom-shutdown": + Services.obs.removeObserver(this, "profile-after-change"); + Services.obs.removeObserver(this, "profile-before-change"); + Services.obs.removeObserver(this, "xpcom-shutdown"); + break; + } + } +}; diff --git a/calendar/base/src/calTimezone.js b/calendar/base/src/calTimezone.js new file mode 100644 index 000000000..ac88d511c --- /dev/null +++ b/calendar/base/src/calTimezone.js @@ -0,0 +1,109 @@ +/* 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/. */ + +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://calendar/modules/ical.js"); + +function calICALJSTimezone(innerObject) { + this.innerObject = innerObject || new ICAL.Timezone(); + this.wrappedJSObject = this; +} + +var calTimezoneInterfaces = [Components.interfaces.calITimezone]; +var calTimezoneClassID = Components.ID("{6702eb17-a968-4b43-b562-0d0c5f8e9eb5}"); +calICALJSTimezone.prototype = { + QueryInterface: XPCOMUtils.generateQI(calTimezoneInterfaces), + classID: calTimezoneClassID, + classInfo: XPCOMUtils.generateCI({ + contractID: "@mozilla.org/calendar/timezone;1", + classDescription: "Calendar Timezone", + classID: calTimezoneClassID, + interfaces: calTimezoneInterfaces + }), + + innerObject: null, + + get provider() { return cal.getTimezoneService(); }, + get icalComponent() { + let innerComp = this.innerObject.component; + let comp = null; + if (innerComp) { + comp = cal.getIcsService().createIcalComponent("VTIMEZONE"); + comp.icalComponent = innerComp; + } + return comp; + }, + get tzid() { return this.innerObject.tzid; }, + get isFloating() { return this.innerObject == ICAL.Timezone.localTimezone; }, + get isUTC() { return this.innerObject == ICAL.Timezone.utcTimezone; }, + get latitude() { return this.innerObject.latitude; }, + get longitude() { return this.innerObject.longitude; }, + get displayName() { + let bundle = ICAL.Timezone.cal_tz_bundle; + let stringName = "pref.timezone." + this.tzid.replace(/\//g, "."); + let displayName = this.tzid; + try { + displayName = bundle.GetStringFromName(stringName); + } catch (e) { + // Just use the TZID if the string is mising. + } + this.__defineGetter__("displayName", () => { + return displayName; + }); + return displayName; + }, + + tostring: function() { return this.innerObject.toString(); } +}; + +function calLibicalTimezone(tzid, component, latitude, longitude) { + this.wrappedJSObject = this; + this.tzid = tzid; + this.mComponent = component; + this.mUTC = false; + this.isFloating = false; + this.latitude = latitude; + this.longitude = longitude; +} +calLibicalTimezone.prototype = { + QueryInterface: XPCOMUtils.generateQI(calTimezoneInterfaces), + classID: calTimezoneClassID, + classInfo: XPCOMUtils.generateCI({ + contractID: "@mozilla.org/calendar/timezone;1", + classDescription: "Calendar Timezone", + classID: calTimezoneClassID, + interfaces: calTimezoneInterfaces + }), + + toString: function() { + return (this.icalComponent ? this.icalComponent.toString() : this.tzid); + }, + + get isUTC() { return this.mUTC; }, + + get icalComponent() { + let comp = this.mComponent; + if (comp && (typeof comp == "string")) { + this.mComponent = cal.getIcsService().parseICS("BEGIN:VCALENDAR\r\n" + comp + "\r\nEND:VCALENDAR\r\n", null) + .getFirstSubcomponent("VTIMEZONE"); + } + return this.mComponent; + }, + + get displayName() { + if (this.mDisplayName === undefined) { + try { + this.mDisplayName = g_stringBundle.GetStringFromName("pref.timezone." + this.tzid.replace(/\//g, ".")); + } catch (exc) { + // don't assert here, but gracefully fall back to TZID: + cal.LOG("Timezone property lookup failed! Falling back to " + this.tzid + "\n" + exc); + this.mDisplayName = this.tzid; + } + } + return this.mDisplayName; + }, + + get provider() { return cal.getTimezoneService(); } +}; diff --git a/calendar/base/src/calTimezoneService.js b/calendar/base/src/calTimezoneService.js new file mode 100644 index 000000000..42cd60c97 --- /dev/null +++ b/calendar/base/src/calTimezoneService.js @@ -0,0 +1,817 @@ +/* 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/. */ + +Components.utils.import("resource://gre/modules/AddonManager.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); +Components.utils.import("resource://calendar/modules/calIteratorUtils.jsm"); +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); +Components.utils.import("resource://gre/modules/Preferences.jsm"); +Components.utils.import("resource://calendar/modules/ical.js"); +Components.utils.import("resource://gre/modules/NetUtil.jsm"); +Components.utils.import("resource://gre/modules/Promise.jsm"); + +/* exported g_stringBundle */ + +var g_stringBundle = null; + +function calStringEnumerator(stringArray) { + this.mIndex = 0; + this.mStringArray = stringArray; +} +calStringEnumerator.prototype = { + // nsIUTF8StringEnumerator: + hasMore: function() { + return (this.mIndex < this.mStringArray.length); + }, + getNext: function() { + if (!this.hasMore()) { + throw Components.results.NS_ERROR_UNEXPECTED; + } + return this.mStringArray[this.mIndex++]; + } +}; + +function calTimezoneService() { + this.wrappedJSObject = this; + + this.mZones = new Map(); + + ICAL.TimezoneService = this.wrappedJSObject; +} +var calTimezoneServiceClassID = Components.ID("{e736f2bd-7640-4715-ab35-887dc866c587}"); +var calTimezoneServiceInterfaces = [ + Components.interfaces.calITimezoneService, + Components.interfaces.calITimezoneProvider, + Components.interfaces.calIStartupService +]; +calTimezoneService.prototype = { + mDefaultTimezone: null, + mHasSetupObservers: false, + mVersion: null, + mZones: null, + + classID: calTimezoneServiceClassID, + QueryInterface: XPCOMUtils.generateQI(calTimezoneServiceInterfaces), + classInfo: XPCOMUtils.generateCI({ + classID: calTimezoneServiceClassID, + contractID: "@mozilla.org/calendar/timezone-service;1", + classDescription: "Calendar Timezone Service", + interfaces: calTimezoneServiceInterfaces, + flags: Components.interfaces.nsIClassInfo.SINGLETON + }), + + // ical.js TimezoneService methods + has: function(id) { return this.getTimezone(id) != null; }, + get: function(id) { + return id ? unwrapSingle(ICAL.Timezone, this.getTimezone(id)) : null; + }, + remove: function() {}, + register: function() {}, + + // calIStartupService: + startup: function(aCompleteListener) { + function fetchJSON(aURL) { + cal.LOG("[calTimezoneService] Loading " + aURL); + + return new Promise((resolve, reject) => { + let uri = Services.io.newURI(aURL, null, null); + let channel = Services.io.newChannelFromURI2(uri, + null, + Services.scriptSecurityManager.getSystemPrincipal(), + null, + Components.interfaces.nsILoadInfo.SEC_REQUIRE_SAME_ORIGIN_DATA_INHERITS, + Components.interfaces.nsIContentPolicy.TYPE_OTHER); + + NetUtil.asyncFetch(channel, (inputStream, status) => { + if (!Components.isSuccessCode(status)) { + reject(status); + return; + } + + try { + let jsonData = NetUtil.readInputStreamToString(inputStream, inputStream.available()); + let tzData = JSON.parse(jsonData); + resolve(tzData); + } catch (ex) { + reject(ex); + } + }); + }); + } + + let resNamespace = "calendar"; + // Check for presence of the calendar timezones add-on. + let resProtocol = Services.io.getProtocolHandler("resource") + .QueryInterface(Components.interfaces.nsIResProtocolHandler); + if (resProtocol.hasSubstitution("calendar-timezones")) { + resNamespace = "calendar-timezones"; + } + + fetchJSON("resource://" + resNamespace + "/timezones/zones.json").then((tzData) => { + for (let tzid of Object.keys(tzData.aliases)) { + let data = tzData.aliases[tzid]; + if (typeof data == "object" && data !== null) { + this.mZones.set(tzid, data); + } + } + for (let tzid of Object.keys(tzData.zones)) { + let data = tzData.zones[tzid]; + if (typeof data == "object" && data !== null) { + this.mZones.set(tzid, data); + } + } + + this.mVersion = tzData.version; + cal.LOG("[calTimezoneService] Timezones version " + this.version + " loaded"); + + let bundleURL = "chrome://" + resNamespace + "/locale/timezones.properties"; + g_stringBundle = ICAL.Timezone.cal_tz_bundle = Services.strings.createBundle(bundleURL); + + // Make sure UTC and floating are cached by calling their getters + this.UTC; // eslint-disable-line no-unused-expressions + this.floating; // eslint-disable-line no-unused-expressions + }).then(() => { + if (aCompleteListener) { + aCompleteListener.onResult(null, Components.results.NS_OK); + } + }, (error) => { + // We have to give up. Show an error and fail hard! + let msg = cal.calGetString("calendar", "missingCalendarTimezonesError"); + cal.ERROR(msg); + cal.showError(msg); + }); + }, + + shutdown: function(aCompleteListener) { + Services.prefs.removeObserver("calendar.timezone.local", this); + aCompleteListener.onResult(null, Components.results.NS_OK); + }, + + get UTC() { + if (!this.mZones.has("UTC")) { + let utc; + if (Preferences.get("calendar.icaljs", false)) { + utc = new calICALJSTimezone(ICAL.Timezone.utcTimezone); + } else { + utc = new calLibicalTimezone("UTC", null, "", ""); + utc.mUTC = true; + } + + this.mZones.set("UTC", { zone: utc }); + } + + return this.mZones.get("UTC").zone; + }, + + get floating() { + if (!this.mZones.has("floating")) { + let floating; + if (Preferences.get("calendar.icaljs", false)) { + floating = new calICALJSTimezone(ICAL.Timezone.localTimezone); + } else { + floating = new calLibicalTimezone("floating", null, "", ""); + floating.isFloating = true; + } + this.mZones.set("floating", { zone: floating }); + } + + return this.mZones.get("floating").zone; + }, + + // calITimezoneProvider: + getTimezone: function(tzid) { + if (!tzid) { + cal.ERROR("Unknown timezone requested\n" + cal.STACK(10)); + return null; + } + if (tzid.startsWith("/mozilla.org/")) { + // We know that our former tzids look like "/mozilla.org/<dtstamp>/continent/..." + // The ending of the mozilla prefix is the index of that slash before the + // continent. Therefore, we start looking for the prefix-ending slash + // after position 13. + tzid = tzid.substring(tzid.indexOf("/", 13) + 1); + } + + let timezone = this.mZones.get(tzid); + if (!timezone) { + cal.ERROR("Couldn't find " + tzid); + return null; + } + if (!timezone.zone) { + if (timezone.aliasTo) { + // This zone is an alias. + timezone.zone = this.getTimezone(timezone.aliasTo); + } else if (Preferences.get("calendar.icaljs", false)) { + let parsedComp = ICAL.parse("BEGIN:VCALENDAR\r\n" + timezone.ics + "\r\nEND:VCALENDAR"); + let icalComp = new ICAL.Component(parsedComp); + let tzComp = icalComp.getFirstSubcomponent("vtimezone"); + timezone.zone = new calICALJSTimezone(ICAL.Timezone.fromData({ + tzid: tzid, + component: tzComp, + latitude: timezone.latitude, + longitude: timezone.longitude + })); + } else { + timezone.zone = new calLibicalTimezone(tzid, timezone.ics, timezone.latitude, timezone.longitude); + } + } + return timezone.zone; + }, + + get timezoneIds() { + let zones = []; + for (let [k, v] of this.mZones.entries()) { + if (!v.aliasTo && k != "UTC" && k != "floating") { + zones.push(k); + } + } + return new calStringEnumerator(zones); + }, + + get aliasIds() { + let zones = []; + for (let [key, value] of this.mZones.entries()) { + if (value.aliasTo && key != "UTC" && key != "floating") { + zones.push(key); + } + } + return new calStringEnumerator(zones); + }, + + get version() { + return this.mVersion; + }, + + get defaultTimezone() { + if (!this.mDefaultTimezone) { + let prefTzid = Preferences.get("calendar.timezone.local", null); + let tzid = prefTzid; + if (!tzid) { + try { + tzid = guessSystemTimezone(); + } catch (e) { + cal.WARN("An exception occurred guessing the system timezone, trying UTC. Exception: " + e); + tzid = "UTC"; + } + } + this.mDefaultTimezone = this.getTimezone(tzid); + cal.ASSERT(this.mDefaultTimezone, "Timezone not found: " + tzid); + // Update prefs if necessary: + if (this.mDefaultTimezone && this.mDefaultTimezone.tzid != prefTzid) { + Preferences.set("calendar.timezone.local", this.mDefaultTimezone.tzid); + } + + // We need to observe the timezone preference to update the default + // timezone if needed. + this.setupObservers(); + } + return this.mDefaultTimezone; + }, + + setupObservers: function() { + if (!this.mHasSetupObservers) { + // Now set up the observer + Services.prefs.addObserver("calendar.timezone.local", this, false); + this.mHasSetupObservers = true; + } + }, + + observe: function(aSubject, aTopic, aData) { + if (aTopic == "nsPref:changed" && aData == "calendar.timezone.local") { + // Unsetting the default timezone will make the next call to the + // default timezone getter set up the correct timezone again. + this.mDefaultTimezone = null; + } + } +}; + +/** + * We're going to do everything in our power, short of rumaging through the + * user's actual file-system, to figure out the time-zone they're in. The + * deciding factors are the offsets given by (northern-hemisphere) summer and + * winter JSdates. However, when available, we also use the name of the + * timezone in the JSdate, or a string-bundle term from the locale. + * + * @return a mozilla ICS timezone string. +*/ +function guessSystemTimezone() { + // Probe JSDates for basic OS timezone offsets and names. + const dateJun = (new Date(2005, 5, 20)).toString(); + const dateDec = (new Date(2005, 11, 20)).toString(); + const tzNameRegex = /[^(]* ([^ ]*) \(([^)]+)\)/; + const nameDataJun = dateJun.match(tzNameRegex); + const nameDataDec = dateDec.match(tzNameRegex); + const tzNameJun = nameDataJun && nameDataJun[2]; + const tzNameDec = nameDataDec && nameDataDec[2]; + const offsetRegex = /[+-]\d{4}/; + const offsetJun = dateJun.match(offsetRegex)[0]; + const offsetDec = dateDec.match(offsetRegex)[0]; + + const tzSvc = cal.getTimezoneService(); + + let continent = "Africa|America|Antarctica|Asia|Australia|Europe"; + let ocean = "Arctic|Atlantic|Indian|Pacific"; + let tzRegex = new RegExp(".*((?:" + continent + "|" + ocean + ")" + + "(?:[/][-A-Z_a-z]+)+)"); + + function getIcalString(component, property) { + let prop = (component && component.getFirstProperty(property)); + return (prop ? prop.valueAsIcalString : null); + } + + // Check if Olson ZoneInfo timezone matches OS/JSDate timezone properties: + // * standard offset and daylight/summer offset if present (longitude), + // * if has summer time, direction of change (northern/southern hemisphere) + // * if has summer time, dates of next transitions + // * timezone name (such as "Western European Standard Time"). + // Score is 3 if matches dates and names, 2 if matches dates without names, + // 1 if matches dates within a week (so changes on different weekday), + // otherwise 0 if no match. + function checkTZ(tzId) { + let timezone = tzSvc.getTimezone(tzId); + + // Have to handle UTC separately because it has no .icalComponent. + if (timezone.isUTC) { + if (offsetDec == 0 && offsetJun == 0) { + if (tzNameJun == "UTC" && tzNameDec == "UTC") { + return 3; + } else { + return 2; + } + } else { + return 0; + } + } + + let subComp = timezone.icalComponent; + // find currently applicable time period, not just first, + // because offsets of timezone may be changed over the years. + let standard = findCurrentTimePeriod(timezone, subComp, "STANDARD"); + let standardTZOffset = getIcalString(standard, "TZOFFSETTO"); + let standardName = getIcalString(standard, "TZNAME"); + let daylight = findCurrentTimePeriod(timezone, subComp, "DAYLIGHT"); + let daylightTZOffset = getIcalString(daylight, "TZOFFSETTO"); + let daylightName = getIcalString(daylight, "TZNAME"); + + // Try northern hemisphere cases. + if (offsetDec == standardTZOffset && offsetDec == offsetJun && + !daylight) { + if (standardName && standardName == tzNameJun) { + return 3; + } else { + return 2; + } + } + + if (offsetDec == standardTZOffset && offsetJun == daylightTZOffset && + daylight) { + let dateMatchWt = systemTZMatchesTimeShiftDates(timezone, subComp); + if (dateMatchWt > 0) { + if (standardName && standardName == tzNameJun && + daylightName && daylightName == tzNameDec) { + return 3; + } else { + return dateMatchWt; + } + } + } + + // Now flip them and check again, to cover southern hemisphere cases. + if (offsetJun == standardTZOffset && offsetDec == offsetJun && + !daylight) { + if (standardName && standardName == tzNameDec) { + return 3; + } else { + return 2; + } + } + + if (offsetJun == standardTZOffset && offsetDec == daylightTZOffset && + daylight) { + let dateMatchWt = systemTZMatchesTimeShiftDates(timezone, subComp); + if (dateMatchWt > 0) { + if (standardName && standardName == tzNameJun && + daylightName && daylightName == tzNameDec) { + return 3; + } else { + return dateMatchWt; + } + } + } + return 0; + } + + // returns 2=match-within-hours, 1=match-within-week, 0=no-match + function systemTZMatchesTimeShiftDates(timezone, subComp) { + // Verify local autumn and spring shifts also occur in system timezone + // (jsDate) on correct date in correct direction. + // (Differs for northern/southern hemisphere. + // Local autumn shift is to local winter STANDARD time. + // Local spring shift is to local summer DAYLIGHT time.) + const autumnShiftJSDate = + findCurrentTimePeriod(timezone, subComp, "STANDARD", true); + const afterAutumnShiftJSDate = new Date(autumnShiftJSDate); + const beforeAutumnShiftJSDate = new Date(autumnShiftJSDate); + const springShiftJSDate = + findCurrentTimePeriod(timezone, subComp, "DAYLIGHT", true); + const beforeSpringShiftJSDate = new Date(springShiftJSDate); + const afterSpringShiftJSDate = new Date(springShiftJSDate); + // Try with 6 HOURS fuzz in either direction, since OS and ZoneInfo + // may disagree on the exact time of shift (midnight, 2am, 4am, etc). + beforeAutumnShiftJSDate.setHours(autumnShiftJSDate.getHours() - 6); + afterAutumnShiftJSDate.setHours(autumnShiftJSDate.getHours() + 6); + afterSpringShiftJSDate.setHours(afterSpringShiftJSDate.getHours() + 6); + beforeSpringShiftJSDate.setHours(beforeSpringShiftJSDate.getHours() - 6); + if ((beforeAutumnShiftJSDate.getTimezoneOffset() < + afterAutumnShiftJSDate.getTimezoneOffset()) && + (beforeSpringShiftJSDate.getTimezoneOffset() > + afterSpringShiftJSDate.getTimezoneOffset())) { + return 2; + } + // Try with 7 DAYS fuzz in either direction, so if no other timezone + // found, will have a nearby timezone that disagrees only on the + // weekday of shift (sunday vs. friday vs. calendar day), or off by + // exactly one week, (e.g., needed to guess Africa/Cairo on w2k in + // 2006). + beforeAutumnShiftJSDate.setDate(autumnShiftJSDate.getDate() - 7); + afterAutumnShiftJSDate.setDate(autumnShiftJSDate.getDate() + 7); + afterSpringShiftJSDate.setDate(afterSpringShiftJSDate.getDate() + 7); + beforeSpringShiftJSDate.setDate(beforeSpringShiftJSDate.getDate() - 7); + if ((beforeAutumnShiftJSDate.getTimezoneOffset() < + afterAutumnShiftJSDate.getTimezoneOffset()) && + (beforeSpringShiftJSDate.getTimezoneOffset() > + afterSpringShiftJSDate.getTimezoneOffset())) { + return 1; + } + // no match + return 0; + } + + const todayUTC = cal.jsDateToDateTime(new Date()); + const oneYrUTC = todayUTC.clone(); oneYrUTC.year += 1; + const periodStartCalDate = cal.createDateTime(); + const periodUntilCalDate = cal.createDateTime(); // until timezone is UTC + const periodCalRule = cal.createRecurrenceRule(); + const untilRegex = /UNTIL=(\d{8}T\d{6}Z)/; + + function findCurrentTimePeriod(timezone, subComp, standardOrDaylight, + isForNextTransitionDate) { + // Iterate through 'STANDARD' declarations or 'DAYLIGHT' declarations + // (periods in history with different settings. + // e.g., US changes daylight start in 2007 (from April to March).) + // Each period is marked by a DTSTART. + // Find the currently applicable period: has most recent DTSTART + // not later than today and no UNTIL, or UNTIL is greater than today. + for (let period of cal.ical.subcomponentIterator(subComp, standardOrDaylight)) { + periodStartCalDate.icalString = getIcalString(period, "DTSTART"); + periodStartCalDate.timezone = timezone; + if (oneYrUTC.nativeTime < periodStartCalDate.nativeTime) { + continue; // period starts too far in future + } + // Must examine UNTIL date (not next daylight start) because + // some zones (e.g., Arizona, Hawaii) may stop using daylight + // time, so there might not be a next daylight start. + let rrule = period.getFirstProperty("RRULE"); + if (rrule) { + let match = untilRegex.exec(rrule.valueAsIcalString); + if (match) { + periodUntilCalDate.icalString = match[1]; + if (todayUTC.nativeTime > periodUntilDate.nativeTime) { + continue; // period ends too early + } + } // else forever rule + } // else no daylight rule + + // found period that covers today. + if (!isForNextTransitionDate) { + return period; + } else if (todayUTC.nativeTime < periodStartCalDate.nativeTime) { + // already know periodStartCalDate < oneYr from now, + // and transitions are at most once per year, so it is next. + return cal.dateTimeToJsDate(periodStartCalDate); + } else if (rrule) { + // find next occurrence after today + periodCalRule.icalProperty = rrule; + let nextTransitionDate = + periodCalRule.getNextOccurrence(periodStartCalDate, + todayUTC); + // make sure rule doesn't end before next transition date. + if (nextTransitionDate) { + return cal.dateTimeToJsDate(nextTransitionDate); + } + } + } + // no such period found + return null; + } + + function environmentVariableValue(varName) { + let envSvc = Components.classes["@mozilla.org/process/environment;1"] + .getService(Components.interfaces.nsIEnvironment); + let value = envSvc.get(varName); + if (!value || !value.match(tzRegex)) { + return ""; + } + return varName + "=" + value; + } + + function symbolicLinkTarget(filepath) { + try { + let file = Components.classes["@mozilla.org/file/local;1"] + .createInstance(Components.interfaces.nsILocalFile); + file.initWithPath(filepath); + file.QueryInterface(Components.interfaces.nsIFile); + if (!file.exists() || !file.isSymlink() || !file.target.match(tzRegex)) { + return ""; + } + + return filepath + " -> " + file.target; + } catch (ex) { + Components.utils.reportError(filepath + ": " + ex); + return ""; + } + } + + function fileFirstZoneLineString(filepath) { + // return first line of file that matches tzRegex (ZoneInfo id), + // or "" if no file or no matching line. + try { + let file = Components.classes["@mozilla.org/file/local;1"] + .createInstance(Components.interfaces.nsILocalFile); + file.initWithPath(filepath); + file.QueryInterface(Components.interfaces.nsIFile); + if (!file.exists()) { + return ""; + } + let fileInstream = Components.classes["@mozilla.org/network/file-input-stream;1"] + .createInstance(Components.interfaces.nsIFileInputStream); + const PR_RDONLY = 0x1; + fileInstream.init(file, PR_RDONLY, 0, 0); + fileInstream.QueryInterface(Components.interfaces.nsILineInputStream); + try { + let line = {}, hasMore = true, MAXLINES = 10; + for (let i = 0; hasMore && i < MAXLINES; i++) { + hasMore = fileInstream.readLine(line); + if (line.value && line.value.match(tzRegex)) { + return filepath + ": " + line.value; + } + } + return ""; // not found + } finally { + fileInstream.close(); + } + } catch (ex) { + Components.utils.reportError(filepath + ": " + ex); + return ""; + } + } + + function weekday(icsDate, timezone) { + let calDate = cal.createDateTime(icsDate); + calDate.timezone = timezone; + return cal.dateTimeToJsDate(calDate).toLocaleFormat("%a"); + } + + // Try to find a tz that matches OS/JSDate timezone. If no name match, + // will use first of probable timezone(s) with highest score. + let probableTZId = "floating"; // default fallback tz if no tz matches. + let probableTZScore = 0; + let probableTZSource = null; + + const calProperties = Services.strings.createBundle("chrome://calendar/locale/calendar.properties"); + + // First, try to detect operating system timezone. + let zoneInfoIdFromOSUserTimeZone = null; + let osUserTimeZone = null; + try { + let handler = Components.classes["@mozilla.org/network/protocol;1?name=http"] + .getService(Components.interfaces.nsIHttpProtocolHandler); + + if (handler.oscpu.match(/^Windows/)) { + let wrk = Components.classes["@mozilla.org/windows-registry-key;1"] + .createInstance(Components.interfaces.nsIWindowsRegKey); + wrk.open(wrk.ROOT_KEY_LOCAL_MACHINE, + "SYSTEM\\CurrentControlSet\\Control\\TimeZoneInformation", + wrk.ACCESS_READ); + if (wrk.hasValue("TimeZoneKeyName")) { + // Windows Vista and later have this key. + // Clear trailing garbage on this key, see bug 1129712. + osUserTimeZone = wrk.readStringValue("TimeZoneKeyName").split("\0")[0]; + } else { + // If on Windows XP, current timezone only lists its localized name, + // so to find its registry key name, match localized name to + // localized names of each windows timezone listed in registry. + // Then use the registry key name to see if this timezone has a + // known ZoneInfo name. + let currentTZStandardName = wrk.readStringValue("StandardName"); + wrk.close(); + + wrk.open(wrk.ROOT_KEY_LOCAL_MACHINE, + "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\Time Zones", + wrk.ACCESS_READ); + + // Linear search matching localized name of standard timezone + // to find the non-localized registry key. + // (Registry keys are sorted by subkeyName, not by localized name + // nor offset, so cannot use binary search.) + for (let i = 0; i < wrk.childCount; i++) { + let subkeyName = wrk.getChildName(i); + let subkey = wrk.openChild(subkeyName, wrk.ACCESS_READ); + let std = subkey.readStringValue("Std"); + subkey.close(); + if (std == currentTZStandardName) { + osUserTimeZone = subkeyName; + break; + } + } + } + wrk.close(); + + if (osUserTimeZone != null) { + // Lookup timezone registry key in table of known tz keys + // to convert to ZoneInfo timezone id. + const regKeyToZoneInfoBundle = Services.strings.createBundle( + "chrome://calendar/content/WindowsNTToZoneInfoTZId.properties"); + zoneInfoIdFromOSUserTimeZone = + regKeyToZoneInfoBundle.GetStringFromName(osUserTimeZone); + } + } else { + // Else look for ZoneInfo timezone id in + // - TZ environment variable value + // - /etc/localtime symbolic link target path + // - /etc/TIMEZONE or /etc/timezone file content + // - /etc/sysconfig/clock file line content. + // The timezone is set per user via the TZ environment variable. + // TZ may contain a path that may start with a colon and ends with + // a ZoneInfo timezone identifier, such as ":America/New_York" or + // ":/share/lib/zoneinfo/America/New_York". The others are + // in the filesystem so they give one timezone for the system; + // the values are similar (but cannot have a leading colon). + // (Note: the OS ZoneInfo database may be a different version from + // the one we use, so still need to check that DST dates match.) + osUserTimeZone = environmentVariableValue("TZ") || + symbolicLinkTarget("/etc/localtime") || + fileFirstZoneLineString("/etc/TIMEZONE") || + fileFirstZoneLineString("/etc/timezone") || + fileFirstZoneLineString("/etc/sysconfig/clock"); + let results = osUserTimeZone.match(tzRegex); + if (results) { + zoneInfoIdFromOSUserTimeZone = results[1]; + } + } + + // check how well OS tz matches tz defined in our version of zoneinfo db + if (zoneInfoIdFromOSUserTimeZone != null) { + let tzId = zoneInfoIdFromOSUserTimeZone; + let score = checkTZ(tzId); + switch (score) { + case 0: + // Did not match. + // Maybe OS or Application is old, and the timezone changed. + // Or maybe user turned off DST in Date/Time control panel. + // Will look for a better matching tz, or fallback to floating. + // (Match OS so alarms go off at time indicated by OS clock.) + cal.WARN(calProperties.formatStringFromName( + "WarningOSTZNoMatch", [osUserTimeZone, zoneInfoIdFromOSUserTimeZone], 2)); + break; + case 1: case 2: + // inexact match: OS TZ and our ZoneInfo TZ matched imperfectly. + // Will keep looking, will use tzId unless another is better. + // (maybe OS TZ has changed to match a nearby TZ, so maybe + // another ZoneInfo TZ matches it better). + probableTZId = tzId; + probableTZScore = score; + probableTZSource = calProperties.formatStringFromName( + "TZFromOS", [osUserTimeZone], 1); + + break; + case 3: + // exact match + return tzId; + } + } + } catch (ex) { + // zoneInfo id given was not recognized by our ZoneInfo database + let errParams = [zoneInfoIdFromOSUserTimeZone || osUserTimeZone]; + let errMsg = calProperties.formatStringFromName("SkippingOSTimezone", errParams, 1); + Components.utils.reportError(errMsg + " " + ex); + } + + // Second, give priority to "likelyTimezone"s if provided by locale. + try { + // The likelyTimezone property is a comma-separated list of + // ZoneInfo timezone ids. + const bundleTZString = + calProperties.GetStringFromName("likelyTimezone"); + const bundleTZIds = bundleTZString.split(/\s*,\s*/); + for (let bareTZId of bundleTZIds) { + let tzId = bareTZId; + try { + let score = checkTZ(tzId); + + switch (score) { + case 0: + break; + case 1: case 2: + if (score > probableTZScore) { + probableTZId = tzId; + probableTZScore = score; + probableTZSource = calProperties.GetStringFromName("TZFromLocale"); + } + break; + case 3: + return tzId; + } + } catch (ex) { + let errMsg = calProperties.formatStringFromName( + "SkippingLocaleTimezone", [bareTZId], 1); + Components.utils.reportError(errMsg + " " + ex); + } + } + } catch (ex) { // Oh well, this didn't work, next option... + Components.utils.reportError(ex); + } + + // Third, try all known timezones. + const tzIDs = tzSvc.timezoneIds; + while (tzIDs.hasMore()) { + let tzId = tzIDs.getNext(); + try { + let score = checkTZ(tzId); + switch (score) { + case 0: break; + case 1: case 2: + if (score > probableTZScore) { + probableTZId = tzId; + probableTZScore = score; + probableTZSource = calProperties.GetStringFromName("TZFromKnownTimezones"); + } + break; + case 3: + return tzId; + } + } catch (ex) { // bug if ics service doesn't recognize own tzid! + let msg = "ics-service doesn't recognize own tzid: " + tzId + "\n" + ex; + Components.utils.reportError(msg); + } + } + + // If reach here, there were no score=3 matches, so Warn in console. + try { + switch (probableTZScore) { + case 0: { + cal.WARN(calProperties.GetStringFromName("warningUsingFloatingTZNoMatch")); + break; + } + case 1: + case 2: { + let tzId = probableTZId; + let timezone = tzSvc.getTimezone(tzId); + let subComp = timezone.icalComponent; + let standard = findCurrentTimePeriod(timezone, subComp, "STANDARD"); + let standardTZOffset = getIcalString(standard, "TZOFFSETTO"); + let daylight = findCurrentTimePeriod(timezone, subComp, "DAYLIGHT"); + let daylightTZOffset = getIcalString(daylight, "TZOFFSETTO"); + let warningDetail; + if (probableTZScore == 1) { + // score 1 means has daylight time, + // but transitions start on different weekday from os timezone. + let standardStart = getIcalString(standard, "DTSTART"); + let standardStartWeekday = weekday(standardStart, timezone); + let standardRule = getIcalString(standard, "RRULE"); + let standardText = + " Standard: " + standardStart + " " + standardStartWeekday + "\n" + + " " + standardRule + "\n"; + let daylightStart = getIcalString(daylight, "DTSTART"); + let daylightStartWeekday = weekday(daylightStart, timezone); + let daylightRule = getIcalString(daylight, "RRULE"); + let daylightText = + " Daylight: " + daylightStart + " " + daylightStartWeekday + "\n" + + " " + daylightRule + "\n"; + warningDetail = + (standardStart < daylightStart + ? standardText + daylightText + : daylightText + standardText) + + calProperties.GetStringFromName("TZAlmostMatchesOSDifferAtMostAWeek"); + } else { + warningDetail = calProperties.GetStringFromName("TZSeemsToMatchOS"); + } + let offsetString = standardTZOffset + + (daylightTZOffset ? "/" + daylightTZOffset : ""); + let warningMsg = calProperties.formatStringFromName("WarningUsingGuessedTZ", + [tzId, offsetString, warningDetail, probableTZSource], 4); + cal.WARN(warningMsg); + break; + } + } + } catch (ex) { // don't abort if error occurs warning user + Components.utils.reportError(ex); + } + + // return the guessed timezone + return probableTZId; +} + +this.NSGetFactory = cal.loadingNSGetFactory(["calTimezone.js"], [calTimezoneService], this); diff --git a/calendar/base/src/calTimezoneService.manifest b/calendar/base/src/calTimezoneService.manifest new file mode 100644 index 000000000..e8089ed2e --- /dev/null +++ b/calendar/base/src/calTimezoneService.manifest @@ -0,0 +1,2 @@ +component {e736f2bd-7640-4715-ab35-887dc866c587} calTimezoneService.js +contract @mozilla.org/calendar/timezone-service;1 {e736f2bd-7640-4715-ab35-887dc866c587} diff --git a/calendar/base/src/calTodo.js b/calendar/base/src/calTodo.js new file mode 100644 index 000000000..bb0fae782 --- /dev/null +++ b/calendar/base/src/calTodo.js @@ -0,0 +1,249 @@ +/* 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/. */ + +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + +// +// constructor +// +function calTodo() { + this.initItemBase(); + + this.todoPromotedProps = { + DTSTART: true, + DTEND: true, + DUE: true, + COMPLETED: true, + __proto__: this.itemBasePromotedProps + }; +} + +var calTodoClassID = Components.ID("{7af51168-6abe-4a31-984d-6f8a3989212d}"); +var calTodoInterfaces = [ + Components.interfaces.calIItemBase, + Components.interfaces.calITodo, + Components.interfaces.calIInternalShallowCopy +]; +calTodo.prototype = { + __proto__: calItemBase.prototype, + + classID: calTodoClassID, + QueryInterface: XPCOMUtils.generateQI(calTodoInterfaces), + classInfo: XPCOMUtils.generateCI({ + classID: calTodoClassID, + contractID: "@mozilla.org/calendar/todo;1", + classDescription: "Calendar Todo", + interfaces: calTodoInterfaces, + }), + + cloneShallow: function(aNewParent) { + let cloned = new calTodo(); + this.cloneItemBaseInto(cloned, aNewParent); + return cloned; + }, + + createProxy: function(aRecurrenceId) { + cal.ASSERT(!this.mIsProxy, "Tried to create a proxy for an existing proxy!", true); + + let proxy = new calTodo(); + + // override proxy's DTSTART/DUE/RECURRENCE-ID + // before master is set (and item might get immutable): + let duration = this.duration; + if (duration) { + let dueDate = aRecurrenceId.clone(); + dueDate.addDuration(duration); + proxy.dueDate = dueDate; + } + proxy.entryDate = aRecurrenceId; + + proxy.initializeProxy(this, aRecurrenceId); + proxy.mDirty = false; + + return proxy; + }, + + makeImmutable: function() { + this.makeItemBaseImmutable(); + }, + + get isCompleted() { + return this.completedDate != null || + this.percentComplete == 100 || + this.status == "COMPLETED"; + }, + + set isCompleted(completed) { + if (completed) { + if (!this.completedDate) { + this.completedDate = cal.jsDateToDateTime(new Date()); + } + this.status = "COMPLETED"; + this.percentComplete = 100; + } else { + this.deleteProperty("COMPLETED"); + this.deleteProperty("STATUS"); + this.deleteProperty("PERCENT-COMPLETE"); + } + }, + + get duration() { + let dur = this.getProperty("DURATION"); + // pick up duration if available, otherwise calculate difference + // between start and enddate + if (dur) { + return cal.createDuration(dur); + } else { + if (!this.entryDate || !this.dueDate) { + return null; + } + return this.dueDate.subtractDate(this.entryDate); + } + }, + + set duration(value) { + this.setProperty("DURATION", value); + }, + + get recurrenceStartDate() { + // DTSTART is optional for VTODOs, so it's unclear if RRULE is allowed then, + // so fallback to DUE if no DTSTART is present: + return this.entryDate || this.dueDate; + }, + + icsEventPropMap: [ + { cal: "DTSTART", ics: "startTime" }, + { cal: "DUE", ics: "dueTime" }, + { cal: "COMPLETED", ics: "completedTime" }], + + set icalString(value) { + this.icalComponent = getIcsService().parseICS(value, null); + }, + + get icalString() { + let calcomp = getIcsService().createIcalComponent("VCALENDAR"); + calSetProdidVersion(calcomp); + calcomp.addSubcomponent(this.icalComponent); + return calcomp.serializeToICS(); + }, + + get icalComponent() { + let icssvc = getIcsService(); + let icalcomp = icssvc.createIcalComponent("VTODO"); + this.fillIcalComponentFromBase(icalcomp); + this.mapPropsToICS(icalcomp, this.icsEventPropMap); + + let bagenum = this.propertyEnumerator; + while (bagenum.hasMoreElements()) { + let iprop = bagenum.getNext() + .QueryInterface(Components.interfaces.nsIProperty); + try { + if (!this.todoPromotedProps[iprop.name]) { + let icalprop = icssvc.createIcalProperty(iprop.name); + icalprop.value = iprop.value; + let propBucket = this.mPropertyParams[iprop.name]; + if (propBucket) { + for (let paramName in propBucket) { + try { + icalprop.setParameter(paramName, propBucket[paramName]); + } catch (e) { + if (e.result == Components.results.NS_ERROR_ILLEGAL_VALUE) { + // Illegal values should be ignored, but we could log them if + // the user has enabled logging. + cal.LOG("Warning: Invalid todo parameter value " + paramName + "=" + propBucket[paramName]); + } else { + throw e; + } + } + } + } + icalcomp.addProperty(icalprop); + } + } catch (e) { + cal.ERROR("failed to set " + iprop.name + " to " + iprop.value + ": " + e + "\n"); + } + } + return icalcomp; + }, + + todoPromotedProps: null, + + set icalComponent(todo) { + this.modify(); + if (todo.componentType != "VTODO") { + todo = todo.getFirstSubcomponent("VTODO"); + if (!todo) { + throw Components.results.NS_ERROR_INVALID_ARG; + } + } + + this.mDueDate = undefined; + this.setItemBaseFromICS(todo); + this.mapPropsFromICS(todo, this.icsEventPropMap); + + this.importUnpromotedProperties(todo, this.todoPromotedProps); + // Importing didn't really change anything + this.mDirty = false; + }, + + isPropertyPromoted: function(name) { + // avoid strict undefined property warning + return this.todoPromotedProps[name] || false; + }, + + set entryDate(value) { + this.modify(); + + // We're about to change the start date of an item which probably + // could break the associated calIRecurrenceInfo. We're calling + // the appropriate method here to adjust the internal structure in + // order to free clients from worrying about such details. + if (this.parentItem == this) { + let rec = this.recurrenceInfo; + if (rec) { + rec.onStartDateChange(value, this.entryDate); + } + } + + return this.setProperty("DTSTART", value); + }, + + get entryDate() { + return this.getProperty("DTSTART"); + }, + + mDueDate: undefined, + get dueDate() { + let dueDate = this.mDueDate; + if (dueDate === undefined) { + dueDate = this.getProperty("DUE"); + if (!dueDate) { + let entryDate = this.entryDate; + let dur = this.getProperty("DURATION"); + if (entryDate && dur) { + // If there is a duration set on the todo, calculate the right end time. + dueDate = entryDate.clone(); + dueDate.addDuration(cal.createDuration(dur)); + } + } + this.mDueDate = dueDate; + } + return dueDate; + }, + + set dueDate(value) { + this.deleteProperty("DURATION"); // setting dueDate once removes DURATION + this.setProperty("DUE", value); + return (this.mDueDate = value); + } +}; + +// var decl to prevent spurious error messages when loaded as component + +var makeMemberAttr; +if (makeMemberAttr) { + makeMemberAttr(calTodo, "COMPLETED", null, "completedDate", true); + makeMemberAttr(calTodo, "PERCENT-COMPLETE", 0, "percentComplete", true); +} diff --git a/calendar/base/src/calTransactionManager.js b/calendar/base/src/calTransactionManager.js new file mode 100644 index 000000000..e76eb2269 --- /dev/null +++ b/calendar/base/src/calTransactionManager.js @@ -0,0 +1,215 @@ +/* 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/. */ + +Components.utils.import("resource://calendar/modules/calItipUtils.jsm"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); + +function calTransactionManager() { + this.wrappedJSObject = this; + if (!this.transactionManager) { + this.transactionManager = + Components.classes["@mozilla.org/transactionmanager;1"] + .createInstance(Components.interfaces.nsITransactionManager); + } +} + +var calTransactionManagerClassID = Components.ID("{40a1ccf4-5f54-4815-b842-abf06f84dbfd}"); +var calTransactionManagerInterfaces = [Components.interfaces.calITransactionManager]; +calTransactionManager.prototype = { + + classID: calTransactionManagerClassID, + QueryInterface: XPCOMUtils.generateQI(calTransactionManagerInterfaces), + classInfo: XPCOMUtils.generateCI({ + classID: calTransactionManagerClassID, + classDescription: "Calendar Transaction Manager", + contractID: "mozilla.org/calendar/transactionmanager;1", + interfaces: calTransactionManagerInterfaces, + flags: Components.interfaces.nsIClassInfo.SINGLETON + }), + + transactionManager: null, + createAndCommitTxn: function(aAction, aItem, aCalendar, aOldItem, aListener) { + let txn = new calTransaction(aAction, + aItem, + aCalendar, + aOldItem, + aListener); + this.transactionManager.doTransaction(txn); + }, + + beginBatch: function() { + this.transactionManager.beginBatch(null); + }, + + endBatch: function() { + this.transactionManager.endBatch(false); + }, + + checkWritable: function(transaction) { + function checkItem(item) { + return item && item.calendar && + isCalendarWritable(item.calendar) && + userCanAddItemsToCalendar(item.calendar); + } + + let trans = transaction && transaction.wrappedJSObject; + return trans && checkItem(trans.mItem) && checkItem(trans.mOldItem); + }, + + undo: function() { + this.transactionManager.undoTransaction(); + }, + + canUndo: function() { + return this.transactionManager.numberOfUndoItems > 0 && + this.checkWritable(this.transactionManager.peekUndoStack()); + }, + + redo: function() { + this.transactionManager.redoTransaction(); + }, + + canRedo: function() { + return this.transactionManager.numberOfRedoItems > 0 && + this.checkWritable(this.transactionManager.peekRedoStack()); + } +}; + +function calTransaction(aAction, aItem, aCalendar, aOldItem, aListener) { + this.wrappedJSObject = this; + this.mAction = aAction; + this.mItem = aItem; + this.mCalendar = aCalendar; + this.mOldItem = aOldItem; + this.mListener = aListener; +} + +var calTransactionClassID = Components.ID("{fcb54c82-2fb9-42cb-bf44-1e197a55e520}"); +var calTransactionInterfaces = [ + Components.interfaces.nsITransaction, + Components.interfaces.calIOperationListener +]; +calTransaction.prototype = { + classID: calTransactionClassID, + QueryInterface: XPCOMUtils.generateQI(calTransactionInterfaces), + classInfo: XPCOMUtils.generateCI({ + classID: calTransactionClassID, + classDescription: "Calendar Transaction", + contractID: "mozilla.org/calendar/transaction;1", + interfaces: calTransactionInterfaces, + }), + + mAction: null, + mCalendar: null, + mItem: null, + mOldItem: null, + mOldCalendar: null, + mListener: null, + mIsDoTransaction: false, + + onOperationComplete: function(aCalendar, aStatus, aOperationType, aId, aDetail) { + if (Components.isSuccessCode(aStatus)) { + cal.itip.checkAndSend(aOperationType, + aDetail, + this.mIsDoTransaction ? this.mOldItem : this.mItem); + + if (aOperationType == Components.interfaces.calIOperationListener.ADD || + aOperationType == Components.interfaces.calIOperationListener.MODIFY) { + if (this.mIsDoTransaction) { + this.mItem = aDetail; + } else { + this.mOldItem = aDetail; + } + } + } + if (this.mListener) { + this.mListener.onOperationComplete(aCalendar, + aStatus, + aOperationType, + aId, + aDetail); + } + }, + + onGetResult: function(aCalendar, aStatus, aItemType, aDetail, aCount, aItems) { + if (this.mListener) { + this.mListener.onGetResult(aCalendar, + aStatus, + aItemType, + aDetail, + aCount, + aItems); + } + }, + + doTransaction: function() { + this.mIsDoTransaction = true; + switch (this.mAction) { + case "add": + this.mCalendar.addItem(this.mItem, this); + break; + case "modify": + if (this.mItem.calendar.id == this.mOldItem.calendar.id) { + this.mCalendar.modifyItem(cal.itip.prepareSequence(this.mItem, this.mOldItem), + this.mOldItem, + this); + } else { + let self = this; + let addListener = { + onOperationComplete: function(aCalendar, aStatus, aOperationType, aId, aDetail) { + self.onOperationComplete(...arguments); + if (Components.isSuccessCode(aStatus)) { + self.mOldItem.calendar.deleteItem(self.mOldItem, self); + } + } + }; + + this.mOldCalendar = this.mOldItem.calendar; + this.mCalendar.addItem(this.mItem, addListener); + } + break; + case "delete": + this.mCalendar.deleteItem(this.mItem, this); + break; + default: + throw new Components.Exception("Invalid action specified", + Components.results.NS_ERROR_ILLEGAL_VALUE); + } + }, + + undoTransaction: function() { + this.mIsDoTransaction = false; + switch (this.mAction) { + case "add": + this.mCalendar.deleteItem(this.mItem, this); + break; + case "modify": + if (this.mOldItem.calendar.id == this.mItem.calendar.id) { + this.mCalendar.modifyItem(cal.itip.prepareSequence(this.mOldItem, this.mItem), + this.mItem, this); + } else { + this.mCalendar.deleteItem(this.mItem, this); + this.mOldCalendar.addItem(this.mOldItem, this); + } + break; + case "delete": + this.mCalendar.addItem(this.mItem, this); + break; + default: + throw new Components.Exception("Invalid action specified", + Components.results.NS_ERROR_ILLEGAL_VALUE); + } + }, + + redoTransaction: function() { + this.doTransaction(); + }, + + isTransient: false, + + merge: function(aTransaction) { + // No support for merging + return false; + } +}; diff --git a/calendar/base/src/calUtils.js b/calendar/base/src/calUtils.js new file mode 100644 index 000000000..bc150c371 --- /dev/null +++ b/calendar/base/src/calUtils.js @@ -0,0 +1,1914 @@ +/* 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 contains commonly used functions in a centralized place so that + * various components (and other js scopes) don't need to replicate them. Note + * that loading this file twice in the same scope will throw errors. + */ + + +/* exported createEvent, createTodo, createDateTime, createDuration, createAttendee, + * createAttachment, createAlarm, createRelation, + * createRecurrenceDate, createRecurrenceRule, createRecurrenceInfo, + * getCalendarManager, getIcsService, getCalendarSearchService, + * getFreeBusyService, getWeekInfoService, getDateFormatter, UTC, + * floating, saveRecentTimezone, getCalendarDirectory, + * isCalendarWritable, userCanAddItemsToCalendar, + * userCanDeleteItemsFromCalendar, attendeeMatchesAddresses, + * userCanRespondToInvitation, openCalendarWizard, + * openCalendarProperties, calPrint, makeURL, calRadioGroupSelectItem, + * isItemSupported, calInstanceOf, getPrefSafe, setPref, + * setLocalizedPref, getLocalizedPref, getPrefCategoriesArray, + * setPrefCategoriesFromArray, compareItems, calTryWrappedJSObject, + * compareArrays, doQueryInterface, setDefaultStartEndHour, LOG, WARN, + * ERROR, showError, getContrastingTextColor, calGetEndDateProp, + * checkIfInRange, getProgressAtom, sendMailTo, sameDay, + * calSetProdidVersion, applyAttributeToMenuChildren, + * isPropertyValueSame, getParentNodeOrThis, + * getParentNodeOrThisByAttribute, setItemProperty, + * calIterateEmailIdentities, compareItemContent, binaryInsert, + * getCompositeCalendar, findItemWindow + */ + +Components.utils.import("resource:///modules/mailServices.js"); +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); +Components.utils.import("resource://gre/modules/Services.jsm"); +Components.utils.import("resource://gre/modules/Preferences.jsm"); +Components.utils.import("resource://gre/modules/AppConstants.jsm"); + +function _calIcalCreator(cid, iid) { + return function(icalString) { + let thing = Components.classes[cid].createInstance(iid); + if (icalString) { + thing.icalString = icalString; + } + return thing; + }; +} + +var createEvent = _calIcalCreator("@mozilla.org/calendar/event;1", + Components.interfaces.calIEvent); +var createTodo = _calIcalCreator("@mozilla.org/calendar/todo;1", + Components.interfaces.calITodo); +var createDateTime = _calIcalCreator("@mozilla.org/calendar/datetime;1", + Components.interfaces.calIDateTime); +var createDuration = _calIcalCreator("@mozilla.org/calendar/duration;1", + Components.interfaces.calIDuration); +var createAttendee = _calIcalCreator("@mozilla.org/calendar/attendee;1", + Components.interfaces.calIAttendee); +var createAttachment = _calIcalCreator("@mozilla.org/calendar/attachment;1", + Components.interfaces.calIAttachment); +var createAlarm = _calIcalCreator("@mozilla.org/calendar/alarm;1", + Components.interfaces.calIAlarm); +var createRelation = _calIcalCreator("@mozilla.org/calendar/relation;1", + Components.interfaces.calIRelation); +var createRecurrenceDate = _calIcalCreator("@mozilla.org/calendar/recurrence-date;1", + Components.interfaces.calIRecurrenceDate); +var createRecurrenceRule = _calIcalCreator("@mozilla.org/calendar/recurrence-rule;1", + Components.interfaces.calIRecurrenceRule); + +/* Returns a clean new calIRecurrenceInfo */ +function createRecurrenceInfo(aItem) { + let recInfo = Components.classes["@mozilla.org/calendar/recurrence-info;1"] + .createInstance(Components.interfaces.calIRecurrenceInfo); + recInfo.item = aItem; + return recInfo; +} + +/* Shortcut to the calendar-manager service */ +function getCalendarManager() { + return Components.classes["@mozilla.org/calendar/manager;1"] + .getService(Components.interfaces.calICalendarManager); +} + +/* Shortcut to the ICS service */ +function getIcsService() { + return Components.classes["@mozilla.org/calendar/ics-service;1"] + .getService(Components.interfaces.calIICSService); +} + +/* Shortcut to the timezone service */ +function getTimezoneService() { + return Components.classes["@mozilla.org/calendar/timezone-service;1"] + .getService(Components.interfaces.calITimezoneService); +} + +/* Shortcut to calendar search service */ +function getCalendarSearchService() { + return Components.classes["@mozilla.org/calendar/calendarsearch-service;1"] + .getService(Components.interfaces.calICalendarSearchProvider); +} + +/* Shortcut to the freebusy service */ +function getFreeBusyService() { + return Components.classes["@mozilla.org/calendar/freebusy-service;1"] + .getService(Components.interfaces.calIFreeBusyService); +} + +/* Shortcut to week info service */ +function getWeekInfoService() { + return Components.classes["@mozilla.org/calendar/weekinfo-service;1"] + .getService(Components.interfaces.calIWeekInfoService); +} + +/* Shortcut to date formatter service */ +function getDateFormatter() { + return Components.classes["@mozilla.org/calendar/datetime-formatter;1"] + .getService(Components.interfaces.calIDateTimeFormatter); +} + +// @return the UTC timezone. +function UTC() { + if (UTC.mObject === undefined) { + UTC.mObject = getTimezoneService().UTC; + } + return UTC.mObject; +} + +// @return the floating timezone. +function floating() { + if (floating.mObject === undefined) { + floating.mObject = getTimezoneService().floating; + } + return floating.mObject; +} + +/** + * Function to get the best guess at a user's default timezone. + * + * @return user's default timezone. + */ +function calendarDefaultTimezone() { + return getTimezoneService().defaultTimezone; +} + +/** + * Makes sure the given timezone id is part of the list of recent timezones. + * + * @param aTzid The timezone id to add + */ +function saveRecentTimezone(aTzid) { + let recentTimezones = getRecentTimezones(); + const MAX_RECENT_TIMEZONES = 5; // We don't need a pref for *everything*. + + if (aTzid != calendarDefaultTimezone().tzid && + !recentTimezones.includes(aTzid)) { + // Add the timezone if its not already the default timezone + recentTimezones.unshift(aTzid); + recentTimezones.splice(MAX_RECENT_TIMEZONES); + Preferences.set("calendar.timezone.recent", JSON.stringify(recentTimezones)); + } +} + +/** + * Gets the list of recent timezones. Optionally retuns the list as + * calITimezones. + * + * @param aConvertZones (optional) If true, return calITimezones instead + * @return An array of timezone ids or calITimezones. + */ +function getRecentTimezones(aConvertZones) { + let recentTimezones = JSON.parse(Preferences.get("calendar.timezone.recent", "[]") || "[]"); + if (!Array.isArray(recentTimezones)) { + recentTimezones = []; + } + + let tzService = cal.getTimezoneService(); + if (aConvertZones) { + let oldZonesLength = recentTimezones.length; + for (let i = 0; i < recentTimezones.length; i++) { + let timezone = tzService.getTimezone(recentTimezones[i]); + if (timezone) { + // Replace id with found timezone + recentTimezones[i] = timezone; + } else { + // Looks like the timezone doesn't longer exist, remove it + recentTimezones.splice(i, 1); + i--; + } + } + + if (oldZonesLength != recentTimezones.length) { + // Looks like the one or other timezone dropped out. Go ahead and + // modify the pref. + Preferences.set("calendar.timezone.recent", JSON.stringify(recentTimezones)); + } + } + return recentTimezones; +} + +/** + * Format the given string to work inside a CSS rule selector + * (and as part of a non-unicode preference key). + * + * Replaces each space ' ' char with '_'. + * Replaces each char other than ascii digits and letters, with '-uxHHH-' + * where HHH is unicode in hexadecimal (variable length, terminated by the '-'). + * + * Ensures: result only contains ascii digits, letters,'-', and '_'. + * Ensures: result is invertible, so (f(a) = f(b)) implies (a = b). + * also means f is not idempotent, so (a != f(a)) implies (f(a) != f(f(a))). + * Ensures: result must be lowercase. + * Rationale: preference keys require 8bit chars, and ascii chars are legible + * in most fonts (in case user edits PROFILE/prefs.js). + * CSS class names in Gecko 1.8 seem to require lowercase, + * no punctuation, and of course no spaces. + * nmchar [_a-zA-Z0-9-]|{nonascii}|{escape} + * name {nmchar}+ + * http://www.w3.org/TR/CSS21/grammar.html#scanner + * + * @param aString The unicode string to format + * @return The formatted string using only chars [_a-zA-Z0-9-] + */ +function formatStringForCSSRule(aString) { + function toReplacement(char) { + // char code is natural number (positive integer) + let nat = char.charCodeAt(0); + switch (nat) { + case 0x20: // space + return "_"; + default: + return "-ux" + nat.toString(16) + "-"; // lowercase + } + } + // Result must be lowercase or style rule will not work. + return aString.toLowerCase().replace(/[^a-zA-Z0-9]/g, toReplacement); +} + +/** + * Shared dialog functions + * Gets the calendar directory, defaults to <profile-dir>/calendar + */ +function getCalendarDirectory() { + if (getCalendarDirectory.mDir === undefined) { + let dir = Services.dirsvc.get("ProfD", Components.interfaces.nsILocalFile); + dir.append("calendar-data"); + if (!dir.exists()) { + try { + dir.create(Components.interfaces.nsIFile.DIRECTORY_TYPE, + parseInt("0700", 8)); + } catch (exc) { + ASSERT(false, exc); + throw exc; + } + } + getCalendarDirectory.mDir = dir; + } + return getCalendarDirectory.mDir.clone(); +} + +/** + * Check if the specified calendar is writable. This is the case when it is not + * marked readOnly, we are not offline, or we are offline and the calendar is + * local. + * + * @param aCalendar The calendar to check + * @return True if the calendar is writable + */ +function isCalendarWritable(aCalendar) { + return !aCalendar.getProperty("disabled") && + !aCalendar.readOnly && + (!Services.io.offline || + aCalendar.getProperty("cache.enabled") || + aCalendar.getProperty("cache.always") || + aCalendar.getProperty("requiresNetwork") === false); +} + +/** + * Check if the specified calendar is writable from an ACL point of view. + * + * @param aCalendar The calendar to check + * @return True if the calendar is writable + */ +function userCanAddItemsToCalendar(aCalendar) { + let aclEntry = aCalendar.aclEntry; + return !aclEntry || !aclEntry.hasAccessControl || aclEntry.userIsOwner || aclEntry.userCanAddItems; +} + +/** + * Check if the user can delete items from the specified calendar, from an ACL point of view. + * + * @param aCalendar The calendar to check + * @return True if the calendar is writable + */ +function userCanDeleteItemsFromCalendar(aCalendar) { + let aclEntry = aCalendar.aclEntry; + return !aclEntry || !aclEntry.hasAccessControl || aclEntry.userIsOwner || aclEntry.userCanDeleteItems; +} + +/** + * Check if the user can fully modify the specified item, from an ACL point of view. + * Note to be confused with the right to respond to an invitation, which is + * handled instead by userCanRespondToInvitation. + * + * @param aItem The calendar item to check + * @return True if the item is modifiable + */ +function userCanModifyItem(aItem) { + let aclEntry = aItem.aclEntry; + return !aclEntry || !aclEntry.calendarEntry.hasAccessControl || aclEntry.calendarEntry.userIsOwner || aclEntry.userCanModify; +} + +/** + * Check if the attendee object matches one of the addresses in the list. This + * is useful to determine whether the current user acts as a delegate. + * + * @param aAttendee The reference attendee object + * @param addresses The list of addresses + * @return True if there is a match + */ +function attendeeMatchesAddresses(anAttendee, addresses) { + let attId = anAttendee.id; + if (!attId.match(/^mailto:/i)) { + // Looks like its not a normal attendee, possibly urn:uuid:... + // Try getting the email through the EMAIL property. + let emailProp = anAttendee.getProperty("EMAIL"); + if (emailProp) { + attId = emailProp; + } + } + + attId = attId.toLowerCase().replace(/^mailto:/, ""); + for (let address of addresses) { + if (attId == address.toLowerCase().replace(/^mailto:/, "")) { + return true; + } + } + + return false; +} + +/** + * Check if the user can fully modify the specified item, from an ACL point of view. + * Note to be confused with the right to respond to an invitation, which is + * handled instead by userCanRespondToInvitation. + * + * @param aItem The calendar item to check + * @return True if the item is modifiable + */ +function userCanRespondToInvitation(aItem) { + let aclEntry = aItem.aclEntry; + return userCanModifyItem(aItem) || aclEntry.userCanRespond; +} + +/** + * Opens the Create Calendar wizard + * + * @param aCallback a function to be performed after calendar creation + */ +function openCalendarWizard(aCallback) { + openDialog("chrome://calendar/content/calendarCreation.xul", "caEditServer", + // Workaround for Bug 1151440 - the HTML color picker won't work + // in linux when opened from modal dialog + AppConstants.platform == "linux" + ? "chrome,titlebar,resizable" + : "modal,chrome,titlebar,resizable", + aCallback); +} + +/** + * Opens the calendar properties window for aCalendar + * + * @param aCalendar the calendar whose properties should be displayed + */ +function openCalendarProperties(aCalendar) { + openDialog("chrome://calendar/content/calendar-properties-dialog.xul", + "CalendarPropertiesDialog", + // Workaround for Bug 1151440 - the HTML color picker won't work + // in linux when opened from modal dialog + AppConstants.platform == "linux" + ? "chrome,titlebar,resizable" + : "modal,chrome,titlebar,resizable", + { calendar: aCalendar }); +} + +/** + * Opens the print dialog + */ +function calPrint() { + openDialog("chrome://calendar/content/calendar-print-dialog.xul", "Print", + "centerscreen,chrome,resizable"); +} + +/** + * Other functions + */ + +/** + * Takes a string and returns an nsIURI + * + * @param aUriString the string of the address to for the spec of the nsIURI + * + * @returns an nsIURI whose spec is aUriString + */ +function makeURL(aUriString) { + return Services.io.newURI(aUriString, null, null); +} + +/** + * Returns a calIDateTime that corresponds to the current time in the user's + * default timezone. + */ +function now() { + let date = cal.jsDateToDateTime(new Date()); + return date.getInTimezone(calendarDefaultTimezone()); +} + +/** + * Selects an item with id aItemId in the radio group with id aRadioGroupId + * + * @param aRadioGroupId the id of the radio group which contains the item + * @param aItemId the item to be selected + */ +function calRadioGroupSelectItem(aRadioGroupId, aItemId) { + let radioGroup = document.getElementById(aRadioGroupId); + let items = radioGroup.getElementsByTagName("radio"); + let index; + for (let i in items) { + if (items[i].getAttribute("id") == aItemId) { + index = i; + break; + } + } + ASSERT(index && index != 0, "Can't find radioGroup item to select.", true); + radioGroup.selectedIndex = index; +} + + +/** checks if an item is supported by a Calendar +* @param aCalendar the calendar +* @param aItem the item either a task or an event +* @return true or false +*/ +function isItemSupported(aItem, aCalendar) { + if (isToDo(aItem)) { + return (aCalendar.getProperty("capabilities.tasks.supported") !== false); + } else if (isEvent(aItem)) { + return (aCalendar.getProperty("capabilities.events.supported") !== false); + } + return false; +} + +/** + * @deprecated This function has been replaced by cal.wrapInstance() + */ +function calInstanceOf(aObject, aInterface) { + if (!calInstanceOf.warningIssued) { + cal.WARN("Use of calInstanceOf() is deprecated and will be removed " + + "with the next release. Use cal.wrapInstance() instead.\n" + + cal.STACK(10)); + calInstanceOf.warningIssued = true; + } + return (cal.wrapInstance(aObject, aInterface) != null); +} + +/** + * Determines whether or not the aObject is a calIEvent + * + * @param aObject the object to test + * @returns true if the object is a calIEvent, false otherwise + */ +function isEvent(aObject) { + return (cal.wrapInstance(aObject, Components.interfaces.calIEvent) != null); +} + +/** + * Determines whether or not the aObject is a calITodo + * + * @param aObject the object to test + * @returns true if the object is a calITodo, false otherwise + */ +function isToDo(aObject) { + return (cal.wrapInstance(aObject, Components.interfaces.calITodo) != null); +} + +/** + * Normal get*Pref calls will throw if the pref is undefined. This function + * will get a bool, int, or string pref. If the pref is undefined, it will + * return aDefault. + * + * @param aPrefName the (full) name of preference to get + * @param aDefault (optional) the value to return if the pref is undefined + */ +function getPrefSafe(aPrefName, aDefault) { + if (!getPrefSafe.warningIssued) { + cal.WARN("Use of getPrefSafe() is deprecated and will be removed " + + "with the next release. Use Preferences.get() instead.\n" + + cal.STACK(10)); + getPrefSafe.warningIssued = true; + } + + return Preferences.get(aPrefName, aDefault); +} + +/** + * Wrapper for setting prefs of various types. + * + * @param aPrefName the (full) name of preference to set + * @param aPrefValue the value to set the pref to + * @param aPrefType (optional) the type of preference to set. + * Valid values are: BOOL, INT, and CHAR + */ +function setPref(aPrefName, aPrefValue, aPrefType) { + if (!setPref.warningIssued) { + cal.WARN("Use of setPref() is deprecated and will be removed " + + "with the next release. Use Preferences.set() instead.\n" + + cal.STACK(10)); + setPref.warningIssued = true; + } + + let prefValue = aPrefValue; + + if (aPrefType == "BOOL") { + prefValue = Boolean(prefValue); + } else if (aPrefType == "INT") { + prefValue = Number(prefValue); + } else if (aPrefType == "CHAR") { + prefValue = String(prefValue); + } + + return Preferences.set(aPrefName, prefValue); +} + +/** + * Helper function to set a localized (complex) pref from a given string + * + * @param aPrefName the (full) name of preference to set + * @param aString the string to which the preference value should be set + */ +function setLocalizedPref(aPrefName, aString) { + if (!setLocalizedPref.warningIssued) { + cal.WARN("Use of setLocalizedPref() is deprecated and will be removed " + + "with the next release. Use Preferences.set() instead.\n" + + cal.STACK(10)); + setLocalizedPref.warningIssued = true; + } + + return Preferences.set(aPrefName, aString); +} + +/** + * Like getPrefSafe, except for complex prefs (those used for localized data). + * + * @param aPrefName the (full) name of preference to get + * @param aDefault (optional) the value to return if the pref is undefined + */ +function getLocalizedPref(aPrefName, aDefault) { + if (!getLocalizedPref.warningIssued) { + cal.WARN("Use of getLocalizedPref() is deprecated and will be removed " + + "with the next release. Use Preferences.get() instead.\n" + + cal.STACK(10)); + getLocalizedPref.warningIssued = true; + } + + return Preferences.get(aPrefName, aDefault); +} + +/** + * Get array of category names from preferences or locale default, + * unescaping any commas in each category name. + * @return array of category names + */ +function getPrefCategoriesArray() { + let categories = Preferences.get("calendar.categories.names", null); + + // If no categories are configured load a default set from properties file + if (!categories) { + categories = setupDefaultCategories(); + } + return categoriesStringToArray(categories); +} + +/** + * Sets up the default categories from the localized string + * + * @return The default set of categories as a comma separated string. + */ +function setupDefaultCategories() { + // First, set up the category names + let categories = calGetString("categories", "categories2"); + Preferences.set("calendar.categories.names", categories); + + // Now, initialize the category default colors + let categoryArray = categoriesStringToArray(categories); + for (let category of categoryArray) { + let prefName = formatStringForCSSRule(category); + Preferences.set("calendar.category.color." + prefName, + hashColor(category)); + } + + // Return the list of categories for further processing + return categories; +} + +/** + * Hash the given string into a color from the color palette of the standard + * color picker. + * + * @param str The string to hash into a color. + * @return The hashed color. + */ +function hashColor(str) { + // This is the palette of colors in the current colorpicker implementation. + // Unfortunately, there is no easy way to extract these colors from the + // binding directly. + const colorPalette = ["#FFFFFF", "#FFCCCC", "#FFCC99", "#FFFF99", "#FFFFCC", + "#99FF99", "#99FFFF", "#CCFFFF", "#CCCCFF", "#FFCCFF", + "#CCCCCC", "#FF6666", "#FF9966", "#FFFF66", "#FFFF33", + "#66FF99", "#33FFFF", "#66FFFF", "#9999FF", "#FF99FF", + "#C0C0C0", "#FF0000", "#FF9900", "#FFCC66", "#FFFF00", + "#33FF33", "#66CCCC", "#33CCFF", "#6666CC", "#CC66CC", + "#999999", "#CC0000", "#FF6600", "#FFCC33", "#FFCC00", + "#33CC00", "#00CCCC", "#3366FF", "#6633FF", "#CC33CC", + "#666666", "#990000", "#CC6600", "#CC9933", "#999900", + "#009900", "#339999", "#3333FF", "#6600CC", "#993399", + "#333333", "#660000", "#993300", "#996633", "#666600", + "#006600", "#336666", "#000099", "#333399", "#663366", + "#000000", "#330000", "#663300", "#663333", "#333300", + "#003300", "#003333", "#000066", "#330099", "#330033"]; + + let sum = Array.map(str || " ", e => e.charCodeAt(0)).reduce((a, b) => a + b); + return colorPalette[sum % colorPalette.length]; +} + +/** + * Convert categories string to list of category names. + * + * Stored categories may include escaped commas within a name. + * Split categories string at commas, but not at escaped commas (\,). + * Afterward, replace escaped commas (\,) with commas (,) in each name. + * @param aCategoriesPrefValue string from "calendar.categories.names" pref, + * which may contain escaped commas (\,) in names. + * @return list of category names + */ +function categoriesStringToArray(aCategories) { + if (!aCategories) { + return []; + } + // \u001A is the unicode "SUBSTITUTE" character + function revertCommas(name) { return name.replace(/\u001A/g, ","); } + let categories = aCategories.replace(/\\,/g, "\u001A").split(",").map(revertCommas); + if (categories.length == 1 && categories[0] == "") { + // Split will return an array with an empty element when splitting an + // empty string, correct this. + categories.pop(); + } + return categories; +} + +/** + * Set categories preference, escaping any commas in category names. + * @param aCategoriesArray array of category names, + * may contain unescaped commas which will be escaped in combined pref. + */ +function setPrefCategoriesFromArray(aCategoriesArray) { + Preferences.set("calendar.categories.names", + categoriesArrayToString(aCategoriesList)); +} + +/** + * Convert array of category names to string. + * + * Category names may contain commas (,). Escape commas (\,) in each, + * then join them in comma separated string for storage. + * @param aSortedCategoriesArray sorted array of category names, + * may contain unescaped commas, which will be escaped in combined string. + */ +function categoriesArrayToString(aSortedCategoriesArray) { + function escapeComma(category) { return category.replace(/,/g, "\\,"); } + return aSortedCategoriesArray.map(escapeComma).join(","); +} + +/** + * Gets the value of a string in a .properties file from the calendar bundle + * + * @param aBundleName the name of the properties file. It is assumed that the + * file lives in chrome://calendar/locale/ + * @param aStringName the name of the string within the properties file + * @param aParams optional array of parameters to format the string + * @param aComponent optional stringbundle component name + */ +function calGetString(aBundleName, aStringName, aParams, aComponent="calendar") { + let propName = "chrome://" + aComponent + "/locale/" + aBundleName + ".properties"; + + try { + let props = Services.strings.createBundle(propName); + + if (aParams && aParams.length) { + return props.formatStringFromName(aStringName, aParams, aParams.length); + } else { + return props.GetStringFromName(aStringName); + } + } catch (ex) { + let msg = "Failed to read '" + aStringName + "' from " + propName + "."; + Components.utils.reportError(msg + " Error: " + ex); + return msg; + } +} + +/** + * Make a UUID using the UUIDGenerator service available, we'll use that. + */ +function getUUID() { + let uuidGen = Components.classes["@mozilla.org/uuid-generator;1"] + .getService(Components.interfaces.nsIUUIDGenerator); + // generate uuids without braces to avoid problems with + // CalDAV servers that don't support filenames with {} + return uuidGen.generateUUID().toString().replace(/[{}]/g, ""); +} + +/** + * Due to a bug in js-wrapping, normal == comparison can fail when we + * have 2 objects. Use these functions to force them both to get wrapped + * the same way, allowing for normal comparison. + */ + +/** + * calIItemBase comparer + */ +function compareItems(aItem, aOtherItem) { + let sip1 = Components.classes["@mozilla.org/supports-interface-pointer;1"] + .createInstance(Components.interfaces.nsISupportsInterfacePointer); + sip1.data = aItem; + sip1.dataIID = Components.interfaces.calIItemBase; + + let sip2 = Components.classes["@mozilla.org/supports-interface-pointer;1"] + .createInstance(Components.interfaces.nsISupportsInterfacePointer); + sip2.data = aOtherItem; + sip2.dataIID = Components.interfaces.calIItemBase; + return sip1.data == sip2.data; +} + +/** + * Tries to get rid of wrappers. This is used to avoid cyclic references, and thus leaks. + */ +function calTryWrappedJSObject(obj) { + if (obj && obj.wrappedJSObject) { + obj = obj.wrappedJSObject; + } + return obj; +} + +/** + * Generic object comparer + * Use to compare two objects which are not of type calIItemBase, in order + * to avoid the js-wrapping issues mentioned above. + * + * @param aObject first object to be compared + * @param aOtherObject second object to be compared + * @param aIID IID to use in comparison, undefined/null defaults to nsISupports + */ +function compareObjects(aObject, aOtherObject, aIID) { + // xxx todo: seems to work fine e.g. for WCAP, but I still mistrust this trickery... + // Anybody knows an official API that could be used for this purpose? + // For what reason do clients need to pass aIID since + // every XPCOM object has to implement nsISupports? + // XPCOM (like COM, like UNO, ...) defines that QueryInterface *only* needs to return + // the very same pointer for nsISupports during its lifetime. + if (!aIID) { + aIID = Components.interfaces.nsISupports; + } + let sip1 = Components.classes["@mozilla.org/supports-interface-pointer;1"] + .createInstance(Components.interfaces.nsISupportsInterfacePointer); + sip1.data = aObject; + sip1.dataIID = aIID; + + let sip2 = Components.classes["@mozilla.org/supports-interface-pointer;1"] + .createInstance(Components.interfaces.nsISupportsInterfacePointer); + sip2.data = aOtherObject; + sip2.dataIID = aIID; + return sip1.data == sip2.data; +} + +/** + * Compare two arrays using the passed function. + */ +function compareArrays(aOne, aTwo, compareFunc) { + if (!aOne && !aTwo) { + return true; + } + if (!aOne || !aTwo) { + return false; + } + let len = aOne.length; + if (len != aTwo.length) { + return false; + } + for (let i = 0; i < len; ++i) { + if (!compareFunc(aOne[i], aTwo[i])) { + return false; + } + } + return true; +} + +/** + * Takes care of all QueryInterface business, including calling the QI of any + * existing parent prototypes. + * + * @deprecated + * @param aSelf The object the QueryInterface is being made to + * @param aProto Caller's prototype object + * @param aIID The IID to check for + * @param aList (Optional if aClassInfo is specified) An array of + * interfaces from Components.interfaces + * @param aClassInfo (Optional) an Object containing the class info for this + * prototype. + */ +function doQueryInterface(aSelf, aProto, aIID, aList, aClassInfo) { + if (!doQueryInterface.warningIssued) { + cal.WARN("Use of doQueryInterface() is deprecated and will be removed " + + "with the next release. Use XPCOMUtils.generateQI() instead.\n" + + cal.STACK(10)); + doQueryInterface.warningIssued = true; + } + + if (aClassInfo) { + if (aIID.equals(Components.interfaces.nsIClassInfo)) { + return aClassInfo; + } + if (!aList) { + aList = aClassInfo.getInterfaces({}); + } + } + + if (aList) { + for (let iid of aList) { + if (aIID.equals(iid)) { + return aSelf; + } + } + } + + if (aIID.equals(Components.interfaces.nsISupports)) { + return aSelf; + } + + if (aProto) { + let base = aProto.__proto__; + if (base && base.QueryInterface) { + // Try to QI the base prototype + return base.QueryInterface.call(aSelf, aIID); + } + } + + throw Components.results.NS_ERROR_NO_INTERFACE; +} + +/** + * Many computations want to work only with date-times, not with dates. This + * method will return a proper datetime (set to midnight) for a date object. If + * the object is already a datetime, it will simply be returned. + * + * @param aDate the date or datetime to check + */ +function ensureDateTime(aDate) { + if (!aDate || !aDate.isDate) { + return aDate; + } + let newDate = aDate.clone(); + newDate.isDate = false; + return newDate; +} + +/** + * Get the default event start date. This is the next full hour, or 23:00 if it + * is past 23:00. + * + * @param aReferenceDate If passed, the time of this date will be modified, + * keeping the date and timezone intact. + */ +function getDefaultStartDate(aReferenceDate) { + let startDate = now(); + if (aReferenceDate) { + let savedHour = startDate.hour; + startDate = aReferenceDate; + if (!startDate.isMutable) { + startDate = startDate.clone(); + } + startDate.isDate = false; + startDate.hour = savedHour; + } + + startDate.second = 0; + startDate.minute = 0; + if (startDate.hour < 23) { + startDate.hour++; + } + return startDate; +} + +/** + * Setup the default start and end hours of the given item. This can be a task + * or an event. + * + * @param aItem The item to set up the start and end date for. + * @param aReferenceDate If passed, the time of this date will be modified, + * keeping the date and timezone intact. + */ +function setDefaultStartEndHour(aItem, aReferenceDate) { + aItem[calGetStartDateProp(aItem)] = getDefaultStartDate(aReferenceDate); + + if (isEvent(aItem)) { + aItem.endDate = aItem.startDate.clone(); + aItem.endDate.minute += Preferences.get("calendar.event.defaultlength", 60); + } +} + +/** + * Helper used in the following log functions to actually log the message. + * Should not be used outside of this file. + */ +function _log(message, flag) { + let frame = Components.stack.caller.caller; + let filename = frame.filename ? frame.filename.split(" -> ").pop() : null; + let scriptError = Components.classes["@mozilla.org/scripterror;1"] + .createInstance(Components.interfaces.nsIScriptError); + scriptError.init(message, filename, null, frame.lineNumber, frame.columnNumber, + flag, "component javascript"); + Services.console.logMessage(scriptError); +} + +/** + * Logs a string or an object to both stderr and the js-console only in the case + * where the calendar.debug.log pref is set to true. + * + * @param aArg either a string to log or an object whose entire set of + * properties should be logged. + */ +function LOG(aArg) { + if (!Preferences.get("calendar.debug.log", false)) { + return; + } + + ASSERT(aArg, "Bad log argument.", false); + let string = aArg; + // We should just dump() both String objects, and string primitives. + if (!(aArg instanceof String) && !(typeof aArg == "string")) { + string = "Logging object...\n"; + for (let prop in aArg) { + string += prop + ": " + aArg[prop] + "\n"; + } + string += "End object\n"; + } + + dump(string + "\n"); + _log(string, Components.interfaces.nsIScriptError.infoFlag); +} + +/** + * Dumps a warning to both console and js console. + * + * @param aMessage warning message + */ +function WARN(aMessage) { + dump("Warning: " + aMessage + "\n"); + _log(aMessage, Components.interfaces.nsIScriptError.warningFlag); +} + +/** + * Dumps an error to both console and js console. + * + * @param aMessage error message + */ +function ERROR(aMessage) { + dump("Error: " + aMessage + "\n"); + _log(aMessage, Components.interfaces.nsIScriptError.errorFlag); +} + +/** + * Returns a string describing the current js-stack with filename and line + * numbers. + * + * @param aDepth (optional) The number of frames to include. Defaults to 5. + * @param aSkip (optional) Number of frames to skip + */ +function STACK(aDepth, aSkip) { + let depth = aDepth || 10; + let skip = aSkip || 0; + let stack = ""; + let frame = Components.stack.caller; + for (let i = 1; i <= depth + skip && frame; i++) { + if (i > skip) { + stack += i + ": [" + frame.filename + ":" + + frame.lineNumber + "] " + frame.name + "\n"; + } + frame = frame.caller; + } + return stack; +} + +/** + * Logs a message and the current js-stack, if aCondition fails + * + * @param aCondition the condition to test for + * @param aMessage the message to report in the case the assert fails + * @param aCritical if true, throw an error to stop current code execution + * if false, code flow will continue + * may be a result code + */ +function ASSERT(aCondition, aMessage, aCritical) { + if (aCondition) { + return; + } + + let string = "Assert failed: " + aMessage + "\n" + STACK(0, 1); + if (aCritical) { + throw new Components.Exception(string, + aCritical === true ? Components.results.NS_ERROR_UNEXPECTED : aCritical); + } else { + Components.utils.reportError(string); + } +} + +/** + * Uses the prompt service to display an error message. + * This function cannot be migrated into a module file, because it relies on an outer window object. + * + * @param aMsg The message to be shown + */ +function showError(aMsg) { + let wnd = window || null; + if (wnd) { + Services.prompt.alert(wnd, calGetString("calendar", "genericErrorTitle"), aMsg); + } +} + +/** + * Pick whichever of "black" or "white" will look better when used as a text + * color against a background of bgColor. + * + * @param bgColor the background color as a "#RRGGBB" string + */ +function getContrastingTextColor(bgColor) { + let calcColor = bgColor.replace(/#/g, ""); + let red = parseInt(calcColor.substring(0, 2), 16); + let green = parseInt(calcColor.substring(2, 4), 16); + let blue = parseInt(calcColor.substring(4, 6), 16); + + // Calculate the brightness (Y) value using the YUV color system. + let brightness = (0.299 * red) + (0.587 * green) + (0.114 * blue); + + // Consider all colors with less than 56% brightness as dark colors and + // use white as the foreground color, otherwise use black. + if (brightness < 144) { + return "white"; + } + + return "black"; +} + +/** + * Returns the property name used for the start date of an item, ie either an + * event's start date or a task's entry date. + */ +function calGetStartDateProp(aItem) { + if (isEvent(aItem)) { + return "startDate"; + } else if (isToDo(aItem)) { + return "entryDate"; + } + throw Components.results.NS_ERROR_NOT_IMPLEMENTED; +} + +/** + * Returns the property name used for the end date of an item, ie either an + * event's end date or a task's due date. + */ +function calGetEndDateProp(aItem) { + if (isEvent(aItem)) { + return "endDate"; + } else if (isToDo(aItem)) { + return "dueDate"; + } + throw Components.results.NS_ERROR_NOT_IMPLEMENTED; +} + +/** + * Checks whether the passed item fits into the demanded range. + * + * @param item the item + * @param rangeStart (inclusive) range start or null (open range) + * @param rangeStart (exclusive) range end or null (open range) + * @param returnDtstartOrDue returns item's start (or due) date in case + * the item is in the specified Range; null otherwise. + */ +function checkIfInRange(item, rangeStart, rangeEnd, returnDtstartOrDue) { + let startDate; + let endDate; + let queryStart = ensureDateTime(rangeStart); + if (isEvent(item)) { + startDate = item.startDate; + if (!startDate) { // DTSTART mandatory + // xxx todo: should we assert this case? + return null; + } + endDate = item.endDate || startDate; + } else { + let dueDate = item.dueDate; + startDate = item.entryDate || dueDate; + if (!item.entryDate) { + if (returnDtstartOrDue) { // DTSTART or DUE mandatory + return null; + } + // 3.6.2. To-do Component + // A "VTODO" calendar component without the "DTSTART" and "DUE" (or + // "DURATION") properties specifies a to-do that will be associated + // with each successive calendar date, until it is completed. + let completedDate = ensureDateTime(item.completedDate); + dueDate = ensureDateTime(dueDate); + return !completedDate || !queryStart || + completedDate.compare(queryStart) > 0 || + (dueDate && dueDate.compare(queryStart) >= 0); + } + endDate = dueDate || startDate; + } + + let start = ensureDateTime(startDate); + let end = ensureDateTime(endDate); + let queryEnd = ensureDateTime(rangeEnd); + + if (start.compare(end) == 0) { + if ((!queryStart || start.compare(queryStart) >= 0) && + (!queryEnd || start.compare(queryEnd) < 0)) { + return startDate; + } + } else if ((!queryEnd || start.compare(queryEnd) < 0) && + (!queryStart || end.compare(queryStart) > 0)) { + return startDate; + } + return null; +} + +/** + * This function return the progress state of a task: + * completed, overdue, duetoday, inprogress, future + * + * @param aTask The task to check. + * @return The progress atom. + */ +function getProgressAtom(aTask) { + let nowdate = new Date(); + + if (aTask.recurrenceInfo) { + return "repeating"; + } + + if (aTask.isCompleted) { + return "completed"; + } + + if (aTask.dueDate && aTask.dueDate.isValid) { + if (cal.dateTimeToJsDate(aTask.dueDate).getTime() < nowdate.getTime()) { + return "overdue"; + } else if (aTask.dueDate.year == nowdate.getFullYear() && + aTask.dueDate.month == nowdate.getMonth() && + aTask.dueDate.day == nowdate.getDate()) { + return "duetoday"; + } + } + + if (aTask.entryDate && aTask.entryDate.isValid && + cal.dateTimeToJsDate(aTask.entryDate).getTime() < nowdate.getTime()) { + return "inprogress"; + } + + return "future"; +} + +function calInterfaceBag(iid) { + this.init(iid); +} +calInterfaceBag.prototype = { + mIid: null, + mInterfaces: null, + + // Iterating the inteface bag iterates the interfaces it contains + [Symbol.iterator]: function() { return this.mInterfaces[Symbol.iterator](); }, + + // internal: + init: function(iid) { + this.mIid = iid; + this.mInterfaces = []; + }, + + // external: + get size() { + return this.mInterfaces.length; + }, + + get interfaceArray() { + return this.mInterfaces; + }, + + add: function(iface) { + if (iface) { + let existing = this.mInterfaces.some(obj => { + return compareObjects(obj, iface, this.mIid); + }); + if (!existing) { + this.mInterfaces.push(iface); + } + return !existing; + } + return false; + }, + + remove: function(iface) { + if (iface) { + this.mInterfaces = this.mInterfaces.filter((obj) => { + return !compareObjects(obj, iface, this.mIid); + }); + } + }, + + forEach: function(func) { + this.mInterfaces.forEach(func); + } +}; + +function calListenerBag(iid) { + this.init(iid); +} +calListenerBag.prototype = { + __proto__: calInterfaceBag.prototype, + + notify: function(func, args=[]) { + function notifyFunc(iface) { + try { + iface[func](...args); + } catch (exc) { + let stack = exc.stack || (exc.location ? exc.location.formattedStack : null); + Components.utils.reportError(exc + "\nSTACK: " + stack); + } + } + this.mInterfaces.forEach(notifyFunc); + } +}; + +function sendMailTo(aRecipient, aSubject, aBody, aIdentity) { + let msgParams = Components.classes["@mozilla.org/messengercompose/composeparams;1"] + .createInstance(Components.interfaces.nsIMsgComposeParams); + let composeFields = Components.classes["@mozilla.org/messengercompose/composefields;1"] + .createInstance(Components.interfaces.nsIMsgCompFields); + + composeFields.to = aRecipient; + composeFields.subject = aSubject; + composeFields.body = aBody; + + msgParams.type = Components.interfaces.nsIMsgCompType.New; + msgParams.format = Components.interfaces.nsIMsgCompFormat.Default; + msgParams.composeFields = composeFields; + msgParams.identity = aIdentity; + + MailServices.compose.OpenComposeWindowWithParams(null, msgParams); +} + +/** + * This object implements calIOperation and could group multiple sub + * operations into one. You can pass a cancel function which is called once + * the operation group is cancelled. + * Users must call notifyCompleted() once all sub operations have been + * successful, else the operation group will stay pending. + * The reason for the latter is that providers currently should (but need + * not) implement (and return) calIOperation handles, thus there may be pending + * calendar operations (without handle). + */ +function calOperationGroup(cancelFunc) { + this.wrappedJSObject = this; + if (calOperationGroup.mOpGroupId === undefined) { + calOperationGroup.mOpGroupId = 0; + } + if (calOperationGroup.mOpGroupPrefix === undefined) { + calOperationGroup.mOpGroupPrefix = getUUID() + "-"; + } + this.mCancelFunc = cancelFunc; + this.mId = calOperationGroup.mOpGroupPrefix + calOperationGroup.mOpGroupId++; + this.mSubOperations = []; +} +calOperationGroup.prototype = { + mCancelFunc: null, + mId: null, + mIsPending: true, + mStatus: Components.results.NS_OK, + mSubOperations: null, + + add: function(aOperation) { + if (aOperation && aOperation.isPending) { + this.mSubOperations.push(aOperation); + } + }, + + remove: function(aOperation) { + if (aOperation) { + this.mSubOperations = this.mSubOperations.filter(operation => aOperation.id != operation.id); + } + }, + + get isEmpty() { + return (this.mSubOperations.length == 0); + }, + + notifyCompleted: function(status) { + ASSERT(this.isPending, "[calOperationGroup_notifyCompleted] this.isPending"); + if (this.isPending) { + this.mIsPending = false; + if (status) { + this.mStatus = status; + } + } + }, + + toString: function() { + return "[calOperationGroup] id=" + this.id; + }, + + // calIOperation: + get id() { + return this.mId; + }, + + get isPending() { + return this.mIsPending; + }, + + get status() { + return this.mStatus; + }, + + cancel: function(status) { + if (this.isPending) { + if (!status) { + status = Components.interfaces.calIErrors.OPERATION_CANCELLED; + } + this.notifyCompleted(status); + let cancelFunc = this.mCancelFunc; + if (cancelFunc) { + this.mCancelFunc = null; + cancelFunc(); + } + let subOperations = this.mSubOperations; + this.mSubOperations = []; + for (let operation of subOperations) { + operation.cancel(Components.interfaces.calIErrors.OPERATION_CANCELLED); + } + } + } +}; + +function sameDay(date1, date2) { + if (date1 && date2) { + if ((date1.day == date2.day) && + (date1.month == date2.month) && + (date1.year == date2.year)) { + return true; + } + } + return false; +} + +/** + * Centralized funtions for accessing prodid and version + */ +function calGetProductId() { + return "-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN"; +} +function calGetProductVersion() { + return "2.0"; +} + +/** + * This is a centralized function for setting the prodid and version on an + * ical component. This should be used whenever you need to set the prodid + * and version on a calIcalComponent object. + * + * @param + * aIcalComponent The ical component to set the prodid and version on. + */ +function calSetProdidVersion(aIcalComponent) { + // Throw for an invalid parameter + aIcalComponent = cal.wrapInstance(aIcalComponent, Components.interfaces.calIIcalComponent); + if (!aIcalComponent) { + throw Components.results.NS_ERROR_INVALID_ARG; + } + // Set the prodid and version + aIcalComponent.prodid = calGetProductId(); + aIcalComponent.version = calGetProductVersion(); +} + + +/** + * TODO: The following UI-related functions need to move somewhere different, + * i.e calendar-ui-utils.js + */ + +/** + * applies a value to all children of a Menu. If the respective childnodes define + * a command the value is applied to the attribute of thecommand of the childnode + * + * @param aElement The parentnode of the elements + * @param aAttributeName The name of the attribute + * @param aValue The value of the attribute + */ +function applyAttributeToMenuChildren(aElement, aAttributeName, aValue) { + let sibling = aElement.firstChild; + do { + if (sibling) { + let domObject = sibling; + let commandName = null; + if (sibling.hasAttribute("command")) { + commandName = sibling.getAttribute("command"); + } + if (commandName) { + let command = document.getElementById(commandName); + if (command) { + domObject = command; + } + } + domObject.setAttribute(aAttributeName, aValue); + sibling = sibling.nextSibling; + } + } while (sibling); +} + + +/** + * compares the value of a property of an array of objects and returns + * true or false if it is same or not among all array members + * + * @param aObjects An Array of Objects to inspect + * @param aProperty Name the name of the Property of which the value is compared + */ +function isPropertyValueSame(aObjects, aPropertyName) { + let value = null; + for (let i = 0; i < aObjects.length; i++) { + if (!value) { + value = aObjects[0][aPropertyName]; + } + let compValue = aObjects[i][aPropertyName]; + if (compValue != value) { + return false; + } + } + return true; +} + +/** + * returns a parentnode - or the overgiven node - with the given localName, + * by "walking up" the DOM-hierarchy. + * + * @param aChildNode The childnode. + * @param aLocalName The localName of the to-be-returned parent + * that is looked for. + * @return The parent with the given localName or the + * given childNode 'aChildNode'. If no appropriate + * parent node with aLocalName could be + * retrieved it is returned 'null'. + */ +function getParentNodeOrThis(aChildNode, aLocalName) { + let node = aChildNode; + while (node && (node.localName != aLocalName)) { + node = node.parentNode; + if (node.tagName == undefined) { + return null; + } + } + return node; +} + +/** + * Returns a parentnode - or the overgiven node - with the given attributevalue + * for the given attributename by "walking up" the DOM-hierarchy. + * + * @param aChildNode The childnode. + * @param aAttibuteName The name of the attribute that is to be compared with + * @param aAttibuteValue The value of the attribute that is to be compared with + * @return The parent with the given attributeName set that has + * the same value as the given given attributevalue + * 'aAttributeValue'. If no appropriate + * parent node can be retrieved it is returned 'null'. + */ +function getParentNodeOrThisByAttribute(aChildNode, aAttributeName, aAttributeValue) { + let node = aChildNode; + while (node && (node.getAttribute(aAttributeName) != aAttributeValue)) { + node = node.parentNode; + if (node.tagName == undefined) { + return null; + } + } + return node; +} + +function setItemProperty(item, propertyName, aValue, aCapability) { + let isSupported = (item.calendar.getProperty("capabilities." + aCapability + ".supported") !== false); + let value = (aCapability && !isSupported ? null : aValue); + + switch (propertyName) { + case "startDate": + if ((value.isDate && !item.startDate.isDate) || + (!value.isDate && item.startDate.isDate) || + !compareObjects(value.timezone, item.startDate.timezone) || + value.compare(item.startDate) != 0) { + item.startDate = value; + } + break; + case "endDate": + if ((value.isDate && !item.endDate.isDate) || + (!value.isDate && item.endDate.isDate) || + !compareObjects(value.timezone, item.endDate.timezone) || + value.compare(item.endDate) != 0) { + item.endDate = value; + } + break; + case "entryDate": + if (value == item.entryDate) { + break; + } + if ((value && !item.entryDate) || + (!value && item.entryDate) || + value.isDate != item.entryDate.isDate || + !compareObjects(value.timezone, item.entryDate.timezone) || + value.compare(item.entryDate) != 0) { + item.entryDate = value; + } + break; + case "dueDate": + if (value == item.dueDate) { + break; + } + if ((value && !item.dueDate) || + (!value && item.dueDate) || + value.isDate != item.dueDate.isDate || + !compareObjects(value.timezone, item.dueDate.timezone) || + value.compare(item.dueDate) != 0) { + item.dueDate = value; + } + break; + case "isCompleted": + if (value != item.isCompleted) { + item.isCompleted = value; + } + break; + case "PERCENT-COMPLETE": { + let perc = parseInt(item.getProperty(propertyName), 10); + if (isNaN(perc)) { + perc = 0; + } + if (perc != value) { + item.setProperty(propertyName, value); + } + break; + } + case "title": + if (value != item.title) { + item.title = value; + } + break; + default: + if (!value || value == "") { + item.deleteProperty(propertyName); + } else if (item.getProperty(propertyName) != value) { + item.setProperty(propertyName, value); + } + break; + } +} +/** + * END TODO: The above UI-related functions need to move somewhere different, + * i.e calendar-ui-utils.js + */ + +/** + * Implements a property bag. + */ +function calPropertyBag() { + this.mData = {}; +} +calPropertyBag.prototype = { + mData: null, + + setProperty: function(aName, aValue) { + return (this.mData[aName] = aValue); + }, + getProperty_: function(aName) { + // avoid strict undefined property warning + return (aName in this.mData ? this.mData[aName] : undefined); + }, + getProperty: function(aName) { + // avoid strict undefined property warning + return (aName in this.mData ? this.mData[aName] : null); + }, + getAllProperties: function(aOutKeys, aOutValues) { + let keys = []; + let values = []; + for (let key in this.mData) { + keys.push(key); + values.push(this.mData[key]); + } + aOutKeys.value = keys; + aOutValues.value = values; + }, + deleteProperty: function(aName) { + delete this.mData[aName]; + }, + get enumerator() { + return new calPropertyBagEnumerator(this); + }, + [Symbol.iterator]: function* () { + for (let name of Object.keys(this.mData)) { + yield [name, this.mData[name]]; + } + } +}; +// implementation part of calPropertyBag +function calPropertyBagEnumerator(bag) { + this.mIndex = 0; + this.mBag = bag; + this.mKeys = Object.keys(bag.mData); +} +calPropertyBagEnumerator.prototype = { + mIndex: 0, + mBag: null, + mKeys: null, + + // nsISimpleEnumerator: + getNext: function() { + if (!this.hasMoreElements()) { // hasMoreElements is called by intention to skip yet deleted properties + ASSERT(false, Components.results.NS_ERROR_UNEXPECTED); + throw Components.results.NS_ERROR_UNEXPECTED; + } + let name = this.mKeys[this.mIndex++]; + return { // nsIProperty: + QueryInterface: XPCOMUtils.generateQI([Components.interfaces.nsIProperty]), + name: name, + value: this.mCurrentValue + }; + }, + hasMoreElements: function() { + while (this.mIndex < this.mKeys.length) { + this.mCurrentValue = this.mBag.mData[this.mKeys[this.mIndex]]; + if (this.mCurrentValue !== undefined) { + return true; + } + ++this.mIndex; + } + return false; + } +}; + +/** + * Iterates all email identities and calls the passed function with identity and account. + * If the called function returns false, iteration is stopped. + */ +function calIterateEmailIdentities(func) { + let accounts = MailServices.accounts.accounts; + for (let i = 0; i < accounts.length; ++i) { + let account = accounts.queryElementAt(i, Components.interfaces.nsIMsgAccount); + let identities = account.identities; + for (let j = 0; j < identities.length; ++j) { + let identity = identities.queryElementAt(j, Components.interfaces.nsIMsgIdentity); + if (!func(identity, account)) { + break; + } + } + } +} + +/** + * Compare two items by *content*, leaving out any revision information such as + * X-MOZ-GENERATION, SEQUENCE, DTSTAMP, LAST-MODIFIED. + + * The format for the parameters to ignore object is: + * { "PROPERTY-NAME": ["PARAM-NAME", ...] } + * + * If aIgnoreProps is not passed, these properties are ignored: + * X-MOZ-GENERATION, SEQUENCE, DTSTAMP, LAST-MODIFIED, X-MOZ-SEND-INVITATIONS + * + * If aIgnoreParams is not passed, these parameters are ignored: + * ATTENDEE: CN + * ORGANIZER: CN + * + * @param aFirstItem The item to compare. + * @param aSecondItem The item to compare to. + * @param aIgnoreProps (optional) An array of parameters to ignore. + * @param aIgnoreParams (optional) An object describing which parameters to + * ignore. + * @return True, if items match. + */ +function compareItemContent(aFirstItem, aSecondItem, aIgnoreProps, aIgnoreParams) { + let ignoreProps = arr2hash(aIgnoreProps || + ["SEQUENCE", "DTSTAMP", "LAST-MODIFIED", "X-MOZ-GENERATION", "X-MICROSOFT-DISALLOW-COUNTER", + "X-MOZ-SEND-INVITATIONS", "X-MOZ-SEND-INVITATIONS-UNDISCLOSED"]); + + let ignoreParams = aIgnoreParams || + { ATTENDEE: ["CN"], ORGANIZER: ["CN"] }; + for (let x in ignoreParams) { + ignoreParams[x] = arr2hash(ignoreParams[x]); + } + + function arr2hash(arr) { + let hash = {}; + for (let x of arr) { + hash[x] = true; + } + return hash; + } + + // This doesn't have to be super correct rfc5545, it just needs to be + // in the same order + function normalizeComponent(comp) { + let props = []; + for (let prop of cal.ical.propertyIterator(comp)) { + if (!(prop.propertyName in ignoreProps)) { + props.push(normalizeProperty(prop)); + } + } + props = props.sort(); + + let comps = []; + for (let subcomp of cal.ical.subcomponentIterator(comp)) { + comps.push(normalizeComponent(subcomp)); + } + comps = comps.sort(); + + return comp.componentType + props.join("\r\n") + comps.join("\r\n"); + } + + function normalizeProperty(prop) { + let params = [...cal.ical.paramIterator(prop)] + .filter(([k, v]) => !(prop.propertyName in ignoreParams) || + !(k in ignoreParams[prop.propertyName])) + .map(([k, v]) => k + "=" + v) + .sort(); + + return prop.propertyName + ";" + + params.join(";") + ":" + + prop.valueAsIcalString; + } + + return normalizeComponent(aFirstItem.icalComponent) == + normalizeComponent(aSecondItem.icalComponent); +} + +/** + * Use the binary search algorithm to search for an item in an array. + * function. + * + * The comptor function may look as follows for calIDateTime objects. + * function comptor(a, b) { + * return a.compare(b); + * } + * If no comptor is specified, the default greater-than comptor will be used. + * + * @param itemArray The array to search. + * @param newItem The item to search in the array. + * @param comptor A comparation function that can compare two items. + * @return The index of the new item. + */ +function binarySearch(itemArray, newItem, comptor) { + function binarySearchInternal(low, high) { + // Are we done yet? + if (low == high) { + return low + (comptor(newItem, itemArray[low]) < 0 ? 0 : 1); + } + + let mid = Math.floor(low + ((high - low) / 2)); + let cmp = comptor(newItem, itemArray[mid]); + if (cmp > 0) { + return binarySearchInternal(mid + 1, high); + } else if (cmp < 0) { + return binarySearchInternal(low, mid); + } else { + return mid; + } + } + + if (itemArray.length < 1) { + return -1; + } + if (!comptor) { + comptor = function(a, b) { + return (a > b) - (a < b); + }; + } + return binarySearchInternal(0, itemArray.length - 1); +} + +/** + * Insert a new node underneath the given parentNode, using binary search. See binarySearch + * for a note on how the comptor works. + * + * @param parentNode The parent node underneath the new node should be inserted. + * @param inserNode The node to insert + * @param aItem The calendar item to add a widget for. + * @param comptor A comparison function that can compare two items (not DOM Nodes!) + * @param discardDuplicates Use the comptor function to check if the item in + * question is already in the array. If so, the + * new item is not inserted. + * @param itemAccessor [optional] A function that receives a DOM node and returns the associated item + * If null, this function will be used: function(n) n.item + */ +function binaryInsertNode(parentNode, insertNode, aItem, comptor, discardDuplicates, itemAccessor) { + let accessor = itemAccessor || binaryInsertNode.defaultAccessor; + + // Get the index of the node before which the inserNode will be inserted + let newIndex = binarySearch(Array.map(parentNode.childNodes, accessor), aItem, comptor); + + if (newIndex < 0) { + parentNode.appendChild(insertNode); + newIndex = 0; + } else if (!discardDuplicates || + comptor(accessor(parentNode.childNodes[Math.min(newIndex, parentNode.childNodes.length - 1)]), aItem) >= 0) { + // Only add the node if duplicates should not be discarded, or if + // they should and the childNode[newIndex] == node. + let node = parentNode.childNodes[newIndex]; + parentNode.insertBefore(insertNode, node); + } + return newIndex; +} +binaryInsertNode.defaultAccessor = n => n.item; + +/** + * Insert an item into the given array, using binary search. See binarySearch + * for a note on how the comptor works. + * + * @param itemArray The array to insert into. + * @param item The item to insert into the array. + * @param comptor A comparation function that can compare two items. + * @param discardDuplicates Use the comptor function to check if the item in + * question is already in the array. If so, the + * new item is not inserted. + * @return The index of the new item. + */ +function binaryInsert(itemArray, item, comptor, discardDuplicates) { + let newIndex = binarySearch(itemArray, item, comptor); + + if (newIndex < 0) { + itemArray.push(item); + newIndex = 0; + } else if (!discardDuplicates || + comptor(itemArray[Math.min(newIndex, itemArray.length - 1)], item) != 0) { + // Only add the item if duplicates should not be discarded, or if + // they should and itemArray[newIndex] != item. + itemArray.splice(newIndex, 0, item); + } + return newIndex; +} + +/** + * Gets the cached instance of the composite calendar. + * + * WARNING: Great care should be taken how this function is called. If it is + * called as "cal.getCompositeCalendar()" then it is called through calUtils.jsm + * which means there will be one instance per app. If called as + * "getCompositeCalendar()" from chrome code, then it will get a window-specific + * composite calendar, which is often what is wanted + */ +function getCompositeCalendar() { + if (getCompositeCalendar.mObject === undefined) { + getCompositeCalendar.mObject = Components.classes["@mozilla.org/calendar/calendar;1?type=composite"] + .createInstance(Components.interfaces.calICompositeCalendar); + getCompositeCalendar.mObject.prefPrefix = "calendar-main"; + + try { + if (gCalendarStatusFeedback) { + // If we are in a window that has calendar status feedback, set up + // our status observer. + let chromeWindow = window.QueryInterface(Components.interfaces.nsIDOMChromeWindow); + getCompositeCalendar.mObject.setStatusObserver(gCalendarStatusFeedback, chromeWindow); + } + } catch (exc) { // catch errors in case we run in contexts without status feedback + } + } + return getCompositeCalendar.mObject; +} + +/** + * Search for already open item dialog or tab. + * + * @param aItem The item of the dialog or tab to search for. + */ +function findItemWindow(aItem) { + // check for existing dialog windows + let list = Services.wm.getEnumerator("Calendar:EventDialog"); + while (list.hasMoreElements()) { + let dlg = list.getNext(); + if (dlg.arguments[0] && + dlg.arguments[0].mode == "modify" && + dlg.arguments[0].calendarEvent && + dlg.arguments[0].calendarEvent.hashId == aItem.hashId) { + return dlg; + } + } + // check for existing summary windows + list = Services.wm.getEnumerator("Calendar:EventSummaryDialog"); + while (list.hasMoreElements()) { + let dlg = list.getNext(); + if (dlg.calendarItem && + dlg.calendarItem.hashId == aItem.hashId) { + return dlg; + } + } + return null; +} diff --git a/calendar/base/src/calWeekInfoService.js b/calendar/base/src/calWeekInfoService.js new file mode 100644 index 000000000..724b740b4 --- /dev/null +++ b/calendar/base/src/calWeekInfoService.js @@ -0,0 +1,118 @@ +/* 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/. */ + +Components.utils.import("resource://gre/modules/XPCOMUtils.jsm"); +Components.utils.import("resource://calendar/modules/calUtils.jsm"); +Components.utils.import("resource://gre/modules/Preferences.jsm"); + +function calWeekInfoService() { + this.wrappedJSObject = this; +} +var calWeekInfoServiceClassID = Components.ID("{6877bbdd-f336-46f5-98ce-fe86d0285cc1}"); +var calWeekInfoServiceInterfaces = [Components.interfaces.calIWeekInfoService]; +calWeekInfoService.prototype = { + classID: calWeekInfoServiceClassID, + QueryInterface: XPCOMUtils.generateQI(calWeekInfoServiceInterfaces), + classInfo: XPCOMUtils.generateCI({ + classID: calWeekInfoServiceClassID, + contractID: "@mozilla.org/calendar/weekinfo-service;1", + classDescription: "Calendar WeekInfo Service", + interfaces: calWeekInfoServiceInterfaces, + flags: Components.interfaces.nsIClassInfo.SINGLETON + }), + + // calIWeekInfoService: + getWeekTitle: function(aDateTime) { + /** + * This implementation is based on the ISO 8601 standard. + * ISO 8601 defines week one as the first week with at least 4 + * days, and defines Monday as the first day of the week. + * Equivalently, the week one is the week with the first Thursday. + * + * This implementation uses the second definition, because it + * enables the user to set a different start-day of the week + * (Sunday instead of Monday is a common setting). If the first + * definition was used, all week-numbers could be off by one + * depending on the week start day. (For example, if weeks start + * on Sunday, a year that starts on Thursday has only 3 days + * [Thu-Sat] in that week, so it would be part of the last week of + * the previous year, but if weeks start on Monday, the year would + * have four days [Thu-Sun] in that week, so it would be counted + * as week 1.) + */ + + // The week number is the number of days since the start of week 1, + // divided by 7 and rounded up. Week 1 is the week containing the first + // Thursday of the year. + // Thus, the week number of any day is the same as the number of days + // between the Thursday of that week and the Thursday of week 1, divided + // by 7 and rounded up. (This takes care of days at end/start of a year + // which may be part of first/last week in the other year.) + // The Thursday of a week is the Thursday that follows the first day + // of the week. + // The week number of a day is the same as the week number of the first + // day of the week. (This takes care of days near the start of the year, + // which may be part of the week counted in the previous year.) So we + // need the startWeekday. + const SUNDAY = 0; + let startWeekday = Preferences.get("calendar.week.start", SUNDAY); // default to monday per ISO8601 standard. + + // The number of days since the start of the week. + // Notice that the result of the substraction might be negative. + // We correct for that by adding 7, and then using the remainder operator. + let sinceStartOfWeek = (aDateTime.weekday - startWeekday + 7) % 7; + + // The number of days to Thursday is the difference between Thursday + // and the start-day of the week (again corrected for negative values). + const THURSDAY = 4; + let startToThursday = (THURSDAY - startWeekday + 7) % 7; + + // The yearday number of the Thursday this week. + let thisWeeksThursday = aDateTime.yearday - sinceStartOfWeek + startToThursday; + + if (thisWeeksThursday < 1) { + // For the first few days of the year, we still are in week 52 or 53. + let lastYearDate = aDateTime.clone(); + lastYearDate.year -= 1; + thisWeeksThursday += lastYearDate.endOfYear.yearday; + } else if (thisWeeksThursday > aDateTime.endOfYear.yearday) { + // For the last few days of the year, we already are in week 1. + thisWeeksThursday -= aDateTime.endOfYear.yearday; + } + + let weekNumber = Math.ceil(thisWeeksThursday / 7); + return weekNumber; + }, + + /** + * gets the first day of a week of a passed day under consideration + * of the preference setting "calendar.week.start" + * + * @param aDate a date time object + * @return a dateTime-object denoting the first day of the week + */ + getStartOfWeek: function(aDate) { + let date = aDate.clone(); + date.isDate = true; + let offset = Preferences.get("calendar.week.start", 0) - aDate.weekday; + date.day += offset; + if (offset > 0) { + date.day -= 7; + } + return date; + }, + + /** + * gets the last day of a week of a passed day under consideration + * of the preference setting "calendar.week.start" + * + * @param aDate a date time object + * @return a dateTime-object denoting the last day of the week + */ + getEndOfWeek: function(aDate) { + let date = this.getStartOfWeek(aDate); + date.day += 6; + return date; + } +}; diff --git a/calendar/base/src/moz.build b/calendar/base/src/moz.build new file mode 100644 index 000000000..51d772374 --- /dev/null +++ b/calendar/base/src/moz.build @@ -0,0 +1,58 @@ +# vim: set filetype=python: +# 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/. + +XPIDL_SOURCES += [ + 'calInternalInterfaces.idl', +] + +XPIDL_MODULE = 'calbaseinternal' + +EXTRA_COMPONENTS += [ + 'calDefaultACLManager.js', + 'calDefaultACLManager.manifest', + 'calItemModule.js', + 'calItemModule.manifest', + 'calSleepMonitor.js', + 'calSleepMonitor.manifest', + 'calTimezoneService.js', + 'calTimezoneService.manifest', +] + +FINAL_TARGET_FILES['calendar-js'] += [ + 'calAlarm.js', + 'calAlarmMonitor.js', + 'calAlarmService.js', + 'calAttachment.js', + 'calAttendee.js', + 'calCachedCalendar.js', + 'calCalendarManager.js', + 'calCalendarSearchService.js', + 'calDateTimeFormatter.js', + 'calDeletedItems.js', + 'calEvent.js', + 'calFilter.js', + 'calFreeBusyService.js', + 'calIcsParser.js', + 'calIcsSerializer.js', + 'calItemBase.js', + 'calItipItem.js', + 'calProtocolHandler.js', + 'calRecurrenceDate.js', + 'calRecurrenceInfo.js', + 'calRelation.js', + 'calStartupService.js', + 'calTimezone.js', + 'calTodo.js', + 'calTransactionManager.js', + 'calUtils.js', + 'calWeekInfoService.js', +] + +with Files('**'): + BUG_COMPONENT = ('Calendar', 'Internal Components') + +with Files('calAlarm*'): + BUG_COMPONENT = ('Calendar', 'Alarms') + diff --git a/calendar/base/themes/common/calendar-alarms.css b/calendar/base/themes/common/calendar-alarms.css new file mode 100644 index 000000000..3ea33439c --- /dev/null +++ b/calendar/base/themes/common/calendar-alarms.css @@ -0,0 +1,68 @@ +/* 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/. */ + +/** + * Reminder icons (used from the event dialog, reminder dialog, views, ...) + */ +.reminder-icon { + /* Initially hide the image, overwrite by setting a correct image region */ + list-style-image: url(chrome://calendar-common/skin/alarm-icons.png); + -moz-image-region: rect(0px 1px 1px 0px); +} + +.reminder-icon[value="DISPLAY"] { + -moz-image-region: rect(0px 30px 11px 17px); +} + +.alarm-icons-box[suppressed="true"] > .reminder-icon[value="DISPLAY"] { + -moz-image-region: rect(0px 44px 11px 31px); +} + +.reminder-icon[value="EMAIL"] { + -moz-image-region: rect(0px 16px 11px 0px); +} + +.alarm-icons-box[flashing="true"] > .reminder-icon[value="DISPLAY"] { + list-style-image: url(chrome://calendar-common/skin/alarm-flashing.png); + -moz-image-region: auto; +} + +/** + * Reminder dialog (i.e "custom" alarm in the event dialog) + * Please make sure rules added here are very specific and won't hurt other + * dialogs. + */ +#reminder-relative-radio > .radio-label-center-box > .radio-label-box, +#reminder-absolute-radio > .radio-label-center-box > .radio-label-box { + display: none; +} + +#reminder-actions-menulist > menupopup > menuitem > .menu-iconic-left { + display: -moz-box; +} + +#reminder-notifications { + overflow-y: visible; +} + +#reminder-notifications > notification { + background-color: transparent; +} +#reminder-notifications > notification > .notification-inner { + border: 0; +} +#reminder-notifications > notification[type="warning"] { + list-style-image: url(chrome://global/skin/icons/Warning.png); +} + +#reminder-actions-caption, +#reminder-details-caption, +#calendar-event-dialog-reminder > .dialog-button-box { + padding-top: 20px; +} + +.reminder-icon > .menu-iconic-left > .menu-iconic-icon { + width: auto; + height: auto; +} diff --git a/calendar/base/themes/common/calendar-attendees.css b/calendar/base/themes/common/calendar-attendees.css new file mode 100644 index 000000000..390503278 --- /dev/null +++ b/calendar/base/themes/common/calendar-attendees.css @@ -0,0 +1,264 @@ +/* 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 is for attendee and organizer decoration in summary and event dialog */ + +#item-attendees-box { + -moz-appearance: listbox; + margin: 2px 4px 0; + overflow-y: auto; + min-height: 54px; /*at least two rows - otherwise a scrollbar (if required) wouldn't appear*/ +} + +#calendar-summary-dialog #item-attendees, +#calendar-event-summary-dialog #item-attendees, +#calendar-task-summary-dialog #item-attendees { + max-height: 135px; /* displays up to four rows of attendees*/ +} + +.item-attendees-cell { + padding: 2px; +} + +#calendar-event-dialog-inner .item-attendees-cell { + -moz-user-focus: normal; + margin-bottom: 1px; + margin-inline-end: 1px; +} + +#calendar-event-dialog-inner .item-attendees-cell:focus { + background-color: Highlight; + color: Highlighttext; +} + +.item-attendees-cell-label { + border: 0px; + margin: 0px 3px; + padding: 0px; +} + +.item-organizer-cell { + padding: 0px; + margin-left: 6px; +} + +/* this is for the itip icon setup in calendar */ + +.itip-icon { + --itip-icon-partstat: -16px -16px; /* default: NEEDS-ACTION */ + --itip-icon-role: 0px; /* default: REQ-PARTICIPANT */ + --itip-icon-usertype: -32px; /* default: INDIVIDUAL */ + width: 16px; + height: 16px; + max-height: 16px; + background-image: url(chrome://calendar-common/skin/calendar-itip-icons.svg), + url(chrome://calendar-common/skin/calendar-itip-icons.svg); + background-position: var(--itip-icon-partstat), var(--itip-icon-usertype) var(--itip-icon-role); +} +.itip-icon[partstat="ACCEPTED"] { + --itip-icon-partstat: 0px 0px; +} +.itip-icon[partstat="DECLINED"] { + --itip-icon-partstat: 0px -16px; +} +.itip-icon[partstat="DELEGATED"] { + --itip-icon-partstat: 0px -32px; +} +.itip-icon[partstat="TENTATIVE"] { + --itip-icon-partstat: -16px 0px; +} +.itip-icon[usertype="INDIVIDUAL"] { + --itip-icon-usertype: -32px; +} +.itip-icon[usertype="GROUP"] { + --itip-icon-usertype: -48px; +} +.itip-icon[usertype="RESOURCE"] { + --itip-icon-usertype: -64px; +} +.itip-icon[usertype="ROOM"] { + --itip-icon-usertype: -80px; +} +.itip-icon[usertype="UNKNOWN"] { + --itip-icon-usertype: -96px; +} +.itip-icon[role="REQ-PARTICIPANT"] { + --itip-icon-role: 0px; +} +.itip-icon[role="OPT-PARTICIPANT"] { + --itip-icon-role: -16px; +} +.itip-icon[role="NON-PARTICIPANT"] { + --itip-icon-role: -32px; +} +.itip-icon[role="CHAIR"] { + --itip-icon-role: -32px; + --itip-icon-usertype: -16px; +} + +/* the following will get obsolete once porting to new itip icons is complete */ + +.status-icon > .menu-iconic-left, +.role-icon > .menu-iconic-left, +.usertype-icon > .menu-iconic-left { + visibility: inherit; +} + +.status-icon { + margin: 0 3px; + list-style-image: url(chrome://calendar-common/skin/calendar-event-dialog-attendees.png); + -moz-image-region: rect(0px 48px 14px 36px); +} + +.status-icon[disabled="true"] { + -moz-image-region: rect(14px 48px 28px 36px); +} + +.status-icon[status="ACCEPTED"] { + -moz-image-region: rect(0px 12px 14px 0px); +} +.status-icon[status="ACCEPTED"][disabled="true"] { + -moz-image-region: rect(14px 12px 28px 0px); +} + +.status-icon[status="DECLINED"] { + -moz-image-region: rect(0px 24px 14px 12px); +} +.status-icon[status="DECLINED"][disabled="true"] { + -moz-image-region: rect(14px 24px 28px 12px); +} + +.status-icon[status="NEEDS-ACTION"] { + -moz-image-region: rect(0px 36px 14px 24px); +} +.status-icon[status="NEEDS-ACTION"][disabled="true"] { + -moz-image-region: rect(14px 36px 28px 24px); +} + +.status-icon[status="TENTATIVE"] { + -moz-image-region: rect(0px 48px 14px 36px); +} +.status-icon[status="TENTATIVE"][disabled="true"] { + -moz-image-region: rect(14px 48px 28px 36px); +} + +.role-icon { + margin: 0 3px; + list-style-image: url(chrome://calendar-common/skin/calendar-event-dialog-attendees.png); + -moz-image-region: rect(0px 159px 16px 138px); +} + +.role-icon[disabled="true"] { + -moz-image-region: rect(0px 159px 16px 138px); +} + +.role-icon[role="REQ-PARTICIPANT"] { + -moz-image-region: rect(0px 159px 16px 138px); +} +.role-icon[role="REQ-PARTICIPANT"][disabled="true"] { + -moz-image-region: rect(0px 159px 16px 138px); +} + +.role-icon[role="OPT-PARTICIPANT"] { + -moz-image-region: rect(0px 180px 16px 159px); +} +.role-icon[role="OPT-PARTICIPANT"][disabled="true"] { + -moz-image-region: rect(0px 180px 16px 159px); +} + +.role-icon[role="CHAIR"] { + -moz-image-region: rect(0px 201px 16px 180px); +} +.role-icon[role="CHAIR"][disabled="true"] { + -moz-image-region: rect(0px 201px 16px 180px); +} + +.role-icon[role="NON-PARTICIPANT"] { + -moz-image-region: rect(0px 222px 16px 201px); +} +.role-icon[role="NON-PARTICIPANT"][disabled="true"] { + -moz-image-region: rect(0px 222px 16px 201px); +} + +.usertype-icon, +.usertype-icon[cutype="INDIVIDUAL"] { + margin: 0 3px; + list-style-image: url(chrome://calendar-common/skin/attendee-icons.png); + -moz-image-region: rect(0px 16px 16px 0px); +} +.usertype-icon[disabled="true"], +.usertype-icon[cutype="INDIVIDUAL"][disabled="true"] { + -moz-image-region: rect(16px 16px 32px 0px); +} + +.usertype-icon[cutype="GROUP"] { + -moz-image-region: rect(0px 32px 16px 16px); +} +.usertype-icon[cutype="GROUP"][disabled="true"] { + -moz-image-region: rect(16px 32px 32px 16px); +} + +.usertype-icon[cutype="RESOURCE"] { + -moz-image-region: rect(0px 48px 16px 32px); +} +.usertype-icon[cutype="RESOURCE"][disabled="true"] { + -moz-image-region: rect(16px 48px 32px 32px); +} + +.usertype-icon[cutype="ROOM"] { + -moz-image-region: rect(0px 64px 16px 48px); +} +.usertype-icon[cutype="ROOM"][disabled="true"] { + -moz-image-region: rect(16px 64px 32px 48px); +} + +@media (-moz-windows-default-theme) and (-moz-os-version: windows-vista), + (-moz-windows-default-theme) and (-moz-os-version: windows-win7), + (-moz-windows-default-theme) and (-moz-os-version: windows-win8), + (-moz-windows-default-theme) and (-moz-os-version: windows-win10) { + #calendar-event-dialog-inner .item-attendees-cell { + background-repeat: no-repeat; + background-size: 100% 100%; + --attendees-currentColor: rgb(125, 162, 206); + } + + #calendar-event-dialog-inner .item-attendees-cell:focus { + color: -moz-FieldText; + background-color: transparent; + -moz-border-top-colors: var(--attendees-focusBorder); + -moz-border-right-colors: var(--attendees-focusBorder); + -moz-border-left-colors: var(--attendees-focusBorder); + -moz-border-bottom-colors: var(--attendees-focusBottomBorder); + background-image: var(--attendees-focusImage); + } +} + +@media (-moz-windows-default-theme) and (-moz-os-version: windows-vista), + (-moz-windows-default-theme) and (-moz-os-version: windows-win7) { + .item-attendees-cell { + border: 2px solid transparent; + border-radius: 3px; + --attendees-2ndBorderColor: rgba(255, 255, 255, .4); + --attendees-2ndBottomBorderColor: rgba(255, 255, 255, .6); + --attendees-focusBorder: var(--attendees-currentColor) + var(--attendees-2ndBorderColor); + --attendees-focusBottomBorder: var(--attendees-currentColor) + var(--attendees-2ndBottomBorderColor); + --attendees-focusImage: linear-gradient(rgba(131, 183, 249, .28), + rgba(131, 183, 249, .5)); + } +} + +@media (-moz-windows-default-theme) and (-moz-os-version: windows-win8), + (-moz-windows-default-theme) and (-moz-os-version: windows-win10) { + #calendar-event-dialog-inner .item-attendees-cell { + padding: 1px; + border: 1px solid transparent; + --attendees-focusColor: rgb(123, 195, 255); + --attendees-focusBorder: var(--attendees-focusColor); + --attendees-focusBottomBorder: var(--attendees-focusColor); + --attendees-focusImage: linear-gradient(rgb(205, 232, 255), + rgb(205, 232, 255)); + } +} diff --git a/calendar/base/themes/common/calendar-creation-wizard.css b/calendar/base/themes/common/calendar-creation-wizard.css new file mode 100644 index 000000000..b8f0948df --- /dev/null +++ b/calendar/base/themes/common/calendar-creation-wizard.css @@ -0,0 +1,15 @@ +/* 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/. */ + +#customize-rows > row { + min-height: 26px; +} + +.checkbox-no-label > .checkbox-label-box { + display: none; +} + +#calendar-uri > .textbox-input-box > .textbox-search-icons { + display: none; +} diff --git a/calendar/base/themes/common/calendar-daypicker.css b/calendar/base/themes/common/calendar-daypicker.css new file mode 100644 index 000000000..d86113d37 --- /dev/null +++ b/calendar/base/themes/common/calendar-daypicker.css @@ -0,0 +1,53 @@ +/* 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/. */ + +daypicker { + -moz-binding: url(chrome://calendar/content/calendar-daypicker.xml#daypicker); + background-image: linear-gradient(rgba(0, 0, 0, .0) 5%, rgba(0, 0, 0, .20)); + background-color: -moz-Field; + text-align: center; +} + +daypicker[mode="monthly-days"] { + width: 32px; + height: 15px; +} + +daypicker[mode="daypicker-weekday"] { + min-width: 36px; + height: 32px; +} + +daypicker[mode="monthly-days"][bottom="true"][right="true"] { + width: 128px; + height: 15px; +} + +daypicker:hover { + background-image: linear-gradient(rgba(255, 255, 255, .0), rgba(0, 0, 0, .10) 90%); + cursor: pointer; +} + +daypicker:hover:active, +daypicker[open="true"] { + background-image: linear-gradient(rgba(0, 0, 0, .15), rgba(0, 0, 0, .01) 15%); + cursor: pointer; +} + +daypicker[disabled="true"], +daypicker[disabled="true"][checked="true"], +daypicker[disabled="true"]:hover, +daypicker[disabled="true"]:hover:active, +daypicker[disabled="true"][open="true"] { + background-image: linear-gradient(rgba(0, 0, 0, .0) 5%, rgba(0, 0, 0, .20)); + color: GrayText; + cursor: default; + background-color: -moz-Dialog; +} + +daypicker[checked="true"] { + background-image: linear-gradient(rgba(0, 0, 0, .30), rgba(255, 255, 255, .0) 35%); + background-color: Highlight; + color: HighlightText; +} diff --git a/calendar/base/themes/common/calendar-itip-icons.svg b/calendar/base/themes/common/calendar-itip-icons.svg new file mode 100644 index 000000000..75b4655b3 --- /dev/null +++ b/calendar/base/themes/common/calendar-itip-icons.svg @@ -0,0 +1,121 @@ +<svg xmlns="http://www.w3.org/2000/svg"
+ xmlns:xlink="http://www.w3.org/1999/xlink"
+ viewBox="0 0 112 48" width="112" height="48">
+
+ <!-- definitions -->
+ <style>
+ .req {
+ fill: #ffcc00;
+ stroke: #000000;
+ stroke-width: 0.5;
+ }
+ .opt {
+ fill: #cccccc;
+ stroke: #000000;
+ stroke-width: 0.5;
+ }
+ .non {
+ fill: #ffffff;
+ stroke: #000000;
+ stroke-width: 0.5;
+ }
+ .status {
+ fill:#ffffff;
+ stroke:#ffffff;
+ stroke-width:0.5;
+ }
+ </style>
+ <clipPath id="cut-off-bottom">
+ <rect x="0" y="0" width="16" height="16" />
+ </clipPath>
+ <symbol id="chairsymbol" clip-path="url(#cut-off-bottom)">
+ <ellipse cx="6" cy="16" rx="5.5" ry="8" fill="#000000" />
+ <circle cx="6" cy="6.25" r="4" fill="#ffffff"/>
+ <ellipse cx="10" cy="16" rx="5.5" ry="8" fill="#000000" />
+ <circle cx="10" cy="6.25" r="4" fill="#ffffff"/>
+ <ellipse cx="8" cy="16" rx="6.5" ry="9" fill="#ffcc00" />
+ <circle cx="8" cy="4.5" r="4" fill="#ffcc00"/>
+ <line x1="0.5" y1="15.75" x2="15.5" y2="15.75" />
+ </symbol>
+ <symbol id="individual" clip-path="url(#cut-off-bottom)">
+ <ellipse cx="8" cy="16" rx="7.5" ry="8.5" />
+ <circle cx="8" cy="5" r="4.5"/>
+ <line x1="0.5" y1="15.75" x2="15.5" y2="15.75" />
+ </symbol>
+ <symbol id="group" clip-path="url(#cut-off-bottom)">
+ <ellipse cx="5.75" cy="16" rx="5.5" ry="8" />
+ <circle cx="5.75" cy="6.5" r="4"/>
+ <ellipse cx="7.5" cy="16" rx="6.0" ry="8.5" />
+ <circle cx="7.5" cy="5.5" r="4"/>
+ <ellipse cx="9.25" cy="16" rx="6.25" ry="9" />
+ <circle cx="9.25" cy="4.5" r="4"/>
+ <line x1="0.25" y1="15.75" x2="15.55" y2="15.75" />
+ </symbol>
+ <symbol id="resource">
+ <rect x="5.25" y="0.5" rx="1" ry="1" width="10.25" height="12" />
+ <rect x="0.25" y="7" rx="1" ry="1" width="13" height="8" />
+ <circle cx="4.25" cy="11" r="2.5" style="fill:#ffffff" />
+ <line x1="8.25" y1="9" x2="12" y2="9" />
+ <line x1="8.25" y1="11" x2="12" y2="11" />
+ <line x1="8.25" y1="13" x2="12" y2="13" />
+ <rect x="1.25" y="15" width="4" height="1" style="fill:#000000; stroke-width:0" />
+ <rect x="8" y="15" width="4" height="1" style="fill:#000000; stroke-width:0" />
+ </symbol>
+ <symbol id="room">
+ <rect x="4" y="0.5" rx="0.25" ry="0.25" width="3" height="2" />
+ <rect x="8.5" y="0.5" rx="0.25" ry="0.25" width="3" height="2" />
+ <rect x="0.25" y="4.5" rx="0.25" ry="0.25" width="2" height="3" />
+ <rect x="0.25" y="8.5" rx="0.25" ry="0.25" width="2" height="3" />
+ <rect x="3.5" y="4" rx="1" ry="1" width="8.75" height="8" />
+ <rect x="13.5" y="4.5" rx="0.25" ry="0.25" width="2" height="3" />
+ <rect x="13.5" y="8.5" rx="0.25" ry="0.25" width="2" height="3" />
+ <rect x="4" y="13.5" rx="0.25" ry="0.25" width="3" height="2" />
+ <rect x="8.5" y="13.5" rx="0.25" ry="0.25" width="3" height="2" />
+ </symbol>
+ <symbol id="unknown">
+ <path d="m 7.8339844,11.558594 -2.4902344,0 q -0.00977,-0.53711 -0.00977,-0.654297 0,-1.2109376 0.4003906,-1.9921876 Q 6.1347656,8.1308594 7.3359375,7.1542969 8.5371094,6.1777344 8.7714844,5.875 9.1328125,5.3964844 9.1328125,4.8203125 q 0,-0.8007813 -0.6445312,-1.3671875 -0.6347657,-0.5761719 -1.7187501,-0.5761719 -1.0449218,0 -1.7480468,0.5957032 -0.703125,0.5957031 -0.9667969,1.8164062 l -2.5195313,-0.3125 Q 1.6425781,3.2285156 3.0195313,2.0078125 4.40625,0.78710937 6.6523437,0.78710937 q 2.3632813,0 3.7597653,1.24023443 1.396485,1.2304687 1.396485,2.8710937 0,0.9082031 -0.517578,1.71875 Q 10.783203,7.4277344 9.1035156,8.8242188 8.234375,9.546875 8.0195313,9.9863281 7.8144531,10.425781 7.8339844,11.558594 Z M 5.34375,15.25 l 0,-2.744141 2.7441406,0 0,2.744141 -2.7441406,0 z" />
+ </symbol>
+ <symbol id="status" style="stroke-width:0.25;">
+ <circle cx="11.5" cy="11.5" r="4.5" style="fill:#000000; stroke:#ffffff" />
+ <circle cx="11.5" cy="11.5" r="4.25" style="stroke:#000000" />
+ </symbol>
+
+ <!-- status icons -->
+ <g id="accepted" class="status">
+ <use style="fill:#00a000;" xlink:href="#status" x="0" y="0" />
+ <rect x="9" y="11" width="5" height="1" />
+ <rect x="11" y="9" width="1" height="5" />
+ </g>
+ <g id="tentative" class="status">
+ <use style="fill:#0000ff;" xlink:href="#status" x="16" y="0" />
+ <path d="m 27.933594,13 -0.996094,0 q -0.0039,-0.214844 -0.0039,-0.261719 0,-0.484375 0.160156,-0.796875 0.160156,-0.3125 0.640625,-0.703125 0.480469,-0.390625 0.574219,-0.511719 0.144531,-0.191406 0.144531,-0.421875 0,-0.3203125 -0.257813,-0.546875 -0.253906,-0.2304687 -0.6875,-0.2304687 -0.417968,0 -0.699218,0.2382812 -0.28125,0.2382813 -0.386719,0.7265625 l -1.007813,-0.125 q 0.04297,-0.6992187 0.59375,-1.1875 0.554688,-0.4882812 1.453125,-0.4882812 0.945313,0 1.503907,0.4960937 0.558593,0.4921875 0.558593,1.1484375 0,0.363281 -0.207031,0.6875 -0.203125,0.324219 -0.875,0.882813 -0.347656,0.289062 -0.433594,0.464843 -0.08203,0.175782 -0.07422,0.628907 z M 26.9375,14.25 l 0,-1.097656 1.097656,0 0,1.097656 -1.097656,0 z" />
+ </g>
+ <g id="declined" class="status">
+ <use style="fill:#ee0000;" xlink:href="#status" x="0" y="16" />
+ <rect x="9.5" y="26.75" width="4.25" height="1.5" />
+ </g>
+ <use id="needs-action" style="stroke-width:0.5; fill:#f4f444;" xlink:href="#status" x="16" y="16" />
+ <g id="delegated" class="status">
+ <use style="fill:#444444;" xlink:href="#status" x="0" y="32" />
+ <rect x="9" y="43" width="2.5" height="1" />
+ <polygon points="11.5,41, 11.5,46, 14.5,43.5" />
+ </g>
+
+ <!-- role/partstat icons -->
+ <use id="chair" class="req" xlink:href="#chairsymbol" x="16" y="32" />
+ <use id="individual-reqparticipant" class="req" xlink:href="#individual" x="32" y="0" />
+ <use id="group-reqparticipant" class="req" xlink:href="#group" x="48" y="0" />
+ <use id="resource-reqparticipant" class="req" xlink:href="#resource" x="64" y="0" />
+ <use id="room-reqparticipant" class="req" xlink:href="#room" x="80" y="0" />
+ <use id="unknown-reqparticipant" class="req" xlink:href="#unknown" x="96" y="0" />
+ <use id="individual-optparticipant" class="opt" xlink:href="#individual" x="32" y="16" />
+ <use id="group-optparticipant" class="opt" xlink:href="#group" x="48" y="16" />
+ <use id="resource-optparticipant" class="opt" xlink:href="#resource" x="64" y="16" />
+ <use id="room-optparticipant" class="opt" xlink:href="#room" x="80" y="16" />
+ <use id="unknown-optparticipant" class="opt" xlink:href="#unknown" x="96" y="16" />
+ <use id="individual-nonparticipant" class="non" xlink:href="#individual" x="32" y="32" />
+ <use id="group-nonparticipant" class="non" xlink:href="#group" x="48" y="32" />
+ <use id="resource-nonparticipant" class="non" xlink:href="#resource" x="64" y="32" />
+ <use id="room-nonparticipant" class="non" xlink:href="#room" x="80" y="32" />
+ <use id="unknown-nonparticipant" class="non" xlink:href="#unknown" x="96" y="32" />
+</svg>
\ No newline at end of file diff --git a/calendar/base/themes/common/calendar-management.css b/calendar/base/themes/common/calendar-management.css new file mode 100644 index 000000000..ccc82d6ef --- /dev/null +++ b/calendar/base/themes/common/calendar-management.css @@ -0,0 +1,49 @@ +/* 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/. */ + +calendar-list-tree > tree > treechildren::-moz-tree-cell(color-treecol, color-default) { + background-color: #a8c2e1; +} + +calendar-list-tree > tree > treechildren::-moz-tree-cell(color-treecol) { + margin: 1px; +} + +calendar-list-tree > tree > treechildren::-moz-tree-cell(calendarname-treecol) { + margin-inline-start: 1px; +} + +calendar-list-tree > tree > treechildren::-moz-tree-image(status-treecol, readonly) { + list-style-image: url(chrome://calendar-common/skin/calendar-status.png); + -moz-image-region: rect(0px, 14px, 14px, 0px); +} + +calendar-list-tree > tree > treechildren::-moz-tree-image(status-treecol, readfailed) { + list-style-image: url(chrome://calendar-common/skin/calendar-status.png); + -moz-image-region: rect(0px, 28px, 14px, 14px); +} + +calendar-list-tree > tree { + border: none; + padding: 0; + margin: 4px 0; + -moz-border-top-colors: none; + -moz-border-right-colors: none; + -moz-border-bottom-colors: none; + -moz-border-left-colors: none; + -moz-appearance: none; +} + +calendar-list-tree > tree > treecols > treecol[hideheader="true"], +calendar-list-tree > tree > treecols > treecol[hideheader="true"] { + font-size: 0px; + border: none; + padding: 0; + max-height: 0px; + height: 0px; +} + +calendar-list-tree > tree > treecols > treecol[anonid="scrollbar-spacer"]:-moz-system-metric(overlay-scrollbars) { + display: none; +} diff --git a/calendar/base/themes/common/calendar-occurrence-prompt.css b/calendar/base/themes/common/calendar-occurrence-prompt.css new file mode 100644 index 000000000..21def598e --- /dev/null +++ b/calendar/base/themes/common/calendar-occurrence-prompt.css @@ -0,0 +1,72 @@ +/* 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/. */ + +#calendar-occurrence-prompt { + padding: 0; + width: 25em; + height: 34ex; + min-width: 25em; + min-height: 34ex; + -moz-user-focus: ignore; +} + +#occurrence-prompt-header { + height: 50px; + padding: 0 15px; + border-bottom: 2px groove ThreeDFace; + background-color: window; + color: windowtext; +} + +#title-label { + font-weight: bold; +} + +#accept-buttons-box { + padding: 0 18px; + border-bottom: 2px groove ThreeDFace; +} + + +.occurrence-accept-buttons { + list-style-image: url(chrome://calendar/skin/calendar-occurrence-prompt.png); + margin: 10px 0px; +} + +.occurrence-accept-buttons > .button-box > .button-text { + margin: 0 3px !important; +} + +#accept-buttons-box[action="edit"] > #accept-occurrence-button { + -moz-image-region: rect(0 20px 20px 0); +} + +#accept-buttons-box[action="edit"] > #accept-parent-button { + -moz-image-region: rect(0 40px 20px 20px); +} + +#accept-buttons-box[action="edit"] > #accept-allfollowing-button { + -moz-image-region: rect(0 60px 20px 40px); +} + +#accept-buttons-box[action="delete"] > #accept-occurrence-button { + -moz-image-region: rect(0 80px 20px 60px); +} + +#accept-buttons-box[action="delete"] > #accept-parent-button { + -moz-image-region: rect(0 100px 20px 80px); +} + +#accept-buttons-box[action="delete"] > #accept-allfollowing-button { + -moz-image-region: rect(0 120px 20px 100px); +} + +@media (-moz-os-version: windows-vista), + (-moz-os-version: windows-win7), + (-moz-os-version: windows-win8), + (-moz-os-version: windows-win10) { + .occurrence-accept-buttons { + list-style-image: url(chrome://calendar/skin/calendar-occurrence-prompt-aero.png); + } +} diff --git a/calendar/base/themes/common/calendar-printing.css b/calendar/base/themes/common/calendar-printing.css new file mode 100644 index 000000000..4a0ae9a25 --- /dev/null +++ b/calendar/base/themes/common/calendar-printing.css @@ -0,0 +1,43 @@ +/* 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/. */ + +.main-table { + font-size: 26px; + font-weight: bold; +} + +.day-name { + border: 1px solid #000; + background-color: #e0e0e0; + font-size: 12px; + font-weight: bold; +} + +.day-box { + border: 1px solid black; + vertical-align: top; +} + +.out-of-month { + background-color: gray !important; +} + +.day-off { + background-color: #D3D3D3 !important; +} + +.taskItem { + border-bottom: 1px solid #F3F3F3; +} + +.tasks { + margin: 5px; +} + +.taskList { + margin: 0px; + list-style-image: none; + list-style-type: none; + padding: 0px; +} diff --git a/calendar/base/themes/common/calendar-providerUninstall-dialog.css b/calendar/base/themes/common/calendar-providerUninstall-dialog.css new file mode 100644 index 000000000..f788899c8 --- /dev/null +++ b/calendar/base/themes/common/calendar-providerUninstall-dialog.css @@ -0,0 +1,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/. */ + +#provider-name-label { + font-weight: bold; + margin-inline-start: 3em; +} diff --git a/calendar/base/themes/common/calendar-task-tree.css b/calendar/base/themes/common/calendar-task-tree.css new file mode 100644 index 000000000..846bbe309 --- /dev/null +++ b/calendar/base/themes/common/calendar-task-tree.css @@ -0,0 +1,136 @@ +/* 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/. */ + +.calendar-task-tree { + -moz-appearance: none; + background-color: -moz-Field; + color: -moz-FieldText; + border: 0; + margin: 0; +} + +/* align the treechildren text */ +.calendar-task-tree > treechildren::-moz-tree-cell-text { + margin-top: 1px; + margin-bottom: 1px; +} + +.calendar-task-tree > treechildren::-moz-tree-row(selected, focus) { + background-color: Highlight; +} + +.calendar-task-tree > treechildren::-moz-tree-cell-text(selected, focus) { + color: HighlightText; +} + +.calendar-task-tree > treechildren::-moz-tree-cell-text(inprogress) { + color: green; +} + +.calendar-task-tree > treechildren::-moz-tree-row(inprogress, selected, focus) { + background-color: green; +} + +.calendar-task-tree > treechildren::-moz-tree-cell-text(inprogress, selected, focus) { + color: HighlightText; +} + +.calendar-task-tree > treechildren::-moz-tree-cell-text(overdue) { + color: red; +} + +.calendar-task-tree > treechildren::-moz-tree-row(overdue, selected, focus) { + background-color: red; +} + +.calendar-task-tree > treechildren::-moz-tree-cell-text(overdue, selected, focus) { + color: HighlightText; +} + +.calendar-task-tree > treechildren::-moz-tree-cell-text(duetoday) { + color: WindowText; + font-weight: bold; +} + +.calendar-task-tree > treechildren::-moz-tree-row(duetoday, selected, focus) { + background-color: Highlight; +} + +.calendar-task-tree > treechildren::-moz-tree-cell-text(duetoday, selected, focus) { + color: HighlightText; +} + +.calendar-task-tree > treechildren::-moz-tree-cell-text(future) { + color: WindowText; +} + +.calendar-task-tree > treechildren::-moz-tree-row(future, selected, focus) { + background-color: Highlight; +} + +.calendar-task-tree > treechildren::-moz-tree-cell-text(future, selected, focus) { + color: HighlightText; +} + +.calendar-task-tree > treechildren::-moz-tree-cell-text(completed) { + text-decoration: line-through; + font-style: italic; + color: WindowText; +} + +.calendar-task-tree > treechildren::-moz-tree-row(completed, selected, focus) { + background-color: Highlight; +} + +.calendar-task-tree > treechildren::-moz-tree-cell-text(completed, selected, focus) { + color: HighlightText; +} + +.calendar-task-tree-col-priority { + list-style-image: url(chrome://calendar-common/skin/task-images.png); + -moz-image-region: rect(0 13px 13px 0); +} + +.calendar-task-tree > treechildren::-moz-tree-image(calendar-task-tree-col-priority, normalpriority), +.todo-due-image-class { + list-style-image: url(chrome://calendar-common/skin/task-images.png); + -moz-image-region: rect(0 13px 13px 0); +} + +.calendar-task-tree > treechildren::-moz-tree-image(calendar-task-tree-col-priority, highpriority), +.todo-due-image-class[highpriority="true"] { + list-style-image: url(chrome://calendar-common/skin/task-images.png); + -moz-image-region: rect(0 52px 13px 39px); +} + +.calendar-task-tree > treechildren::-moz-tree-image(calendar-task-tree-col-priority, lowpriority), +.todo-due-image-class[lowpriority="true"] { + list-style-image: url(chrome://calendar-common/skin/task-images.png); + -moz-image-region: rect(0 26px 13px 13px); +} + +.calendar-task-tree > treechildren::-moz-tree-image(calendar-task-tree-col-priority, selected, highpriority) { + list-style-image: url(chrome://calendar-common/skin/task-images.png); + -moz-image-region: rect(0 65px 13px 52px); +} + +.calendar-task-tree > treechildren::-moz-tree-image(calendar-task-tree-col-priority, selected, lowpriority) { + list-style-image: url(chrome://calendar-common/skin/task-images.png); + -moz-image-region: rect(0 39px 13px 26px); +} + +/* ::::: tree progress meter ::::: */ + +.calendar-task-tree > treechildren::-moz-tree-progressmeter { + border: 1px solid ThreeDShadow; + color: Highlight; + background-color: -moz-field; +} + +@media all and (-moz-windows-default-theme) { + .calendar-task-tree > treechildren::-moz-tree-progressmeter(hover), + .calendar-task-tree > treechildren::-moz-tree-progressmeter(selected) { + margin: 1px 4px; + } +} diff --git a/calendar/base/themes/common/calendar-task-view.css b/calendar/base/themes/common/calendar-task-view.css new file mode 100644 index 000000000..13640a165 --- /dev/null +++ b/calendar/base/themes/common/calendar-task-view.css @@ -0,0 +1,139 @@ +/* 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/. */ + +#calendar-header-name-column, +#calendar-task-details-attachment-row > hbox { + padding-inline-start: 0.1em; +} + +#calendar-task-details-grid { + padding-top: 1px; + padding-inline-start: 0; + padding-inline-end: 0; + padding-bottom: 0.2em; +} + +#other-actions-box { + padding-bottom: 0.3em; +} + +#calendar-task-details { + min-height: 6ex; +} + +#task-addition-box { + border-bottom: 1px solid ThreeDShadow; +} + +#calendar-task-details-description { + -moz-appearance: textfield; + border: 1px solid; + margin: 0; + font-family: serif; + font-size: 16px; +} + +.task-details-name { + text-align: right; + background-color: transparent; + border: none; +} + +#calendar-task-details-grid > rows > .item-date-row > .headline { + font-weight: normal; +} + +#calendar-task-details-attachment-row { + margin-top: 3px; +} + +#calendar-task-details-attachment-rows { + max-height: 60px; +} + +.task-details-value { + text-align: left; + background-color: transparent; + border: none; +} + +#calendar-task-tree { + min-height: 98px; +} + +#calendar-task-tree-detail { + border-top: 1px solid ThreeDShadow; + margin: 3px 0; +} + +#view-task-edit-field { + margin: 5px; +} + +.task-edit-field[readonly="true"] { + color: GrayText; +} + +#calendar-task-details-title { + font-weight: bold; +} + +#unifinder-task-edit-field { + margin: 3px; +} + +#unifinder-todo-tree > .calendar-task-tree { + margin-bottom: 3px; +} + +/* ::::: task actions toolbar ::::: */ + +#task-actions-toolbox { + border: none; +} + +#task-actions-toolbar { + -moz-appearance: none; + -moz-box-pack: end; + border: none; +} + +#task-actions-toolbar toolbarpaletteitem toolbarseparator, +#task-actions-toolbar toolbarseparator { + height: 26px; +} + +#task-actions-toolbar toolbarspacer { + height: 20px; +} + +window[toolboxId="task-actions-toolbox"] #wrapper-spring { + display: none; +} + +window[toolboxId="task-actions-toolbox"] #smallicons, +window[toolboxId="task-actions-toolbox"] button[icon="add"] { + display: none; +} + +window[toolboxId="task-actions-toolbox"] #modelist menuitem:first-child { + display: none; +} + +#task-actions-toolbox[doCustomization] { + background: grey; +} + +#task-actions-toolbox[doCustomization] #task-actions-toolbar { + min-width: 100px; + min-height: 24px; +} + +#calendar-add-task-button { + margin-inline-start: 5px; +} + +#calendar-add-task-button > .toolbarbutton-text { + padding-inline-start: 5px; +} diff --git a/calendar/base/themes/common/calendar-toolbar-osxlion.svg b/calendar/base/themes/common/calendar-toolbar-osxlion.svg new file mode 100644 index 000000000..c8c56c331 --- /dev/null +++ b/calendar/base/themes/common/calendar-toolbar-osxlion.svg @@ -0,0 +1,64 @@ +<svg xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink" + viewBox="0 0 18 18"> + <style> + path { + display: none; + } + path:target { + display: block; + stroke-width: 1; + } + .normal { + fill: url(#osx1); + fill-opacity: 1; + stroke: url(#osx2); + } + </style> + <defs> + <linearGradient + id="osx1" + x1="8" + y1="1" + x2="8" + y2="15" + gradientUnits="userSpaceOnUse"> + <stop stop-color="#4f4f4f" stop-opacity="0.76" offset="0"/> + <stop stop-color="#717171" stop-opacity="0.6" offset="1"/> + </linearGradient> + <linearGradient + id="osx2" + x1="8" + y1="1" + x2="8" + y2="15" + gradientUnits="userSpaceOnUse"> + <stop stop-color="#252525" stop-opacity="0.88" offset="0"/> + <stop stop-color="#505050" stop-opacity="0.68" offset="1"/> + </linearGradient> + </defs> + <path id="calendar-tab" class="normal" d="m 1.5,2.5 0,14 15,0 0,-14 -2,0 0,2 -2,0 0,-3.5 0,3.5 -2,0 0,-2 -3,0 0,2 -2,0 0,-3.5 0,3.5 -2,0 0,-2 z m 0,3 15,0 0,11 -15,0 z m 4,2 6.5,0 -3,7 -2.5,0 2,-5 -3,0 z"/> + <path id="task-tab" class="normal" d="m 4.5,2.5 -2,0 0,14 13,0 0,-14 -2,0 0,2 1,0 0,11 -11,0 0,-11 1,0 z m 2,-1 5,0 0,3 -5,0 z M 5.1,10.8 6.7,9.2 8.5,10.7 12,7 l 1.5,1.5 -5,5 z"/> + <path id="synchronize" class="normal" d="m 11.5,1.9 0,3.7 c 3.7,1.8 1.7,5.5 -1,6.8 l -2,-1.9 0,6 5.7,0 -1.6,-1.6 C 18.5,12.5 18.2,2.7 11.5,1.9 Z m -3.8,3.8 1.8,1.8 0,-6 -5.8,0 L 5.3,3 C -0.9,6.3 0.4,15 6.5,16.1 l 0,-3.6 C 3,10.7 4.5,7 7.7,5.7 Z"/> + <path id="newevent" class="normal" d="m 1.5,2.5 0,14 15,0 0,-14 -2,0 0,2 -2,0 0,-3.5 0,3.5 -2,0 0,-2 -3,0 0,2 -2,0 0,-3.5 0,3.5 -2,0 0,-2 z m 0,3 15,0 0,11 -15,0 z m 6,2 3,0 0,2 2,0 0,3 -2,0 0,2 -3,0 0,-2 -2,0 0,-3 2,0 z"/> + <path id="newtask" class="normal" d="m 4.5,2.5 -2,0 0,14 13,0 0,-14 -2,0 0,2 1,0 0,11 -11,0 0,-11 1,0 z m 2,-1 5,0 0,3 -5,0 z m 1,5 3,0 0,2 2,0 0,3 -2,0 0,2 -3,0 0,-2 -2,0 0,-3 2,0 z"/> + <path id="edit" class="normal" d="M 3.6,11.5 2.2,15.7 6.5,14.3 15.8,5 13,2.2 Z m 0,0 L 4.1,11 7,13.8 6.5,14.3 2.2,15.7 Z"/> + <path id="delete" class="normal" d="m 9,1.5 c 4.2,0 7.5,3.3 7.5,7.5 0,4.1 -3.3,7.5 -7.5,7.5 C 4.9,16.5 1.5,13.2 1.5,9 1.5,4.8 4.9,1.5 9,1.5 Z m -3,3 7.5,7.5 C 16.3,7.1 11,1.7 6,4.5 Z M 4.5,6 c -3.1,5.4 2.9,10 7.5,7.5 z"/> + <path id="today" class="normal" d="m 1.5,2.5 0,14 15,0 0,-14 -2,0 0,2 -2,0 0,-3.5 0,3.5 -2,0 0,-2 -3,0 0,2 -2,0 0,-3.5 0,3.5 -2,0 0,-2 z m 0,3 15,0 0,11 -15,0 z m 6,2 3,0 0,4 2,0 -3.5,3 -3.5,-3 2,0 z"/> + <path id="print" class="normal" d="m 4.5,2.5 0,4 -1.5,0 c -0.8,0 -1.5,1.1 -1.5,2 l 0,3 c 0,1 1.4,2 2.5,2 l 1.5,0 0,-1 7,0 0,1 1.5,0 c 1,0 2.5,-1 2.5,-2 l 0,-3 c 0,-0.8 -0.6,-2 -1.5,-2 l -1.5,0 0,-4 z m 0,0 9,0 0,6 -9,0 z m 0,6 2,0 0,2 -2,0 z m 0,5.4 0,1.6 m -2.5,0 14,0 m -2.5,0 0,-1.6"/> + <path id="find" class="normal" d="M 8,2.5 C 5,2.5 2.5,5 2.5,8 c 0,3 2.5,5.5 5.5,5.5 3,0 5.5,-2.4 5.5,-5.5 C 13.5,5 11,2.5 8,2.5 Z m 0,2 c 2,0 3.5,1.5 3.5,3.5 0,2 -1.5,3.5 -3.5,3.5 C 6,11.5 4.5,10 4.5,8 4.5,6 6,4.5 8,4.5 Z m 4.5,7 -1,1 3,3 1,-1 z"/> + <path id="category" class="normal" d="M 4 2.5 C 3.3 2.5 2.5 3.3 2.5 4 L 2.5 8 L 10 15.5 L 15.5 10 L 8 2.5 L 4 2.5 z M 6 4 A 2 2 0 0 1 8 6 A 2 2 0 0 1 6 8 A 2 2 0 0 1 4 6 A 2 2 0 0 1 6 4 z"/> + <path id="complete" class="normal" d="M 1.5,10.5 3,8.5 6.7,11.7 C 9,7.8 12.6,5.6 16.5,3.5 l 0,1 c -4,3.1 -7,6.7 -9,11.2 z"/> + <path id="priority" class="normal" d="m 7.5,2.5 3,0 0,8 -3,0 z M 10.5,14 A 1.5,1.5 0 0 1 9,15.5 1.5,1.5 0 0 1 7.5,14 1.5,1.5 0 0 1 9,12.5 1.5,1.5 0 0 1 10.5,14 Z"/> + <path id="pane" class="normal" d="m 1.5,2.5 0,14 15,0 0,-14 -2,0 0,2 -2,0 0,-3.5 0,3.5 -2,0 0,-2 -3,0 0,2 -2,0 0,-3.5 0,3.5 -2,0 0,-2 z m 0,3 15,0 0,11 -15,0 z"/> + <path id="save" class="normal" d="m 2.5,2.5 0,13 2,0 0,-6 8,0 0,6 3,0 0,-13 z m 2,0 9,0 0,5 -9,0 z m 2,9 4,0 0,4 -4,0 z"/> + <path id="save-close" class="normal" d="m 4.5,2.5 4.5,0 4.5,0 z m -2,0 0,8.2 3,-3.2 0.2,0 2.1,2 1.4,0 2,-2 -6.7,0 0,-5 z m 11,0 0,2.7 0.4,-0.4 1.6,0 0,-2.3 z M 15,7 8.5,13.2 5.5,10.4 4,12 l 4.5,4.5 8,-8 z m 0.5,5.3 -3,3 0,0.2 3,0 z m -13,1 0,2.2 2,0 0,-0.2 z"/> + <path id="address" class="normal" d="m 2.5,15.5 13,0 c 0,-3.4 -2.2,-3 -5,-3 l 0,-1 C 12.5,10.4 12.7,9.4 13,8 13,7.7 13.5,7.4 13.5,7 13.5,6.6 13.1,6.3 13,6 12.8,5.6 12.9,3.7 12,3 10.4,1.8 7.56,1.8 6,3 5,3.7 5.1,5.6 5,6 4.8,6.3 4.5,6.6 4.5,7 c 0,0.3 0.4,0.6 0.5,1 0.2,1.5 0.5,2.3 2.5,3.5 l 0,1 c -2.5,0 -5,-0.5 -5,3 z"/> + <path id="security" class="normal" d="m 9,2.5 c -3,0 -5,1 -5.5,5 l 0,1 -1,0 0,7 13,0 0,-7 -1,0 0,-1 c 0,-4 -3,-5 -5,-5 z m 0,2 c 3,0 3.5,1 3.5,4 l -7,0 c 0,-3 1,-4 3.5,-4 z"/> + <path id="attach" class="normal" d="m 13.5,2.5 0,10.5 -4,3.5 -4,-3.5 0,-11.5 6,0 0,10 -2,2 -2,-2 0,-8 2,0 0,6.5" style="fill:none; stroke-opacity:1"/> + <path id="status" class="normal" d="M 16.5,9 A 7.5,7.5 0 0 1 9,16.5 7.5,7.5 0 0 1 1.5,9 7.5,7.5 0 0 1 9,1.5 7.5,7.5 0 0 1 16.5,9 Z M 12.5,9 A 3.5,3.5 0 0 1 9,12.5 3.5,3.5 0 0 1 5.5,9 3.5,3.5 0 0 1 9,5.5 3.5,3.5 0 0 1 12.5,9 Z" style="fill-opacity:0.5"/> + <path id="freebusy" class="normal" d="M 9,1.5 C 4.8,1.5 1.5,4.8 1.5,9 1.5,13.1 5.2,16.3 9,16.5 13,16.7 16.5,13.2 16.5,9 16.5,4.8 13.1,1.5 9,1.5 Z m 0,0 c 0.7,0 1.5,0 2.5,0.5 l 0,4.5 4,0 0,5 -6,0 0,5 C 4.5,16.3 1.5,12.5 1.5,9 1.5,5.3 4.2,1.6 9,1.5 Z m -0.5,2 1,0 0,5 4,0 0,1 -5,0 z"/> + <path id="timezones" class="normal" d="M 9,1.5 C 4.8,1.5 1.5,4.8 1.5,9 1.5,13.1 5.2,16.3 9,16.5 13,16.7 16.5,13.2 16.5,9 16.5,4.8 13.1,1.5 9,1.5 Z M 7,3.8 6.3,4.6 6.3,5.7 7.1,6.8 7.8,8.2 9.4,8.3 10.4,6.1 9.8,3.8 11.3,1.9 c 1.5,0.5 3,1.5 3.8,3 l -3.4,2.5 1.6,-0.1 1.3,2 -0.3,1.8 -0.8,1.4 0.7,1.8 c -0.5,0.7 -1.5,1.3 -2.6,1.7 L 9.7,14 11.2,11.1 9.7,9.9 8.3,9 6.8,10.2 5.9,12.2 6,14 7,16 C 4.7,15.4 3.3,14 2.3,12.2 L 3.5,10.4 3.4,8.9 4.3,8 3.7,7.1 3.7,5.4 2.8,4.7 C 4,3 5.8,1.8 7.8,1.6 Z"/> + <path id="decline" class="normal" d="M 2.5,5 5,2.5 l 4,4 4,-4 2.5,2.5 -4,4 4,4 -2.5,2.5 -4,-4 -4,4 -2.5,-2.5 4,-4 z"/> + <path id="tentative" class="normal" d="m 8.2,6.5 -2.7,0 c 0,-1.4 0.5,-4 3.5,-4 2.3,0 4.4,1 4.5,4 0,2.8 -3,2.5 -3,5 l -3,0 C 7.4,8 10.3,8.5 10.2,6.5 10,5.5 9.3,5.5 9,5.5 c 0,0 -0.8,0 -0.8,1 z m -0.7,7 3,0 0,2 -3,0 z"/> +</svg> diff --git a/calendar/base/themes/common/calendar-toolbar.svg b/calendar/base/themes/common/calendar-toolbar.svg new file mode 100644 index 000000000..8be505cf4 --- /dev/null +++ b/calendar/base/themes/common/calendar-toolbar.svg @@ -0,0 +1,151 @@ +<svg xmlns="http://www.w3.org/2000/svg" + xmlns:xlink="http://www.w3.org/1999/xlink" + viewBox="0 0 18 18"> + <style> + path { + display: none; + } + path:target { + display: block; + stroke-width: 1; + } + .normal { + fill: #505050; + fill-opacity: 0.8; + stroke: #404040; + } + @media (-moz-windows-default-theme) and (-moz-os-version: windows-win8) { + .normal { + fill: #797c80; + fill-opacity: 1; + stroke: #797c80; + stroke-opacity: 0; + } + } + @media (-moz-windows-default-theme) and (-moz-os-version: windows-win10) { + .normal { + fill: #4c4c4c; + fill-opacity: 1; + stroke: #4c4c4c; + stroke-opacity: 0; + } + } + @media (-moz-windows-default-theme) and (-moz-os-version: windows-vista), + (-moz-windows-default-theme) and (-moz-os-version: windows-win7) { + .normal { + fill: url(#win1); + fill-opacity: 1; + stroke: url(#win2); + } + } + @media (-moz-mac-yosemite-theme) { + .normal { + fill: #4d4d4d; + fill-opacity: 1; + stroke-opacity: 0; + } + } + .inverted { + fill: #fff; + fill-opacity: 1; + stroke: #111922; + } + .unread { + fill: #3971c3; + fill-opacity: 1; + stroke: #115174; + } + </style> + <defs> + <linearGradient + id="win1" + x1="8" + y1="1" + x2="8" + y2="15" + gradientUnits="userSpaceOnUse"> + <stop stop-color="#3f4f5a" offset="0"/> + <stop stop-color="#7e8c97" offset="1"/> + </linearGradient> + <linearGradient + id="win2" + x1="8" + y1="1" + x2="8" + y2="15" + gradientUnits="userSpaceOnUse"> + <stop stop-color="#0c1b25" offset="0"/> + <stop stop-color="#4f585f" stop-opacity="0.9" offset="1"/> + </linearGradient> + </defs> + <path id="calendar-tab" class="normal" d="m 1.5,2.5 0,14 15,0 0,-14 -2,0 0,2 -2,0 0,-3.5 0,3.5 -2,0 0,-2 -3,0 0,2 -2,0 0,-3.5 0,3.5 -2,0 0,-2 z m 0,3 15,0 0,11 -15,0 z m 4,2 6.5,0 -3,7 -2.5,0 2,-5 -3,0 z"/> + <path id="calendar-tab-flat" class="normal" d="M 2,3 2,16 16,16 16,3 14,3 14,5 13,5 13,2 12,2 12,5 11,5 11,3 7,3 7,5 6,5 6,2 5,2 5,5 4,5 4,3 Z m 1,3 12,0 0,9 -12,0 z m 3,2 6,0 -3,6 -2.5,0 2,-4 -2.5,0 z"/> + <path id="calendar-tab-inverted" class="inverted" d="m 1.5,3.5 0,13 15,0 0,-13 -3,0 0,1 -3,0 0,-1 -3,0 0,1 -3,0 0,-1 z m 3,3 9,0 0,7 -9,0 z m 0,-5 0,3 3,0 0,-3 z m 6,0 0,3 3,0 0,-3 z m -5,5 7,0 -3,7 -3,0 1.7,-4 -2.7,0 z"/> + <path id="task-tab" class="normal" d="m 4.5,2.5 -2,0 0,14 13,0 0,-14 -2,0 0,2 1,0 0,11 -11,0 0,-11 1,0 z m 2,-1 5,0 0,3 -5,0 z M 5.1,10.8 6.7,9.2 8.5,10.7 12,7 l 1.5,1.5 -5,5 z"/> + <path id="task-tab-flat" class="normal" d="m 5,3 -2,0 0,13 12,0 0,-13 -2,0 0,3 1,0 0,9 L 4,15 4,6 5,6 Z M 6,2 12,2 12,6 6,6 Z M 5.4,11.3 6.7,9.7 8.5,11.2 11.5,7.5 13,9 8.5,14 Z"/> + <path id="task-tab-inverted" class="inverted" d="m 5.5,2.5 -3,0 0,14 13,0 0,-14 -3,0 0,3 0,0 0,8 -7,0 0,-8 0,0 z m 0,-1 7,0 0,4 -7,0 z m 0.1,9.3 1.5,-2.1 1.3,1.2 2.4,-3 1.7,1.6 -4,4.8 z"/> + <path id="synchronize" class="normal" d="m 11.5,1.9 0,3.7 c 3.7,1.8 1.7,5.5 -1,6.8 l -2,-1.9 0,6 5.7,0 -1.6,-1.6 C 18.5,12.5 18.2,2.7 11.5,1.9 Z m -3.8,3.8 1.8,1.8 0,-6 -5.8,0 L 5.3,3 C -0.9,6.3 0.4,15 6.5,16.1 l 0,-3.6 C 3,10.7 4.5,7 7.7,5.7 Z"/> + <path id="synchronize-flat" class="normal" d="m 11,2.4 0,3.7 c 3,1.6 2.4,4.4 0,5.8 L 9,10 l 0,6 5.7,0 -1.6,-1.6 C 17.6,12 17,3.2 11,2.4 Z M 7.2,6.2 9,8 9,2 3.2,2 4.8,3.5 C -0.4,6.7 2.3,14.4 7,15.6 L 7,12 C 4.1,10.2 4.3,7.3 7.2,6.2 Z"/> + <path id="synchronize-inverted" class="inverted" d="m 11.5,1.9 0,3.7 c 3.6,1.2 2.1,5.4 -0.7,6.5 L 8.5,9.5 l 0,7 6.7,0 -1.9,-2 C 18.6,11.6 18,2.8 11.5,1.9 Z M 7,6 9.5,8.5 l 0,-7 -6.8,0 2.2,2.1 C -0.9,6.8 0.6,14.8 6.5,16.1 l 0,-3.6 C 3.5,11 4,7.3 7,6 Z"/> + <path id="newevent" class="normal" d="m 1.5,2.5 0,14 15,0 0,-14 -2,0 0,2 -2,0 0,-3.5 0,3.5 -2,0 0,-2 -3,0 0,2 -2,0 0,-3.5 0,3.5 -2,0 0,-2 z m 0,3 15,0 0,11 -15,0 z m 6,2 3,0 0,2 2,0 0,3 -2,0 0,2 -3,0 0,-2 -2,0 0,-3 2,0 z"/> + <path id="newevent-flat" class="normal" d="M 2,3 2,16 16,16 16,3 14,3 14,5 13,5 13,2 12,2 12,5 11,5 11,3 7,3 7,5 6,5 6,2 5,2 5,5 4,5 4,3 Z m 1,3 12,0 0,9 -12,0 z m 5,2 2,0 0,2 2,0 0,2 -2,0 0,2 -2,0 0,-2 -2,0 0,-2 2,0 z"/> + <path id="newevent-inverted" class="inverted" d="m 1.5,3.5 0,13 15,0 0,-13 -3,0 0,1 -3,0 0,-1 -3,0 0,1 -3,0 0,-1 z m 3,3 9,0 0,7 -9,0 z m 0,-5 0,3 3,0 0,-3 z m 6,0 0,3 3,0 0,-3 z m 0,5 0,2 2,0 0,3 -2,0 0,2 -3,0 0,-2 -2,0 0,-3 2,0 0,-2 z"/> + <path id="newtask" class="normal" d="m 4.5,2.5 -2,0 0,14 13,0 0,-14 -2,0 0,2 1,0 0,11 -11,0 0,-11 1,0 z m 2,-1 5,0 0,3 -5,0 z m 1,5 3,0 0,2 2,0 0,3 -2,0 0,2 -3,0 0,-2 -2,0 0,-3 2,0 z"/> + <path id="newtask-flat" class="normal" d="m 5,3 -2,0 0,13 12,0 0,-13 -2,0 0,3 1,0 0,9 -10,0 0,-9 1,0 z m 1,-1 6,0 0,4 -6,0 z m 2,6 2,0 0,2 2,0 0,2 -2,0 0,2 -2,0 0,-2 -2,0 0,-2 2,0 z"/> + <path id="newtask-inverted" class="inverted" d="m 5.5,2.5 -3,0 0,14 13,0 0,-14 -3,0 0,3 0,0 0,8 -7,0 0,-8 0,0 z m 0,-1 7,0 0,4 -7,0 z m 2,5 3,0 0,2 2,0 0,3 -2,0 0,2 -3,0 0,-2 -2,0 0,-3 2,0 z"/> + <path id="edit" class="normal" d="M 3.6,11.5 2.2,15.7 6.5,14.3 15.8,5 13,2.2 Z m 0,0 L 4.1,11 7,13.8 6.5,14.3 2.2,15.7 Z"/> + <path id="edit-flat" class="normal" d="M 4.2,11 2.5,15.5 7,13.8 Z M 7.7,13 15.5,5.3 12.7,2.5 5,10.3 Z"/> + <path id="edit-inverted" class="inverted" d="M 3.6,11.5 2.2,15.7 6.5,14.3 15.8,4.9 13,2.1 Z m 1,-1 0.4,-0.4 2.8,2.8 -0.4,0.4 z"/> + <path id="delete" class="normal" d="m 9,1.5 c 4.2,0 7.5,3.3 7.5,7.5 0,4.1 -3.3,7.5 -7.5,7.5 C 4.9,16.5 1.5,13.2 1.5,9 1.5,4.8 4.9,1.5 9,1.5 Z m -3,3 7.5,7.5 C 16.3,7.1 11,1.7 6,4.5 Z M 4.5,6 c -3.1,5.4 2.9,10 7.5,7.5 z"/> + <path id="delete-inverted" class="inverted" d="m 9,1.5 c 4.2,0 7.5,3.3 7.5,7.5 0,4.1 -3.3,7.5 -7.5,7.5 C 4.9,16.5 1.5,13.2 1.5,9 1.5,4.8 4.9,1.5 9,1.5 Z M 7,5 13,11 C 14.5,6.6 11.2,3.6 7,5 Z M 5,7 c -1.6,4.3 1.5,7.7 6,6 z"/> + <path id="today" class="normal" d="m 1.5,2.5 0,14 15,0 0,-14 -2,0 0,2 -2,0 0,-3.5 0,3.5 -2,0 0,-2 -3,0 0,2 -2,0 0,-3.5 0,3.5 -2,0 0,-2 z m 0,3 15,0 0,11 -15,0 z m 6,2 3,0 0,4 2,0 -3.5,3 -3.5,-3 2,0 z"/> + <path id="today-flat" class="normal" d="M 2,3 2,16 16,16 16,3 14,3 14,5 13,5 13,2 12,2 12,5 11,5 11,3 7,3 7,5 6,5 6,2 5,2 5,5 4,5 4,3 Z m 1,3 12,0 0,9 -12,0 z m 5,1 2,0 0,3 2,0 -3,4 -3,-4 2,0 z"/> + <path id="today-inverted" class="inverted" d="m 1.5,3.5 0,13 15,0 0,-13 -3,0 0,1 -3,0 0,-1 -3,0 0,1 -3,0 0,-1 z m 3,3 9,0 0,7 -9,0 z m 0,-5 0,3 3,0 0,-3 z m 6,0 0,3 3,0 0,-3 z m -3,5 3,0 0,3 3,0 -4.5,4 -4.5,-4 3,0 z"/> + <path id="print" class="normal" d="m 4.5,2.5 0,4 -1.5,0 c -0.8,0 -1.5,1.1 -1.5,2 l 0,3 c 0,1 1.4,2 2.5,2 l 1.5,0 0,-1 7,0 0,1 1.5,0 c 1,0 2.5,-1 2.5,-2 l 0,-3 c 0,-0.8 -0.6,-2 -1.5,-2 l -1.5,0 0,-4 z m 0,0 9,0 0,6 -9,0 z m 0,6 2,0 0,2 -2,0 z m 0,5.4 0,1.6 m -2.5,0 14,0 m -2.5,0 0,-1.6"/> + <path id="print-flat" class="normal" d="M 5,3 5,7 4,7 C 3.2,7 2,7.6 2,8.5 l 0,3 c 0,1 0.9,1.5 2,1.5 l 1,0 0,1 -1,0 0,1 10,0 0,-1 -1,0 0,-1 1,0 c 1,0 2,-0.5 2,-1.5 l 0,-3 C 16,7.7 14.8,7 14,7 l -1,0 0,-4 z m 1,1 6,0 0,4 -6,0 z m -1,5 1,0 0,1 -1,0 z m 1,3 6,0 0,2 -6,0 z"/> + <path id="print-inverted" class="inverted" d="m 3.5,2.5 0,5 -0.5,0 c -0.8,0 -1.5,1.1 -1.5,2 l 0,2 c 0,0.8 0.4,2 2.5,2 l -1.5,0 0,2 13,0 0,-2 -1.5,0 c 2,0 2.5,-1.1 2.5,-2 l 0,-2 c 0,-0.8 -0.6,-2 -1.5,-2 l -0.5,0 0,-5 z m 2,2 7,0 0,3 -7,0 z m -1,5 2,0 0,1 -2,0 z m 1,3 7,0 0,1 -7,0 z"/> + <path id="find" class="normal" d="M 8,2.5 C 5,2.5 2.5,5 2.5,8 c 0,3 2.5,5.5 5.5,5.5 3,0 5.5,-2.4 5.5,-5.5 C 13.5,5 11,2.5 8,2.5 Z m 0,2 c 2,0 3.5,1.5 3.5,3.5 0,2 -1.5,3.5 -3.5,3.5 C 6,11.5 4.5,10 4.5,8 4.5,6 6,4.5 8,4.5 Z m 4.5,7 -1,1 3,3 1,-1 z"/> + <path id="find-flat" class="normal" d="M 8 3 A 5 5 0 0 0 3 8 A 5 5 0 0 0 8 13 A 5 5 0 0 0 13 8 A 5 5 0 0 0 8 3 z M 8 4 A 4 4 0 0 1 12 8 A 4 4 0 0 1 8 12 A 4 4 0 0 1 4 8 A 4 4 0 0 1 8 4 z m 4,7 -1,1 3,3 1,-1 z"/> + <path id="find-inverted" class="inverted" d="M 11,12.7 14.3,16 16,14 12.7,10.9 C 13.1,10 13.5,9 13.5,8 13.5,5 11,2.5 8,2.5 5,2.5 2.5,5 2.5,8 c 0,3 2.5,5.5 5.5,5.5 1,0 2,0 3,-0.8 z M 8,5 c 1.6,0 3,1.3 3,3 0,1.6 -1.3,3 -3,3 C 6.3,11 5,9.6 5,8 5,6.3 6.3,5 8,5 Z"/> + <path id="category" class="normal" d="M 4 2.5 C 3.3 2.5 2.5 3.3 2.5 4 L 2.5 8 L 10 15.5 L 15.5 10 L 8 2.5 L 4 2.5 z M 6 4 A 2 2 0 0 1 8 6 A 2 2 0 0 1 6 8 A 2 2 0 0 1 4 6 A 2 2 0 0 1 6 4 z"/> + <path id="category-flat" class="normal" d="M 4.5,3 C 3.8,3 3,3.8 3,4.5 L 3,8 10,15.5 15.5,10 8,3 Z m 2,1.5 c 1.1,0 2,0.9 2,2 0,1.1 -0.9,2 -2,2 -1.1,0 -2,-0.9 -2,-2 0,-1.1 0.9,-2 2,-2 z"/> + <path id="category-inverted" class="inverted" d="M 4 2.5 C 3.3 2.5 2.5 3.3 2.5 4 L 2.5 8 L 10 15.5 L 15.5 10 L 8 2.5 L 4 2.5 z M 6 4 A 2 2 0 0 1 8 6 A 2 2 0 0 1 6 8 A 2 2 0 0 1 4 6 A 2 2 0 0 1 6 4 z"/> + <path id="complete" class="normal" d="M 1.5,10.5 3,8.5 6.7,11.7 C 9,7.8 12.6,5.6 16.5,3.5 l 0,1 c -4,3.1 -7,6.7 -9,11.2 z"/> + <path id="complete-flat" class="normal" d="m 2,10.5 1.5,-2 3.7,3 C 9.5,7.6 12.1,5.6 16,3.5 l 0,1 c -4,3.1 -8.5,11 -8.5,11 z"/> + <path id="complete-inverted" class="inverted" d="M 1.5,10.5 3,7.5 6.7,10.7 C 9,6.8 12.6,4.6 16.5,2.5 l 0,2 c -4,3.1 -7,6.7 -9,11.2 z"/> + <path id="priority" class="normal" d="m 7.5,2.5 3,0 0,8 -3,0 z M 10.5,14 A 1.5,1.5 0 0 1 9,15.5 1.5,1.5 0 0 1 7.5,14 1.5,1.5 0 0 1 9,12.5 1.5,1.5 0 0 1 10.5,14 Z"/> + <path id="priority-flat" class="normal" d="m 7,3 4,0 -1,7.9 -2,0 z m 3.5,11 c 0,0.8 -0.6,1.5 -1.5,1.5 -0.8,0 -1.5,-0.6 -1.5,-1.5 0,-0.8 0.6,-1.5 1.5,-1.5 0.8,0 1.5,0.6 1.5,1.5 z"/> + <path id="priority-inverted" class="inverted" d="m 7.5,2.5 3,0 0,8 -3,0 z m 0,10 3,0 0,3 -3,0 z"/> + <path id="pane" class="normal" d="m 1.5,2.5 0,14 15,0 0,-14 -2,0 0,2 -2,0 0,-3.5 0,3.5 -2,0 0,-2 -3,0 0,2 -2,0 0,-3.5 0,3.5 -2,0 0,-2 z m 0,3 15,0 0,11 -15,0 z"/> + <path id="pane-flat" class="normal" d="M 2,3 2,16 16,16 16,3 14,3 14,5 13,5 13,2 12,2 12,5 11,5 11,3 7,3 7,5 6,5 6,2 5,2 5,5 4,5 4,3 Z m 1,3 12,0 0,9 -12,0 z"/> + <path id="pane-inverted" class="inverted" d="m 0.5,2.5 0,15 17,0 0,-15 -4,0 0,1 -3,0 0,-1 -3,0 0,1 -3,0 0,-1 z m 2,3 13,0 0,10 -13,0 z m 2,-5 0,3 3,0 0,-3 z m 6,0 0,3 3,0 0,-3 z"/> + <path id="save" class="normal" d="m 2.5,2.5 0,13 2,0 0,-6 8,0 0,6 3,0 0,-13 z m 2,0 9,0 0,5 -9,0 z m 2,9 4,0 0,4 -4,0 z"/> + <path id="save-flat" class="normal" d="M 2,2 2,16 16,16 16,2 Z M 4,3 14,3 14,8 4,8 Z m 0,7 7,0 0,5 -7,0 z m 1,1 0,3 2,0 0,-3 z"/> + <path id="save-inverted" class="inverted" d="m 2.5,2.5 0,13 2.1,0 0,-5 6.9,0 0,5 4,0 0,-13 z m 2,2 9,0 0,3 -9,0 z m 0.1,6 6.9,0 0,5 -6.9,0 z"/> + <path id="save-close" class="normal" d="m 4.5,2.5 4.5,0 4.5,0 z m -2,0 0,8.2 3,-3.2 0.2,0 2.1,2 1.4,0 2,-2 -6.7,0 0,-5 z m 11,0 0,2.7 0.4,-0.4 1.6,0 0,-2.3 z M 15,7 8.5,13.2 5.5,10.4 4,12 l 4.5,4.5 8,-8 z m 0.5,5.3 -3,3 0,0.2 3,0 z m -13,1 0,2.2 2,0 0,-0.2 z"/> + <path id="save-close-flat" class="normal" d="M 2,2 2,16 16,16 16,2 Z M 4,3 14,3 14,7 4,7 Z M 4.5,11.5 6,10 8,12 12.5,8 14,9.7 8,15 Z"/> + <path id="save-close-inverted" class="inverted" d="m 2.5,2.5 0,13 13,0 0,-13 z m 2,1.9 9,0 0,3.1 -9,0 z m 0.7,7.3 0.7,-0.6 1.7,1.6 4.4,-3.1 0.6,0.8 -5.1,3.6 z"/> + <path id="address" class="normal" d="m 2.5,15.5 13,0 c 0,-3.4 -2.2,-3 -5,-3 l 0,-1 C 12.5,10.4 12.7,9.4 13,8 13,7.7 13.5,7.4 13.5,7 13.5,6.6 13.1,6.3 13,6 12.8,5.6 12.9,3.7 12,3 10.4,1.8 7.56,1.8 6,3 5,3.7 5.1,5.6 5,6 4.8,6.3 4.5,6.6 4.5,7 c 0,0.3 0.4,0.6 0.5,1 0.2,1.5 0.5,2.3 2.5,3.5 l 0,1 c -2.5,0 -5,-0.5 -5,3 z"/> + <path id="address-flat" class="normal" d="m 3,15 12,0 c 0,-3 -1.2,-3 -4,-3 l 0,-1 C 13,9.9 12.7,9.4 13,8 13,7.7 13.5,7.4 13.5,7 13.5,6.6 13.1,6.3 13,6 12.8,5.6 12.9,3.7 12,3 10.4,1.8 7.56,1.8 6,3 5,3.7 5.1,5.6 5,6 4.8,6.3 4.5,6.6 4.5,7 4.5,7.3 4.9,7.6 5,8 5.2,9.5 5,9.8 7,11 l 0,1 c -2.5,0 -4,0 -4,3 z"/> + <path id="address-inverted" class="inverted" d="m 2.5,15.5 13,0 c 0,-3.4 -2.2,-3 -5,-3 l 0,-1 C 12.5,10.4 12.7,9.4 13,8 13,7.7 13.5,7.4 13.5,7 13.5,6.6 13.1,6.3 13,6 12.8,5.6 12.9,3.7 12,3 10.4,1.8 7.56,1.8 6,3 5,3.7 5.1,5.6 5,6 4.8,6.3 4.5,6.6 4.5,7 c 0,0.3 0.4,0.6 0.5,1 0.2,1.5 0.5,2.3 2.5,3.5 l 0,1 c -2.5,0 -5,-0.5 -5,3 z"/> + <path id="security" class="normal" d="m 9,2.5 c -3,0 -5,1 -5.5,5 l 0,1 -1,0 0,7 13,0 0,-7 -1,0 0,-1 c 0,-4 -3,-5 -5,-5 z m 0,2 c 3,0 3.5,1 3.5,4 l -7,0 c 0,-3 1,-4 3.5,-4 z"/> + <path id="security-flat" class="normal" d="M 9,3 C 6.25,3 4,4 4,7.5 L 4,9 3,9 3,15 15,15 15,9 14,9 14,7.5 C 14,4 11.5,3 9,3 Z m 0,2 c 3,0 3,1 3,4 L 6,9 C 6,6 6,5 9,5 Z"/> + <path id="security-inverted" class="inverted" d="m 9,2.5 c -2.75,0 -5.5,0.75 -5.5,5 l 0,1 -1,0 0,7 13,0 0,-7 -1,0 0,-1 c 0,-4 -3,-5 -5.5,-5 z m 0,3 c 2.5,0 2.5,1 2.5,3 l -5,0 c 0,-2 0,-3 2.5,-3 z"/> + <path id="attach" class="normal" d="m 13.5,2.5 0,10.5 -4,3.5 -4,-3.5 0,-11.5 6,0 0,10 -2,2 -2,-2 0,-8 2,0 0,6.5" style="fill:none; stroke-opacity:1"/> + <path id="attach-inverted" class="inverted" d="m 11.5,3 2,0 0,10 L 9,16.5 4.5,13 l 0,-11.5 7,0 z m 0,0.5 0,8.5 -2.5,2 -2.5,-2 0,-8.5 3,0 0,7.5 L 9,11.4 8.5,11 8.5,4"/> + <path id="status" class="normal" d="M 16.5,9 A 7.5,7.5 0 0 1 9,16.5 7.5,7.5 0 0 1 1.5,9 7.5,7.5 0 0 1 9,1.5 7.5,7.5 0 0 1 16.5,9 Z M 12.5,9 A 3.5,3.5 0 0 1 9,12.5 3.5,3.5 0 0 1 5.5,9 3.5,3.5 0 0 1 9,5.5 3.5,3.5 0 0 1 12.5,9 Z" style="fill-opacity:0.5"/> + <path id="status-flat" class="normal" d="M 9 2 A 7 7 0 0 0 2 9 A 7 7 0 0 0 9 16 A 7 7 0 0 0 16 9 A 7 7 0 0 0 9 2 z M 9 5 A 4 4 0 0 1 13 9 A 4 4 0 0 1 9 13 A 4 4 0 0 1 5 9 A 4 4 0 0 1 9 5 z M 12,9 A 3,3 0 0 1 9,12 3,3 0 0 1 6,9 3,3 0 0 1 9,6 3,3 0 0 1 12,9 Z"/> + <path id="status-inverted" class="inverted" d="M 16.5,9 A 7.5,7.5 0 0 1 9,16.5 7.5,7.5 0 0 1 1.5,9 7.5,7.5 0 0 1 9,1.5 7.5,7.5 0 0 1 16.5,9 Z M 12.5,9 A 3.5,3.5 0 0 1 9,12.5 3.5,3.5 0 0 1 5.5,9 3.5,3.5 0 0 1 9,5.5 3.5,3.5 0 0 1 12.5,9 Z"/> + <path id="freebusy" class="normal" d="M 9,1.5 C 4.8,1.5 1.5,4.8 1.5,9 1.5,13.1 5.2,16.3 9,16.5 13,16.7 16.5,13.2 16.5,9 16.5,4.8 13.1,1.5 9,1.5 Z m 0,0 c 0.7,0 1.5,0 2.5,0.5 l 0,4.5 4,0 0,5 -6,0 0,5 C 4.5,16.3 1.5,12.5 1.5,9 1.5,5.3 4.2,1.6 9,1.5 Z m -0.5,2 1,0 0,5 4,0 0,1 -5,0 z"/> + <path id="freebusy-flat" class="normal" d="M 9,2 C 4.8,2 2,4.8 2,9 c 0,4.1 4,7 7,7 4,0 7,-2.8 7,-7 C 16,4.8 13.1,2 9,2 Z m 0,1 c 0.7,0 1,0 2,0.3 l 0,3.7 3,0 0,4 -5,0 0,4 C 5,15 3,11 3,9 3,6 5,3 9,3 Z m -1,1 2,0 0,4 3,0 0,2 -5,0 z"/> + <path id="freebusy-inverted" class="inverted" d="M 9,1.5 C 4.8,1.5 1.5,4.8 1.5,9 1.5,13.1 5.2,16.3 9,16.5 13,16.7 16.5,13.2 16.5,9 16.5,4.8 13.1,1.5 9,1.5 Z m -1.5,3.7 0,-1.7 3,0 0,4 4,0 0,3 -5,0 0,3 C 6.2,13.4 4.4,11.5 4.5,9 4.5,6.4 6,5.2 7.5,5.2 Z m 0,-1.7 3,0 0,4 4,0 0,3 -7,0 z"/> + <path id="timezones" class="normal" d="M 9,1.5 C 4.8,1.5 1.5,4.8 1.5,9 1.5,13.1 5.2,16.3 9,16.5 13,16.7 16.5,13.2 16.5,9 16.5,4.8 13.1,1.5 9,1.5 Z M 7,3.8 6.3,4.6 6.3,5.7 7.1,6.8 7.8,8.2 9.4,8.3 10.4,6.1 9.8,3.8 11.3,1.9 c 1.5,0.5 3,1.5 3.8,3 l -3.4,2.5 1.6,-0.1 1.3,2 -0.3,1.8 -0.8,1.4 0.7,1.8 c -0.5,0.7 -1.5,1.3 -2.6,1.7 L 9.7,14 11.2,11.1 9.7,9.9 8.3,9 6.8,10.2 5.9,12.2 6,14 7,16 C 4.7,15.4 3.3,14 2.3,12.2 L 3.5,10.4 3.4,8.9 4.3,8 3.7,7.1 3.7,5.4 2.8,4.7 C 4,3 5.8,1.8 7.8,1.6 Z"/> + <path id="timezones-flat" class="normal" d="M 9,2 C 4.8,2 2,4.8 2,9 c 0,4.1 3,7 7,7 4,0 7,-2.8 7,-7 C 16,4.8 13.1,2 9,2 Z m -2,2.3 -0.7,0.8 0,1.1 0.8,1.1 0.7,0.7 1.6,0 1,-1.4 -0.6,-2.3 1.4,-1 c 0,0 1,0 2.4,1.6 l -2.4,2.5 1.3,0.5 1.6,1.4 -0.3,1.8 -1,1.2 1,0.8 -2,1.4 -1.9,-1 L 11.5,11 8.3,9 5.9,11.8 6,13.1 7,14.5 C 6,14 4,13 3.9,12.2 L 4.7,10.5 3.9,8.4 4.8,7.5 4.2,6.6 4.2,5.4 4.3,4.8 C 5,4 6,3 8,3 Z"/> + <path id="timezones-inverted" class="inverted" d="M 9,1.5 C 4.8,1.5 1.5,4.8 1.5,9 1.5,13.1 5.2,16.3 9,16.5 13,16.7 16.5,13.2 16.5,9 16.5,4.8 13.1,1.5 9,1.5 Z m -2.8,3.3 0.8,2.8 L 7.5,9 9.9,8.6 10,5.7 9.8,3.6 c 1.5,0.5 1.8,0.1 2.6,1.6 l -1,2 0.3,2.7 2.6,-0.7 C 14.2,12 12.6,13.4 10.2,14.3 L 11.1,13.3 11,11.2 10.1,8.7 7,9 5.8,13.4 8,14.5 c -2.3,-0.6 -3.5,-2 -4.5,-3.8 C 5.6,9.3 6,8 4.3,5.7 5.6,4.3 6,4 7.7,3.5 Z"/> + <path id="decline" class="normal" d="M 2.5,5 5,2.5 l 4,4 4,-4 2.5,2.5 -4,4 4,4 -2.5,2.5 -4,-4 -4,4 -2.5,-2.5 4,-4 z"/> + <path id="decline-flat" class="normal" d="M 3,5.5 5.5,3 9,6.5 12.5,3 15,5.5 11.5,9 15,12.5 12.5,15 9,11.5 5.5,15 3,12.5 6.5,9 Z"/> + <path id="decline-inverted" class="inverted" d="M 2.5,5 5,2.5 l 4,4 4,-4 2.5,2.5 -4,4 4,4 -2.5,2.5 -4,-4 -4,4 -2.5,-2.5 4,-4 z"/> + <path id="tentative" class="normal" d="m 8.2,6.5 -2.7,0 c 0,-1.4 0.5,-4 3.5,-4 2.3,0 4.4,1 4.5,4 0,2.8 -3,2.5 -3,5 l -3,0 C 7.4,8 10.3,8.5 10.2,6.5 10,5.5 9.3,5.5 9,5.5 c 0,0 -0.8,0 -0.8,1 z m -0.7,7 3,0 0,2 -3,0 z"/> + <path id="tentative-flat" class="normal" d="M 8,7 6,7 C 6,5.6 6,3 9,3 c 2,0 4,0 4,3.5 0,3 -3,3 -3,5.5 L 8,12 C 8,8.5 11.1,8.5 11,6.5 11,5 10,5 9,5 8,5 8,6 8,7 Z m 0,6 2,0 0,2 -2,0 z"/> + <path id="tentative-inverted" class="inverted" d="m 8.2,6.5 -2.7,0 c 0,-1.4 0.5,-4 3.5,-4 2.3,0 4.4,1 4.5,4 0,2.8 -3,2.5 -3,5 l -3,0 C 7.4,8 10.3,8.5 10.2,6.5 10,5.5 9.3,5.5 9,5.5 c 0,0 -0.8,0 -0.8,1 z m -0.7,7 3,0 0,2 -3,0 z"/> +</svg> diff --git a/calendar/base/themes/common/calendar-unifinder.css b/calendar/base/themes/common/calendar-unifinder.css new file mode 100644 index 000000000..eb101f2a4 --- /dev/null +++ b/calendar/base/themes/common/calendar-unifinder.css @@ -0,0 +1,39 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/* only format Unifinder lists */ +#unifinder-search-results-tree > treechildren::-moz-tree-cell-text(highpriority) { + font-weight: bold; +} + +#unifinder-search-results-tree > treechildren::-moz-tree-cell-text(lowpriority) { + font-style: italic; + color: GrayText !important; + background-color: -moz-field; +} + +/* workaround to avoid Window Flick */ +#unifinder-search-results-tree { + -moz-appearance: none; + background-color: -moz-Field; + color: -moz-FieldText; + min-height: 92px; + border: 0; + -moz-border-bottom-colors: ThreeDHighlight ThreeDLightShadow; + border-bottom: 1px solid; + margin: 0; +} + +.unifinder-closebutton { + list-style-image: url("chrome://global/skin/icons/close.png"); + -moz-image-region: rect(0 16px 16px 0); +} + +.unifinder-closebutton:hover { + -moz-image-region: rect(0 32px 16px 16px); +} + +.unifinder-closebutton:hover:active { + -moz-image-region: rect(0 48px 16px 32px); +} diff --git a/calendar/base/themes/common/calendar-views.css b/calendar/base/themes/common/calendar-views.css new file mode 100644 index 000000000..9a5bb8f9e --- /dev/null +++ b/calendar/base/themes/common/calendar-views.css @@ -0,0 +1,955 @@ +/* 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/. */ + +window { + --viewColor: #000; + --viewBackground: #fff; + --viewBorderColor: #d2d2d2; + --viewDoubleBorderColor: var(--viewBackground) var(--viewBorderColor); + --viewHighlightBorderColor: #67acd8; + --viewTodayBorderColor: #7fb9ee; + --viewTodayBackground: #e1f0fd; + --viewTodayLabelColor: #616163; + --viewTodayLabelBackground: #d2e3f3; + --viewTodayLabelSelectedColor: #616163; + --viewTodayLabelSelectedBackground: #d2e3f3; + --viewTodayOffBackground: #d7e8f8; + --viewTodayDayLabelBackground: #d2e3f3; + --viewTodayWeekendBackground: #e1f0fd; + --viewWeekendBackground: #f7ffe3; + --viewHeaderSelectedBackground: #fffcd8; + --viewDayBoxSelectedBackground: #fffcd8; + --viewDayBoxOffSelectedBackground: #f2edb2; + --viewDayBoxOtherSelectedBackground: #fffcd8; + --viewMonthCurrentBackground: #f7f7f7; + --viewMonthOtherBackground: #f3f3f3; + --viewMonthDayBoxSelectedColor: #616163; + --viewMonthDayBoxSelectedBackground: #f2edb2; + --viewMonthDayBoxLabelColor: #616163; + --viewMonthDayBoxLabelTexture: none; + --viewMonthDayBoxWeekLabel: #aaaaaa; + --viewMonthDayOtherBackground: #e8e8e8; + --viewMonthDayOtherLabelBackground: #ddd; + --viewMonthDayOffLabelBackground: #eaf7ca; + --viewOffTimeBackground: #f0f0f0; + --viewTimeBoxColor: #6a6969; + --viewDayLabelSelectedColor: #000; + --viewDayLabelSelectedBackground: #fffabc; + --viewDragboxColor: -moz-dialogtext; + --viewDragboxBackground: linear-gradient(#fe4b22, #feb822); + --viewDropshadowBackground: #ffa47d; +} + +window[systemcolors] { + --viewColor: WindowText; + --viewBackground: -moz-Field; + --viewBorderColor: ThreeDShadow; + --viewHighlightBorderColor: Highlight; + --viewTodayBorderColor: Highlight; + --viewTodayBackground: -moz-Field; + --viewTodayLabelColor: Highlight; + --viewTodayLabelBackground: rgba(0, 0, 0, .2); + --viewTodayLabelSelectedColor: HighlightText; + --viewTodayLabelSelectedBackground: Highlight; + --viewTodayOffBackground: ButtonFace; + --viewTodayDayLabelBackground: ButtonFace; + --viewTodayWeekendBackground: ButtonFace; + --viewWeekendBackground: rgba(0, 0, 0, .1); + --viewHeaderSelectedBackground: ButtonFace; + --viewDayBoxSelectedBackground: -moz-Field; + --viewDayBoxOffSelectedBackground: rgba(0, 0, 0, .2); + --viewDayBoxOtherSelectedBackground: rgba(0, 0, 0, .2); + --viewMonthCurrentBackground: -moz-Field; + --viewMonthOtherBackground: ButtonFace; + --viewMonthDayBoxSelectedColor: HighlightText; + --viewMonthDayBoxSelectedBackground: Highlight; + --viewMonthDayBoxLabelColor: WindowText; + --viewMonthDayBoxLabelTexture: linear-gradient(rgba(0, 0, 0, .05), rgba(0, 0, 0, .05)); + --viewMonthDayBoxWeekLabel: GrayText; + --viewMonthDayOtherBackground: ButtonFace; + --viewMonthDayOtherLabelBackground: ButtonFace; + --viewMonthDayOffLabelBackground: rgba(0, 0, 0, .1); + --viewOffTimeBackground: rgba(0, 0, 0, .1); + --viewTimeBoxColor: GrayText; + --viewDayLabelSelectedColor: HighlightText; + --viewDayLabelSelectedBackground: Highlight; + --viewDragboxColor: GrayText; + --viewDragboxBackground: Highlight; + --viewDropshadowBackground: Highlight !important; +} + +/* Core */ +calendar-category-box:not([categories]) { + display: none; +} + +.calendar-category-box-gradient { + width: 7px; + background-image: linear-gradient(rgba(255, 255, 255, 0.38), transparent) !important; + border-left: 1px solid rgba(255, 255, 255, 0.38); +} + +.calendar-item-image { + list-style-image: url(chrome://calendar-common/skin/day-box-item-image.png); + padding-top: 2px; + padding-bottom: 2px; + margin-inline-end: 4px; + display: none; +} + +.calendar-item-image[itemType="todo"] { + -moz-image-region: rect(0px 11px 11px 0px); + display: -moz-box; +} + +.calendar-item-image[itemType="todo"][todoType="start"] { + -moz-image-region: rect(0px 18px 11px 0px); +} + +.calendar-item-image[itemType="todo"][todoType="end"] { + -moz-image-region: rect(0px 36px 11px 18px); +} + +.calendar-item-image[itemType="todo"][progress="completed"] { + -moz-image-region: rect(0px 47px 11px 36px); +} + +.calendar-item-image[itemType="todo"][progress="completed"][todoType="start"] { + -moz-image-region: rect(0px 54px 11px 36px); +} + +.calendar-item-image[itemType="todo"][progress="completed"][todoType="end"] { + -moz-image-region: rect(0px 72px 11px 54px); +} + +/* Multiday view */ + +/* Margin that allows event creation by click and drag when the time slot is + full of events. On the right side in normal view ... */ +.multiday-column-box-stack > .multiday-column-top-box[orient="horizontal"] { + margin-inline-end: 5px; +} +/* ... and on bottom in rotate view. */ +.multiday-column-box-stack > .multiday-column-top-box[orient="vertical"] { + margin-bottom: 5px; +} + +/* .. and on the right side in the header container in normal view */ +calendar-header-container:not([rotated]) { + padding-right: 6px; + padding-left: 1px; +} +/* ... and on the bottom in rotated view. */ +calendar-header-container[rotated] { + padding-top: 1px; + padding-bottom: 5px; +} + +calendar-event-column { + background-color: var(--viewBackground); +} + +calendar-event-column[orient="horizontal"] { + border-top: 1px solid var(--viewBorderColor); +} + +calendar-event-column[orient="vertical"] { + border-left: 1px solid var(--viewBorderColor); +} + +calendar-event-column[orient="horizontal"][relation="today"] { + border-top: 1px solid var(--viewHighlightBorderColor); + border-bottom: 1px solid var(--viewHighlightBorderColor); + margin-bottom: -1px; + position: relative; +} + +calendar-event-column[orient="vertical"][relation="today"] { + border-left: 1px solid var(--viewHighlightBorderColor); + border-right: 1px solid var(--viewHighlightBorderColor); + margin-inline-end: -1px; + position: relative; +} + +calendar-header-container { + background-color: var(--viewBackground); + border-left: 1px solid var(--viewBorderColor); +} + +calendar-header-container[rotated] { + max-width: 150px; +} + +calendar-header-container[weekend="true"], +.calendar-event-column-linebox[weekend="true"] { + background-color: var(--viewWeekendBackground); +} + +.calendar-event-column-linebox[off-time="true"] { + background-color: var(--viewOffTimeBackground); +} + +.calendar-event-column-linebox[off-time="true"][weekend="true"] { + background-color: var(--viewMonthDayOffLabelBackground); +} + +calendar-header-container[relation="today"], +.calendar-event-column-linebox[relation="today"], +calendar-day-label[orient][relation="today"] { + background-color: var(--viewTodayBackground); +} + +calendar-header-container[relation="today"] { + border-left: 1px solid var(--viewTodayBorderColor); + border-right: 1px solid var(--viewTodayBorderColor); + margin-inline-end: -1px; + position: relative; +} + +calendar-header-container[relation="today"][rotated="true"] { + border-top: 1px solid var(--viewTodayBorderColor); + border-bottom: 1px solid var(--viewTodayBorderColor) !important; + border-right: 1px solid var(--viewBorderColor); + margin-top: -1px; + position: relative; +} + +calendar-header-container[selected="true"], +.calendar-event-column-linebox[selected="true"] { + background-color: var(--viewHeaderSelectedBackground); +} + +calendar-header-container[weekend="true"][relation="today"], +.calendar-event-column-linebox[weekend="true"][relation="today"] { + background-color: var(--viewTodayWeekendBackground); +} + +.calendar-event-column-linebox[off-time="true"][relation="today"] { + background-color: var(--viewTodayOffBackground); +} + +.multiday-view-header-day-box[orient="vertical"] .calendar-event-column-header { + border-bottom: 1px solid var(--viewBorderColor); + min-width: 100px; +} + +calendar-header-container[weekend="true"][selected="true"], +.calendar-event-column-linebox[weekend="true"][selected="true"] { + background-color: var(--viewHeaderSelectedBackground); +} + +.calendar-event-column-linebox[off-time="true"][selected="true"] { + background-color: var(--viewDayBoxOffSelectedBackground); +} + +.calendar-event-column-linebox[orient="horizontal"] { + border-right: 1px solid var(--viewBorderColor); +} + +.calendar-event-column-linebox[orient="vertical"] { + border-bottom: 1px solid var(--viewBorderColor); +} + +.calendar-event-column-linebox[orient="horizontal"][relation="today"]:last-child { + border-right: 1px solid var(--viewHighlightBorderColor); +} + +.calendar-event-column-linebox[orient="vertical"][relation="today"]:last-child { + border-bottom: 1px solid var(--viewHighlightBorderColor); +} + +/* Make sure we extend the bold line separating scrollable and non-scrollable + areas over the timebar. */ +.multiday-view-header-time-spacer[orient="horizontal"] { + border-bottom: 2px solid var(--viewBorderColor); + border-right: 2px solid; + -moz-border-right-colors: var(--viewDoubleBorderColor); +} + +.multiday-view-header-time-spacer[orient="vertical"] { + border-right: 2px solid; + -moz-border-right-colors: var(--viewDoubleBorderColor); +} + +.multiday-view-label-box[orient="horizontal"] > .multiday-view-label-time-spacer { + border-right: 2px solid; + -moz-border-right-colors: var(--viewDoubleBorderColor); +} + +.multiday-view-header-day-box[orient="horizontal"] { + border-right: 1px solid var(--viewBorderColor); + border-bottom: 2px solid var(--viewBorderColor); + overflow-x: hidden; + overflow-y: auto; + max-height: 120px; +} + +.multiday-view-header-day-box[orient="horizontal"][todaylastinview="true"] { + border-right: 1px solid var(--viewHighlightBorderColor); +} + +/* Make sure the box for day-labels appears to end before the scrollbar. */ +.multiday-view-label-day-box[orient="horizontal"] { + border-top: 1px solid var(--viewBorderColor); + border-right: 1px solid var(--viewBorderColor); +} + +.multiday-view-label-day-box[orient="vertical"] { + border-top: 1px solid var(--viewBorderColor); +} + +.multiday-view-header-day-box[orient="vertical"] { + border-top: 1px solid var(--viewBorderColor); + border-right: 2px solid var(--viewBorderColor); +} + +/* Make sure to have a border between the edge of the views and the scrollbar. */ +.multiday-view-day-box { + border-right: 1px solid var(--viewBorderColor); + border-bottom: 1px solid var(--viewBorderColor); +} + +.fgdragbox { + -moz-box-orient: inherit; + display: none; +} + +.fgdragbox[dragging="true"] { + display: -moz-box; + background: var(--viewDragboxBackground); + border: 5px var(--viewBackground); + opacity: 0.5; + min-height: 2px; + min-width: 2px; +} + +.fgdragcontainer { + -moz-box-orient: inherit; + display: none; +} + +.fgdragcontainer[dragging="true"] { + display: -moz-box; + /* This is a workaround for a stack bug and display: hidden in underlying + * elements -- the display: hidden bits get misrendered as being on top. + * Setting an opacity here forces a view to be created for this element, too. + */ + opacity: 0.9999; +} + +.fgdragbox-label { + font-weight: bold; + text-align: center; + overflow: hidden; + color: var(--viewDragboxColor); +} + +.timeIndicator[orient="vertical"] { + min-width: 1px; + margin-inline-start: -1px; + margin-inline-end: -1px; + border-top: 2px solid red; + opacity: 0.7; +} + +.timeIndicator[orient="horizontal"] { + min-height: 1px; + margin-top: -1px; + margin-bottom: -1px; + border-left: 2px solid red; + opacity: 0.7; +} + +.timeIndicator-timeBar { + background-color: red; + position: absolute; + border-radius: 2px; +} + +.timeIndicator-timeBar[orient="vertical"] { + margin-top: -1px; + height: 4px; + width: 8px; + right: 0px; +} + +.timeIndicator-timeBar[orient="horizontal"] { + margin-left: -1px; + height: 8px; + width: 4px; + bottom: 0px; +} + +.calendar-event-box-container { + padding: 0; + overflow: hidden; + margin: 1px; +} + +.calendar-event-box-container[categories] { + margin-inline-end: 0px; +} + +.calendar-event-details { + padding-inline-start: 2px; + overflow: hidden; +} + +.calendar-event-details-core { + width: 0px; + margin: 0px; + overflow: hidden; +} + +.calendar-event-name-textbox { + background: transparent !important; + color: inherit; +} + +calendar-event-box { + border: 1px solid transparent; +} + +calendar-month-day-box-item[selected="true"] .calendar-color-box, +calendar-event-box[selected="true"] .calendar-color-box, +calendar-editable-item[selected="true"] .calendar-color-box { + color: var(--viewColor) !important; + background-color: #FDF5A0 !important; + box-shadow: 1px 2px 5px rgba(30, 20, 0, 0.6); +} + + +/* RTL styles for the main box and children */ +.multiday-view-main-box { + direction: ltr; +} + +.multiday-view-label-day-box:-moz-locale-dir(rtl) { + direction: rtl; +} + +/* headers horizontal, times vertical */ +.multiday-view-label-box[orient="horizontal"] { + height: 10px; +} + +.multiday-view-header-box[orient="horizontal"] { + min-height: 30px; +} + +.multiday-view-label-box[orient="horizontal"] > .multiday-view-label-time-spacer, +.multiday-view-header-box[orient="horizontal"] > .multiday-view-header-time-spacer, +calendar-time-bar[orient="vertical"] { + width: 10ex; /* space for "11:00 AM" */ +} + +/* headers vertical, times horizonal */ +.view-label-box[orient="vertical"] { + width: 30px; +} + +.view-header-box[orient="vertical"] { + width: 40px; +} + +.multiday-view-label-box[orient="vertical"] > .multiday-view-label-time-spacer, +.multiday-view-header-box[orient="vertical"] > .multiday-view-header-time-spacer { + height: 40px; +} + +calendar-time-bar[orient="horizontal"] { + height: 40px; +} + +/** Start time bar **/ + +.calendar-time-bar-label { + font-size: 1em; +} + +.calendar-time-bar-box-odd, +.calendar-time-bar-box-even { + color: var(--viewTimeBoxColor); + background-color: var(--viewBackground); + text-align: right; + overflow: hidden; +} + +.calendar-time-bar-box-odd[off-time="true"] , +.calendar-time-bar-box-even[off-time="true"] { + background-color: var(--viewOffTimeBackground); + border-right: 2px solid; + -moz-border-right-colors: var(--viewDoubleBorderColor); +} + +.calendar-time-bar-box-odd[orient="horizontal"], +.calendar-time-bar-box-even[orient="horizontal"] { + border-right: 1px solid var(--viewBorderColor); + border-top: 1px solid var(--viewBorderColor); + -moz-border-right-colors: none; + height: 40px; /* the same as the calendar-time-bar element */ +} + +.calendar-time-bar-box-odd[orient="vertical"], +.calendar-time-bar-box-even[orient="vertical"] { + border-bottom: 1px transparent !important; + border-right: 2px solid; + -moz-border-right-colors: var(--viewDoubleBorderColor); + width: 10ex; /* the same as the calendar-time-bar element */ +} + +/** End time bar **/ + +calendar-multiday-view { + background-color: var(--viewBackground); + padding: 0px; +} + +calendar-multiday-view[hidden="true"] { + display: none; +} + +calendar-day-label { + color: var(--viewColor); + background-color: var(--viewBackground); + background-image: linear-gradient(transparent, transparent 48%, + rgba(0, 0, 0, 0.02) 52%, rgba(0, 0, 0, 0.1)); + border-left: 1px solid var(--viewBorderColor); + border-bottom: 1px solid var(--viewBorderColor); +} + +calendar-day-label[selected="true"] { + color: var(--viewDayLabelSelectedColor); + background-color: var(--viewDayLabelSelectedBackground) !important; +} + +calendar-day-label[orient="vertical"] { + background-image: none !important; + min-width: 100px; +} + +calendar-day-label[relation="today"], +calendar-day-label[relation="today1day"] { + background-color: var(--viewTodayDayLabelBackground); + border: 1px solid var(--viewHighlightBorderColor); + margin-inline-end: -1px; + margin-top: -1px; + position: relative; +} + +.calendar-day-label-name { + text-align: center; +} + +.calendar-day-label-name[relation="today"], +.calendar-day-label-name[relation="today1day"] { + font-weight: bold; +} + +/* Month View */ +calendar-month-view, +calendar-multiweek-view { + padding: 0px 2px 2px; +} + +.calendar-month-view-grid-column { + min-width: 1px; + width: 1px; +} + +.calendar-month-view-grid-row { + min-height: 1px; + height: 1px; + border-right: 1px solid var(--viewBorderColor); +} + +calendar-month-day-box { + border:none !important; + border-left: 1px solid var(--viewBorderColor) !important; + border-bottom: 1px solid var(--viewBorderColor) !important; +} + +.calendar-month-day-box-items-box { + overflow-y: auto; + overflow-x: hidden; +} + +.calendar-month-day-box-current-month { + background-color: var(--viewBackground); +} + +.calendar-month-day-box-current-month .calendar-month-day-box-date-label { + background-color: var(--viewMonthCurrentBackground); +} + +.calendar-month-day-box-day-off { + background-color: var(--viewWeekendBackground); +} + +.calendar-month-day-box-day-off .calendar-month-day-box-date-label { + background-color: var(--viewMonthDayOffLabelBackground); +} + +.calendar-month-day-box-other-month { + background-color: var(--viewMonthOtherBackground); +} + +.calendar-month-day-box-other-month .calendar-month-day-box-date-label { + background-color: var(--viewMonthDayOtherBackground); +} + +.calendar-month-day-box-other-month.calendar-month-day-box-day-off { + background-color: var(--viewMonthDayOtherBackground); +} + +.calendar-month-day-box-other-month.calendar-month-day-box-day-off .calendar-month-day-box-date-label { + background-color: var(--viewMonthDayOtherLabelBackground); +} + +.calendar-month-day-box-current-month[relation="today"], +.calendar-month-day-box-day-off[relation="today"], +.calendar-month-day-box-other-month[relation="today"] { + background-color: var(--viewTodayBackground); + border: 1px solid var(--viewTodayBorderColor) !important; + margin-inline-end: -1px !important; + margin-top: -1px !important; + position: relative; +} + +.calendar-month-day-box-date-label[relation="today"] { + color: var(--viewTodayLabelColor); + background-color: var(--viewTodayLabelBackground); + font-weight: bold; +} + +.calendar-month-day-box-current-month[selected="true"] { + background-color: var(--viewDayBoxSelectedBackground); +} + +.calendar-month-day-box-day-off[selected="true"] { + background-color: var(--viewDayBoxSelectedBackground); +} + +.calendar-month-day-box-other-month[selected="true"] { + background-color: var(--viewDayBoxOtherSelectedBackground); +} + +.calendar-month-day-box-date-label[selected="true"] { + color: var(--viewMonthDayBoxSelectedColor); + background-color: var(--viewMonthDayBoxSelectedBackground); +} + +.calendar-month-day-box-date-label[relation="today"][selected="true"] { + color: var(--viewTodayLabelSelectedColor); + background-color: var(--viewTodayLabelSelectedBackground); +} + +.calendar-month-day-box-other-month.calendar-month-day-box-day-off .calendar-month-day-box-date-label[selected="true"] { + background-color: var(--viewMonthDayBoxSelectedBackground); +} + +.calendar-month-day-box-date-label { + color: var(--viewMonthDayBoxLabelColor); + font-size: 0.9em; + text-align: right; + margin: 0px; + padding-top: 1px; + padding-inline-end: 2px; + padding-bottom: 1px; + background-image: var(--viewMonthDayBoxLabelTexture); +} + +.calendar-month-day-box-week-label { + text-align: left; + padding-inline-start: 2px; + font-weight: normal !important; + color: var(--viewMonthDayBoxWeekLabel) !important; +} + +calendar-month-day-box-item { + margin: 1px; + padding: 1px 1px; +} + +.calendar-color-box { + /* This rule should be adopted if the alarm image size is changed */ + min-height: 13px; + background-image: linear-gradient(rgba(255, 255, 255, 0.25), rgba(255, 255, 255, 0.01) 50%, rgba(0, 0, 0, 0.05)); + border: 1px solid transparent; + border-radius: 2px; +} + +calendar-month-day-box calendar-month-day-box-item[allday="true"] .calendar-color-box { + border-color: rgba(0, 0, 0, 0.5); + box-shadow: inset -1px -1px 0 rgba(255, 255, 255, 0.7), inset 1px 1px 0 rgba(255, 255, 255, 0.7); +} + +.calendar-month-day-box-item-label { + padding: 0px; + margin: 0px; +} + +.calendar-month-day-box-item-label[time="true"] { + margin-inline-end: 4px; +} + +.labeldaybox-container { + border-right: 1px solid var(--viewBorderColor); + border-top: 1px solid var(--viewBorderColor); +} + +.dropshadow { + height: 1.2em; + margin: 1px; + padding: 0px 1px; + background-color: var(--viewDropshadowBackground); + box-shadow: 1px 1px 3px rgba(68, 35, 0, 0.7) inset; +} + +calendar-event-gripbar { + -moz-box-align: center; + -moz-box-pack: center; + min-height: 4px; + min-width: 4px; + overflow: hidden; +} + +calendar-event-gripbar[parentorient="vertical"][whichside="start"] { + cursor: n-resize; +} + +calendar-event-gripbar[parentorient="vertical"][whichside="end"] { + cursor: s-resize; +} + +calendar-event-gripbar[parentorient="horizontal"][whichside="start"] { + cursor: w-resize; +} + +calendar-event-gripbar[parentorient="horizontal"][whichside="end"] { + cursor: e-resize; +} + +calendar-event-box[orient="vertical"] .calendar-event-box-grippy-top { + list-style-image: url("chrome://calendar-common/skin/event-grippy-top.png"); + visibility: hidden; +} + +calendar-event-box[orient="vertical"] .calendar-event-box-grippy-bottom { + list-style-image: url("chrome://calendar-common/skin/event-grippy-bottom.png"); + visibility: hidden; +} + +calendar-event-box[orient="horizontal"] .calendar-event-box-grippy-top { + list-style-image: url("chrome://calendar-common/skin/event-grippy-left.png"); + visibility: hidden; +} + +calendar-event-box[orient="horizontal"] .calendar-event-box-grippy-bottom { + list-style-image: url("chrome://calendar-common/skin/event-grippy-right.png"); + visibility: hidden; +} + +calendar-event-box[gripBars="start"]:not([readonly="true"]):hover .calendar-event-box-grippy-top, +calendar-event-box[gripBars="end"]:not([readonly="true"]):hover .calendar-event-box-grippy-bottom, +calendar-event-box[gripBars="both"]:not([readonly="true"]):hover .calendar-event-box-grippy-top, +calendar-event-box[gripBars="both"]:not([readonly="true"]):hover .calendar-event-box-grippy-bottom { + visibility: visible; +} + +/* tooltips */ +.tooltipBox { + max-width: 40em; +} + +.tooltipValueColumn { + max-width: 35em; /* tooltipBox max-width minus space for label */ +} + +.tooltipHeaderLabel { + font-weight: bold; + text-align: right; + margin-top: 0; + margin-bottom: 0; + margin-inline-start: 0; + margin-inline-end: 1em; /* 1em space before value */ +} + +.tooltipHeaderDescription { + font-weight: normal; + text-align: left; + margin: 0pt; +} + +.tooltipBodySeparator { + height: 1ex; /* 1ex space above body text, below last header. */ +} + +.tooltipBody { + font-weight: normal; + white-space: pre-wrap; + margin: 0pt; +} + +#calendar-view-context-menu[type="event"] .todo-only, +#calendar-view-context-menu[type="todo"] .event-only, +#calendar-view-context-menu[type="mixed"] .event-only, +#calendar-view-context-menu[type="mixed"] .todo-only, +#calendar-item-context-menu[type="event"] .todo-only, +#calendar-item-context-menu[type="todo"] .event-only, +#calendar-item-context-menu[type="mixed"] .event-only, +#calendar-item-context-menu[type="mixed"] .todo-only { + display: none; +} + +.attendance-menu[itemType="single"] > menupopup > *[scope="all-occurrences"] { + display: none; +} + +.calendar-context-heading-label { + font-weight: bold; + color: menutext; +} + +calendar-event-box, +calendar-editable-item, +calendar-month-day-box-item { + opacity: 0.99; + /* Do not change next line, since it would break item selection */ + -moz-user-focus: normal; +} + +calendar-event-box[invitation-status="NEEDS-ACTION"], +calendar-editable-item[invitation-status="NEEDS-ACTION"], +calendar-month-day-box-item[invitation-status="NEEDS-ACTION"] { + border: 2px dotted black; + opacity: 0.6; +} + +calendar-event-box[invitation-status="TENTATIVE"], +calendar-editable-item[invitation-status="TENTATIVE"], +calendar-month-day-box-item[invitation-status="TENTATIVE"], +calendar-event-box[status="TENTATIVE"], +calendar-editable-item[status="TENTATIVE"], +calendar-month-day-box-item[status="TENTATIVE"], +agenda-richlist-item[status="TENTATIVE"] { + opacity: 0.6; +} + +calendar-event-box[invitation-status="DECLINED"], +calendar-editable-item[invitation-status="DECLINED"], +calendar-month-day-box-item[invitation-status="DECLINED"], +calendar-event-box[status="CANCELLED"], +calendar-editable-item[status="CANCELLED"], +calendar-month-day-box-item[status="CANCELLED"], +agenda-richlist-item[status="CANCELLED"] { + opacity: 0.5; +} + +calendar-month-day-box-item[status="CANCELLED"] .calendar-color-box, +calendar-event-box[status="CANCELLED"] .calendar-color-box, +calendar-editable-item[status="CANCELLED"] .calendar-color-box, +agenda-richlist-item[status="CANCELLED"] .agenda-container-box { + text-decoration: line-through; +} + +/* Navigation controls for the views */ +#view-deck { + border: solid ThreeDShadow; +} + +#view-tabs .tabs-left, +#view-tabs .tabs-right { + border-bottom: 1px solid ThreeDShadow; + -moz-border-bottom-colors: none; +} + +tab[calview] { + -moz-appearance: none; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + margin-top: 0px; + -moz-user-focus: normal; +} + +tab[calview][selected="true"], +tab[calview][selected="true"]:hover { + margin-bottom: 0px; + border-bottom: none; +} + +tab[calview] > .tab-middle { + text-align: center; +} + +.view-header { + font-weight: normal; + font-size: 14px; + color: ButtonText; +} + +.view-header[type="end"] { + text-align: right; + margin-inline-end: 6px; +} + +.navigation-inner-box { + padding-inline-start: 6px; + padding-inline-end: 6px; + padding-bottom: 1px; +} + +.navigation-bottombox { + min-height: 4px; + max-height: 4px; +} + +.view-navigation-button { + -moz-user-focus: normal; + -moz-appearance: toolbarbutton; + min-width: 22px; +} + +.today-navigation-button { + -moz-user-focus: normal; + -moz-appearance: toolbarbutton; + margin-inline-start: 2px; + margin-inline-end: 2px; + color: ButtonText; + font-size: 14px; +} + +.view-navigation-button > .toolbarbutton-text { + display: none; +} + +.today-navigation-button > .toolbarbutton-icon { + display: none; +} + +.item-classification-box { + list-style-image: url("chrome://calendar-common/skin/classification.png"); + width: 11px; + height: 11px; + display: none; +} + +.item-classification-box[classification="PUBLIC"] { + display: none; +} + +.item-classification-box[classification="PRIVATE"] { + -moz-image-region: rect(0 22px 11px 11px); + display: -moz-box; +} + +.item-classification-box[classification="CONFIDENTIAL"] { + -moz-image-region: rect(0 11px 11px 0); + display: -moz-box; +} + +.multiday-headerscrollbarspacer:-moz-system-metric(overlay-scrollbars), +.multiday-labelscrollbarspacer:-moz-system-metric(overlay-scrollbars) { + display: none; +} diff --git a/calendar/base/themes/common/dialogs/calendar-alarm-dialog.css b/calendar/base/themes/common/dialogs/calendar-alarm-dialog.css new file mode 100644 index 000000000..244ccb852 --- /dev/null +++ b/calendar/base/themes/common/dialogs/calendar-alarm-dialog.css @@ -0,0 +1,116 @@ +/* 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/. */ + +/* Bindings */ +calendar-alarm-widget { + -moz-binding: url(chrome://calendar/content/widgets/calendar-alarm-widget.xml#calendar-alarm-widget); +} + +/* Alarm dialog styles */ +#alarm-richlist { + margin: 10px; +} + +#alarm-actionbar { + min-width: 1px; + margin: 0 5px; +} + +/* Alarm widget specific styles */ +calendar-alarm-widget { + border-bottom: 1px dotted ThreeDShadow; + padding: 6px 7px; +} + +calendar-alarm-widget[selected="true"] { + background-color: Highlight; + color: HighlightText; + padding: 0 5px; +} + +calendar-alarm-widget[selected="true"] .alarm-title-label { + font-weight: bold; +} + +calendar-alarm-widget[selected="true"] .alarm-action-buttons { + display: -moz-box; +} + +calendar-alarm-widget[selected="true"] > hbox { + margin: 5px; +} + +calendar-alarm-widget[selected="true"] .alarm-relative-date-label, +.additional-information-box, +.alarm-action-buttons { + display: none; +} + +calendar-alarm-widget[selected="true"] .additional-information-box, +calendar-alarm-widget[selected="true"] .action-buttons-box { + display: -moz-box; +} + +.alarm-details-label { + color: HighlightText; + text-decoration: underline; +} + +calendar-alarm-widget[selected="true"] .alarm-calendar-image { + list-style-image: url(chrome://calendar/skin/cal-icon32.png); +} + +.resizer-box { + min-height: 15px; +} + +.snooze-popup-ok-button { + -moz-image-region: rect(0px, 14px, 14px, 0px); +} + +.snooze-popup-ok-button:hover { + -moz-image-region: rect(14px, 14px, 28px, 0px); +} + +.snooze-popup-ok-button:active { + -moz-image-region: rect(28px, 14px, 42px, 0px); +} + +.snooze-popup-ok-button[disabled="true"] { + -moz-image-region: rect(42px, 14px, 56px, 0px); +} + +.snooze-popup-cancel-button { + -moz-image-region: rect(0px, 28px, 14px, 14px); +} + +.snooze-popup-cancel-button:hover { + -moz-image-region: rect(14px, 28px, 28px, 14px); +} + +.snooze-popup-cancel-button:active { + -moz-image-region: rect(28px, 28px, 42px, 14px); +} + +.snooze-popup-button { + list-style-image: url(chrome://calendar-common/skin/ok-cancel.png); + min-width: 0; + -moz-appearance: toolbarbutton; +} + +.snooze-popup-button > .button-box > .button-icon { + margin: 0; +} + +.snooze-popup-button > .button-box { + border: 0; + padding: 0; + -moz-box-pack: center; + -moz-box-align: center; +} + +.snooze-popup-button:focus > .button-box { + border: 1px dotted ThreeDDarkShadow; + padding: 0; +} diff --git a/calendar/base/themes/common/dialogs/calendar-event-dialog.css b/calendar/base/themes/common/dialogs/calendar-event-dialog.css new file mode 100644 index 000000000..b0e5cc213 --- /dev/null +++ b/calendar/base/themes/common/dialogs/calendar-event-dialog.css @@ -0,0 +1,565 @@ +/* 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/. */ + +dialog { + --eventBorderColor: #8d8e90; + --eventWidgetBorderColor: #cacaff; + --eventGridStartBorderColor: #bdbec0; +} + +dialog[systemcolors] { + --eventBorderColor: ThreeDShadow; + --eventWidgetBorderColor: ThreeDShadow; + --eventGridStartBorderColor: ThreeDShadow; +} + +#calendar-event-dialog, +#calendar-task-dialog { + min-width: 444px; + min-height: 476px; +} + +#calendar-event-dialog, +#calendar-task-dialog, +#calendar-event-dialog-inner, +#calendar-task-dialog-inner { + padding: 0px; +} + +#calendar-event-dialog .todo-only, +#calendar-task-dialog .event-only, +#calendar-event-dialog-inner .todo-only, +#calendar-task-dialog-inner .event-only { + display: none; +} + +/*-------------------------------------------------------------------- + * Event dialog counter box section + *-------------------------------------------------------------------*/ + +#counter-proposal-box { + background-color: rgb(186, 238, 255); + border-bottom: 1px solid #444444; +} + +#counter-proposal-property-values > description { + margin-bottom: 2px; +} + +#counter-proposal-summary { + font-weight: bold; +} + +.counter-buttons { + max-height: 25px; +} + +#yearly-period-of-label, +label.label { + text-align: right; +} + +#item-calendar, +#item-categories, +#item-repeat, +#item-alarm, +.datepicker-text-class { + min-width: 12em; +} + +@media not all and (-moz-os-version: windows-xp) { + .cal-event-toolbarbutton .toolbarbutton-icon { + width: 18px; + height: 18px; + } +} + +.icon-holder[type="calendarEvent"], +.tabmail-tab[type="calendarEvent"] { + list-style-image: url(chrome://calendar-common/skin/calendar-event-tab.png); + -moz-image-region: auto; +} + +.icon-holder[type="calendarTask"], +.tabmail-tab[type="calendarTask"] { + list-style-image: url(chrome://calendar-common/skin/calendar-task-tab.png); + -moz-image-region: auto; +} + +/*-------------------------------------------------------------------- + * Event dialog tabbox section + *-------------------------------------------------------------------*/ + +#event-grid-tabbox { + margin: 5px 0px; +} + +/*-------------------------------------------------------------------- + * Event dialog keep duration button + *-------------------------------------------------------------------*/ + +#keepduration-button { + list-style-image: url(chrome://calendar-common/skin/calendar-event-dialog.png); + -moz-image-region: rect(0px 147px 24px 140px); + padding-top: 3px; + padding-bottom: 3px; + margin-inline-start: -3px; + margin-bottom: -15px; + position: relative; + -moz-user-focus: normal; +} + +#keepduration-button[keep="true"] { + -moz-image-region: rect(0px 139px 24px 132px); +} + +#keepduration-button[disabled="true"] { + -moz-image-region: rect(0px 163px 24px 156px); +} + +#keepduration-button[keep="true"][disabled="true"] { + -moz-image-region: rect(0px 155px 24px 148px); +} + +#keepduration-button > label { + display: none; +} + +.keepduration-link-image { + list-style-image: url(chrome://calendar-common/skin/calendar-event-dialog.png); + margin-inline-start: -1px; +} + +#link-image-top { + -moz-image-region: rect(7px 174px 14px 164px); + margin-top: 0.6em; +} + +#link-image-top[keep="true"]{ + -moz-image-region: rect(0px 174px 7px 164px); +} + +#link-image-bottom { + -moz-image-region: rect(0px 184px 7px 174px); + margin-bottom: 0.6em; +} + +/*-------------------------------------------------------------------- + * Event dialog statusbar images + *-------------------------------------------------------------------*/ + +.cal-statusbar-1 { + -moz-box-orient: vertical; + min-width: 0px; + list-style-image: url("chrome://calendar-common/skin/calendar-event-dialog.png"); +} + +/*-------------------------------------------------------------------- + * Event dialog statusbarpanels + *-------------------------------------------------------------------*/ + +#status-privacy, +#status-priority, +#status-status, +#status-freebusy { + overflow: hidden; +} + +/*-------------------------------------------------------------------- + * priority "low" image + *-------------------------------------------------------------------*/ + +#image-priority-low { + -moz-image-region: rect(0px 100px 16px 84px); +} + +/*-------------------------------------------------------------------- + * priority "normal" image + *-------------------------------------------------------------------*/ + +#image-priority-normal { + -moz-image-region: rect(0px 116px 16px 100px); +} + +/*-------------------------------------------------------------------- + * priority "high" image + *-------------------------------------------------------------------*/ + +#image-priority-high { + -moz-image-region: rect(0px 132px 16px 116px); +} + +/*-------------------------------------------------------------------- + * Recurrence dialog preview border + *-------------------------------------------------------------------*/ +#preview-border { + border: none; + padding: 0px; +} + +/*-------------------------------------------------------------------- + * freebusy + *-------------------------------------------------------------------*/ +#freebusy-container { + overflow: hidden; + min-width: 100px; +} + +#freebusy-grid { + min-width: 1px; +} + +#calendar-summary-dialog { + min-width: 35em; +} + +listbox[disabled="true"] { + color: -moz-FieldText; +} + +daypicker-weekday { + margin-top: 2px; +} + +daypicker-monthday { + margin-top: 2px; +} + +.headline { + font-weight: bold; +} + +.headline[align=end], +.headline[align=right]{ + text-align: right; +} + +.default-spacer { + width: 1em; + height: 1em; +} + +.default-indent { + margin-inline-start: 1.5em; +} + +/*-------------------------------------------------------------------- + * Attendees Dialog + *-------------------------------------------------------------------*/ + +.listbox-noborder { + -moz-appearance: none; + margin: 0px 0px; + border: 1px solid var(--eventBorderColor); +} + +#timebar > .listbox-noborder { + border-bottom-style: none; +} + +#freebusy-grid > .listbox-noborder { + border-top-color: transparent; +} + +/* remove on Windows the double border with the splitter */ +@media (-moz-windows-theme) { + #attendees-list > .listbox-noborder { + border-inline-end-style: none; + } + #timebar > .listbox-noborder, + #freebusy-grid > .listbox-noborder { + border-inline-start-style: none; + } +} + +.selection-bar { + background-color: rgba(0, 128, 128, .2); + border: 1px solid #008080; + border-top-left-radius: 5px; + border-top-right-radius: 5px; +} + +.freebusy-container { + overflow: hidden; + clip: rect(0px 0px 0px 0px); +} + +.freebusy-listitem { + border-width: 0px; +} + +freebusy-day { + margin-inline-end: 2px; + border-inline-start: 1px solid var(--eventBorderColor); + border-inline-end: 1px solid var(--eventBorderColor); + border-bottom: 1px solid var(--eventBorderColor); +} + +freebusy-day:first-child { + border-inline-start: 1px solid transparent; +} + +freebusy-day:last-child { + border-inline-end: 1px solid transparent; +} + +/* negative margins to clear freebusy-day's border and margin */ +freebusy-day > box { + margin-inline-start: -1px; + margin-inline-end: -3px; +} + +.freebusy-content { + overflow: hidden; + clip: rect(0px 0px 0px 0px); +} + +/* margin-inline-end 2px is needed to take border-right from the grid elements into account */ +.freebusy-timebar-title { + margin-inline-end: 2px; + padding-inline-start: 2px; +} + +.freebusy-timebar-hour { + padding-inline-start: 2px; + padding-inline-end: 3px; + margin-top: 2px; + margin-bottom: 3px; +} + +.freebusy-timebar-hour[scheduled="true"] { + /* the 'sechuled'-attribute is used in the timebar to indicate + which hours are affected of the currently schedued event. + since we added the selection-bar this is no longer necessary + but we keep the possibity to decorate those hours if it should + become beneficial. + text-decoration: underline; + */ +} + +.freebusy-grid { + border-inline-start: 1px solid var(--eventGridStartBorderColor); + background-color: #E09EBD; + color: #E09EBD; + min-height: 16px; + padding-inline-start: 1px; +} + +.freebusy-grid[state="busy"] { + background-color: #153E7E; + color: #153E7E; +} + +.freebusy-grid[state="busy_tentative"] { + background-color: #1589FF; + color: #1589FF; +} + +.freebusy-grid[state="busy_unavailable"] { + background-color: #4E387E; + color: #4E387E; +} + +.freebusy-grid[state="free"] { + background-color: #EBEBE4; + color: #EBEBE4; +} + +.freebusy-grid:first-child { + border-inline-start: 1px solid transparent; +} + +.freebusy-grid.last-in-day { + border-inline-end: 1px solid var(--eventBorderColor); + margin-inline-end: 2px; +} + +#dialog-box { + padding-top: 8px; + padding-bottom: 10px; + padding-inline-start: 8px; + padding-inline-end: 10px; +} + +#addressingWidget { + -moz-user-focus: none; +} + +#typecol-addressingWidget { + min-width: 9em; + border-right: 1px solid var(--eventWidgetBorderColor); +} + +/* This applies to rows of the attendee-list and the freebusy-grid */ +.addressingWidgetItem, +.dummy-row { + border: none !important; + background-color: inherit !important; + color: inherit !important; + + /* we set the minimal height to the height of the + largest icon [the usertype-icon in this case] to + ensure that the rows of the freebusy-grid and + the attendee-list always have the same height, + regardless of the font size. */ + min-height: 16px; +} + +.addressingWidgetCell { + border-bottom: 1px solid var(--eventWidgetBorderColor); + padding: 0px; +} + +.addressingWidgetCell:first-child { + border-top: none; +} + +.dummy-row-cell:first-child { + border-top: none; +} + +.zoom-in-icon { + margin: 3px 3px; + list-style-image: url(chrome://calendar-common/skin/calendar-event-dialog-attendees.png); + -moz-image-region: rect(0px 97px 14px 84px); +} +.zoom-in-icon[disabled="true"] { + -moz-image-region: rect(14px 97px 28px 84px); +} + +.zoom-out-icon { + margin: 3px 3px; + list-style-image: url(chrome://calendar-common/skin/calendar-event-dialog-attendees.png); + -moz-image-region: rect(0px 110px 14px 97px); +} +.zoom-out-icon[disabled="true"] { + -moz-image-region: rect(14px 110px 28px 97px); +} + +.left-icon { + margin: 3px 3px; + list-style-image: url(chrome://calendar-common/skin/calendar-event-dialog-attendees.png); + -moz-image-region: rect(0px 124px 14px 110px); +} +.left-icon[disabled="true"] { + -moz-image-region: rect(14px 124px 28px 110px); +} + +.right-icon { + margin: 3px 3px; + list-style-image: url(chrome://calendar-common/skin/calendar-event-dialog-attendees.png); + -moz-image-region: rect(0px 138px 14px 124px); +} +.right-icon[disabled="true"] { + -moz-image-region: rect(14px 138px 28px 124px); +} + +.left-icon .button-icon { + margin-inline-end: 3px; +} + +.right-icon .button-icon { + margin-inline-start: 3px; +} + +.legend { + width: 3em; + height: 1em; + border-top: 1px solid #A1A1A1; + border-right: 1px solid #C3C3C3; + border-bottom: 1px solid #DDDDDD; + border-left: 1px solid #C3C3C3; +} + +.legend[status="FREE"] { + background-color: #EBEBE4; + color: #EBEBE4; +} + +.legend[status="BUSY"] { + background-color: #153E7E; + color: #153E7E; +} + +.legend[status="BUSY_TENTATIVE"] { + background-color: #1589FF; + color: #1589FF; +} + +.legend[status="BUSY_UNAVAILABLE"] { + background-color: #4E387E; + color: #4E387E; +} + +.legend[status="UNKNOWN"] { + background-color: #E09EBD; + color: #E09EBD; +} + +#content-frame { + border-left: 1px solid ThreeDDarkShadow; + border-right: 1px solid ThreeDLightShadow; + min-width: 10px; + min-height: 10px; + height: 400px; +} + +.attendees-list-listbox > listboxbody { + overflow-y: hidden !important; +} + +.selection-bar-left { + width: 3px; + cursor: w-resize; +} + +.selection-bar-right { + width: 3px; + cursor: e-resize; +} + +.selection-bar-spacer { + cursor: grab; +} + +.checkbox-no-label > .checkbox-label-box { + display: none; +} + +/*-------------------------------------------------------------------- + * Event summary dialog + *-------------------------------------------------------------------*/ + +#calendar-summary-dialog, +#calendar-event-summary-dialog, +#calendar-task-summary-dialog { + min-width: 35em; +} + +#calendar-summary-dialog #item-attachment-cell, +#calendar-event-summary-dialog #item-attachment-cell, +#calendar-task-summary-dialog #item-attachment-cell { + margin-left: 6px; +} + +#calendar-summary-dialog .item-attachment-cell-label, +#calendar-event-summary-dialog .item-attachment-cell-label, +#calendar-task-summary-dialog .item-attachment-cell-label { + margin-left: 3px; +} + +#calendar-summary-dialog #item-description, +#calendar-event-summary-dialog #item-description, +#calendar-task-summary-dialog #item-description { + min-height: 54px; +} + +#calendar-summary-dialog #item-start-row .headline, +#calendar-event-summary-dialog #item-start-row .headline, +#calendar-task-summary-dialog #item-start-row .headline, +#calendar-summary-dialog #item-end-row .headline, +#calendar-event-summary-dialog #item-end-row .headline, +#calendar-task-summary-dialog #item-end-row .headline { + font-weight: normal; +} diff --git a/calendar/base/themes/common/dialogs/calendar-invitations-dialog.css b/calendar/base/themes/common/dialogs/calendar-invitations-dialog.css new file mode 100644 index 000000000..66dcd0f9e --- /dev/null +++ b/calendar/base/themes/common/dialogs/calendar-invitations-dialog.css @@ -0,0 +1,79 @@ +/* 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/. */ + +#calendar-invitations-dialog { + min-width: 500px; + min-height: 273px; +} + +.calendar-invitations-updating-icon { + list-style-image: url("chrome://global/skin/icons/loading.png"); + opacity: 0.5; +} + +calendar-invitations-richlistbox { + background-color: -moz-Field; + color: -moz-FieldText; + border: 1px solid #7F9DB9; +} + +calendar-invitations-richlistitem { + padding-top: 6px; + padding-bottom: 6px; + padding-inline-start: 7px; + padding-inline-end: 7px; + min-height: 25px; + border-bottom: 1px dotted #C0C0C0; +} + +calendar-invitations-richlistitem[selected="true"] { + background-color: Highlight; + color: HighlightText; + border-bottom: 1px dotted #7F9DB9; +} + +.calendar-invitations-richlistitem-title { + font-weight: bold; +} + +.calendar-invitations-richlistitem-icon[status="NEEDS-ACTION"] { + list-style-image: url("chrome://calendar-common/skin/calendar-invitations-dialog-list-images.png"); + -moz-image-region: rect(0px 32px 32px 0px); +} + +.calendar-invitations-richlistitem-icon[status="ACCEPTED"] { + list-style-image: url("chrome://calendar-common/skin/calendar-invitations-dialog-list-images.png"); + -moz-image-region: rect(0px 64px 32px 32px); +} + +.calendar-invitations-richlistitem-icon[status="DECLINED"] { + list-style-image: url("chrome://calendar-common/skin/calendar-invitations-dialog-list-images.png"); + -moz-image-region: rect(0px 96px 32px 64px); +} + +.calendar-invitations-richlistitem-button { + margin-bottom: 10px; + visibility: hidden; +} + +calendar-invitations-richlistitem[selected="true"] .calendar-invitations-richlistitem-button { + visibility: visible; +} + +.calendar-invitations-richlistitem-button .button-icon { + margin-top: 0px; + margin-bottom: 0px; + margin-inline-start: 0px; + margin-inline-end: 5px; +} + +.calendar-invitations-richlistitem-accept-button { + list-style-image: url("chrome://calendar-common/skin/calendar-invitations-dialog-button-images.png"); + -moz-image-region: rect(0px 16px 16px 0px); +} + +.calendar-invitations-richlistitem-decline-button { + list-style-image: url("chrome://calendar-common/skin/calendar-invitations-dialog-button-images.png"); + -moz-image-region: rect(0px 32px 16px 16px); +} diff --git a/calendar/base/themes/common/dialogs/calendar-properties-dialog.css b/calendar/base/themes/common/dialogs/calendar-properties-dialog.css new file mode 100644 index 000000000..68f7fd024 --- /dev/null +++ b/calendar/base/themes/common/dialogs/calendar-properties-dialog.css @@ -0,0 +1,11 @@ +/* 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/. */ + +#calendar-properties-grid { + margin-inline-start: 20px; +} + +#calendar-properties-rows > row { + min-height: 26px; +} diff --git a/calendar/base/themes/common/dialogs/calendar-subscriptions-dialog.css b/calendar/base/themes/common/dialogs/calendar-subscriptions-dialog.css new file mode 100644 index 000000000..6351eb984 --- /dev/null +++ b/calendar/base/themes/common/dialogs/calendar-subscriptions-dialog.css @@ -0,0 +1,52 @@ +/* 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/. */ + +#calendar-subscriptions-dialog { + min-width: 480px; + min-height: 320px; +} + +calendar-subscriptions-richlistbox { + background-color: white; + border: 1px solid black; + margin-top: 2px; + margin-bottom: 2px; + margin-inline-start: 4px; + margin-inline-end: 4px; + padding-top: 2px; + padding-bottom: 3px; + padding-inline-start: 4px; + padding-inline-end: 2px; +} + +calendar-subscriptions-richlistitem[selected="true"] { + background-color: Highlight; + color: HighlightText; +} + +.calendar-subscriptions-richlistitem-checkbox { + margin-inline-end: 0px; +} + +.calendar-subscriptions-richlistitem-checkbox > .checkbox-label-box { + display: none; +} + +.calendar-subscriptions-select-label { + margin-top: 6px; +} + +.calendar-subscriptions-status-box { + margin-top: 2px; + margin-bottom: 2px; + margin-inline-start: 4px; +} + +.calendar-subscriptions-status-icon { + list-style-image: url("chrome://messenger/skin/icons/notloading.png"); +} + +.calendar-subscriptions-status-icon[busy="true"] { + list-style-image: url("chrome://global/skin/icons/loading.png"); +} diff --git a/calendar/base/themes/common/dialogs/calendar-timezone-highlighter.css b/calendar/base/themes/common/dialogs/calendar-timezone-highlighter.css new file mode 100644 index 000000000..725c7af8d --- /dev/null +++ b/calendar/base/themes/common/dialogs/calendar-timezone-highlighter.css @@ -0,0 +1,136 @@ +/* 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/. */ + +.timezone-highlight { + list-style-image: url(chrome://calendar-common/skin/timezones.png); +} +.timezone-highlight[tzid="none"] { + display: none; +} +.timezone-highlight[tzid="+0000"] { + -moz-image-region: rect(0 7360px 287px 6900px); +} +.timezone-highlight[tzid="+0100"] { + -moz-image-region: rect(0 7820px 287px 7360px); +} +.timezone-highlight[tzid="+0200"] { + -moz-image-region: rect(0 8280px 287px 7820px); +} +.timezone-highlight[tzid="+0300"] { + -moz-image-region: rect(0 8740px 287px 8280px); +} +.timezone-highlight[tzid="+0330"] { + -moz-image-region: rect(0 9200px 287px 8740px); +} +.timezone-highlight[tzid="+0400"] { + -moz-image-region: rect(0 9660px 287px 9200px); +} +.timezone-highlight[tzid="+0430"] { + -moz-image-region: rect(0 10120px 287px 9660px); +} +.timezone-highlight[tzid="+0500"] { + -moz-image-region: rect(0 10580px 287px 10120px); +} +.timezone-highlight[tzid="+0530"] { + -moz-image-region: rect(0 11040px 287px 10580px); +} +.timezone-highlight[tzid="+0545"] { + -moz-image-region: rect(0 11500px 287px 11040px); +} +.timezone-highlight[tzid="+0600"] { + -moz-image-region: rect(0 11960px 287px 11500px); +} +.timezone-highlight[tzid="+0630"] { + -moz-image-region: rect(0 12420px 287px 11960px); +} +.timezone-highlight[tzid="+0700"] { + -moz-image-region: rect(0 12880px 287px 12420px); +} +.timezone-highlight[tzid="+0800"] { + -moz-image-region: rect(0 13340px 287px 12880px); +} +.timezone-highlight[tzid="+0845"] { + display: none; +} +.timezone-highlight[tzid="+0900"] { + -moz-image-region: rect(0 13800px 287px 13340px); +} +.timezone-highlight[tzid="+0930"] { + -moz-image-region: rect(0 14260px 287px 13800px); +} +.timezone-highlight[tzid="+1000"] { + -moz-image-region: rect(0 14720px 287px 14260px); +} +.timezone-highlight[tzid="+1030"] { + -moz-image-region: rect(0 15180px 287px 14720px); +} +.timezone-highlight[tzid="+1100"] { + -moz-image-region: rect(0 15640px 287px 15180px); +} +.timezone-highlight[tzid="+1130"] { + -moz-image-region: rect(0 15640px 287px 15180px); +} +.timezone-highlight[tzid="+1200"] { + -moz-image-region: rect(0 16560px 287px 16100px); +} +.timezone-highlight[tzid="+1245"] { + -moz-image-region: rect(0 17020px 287px 16560px); +} +.timezone-highlight[tzid="+1300"] { + -moz-image-region: rect(0 17480px 287px 17020px); +} +.timezone-highlight[tzid="+1400"] { + -moz-image-region: rect(0 17940px 287px 17480px); +} +.timezone-highlight[tzid="-0100"] { + -moz-image-region: rect(0 6900px 287px 6440px); +} +.timezone-highlight[tzid="-0200"] { + -moz-image-region: rect(0 6440px 287px 5980px); +} +.timezone-highlight[tzid="-0300"] { + -moz-image-region: rect(0 5980px 287px 5520px); +} +.timezone-highlight[tzid="-0330"] { + -moz-image-region: rect(0 5520px 287px 5060px); +} +.timezone-highlight[tzid="-0400"] { + -moz-image-region: rect(0 5060px 287px 4600px); +} +.timezone-highlight[tzid="-0430"] { + display: none; +} +.timezone-highlight[tzid="-0500"] { + -moz-image-region: rect(0 4600px 287px 4140px); +} +.timezone-highlight[tzid="-0600"] { + -moz-image-region: rect(0 4140px 287px 3680px); +} +.timezone-highlight[tzid="-0700"] { + -moz-image-region: rect(0 3680px 287px 3220px); +} +.timezone-highlight[tzid="-0800"] { + -moz-image-region: rect(0 18400px 287px 17940px); +} +.timezone-highlight[tzid="-0830"] { + -moz-image-region: rect(0 3220px 287px 2760px); +} +.timezone-highlight[tzid="-0900"] { + -moz-image-region: rect(0 2760px 287px 2300px); +} +.timezone-highlight[tzid="-0930"] { + -moz-image-region: rect(0 2300px 287px 1840px); +} +.timezone-highlight[tzid="-1000"] { + -moz-image-region: rect(0 1840px 287px 1380px); +} +.timezone-highlight[tzid="-1100"] { + -moz-image-region: rect(0 1380px 287px 920px); +} +.timezone-highlight[tzid="-1245"] { + -moz-image-region: rect(0 920px 287px 460px); +} +.timezone-highlight[tzid="-1200"] { + -moz-image-region: rect(0 460px 287px 0px); +} diff --git a/calendar/base/themes/common/dialogs/images/calendar-event-dialog-attendees.png b/calendar/base/themes/common/dialogs/images/calendar-event-dialog-attendees.png Binary files differnew file mode 100644 index 000000000..ea52cbc19 --- /dev/null +++ b/calendar/base/themes/common/dialogs/images/calendar-event-dialog-attendees.png diff --git a/calendar/base/themes/common/dialogs/images/calendar-event-dialog.png b/calendar/base/themes/common/dialogs/images/calendar-event-dialog.png Binary files differnew file mode 100644 index 000000000..8c5294a5b --- /dev/null +++ b/calendar/base/themes/common/dialogs/images/calendar-event-dialog.png diff --git a/calendar/base/themes/common/dialogs/images/calendar-event-tab.png b/calendar/base/themes/common/dialogs/images/calendar-event-tab.png Binary files differnew file mode 100644 index 000000000..664cd8262 --- /dev/null +++ b/calendar/base/themes/common/dialogs/images/calendar-event-tab.png diff --git a/calendar/base/themes/common/dialogs/images/calendar-invitations-dialog-button-images.png b/calendar/base/themes/common/dialogs/images/calendar-invitations-dialog-button-images.png Binary files differnew file mode 100644 index 000000000..b641ed276 --- /dev/null +++ b/calendar/base/themes/common/dialogs/images/calendar-invitations-dialog-button-images.png diff --git a/calendar/base/themes/common/dialogs/images/calendar-invitations-dialog-list-images.png b/calendar/base/themes/common/dialogs/images/calendar-invitations-dialog-list-images.png Binary files differnew file mode 100644 index 000000000..db33ad817 --- /dev/null +++ b/calendar/base/themes/common/dialogs/images/calendar-invitations-dialog-list-images.png diff --git a/calendar/base/themes/common/dialogs/images/calendar-task-tab.png b/calendar/base/themes/common/dialogs/images/calendar-task-tab.png Binary files differnew file mode 100644 index 000000000..58e9daf04 --- /dev/null +++ b/calendar/base/themes/common/dialogs/images/calendar-task-tab.png diff --git a/calendar/base/themes/common/icons/calendar-alarm-dialog.ico b/calendar/base/themes/common/icons/calendar-alarm-dialog.ico Binary files differnew file mode 100644 index 000000000..c55dcc7f2 --- /dev/null +++ b/calendar/base/themes/common/icons/calendar-alarm-dialog.ico diff --git a/calendar/base/themes/common/icons/calendar-alarm-dialog.png b/calendar/base/themes/common/icons/calendar-alarm-dialog.png Binary files differnew file mode 100644 index 000000000..daec5e331 --- /dev/null +++ b/calendar/base/themes/common/icons/calendar-alarm-dialog.png diff --git a/calendar/base/themes/common/icons/calendar-event-dialog.ico b/calendar/base/themes/common/icons/calendar-event-dialog.ico Binary files differnew file mode 100644 index 000000000..eb89f54e3 --- /dev/null +++ b/calendar/base/themes/common/icons/calendar-event-dialog.ico diff --git a/calendar/base/themes/common/icons/calendar-event-dialog.png b/calendar/base/themes/common/icons/calendar-event-dialog.png Binary files differnew file mode 100644 index 000000000..b4a583116 --- /dev/null +++ b/calendar/base/themes/common/icons/calendar-event-dialog.png diff --git a/calendar/base/themes/common/icons/calendar-event-summary-dialog.ico b/calendar/base/themes/common/icons/calendar-event-summary-dialog.ico Binary files differnew file mode 100644 index 000000000..6fdee6522 --- /dev/null +++ b/calendar/base/themes/common/icons/calendar-event-summary-dialog.ico diff --git a/calendar/base/themes/common/icons/calendar-event-summary-dialog.png b/calendar/base/themes/common/icons/calendar-event-summary-dialog.png Binary files differnew file mode 100644 index 000000000..7619817b1 --- /dev/null +++ b/calendar/base/themes/common/icons/calendar-event-summary-dialog.png diff --git a/calendar/base/themes/common/icons/calendar-task-dialog.ico b/calendar/base/themes/common/icons/calendar-task-dialog.ico Binary files differnew file mode 100644 index 000000000..809d02a38 --- /dev/null +++ b/calendar/base/themes/common/icons/calendar-task-dialog.ico diff --git a/calendar/base/themes/common/icons/calendar-task-dialog.png b/calendar/base/themes/common/icons/calendar-task-dialog.png Binary files differnew file mode 100644 index 000000000..6ed67385c --- /dev/null +++ b/calendar/base/themes/common/icons/calendar-task-dialog.png diff --git a/calendar/base/themes/common/icons/calendar-task-summary-dialog.ico b/calendar/base/themes/common/icons/calendar-task-summary-dialog.ico Binary files differnew file mode 100644 index 000000000..cf75dc928 --- /dev/null +++ b/calendar/base/themes/common/icons/calendar-task-summary-dialog.ico diff --git a/calendar/base/themes/common/icons/calendar-task-summary-dialog.png b/calendar/base/themes/common/icons/calendar-task-summary-dialog.png Binary files differnew file mode 100644 index 000000000..8593ad27f --- /dev/null +++ b/calendar/base/themes/common/icons/calendar-task-summary-dialog.png diff --git a/calendar/base/themes/common/images/alarm-flashing.png b/calendar/base/themes/common/images/alarm-flashing.png Binary files differnew file mode 100644 index 000000000..b094b5fa7 --- /dev/null +++ b/calendar/base/themes/common/images/alarm-flashing.png diff --git a/calendar/base/themes/common/images/alarm-icons.png b/calendar/base/themes/common/images/alarm-icons.png Binary files differnew file mode 100644 index 000000000..554f823a0 --- /dev/null +++ b/calendar/base/themes/common/images/alarm-icons.png diff --git a/calendar/base/themes/common/images/attendee-icons.png b/calendar/base/themes/common/images/attendee-icons.png Binary files differnew file mode 100644 index 000000000..c425552e7 --- /dev/null +++ b/calendar/base/themes/common/images/attendee-icons.png diff --git a/calendar/base/themes/common/images/calendar-overlay.png b/calendar/base/themes/common/images/calendar-overlay.png Binary files differnew file mode 100644 index 000000000..af341e77d --- /dev/null +++ b/calendar/base/themes/common/images/calendar-overlay.png diff --git a/calendar/base/themes/common/images/calendar-status.png b/calendar/base/themes/common/images/calendar-status.png Binary files differnew file mode 100644 index 000000000..baae3074d --- /dev/null +++ b/calendar/base/themes/common/images/calendar-status.png diff --git a/calendar/base/themes/common/images/checkbox-images.png b/calendar/base/themes/common/images/checkbox-images.png Binary files differnew file mode 100644 index 000000000..dcbbae97b --- /dev/null +++ b/calendar/base/themes/common/images/checkbox-images.png diff --git a/calendar/base/themes/common/images/classification.png b/calendar/base/themes/common/images/classification.png Binary files differnew file mode 100644 index 000000000..3b1eb5c0c --- /dev/null +++ b/calendar/base/themes/common/images/classification.png diff --git a/calendar/base/themes/common/images/day-box-item-image.png b/calendar/base/themes/common/images/day-box-item-image.png Binary files differnew file mode 100644 index 000000000..b674ce33e --- /dev/null +++ b/calendar/base/themes/common/images/day-box-item-image.png diff --git a/calendar/base/themes/common/images/event-grippy-bottom.png b/calendar/base/themes/common/images/event-grippy-bottom.png Binary files differnew file mode 100644 index 000000000..a6864a9c7 --- /dev/null +++ b/calendar/base/themes/common/images/event-grippy-bottom.png diff --git a/calendar/base/themes/common/images/event-grippy-left.png b/calendar/base/themes/common/images/event-grippy-left.png Binary files differnew file mode 100644 index 000000000..69d0f8072 --- /dev/null +++ b/calendar/base/themes/common/images/event-grippy-left.png diff --git a/calendar/base/themes/common/images/event-grippy-right.png b/calendar/base/themes/common/images/event-grippy-right.png Binary files differnew file mode 100644 index 000000000..62afc8c0f --- /dev/null +++ b/calendar/base/themes/common/images/event-grippy-right.png diff --git a/calendar/base/themes/common/images/event-grippy-top.png b/calendar/base/themes/common/images/event-grippy-top.png Binary files differnew file mode 100644 index 000000000..87dadbc89 --- /dev/null +++ b/calendar/base/themes/common/images/event-grippy-top.png diff --git a/calendar/base/themes/common/images/ok-cancel.png b/calendar/base/themes/common/images/ok-cancel.png Binary files differnew file mode 100644 index 000000000..18380954d --- /dev/null +++ b/calendar/base/themes/common/images/ok-cancel.png diff --git a/calendar/base/themes/common/images/task-images.png b/calendar/base/themes/common/images/task-images.png Binary files differnew file mode 100644 index 000000000..ddf4188c1 --- /dev/null +++ b/calendar/base/themes/common/images/task-images.png diff --git a/calendar/base/themes/common/images/timezone_map.png b/calendar/base/themes/common/images/timezone_map.png Binary files differnew file mode 100644 index 000000000..1d3f84445 --- /dev/null +++ b/calendar/base/themes/common/images/timezone_map.png diff --git a/calendar/base/themes/common/images/timezones.png b/calendar/base/themes/common/images/timezones.png Binary files differnew file mode 100644 index 000000000..9170d9d79 --- /dev/null +++ b/calendar/base/themes/common/images/timezones.png diff --git a/calendar/base/themes/common/today-pane-cycler.svg b/calendar/base/themes/common/today-pane-cycler.svg new file mode 100644 index 000000000..b4cbf1572 --- /dev/null +++ b/calendar/base/themes/common/today-pane-cycler.svg @@ -0,0 +1,15 @@ +<!-- 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/. --> +<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg"> + <style> + path { + display: none; + } + path:target { + display: block; + } + </style> + <path id="normal" d="m 3,2 9,6 -9,6 z" style="fill: -moz-dialogtext;"/> + <path id="inverted" d="m 3,2 9,6 -9,6 z" style="fill: white;"/> +</svg> diff --git a/calendar/base/themes/common/today-pane.css b/calendar/base/themes/common/today-pane.css new file mode 100644 index 000000000..b30cc527d --- /dev/null +++ b/calendar/base/themes/common/today-pane.css @@ -0,0 +1,240 @@ +/* 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/. */ + +.today-subpane { + border-style: solid; + border-width: 1px 0; + margin-bottom: 3px; + padding: 0px; +} + +#buttonspacer { + width: 5px; +} + +#today-pane-panel { + background-color: -moz-Dialog; +} + +#today-pane-panel:-moz-lwtheme { + background-color: transparent; +} + +#today-pane-panel > * { + color: -moz-DialogText; +} + +#today-pane-panel:-moz-lwtheme > sidebarheader { + color: inherit; +} + +#agenda-panel:-moz-lwtheme > vbox, +#today-pane-splitter:-moz-lwtheme, +#todo-tab-panel:-moz-lwtheme { + background-color: -moz-Dialog; +} + +#today-pane-panel:-moz-lwtheme > vbox { + text-shadow: none; + background-color: -moz-Dialog; +} + +#today-minimonth-box { + background-color: -moz-field; +} + +#weekdayNameContainer { + font-family: Trebuchet MS, Lucida Grande, Arial, Helvetica; + padding-top: 4px; + font-weight: bold; + font-size: 18px; +} + +.monthlabel { + margin-inline-end: 0; +} + +.dateValue { + font-family: Arial, Helvetica, Trebuchet MS, Lucida Grande, sans-serif; + margin-top: initial; + margin-bottom: initial; + font-size: 36px; + font-weight: bold; + width: 1em; + text-align: center; +} + +#dragCenter-image { + list-style-image: url("chrome://calendar-common/skin/widgets/drag-center.svg"); +} + +.miniday-nav-buttons { + margin-top: 2px; + min-width: 19px; + -moz-user-focus: normal; +} + +#today-button { + list-style-image: url("chrome://calendar-common/skin/widgets/nav-today.svg"); +} + +.miniday-nav-buttons[disabled] { + opacity: .3; +} + +.miniday-nav-buttons > .toolbarbutton-icon { + margin: 1px; +} + +#miniday-dropdown-button { + margin: 2px; + -moz-user-focus: normal; +} + +#miniday-dropdown-button > .toolbarbutton-icon, +#miniday-dropdown-button > .toolbarbutton-text, + .miniday-nav-buttons > .toolbarbutton-text { + display: none; +} + +#miniday-dropdown-button > .toolbarbutton-menu-dropmarker { + padding-inline-start: 0; +} + +#agenda-toolbar { + border: none; + padding: 1px; +} + +#todaypane-new-event-button { + -moz-user-focus: normal; +} + +#todaypane-new-event-button > .toolbarbutton-text { + padding-inline-start: 5px; +} + +#agenda-listbox { + -moz-appearance: none; + -moz-user-focus: normal; + margin: 3px 0 0; + border-top: 1px solid ThreeDShadow; + background-color: -moz-field; +} + +agenda-checkbox-richlist-item { + -moz-binding: url("chrome://calendar/content/agenda-listbox.xml#agenda-checkbox-richlist-item"); + -moz-user-focus: normal; +} + +agenda-richlist-item { + -moz-binding: url("chrome://calendar/content/agenda-listbox.xml#agenda-richlist-item"); + -moz-user-focus: normal; +} + +agenda-allday-richlist-item { + -moz-binding: url("chrome://calendar/content/agenda-listbox.xml#agenda-allday-richlist-item"); + -moz-user-focus: normal; +} +.wrap { + overflow: visible; +} + +.agenda-container-box { + border-bottom: 1px dotted #C0C0C0; + margin-inline-start: 4px; + margin-inline-end: 4px; + padding-top: 2px; + padding-bottom: 2px; +} + +.agenda-allday-container-box { + border-bottom: 1px dotted #C0C0C0; + margin-inline-start: 4px; + margin-inline-end: 4px; + padding-top: 4px; + padding-bottom: 4px; +} + +.agenda-container-box[selected="true"], +.agenda-allday-container-box[selected="true"], +.agenda-checkbox[selected="true"], +.agenda-container-box[selected="true"][current="true"], +.agenda-allday-container-box[selected="true"][current="true"], +.agenda-checkbox[selected="true"][current="true"] { + background-color: #FDF5A0; + color: #000000; +} + +.agenda-container-box[current="true"], +.agenda-alldaycontainer-box[current="true"], +.agenda-checkbox[current="true"], +.agenda-container-box[selected="true"][current="true"][disabled="true"], +.agenda-allday-container-box[selected="true"][current="true"][disabled="true"], +.agenda-checkbox[selected="true"][current="true"][disabled="true"] { + background-color: #DFEAF4; +} + +.agenda-container-box[selected="true"][disabled="true"], +.agenda-allday-container-box[selected="true"][disabled="true"], +.agenda-checkbox[selected="true"][disabled="true"] { + color: -moz-dialogText; + background-color: -moz-dialog; +} + +.agenda-allday-container-box .item-classification-box { + display: none; +} + +.agenda-event-title { + margin-top: 0px; +} + +.agenda-event-start { + margin-bottom: 0px; +} + +.agenda-new-date { + width: 15px; + height: 15px; + border: 1px solid grey; + cursor: pointer; +} + +.agenda-calendar-image { + list-style-image: url("chrome://calendar-common/skin/calendar-overlay.png"); + -moz-image-region: rect(0px 10px 10px 0px); + margin-top: 0.35em; + margin-inline-start: 4px; + width: 10px; + height: 10px; +} + +.agenda-multiDayEvent-image { + list-style-image: url("chrome://calendar-common/skin/calendar-overlay.png"); + margin-top: 0.35em; + margin-inline-start: 3px; + width: 10px; + height: 10px; + display: none; +} + +.agenda-allday-container-box .agenda-multiDayEvent-image { + margin-top: 0.6em; +} + +.agenda-multiDayEvent-image[type="start"] { + -moz-image-region: rect(0px 20px 10px 10px); + display: -moz-box; +} + +.agenda-multiDayEvent-image[type="continue"] { + -moz-image-region: rect(0px 30px 10px 20px); + display: -moz-box; +} + +.agenda-multiDayEvent-image[type="end"] { + -moz-image-region: rect(0px 40px 10px 30px); + display: -moz-box; +} diff --git a/calendar/base/themes/common/widgets/calendar-widgets.css b/calendar/base/themes/common/widgets/calendar-widgets.css new file mode 100644 index 000000000..655d6e255 --- /dev/null +++ b/calendar/base/themes/common/widgets/calendar-widgets.css @@ -0,0 +1,76 @@ +/* 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/. */ + +treenode-checkbox { + -moz-box-align: center; + padding-top: 4px; + padding-inline-start: 4px; + padding-inline-end: 4px; + font-weight: bold; +} + +.checkbox-label-box { + margin-inline-start: 4px; +} + +.checkbox-icon { + margin-inline-end: 2px; +} + +.checkbox-label { + margin: 0 !important; +} + +treenode-checkbox > .checkbox-label-center-box > .checkbox-label-box > .checkbox-label { + font-weight: bold; + border-bottom: 1px solid -moz-Dialog; +} + +.view-navigation-button { + list-style-image: url(chrome://calendar-common/skin/widgets/view-navigation.svg); + -moz-user-focus: normal; +} + +.view-navigation-button[disabled="true"] { + opacity: .3; +} + +.view-navigation-button:-moz-locale-dir(ltr)[type="prev"] > .toolbarbutton-icon, +.view-navigation-button:-moz-locale-dir(rtl)[type="next"] > .toolbarbutton-icon { + transform: scaleX(-1); +} + +.view-navigation-button > .toolbarbutton-icon { + margin: 0px !important; +} + +.view-navigation-button > .toolbarbutton-text { + display: none; +} + +.selected-text { + font-weight: bold; +} + +.selected-text:not([selected="true"]), +.unselected-text[selected="true"] { + visibility: hidden; +} + +.categories-listbox { + -moz-appearance: none; + background-color: Menu; + color: MenuText; + margin: 0 0 4px 0; + border: 0; +} + +.categories-textbox .textbox-search-icon { + list-style-image: none; + cursor:default; +} + +.categories-textbox { + -moz-appearance: textfield; +} diff --git a/calendar/base/themes/common/widgets/images/drag-center.svg b/calendar/base/themes/common/widgets/images/drag-center.svg new file mode 100644 index 000000000..2e0491801 --- /dev/null +++ b/calendar/base/themes/common/widgets/images/drag-center.svg @@ -0,0 +1,11 @@ +<svg xmlns="http://www.w3.org/2000/svg" + width="36" height="15"> + <line id="plusV_b" x1="31.5" y1="3" x2="31.5" y2="12" stroke="#ffffff" stroke-width="3" stroke-opacity="0.8" fill="none"/> + <line id="plusH_b" x1="27" y1="7.5" x2="36" y2="7.5" stroke="#ffffff" stroke-width="3" stroke-opacity="0.8" fill="none"/> + <line id="minus_b" x1="0" y1="7.5" x2="8" y2="7.5" stroke="#ffffff" stroke-width="3" stroke-opacity="0.8" fill="none"/> + <line id="minus" x1="1" y1="7.5" x2="7" y2="7.5" stroke="#aaaaaa" stroke-width="1" fill="none"/> + <line id="plusH" x1="28" y1="7.5" x2="35" y2="7.5" stroke="#aaaaaa" stroke-width="1" fill="none"/> + <line id="plusV" x1="31.5" y1="4" x2="31.5" y2="11" stroke="#aaaaaa" stroke-width="1" fill="none"/> + <circle id="outer" r="6.5" cy="7.5" cx="18" stroke="#ffffff" stroke-width="2" stroke-opacity="0.8" fill="none"/> + <circle id="inner" r="5.5" cy="7.5" cx="18" stroke="#4c4c4c" stroke-width="1.5" stroke-opacity="0.5" fill="none"/> +</svg> diff --git a/calendar/base/themes/common/widgets/images/nav-arrow.svg b/calendar/base/themes/common/widgets/images/nav-arrow.svg new file mode 100644 index 000000000..1e6dcbf74 --- /dev/null +++ b/calendar/base/themes/common/widgets/images/nav-arrow.svg @@ -0,0 +1,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/. --> + +<svg xmlns="http://www.w3.org/2000/svg" + width="5" height="13"> + <path d="M 0,2 5,6.5 0,11 z" style="fill:-moz-dialogtext" /> +</svg> diff --git a/calendar/base/themes/common/widgets/images/nav-today-hov.svg b/calendar/base/themes/common/widgets/images/nav-today-hov.svg new file mode 100644 index 000000000..4e19cf37e --- /dev/null +++ b/calendar/base/themes/common/widgets/images/nav-today-hov.svg @@ -0,0 +1,9 @@ +<!-- 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/. --> + +<svg xmlns="http://www.w3.org/2000/svg" + width="11" height="13"> + <path d="m 9.67,6.5 a 4.17,4.1 0 1 1 -8.34,0 4.17,4.1 0 1 1 8.34,0 z" + style="fill:none;stroke:-moz-buttonhovertext;stroke-width:2;" /> +</svg> diff --git a/calendar/base/themes/common/widgets/images/nav-today.svg b/calendar/base/themes/common/widgets/images/nav-today.svg new file mode 100644 index 000000000..f2b6e3a66 --- /dev/null +++ b/calendar/base/themes/common/widgets/images/nav-today.svg @@ -0,0 +1,10 @@ +<!-- 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/. --> + +<svg xmlns="http://www.w3.org/2000/svg" + width="11" height="13"> + <path + d="m 9.67,6.5 a 4.17,4.1 0 1 1 -8.34,0 4.17,4.1 0 1 1 8.34,0 z" + style="fill:none;stroke:-moz-dialogtext;stroke-width:2;" /> +</svg> diff --git a/calendar/base/themes/common/widgets/images/view-navigation-hov.svg b/calendar/base/themes/common/widgets/images/view-navigation-hov.svg new file mode 100644 index 000000000..692203009 --- /dev/null +++ b/calendar/base/themes/common/widgets/images/view-navigation-hov.svg @@ -0,0 +1,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/. --> + +<svg xmlns="http://www.w3.org/2000/svg" + width="11" height="11"> + <path d="M 1,1 10,5.5 1,10 Z" style="fill:-moz-buttonhovertext;" /> +</svg> diff --git a/calendar/base/themes/common/widgets/images/view-navigation.svg b/calendar/base/themes/common/widgets/images/view-navigation.svg new file mode 100644 index 000000000..7febb01a5 --- /dev/null +++ b/calendar/base/themes/common/widgets/images/view-navigation.svg @@ -0,0 +1,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/. --> + +<svg xmlns="http://www.w3.org/2000/svg" + width="11" height="11"> + <path d="M 1,1 10,5.5 1,10 Z" style="fill:ButtonText;" /> +</svg> diff --git a/calendar/base/themes/common/widgets/minimonth.css b/calendar/base/themes/common/widgets/minimonth.css new file mode 100644 index 000000000..1dc2adf45 --- /dev/null +++ b/calendar/base/themes/common/widgets/minimonth.css @@ -0,0 +1,202 @@ +/* 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/. */ + +minimonth { + --mmMainBackground: #fff; + --mmHighlightColor: HighlightText; + --mmHighlightBackground: Highlight; + --mmHighlightBorderColor: Highlight; + --mmBoxBackground: #f5f5f6; + --mmBoxBorderColor: #c0c0c0; + --mmDayColor: #2e4e73; + --mmDayBorderColor: #fff; + --mmDayOtherColor: #b2b2b2; + --mmDayOtherBackground: #f0f0f0; + --mmDayOtherBorderColor: #f0f0f0; + --mmDayTodayBackground: #dfeaf4; + --mmDayTodayBorderColor: #67acd8; + --mmDaySelectedColor: #2e4e73; + --mmDaySelectedBackground: #fffabc; + --mmDaySelectedBorderColor: #d9c585; + --mmDaySelectedTodayBackground: #f2edb2; + --mmDaySelectedTodayBorderColor: #67acd8; +} + +:root[systemcolors] minimonth { + --mmMainBackground: -moz-field; + --mmBoxBackground: -moz-Dialog; + --mmBoxBorderColor: ThreeDShadow; + --mmDayColor: WindowText; + --mmDayBorderColor: Window; + --mmDayOtherColor: GrayText; + --mmDayOtherBackground: ButtonFace; + --mmDayOtherBorderColor: Transparent; + --mmDayTodayBackground: -moz-field; + --mmDayTodayBorderColor: Highlight; + --mmDaySelectedColor: HighlightText; + --mmDaySelectedBackground: Highlight; + --mmDaySelectedBorderColor: ButtonFace; + --mmDaySelectedTodayBackground: Highlight; + --mmDaySelectedTodayBorderColor: ButtonFace; +} + +minimonth { + background-color: var(--mmMainBackground); + border: 0px; + padding: 4px; +} + +.minimonth-month-box { + background-color: var(--mmBoxBackground); + border: 1px dotted var(--mmBoxBorderColor); +} + +.minimonth-month-name { + font-weight: bold; + padding: 0px; + -moz-user-focus: normal; +} + +.minimonth-month-name-readonly { + text-align: right; + font-weight: bold; +} + +.minimonth-year-name { + min-width: 4em; + font-weight: bold; + padding: 0px; + -moz-user-focus: normal; +} + +.minimonth-year-name-readonly { + min-width: 4em; + font-weight: bold; + padding-inline-start: 4px; +} + +.minimonth-month-name > .toolbarbutton-text { + text-align: right; +} + +.minimonth-month-name > .toolbarbutton-icon, +.minimonth-year-name > .toolbarbutton-icon { + display: none; +} + +.minimonth-nav-btns > .toolbarbutton-icon { + margin: 1px; +} + +.minimonth-nav-btns { + padding: 0px; + min-width: 19px; + list-style-image: url("chrome://calendar-common/skin/widgets/nav-arrow.svg"); + -moz-user-focus: normal; +} + +.minimonth-nav-btns > .toolbarbutton-text { + display: none; +} + +.minimonth-nav-btns[disabled] { + opacity: .3; +} + +.minimonth-nav-btns:-moz-locale-dir(ltr)[dir="-1"], +.minimonth-nav-btns:-moz-locale-dir(rtl)[dir="1"] { + transform: scaleX(-1); +} + +.minimonth-nav-btns[dir="0"] { + list-style-image: url("chrome://calendar-common/skin/widgets/nav-today.svg"); +} + +.minimonth-row-header { + text-align: center; +} + +.minimonth-day { + color: var(--mmDayColor); + text-align: center; + border: 1px solid var(--mmDayBorderColor); + background-color: var(--mmMainBackground); + min-height: 16px; +} + +.minimonth-row-header-week { + color: var(--mmDayOtherColor); + text-align: center; + border-right: 1px dotted var(--mmDayOtherColor); +} + +.minimonth-week { + color: var(--mmDayOtherColor); + text-align: center; + border: 1px solid var(--mmDayBorderColor); + border-right: 1px dotted var(--mmDayOtherColor); + background-color: var(--mmMainBackground); + min-height: 16px; +} + +.minimonth-day[othermonth="true"] { + color: var(--mmDayOtherColor); + background-color: var(--mmDayOtherBackground); + border: 1px solid var(--mmDayOtherBorderColor); +} + +.minimonth-day[today="true"] { + background-color: var(--mmDayTodayBackground); + border: 1px solid var(--mmDayTodayBorderColor); +} + +.minimonth-day[selected="true"] { + background-color: var(--mmDaySelectedBackground); + color: var(--mmDaySelectedColor); + border: 1px solid var(--mmDaySelectedBorderColor); +} + +#repeat-until-datepicker .minimonth-day[extra="true"], +#repeat-until-date .minimonth-day[extra="true"] { + border: 1px solid var(--mmDayOtherColor); +} + +#repeat-until-datepicker .minimonth-day:hover[extra="true"], +#repeat-until-date .minimonth-day:hover[extra="true"] { + border: 1px solid var(--mmHighlightBorderColor); +} + +.minimonth-day[selected="true"][today="true"] { + background-color: var(--mmDaySelectedTodayBackground); + border: 1px solid var(--mmDaySelectedTodayBorderColor); +} + +.minimonth-day[busy] { + font-weight: bold; +} + +.minimonth-day:hover[interactive] { + cursor: pointer; + border: 1px solid var(--mmHighlightBorderColor); +} + +.minimonth-day:active[interactive] { + background-color: var(--mmHighlightBackground); + color: var(--mmHighlightColor); +} + +.minimonth-list { + padding-inline-start: 1em; + padding-inline-end: 1em; +} + +.minimonth-list[current="true"] { + font-weight: bold; +} + +.minimonth-list:hover { + background-color: var(--mmHighlightBackground); + color: var(--mmHighlightColor); + cursor: pointer; +} diff --git a/calendar/base/themes/linux/calendar-daypicker.css b/calendar/base/themes/linux/calendar-daypicker.css new file mode 100644 index 000000000..f8a3c0680 --- /dev/null +++ b/calendar/base/themes/linux/calendar-daypicker.css @@ -0,0 +1,18 @@ +/* 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 url(chrome://calendar-common/skin/calendar-daypicker.css); + +daypicker { + border-top: 1px solid ThreeDShadow; + border-left: 1px solid ThreeDShadow; +} + +daypicker[bottom="true"] { + border-bottom: 1px solid ThreeDShadow; +} + +daypicker[right="true"] { + border-right: 1px solid ThreeDShadow; +} diff --git a/calendar/base/themes/linux/calendar-management.css b/calendar/base/themes/linux/calendar-management.css new file mode 100644 index 000000000..8017f4c53 --- /dev/null +++ b/calendar/base/themes/linux/calendar-management.css @@ -0,0 +1,25 @@ +/* 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 url(chrome://calendar-common/skin/calendar-management.css); + +calendar-list-tree > tree > treechildren::-moz-tree-image(checkbox-treecol) { + list-style-image: url(chrome://calendar-common/skin/checkbox-images.png); +} + +calendar-list-tree > tree > treechildren::-moz-tree-image(checkbox-treecol) { + -moz-image-region: rect(0 13px 13px 0); +} + +calendar-list-tree > tree > treechildren::-moz-tree-image(checkbox-treecol, checked) { + -moz-image-region: rect(0 26px 13px 13px); +} + +calendar-list-tree > tree > treechildren::-moz-tree-image(checkbox-treecol, disabled) { + -moz-image-region: rect(0 39px 13px 26px); +} + +calendar-list-tree > tree > treechildren::-moz-tree-cell-text(disabled) { + color: GrayText; +} diff --git a/calendar/base/themes/linux/calendar-task-tree.css b/calendar/base/themes/linux/calendar-task-tree.css new file mode 100644 index 000000000..e946a8282 --- /dev/null +++ b/calendar/base/themes/linux/calendar-task-tree.css @@ -0,0 +1,26 @@ +/* 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 url(chrome://calendar-common/skin/calendar-task-tree.css); + +.calendar-task-tree > treechildren::-moz-tree-image(calendar-task-tree-col-completed), +.calendar-task-tree-col-completed { + list-style-image: url(chrome://calendar-common/skin/checkbox-images.png); +} + +.calendar-task-tree > treechildren::-moz-tree-image(calendar-task-tree-col-completed, completed), +.calendar-task-tree-col-completed { + -moz-image-region: rect(0 26px 13px 13px); +} + +.calendar-task-tree > treechildren::-moz-tree-image(calendar-task-tree-col-completed, duetoday), +.calendar-task-tree > treechildren::-moz-tree-image(calendar-task-tree-col-completed, overdue), +.calendar-task-tree > treechildren::-moz-tree-image(calendar-task-tree-col-completed, future), +.calendar-task-tree > treechildren::-moz-tree-image(calendar-task-tree-col-completed, inprogress) { + -moz-image-region: rect(0 13px 13px 0); +} + +.calendar-task-tree > treechildren::-moz-tree-image(calendar-task-tree-col-completed, repeating) { + -moz-image-region: rect(0 39px 13px 26px); +} diff --git a/calendar/base/themes/linux/calendar-task-view.css b/calendar/base/themes/linux/calendar-task-view.css new file mode 100644 index 000000000..88dcf3f19 --- /dev/null +++ b/calendar/base/themes/linux/calendar-task-view.css @@ -0,0 +1,106 @@ +/* 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 url(chrome://calendar-common/skin/calendar-task-view.css); + +#calendar-task-view-splitter { + -moz-appearance: none; + border-bottom: 1px solid ThreeDShadow; + /* splitter grip area */ + height: 5px; + /* make only the splitter border visible */ + margin-top: -5px; + /* because of the negative margin needed to make the splitter visible */ + position: relative; + z-index: 10; +} + +#task-addition-box { + height: 37px; +} + +#calendar-task-details-container { + padding-top: 1px; +} + +#other-actions-box { + margin-inline-end: -1px; +} + +.task-details-name { + color: windowtext; + opacity: 0.5; /* lower contrast */ +} + +#calendar-task-details-grid > rows > .item-date-row > .headline { + color: windowtext; + opacity: 0.5; /* lower contrast */ +} + +.task-details-value { + color: WindowText; +} + +#task-text-filter-field { + margin: 5px; +} + +/* ::::: task actions toolbar ::::: */ + +#task-actions-toolbox { + -moz-appearance: none; +} + +#task-actions-toolbar { + min-height: 0; + padding: 0; +} + +#task-actions-category { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#category); +} + +#task-actions-markcompleted { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#complete); +} + +#task-actions-priority { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#priority); +} + +#calendar-delete-task-button { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#delete); +} + +#task-actions-toolbar[brighttext] > #task-actions-category { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#category-inverted); +} + +#task-actions-toolbar[brighttext] > #task-actions-markcompleted { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#complete-inverted); +} + +#task-actions-toolbar[brighttext] > #task-actions-priority { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#priority-inverted); +} + + +#task-actions-toolbar[brighttext] > #calendar-delete-task-button { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#delete-inverted); +} + +#calendar-add-task-button { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#newtask); + -moz-image-region: rect(0 18px 18px 0); +} + +#calendar-add-task-button[disabled="true"] > .toolbarbutton-icon { + opacity: 0.4; +} + +#calendar-add-task-button > .toolbarbutton-icon { + width: 18px; + height: 18px; + margin: -1px; +} diff --git a/calendar/base/themes/linux/calendar-unifinder.css b/calendar/base/themes/linux/calendar-unifinder.css new file mode 100644 index 000000000..a51892749 --- /dev/null +++ b/calendar/base/themes/linux/calendar-unifinder.css @@ -0,0 +1,36 @@ +/* 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 url(chrome://calendar-common/skin/calendar-unifinder.css); + +/* restyle splitter-border to match Thunderbird's layout */ +#calendar-view-splitter { + -moz-appearance: none; + border-bottom: 1px solid ThreeDShadow; + /* splitter grip area */ + height: 5px; + /* make only the splitter border visible */ + margin-top: -5px; + /* because of the negative margin needed to make the splitter visible */ + position: relative; + z-index: 10; +} + +#bottom-events-box { + border-left: 1px solid ThreeDShadow; +} + +/* added for new id ..... search box ..... */ +#unifinder-searchBox { + background-color: transparent; + border-bottom: 1px solid ThreeDShadow; + height: 37px; +} + +.unifinder-closebutton { + -moz-appearance: none; + border: none; + padding: 2px; + background: transparent; +} diff --git a/calendar/base/themes/linux/calendar-views.css b/calendar/base/themes/linux/calendar-views.css new file mode 100644 index 000000000..c60d3d2d7 --- /dev/null +++ b/calendar/base/themes/linux/calendar-views.css @@ -0,0 +1,60 @@ +/* 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 url(chrome://calendar-common/skin/calendar-views.css); + +/* Navigation controls for the views */ +#calendar-nav-control { + background-color: AppWorkspace; + border-inline-start: 1px solid ThreeDShadow; + padding-top: 1px; +} + +#view-deck { + background-color: -moz-Field; + border-width: 0 0 1px 1px; +} + +tab[calview] { + background-color: rgba(0, 0, 0, .1); + color: ButtonText; + border: 1px solid ThreeDShadow; + font-size: 14px; +} + +tab[calview][selected="true"], +tab[calview][selected="true"]:hover { + background-color: -moz-Field; +} + +tab[calview]:hover { + background-color: ButtonHighlight; +} + +#calendarWeek { + margin-top: 4px; + margin-bottom: 0px; +} + +.navigation-inner-box { + border-bottom: 1px solid ThreeDShadow; +} + +.navigation-bottombox { + background-color: -moz-Field; +} + +.navigation-spacer-box { + min-width: 4px; + border-bottom: 1px solid ThreeDShadow; +} + +.view-navigation-button { + margin-inline-start: 2px; + margin-inline-end: 2px; +} + +.today-navigation-button { + padding-top: 0px !important; /* a workaround to center the label vertically on Windows */ +} diff --git a/calendar/base/themes/linux/dialogs/calendar-alarm-dialog.css b/calendar/base/themes/linux/dialogs/calendar-alarm-dialog.css new file mode 100644 index 000000000..ae4bbc79a --- /dev/null +++ b/calendar/base/themes/linux/dialogs/calendar-alarm-dialog.css @@ -0,0 +1,17 @@ +/* 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 url(chrome://calendar-common/skin/dialogs/calendar-alarm-dialog.css); + +menupopup[type="snooze-menupopup"] > arrowscrollbox { + -moz-binding: url(chrome://calendar/content/widgets/calendar-alarm-widget.xml#calendar-snooze-popup); +} + +.snooze-popup-ok-button:hover { + background-color: -moz-menuhover; +} + +.snooze-popup-cancel-button:hover { + background-color: -moz-menuhover; +} diff --git a/calendar/base/themes/linux/dialogs/calendar-event-dialog.css b/calendar/base/themes/linux/dialogs/calendar-event-dialog.css new file mode 100644 index 000000000..01bd2ebad --- /dev/null +++ b/calendar/base/themes/linux/dialogs/calendar-event-dialog.css @@ -0,0 +1,101 @@ +/* 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/. */ + +/*-------------------------------------------------------------------- + * Event dialog keep duration button + *-------------------------------------------------------------------*/ + +#keepduration-button { + min-width: 21px; +} + +#timezone-endtime { + margin-inline-start: 16px; +} + +/*-------------------------------------------------------------------- + * Event dialog toolbar buttons + *-------------------------------------------------------------------*/ + +#button-save { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#save); +} + +#button-saveandclose { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#save-close); +} + +#button-attendees { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#address); +} + +#button-privacy { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#security); +} + +#button-url { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#attach); +} + +#button-delete.cal-event-toolbarbutton { + /* !important to override the SM #button-delete states */ + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#delete) !important; + -moz-image-region: auto !important; +} + +#button-priority { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#priority); +} + +#button-status { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#status); +} + +#button-freebusy { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#freebusy); +} + +#button-timezones { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#timezones); +} + +toolbar[brighttext] #button-save { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#save-inverted); +} + +toolbar[brighttext] #button-saveandclose { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#save-close-inverted); +} + +toolbar[brighttext] #button-attendees { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#address-inverted); +} + +toolbar[brighttext] #button-privacy { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#security-inverted); +} + +toolbar[brighttext] #button-url { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#attach-inverted); +} + +toolbar[brighttext] #button-delete.cal-event-toolbarbutton { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#delete-inverted) !important; +} + +toolbar[brighttext] #button-priority { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#priority-inverted); +} + +toolbar[brighttext] #button-status { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#status-inverted); +} + +toolbar[brighttext] #button-freebusy { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#freebusy-inverted); +} + +toolbar[brighttext] #button-timezones { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#timezones-inverted); +} diff --git a/calendar/base/themes/linux/dialogs/calendar-invitations-dialog.css b/calendar/base/themes/linux/dialogs/calendar-invitations-dialog.css new file mode 100644 index 000000000..31cd81a37 --- /dev/null +++ b/calendar/base/themes/linux/dialogs/calendar-invitations-dialog.css @@ -0,0 +1,5 @@ +/* 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 url(chrome://calendar-common/skin/dialogs/calendar-invitations-dialog.css); diff --git a/calendar/base/themes/linux/dialogs/images/calendar-event-dialog-toolbar-small.png b/calendar/base/themes/linux/dialogs/images/calendar-event-dialog-toolbar-small.png Binary files differnew file mode 100644 index 000000000..d4b19241e --- /dev/null +++ b/calendar/base/themes/linux/dialogs/images/calendar-event-dialog-toolbar-small.png diff --git a/calendar/base/themes/linux/dialogs/images/calendar-event-dialog-toolbar.png b/calendar/base/themes/linux/dialogs/images/calendar-event-dialog-toolbar.png Binary files differnew file mode 100644 index 000000000..044b7ee5e --- /dev/null +++ b/calendar/base/themes/linux/dialogs/images/calendar-event-dialog-toolbar.png diff --git a/calendar/base/themes/linux/images/cal-icon24.png b/calendar/base/themes/linux/images/cal-icon24.png Binary files differnew file mode 100644 index 000000000..01cd8a61c --- /dev/null +++ b/calendar/base/themes/linux/images/cal-icon24.png diff --git a/calendar/base/themes/linux/images/cal-icon32.png b/calendar/base/themes/linux/images/cal-icon32.png Binary files differnew file mode 100644 index 000000000..1ecfba92b --- /dev/null +++ b/calendar/base/themes/linux/images/cal-icon32.png diff --git a/calendar/base/themes/linux/images/calendar-occurrence-prompt.png b/calendar/base/themes/linux/images/calendar-occurrence-prompt.png Binary files differnew file mode 100644 index 000000000..aa6042b5b --- /dev/null +++ b/calendar/base/themes/linux/images/calendar-occurrence-prompt.png diff --git a/calendar/base/themes/linux/images/tasks-actions.png b/calendar/base/themes/linux/images/tasks-actions.png Binary files differnew file mode 100644 index 000000000..c822fbd0d --- /dev/null +++ b/calendar/base/themes/linux/images/tasks-actions.png diff --git a/calendar/base/themes/linux/images/toolbar-large.png b/calendar/base/themes/linux/images/toolbar-large.png Binary files differnew file mode 100644 index 000000000..0aa38aa49 --- /dev/null +++ b/calendar/base/themes/linux/images/toolbar-large.png diff --git a/calendar/base/themes/linux/images/toolbar-small.png b/calendar/base/themes/linux/images/toolbar-small.png Binary files differnew file mode 100644 index 000000000..60da4f8de --- /dev/null +++ b/calendar/base/themes/linux/images/toolbar-small.png diff --git a/calendar/base/themes/linux/preferences/Options.png b/calendar/base/themes/linux/preferences/Options.png Binary files differnew file mode 100644 index 000000000..a62e74429 --- /dev/null +++ b/calendar/base/themes/linux/preferences/Options.png diff --git a/calendar/base/themes/linux/preferences/preferences.css b/calendar/base/themes/linux/preferences/preferences.css new file mode 100644 index 000000000..c42048849 --- /dev/null +++ b/calendar/base/themes/linux/preferences/preferences.css @@ -0,0 +1,94 @@ +/* +# -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- +# 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/. +*/ + +/* Global Styles */ +#CalendarPreferences radio[pane] { + list-style-image: url("chrome://calendar/skin/preferences/Options.png"); +} + +radio[pane=paneGeneral] { + -moz-image-region: rect(0px, 32px, 32px, 0px) +} +radio[pane=paneGeneral]:hover, radio[pane=paneGeneral][selected="true"] { + -moz-image-region: rect(32px, 32px, 64px, 0px) +} + +radio[pane=paneAlarms] { + -moz-image-region: rect(0px, 64px, 32px, 32px) +} +radio[pane=paneAlarms]:hover, radio[pane=paneAlarms][selected="true"] { + -moz-image-region: rect(32px, 64px, 64px, 32px) +} + +radio[pane=paneCategories] { + -moz-image-region: rect(0px, 128px, 32px, 96px) +} +radio[pane=paneCategories]:hover, radio[pane=paneCategories][selected="true"] { + -moz-image-region: rect(32px, 128px, 64px, 96px) +} + +radio[pane=paneViews] { + -moz-image-region: rect(0px, 192px, 32px, 160px) +} +radio[pane=paneViews]:hover, radio[pane=paneViews][selected="true"] { + -moz-image-region: rect(32px, 192px, 64px, 160px) +} + +radio[pane=paneTimezones] { + -moz-image-region: rect(0px, 224px, 32px, 192px) +} +radio[pane=paneTimezones]:hover, radio[pane=paneTimezones][selected="true"] { + -moz-image-region: rect(32px, 224px, 64px, 192px) +} + +radio[pane=paneAdvanced] { + -moz-image-region: rect(0px, 160px, 32px, 128px) +} +radio[pane=paneAdvanced]:hover, radio[pane=paneAdvanced][selected="true"] { + -moz-image-region: rect(32px, 160px, 64px, 128px) +} + +/* File Field Widget */ +filefield { + margin: 2px 4px; + -moz-appearance: textfield; +} + +.fileFieldContentBox { + background-color: -moz-Dialog; + color: -moz-DialogText; + margin: 1px; +} + +filefield[disabled="true"] .fileFieldContentBox { + opacity: 0.5; +} + +filefield[disabled="true"] .fileFieldIcon { + opacity: 0.2; +} + +.fileFieldIcon { + width: 16px; + height: 16px; + margin-top: 2px; + margin-bottom: 2px; + margin-inline-start: 2px; + margin-inline-end: 4px; +} + +.fileFieldLabel { + -moz-appearance: none; + background-color: transparent; + border: none; + padding: 1px 0px 0px; + margin: 0px; +} + +tabpanels caption { + background-color: -moz-Dialog; +} diff --git a/calendar/base/themes/linux/today-pane.css b/calendar/base/themes/linux/today-pane.css new file mode 100644 index 000000000..9ca34821d --- /dev/null +++ b/calendar/base/themes/linux/today-pane.css @@ -0,0 +1,92 @@ +/* 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 url(chrome://calendar-common/skin/today-pane.css); + +#today-pane-panel { + border-bottom: 1px solid ThreeDShadow; +} + +#today-pane-panel:-moz-lwtheme { + box-shadow: 0 1px 0 rgba(255, 255, 255, .3) inset; +} + +#today-pane-panel:-moz-lwtheme > sidebarheader { + background-image: linear-gradient(rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0) 19px); +} + +#today-pane-splitter { + -moz-appearance: none; + border-bottom: 3px double ThreeDShadow; + /* splitter grip area */ + height: 5px; + /* make only the splitter border visible */ + margin-top: -3px; + /* because of the negative margin needed to make the splitter visible */ + position: relative; + z-index: 10; +} + +.today-pane-cycler { + padding-inline-end: 0 !important; +} + +.today-pane-cycler { + list-style-image: url("chrome://calendar-common/skin/today-pane-cycler.svg#normal"); +} + +.today-pane-cycler:-moz-lwtheme-brighttext { + list-style-image: url("chrome://calendar-common/skin/today-pane-cycler.svg#inverted"); +} + +.today-pane-cycler[dir="prev"]:-moz-locale-dir(ltr) > .toolbarbutton-icon, +.today-pane-cycler[dir="next"]:-moz-locale-dir(rtl) > .toolbarbutton-icon { + transform: scaleX(-1); +} + +.today-subpane { + border-color: ThreeDShadow; +} + +#mini-day-image { + background-image: linear-gradient(transparent, rgba(0, 0, 0, .1)); +} + +.miniday-nav-buttons { + max-width: 19px; +} + +#next-day-button > .toolbarbutton-icon { + -moz-appearance: button-arrow-next; +} + +#previous-day-button > .toolbarbutton-icon { + -moz-appearance: button-arrow-previous; +} + +#today-button { + max-width: none; +} + +#today-button:hover { + list-style-image: url("chrome://calendar-common/skin/widgets/nav-today-hov.svg"); +} + +#miniday-dropdown-button { + max-width: 18px; +} + +#todaypane-new-event-button { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#newevent); +} + +#todaypane-new-event-button[disabled="true"] > .toolbarbutton-icon { + opacity: 0.4; +} + +#todaypane-new-event-button > .toolbarbutton-icon { + width: 18px; + height: 18px; + margin: -1px; +} diff --git a/calendar/base/themes/linux/widgets/calendar-widgets.css b/calendar/base/themes/linux/widgets/calendar-widgets.css new file mode 100644 index 000000000..461b78aac --- /dev/null +++ b/calendar/base/themes/linux/widgets/calendar-widgets.css @@ -0,0 +1,38 @@ +/* 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 url(chrome://calendar-common/skin/widgets/calendar-widgets.css); + +treenode-checkbox[checked="true"] > .checkbox-check { + background-image: url(chrome://global/skin/tree/twisty-open.png); +} + +treenode-checkbox > .checkbox-check { + -moz-appearance: none; + -moz-box-align: center; + border: none; + width: 9px; /* The image's width is 9 pixels */ + height: 9px; + background-image: url(chrome://global/skin/tree/twisty-clsd.png); +} + +#task-tree-filtergroup { + padding-inline-start: 12px; +} + +calendar-list-tree .tree-scrollable-columns { + padding-inline-start: 18px; +} + +.view-navigation-button:hover { + list-style-image: url(chrome://calendar-common/skin/widgets/view-navigation-hov.svg); +} + +.toolbarbutton-icon-begin { + margin-inline-end: 5px; +} + +.toolbarbutton-icon-end { + margin-inline-start: 5px; +} diff --git a/calendar/base/themes/windows/calendar-daypicker.css b/calendar/base/themes/windows/calendar-daypicker.css new file mode 100644 index 000000000..f8a3c0680 --- /dev/null +++ b/calendar/base/themes/windows/calendar-daypicker.css @@ -0,0 +1,18 @@ +/* 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 url(chrome://calendar-common/skin/calendar-daypicker.css); + +daypicker { + border-top: 1px solid ThreeDShadow; + border-left: 1px solid ThreeDShadow; +} + +daypicker[bottom="true"] { + border-bottom: 1px solid ThreeDShadow; +} + +daypicker[right="true"] { + border-right: 1px solid ThreeDShadow; +} diff --git a/calendar/base/themes/windows/calendar-management.css b/calendar/base/themes/windows/calendar-management.css new file mode 100644 index 000000000..8017f4c53 --- /dev/null +++ b/calendar/base/themes/windows/calendar-management.css @@ -0,0 +1,25 @@ +/* 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 url(chrome://calendar-common/skin/calendar-management.css); + +calendar-list-tree > tree > treechildren::-moz-tree-image(checkbox-treecol) { + list-style-image: url(chrome://calendar-common/skin/checkbox-images.png); +} + +calendar-list-tree > tree > treechildren::-moz-tree-image(checkbox-treecol) { + -moz-image-region: rect(0 13px 13px 0); +} + +calendar-list-tree > tree > treechildren::-moz-tree-image(checkbox-treecol, checked) { + -moz-image-region: rect(0 26px 13px 13px); +} + +calendar-list-tree > tree > treechildren::-moz-tree-image(checkbox-treecol, disabled) { + -moz-image-region: rect(0 39px 13px 26px); +} + +calendar-list-tree > tree > treechildren::-moz-tree-cell-text(disabled) { + color: GrayText; +} diff --git a/calendar/base/themes/windows/calendar-task-tree.css b/calendar/base/themes/windows/calendar-task-tree.css new file mode 100644 index 000000000..8bbf39b27 --- /dev/null +++ b/calendar/base/themes/windows/calendar-task-tree.css @@ -0,0 +1,82 @@ +/* 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 url(chrome://calendar-common/skin/calendar-task-tree.css); + +.calendar-task-tree > treechildren::-moz-tree-image(calendar-task-tree-col-completed), +.calendar-task-tree-col-completed { + list-style-image: url(chrome://calendar-common/skin/checkbox-images.png); +} + +.calendar-task-tree > treechildren::-moz-tree-image(calendar-task-tree-col-completed, completed), +.calendar-task-tree-col-completed { + -moz-image-region: rect(0 26px 13px 13px); +} + +.calendar-task-tree > treechildren::-moz-tree-image(calendar-task-tree-col-completed, duetoday), +.calendar-task-tree > treechildren::-moz-tree-image(calendar-task-tree-col-completed, overdue), +.calendar-task-tree > treechildren::-moz-tree-image(calendar-task-tree-col-completed, future), +.calendar-task-tree > treechildren::-moz-tree-image(calendar-task-tree-col-completed, inprogress) { + -moz-image-region: rect(0 13px 13px 0); +} + +.calendar-task-tree > treechildren::-moz-tree-image(calendar-task-tree-col-completed, repeating) { + -moz-image-region: rect(0 39px 13px 26px); +} + +/* Use on Vista and up default theme a dark text color when selected focus */ +@media not all and (-moz-os-version: windows-xp) { + @media (-moz-windows-default-theme) { + .calendar-task-tree > treechildren::-moz-tree-cell-text(selected, focus), + .calendar-task-tree > treechildren::-moz-tree-cell-text(duetoday, selected, focus), + .calendar-task-tree > treechildren::-moz-tree-cell-text(future, selected, focus), + .calendar-task-tree > treechildren::-moz-tree-cell-text(completed, selected, focus) { + color: -moz-FieldText; + } + + .calendar-task-tree > treechildren::-moz-tree-row(inprogress, selected, focus) { + -moz-border-top-colors: green rgba(255, 255, 255, .4); + -moz-border-right-colors: green rgba(255, 255, 255, .4); + -moz-border-left-colors: green rgba(255, 255, 255, .4); + -moz-border-bottom-colors: green rgba(255, 255, 255, .6); + } + + .calendar-task-tree > treechildren::-moz-tree-cell-text(inprogress, selected, focus) { + color: white; + } + + .calendar-task-tree > treechildren::-moz-tree-row(overdue, selected, focus) { + -moz-border-top-colors: red rgba(255, 255, 255, .4); + -moz-border-right-colors: red rgba(255, 255, 255, .4); + -moz-border-left-colors: red rgba(255, 255, 255, .4); + -moz-border-bottom-colors: red rgba(255, 255, 255, .6); + } + + .calendar-task-tree > treechildren::-moz-tree-cell-text(overdue, selected, focus) { + color: white; + } + + @media (-moz-os-version: windows-vista), + (-moz-os-version: windows-win7) { + .calendar-task-tree > treechildren::-moz-tree-row(inprogress, selected, focus) { + background: linear-gradient(rgba(0, 128, 0, .28), rgba(0, 128, 0, .5)); + } + + .calendar-task-tree > treechildren::-moz-tree-row(overdue, selected, focus) { + background: linear-gradient(rgba(255, 0, 0, .28), rgba(255, 0, 0, .5)); + } + } + + @media (-moz-os-version: windows-win8), + (-moz-os-version: windows-win10) { + .calendar-task-tree > treechildren::-moz-tree-row(inprogress, selected, focus) { + background: linear-gradient(rgba(0, 128, 0, .5), rgba(0, 128, 0, .5)); + } + + .calendar-task-tree > treechildren::-moz-tree-row(overdue, selected, focus) { + background: linear-gradient(rgba(255, 0, 0, .5), rgba(255, 0, 0, .5)); + } + } + } +} diff --git a/calendar/base/themes/windows/calendar-task-view.css b/calendar/base/themes/windows/calendar-task-view.css new file mode 100644 index 000000000..89b003ccd --- /dev/null +++ b/calendar/base/themes/windows/calendar-task-view.css @@ -0,0 +1,206 @@ +/* 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 url(chrome://calendar-common/skin/calendar-task-view.css); + +#calendar-task-view-splitter { + border: none; + min-height: 5px; +} + +#calendar-task-details-container { + border-top: 1px solid ThreeDShadow; + border-left: 1px solid ThreeDShadow; + border-right: 0px; + border-bottom: 0px; + overflow: hidden; +} + +#other-actions-box { + margin-inline-end: -2px; +} + +.task-details-name { + color: windowtext; + opacity: 0.5; /* lower contrast */ +} + +#calendar-task-details-grid > rows > .item-date-row > .headline { + color: windowtext; + opacity: 0.5; /* lower contrast */ +} + +.task-details-value { + color: WindowText; +} + +#task-text-filter-field { + margin-top: 5px; + margin-bottom: 5px; +} + +#task-text-filter-field .textbox-search-icons { + margin-bottom: -1px; +} + +/* ::::: task actions toolbar ::::: */ + +#task-actions-toolbox { + -moz-appearance: none; +} + +#task-actions-toolbar { + min-height: 0; + padding: 0; +} + +@media (-moz-os-version: windows-xp) { + #task-addition-box { + border-inline-start: 1px solid ThreeDShadow; + } + + #calendar-task-tree { + border-inline-start: 1px solid ThreeDShadow; + border-bottom: 1px solid ThreeDHighlight; + } + + #view-task-edit-field, + #task-text-filter-field { + margin-top: 4px; + margin-bottom: 3px; + } + + #task-actions-category { + list-style-image: url(chrome://calendar/skin/tasks-actions.png); + -moz-image-region: rect(0 16px 16px 0); + } + + #task-actions-markcompleted { + list-style-image: url(chrome://calendar/skin/tasks-actions.png); + -moz-image-region: rect(0 32px 16px 16px); + } + + #task-actions-priority { + list-style-image: url(chrome://calendar/skin/tasks-actions.png); + -moz-image-region: rect(0 48px 16px 32px); + } + + #calendar-delete-task-button { + list-style-image: url(chrome://calendar/skin/toolbar-small.png); + -moz-image-region: rect(0 48px 16px 32px); + } + + #calendar-add-task-button { + list-style-image: url(chrome://calendar/skin/toolbar-small.png); + -moz-image-region: rect(0 256px 16px 240px); + } + + #calendar-add-task-button[disabled="true"] { + -moz-image-region: rect(32px 256px 48px 240px); + } +} + +@media not all and (-moz-os-version: windows-xp) { + #task-actions-category { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#category); + } + + #task-actions-markcompleted { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#complete); + } + + #task-actions-priority { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#priority); + } + + #calendar-delete-task-button { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#delete); + } + + #task-actions-toolbar[brighttext] > #task-actions-category { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#category-inverted); + } + + #task-actions-toolbar[brighttext] > #task-actions-markcompleted { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#complete-inverted); + } + + #task-actions-toolbar[brighttext] > #task-actions-priority { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#priority-inverted); + } + + + #task-actions-toolbar[brighttext] > #calendar-delete-task-button { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#delete-inverted); + } + + #calendar-add-task-button { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#newtask); + -moz-image-region: rect(0 18px 18px 0); + } + + #calendar-add-task-button[disabled="true"] > .toolbarbutton-icon { + opacity: 0.4; + } + + #view-task-edit-field, + #task-text-filter-field { + width: 15em; + } + + #calendar-task-box #calendar-task-view-splitter { + border: none; + border-bottom: 1px solid #A9B7C9; + min-height: 0; + height: 5px; + background-color: transparent; + margin-top: -5px; + position: relative; + z-index: 10; + } + + #calendar-nav-control { + border-top-width: 0; + } + + #calendar-task-details-container { + border-top-width: 0; + padding-top: 0; + } + + @media (-moz-windows-default-theme) { + #task-addition-box { + background-color: #f8f8f8; + height: 32px; + } + } +} + +@media (-moz-os-version: windows-vista), + (-moz-os-version: windows-win7) { + #view-task-edit-field, + #task-text-filter-field { + margin-top: 4px; + margin-bottom: 4px; + } +} + +@media (-moz-windows-default-theme) and (-moz-os-version: windows-win8), + (-moz-windows-default-theme) and (-moz-os-version: windows-win10) { + #task-actions-category { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#category-flat); + } + + #task-actions-markcompleted { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#complete-flat); + } + + #task-actions-priority { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#priority-flat); + } + + #calendar-add-task-button { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#newtask-flat); + } +} diff --git a/calendar/base/themes/windows/calendar-unifinder.css b/calendar/base/themes/windows/calendar-unifinder.css new file mode 100644 index 000000000..fcc0375af --- /dev/null +++ b/calendar/base/themes/windows/calendar-unifinder.css @@ -0,0 +1,55 @@ +/* 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 url(chrome://calendar-common/skin/calendar-unifinder.css); + +#bottom-events-box { + border-inline-start: 1px solid ThreeDShadow; +} + +#unifinder-searchBox { + border-bottom: 1px solid ThreeDShadow; +} + +.unifinder-closebutton { + -moz-appearance: none; + border: none; + padding: 2px; + background: transparent; +} + +@media (-moz-os-version: windows-xp) { + #unifinder-searchBox { + background-color: transparent; + height: 30px; + } +} + +@media not all and (-moz-os-version: windows-xp) { + @media (-moz-windows-default-theme) { + #unifinder-searchBox { + background-color: #f8f8f8; + height: 33px; + } + } +} + +@media (-moz-os-version: windows-win8), + (-moz-os-version: windows-win10) { + #unifinder-searchBox { + height: 35px; + } + + .unifinder-closebutton { + -moz-image-region: rect(0 20px 20px 0); + } + + .unifinder-closebutton:hover { + -moz-image-region: rect(0 40px 20px 20px); + } + + .unifinder-closebutton:hover:active { + -moz-image-region: rect(0 60px 20px 40px); + } +} diff --git a/calendar/base/themes/windows/calendar-views.css b/calendar/base/themes/windows/calendar-views.css new file mode 100644 index 000000000..137222a18 --- /dev/null +++ b/calendar/base/themes/windows/calendar-views.css @@ -0,0 +1,75 @@ +/* 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 url(chrome://calendar-common/skin/calendar-views.css); + +/* Navigation controls for the views */ +#calendar-nav-control { + background-color: ButtonFace; + border-top: 1px solid ThreeDShadow; + border-inline-start: 1px solid ThreeDShadow; + padding-top: 1px; +} + +#view-deck { + background-color: -moz-Field; + border-width: 0; + border-inline-start: 1px; +} + +tab[calview] { + background-color: rgba(0, 0, 0, .1); + color: ButtonText; + border: 1px solid ThreeDShadow; + font-size: 14px; +} + +tab[calview][selected="true"], +tab[calview][selected="true"]:hover { + background-color: -moz-Field; +} + +tab[calview]:hover { + background-color: ButtonHighlight; +} + +#calendarWeek { + margin-top: 4px; + margin-bottom: 0px; +} + +.navigation-inner-box { + border-bottom: 1px solid ThreeDShadow; +} + +.navigation-bottombox { + background-color: -moz-Field; +} + +.navigation-spacer-box { + min-width: 4px; + border-bottom: 1px solid ThreeDShadow; +} + +.view-navigation-button { + margin-inline-start: 2px; + margin-inline-end: 2px; +} + +.today-navigation-button { + padding-top: 0px !important; /* a workaround to center the label vertically on Windows */ +} + +@media not all and (-moz-os-version: windows-xp) { + #calendar-view-box #calendar-view-splitter { + border: none; + border-bottom: 1px solid #A9B7C9; + min-height: 0; + height: 5px; + background-color: transparent; + margin-top: -5px; + position: relative; + z-index: 10; + } +} diff --git a/calendar/base/themes/windows/dialogs/calendar-alarm-dialog.css b/calendar/base/themes/windows/dialogs/calendar-alarm-dialog.css new file mode 100644 index 000000000..ae4bbc79a --- /dev/null +++ b/calendar/base/themes/windows/dialogs/calendar-alarm-dialog.css @@ -0,0 +1,17 @@ +/* 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 url(chrome://calendar-common/skin/dialogs/calendar-alarm-dialog.css); + +menupopup[type="snooze-menupopup"] > arrowscrollbox { + -moz-binding: url(chrome://calendar/content/widgets/calendar-alarm-widget.xml#calendar-snooze-popup); +} + +.snooze-popup-ok-button:hover { + background-color: -moz-menuhover; +} + +.snooze-popup-cancel-button:hover { + background-color: -moz-menuhover; +} diff --git a/calendar/base/themes/windows/dialogs/calendar-event-dialog.css b/calendar/base/themes/windows/dialogs/calendar-event-dialog.css new file mode 100644 index 000000000..fc3520d86 --- /dev/null +++ b/calendar/base/themes/windows/dialogs/calendar-event-dialog.css @@ -0,0 +1,300 @@ +/* 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/. */ + +/*-------------------------------------------------------------------- + * Event dialog keep duration button + *-------------------------------------------------------------------*/ + +#keepduration-button { + min-width: 21px; +} + +#timezone-endtime { + margin-inline-start: 16px; +} + +@media (-moz-os-version: windows-xp) { + .cal-event-toolbarbutton { + list-style-image: url("chrome://calendar/skin/calendar-event-dialog-toolbar.png"); + } + + toolbar[iconsize="small"] .cal-event-toolbarbutton { + list-style-image: url("chrome://calendar/skin/calendar-event-dialog-toolbar-small.png"); + } + + #button-attendees { + -moz-image-region: rect(0px 48px 24px 24px); + } + #button-attendees[disabled="true"] { + -moz-image-region: rect(48px 48px 72px 24px); + } + + toolbar[iconsize="small"] #button-attendees { + -moz-image-region: rect(0px 32px 16px 16px); + } + toolbar[iconsize="small"] #button-attendees[disabled="true"] { + -moz-image-region: rect(32px 32px 48px 16px); + } + + #button-url { + -moz-image-region: rect(0px 96px 24px 72px); + } + #button-url[disabled="true"] { + -moz-image-region: rect(48px 96px 72px 72px); + } + + toolbar[iconsize="small"] #button-url { + -moz-image-region: rect(0px 64px 16px 48px); + } + toolbar[iconsize="small"] #button-url[disabled="true"] { + -moz-image-region: rect(32px 64px 48px 48px); + } + + #button-privacy { + -moz-image-region: rect(0px 120px 24px 96px); + } + #button-privacy[disabled="true"] { + -moz-image-region: rect(48px 120px 72px 96px); + } + + toolbar[iconsize="small"] #button-privacy { + -moz-image-region: rect(0px 80px 16px 64px); + } + toolbar[iconsize="small"] #button-privacy[disabled="true"] { + -moz-image-region: rect(32px 80px 48px 64px); + } + + #button-save { + -moz-image-region: rect(0px 144px 24px 120px); + } + #button-save[disabled="true"] { + -moz-image-region: rect(48px 144px 72px 120px); + } + + toolbar[iconsize="small"] #button-save { + -moz-image-region: rect(0px 96px 16px 80px); + } + toolbar[iconsize="small"] #button-save[disabled="true"] { + -moz-image-region: rect(32px 96px 48px 80px); + } + + #button-saveandclose { + -moz-image-region: rect(0px 696px 24px 672px); + } + #button-saveandclose[disabled="true"] { + -moz-image-region: rect(48px 696px 72px 672px); + } + + toolbar[iconsize="small"] #button-saveandclose { + -moz-image-region: rect(0px 464px 16px 448px); + } + toolbar[iconsize="small"] #button-saveandclose[disabled="true"] { + -moz-image-region: rect(32px 464px 48px 448px); + } + + #button-delete.cal-event-toolbarbutton { + list-style-image: url("chrome://calendar/skin/calendar-event-dialog-toolbar.png"); + -moz-image-region: rect(0px 408px 24px 384px); + } + #button-delete.cal-event-toolbarbutton[disabled="true"], + #button-delete.cal-event-toolbarbutton[disabled="true"]:hover { + -moz-image-region: rect(48px 408px 72px 384px); + } + #button-delete.cal-event-toolbarbutton:hover, + #button-delete.cal-event-toolbarbutton:hover:active { + -moz-image-region: rect(24px 408px 48px 384px); + } + + toolbar[iconsize="small"] #button-delete.cal-event-toolbarbutton { + list-style-image: url("chrome://calendar/skin/calendar-event-dialog-toolbar-small.png"); + -moz-image-region: rect(0px 272px 16px 256px); + } + toolbar[iconsize="small"] #button-delete.cal-event-toolbarbutton[disabled="true"], + toolbar[iconsize="small"] #button-delete.cal-event-toolbarbutton[disabled="true"]:hover { + -moz-image-region: rect(32px 272px 48px 256px); + } + toolbar[iconsize="small"] #button-delete.cal-event-toolbarbutton:hover, + toolbar[iconsize="small"] #button-delete.cal-event-toolbarbutton:hover:active { + -moz-image-region: rect(16px 272px 32px 256px); + } + + #button-priority { + -moz-image-region: rect(0px 600px 24px 576px); + } + #button-priority[disabled="true"] { + -moz-image-region: rect(48px 600px 72px 576px); + } + + toolbar[iconsize="small"] #button-priority { + -moz-image-region: rect(0px 400px 16px 384px); + } + toolbar[iconsize="small"] #button-priority[disabled="true"] { + -moz-image-region: rect(32px 400px 48px 384px); + } + + #button-status { + -moz-image-region: rect(0px 624px 24px 600px); + } + #button-status[disabled="true"] { + -moz-image-region: rect(48px 624px 72px 600px); + } + + toolbar[iconsize="small"] #button-status { + -moz-image-region: rect(0px 416px 16px 400px); + } + toolbar[iconsize="small"] #button-status[disabled="true"] { + -moz-image-region: rect(32px 416px 48px 400px); + } + + #button-freebusy { + -moz-image-region: rect(0px 648px 24px 624px); + } + #button-freebusy[disabled="true"] { + -moz-image-region: rect(48px 648px 72px 624px); + } + + toolbar[iconsize="small"] #button-freebusy { + -moz-image-region: rect(0px 432px 16px 416px); + } + toolbar[iconsize="small"] #button-freebusy[disabled="true"] { + -moz-image-region: rect(32px 432px 48px 416px); + } + + #button-timezones { + -moz-image-region: rect(0px 672px 24px 648px); + } + #button-timezones[disabled="true"] { + -moz-image-region: rect(48px 672px 72px 648px); + } + + toolbar[iconsize="small"] #button-timezones { + -moz-image-region: rect(0px 448px 16px 432px); + } + toolbar[iconsize="small"] #button-timezones[disabled="true"] { + -moz-image-region: rect(32px 448px 48px 432px); + } +} + +@media not all and (-moz-os-version: windows-xp) { + #button-save { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#save); + } + + #button-saveandclose { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#save-close); + } + + #button-attendees { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#address); + } + + #button-privacy { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#security); + } + + #button-url { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#attach); + } + + #button-delete.cal-event-toolbarbutton { + /* !important to override the SM #button-delete states */ + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#delete) !important; + -moz-image-region: auto !important; + } + + #button-priority { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#priority); + } + + #button-status { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#status); + } + + #button-freebusy { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#freebusy); + } + + #button-timezones { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#timezones); + } + + toolbar[brighttext] #button-save { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#save-inverted); + } + + toolbar[brighttext] #button-saveandclose { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#save-close-inverted); + } + + toolbar[brighttext] #button-attendees { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#address-inverted); + } + + toolbar[brighttext] #button-privacy { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#security-inverted); + } + + toolbar[brighttext] #button-url { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#attach-inverted); + } + + toolbar[brighttext] #button-delete.cal-event-toolbarbutton { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#delete-inverted) !important; + } + + toolbar[brighttext] #button-priority { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#priority-inverted); + } + + toolbar[brighttext] #button-status { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#status-inverted); + } + + toolbar[brighttext] #button-freebusy { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#freebusy-inverted); + } + + toolbar[brighttext] #button-timezones { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#timezones-inverted); + } + + #calendar-event-dialog > #event-toolbox > #event-toolbar { + padding-bottom: 2px; + } +} + +@media (-moz-windows-default-theme) and (-moz-os-version: windows-win8), + (-moz-windows-default-theme) and (-moz-os-version: windows-win10) { + #button-save { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#save-flat); + } + + #button-saveandclose { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#save-close-flat); + } + + #button-attendees { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#address-flat); + } + + #button-privacy { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#security-flat); + } + + #button-priority { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#priority-flat); + } + + #button-status { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#status-flat); + } + + #button-freebusy { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#freebusy-flat); + } + + #button-timezones { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#timezones-flat); + } +} diff --git a/calendar/base/themes/windows/dialogs/calendar-invitations-dialog.css b/calendar/base/themes/windows/dialogs/calendar-invitations-dialog.css new file mode 100644 index 000000000..31cd81a37 --- /dev/null +++ b/calendar/base/themes/windows/dialogs/calendar-invitations-dialog.css @@ -0,0 +1,5 @@ +/* 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 url(chrome://calendar-common/skin/dialogs/calendar-invitations-dialog.css); diff --git a/calendar/base/themes/windows/dialogs/images/calendar-event-dialog-toolbar-aero.png b/calendar/base/themes/windows/dialogs/images/calendar-event-dialog-toolbar-aero.png Binary files differnew file mode 100644 index 000000000..4790c21be --- /dev/null +++ b/calendar/base/themes/windows/dialogs/images/calendar-event-dialog-toolbar-aero.png diff --git a/calendar/base/themes/windows/dialogs/images/calendar-event-dialog-toolbar-inverted.png b/calendar/base/themes/windows/dialogs/images/calendar-event-dialog-toolbar-inverted.png Binary files differnew file mode 100644 index 000000000..3cda77303 --- /dev/null +++ b/calendar/base/themes/windows/dialogs/images/calendar-event-dialog-toolbar-inverted.png diff --git a/calendar/base/themes/windows/dialogs/images/calendar-event-dialog-toolbar-small.png b/calendar/base/themes/windows/dialogs/images/calendar-event-dialog-toolbar-small.png Binary files differnew file mode 100644 index 000000000..bc89f805b --- /dev/null +++ b/calendar/base/themes/windows/dialogs/images/calendar-event-dialog-toolbar-small.png diff --git a/calendar/base/themes/windows/dialogs/images/calendar-event-dialog-toolbar.png b/calendar/base/themes/windows/dialogs/images/calendar-event-dialog-toolbar.png Binary files differnew file mode 100644 index 000000000..884e3eabd --- /dev/null +++ b/calendar/base/themes/windows/dialogs/images/calendar-event-dialog-toolbar.png diff --git a/calendar/base/themes/windows/images/cal-icon24.png b/calendar/base/themes/windows/images/cal-icon24.png Binary files differnew file mode 100644 index 000000000..c17924e85 --- /dev/null +++ b/calendar/base/themes/windows/images/cal-icon24.png diff --git a/calendar/base/themes/windows/images/cal-icon32.png b/calendar/base/themes/windows/images/cal-icon32.png Binary files differnew file mode 100644 index 000000000..6601ed9fa --- /dev/null +++ b/calendar/base/themes/windows/images/cal-icon32.png diff --git a/calendar/base/themes/windows/images/calendar-occurrence-prompt-aero.png b/calendar/base/themes/windows/images/calendar-occurrence-prompt-aero.png Binary files differnew file mode 100644 index 000000000..1daf447d4 --- /dev/null +++ b/calendar/base/themes/windows/images/calendar-occurrence-prompt-aero.png diff --git a/calendar/base/themes/windows/images/calendar-occurrence-prompt.png b/calendar/base/themes/windows/images/calendar-occurrence-prompt.png Binary files differnew file mode 100644 index 000000000..09ece5a13 --- /dev/null +++ b/calendar/base/themes/windows/images/calendar-occurrence-prompt.png diff --git a/calendar/base/themes/windows/images/tasks-actions-aero.png b/calendar/base/themes/windows/images/tasks-actions-aero.png Binary files differnew file mode 100644 index 000000000..db61b7f54 --- /dev/null +++ b/calendar/base/themes/windows/images/tasks-actions-aero.png diff --git a/calendar/base/themes/windows/images/tasks-actions-inverted.png b/calendar/base/themes/windows/images/tasks-actions-inverted.png Binary files differnew file mode 100644 index 000000000..d13e1b1be --- /dev/null +++ b/calendar/base/themes/windows/images/tasks-actions-inverted.png diff --git a/calendar/base/themes/windows/images/tasks-actions.png b/calendar/base/themes/windows/images/tasks-actions.png Binary files differnew file mode 100644 index 000000000..42f2c97ba --- /dev/null +++ b/calendar/base/themes/windows/images/tasks-actions.png diff --git a/calendar/base/themes/windows/images/toolbar-aero-inverted.png b/calendar/base/themes/windows/images/toolbar-aero-inverted.png Binary files differnew file mode 100644 index 000000000..ff1ce4be4 --- /dev/null +++ b/calendar/base/themes/windows/images/toolbar-aero-inverted.png diff --git a/calendar/base/themes/windows/images/toolbar-aero.png b/calendar/base/themes/windows/images/toolbar-aero.png Binary files differnew file mode 100644 index 000000000..a2a73e513 --- /dev/null +++ b/calendar/base/themes/windows/images/toolbar-aero.png diff --git a/calendar/base/themes/windows/images/toolbar-large-aero.png b/calendar/base/themes/windows/images/toolbar-large-aero.png Binary files differnew file mode 100644 index 000000000..2b342b6ac --- /dev/null +++ b/calendar/base/themes/windows/images/toolbar-large-aero.png diff --git a/calendar/base/themes/windows/images/toolbar-large.png b/calendar/base/themes/windows/images/toolbar-large.png Binary files differnew file mode 100644 index 000000000..16eae13ee --- /dev/null +++ b/calendar/base/themes/windows/images/toolbar-large.png diff --git a/calendar/base/themes/windows/images/toolbar-small-aero.png b/calendar/base/themes/windows/images/toolbar-small-aero.png Binary files differnew file mode 100644 index 000000000..8f1ad149d --- /dev/null +++ b/calendar/base/themes/windows/images/toolbar-small-aero.png diff --git a/calendar/base/themes/windows/images/toolbar-small.png b/calendar/base/themes/windows/images/toolbar-small.png Binary files differnew file mode 100644 index 000000000..a0aea24a7 --- /dev/null +++ b/calendar/base/themes/windows/images/toolbar-small.png diff --git a/calendar/base/themes/windows/preferences/Options.png b/calendar/base/themes/windows/preferences/Options.png Binary files differnew file mode 100644 index 000000000..a62e74429 --- /dev/null +++ b/calendar/base/themes/windows/preferences/Options.png diff --git a/calendar/base/themes/windows/preferences/preferences.css b/calendar/base/themes/windows/preferences/preferences.css new file mode 100644 index 000000000..c42048849 --- /dev/null +++ b/calendar/base/themes/windows/preferences/preferences.css @@ -0,0 +1,94 @@ +/* +# -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- +# 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/. +*/ + +/* Global Styles */ +#CalendarPreferences radio[pane] { + list-style-image: url("chrome://calendar/skin/preferences/Options.png"); +} + +radio[pane=paneGeneral] { + -moz-image-region: rect(0px, 32px, 32px, 0px) +} +radio[pane=paneGeneral]:hover, radio[pane=paneGeneral][selected="true"] { + -moz-image-region: rect(32px, 32px, 64px, 0px) +} + +radio[pane=paneAlarms] { + -moz-image-region: rect(0px, 64px, 32px, 32px) +} +radio[pane=paneAlarms]:hover, radio[pane=paneAlarms][selected="true"] { + -moz-image-region: rect(32px, 64px, 64px, 32px) +} + +radio[pane=paneCategories] { + -moz-image-region: rect(0px, 128px, 32px, 96px) +} +radio[pane=paneCategories]:hover, radio[pane=paneCategories][selected="true"] { + -moz-image-region: rect(32px, 128px, 64px, 96px) +} + +radio[pane=paneViews] { + -moz-image-region: rect(0px, 192px, 32px, 160px) +} +radio[pane=paneViews]:hover, radio[pane=paneViews][selected="true"] { + -moz-image-region: rect(32px, 192px, 64px, 160px) +} + +radio[pane=paneTimezones] { + -moz-image-region: rect(0px, 224px, 32px, 192px) +} +radio[pane=paneTimezones]:hover, radio[pane=paneTimezones][selected="true"] { + -moz-image-region: rect(32px, 224px, 64px, 192px) +} + +radio[pane=paneAdvanced] { + -moz-image-region: rect(0px, 160px, 32px, 128px) +} +radio[pane=paneAdvanced]:hover, radio[pane=paneAdvanced][selected="true"] { + -moz-image-region: rect(32px, 160px, 64px, 128px) +} + +/* File Field Widget */ +filefield { + margin: 2px 4px; + -moz-appearance: textfield; +} + +.fileFieldContentBox { + background-color: -moz-Dialog; + color: -moz-DialogText; + margin: 1px; +} + +filefield[disabled="true"] .fileFieldContentBox { + opacity: 0.5; +} + +filefield[disabled="true"] .fileFieldIcon { + opacity: 0.2; +} + +.fileFieldIcon { + width: 16px; + height: 16px; + margin-top: 2px; + margin-bottom: 2px; + margin-inline-start: 2px; + margin-inline-end: 4px; +} + +.fileFieldLabel { + -moz-appearance: none; + background-color: transparent; + border: none; + padding: 1px 0px 0px; + margin: 0px; +} + +tabpanels caption { + background-color: -moz-Dialog; +} diff --git a/calendar/base/themes/windows/today-pane.css b/calendar/base/themes/windows/today-pane.css new file mode 100644 index 000000000..b124d913c --- /dev/null +++ b/calendar/base/themes/windows/today-pane.css @@ -0,0 +1,165 @@ +/* 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 url(chrome://calendar-common/skin/today-pane.css); + +@media (-moz-os-version: windows-xp), + (-moz-os-version: windows-vista), + (-moz-os-version: windows-win7) { + #today-pane-panel:-moz-lwtheme { + box-shadow: 0 1px 0 rgba(253, 253, 253, 0.45) inset; + } +} + +#today-pane-panel:-moz-lwtheme > sidebarheader { + background-image: linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0) 19px); +} + +.today-pane-cycler { + list-style-image: url("chrome://calendar-common/skin/today-pane-cycler.svg#normal"); +} + +.today-pane-cycler:-moz-lwtheme-brighttext { + list-style-image: url("chrome://calendar-common/skin/today-pane-cycler.svg#inverted"); +} + +.today-pane-cycler[dir="prev"]:-moz-locale-dir(ltr) > .toolbarbutton-icon, +.today-pane-cycler[dir="next"]:-moz-locale-dir(rtl) > .toolbarbutton-icon { + transform: scaleX(-1); +} + +.today-subpane { + border-color: ThreeDShadow; +} + +#mini-day-image { + background-image: linear-gradient(transparent, rgba(0, 0, 0, .1)); +} + +@media (-moz-os-version: windows-win8), + (-moz-os-version: windows-win10) { + #mini-day-image { + background-image: none; + } +} + +@media (-moz-os-version: windows-win8) { + #mini-day-box { + padding-top: 1px; + padding-bottom: 1px; + } +} + +.miniday-nav-buttons { + list-style-image: url("chrome://calendar-common/skin/widgets/nav-arrow.svg"); +} + +#previous-day-button:-moz-locale-dir(ltr), +#next-day-button:-moz-locale-dir(rtl) { + transform: scaleX(-1); +} + +#miniday-dropdown-button { + max-width: 18px; +} + +@media (-moz-os-version: windows-xp) { + #today-pane-panel { + border-left: 1px solid ThreeDShadow; + } + + .today-pane-cycler { + padding-inline-end: 0; + } + + #todaypane-new-event-button { + list-style-image: url("chrome://calendar/skin/toolbar-small.png"); + -moz-image-region: rect(0px 16px 16px 0px); + } + #todaypane-new-event-button[disabled="true"] { + -moz-image-region: rect(32px 16px 48px 0px); + } +} + +@media not all and (-moz-os-version: windows-xp) { + #today-none-box { + border-top: 1px solid ThreeDShadow; + } + + .today-pane-cycler { + padding-inline-start: 5px; + padding-inline-end: 5px; + } + + #todaypane-new-event-button { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#newevent); + } + + #todaypane-new-event-button[disabled="true"] > .toolbarbutton-icon { + opacity: 0.4; + } + + #todaypane-new-event-button > .toolbarbutton-icon { + width: 18px; + height: 18px; + margin: -1px; + } + + @media (-moz-windows-default-theme) { + #today-pane-panel > sidebarheader { + -moz-appearance: none; + background-color: #f8f8f8; + border-bottom: none; + } + + #today-pane-panel:-moz-lwtheme > sidebarheader { + background-color: rgba(255, 255, 255, 0.3); + background-image: linear-gradient(rgba(255, 255, 255, 0.5), + rgba(255, 255, 255, 0) 28px); + border-top: 1px solid rgba(253, 253, 253, 0.45); + } + + sidebarheader > spacer { + min-height: 25px; + } + } +} + +@media all and (-moz-windows-compositor) { + @media not all and (-moz-os-version: windows-win10) { + #messengerWindow[sizemode=normal] #today-pane-panel { + border-inline-end: 1px solid rgba(10%, 10%, 10%, .4); + border-bottom: 1px solid rgba(10%, 10%, 10%, .4); + background-clip: padding-box; + } + } + + .today-pane-cycler { + margin-top: -1px; + } +} + +@media (-moz-os-version: windows-win8), + (-moz-os-version: windows-win10) { + .today-closebutton { + padding-top: 0; + padding-bottom: 0; + -moz-image-region: rect(0 20px 20px 0); + } + + .today-closebutton:hover { + -moz-image-region: rect(0 40px 20px 20px); + } + + .today-closebutton:hover:active { + -moz-image-region: rect(0 60px 20px 40px); + } +} + +@media (-moz-windows-default-theme) and (-moz-os-version: windows-win8), + (-moz-windows-default-theme) and (-moz-os-version: windows-win10) { + #todaypane-new-event-button { + list-style-image: url(chrome://calendar-common/skin/calendar-toolbar.svg#newevent-flat); + } +} diff --git a/calendar/base/themes/windows/widgets/calendar-widgets.css b/calendar/base/themes/windows/widgets/calendar-widgets.css new file mode 100644 index 000000000..80564bf1f --- /dev/null +++ b/calendar/base/themes/windows/widgets/calendar-widgets.css @@ -0,0 +1,60 @@ +/* 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 url(chrome://calendar-common/skin/widgets/calendar-widgets.css); + +treenode-checkbox > .checkbox-check { + -moz-appearance: none; + -moz-box-align: center; + border: none; + width: 9px; /* The image's width is 9 pixels */ + height: 9px; + background-image: url(chrome://global/skin/tree/twisty.svg#clsd); +} + +treenode-checkbox[checked="true"] > .checkbox-check { + background-image: url(chrome://global/skin/tree/twisty.svg#open); +} + +@media not all and (-moz-os-version: windows-xp) { + treenode-checkbox:hover > .checkbox-check { + background-image: url(chrome://global/skin/tree/twisty.svg#clsd-hover); + } + + treenode-checkbox[checked="true"]:hover > .checkbox-check { + background-image: url(chrome://global/skin/tree/twisty.svg#open-hover); + } + + treenode-checkbox:-moz-locale-dir(rtl) > .checkbox-check { + background-image: url(chrome://global/skin/tree/twisty.svg#clsd-rtl); + } + + treenode-checkbox[checked="true"]:-moz-locale-dir(rtl) > .checkbox-check { + background-image: url(chrome://global/skin/tree/twisty.svg#open-rtl); + } + + treenode-checkbox:-moz-locale-dir(rtl):hover > .checkbox-check { + background-image: url(chrome://global/skin/tree/twisty.svg#clsd-hover-rtl); + } + + treenode-checkbox[checked="true"]:-moz-locale-dir(rtl):hover > .checkbox-check { + background-image: url(chrome://global/skin/tree/twisty.svg#open-hover-rtl); + } +} + +#task-tree-filtergroup { + padding-inline-start: 12px; +} + +calendar-list-tree .tree-scrollable-columns { + padding-inline-start: 18px; +} + +.toolbarbutton-icon-begin { + margin-inline-end: 5px; +} + +.toolbarbutton-icon-end { + margin-inline-start: 5px; +} |