summaryrefslogtreecommitdiffstats
path: root/comm/calendar/base/modules/utils/calViewUtils.jsm
diff options
context:
space:
mode:
Diffstat (limited to 'comm/calendar/base/modules/utils/calViewUtils.jsm')
-rw-r--r--comm/calendar/base/modules/utils/calViewUtils.jsm521
1 files changed, 521 insertions, 0 deletions
diff --git a/comm/calendar/base/modules/utils/calViewUtils.jsm b/comm/calendar/base/modules/utils/calViewUtils.jsm
new file mode 100644
index 0000000000..dd4d1fd9ba
--- /dev/null
+++ b/comm/calendar/base/modules/utils/calViewUtils.jsm
@@ -0,0 +1,521 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * View and DOM related helper functions
+ */
+
+// NOTE: This module should not be loaded directly, it is available when
+// including calUtils.jsm under the cal.view namespace.
+
+const EXPORTED_SYMBOLS = ["calview"];
+
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+
+const lazy = {};
+ChromeUtils.defineModuleGetter(lazy, "cal", "resource:///modules/calendar/calUtils.jsm");
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "gParserUtils",
+ "@mozilla.org/parserutils;1",
+ "nsIParserUtils"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "gTextToHtmlConverter",
+ "@mozilla.org/txttohtmlconv;1",
+ "mozITXTToHTMLConv"
+);
+XPCOMUtils.defineLazyPreferenceGetter(
+ lazy,
+ "calendarSortOrder",
+ "calendar.list.sortOrder",
+ null,
+ null,
+ val => (val ? val.split(" ") : [])
+);
+
+var calview = {
+ /**
+ * Returns a parentnode - or the passed node - with the given attribute
+ * value for the given attributename by traversing 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
+ * @returns 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'.
+ */
+ getParentNodeOrThisByAttribute(aChildNode, aAttributeName, aAttributeValue) {
+ let node = aChildNode;
+ while (node && node.getAttribute(aAttributeName) != aAttributeValue) {
+ node = node.parentNode;
+ if (node.tagName == undefined) {
+ return null;
+ }
+ }
+ return node;
+ },
+
+ /**
+ * 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
+ * @returns The formatted string using only chars [_a-zA-Z0-9-]
+ */
+ 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);
+ },
+
+ /**
+ * Gets the cached instance of the composite calendar.
+ *
+ * @param aWindow The window to get the composite calendar for.
+ */
+ getCompositeCalendar(aWindow) {
+ if (typeof aWindow._compositeCalendar == "undefined") {
+ let comp = (aWindow._compositeCalendar = Cc[
+ "@mozilla.org/calendar/calendar;1?type=composite"
+ ].createInstance(Ci.calICompositeCalendar));
+ const prefix = "calendar-main";
+
+ const calManagerObserver = {
+ QueryInterface: ChromeUtils.generateQI([Ci.calICalendarManagerObserver]),
+
+ onCalendarRegistered(calendar) {
+ let inComposite = calendar.getProperty(prefix + "-in-composite");
+ if (inComposite === null && !calendar.getProperty("disabled")) {
+ comp.addCalendar(calendar);
+ }
+ },
+ onCalendarUnregistering(calendar) {
+ comp.removeCalendar(calendar);
+ if (!comp.defaultCalendar || comp.defaultCalendar.id == calendar.id) {
+ comp.defaultCalendar = comp.getCalendars()[0];
+ }
+ },
+ onCalendarDeleting(calendar) {},
+ };
+ lazy.cal.manager.addObserver(calManagerObserver);
+ aWindow.addEventListener("unload", () => lazy.cal.manager.removeObserver(calManagerObserver));
+
+ comp.prefPrefix = prefix; // populate calendar from existing calendars
+
+ if (typeof aWindow.gCalendarStatusFeedback != "undefined") {
+ // If we are in a window that has calendar status feedback, set
+ // up our status observer.
+ comp.setStatusObserver(aWindow.gCalendarStatusFeedback, aWindow);
+ }
+ }
+ return aWindow._compositeCalendar;
+ },
+
+ /**
+ * 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.
+ * @returns The hashed color.
+ */
+ 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.from(str || " ", e => e.charCodeAt(0)).reduce((a, b) => a + b);
+ return colorPalette[sum % colorPalette.length];
+ },
+
+ /**
+ * 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
+ */
+ 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 "#222";
+ },
+
+ /**
+ * Item comparator for inserting items into dayboxes.
+ *
+ * @param a The first item
+ * @param b The second item
+ * @returns The usual -1, 0, 1
+ */
+ compareItems(a, b) {
+ if (!a) {
+ return -1;
+ }
+ if (!b) {
+ return 1;
+ }
+
+ let aIsEvent = a.isEvent();
+ let aIsTodo = a.isTodo();
+
+ let bIsEvent = b.isEvent();
+ let bIsTodo = b.isTodo();
+
+ // 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;
+ }
+
+ if (a.calendar && b.calendar) {
+ cmp =
+ lazy.calendarSortOrder.indexOf(a.calendar.id) -
+ lazy.calendarSortOrder.indexOf(b.calendar.id);
+ if (cmp != 0) {
+ return cmp;
+ }
+ }
+
+ cmp = (a.title > b.title) - (a.title < b.title);
+ return cmp;
+ },
+
+ get calendarSortOrder() {
+ return lazy.calendarSortOrder;
+ },
+
+ /**
+ * Converts plain or HTML text into an HTML document fragment.
+ *
+ * @param {string} text - The text to convert.
+ * @param {Document} doc - The document where the fragment will be appended.
+ * @param {string} html - HTML if it's already available.
+ * @returns {DocumentFragment} An HTML document fragment.
+ */
+ textToHtmlDocumentFragment(text, doc, html) {
+ if (!html) {
+ let mode =
+ Ci.mozITXTToHTMLConv.kStructPhrase |
+ Ci.mozITXTToHTMLConv.kGlyphSubstitution |
+ Ci.mozITXTToHTMLConv.kURLs;
+ html = lazy.gTextToHtmlConverter.scanTXT(text, mode);
+ html = html.replace(/\r?\n/g, "<br>");
+ }
+
+ // Sanitize and convert the HTML into a document fragment.
+ let flags =
+ lazy.gParserUtils.SanitizerLogRemovals |
+ lazy.gParserUtils.SanitizerDropForms |
+ lazy.gParserUtils.SanitizerDropMedia;
+
+ let uri = Services.io.newURI(doc.baseURI);
+ return lazy.gParserUtils.parseFragment(html, flags, false, uri, doc.createElement("div"));
+ },
+
+ /**
+ * Correct the description of a Google Calendar item so that it will display
+ * as intended.
+ *
+ * @param {calIItemBase} item - The item to correct.
+ */
+ fixGoogleCalendarDescription(item) {
+ // Google Calendar inserts bare HTML into its description field instead of
+ // using the standard Alternate Text Representation mechanism. However,
+ // the HTML is a poor representation of how it displays descriptions on
+ // the site: links may be included as bare URLs and line breaks may be
+ // included as raw newlines, so in order to display descriptions as Google
+ // intends, we need to make some corrections.
+ if (item.descriptionText) {
+ // Convert HTML entities which scanHTML won't handle into their standard
+ // text representation.
+ let description = item.descriptionText.replace(/&#?\w+;?/g, potentialEntity => {
+ // Attempt to parse the pattern match as an HTML entity.
+ let body = new DOMParser().parseFromString(potentialEntity, "text/html").body;
+
+ // Don't replace text that didn't parse as an entity or that parsed as
+ // an entity which could break HTML parsing below.
+ return body.innerText.length == 1 && !'"&<>'.includes(body.innerText)
+ ? body.innerText
+ : potentialEntity;
+ });
+
+ // Replace bare URLs with links and convert remaining entities.
+ description = lazy.gTextToHtmlConverter.scanHTML(description, Ci.mozITXTToHTMLConv.kURLs);
+
+ // Setting the HTML description will mark the item dirty, but we want to
+ // avoid unnecessary updates; preserve modification time.
+ let stamp = item.stampTime;
+ let lastModified = item.lastModifiedTime;
+
+ item.descriptionHTML = description.replace(/\r?\n/g, "<br>");
+
+ // Restore modification time.
+ item.setProperty("DTSTAMP", stamp);
+ item.setProperty("LAST-MODIFIED", lastModified);
+ }
+ },
+};
+
+/**
+ * Adds CSS variables for each calendar to registered windows for coloring
+ * UI elements. Automatically tracks calendar creation, changes, and deletion.
+ */
+calview.colorTracker = {
+ calendars: null,
+ categoryBranch: null,
+ windows: new Set(),
+ QueryInterface: ChromeUtils.generateQI(["calICalendarManagerObserver", "calIObserver"]),
+
+ // Deregistration is not required.
+ registerWindow(aWindow) {
+ if (this.calendars === null) {
+ this.calendars = new Set(lazy.cal.manager.getCalendars());
+ lazy.cal.manager.addObserver(this);
+ lazy.cal.manager.addCalendarObserver(this);
+
+ this.categoryBranch = Services.prefs.getBranch("calendar.category.color.");
+ this.categoryBranch.addObserver("", this);
+ Services.obs.addObserver(this, "xpcom-shutdown");
+ }
+
+ this.windows.add(aWindow);
+ aWindow.addEventListener("unload", () => this.windows.delete(aWindow));
+
+ this.addColorsToDocument(aWindow.document);
+ },
+ addColorsToDocument(aDocument) {
+ for (let calendar of this.calendars) {
+ this._addCalendarToDocument(aDocument, calendar);
+ }
+ this._addAllCategoriesToDocument(aDocument);
+ },
+
+ _addCalendarToDocument(aDocument, aCalendar) {
+ let cssSafeId = calview.formatStringForCSSRule(aCalendar.id);
+ let style = aDocument.documentElement.style;
+ let backColor = aCalendar.getProperty("color") || "#a8c2e1";
+ let foreColor = calview.getContrastingTextColor(backColor);
+ style.setProperty(`--calendar-${cssSafeId}-backcolor`, backColor);
+ style.setProperty(`--calendar-${cssSafeId}-forecolor`, foreColor);
+ },
+ _removeCalendarFromDocument(aDocument, aCalendar) {
+ let cssSafeId = calview.formatStringForCSSRule(aCalendar.id);
+ let style = aDocument.documentElement.style;
+ style.removeProperty(`--calendar-${cssSafeId}-backcolor`);
+ style.removeProperty(`--calendar-${cssSafeId}-forecolor`);
+ },
+ _addCategoryToDocument(aDocument, aCategoryName) {
+ // aCategoryName should already be formatted for CSS, because that's
+ // what is stored in the prefs, and this function is only called with
+ // arguments that come from the prefs.
+ if (/[^\w-]/.test(aCategoryName)) {
+ return;
+ }
+
+ let style = aDocument.documentElement.style;
+ let color = this.categoryBranch.getStringPref(aCategoryName, "");
+ if (color == "") {
+ // Don't use the getStringPref default, the value might actually be ""
+ // and we don't want that.
+ color = "transparent";
+ }
+ style.setProperty(`--category-${aCategoryName}-color`, color);
+ },
+ _addAllCategoriesToDocument(aDocument) {
+ for (let categoryName of this.categoryBranch.getChildList("")) {
+ this._addCategoryToDocument(aDocument, categoryName);
+ }
+ },
+
+ // calICalendarManagerObserver methods
+ onCalendarRegistered(aCalendar) {
+ this.calendars.add(aCalendar);
+ for (let window of this.windows) {
+ this._addCalendarToDocument(window.document, aCalendar);
+ }
+ },
+ onCalendarUnregistering(aCalendar) {
+ this.calendars.delete(aCalendar);
+ for (let window of this.windows) {
+ this._removeCalendarFromDocument(window.document, aCalendar);
+ }
+ },
+ onCalendarDeleting(aCalendar) {},
+
+ // calIObserver methods
+ onStartBatch() {},
+ onEndBatch() {},
+ onLoad() {},
+ onAddItem(aItem) {},
+ onModifyItem(aNewItem, aOldItem) {},
+ onDeleteItem(aDeletedItem) {},
+ onError(aCalendar, aErrNo, aMessage) {},
+ onPropertyChanged(aCalendar, aName, aValue, aOldValue) {
+ if (aName == "color") {
+ for (let window of this.windows) {
+ this._addCalendarToDocument(window.document, aCalendar);
+ }
+ }
+ },
+ onPropertyDeleting(aCalendar, aName) {},
+
+ // nsIObserver method
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "nsPref:changed") {
+ for (let window of this.windows) {
+ this._addCategoryToDocument(window.document, aData);
+ }
+ // 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.
+ } else if (aTopic == "xpcom-shutdown") {
+ this.categoryBranch.removeObserver("", this);
+ Services.obs.removeObserver(this, "xpcom-shutdown");
+ }
+ },
+};