summaryrefslogtreecommitdiffstats
path: root/comm/calendar/base/content/dialogs/calendar-ics-file-dialog.js
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--comm/calendar/base/content/dialogs/calendar-ics-file-dialog.js476
1 files changed, 476 insertions, 0 deletions
diff --git a/comm/calendar/base/content/dialogs/calendar-ics-file-dialog.js b/comm/calendar/base/content/dialogs/calendar-ics-file-dialog.js
new file mode 100644
index 0000000000..41c85eeea9
--- /dev/null
+++ b/comm/calendar/base/content/dialogs/calendar-ics-file-dialog.js
@@ -0,0 +1,476 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/* globals addMenuItem, getItemsFromIcsFile, putItemsIntoCal,
+ sortCalendarArray */
+
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+const gModel = {
+ /** @type {calICalendar[]} */
+ calendars: [],
+
+ /** @type {Map(number -> calIItemBase)} */
+ itemsToImport: new Map(),
+
+ /** @type {nsIFile | null} */
+ file: null,
+
+ /** @type {Map(number -> CalendarItemSummary)} */
+ itemSummaries: new Map(),
+};
+
+/**
+ * Window load event handler.
+ */
+async function onWindowLoad() {
+ // Workaround to add padding to the dialog buttons area which is in shadow dom.
+ // If the padding value changes here it should also change in the CSS.
+ let dialog = document.getElementsByTagName("dialog")[0];
+ dialog.shadowRoot.querySelector(".dialog-button-box").style = "padding-inline: 10px;";
+
+ gModel.file = window.arguments[0];
+ document.getElementById("calendar-ics-file-dialog-file-path").value = gModel.file.path;
+
+ let calendars = cal.manager.getCalendars();
+ gModel.calendars = getCalendarsThatCanImport(calendars);
+ if (!gModel.calendars.length) {
+ // No calendars to import into. Show error dialog and close the window.
+ cal.showError(await document.l10n.formatValue("calendar-ics-file-dialog-no-calendars"), window);
+ window.close();
+ return;
+ }
+
+ let composite = cal.view.getCompositeCalendar(window);
+ let defaultCalendarId = composite && composite.defaultCalendar?.id;
+ setUpCalendarMenu(gModel.calendars, defaultCalendarId);
+ cal.view.colorTracker.registerWindow(window);
+
+ // Finish laying out and displaying the window, then come back to do the hard work.
+ Services.tm.dispatchToMainThread(async () => {
+ let startTime = Date.now();
+
+ getItemsFromIcsFile(gModel.file).forEach((item, index) => {
+ gModel.itemsToImport.set(index, item);
+ });
+ if (gModel.itemsToImport.size == 0) {
+ // No items to import, close the window. An error dialog has already been
+ // shown by `getItemsFromIcsFile`.
+ window.close();
+ return;
+ }
+
+ // We know that if `getItemsFromIcsFile` took a long time, then `setUpItemSummaries` will also
+ // take a long time. Show a loading message so the user knows something is happening.
+ let loadingMessage = document.getElementById("calendar-ics-file-dialog-items-loading-message");
+ if (Date.now() - startTime > 150) {
+ loadingMessage.removeAttribute("hidden");
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ }
+
+ // Not much point filtering or sorting if there's only one event.
+ if (gModel.itemsToImport.size == 1) {
+ document.getElementById("calendar-ics-file-dialog-filters").collapsed = true;
+ }
+
+ await setUpItemSummaries();
+
+ // Remove the loading message from the DOM to avoid it causing problems later.
+ loadingMessage.remove();
+
+ document.addEventListener("dialogaccept", importRemainingItems);
+ });
+}
+window.addEventListener("load", onWindowLoad);
+
+/**
+ * Takes an array of calendars and returns a sorted array of the calendars
+ * that can import items.
+ *
+ * @param {calICalendar[]} calendars - An array of calendars.
+ * @returns {calICalendar[]} Sorted array of calendars that can import items.
+ */
+function getCalendarsThatCanImport(calendars) {
+ let calendarsThatCanImport = calendars.filter(
+ calendar =>
+ !calendar.getProperty("disabled") &&
+ !calendar.readOnly &&
+ cal.acl.userCanAddItemsToCalendar(calendar)
+ );
+ return sortCalendarArray(calendarsThatCanImport);
+}
+
+/**
+ * Add calendars to the calendar drop down menu, and select one.
+ *
+ * @param {calICalendar[]} calendars - An array of calendars.
+ * @param {string | null} defaultCalendarId - ID of the default (currently selected) calendar.
+ */
+function setUpCalendarMenu(calendars, defaultCalendarId) {
+ let menulist = document.getElementById("calendar-ics-file-dialog-calendar-menu");
+ for (let calendar of calendars) {
+ let menuitem = addMenuItem(menulist, calendar.name, calendar.name);
+ let cssSafeId = cal.view.formatStringForCSSRule(calendar.id);
+ menuitem.style.setProperty("--item-color", `var(--calendar-${cssSafeId}-backcolor)`);
+ menuitem.classList.add("menuitem-iconic");
+ }
+
+ let index = defaultCalendarId
+ ? calendars.findIndex(calendar => calendar.id == defaultCalendarId)
+ : 0;
+
+ menulist.selectedIndex = index == -1 ? 0 : index;
+ updateCalendarMenu();
+}
+
+/**
+ * Update to reflect a change in the selected calendar.
+ */
+function updateCalendarMenu() {
+ let menulist = document.getElementById("calendar-ics-file-dialog-calendar-menu");
+ menulist.style.setProperty(
+ "--item-color",
+ menulist.selectedItem.style.getPropertyValue("--item-color")
+ );
+}
+
+/**
+ * Display summaries of each calendar item from the file being imported.
+ */
+async function setUpItemSummaries() {
+ let items = [...gModel.itemsToImport];
+ let itemsContainer = document.getElementById("calendar-ics-file-dialog-items-container");
+
+ // Sort the items, chronologically first, tasks without a date to the end,
+ // then alphabetically.
+ let collator = new Intl.Collator(undefined, { numeric: true });
+ items.sort(([, a], [, b]) => {
+ let aStartDate =
+ a.startDate?.nativeTime ||
+ a.entryDate?.nativeTime ||
+ a.dueDate?.nativeTime ||
+ Number.MAX_SAFE_INTEGER;
+ let bStartDate =
+ b.startDate?.nativeTime ||
+ b.entryDate?.nativeTime ||
+ b.dueDate?.nativeTime ||
+ Number.MAX_SAFE_INTEGER;
+ return aStartDate - bStartDate || collator.compare(a.title, b.title);
+ });
+
+ let [eventButtonText, taskButtonText] = await document.l10n.formatValues([
+ "calendar-ics-file-dialog-import-event-button-label",
+ "calendar-ics-file-dialog-import-task-button-label",
+ ]);
+
+ items.forEach(([index, item]) => {
+ let itemFrame = document.createXULElement("vbox");
+ itemFrame.classList.add("calendar-ics-file-dialog-item-frame");
+
+ let importButton = document.createXULElement("button");
+ importButton.classList.add("calendar-ics-file-dialog-item-import-button");
+ importButton.setAttribute("label", item.isEvent() ? eventButtonText : taskButtonText);
+ importButton.addEventListener("command", importSingleItem.bind(null, item, index));
+
+ let buttonBox = document.createXULElement("hbox");
+ buttonBox.setAttribute("pack", "end");
+ buttonBox.setAttribute("align", "end");
+
+ let summary = document.createXULElement("calendar-item-summary");
+ summary.setAttribute("id", "import-item-summary-" + index);
+
+ itemFrame.appendChild(summary);
+ buttonBox.appendChild(importButton);
+ itemFrame.appendChild(buttonBox);
+
+ itemsContainer.appendChild(itemFrame);
+ summary.item = item;
+
+ summary.updateItemDetails();
+ gModel.itemSummaries.set(index, summary);
+ });
+}
+
+/**
+ * Filter item summaries by search string.
+ *
+ * @param {searchString} [searchString] - Terms to search for.
+ */
+function filterItemSummaries(searchString = "") {
+ let itemsContainer = document.getElementById("calendar-ics-file-dialog-items-container");
+
+ searchString = searchString.trim();
+ // Nothing to search for. Display all item summaries.
+ if (!searchString) {
+ gModel.itemSummaries.forEach(s => {
+ s.closest(".calendar-ics-file-dialog-item-frame").hidden = false;
+ });
+
+ itemsContainer.scrollTo(0, 0);
+ return;
+ }
+
+ searchString = searchString.toLowerCase().normalize();
+
+ // Split the search string into tokens. Quoted strings are preserved.
+ let searchTokens = [];
+ let startIndex;
+ while ((startIndex = searchString.indexOf('"')) != -1) {
+ let endIndex = searchString.indexOf('"', startIndex + 1);
+ if (endIndex == -1) {
+ endIndex = searchString.length;
+ }
+
+ searchTokens.push(searchString.substring(startIndex + 1, endIndex));
+ let query = searchString.substring(0, startIndex);
+ if (endIndex < searchString.length) {
+ query += searchString.substr(endIndex + 1);
+ }
+
+ searchString = query.trim();
+ }
+
+ if (searchString.length != 0) {
+ searchTokens = searchTokens.concat(searchString.split(/\s+/));
+ }
+
+ // Check the title and description of each item for matches.
+ gModel.itemSummaries.forEach(s => {
+ let title, description;
+ let matches = searchTokens.every(term => {
+ if (title === undefined) {
+ title = s.item.title.toLowerCase().normalize();
+ }
+ if (title?.includes(term)) {
+ return true;
+ }
+
+ if (description === undefined) {
+ description = s.item.getProperty("description")?.toLowerCase().normalize();
+ }
+ return description?.includes(term);
+ });
+ s.closest(".calendar-ics-file-dialog-item-frame").hidden = !matches;
+ });
+
+ itemsContainer.scrollTo(0, 0);
+}
+
+/**
+ * Sort item summaries.
+ *
+ * @param {Event} event - The oncommand event that triggered this sort.
+ */
+function sortItemSummaries(event) {
+ let [key, direction] = event.target.value.split(" ");
+
+ let comparer;
+ if (key == "title") {
+ let collator = new Intl.Collator(undefined, { numeric: true });
+ if (direction == "ascending") {
+ comparer = (a, b) => collator.compare(a.item.title, b.item.title);
+ } else {
+ comparer = (a, b) => collator.compare(b.item.title, a.item.title);
+ }
+ } else if (key == "start") {
+ if (direction == "ascending") {
+ comparer = (a, b) => a.item.startDate.nativeTime - b.item.startDate.nativeTime;
+ } else {
+ comparer = (a, b) => b.item.startDate.nativeTime - a.item.startDate.nativeTime;
+ }
+ } else {
+ // How did we get here?
+ throw new Error(`Unexpected sort key: ${key}`);
+ }
+
+ let items = [...gModel.itemSummaries.values()].sort(comparer);
+ let itemsContainer = document.getElementById("calendar-ics-file-dialog-items-container");
+ for (let item of items) {
+ itemsContainer.appendChild(item.closest(".calendar-ics-file-dialog-item-frame"));
+ }
+ itemsContainer.scrollTo(0, 0);
+
+ for (let menuitem of document.querySelectorAll(
+ "#calendar-ics-file-dialog-sort-popup > menuitem"
+ )) {
+ menuitem.checked = menuitem == event.target;
+ }
+}
+
+/**
+ * Get the currently selected calendar.
+ *
+ * @returns {calICalendar} The currently selected calendar.
+ */
+function getCurrentlySelectedCalendar() {
+ let menulist = document.getElementById("calendar-ics-file-dialog-calendar-menu");
+ let calendar = gModel.calendars[menulist.selectedIndex];
+ return calendar;
+}
+
+/**
+ * Handler for buttons that import a single item. The arguments are bound for
+ * each button instance, except for the event argument.
+ *
+ * @param {calIItemBase} item - Calendar item.
+ * @param {number} itemIndex - Index of the calendar item in the item array.
+ * @param {string} filePath - Path to the file being imported.
+ * @param {Event} event - The button event.
+ */
+async function importSingleItem(item, itemIndex, event) {
+ let dialog = document.getElementsByTagName("dialog")[0];
+ let acceptButton = dialog.getButton("accept");
+ let cancelButton = dialog.getButton("cancel");
+
+ acceptButton.disabled = true;
+ cancelButton.disabled = true;
+
+ let calendar = getCurrentlySelectedCalendar();
+
+ await putItemsIntoCal(calendar, [item], {
+ onDuplicate(item, error) {
+ // TODO: CalCalendarManager already shows a not-very-useful error pop-up.
+ // Once that is fixed, use this callback to display a proper error message.
+ },
+ onError(item, error) {
+ // TODO: CalCalendarManager already shows a not-very-useful error pop-up.
+ // Once that is fixed, use this callback to display a proper error message.
+ },
+ });
+
+ event.target.closest(".calendar-ics-file-dialog-item-frame").remove();
+ gModel.itemsToImport.delete(itemIndex);
+ gModel.itemSummaries.delete(itemIndex);
+
+ acceptButton.disabled = false;
+ if (gModel.itemsToImport.size > 0) {
+ // Change the cancel button label to Close, as we've done some work that
+ // won't be cancelled.
+ cancelButton.label = await document.l10n.formatValue(
+ "calendar-ics-file-cancel-button-close-label"
+ );
+ cancelButton.disabled = false;
+ } else {
+ // No more items to import, remove the "Import All" option.
+ document.removeEventListener("dialogaccept", importRemainingItems);
+
+ cancelButton.hidden = true;
+ acceptButton.label = await document.l10n.formatValue(
+ "calendar-ics-file-accept-button-ok-label"
+ );
+ }
+}
+
+/**
+ * "Import All" button command handler.
+ *
+ * @param {Event} event - Button command event.
+ */
+async function importRemainingItems(event) {
+ event.preventDefault();
+
+ let dialog = document.getElementsByTagName("dialog")[0];
+ let acceptButton = dialog.getButton("accept");
+ let cancelButton = dialog.getButton("cancel");
+
+ acceptButton.disabled = true;
+ cancelButton.disabled = true;
+
+ let calendar = getCurrentlySelectedCalendar();
+ let filteredSummaries = [...gModel.itemSummaries.values()].filter(
+ summary => !summary.closest(".calendar-ics-file-dialog-item-frame").hidden
+ );
+ let remainingItems = filteredSummaries.map(summary => summary.item);
+
+ let progressElement = document.getElementById("calendar-ics-file-dialog-progress");
+ let duplicatesElement = document.getElementById("calendar-ics-file-dialog-duplicates-message");
+ let errorsElement = document.getElementById("calendar-ics-file-dialog-errors-message");
+
+ let optionsPane = document.getElementById("calendar-ics-file-dialog-options-pane");
+ let progressPane = document.getElementById("calendar-ics-file-dialog-progress-pane");
+ let resultPane = document.getElementById("calendar-ics-file-dialog-result-pane");
+
+ let importListener = {
+ count: 0,
+ duplicatesCount: 0,
+ errorsCount: 0,
+ progressInterval: null,
+
+ onStart() {
+ progressElement.max = remainingItems.length;
+ optionsPane.hidden = true;
+ progressPane.hidden = false;
+
+ this.progressInterval = setInterval(() => {
+ progressElement.value = this.count;
+ }, 50);
+ },
+ onDuplicate(item, error) {
+ this.duplicatesCount++;
+ },
+ onError(item, error) {
+ this.errorsCount++;
+ },
+ onProgress(count, total) {
+ this.count = count;
+ },
+ async onEnd() {
+ progressElement.value = this.count;
+ clearInterval(this.progressInterval);
+
+ document.l10n.setAttributes(duplicatesElement, "calendar-ics-file-import-duplicates", {
+ duplicatesCount: this.duplicatesCount,
+ });
+ duplicatesElement.hidden = this.duplicatesCount == 0;
+ document.l10n.setAttributes(errorsElement, "calendar-ics-file-import-errors", {
+ errorsCount: this.errorsCount,
+ });
+ errorsElement.hidden = this.errorsCount == 0;
+
+ let [acceptButtonLabel, cancelButtonLabel] = await document.l10n.formatValues([
+ { id: "calendar-ics-file-accept-button-ok-label" },
+ { id: "calendar-ics-file-cancel-button-close-label" },
+ ]);
+
+ filteredSummaries.forEach(summary => {
+ let itemIndex = parseInt(summary.id.substring("import-item-summary-".length), 10);
+ gModel.itemsToImport.delete(itemIndex);
+ gModel.itemSummaries.delete(itemIndex);
+ summary.closest(".calendar-ics-file-dialog-item-frame").remove();
+ });
+
+ document.getElementById("calendar-ics-file-dialog-search-input").value = "";
+ filterItemSummaries();
+ let itemsRemain = !!document.querySelector(".calendar-ics-file-dialog-item-frame");
+
+ // An artificial delay so the progress pane doesn't appear then immediately disappear.
+ setTimeout(() => {
+ if (itemsRemain) {
+ acceptButton.disabled = false;
+ cancelButton.label = cancelButtonLabel;
+ cancelButton.disabled = false;
+ } else {
+ acceptButton.label = acceptButtonLabel;
+ acceptButton.disabled = false;
+ cancelButton.hidden = true;
+ document.removeEventListener("dialogaccept", importRemainingItems);
+ }
+
+ optionsPane.hidden = !itemsRemain;
+ progressPane.hidden = true;
+ resultPane.hidden = itemsRemain;
+ }, 500);
+ },
+ };
+
+ putItemsIntoCal(calendar, remainingItems, importListener);
+}
+
+/**
+ * These functions are called via `putItemsIntoCal` in import-export.js so
+ * they need to be defined in global scope but they don't need to do anything
+ * in this case.
+ */
+function startBatchTransaction() {}
+function endBatchTransaction() {}