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/calendar-task-tree.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/calendar-task-tree.js')
-rw-r--r-- | comm/calendar/base/content/calendar-task-tree.js | 685 |
1 files changed, 685 insertions, 0 deletions
diff --git a/comm/calendar/base/content/calendar-task-tree.js b/comm/calendar/base/content/calendar-task-tree.js new file mode 100644 index 0000000000..b7cc069e42 --- /dev/null +++ b/comm/calendar/base/content/calendar-task-tree.js @@ -0,0 +1,685 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.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 MozXULElement, calendarController, invokeEventDragSession, CalendarTaskTreeView, + calFilter, TodayPane, currentView */ + +// Wrap in a block to prevent leaking to window scope. +{ + const { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm"); + const { PluralForm } = ChromeUtils.importESModule("resource://gre/modules/PluralForm.sys.mjs"); + + /** + * An observer for the calendar event data source. This keeps the unifinder + * display up to date when the calendar event data is changed. + * + * @implements {calIObserver} + * @implements {calICompositeObserver} + */ + class TaskTreeObserver { + /** + * Creates and connects the new observer to a CalendarTaskTree and sets up Query Interface. + * + * @param {CalendarTaskTree} taskTree - The tree to observe. + */ + constructor(taskTree) { + this.tree = taskTree; + this.QueryInterface = ChromeUtils.generateQI(["calICompositeObserver", "calIObserver"]); + } + + // calIObserver Methods + + onStartBatch() {} + + onEndBatch() {} + + onLoad() { + this.tree.refresh(); + } + + onAddItem(item) { + if (!this.tree.hasBeenVisible) { + return; + } + + if (item.isTodo()) { + this.tree.mTreeView.addItems(this.tree.mFilter.getOccurrences(item)); + } + } + + onModifyItem(newItem, oldItem) { + if (!this.tree.hasBeenVisible) { + return; + } + + if (newItem.isTodo() || oldItem.isTodo()) { + this.tree.mTreeView.modifyItems( + this.tree.mFilter.getOccurrences(newItem), + this.tree.mFilter.getOccurrences(oldItem) + ); + // We also need to notify potential listeners. + let event = document.createEvent("Events"); + event.initEvent("select", true, false); + this.tree.dispatchEvent(event); + } + } + + onDeleteItem(deletedItem) { + if (!this.tree.hasBeenVisible) { + return; + } + + if (deletedItem.isTodo()) { + this.tree.mTreeView.removeItems(this.tree.mFilter.getOccurrences(deletedItem)); + } + } + + onError(calendar, errNo, message) {} + + onPropertyChanged(calendar, name, value, oldValue) { + switch (name) { + case "disabled": + if (value) { + this.tree.onCalendarRemoved(calendar); + } else { + this.tree.onCalendarAdded(calendar); + } + break; + } + } + + onPropertyDeleting(calendar, name) { + this.onPropertyChanged(calendar, name, null, null); + } + + // End calIObserver Methods + // calICompositeObserver Methods + + onCalendarAdded(calendar) { + if (!calendar.getProperty("disabled")) { + this.tree.onCalendarAdded(calendar); + } + } + + onCalendarRemoved(calendar) { + this.tree.onCalendarRemoved(calendar); + } + + onDefaultCalendarChanged(newDefaultCalendar) {} + + // End calICompositeObserver Methods + } + + /** + * Custom element for table-style display of tasks (rows and columns). + * + * @augments {MozTree} + */ + class CalendarTaskTree extends customElements.get("tree") { + connectedCallback() { + super.connectedCallback(); + if (this.delayConnectedCallback() || this.hasConnected) { + return; + } + this.hasConnected = true; + this.appendChild( + MozXULElement.parseXULToFragment( + ` + <treecols> + <treecol is="treecol-image" id="calendar-task-tree-col-completed" + class="calendar-task-tree-col-completed" + style="min-width: 18px" + fixed="true" + cycler="true" + sortKey="completedDate" + itemproperty="completed" + closemenu="none" + src="chrome://messenger/skin/icons/new/compact/checkbox.svg" + label="&calendar.unifinder.tree.done.label;" + tooltiptext="&calendar.unifinder.tree.done.tooltip2;"/> + <splitter class="tree-splitter"/> + <treecol is="treecol-image" id="calendar-task-tree-col-priority" + class="calendar-task-tree-col-priority" + style="min-width: 17px" + fixed="true" + itemproperty="priority" + closemenu="none" + src="chrome://messenger/skin/icons/new/compact/priority.svg" + label="&calendar.unifinder.tree.priority.label;" + tooltiptext="&calendar.unifinder.tree.priority.tooltip2;"/> + <splitter class="tree-splitter"/> + <treecol class="calendar-task-tree-col-title" + itemproperty="title" + style="flex: 1 auto" + closemenu="none" + label="&calendar.unifinder.tree.title.label;" + tooltiptext="&calendar.unifinder.tree.title.tooltip2;"/> + <splitter class="tree-splitter"/> + <treecol class="calendar-task-tree-col-entrydate" + itemproperty="entryDate" + style="flex: 1 auto" + closemenu="none" + label="&calendar.unifinder.tree.startdate.label;" + tooltiptext="&calendar.unifinder.tree.startdate.tooltip2;"/> + <splitter class="tree-splitter"/> + <treecol class="calendar-task-tree-col-duedate" + itemproperty="dueDate" + style="flex: 1 auto" + closemenu="none" + label="&calendar.unifinder.tree.duedate.label;" + tooltiptext="&calendar.unifinder.tree.duedate.tooltip2;"/> + <splitter class="tree-splitter"/> + <treecol class="calendar-task-tree-col-duration" + itemproperty="duration" + sortKey="dueDate" + style="flex: 1 auto" + closemenu="none" + label="&calendar.unifinder.tree.duration.label;" + tooltiptext="&calendar.unifinder.tree.duration.tooltip2;"/> + <splitter class="tree-splitter"/> + <treecol class="calendar-task-tree-col-completeddate" + itemproperty="completedDate" + style="flex: 1 auto" + closemenu="none" + label="&calendar.unifinder.tree.completeddate.label;" + tooltiptext="&calendar.unifinder.tree.completeddate.tooltip2;"/> + <splitter class="tree-splitter"/> + <treecol class="calendar-task-tree-col-percentcomplete" + itemproperty="percentComplete" + style="flex: 1 auto; min-width: 40px;" + closemenu="none" + label="&calendar.unifinder.tree.percentcomplete.label;" + tooltiptext="&calendar.unifinder.tree.percentcomplete.tooltip2;"/> + <splitter class="tree-splitter"/> + <treecol class="calendar-task-tree-col-categories" + itemproperty="categories" + style="flex: 1 auto" + closemenu="none" + label="&calendar.unifinder.tree.categories.label;" + tooltiptext="&calendar.unifinder.tree.categories.tooltip2;"/> + <splitter class="tree-splitter"/> + <treecol class="calendar-task-tree-col-location" + itemproperty="location" + style="flex: 1 auto" + closemenu="none" + label="&calendar.unifinder.tree.location.label;" + tooltiptext="&calendar.unifinder.tree.location.tooltip2;"/> + <splitter class="tree-splitter"/> + <treecol class="calendar-task-tree-col-status" + itemproperty="status" + style="flex: 1 auto" + closemenu="none" + label="&calendar.unifinder.tree.status.label;" + tooltiptext="&calendar.unifinder.tree.status.tooltip2;"/> + <splitter class="tree-splitter"/> + <treecol class="calendar-task-tree-col-calendar" + itemproperty="calendar" + style="flex: 1 auto" + closemenu="none" + label="&calendar.unifinder.tree.calendarname.label;" + tooltiptext="&calendar.unifinder.tree.calendarname.tooltip2;"/> + </treecols> + <treechildren class="calendar-task-treechildren" + tooltip="taskTreeTooltip" + ondblclick="mTreeView.onDoubleClick(event)"/> + `, + ["chrome://calendar/locale/global.dtd", "chrome://calendar/locale/calendar.dtd"] + ) + ); + + this.classList.add("calendar-task-tree"); + this.setAttribute("enableColumnDrag", "true"); + this.setAttribute("keepcurrentinview", "true"); + + this.addEventListener("select", event => { + this.mTreeView.onSelect(event); + if (calendarController.todo_tasktree_focused) { + calendarController.onSelectionChanged({ detail: this.selectedTasks }); + } + }); + + this.addEventListener("focus", event => { + this.updateFocus(); + }); + + this.addEventListener("blur", event => { + this.updateFocus(); + }); + + this.addEventListener("keypress", event => { + this.mTreeView.onKeyPress(event); + }); + + this.addEventListener("mousedown", event => { + this.mTreeView.onMouseDown(event); + }); + + this.addEventListener("dragstart", event => { + if (event.target.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; + } + invokeEventDragSession(item, event.target); + }); + + this.mTaskArray = []; + this.mHash2Index = {}; + this.mPendingRefreshJobs = {}; + this.mShowCompletedTasks = true; + this.mFilter = null; + this.mStartDate = null; + this.mEndDate = null; + this.mDateRangeFilter = null; + this.mTextFilterField = null; + + this.mTreeView = new CalendarTaskTreeView(this); + this.mTaskTreeObserver = new TaskTreeObserver(this); + + // Observes and responds to changes to calendar preferences. + this.mPrefObserver = (subject, topic, prefName) => { + switch (prefName) { + case "calendar.date.format": + case "calendar.timezone.local": + this.refresh(); + break; + } + }; + + // Set up the tree filter. + this.mFilter = new calFilter(); + this.mFilter.itemType = Ci.calICalendar.ITEM_FILTER_TYPE_TODO; + + this.restoreColumnState(); + + window.addEventListener("unload", this.persistColumnState.bind(this)); + } + + get currentTask() { + const index = this.currentIndex; + + const isSelected = this.view && this.view.selection && this.view.selection.isSelected(index); + + return isSelected ? this.mTaskArray[index] : null; + } + + get selectedTasks() { + let tasks = []; + let start = {}; + let end = {}; + if (!this.mTreeView.selection) { + return tasks; + } + + const 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; + } + + set showCompleted(val) { + this.mShowCompletedTasks = val; + } + + get showCompleted() { + return this.mShowCompletedTasks; + } + + set textFilterField(val) { + this.mTextFilterField = val; + } + + get textFilterField() { + return this.mTextFilterField; + } + + /** + * We want to make several attributes of the calendar-task-tree column elements persist + * across restarts. Unfortunately there's no reliable way by using the XUL 'persist' + * attribute on the column elements. So instead we store the data on the calendar-task-tree + * element before Thunderbird quits (using `persistColumnState`), and then restore the + * attributes on the columns when Thunderbird starts up again (using `restoreColumnState`). + * + * This function reads data from column attributes and sets it on several attributes on the + * task tree element, which are persisted because they are in the "persist" attribute of + * the task tree element. + * (E.g. `persist="visible-columns ordinals widths sort-active sort-direction"`.) + */ + persistColumnState() { + const columns = Array.from(this.querySelectorAll("treecol")); + const widths = columns.map(col => col.getBoundingClientRect().width || 0); + const ordinals = columns.map(col => col.ordinal); + const visibleColumns = columns + .filter(col => !col.hidden) + .map(col => col.getAttribute("itemproperty")); + + this.setAttribute("widths", widths.join(" ")); + this.setAttribute("ordinals", ordinals.join(" ")); + this.setAttribute("visible-columns", visibleColumns.join(" ")); + + const sorted = this.mTreeView.selectedColumn; + 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"); + } + } + + /** + * Reads data from several attributes on the calendar-task-tree element and sets it on the + * attributes of the columns of the tree. Called on Thunderbird startup to persist the + * state of the columns across restarts. Used with `persistTaskTreeColumnState` function. + */ + restoreColumnState() { + let visibleColumns = 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"; + + this.querySelectorAll("treecol").forEach(col => { + const itemProperty = col.getAttribute("itemproperty"); + if (visibleColumns.includes(itemProperty)) { + col.removeAttribute("hidden"); + } else { + col.setAttribute("hidden", "true"); + } + if (ordinals && ordinals.length > 0) { + col.ordinal = ordinals.shift(); + } + if (widths && widths.length > 0) { + col.style.width = Number(widths.shift()) + "px"; + } + if (sorted && sorted == itemProperty) { + this.mTreeView.sortDirection = sortDirection; + this.mTreeView.selectedColumn = col; + } + }); + // Update the ordinal positions of splitters to even numbers, so that + // they are in between columns. + let splitters = this.getElementsByTagName("splitter"); + for (let i = 0; i < splitters.length; i++) { + splitters[i].style.MozBoxOrdinalGroup = (i + 1) * 2; + } + } + + /** + * Calculates the text to display in the "Due In" column for the given task, + * the amount of time between now and when the task is due. + * + * @param {object} task - A task object. + * @returns {string} A formatted string for the "Due In" column for the task. + */ + duration(task) { + const noValidDueDate = !(task && task.dueDate && task.dueDate.isValid); + if (noValidDueDate) { + return ""; + } + + const isCompleted = task.completedDate && task.completedDate.isValid; + const dur = task.dueDate.subtractDate(cal.dtz.now()); + if (isCompleted && dur.isNegative) { + return ""; + } + + const absSeconds = Math.abs(dur.inSeconds); + const absMinutes = Math.ceil(absSeconds / 60); + const prefix = dur.isNegative ? "-" : ""; + + if (absMinutes >= 1440) { + // 1 day or more. + // Convert weeks to days; duration objects look like this (for 6, 7, and 8 days): + // { weeks: 0, days: 6 } + // { weeks: 1, days: 0 } + // { weeks: 0, days: 8 } + const days = dur.days + dur.weeks * 7; + return ( + prefix + PluralForm.get(days, cal.l10n.getCalString("dueInDays")).replace("#1", days) + ); + } else if (absMinutes >= 60) { + // 1 hour or more. + return ( + prefix + + PluralForm.get(dur.hours, cal.l10n.getCalString("dueInHours")).replace("#1", dur.hours) + ); + } + // Less than one hour. + return cal.l10n.getCalString("dueInLessThanOneHour"); + } + + /** + * Return the task object at a given row. + * + * @param {number} row - The index number identifying the row. + * @returns {object | null} A task object or null if none found. + */ + getTaskAtRow(row) { + return row > -1 ? this.mTaskArray[row] : null; + } + + /** + * Return the task object related to a given event. + * + * @param {Event} event - The event. + * @returns {object | false} The task object related to the event or false if none found. + */ + getTaskFromEvent(event) { + return this.mTreeView.getItemFromEvent(event); + } + + refreshFromCalendar(calendar) { + if (!this.hasBeenVisible) { + return; + } + + let refreshJob = { + QueryInterface: ChromeUtils.generateQI(["calIOperationListener"]), + tree: this, + calendar: null, + items: null, + operation: null, + + async cancel() { + if (this.operation) { + await this.operation.cancel(); + this.operation = null; + this.items = []; + } + }, + + async execute() { + if (calendar.id in this.tree.mPendingRefreshJobs) { + this.tree.mPendingRefreshJobs[calendar.id].cancel(); + } + this.calendar = calendar; + this.items = []; + this.tree.mPendingRefreshJobs[calendar.id] = this; + this.operation = cal.iterate.streamValues(this.tree.mFilter.getItems(calendar)); + + for await (let items of this.operation) { + this.items = this.items.concat(items); + } + + if (!this.tree.mTreeView.tree) { + // Looks like we've been disconnected from the DOM, there's no point in continuing. + return; + } + + if (calendar.id in this.tree.mPendingRefreshJobs) { + delete this.tree.mPendingRefreshJobs[calendar.id]; + } + + let oldItems = this.tree.mTaskArray.filter(item => item.calendar.id == calendar.id); + this.tree.mTreeView.modifyItems(this.items, oldItems); + this.tree.dispatchEvent(new CustomEvent("refresh", { bubbles: false })); + }, + }; + + refreshJob.execute(); + } + + selectAll() { + if (this.mTreeView.selection) { + this.mTreeView.selection.selectAll(); + } + } + + /** + * Refreshes the display. Called during connectedCallback and by event observers. + * Sets up the tree view, calendar event observer, and preference observer. + */ + refresh() { + // Only set the view if it's not already mTreeView, otherwise things get confused. + if (this.view?.wrappedJSObject != this.mTreeView) { + this.view = this.mTreeView; + } + + cal.view.getCompositeCalendar(window).addObserver(this.mTaskTreeObserver); + + Services.prefs.getBranch("").addObserver("calendar.", this.mPrefObserver); + + const cals = cal.view.getCompositeCalendar(window).getCalendars() || []; + const enabledCals = cals.filter(calendar => !calendar.getProperty("disabled")); + + enabledCals.forEach(calendar => this.refreshFromCalendar(calendar)); + } + + onCalendarAdded(calendar) { + if (!calendar.getProperty("disabled")) { + this.refreshFromCalendar(calendar); + } + } + + onCalendarRemoved(calendar) { + const tasks = this.mTaskArray.filter(task => task.calendar.id == calendar.id); + this.mTreeView.removeItems(tasks); + } + + sortItems() { + if (this.mTreeView.selectedColumn) { + let column = this.mTreeView.selectedColumn; + let modifier = this.mTreeView.sortDirection == "descending" ? -1 : 1; + let sortKey = column.getAttribute("sortKey") || column.getAttribute("itemproperty"); + + cal.unifinder.sortItems(this.mTaskArray, sortKey, modifier); + } + + this.recreateHashTable(); + } + + recreateHashTable() { + this.mHash2Index = this.mTaskArray.reduce((hash2Index, task, i) => { + hash2Index[task.hashId] = i; + return hash2Index; + }, {}); + + if (this.mTreeView.tree) { + this.mTreeView.tree.invalidate(); + } + } + + getInitialDate() { + return currentView().selectedDay || cal.dtz.now(); + } + + doUpdateFilter(filter) { + let needsRefresh = false; + let oldStart = this.mFilter.mStartDate; + let oldEnd = this.mFilter.mEndDate; + let filterText = this.mFilter.filterText || ""; + + if (filter) { + let props = this.mFilter.filterProperties; + this.mFilter.applyFilter(filter); + 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. + const start = this.mFilter.startDate; + const end = this.mFilter.mEndDate; + + const sameStartDates = start && oldStart && oldStart.compare(start) == 0; + const sameEndDates = end && oldEnd && oldEnd.compare(end) == 0; + + if ( + needsRefresh || + ((start || oldStart) && !sameStartDates) || + ((end || oldEnd) && !sameEndDates) + ) { + this.refresh(); + } + } + + updateFilter(filter) { + this.doUpdateFilter(filter); + } + + updateFocus() { + 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 == this || menuOpen; + + calendarController.onSelectionChanged({ detail: focused ? this.selectedTasks : [] }); + calendarController.todo_tasktree_focused = focused; + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.persistColumnState(); + this.mTreeView = null; + } + } + + customElements.define("calendar-task-tree", CalendarTaskTree, { extends: "tree" }); + + /** + * Custom element for the task tree that appears in the todaypane. + */ + class CalendarTaskTreeTodaypane extends CalendarTaskTree { + getInitialDate() { + return TodayPane.start || cal.dtz.now(); + } + updateFilter(filter) { + this.mFilter.selectedDate = this.getInitialDate(); + this.doUpdateFilter(filter); + } + } + + customElements.define("calendar-task-tree-todaypane", CalendarTaskTreeTodaypane, { + extends: "tree", + }); +} |