diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /comm/calendar/base/content/dialogs/calendar-ics-file-dialog.js | |
parent | Initial commit. (diff) | |
download | thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.tar.xz thunderbird-6bf0a5cb5034a7e684dcc3500e841785237ce2dd.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'comm/calendar/base/content/dialogs/calendar-ics-file-dialog.js')
-rw-r--r-- | comm/calendar/base/content/dialogs/calendar-ics-file-dialog.js | 476 |
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() {} |