summaryrefslogtreecommitdiffstats
path: root/comm/calendar/base/content/calendar-task-tree.js
diff options
context:
space:
mode:
Diffstat (limited to 'comm/calendar/base/content/calendar-task-tree.js')
-rw-r--r--comm/calendar/base/content/calendar-task-tree.js685
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",
+ });
+}