summaryrefslogtreecommitdiffstats
path: root/comm/calendar/base/modules/calRecurrenceUtils.jsm
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 17:32:43 +0000
commit6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch)
treea68f146d7fa01f0134297619fbe7e33db084e0aa /comm/calendar/base/modules/calRecurrenceUtils.jsm
parentInitial commit. (diff)
downloadthunderbird-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/modules/calRecurrenceUtils.jsm')
-rw-r--r--comm/calendar/base/modules/calRecurrenceUtils.jsm553
1 files changed, 553 insertions, 0 deletions
diff --git a/comm/calendar/base/modules/calRecurrenceUtils.jsm b/comm/calendar/base/modules/calRecurrenceUtils.jsm
new file mode 100644
index 0000000000..125f429801
--- /dev/null
+++ b/comm/calendar/base/modules/calRecurrenceUtils.jsm
@@ -0,0 +1,553 @@
+/* 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/. */
+
+/* exported recurrenceStringFromItem, recurrenceRule2String, splitRecurrenceRules,
+ * checkRecurrenceRule, countOccurrences
+ */
+
+var { PluralForm } = ChromeUtils.importESModule("resource://gre/modules/PluralForm.sys.mjs");
+var { XPCOMUtils } = ChromeUtils.importESModule("resource://gre/modules/XPCOMUtils.sys.mjs");
+var { cal } = ChromeUtils.import("resource:///modules/calendar/calUtils.jsm");
+
+const lazy = {};
+XPCOMUtils.defineLazyModuleGetters(lazy, {
+ CalRecurrenceDate: "resource:///modules/CalRecurrenceDate.jsm",
+ CalRecurrenceRule: "resource:///modules/CalRecurrenceRule.jsm",
+});
+
+const EXPORTED_SYMBOLS = [
+ "recurrenceStringFromItem",
+ "recurrenceRule2String",
+ "splitRecurrenceRules",
+ "checkRecurrenceRule",
+ "countOccurrences",
+ "hasUnsupported",
+];
+
+/**
+ * Given a calendar event or task, return a string that describes the item's
+ * recurrence pattern. When the recurrence pattern is too complex, return a
+ * "too complex" string by getting that string using the arguments provided.
+ *
+ * @param {calIEvent | calITodo} item A calendar item.
+ * @param {string} bundleName - Name of the properties file, e.g. "calendar-event-dialog".
+ * @param {string} stringName - Name of the string within the properties file.
+ * @param {string[]} [params] - (optional) Parameters to format the string.
+ * @returns {string | null} A string describing the recurrence
+ * pattern or null if the item has no
+ * recurrence info.
+ */
+function recurrenceStringFromItem(item, bundleName, stringName, params) {
+ // See the `parentItem` property of `calIItemBase`.
+ let parent = item.parentItem;
+
+ let recurrenceInfo = parent.recurrenceInfo;
+ if (!recurrenceInfo) {
+ return null;
+ }
+
+ let kDefaultTimezone = cal.dtz.defaultTimezone;
+
+ let rawStartDate = parent.startDate || parent.entryDate;
+ let rawEndDate = parent.endDate || parent.dueDate;
+
+ let startDate = rawStartDate ? rawStartDate.getInTimezone(kDefaultTimezone) : null;
+ let endDate = rawEndDate ? rawEndDate.getInTimezone(kDefaultTimezone) : null;
+
+ return (
+ recurrenceRule2String(recurrenceInfo, startDate, endDate, startDate.isDate) ||
+ cal.l10n.getString(bundleName, stringName, params)
+ );
+}
+
+/**
+ * This function takes the recurrence info passed as argument and creates a
+ * literal string representing the repeat pattern in natural language.
+ *
+ * @param recurrenceInfo An item's recurrence info to parse.
+ * @param startDate The start date to base rules on.
+ * @param endDate The end date to base rules on.
+ * @param allDay If true, the pattern should assume an allday item.
+ * @returns A human readable string describing the recurrence.
+ */
+function recurrenceRule2String(recurrenceInfo, startDate, endDate, allDay) {
+ function getRString(name, args) {
+ return cal.l10n.getString("calendar-event-dialog", name, args);
+ }
+ function day_of_week(day) {
+ return Math.abs(day) % 8;
+ }
+ function day_position(day) {
+ return ((Math.abs(day) - day_of_week(day)) / 8) * (day < 0 ? -1 : 1);
+ }
+ function nounClass(aDayString, aRuleString) {
+ // Select noun class (grammatical gender) for rule string
+ let nounClassStr = getRString(aDayString + "Nounclass");
+ return aRuleString + nounClassStr.substr(0, 1).toUpperCase() + nounClassStr.substr(1);
+ }
+ function pluralWeekday(aDayString) {
+ let plural = getRString("pluralForWeekdays") == "true";
+ return plural ? aDayString + "Plural" : aDayString;
+ }
+ function everyWeekDay(aByDay) {
+ // Checks if aByDay contains only values from 1 to 7 with any order.
+ let mask = aByDay.reduce((value, item) => value | (1 << item), 1);
+ return aByDay.length == 7 && mask == Math.pow(2, 8) - 1;
+ }
+
+ // Retrieve a valid recurrence rule from the currently
+ // set recurrence info. Bail out if there's more
+ // than a single rule or something other than a rule.
+ recurrenceInfo = recurrenceInfo.clone();
+ if (hasUnsupported(recurrenceInfo)) {
+ return null;
+ }
+
+ let rrules = splitRecurrenceRules(recurrenceInfo);
+ if (rrules[0].length == 1) {
+ let rule = cal.wrapInstance(rrules[0][0], Ci.calIRecurrenceRule);
+ // Currently we allow only for BYDAY, BYMONTHDAY, BYMONTH rules.
+ let byparts = [
+ "BYSECOND",
+ "BYMINUTE",
+ /* "BYDAY", */
+ "BYHOUR",
+ /* "BYMONTHDAY", */
+ "BYYEARDAY",
+ "BYWEEKNO",
+ /* "BYMONTH", */
+ "BYSETPOS",
+ ];
+
+ if (rule && !checkRecurrenceRule(rule, byparts)) {
+ let dateFormatter = cal.dtz.formatter;
+ let ruleString;
+ if (rule.type == "DAILY") {
+ if (checkRecurrenceRule(rule, ["BYDAY"])) {
+ let days = rule.getComponent("BYDAY");
+ let weekdays = [2, 3, 4, 5, 6];
+ if (weekdays.length == days.length) {
+ let i;
+ for (i = 0; i < weekdays.length; i++) {
+ if (weekdays[i] != days[i]) {
+ break;
+ }
+ }
+ if (i == weekdays.length) {
+ ruleString = getRString("repeatDetailsRuleDaily4");
+ }
+ } else {
+ return null;
+ }
+ } else {
+ let dailyString = getRString("dailyEveryNth");
+ ruleString = PluralForm.get(rule.interval, dailyString).replace("#1", rule.interval);
+ }
+ } else if (rule.type == "WEEKLY") {
+ // weekly recurrence, currently we
+ // support a single 'BYDAY'-rule only.
+ if (checkRecurrenceRule(rule, ["BYDAY"])) {
+ // create a string like 'Monday, Tuesday and Wednesday'
+ let days = rule.getComponent("BYDAY");
+ let weekdays = "";
+ // select noun class (grammatical gender) according to the
+ // first day of the list
+ let weeklyString = nounClass("repeatDetailsDay" + days[0], "weeklyNthOn");
+ for (let i = 0; i < days.length; i++) {
+ if (rule.interval == 1) {
+ weekdays += getRString(pluralWeekday("repeatDetailsDay" + days[i]));
+ } else {
+ weekdays += getRString("repeatDetailsDay" + days[i]);
+ }
+ if (days.length > 1 && i == days.length - 2) {
+ weekdays += " " + getRString("repeatDetailsAnd") + " ";
+ } else if (i < days.length - 1) {
+ weekdays += ", ";
+ }
+ }
+
+ weeklyString = getRString(weeklyString, [weekdays]);
+ ruleString = PluralForm.get(rule.interval, weeklyString).replace("#2", rule.interval);
+ } else {
+ let weeklyString = getRString("weeklyEveryNth");
+ ruleString = PluralForm.get(rule.interval, weeklyString).replace("#1", rule.interval);
+ }
+ } else if (rule.type == "MONTHLY") {
+ if (checkRecurrenceRule(rule, ["BYDAY"])) {
+ let byday = rule.getComponent("BYDAY");
+ if (everyWeekDay(byday)) {
+ // Rule every day of the month.
+ ruleString = getRString("monthlyEveryDayOfNth");
+ ruleString = PluralForm.get(rule.interval, ruleString).replace("#2", rule.interval);
+ } else {
+ // For rules with generic number of weekdays with and
+ // without "position" prefix we build two separate
+ // strings depending on the position and then join them.
+ // Notice: we build the description string but currently
+ // the UI can manage only rules with only one weekday.
+ let weekdaysString_every = "";
+ let weekdaysString_position = "";
+ let firstDay = byday[0];
+ for (let i = 0; i < byday.length; i++) {
+ if (day_position(byday[i]) == 0) {
+ if (!weekdaysString_every) {
+ firstDay = byday[i];
+ }
+ weekdaysString_every +=
+ getRString(pluralWeekday("repeatDetailsDay" + byday[i])) + ", ";
+ } else {
+ if (day_position(byday[i]) < -1 || day_position(byday[i]) > 5) {
+ // We support only weekdays with -1 as negative
+ // position ('THE LAST ...').
+ return null;
+ }
+
+ let duplicateWeekday = byday.some(element => {
+ return (
+ day_position(element) == 0 && day_of_week(byday[i]) == day_of_week(element)
+ );
+ });
+ if (duplicateWeekday) {
+ // Prevent to build strings such as for example:
+ // "every Monday and the second Monday...".
+ continue;
+ }
+
+ let ordinalString = "repeatOrdinal" + day_position(byday[i]);
+ let dayString = "repeatDetailsDay" + day_of_week(byday[i]);
+ ordinalString = nounClass(dayString, ordinalString);
+ ordinalString = getRString(ordinalString);
+ dayString = getRString(dayString);
+ let stringOrdinalWeekday = getRString("ordinalWeekdayOrder", [
+ ordinalString,
+ dayString,
+ ]);
+ weekdaysString_position += stringOrdinalWeekday + ", ";
+ }
+ }
+ let weekdaysString = weekdaysString_every + weekdaysString_position;
+ weekdaysString = weekdaysString
+ .slice(0, -2)
+ .replace(/,(?= [^,]*$)/, " " + getRString("repeatDetailsAnd"));
+
+ let monthlyString = weekdaysString_every
+ ? "monthlyEveryOfEvery"
+ : "monthlyRuleNthOfEvery";
+ monthlyString = nounClass("repeatDetailsDay" + day_of_week(firstDay), monthlyString);
+ monthlyString = getRString(monthlyString, [weekdaysString]);
+ ruleString = PluralForm.get(rule.interval, monthlyString).replace("#2", rule.interval);
+ }
+ } else if (checkRecurrenceRule(rule, ["BYMONTHDAY"])) {
+ let component = rule.getComponent("BYMONTHDAY");
+
+ // First, find out if the 'BYMONTHDAY' component contains
+ // any elements with a negative value lesser than -1 ("the
+ // last day"). If so we currently don't support any rule
+ if (component.some(element => element < -1)) {
+ // we don't support any other combination for now...
+ return getRString("ruleTooComplex");
+ } else if (component.length == 1 && component[0] == -1) {
+ // i.e. one day, the last day of the month
+ let monthlyString = getRString("monthlyLastDayOfNth");
+ ruleString = PluralForm.get(rule.interval, monthlyString).replace("#1", rule.interval);
+ } else {
+ // i.e. one or more monthdays every N months.
+
+ // Build a string with a list of days separated with commas.
+ let day_string = "";
+ let lastDay = false;
+ for (let i = 0; i < component.length; i++) {
+ if (component[i] == -1) {
+ lastDay = true;
+ continue;
+ }
+ day_string += dateFormatter.formatDayWithOrdinal(component[i]) + ", ";
+ }
+ if (lastDay) {
+ day_string += getRString("monthlyLastDay") + ", ";
+ }
+ day_string = day_string
+ .slice(0, -2)
+ .replace(/,(?= [^,]*$)/, " " + getRString("repeatDetailsAnd"));
+
+ // Add the word "day" in plural form to the list of days then
+ // compose the final string with the interval of months
+ let monthlyDayString = getRString("monthlyDaysOfNth_day", [day_string]);
+ monthlyDayString = PluralForm.get(component.length, monthlyDayString);
+ let monthlyString = getRString("monthlyDaysOfNth", [monthlyDayString]);
+ ruleString = PluralForm.get(rule.interval, monthlyString).replace("#2", rule.interval);
+ }
+ } else {
+ let monthlyString = getRString("monthlyDaysOfNth", [startDate.day]);
+ ruleString = PluralForm.get(rule.interval, monthlyString).replace("#2", rule.interval);
+ }
+ } else if (rule.type == "YEARLY") {
+ let bymonthday = null;
+ let bymonth = null;
+ if (checkRecurrenceRule(rule, ["BYMONTHDAY"])) {
+ bymonthday = rule.getComponent("BYMONTHDAY");
+ }
+ if (checkRecurrenceRule(rule, ["BYMONTH"])) {
+ bymonth = rule.getComponent("BYMONTH");
+ }
+ if (
+ (bymonth && bymonth.length > 1) ||
+ (bymonthday && (bymonthday.length > 1 || bymonthday[0] < -1))
+ ) {
+ // Don't build a string for a recurrence rule that the UI
+ // currently can't show completely (with more than one month
+ // or than one monthday, or bymonthdays lesser than -1).
+ return getRString("ruleTooComplex");
+ }
+
+ if (
+ checkRecurrenceRule(rule, ["BYMONTHDAY"]) &&
+ (checkRecurrenceRule(rule, ["BYMONTH"]) || !checkRecurrenceRule(rule, ["BYDAY"]))
+ ) {
+ // RRULE:FREQ=YEARLY;BYMONTH=x;BYMONTHDAY=y.
+ // RRULE:FREQ=YEARLY;BYMONTHDAY=x (takes the month from the start date).
+ let monthNumber = bymonth ? bymonth[0] : startDate.month + 1;
+ let month = getRString("repeatDetailsMonth" + monthNumber);
+ let monthDay =
+ bymonthday[0] == -1
+ ? getRString("monthlyLastDay")
+ : dateFormatter.formatDayWithOrdinal(bymonthday[0]);
+ let yearlyString = getRString("yearlyNthOn", [month, monthDay]);
+ ruleString = PluralForm.get(rule.interval, yearlyString).replace("#3", rule.interval);
+ } else if (checkRecurrenceRule(rule, ["BYMONTH"]) && checkRecurrenceRule(rule, ["BYDAY"])) {
+ // RRULE:FREQ=YEARLY;BYMONTH=x;BYDAY=y1,y2,....
+ let byday = rule.getComponent("BYDAY");
+ let month = getRString("repeatDetailsMonth" + bymonth[0]);
+ if (everyWeekDay(byday)) {
+ // Every day of the month.
+ let yearlyString = "yearlyEveryDayOf";
+ yearlyString = getRString(yearlyString, [month]);
+ ruleString = PluralForm.get(rule.interval, yearlyString).replace("#2", rule.interval);
+ } else if (byday.length == 1) {
+ let dayString = "repeatDetailsDay" + day_of_week(byday[0]);
+ if (day_position(byday[0]) == 0) {
+ // Every any weekday.
+ let yearlyString = "yearlyOnEveryNthOfNth";
+ yearlyString = nounClass(dayString, yearlyString);
+ let day = getRString(pluralWeekday(dayString));
+ yearlyString = getRString(yearlyString, [day, month]);
+ ruleString = PluralForm.get(rule.interval, yearlyString).replace("#3", rule.interval);
+ } else if (day_position(byday[0]) >= -1 || day_position(byday[0]) <= 5) {
+ // The first|the second|...|the last Monday, Tuesday, ..., day.
+ let yearlyString = "yearlyNthOnNthOf";
+ yearlyString = nounClass(dayString, yearlyString);
+ let ordinalString = "repeatOrdinal" + day_position(byday[0]);
+ ordinalString = nounClass(dayString, ordinalString);
+ let ordinal = getRString(ordinalString);
+ let day = getRString(dayString);
+ yearlyString = getRString(yearlyString, [ordinal, day, month]);
+ ruleString = PluralForm.get(rule.interval, yearlyString).replace("#4", rule.interval);
+ } else {
+ return getRString("ruleTooComplex");
+ }
+ } else {
+ // Currently we don't support yearly rules with
+ // more than one BYDAY element or exactly 7 elements
+ // with all the weekdays (the "every day" case).
+ return getRString("ruleTooComplex");
+ }
+ } else if (checkRecurrenceRule(rule, ["BYMONTH"])) {
+ // RRULE:FREQ=YEARLY;BYMONTH=x (takes the day from the start date).
+ let month = getRString("repeatDetailsMonth" + bymonth[0]);
+ let yearlyString = getRString("yearlyNthOn", [month, startDate.day]);
+ ruleString = PluralForm.get(rule.interval, yearlyString).replace("#3", rule.interval);
+ } else {
+ let month = getRString("repeatDetailsMonth" + (startDate.month + 1));
+ let yearlyString = getRString("yearlyNthOn", [month, startDate.day]);
+ ruleString = PluralForm.get(rule.interval, yearlyString).replace("#3", rule.interval);
+ }
+ }
+
+ let kDefaultTimezone = cal.dtz.defaultTimezone;
+
+ let detailsString;
+ if (!endDate || allDay) {
+ if (rule.isFinite) {
+ if (rule.isByCount) {
+ let countString = getRString("repeatCountAllDay", [
+ ruleString,
+ dateFormatter.formatDateShort(startDate),
+ ]);
+
+ detailsString = PluralForm.get(rule.count, countString).replace("#3", rule.count);
+ } else {
+ let untilDate = rule.untilDate.getInTimezone(kDefaultTimezone);
+ detailsString = getRString("repeatDetailsUntilAllDay", [
+ ruleString,
+ dateFormatter.formatDateShort(startDate),
+ dateFormatter.formatDateShort(untilDate),
+ ]);
+ }
+ } else {
+ detailsString = getRString("repeatDetailsInfiniteAllDay", [
+ ruleString,
+ dateFormatter.formatDateShort(startDate),
+ ]);
+ }
+ } else if (rule.isFinite) {
+ if (rule.isByCount) {
+ let countString = getRString("repeatCount", [
+ ruleString,
+ dateFormatter.formatDateShort(startDate),
+ dateFormatter.formatTime(startDate),
+ dateFormatter.formatTime(endDate),
+ ]);
+ detailsString = PluralForm.get(rule.count, countString).replace("#5", rule.count);
+ } else {
+ let untilDate = rule.untilDate.getInTimezone(kDefaultTimezone);
+ detailsString = getRString("repeatDetailsUntil", [
+ ruleString,
+ dateFormatter.formatDateShort(startDate),
+ dateFormatter.formatDateShort(untilDate),
+ dateFormatter.formatTime(startDate),
+ dateFormatter.formatTime(endDate),
+ ]);
+ }
+ } else {
+ detailsString = getRString("repeatDetailsInfinite", [
+ ruleString,
+ dateFormatter.formatDateShort(startDate),
+ dateFormatter.formatTime(startDate),
+ dateFormatter.formatTime(endDate),
+ ]);
+ }
+ return detailsString;
+ }
+ }
+ return null;
+}
+
+/**
+ * Used to test if the recurrence items of a calIRecurrenceInfo instance are
+ * supported. We do not currently allow the "SECONDLY" or "MINUTELY" frequency
+ * values.
+ *
+ * @param {calIRecurrenceInfo} recurrenceInfo
+ * @returns {boolean}
+ */
+function hasUnsupported(recurrenceInfo) {
+ return recurrenceInfo
+ .getRecurrenceItems()
+ .some(item => item.type == "SECONDLY" || item.type == "MINUTELY");
+}
+
+/**
+ * Split rules into negative and positive rules.
+ *
+ * @param recurrenceInfo An item's recurrence info to parse.
+ * @returns An array with two elements: an array of positive
+ * rules and an array of negative rules.
+ */
+function splitRecurrenceRules(recurrenceInfo) {
+ let ritems = recurrenceInfo.getRecurrenceItems();
+ let rules = [];
+ let exceptions = [];
+ for (let ritem of ritems) {
+ if (ritem.isNegative) {
+ exceptions.push(ritem);
+ } else {
+ rules.push(ritem);
+ }
+ }
+ return [rules, exceptions];
+}
+
+/**
+ * Check if a recurrence rule's component is valid.
+ *
+ * @see calIRecurrenceRule
+ * @param aRule The recurrence rule to check.
+ * @param aArray An array of component names to check.
+ * @returns Returns true if the rule is valid.
+ */
+function checkRecurrenceRule(aRule, aArray) {
+ for (let comp of aArray) {
+ let ruleComp = aRule.getComponent(comp);
+ if (ruleComp && ruleComp.length > 0) {
+ return true;
+ }
+ }
+ return false;
+}
+
+/**
+ * Counts the occurrences of the parent item if any of a provided item
+ *
+ * @param {(calIEvent|calIToDo)} aItem item to count for
+ * @returns {(number|null)} number of occurrences or null if the
+ * passed item's parent item isn't a
+ * recurring item or its recurrence is
+ * infinite
+ */
+function countOccurrences(aItem) {
+ let occCounter = null;
+ let recInfo = aItem.parentItem.recurrenceInfo;
+ if (recInfo && recInfo.isFinite) {
+ occCounter = 0;
+ let excCounter = 0;
+ let byCount = false;
+ let ritems = recInfo.getRecurrenceItems();
+ for (let ritem of ritems) {
+ if (ritem instanceof lazy.CalRecurrenceRule || ritem instanceof Ci.calIRecurrenceRule) {
+ if (ritem.isByCount) {
+ occCounter = occCounter + ritem.count;
+ byCount = true;
+ } else {
+ // The rule is limited by an until date.
+ let parentItem = aItem.parentItem;
+ let startDate = parentItem.startDate ?? parentItem.entryDate;
+ let endDate = parentItem.endDate ?? parentItem.dueDate ?? startDate;
+ let from = startDate.clone();
+ let until = endDate.clone();
+ if (until.compare(ritem.untilDate) == -1) {
+ until = ritem.untilDate.clone();
+ }
+
+ let exceptionIds = recInfo.getExceptionIds();
+ for (let exceptionId of exceptionIds) {
+ let recur = recInfo.getExceptionFor(exceptionId);
+ let recurStartDate = recur.startDate ?? recur.entryDate;
+ let recurEndDate = recur.endDate ?? recur.dueDate ?? recurStartDate;
+ if (from.compare(recurStartDate) == 1) {
+ from = recurStartDate.clone();
+ }
+ if (until.compare(recurEndDate) == -1) {
+ until = recurEndDate.clone();
+ }
+ }
+
+ // we add an extra day at beginning and end, so we don't
+ // need to take care of any timezone conversion
+ from.addDuration(cal.createDuration("-P1D"));
+ until.addDuration(cal.createDuration("P1D"));
+
+ let occurrences = recInfo.getOccurrences(from, until, 0);
+ occCounter = occCounter + occurrences.length;
+ }
+ } else if (
+ ritem instanceof lazy.CalRecurrenceDate ||
+ ritem instanceof Ci.calIRecurrenceDate
+ ) {
+ if (ritem.isNegative) {
+ // this is an exdate
+ excCounter++;
+ } else {
+ // this is an (additional) rdate
+ occCounter++;
+ }
+ }
+ }
+
+ if (byCount) {
+ // for a rrule by count, we still need to subtract exceptions if any
+ occCounter = occCounter - excCounter;
+ }
+ }
+ return occCounter;
+}