• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

kewisch / gdata-provider / 15147115722

20 May 2025 08:22PM UTC coverage: 91.001% (-0.2%) from 91.152%
15147115722

push

github

kewisch
chore: Bump and release notes for 139.9.3

930 of 989 branches covered (94.03%)

0 of 1 new or added line in 1 file covered. (0.0%)

6 existing lines in 3 files now uncovered.

1254 of 1378 relevant lines covered (91.0%)

80.03 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

99.8
/src/background/items.js
1
/* This Source Code Form is subject to the terms of the Mozilla Public
2
 * License, v. 2.0. If a copy of the MPL was not distributed with this
3
 * file, You can obtain one at http://mozilla.org/MPL/2.0/.
4
 * Portions Copyright (C) Philipp Kewisch */
5

6
import {
7
  categoriesStringToArray,
8
  categoriesArrayToString,
9
  reverseObject,
10
  addVCalendar,
11
  stripFractional,
12
} from "./utils.js";
13

14
import ICAL from "./libs/ical.js";
15
import TimezoneService from "./timezone.js";
16

17
const ATTENDEE_STATUS_MAP = {
8✔
18
  needsAction: "NEEDS-ACTION",
19
  declined: "DECLINED",
20
  tentative: "TENTATIVE",
21
  accepted: "ACCEPTED",
22
};
23
const ALARM_ACTION_MAP = {
8✔
24
  email: "EMAIL",
25
  popup: "DISPLAY",
26
};
27
const ATTENDEE_STATUS_MAP_REV = reverseObject(ATTENDEE_STATUS_MAP);
8✔
28
const ALARM_ACTION_MAP_REV = reverseObject(ALARM_ACTION_MAP);
8✔
29

30
const FOUR_WEEKS_IN_MINUTES = 40320;
8✔
31

32
const CONTAINS_HTML_RE = /^<|&(lt|gt|amp);|<(br|p)>/;
8✔
33

34

35
export function findRelevantInstance(vcalendar, instance, type) {
36
  for (let comp of vcalendar.getAllSubcomponents(type)) {
268✔
37
    let recId = comp.getFirstPropertyValue("recurrence-id");
266✔
38
    if (!instance && !recId) {
266✔
39
      return comp;
252✔
40
    } else if (instance && recId?.convertToZone(ICAL.Timezone.utcTimezone).toString() == instance) {
14✔
41
      return comp;
10✔
42
    }
43
  }
44
  return null;
6✔
45
}
46

47
export function transformDateXprop(value) {
48
  if (!value || value[4] == "-") {
174✔
49
    // No value, or already a jCal/rfc3339 time
50
    return value;
138✔
51
  } else if (value instanceof ICAL.Time) {
36✔
52
    // Core needs to allow x-props with params, then this will simply be parsed an ICAL.Time
53
    return value.toString();
30✔
54
  } else if (value.length == 16 && value.endsWith("Z")) {
6✔
55
    // An ICAL string value like 20240102T030405Z
56
    return ICAL.design.icalendar.value["date-time"].fromICAL(value);
4✔
57
  }
58

59
  return null;
2✔
60
}
61

62
export function itemToJson(item, calendar, isImport) {
63
  if (item.type == "event") {
32✔
64
    return eventToJson(item, calendar, isImport);
24✔
65
  } else if (item.type == "task") {
8✔
66
    return taskToJson(item, calendar, isImport);
6✔
67
  } else {
68
    throw new Error("Unknown item type: " + item.type);
2✔
69
  }
70
}
71

72
function eventToJson(item, calendar, isImport) {
73
  let veventprops = [];
24✔
74
  let oldItem = {
24✔
75
    format: "jcal",
76
    item: ["vcalendar", [], [["vevent", veventprops, []]]],
77
  };
78

79
  if (item.instance) {
24✔
80
    // patchEvent needs to find the relevant instance, which means we need the recurrence-id on the
81
    // old item.
82
    veventprops.push(
2✔
83
      ["recurrence-id", {}, item.instance.length == 10 ? "date" : "date-time", item.instance]
1!
84
    );
85
  }
86

87
  let entry = patchEvent(item, oldItem);
24✔
88
  if (item.id) {
20✔
89
    entry.iCalUID = item.id;
18✔
90
  }
91
  return entry;
20✔
92
}
93

94
function taskToJson(item, calendar, isImport) {
95
  let oldItem = {
6✔
96
    format: "jcal",
97
    item: ["vcalendar", [], [["vtodo", [], []]]],
98
  };
99

100
  let entry = patchTask(item, oldItem);
6✔
101
  if (item.id) {
6✔
102
    entry.id = item.id;
4✔
103
  }
104

105
  return entry;
6✔
106
}
107

108
export function jsonToItem(data) {
109
  if (data.entry?.kind == "tasks#task") {
52✔
110
    return jsonToTask(data);
10✔
111
  } else if (data.entry?.kind == "calendar#event") {
42✔
112
    return jsonToEvent(data);
38✔
113
  } else {
114
    data.calendar.console.error(`Invalid item type ${data.entry?.kind}`);
4✔
115
    return null;
4✔
116
  }
117
}
118

119
function jsonToTask({ entry, calendar }) {
120
  function setIf(prop, type, value, params = {}) {
54✔
121
    if (value) {
120✔
122
      vtodoprops.push([prop, params, type, value]);
100✔
123
    }
124
  }
125

126
  let vtodoprops = [];
12✔
127
  let vtodocomps = [];
12✔
128
  let vtodo = ["vtodo", vtodoprops, vtodocomps];
12✔
129

130
  setIf("uid", "text", entry.id);
12✔
131
  setIf("last-modified", "date-time", stripFractional(entry.updated));
12✔
132
  setIf("dtstamp", "date-time", stripFractional(entry.updated));
12✔
133

134
  setIf("summary", "text", entry.title);
12✔
135
  setIf("description", "text", entry.notes);
12✔
136
  setIf("url", "uri", entry.webViewLink);
12✔
137

138
  setIf("related-to", "text", entry.parent, { reltype: "PARENT" });
12✔
139
  setIf("x-google-sortkey", "integer", entry.position);
12✔
140

141
  let status;
142
  if (entry.deleted) {
12✔
143
    status = "CANCELLED";
2✔
144
  } else if (entry.status == "needsAction") {
10✔
145
    status = "NEEDS-ACTION";
2✔
146
  } else {
147
    status = "COMPLETED";
8✔
148
  }
149
  vtodoprops.push(["status", {}, "text", status]);
12✔
150
  if (status == "COMPLETED") {
12✔
151
    vtodoprops.push(["percent-complete", {}, "integer", 100]);
8✔
152
  }
153
  setIf("completed", "date-time", stripFractional(entry.completed));
12✔
154
  setIf("due", "date-time", stripFractional(entry.due));
12✔
155

156
  for (let link of entry.links || []) {
12✔
157
    vtodoprops.push([
8✔
158
      "attach",
159
      { filename: link.description, "x-google-type": link.type },
160
      "uri",
161
      link.link,
162
    ]);
163
  }
164

165
  return {
12✔
166
    id: entry.id,
167
    type: "task",
168
    metadata: {
169
      etag: entry.etag,
170
      path: entry.id,
171
    },
172
    format: "jcal",
173
    item: addVCalendar(vtodo)
174
  };
175
}
176

177
function haveRemindersChanged(remindersEntry, oldRemindersEntry) {
178
  if (
106✔
179
    remindersEntry.useDefault != oldRemindersEntry.useDefault ||
100✔
180
    remindersEntry.overrides?.length != oldRemindersEntry.overrides?.length
181
  ) {
182
    return true;
18✔
183
  }
184

185
  let reminderMap = new Set(remindersEntry.overrides?.map(entry => entry.method + entry.minutes) ?? []);
322!
186
  if (oldRemindersEntry.overrides?.some(entry => !reminderMap.has(entry.method + entry.minutes))) {
294✔
187
    return true;
14✔
188
  }
189

190
  return false;
74✔
191
}
192

193
function convertReminders(vevent) {
194
  // XXX While Google now supports multiple alarms and alarm values, we still need to fix bug 353492
195
  // first so we can better take care of finding out what alarm is used for snoozing.
196

197
  let reminders = { overrides: [], useDefault: false };
212✔
198
  for (let valarm of vevent.getAllSubcomponents("valarm")) {
212✔
199
    if (valarm.getFirstPropertyValue("x-default-alarm")) {
952✔
200
      // This is one of the Google default alarms on the calendar, it should therefore not be
201
      // serialized. We need to do this because Lightning does not have the concept of a default
202
      // alarm.
203
      reminders.useDefault = true;
134✔
204
      continue;
134✔
205
    }
206
    if (reminders.overrides.length == 5) {
818✔
207
      // Need to continue here, there may be x-default-alarms further on.
208
      continue;
134✔
209
    }
210

211
    let override = {};
684✔
212
    override.method = ALARM_ACTION_MAP_REV[valarm.getFirstPropertyValue("action")] || "popup";
684✔
213
    let trigger = valarm.getFirstProperty("trigger");
684✔
214
    let related = trigger.getParameter("related");
684✔
215
    if (trigger.type == "date-time") {
684✔
216
      override.minutes = Math.floor(
134✔
217
        vevent
218
          .getFirstPropertyValue("dtstart")
219
          .subtractDateTz(trigger.getFirstValue())
220
          .toSeconds() / 60
221
      );
222
    } else if (related == "END") {
550✔
223
      let dtend = vevent.getFirstPropertyValue("dtend");
134✔
224
      let length = dtend
134✔
225
        ? dtend.subtractDateTz(vevent.getFirstPropertyValue("dtstart")).toSeconds()
226
        : 0;
227
      override.minutes = -Math.floor((trigger.getFirstValue().toSeconds() + length) / 60);
134✔
228
    } else {
229
      override.minutes = -Math.floor(trigger.getFirstValue().toSeconds() / 60);
416✔
230
    }
231

232
    override.minutes = Math.min(Math.max(0, override.minutes), FOUR_WEEKS_IN_MINUTES);
684✔
233
    reminders.overrides.push(override);
684✔
234
  }
235

236
  if (!reminders.overrides.length && vevent.getFirstPropertyValue("x-default-alarm")) {
212✔
237
    delete reminders.overrides;
6✔
238
    reminders.useDefault = true;
6✔
239
  }
240

241
  return reminders;
212✔
242
}
243

244
function haveAttendeesChanged(event, oldEvent) {
245
  let oldAttendees = new Map(
106✔
246
    oldEvent.getAllProperties("attendee").map(attendee => [attendee.getFirstValue(), attendee])
146✔
247
  );
248
  let newAttendees = event.getAllProperties("attendee");
106✔
249
  let needsAttendeeUpdate = newAttendees.length != oldAttendees.size;
106✔
250
  let organizer = event.getFirstProperty("organizer");
106✔
251
  let organizerId = organizer?.getFirstParameter("partstat") ? organizer?.getFirstValue() : null;
106✔
252

253
  if (!needsAttendeeUpdate) {
106✔
254
    for (let attendee of newAttendees) {
90✔
255
      let attendeeId = attendee.getFirstValue();
126✔
256
      let oldAttendee = oldAttendees.get(attendeeId);
126✔
257
      if (!oldAttendee) {
126✔
258
        needsAttendeeUpdate = true;
2✔
259
        break;
2✔
260
      }
261

262
      if (attendeeId == organizerId) {
124✔
263
        // Thunderbird sets the participation status on the organizer, not the attendee
264
        attendee = organizer;
2✔
265
        oldAttendee = oldEvent.getFirstProperty("organizer");
2✔
266
      }
267

268
      if (
124✔
269
        attendee.getParameter("cn") != oldAttendee.getParameter("cn") ||
242✔
270
        attendee.getParameter("role") != oldAttendee.getParameter("role") ||
271
        attendee.getParameter("cutype") != oldAttendee.getParameter("cutype") ||
272
        attendee.getParameter("partstat") != oldAttendee.getParameter("partstat")
273
      ) {
274
        needsAttendeeUpdate = true;
10✔
275
        break;
10✔
276
      }
277
      oldAttendees.delete(attendeeId);
114✔
278
    }
279
    if (oldAttendees.size > 0) {
90✔
280
      needsAttendeeUpdate = true;
12✔
281
    }
282
  }
283

284
  return needsAttendeeUpdate;
106✔
285
}
286

287
function convertAttendee(attendee, organizer, isOrganizer) {
288
  function setIf(att, prop, value) {
289
    if (value) {
170✔
290
      att[prop] = value;
42✔
291
    }
292
  }
293

294
  let att = {};
66✔
295
  let attendeeId = attendee.getFirstValue();
66✔
296
  let organizerId = organizer?.getFirstValue();
66✔
297

298
  if (attendeeId == organizerId) {
66✔
299
    // Thunderbird sets the participation status on the organizer, not the attendee.
300
    attendee = organizer;
16✔
301
  }
302

303
  att.email = attendeeId.startsWith("mailto:") ? attendeeId.substr(7) : null;
66✔
304
  let emailParam = attendee.getFirstParameter("email");
66✔
305
  if (!att.email && emailParam) {
66✔
306
    att.email = emailParam.startsWith("mailto:") ? emailParam.substr(7) : emailParam;
4✔
307
  }
308

309
  setIf(att, "displayName", attendee.getFirstParameter("cn"));
66✔
310

311
  if (!isOrganizer) {
66✔
312
    att.optional = attendee.getFirstParameter("role") == "OPT-PARTICIPANT";
52✔
313
    att.resource = attendee.getFirstParameter("cutype") == "RESOURCE";
52✔
314
    att.responseStatus =
52✔
315
      ATTENDEE_STATUS_MAP_REV[attendee.getFirstParameter("partstat")] || "needsAction";
27✔
316

317
    setIf(att, "comment", attendee.getFirstParameter("comment"));
52✔
318
    setIf(att, "additionalGuests", attendee.getFirstParameter("x-num-guests"));
52✔
319
  }
320

321
  return att;
66✔
322
}
323

324
function convertRecurrence(vevent) {
325
  let recrules = new Set();
208✔
326

327
  for (let rrule of vevent.getAllProperties("rrule")) {
208✔
328
    recrules.add(rrule.toICALString());
18✔
329
  }
330

331
  // EXDATEs and RDATEs require special casing, since they might contain a TZID. To avoid the need
332
  // for conversion of TZID strings, convert to UTC before serialization.
333
  for (let rdate of vevent.getAllProperties("rdate")) {
208✔
334
    rdate.setValue(rdate.getFirstValue().convertToZone(ICAL.Timezone.utcTimezone));
18✔
335
    rdate.removeParameter("tzid");
18✔
336
    recrules.add(rdate.toICALString());
18✔
337
  }
338
  for (let exdate of vevent.getAllProperties("exdate")) {
208✔
339
    exdate.setValue(exdate.getFirstValue().convertToZone(ICAL.Timezone.utcTimezone));
18✔
340
    exdate.removeParameter("tzid");
18✔
341
    recrules.add(exdate.toICALString());
18✔
342
  }
343

344
  return recrules;
208✔
345
}
346

347
function convertRecurringSnoozeTime(vevent) {
348
  // This is an evil workaround since we don't have a really good system to save the snooze time
349
  // for recurring alarms or even retrieve them from the event. This should change when we have
350
  // multiple alarms support.
351
  let snoozeObj = {};
20✔
352
  for (let property of vevent.getAllProperties()) {
20✔
353
    if (property.name.startsWith("x-moz-snooze-time-")) {
182✔
354
      snoozeObj[property.name.substr(18)] = property.getFirstValue()?.toICALString();
2✔
355
    }
356
  }
357
  return Object.keys(snoozeObj).length ? JSON.stringify(snoozeObj) : null;
20✔
358
}
359

360
export function patchItem(item, oldItem) {
361
  if (item.type == "event") {
102✔
362
    return patchEvent(...arguments);
86✔
363
  } else if (item.type == "task") {
16✔
364
    return patchTask(...arguments);
14✔
365
  } else {
366
    throw new Error("Unknown item type: " + item.type);
2✔
367
  }
368
}
369

370
function patchTask(item, oldItem) {
371
  function setIfFirstProperty(obj, prop, jprop, transform = null) {
20✔
372
    let oldValue = oldTask.getFirstPropertyValue(jprop);
100✔
373
    let newValue = task.getFirstPropertyValue(jprop);
100✔
374

375
    if (oldValue?.toString() !== newValue?.toString()) {
100✔
376
      obj[prop] = transform ? transform(newValue) : newValue;
32✔
377
    }
378
  }
379

380
  let entry = {};
20✔
381
  let task = findRelevantInstance(new ICAL.Component(item.item), item.instance, "vtodo");
20✔
382
  let oldTask = findRelevantInstance(new ICAL.Component(oldItem.item), item.instance, "vtodo");
20✔
383

384
  setIfFirstProperty(entry, "title", "summary");
20✔
385
  setIfFirstProperty(entry, "status", "status", val => {
20✔
386
    return val == "COMPLETED" ? "completed" : "needsAction";
6✔
387
  });
388
  setIfFirstProperty(entry, "notes", "description");
20✔
389

390
  setIfFirstProperty(entry, "due", "due", dueDate => dueDate.toString());
20✔
391
  setIfFirstProperty(entry, "completed", "completed", completedDate => completedDate.toString());
20✔
392

393
  return entry;
20✔
394
}
395

396
function patchEvent(item, oldItem) {
397
  function setIfFirstProperty(obj, prop, jprop = null, transform = null) {
377✔
398
    let oldValue = oldEvent.getFirstPropertyValue(jprop || prop);
966✔
399
    let newValue = event.getFirstPropertyValue(jprop || prop);
966✔
400

401
    if (oldValue?.toString() !== newValue?.toString()) {
966✔
402
      obj[prop] = transform ? transform(newValue) : newValue;
148✔
403
    }
404
  }
405

406
  function getActualEnd(endEvent) {
407
    let endProp = endEvent.getFirstProperty("dtend");
216✔
408
    let end = endProp?.getFirstValue("dtend");
216✔
409
    let duration = endEvent.getFirstPropertyValue("duration");
216✔
410
    if (!end && duration) {
216✔
411
      let startProp = endEvent.getFirstProperty("dtstart");
2✔
412
      let start = startProp.getFirstValue();
2✔
413
      end = start.clone();
2✔
414
      end.addDuration(duration);
2✔
415

416
      return new ICAL.Property(["dtend", startProp.jCal[1], startProp.jCal[2], end.toString()]);
2✔
417
    }
418
    return endProp;
214✔
419
  }
420

421
  function setIfDateChanged(obj, prop, jprop) {
422
    let oldProp = oldEvent.getFirstProperty(jprop);
108✔
423
    let newProp = event.getFirstProperty(jprop);
108✔
424

425
    let oldDate = oldProp?.getFirstValue();
108✔
426
    let newDate = newProp?.getFirstValue();
108✔
427

428
    let oldZone = oldProp?.getFirstParameter("tzid");
108✔
429
    let newZone = newProp?.getFirstParameter("tzid");
108✔
430

431
    if (oldZone != newZone || !oldDate ^ !newDate || (oldDate && newDate && oldDate.compare(newDate) != 0)) {
108✔
432
      obj[prop] = dateToJson(newProp);
22✔
433
    }
434
  }
435

436
  function setIfEndDateChanged(obj, prop) {
437
    let oldProp = getActualEnd(oldEvent);
108✔
438
    let newProp = getActualEnd(event);
108✔
439

440
    let oldDate = oldProp?.getFirstValue();
108✔
441
    let newDate = newProp?.getFirstValue();
108✔
442

443
    let oldZone = oldProp?.getFirstParameter("tzid");
108✔
444
    let newZone = newProp?.getFirstParameter("tzid");
108✔
445

446
    if (oldZone != newZone || !oldDate ^ !newDate || (oldDate && newDate && oldDate.compare(newDate) != 0)) {
108✔
447
      obj[prop] = dateToJson(newProp);
24✔
448
    }
449
  }
450

451
  let entry = { extendedProperties: { shared: {}, private: {} } };
110✔
452

453
  let event = findRelevantInstance(new ICAL.Component(item.item), item.instance, "vevent");
110✔
454
  let oldEvent = findRelevantInstance(new ICAL.Component(oldItem.item), item.instance, "vevent");
110✔
455

456
  if (!event) {
110✔
457
    throw new Error(`Missing vevent in toplevel component ${item.item?.[0]}`);
2✔
458
  }
459

460
  setIfFirstProperty(entry, "summary");
108✔
461
  setIfFirstProperty(entry, "location");
108✔
462

463
  let oldDesc = oldEvent?.getFirstProperty("description");
108✔
464
  let newDesc = event?.getFirstProperty("description");
108✔
465
  let newHTML = newDesc?.getParameter("altrep");
108✔
466

467
  if (
108✔
468
    oldDesc?.getFirstValue() != newDesc?.getFirstValue() ||
101✔
469
    oldDesc?.getParameter("altrep") != newHTML
470
  ) {
471
    if (newHTML?.startsWith("data:text/html,")) {
16✔
472
      entry.description = decodeURIComponent(newHTML.slice("data:text/html,".length));
2✔
473
    } else {
474
      entry.description = newDesc?.getFirstValue();
14✔
475
    }
476
  }
477

478
  setIfDateChanged(entry, "start", "dtstart");
108✔
479
  setIfEndDateChanged(entry, "end");
108✔
480

481
  if (entry.end === null) {
108✔
482
    delete entry.end;
2✔
483
    entry.endTimeUnspecified = true;
2✔
484
  }
485

486
  if (event.getFirstProperty("rrule") || event.getFirstProperty("rdate")) {
108✔
487
    let oldRecurSnooze = convertRecurringSnoozeTime(oldEvent);
10✔
488
    let newRecurSnooze = convertRecurringSnoozeTime(event);
10✔
489
    if (oldRecurSnooze != newRecurSnooze) {
10✔
490
      entry.extendedProperties.private["X-GOOGLE-SNOOZE-RECUR"] = newRecurSnooze;
2✔
491
    }
492
  }
493

494
  if (item.instance) {
108✔
495
    entry.recurringEventId = item.id.replace(/@google.com$/, "");
4✔
496
    entry.originalStartTime = dateToJson(event.getFirstProperty("recurrence-id"));
4✔
497
  } else {
498
    let oldRecurrenceSet = convertRecurrence(oldEvent);
104✔
499
    let newRecurrence = [...convertRecurrence(event)];
104✔
500
    if (
104✔
501
      oldRecurrenceSet.size != newRecurrence.length ||
103✔
502
      newRecurrence.some(elem => !oldRecurrenceSet.has(elem))
22✔
503
    ) {
504
      entry.recurrence = newRecurrence;
6✔
505
    }
506
  }
507

508
  setIfFirstProperty(entry, "sequence");
108✔
509
  setIfFirstProperty(entry, "transparency", "transp", transparency => transparency?.toLowerCase());
108✔
510
  setIfFirstProperty(entry, "visibility", "class", visibility => visibility?.toLowerCase());
108✔
511

512
  setIfFirstProperty(entry, "status", "status", status => status?.toLowerCase());
108✔
513
  if (entry.status == "cancelled") {
108✔
514
    throw new Error("NS_ERROR_LOSS_OF_SIGNIFICANT_DATA");
2✔
515
  }
516
  if (entry.status == "none") {
106✔
517
    delete entry.status;
2✔
518
  }
519

520
  // Organizer
521
  let organizer = event.getFirstProperty("organizer");
106✔
522
  let organizerId = organizer?.getFirstValue();
106✔
523
  let oldOrganizerId = oldEvent.getFirstPropertyValue("organizer");
106✔
524
  if (!oldOrganizerId && organizerId) {
106✔
525
    // This can be an import, add the organizer
526
    entry.organizer = convertAttendee(organizer, organizer, true);
14✔
527
  } else if (oldOrganizerId != organizerId) {
92✔
528
    // Google requires a move() operation to do this, which is not yet implemented
529
    // eslint-disable-next-line no-console
530
    console.warn(`[calGoogleCalendar(${entry.summary})] Changing organizer requires a move, which is not implemented`);
2✔
531
  }
532

533
  // Attendees
534
  if (haveAttendeesChanged(event, oldEvent)) {
106✔
535
    entry.attendees = event.getAllProperties("attendee").map(attendee => convertAttendee(attendee, organizer));
52✔
536
  }
537

538
  let oldReminders = convertReminders(oldEvent);
106✔
539
  let reminders = convertReminders(event);
106✔
540
  if (haveRemindersChanged(reminders, oldReminders)) {
106✔
541
    entry.reminders = reminders;
32✔
542
  }
543

544
  // Categories
545
  function getAllCategories(vevent) {
546
    return vevent.getAllProperties("categories").reduce((acc, comp) => acc.concat(comp.getValues()), []);
212✔
547
  }
548

549
  let oldCatSet = new Set(getAllCategories(oldEvent));
106✔
550
  let newCatArray = getAllCategories(event);
106✔
551
  let newCatSet = new Set(newCatArray);
106✔
552

553
  if (
106✔
554
    oldCatSet.size != newCatSet.size ||
99✔
555
    oldCatSet.difference(newCatSet).size != 0
556
  ) {
557
    entry.extendedProperties.shared["X-MOZ-CATEGORIES"] = categoriesArrayToString(newCatArray);
14✔
558
  }
559

560
  // Conference info - we only support create and delete
561
  let confnew = event.getFirstPropertyValue("x-google-confnew");
106✔
562
  let confdata = event.getFirstPropertyValue("x-google-confdata");
106✔
563
  let oldConfData = oldEvent.getFirstPropertyValue("x-google-confdata");
106✔
564

565
  if (oldConfData && !confdata) {
106✔
566
    entry.conferenceData = null;
2✔
567
  } else if (confnew) {
104✔
568
    entry.conferenceData = {
2✔
569
      createRequest: {
570
        requestId: crypto.randomUUID(),
571
        conferenceSolutionKey: {
572
          type: confnew
573
        }
574
      }
575
    };
576
  }
577

578
  // Last ack and snooze time are always in UTC, when serialized they'll contain a Z
579
  setIfFirstProperty(
106✔
580
    entry.extendedProperties.private,
581
    "X-MOZ-LASTACK",
582
    "x-moz-lastack",
583
    transformDateXprop
584
  );
585
  setIfFirstProperty(
106✔
586
    entry.extendedProperties.private,
587
    "X-MOZ-SNOOZE-TIME",
588
    "x-moz-snooze-time",
589
    transformDateXprop
590
  );
591

592
  if (!Object.keys(entry.extendedProperties.shared).length) {
106✔
593
    delete entry.extendedProperties.shared;
92✔
594
  }
595
  if (!Object.keys(entry.extendedProperties.private).length) {
106✔
596
    delete entry.extendedProperties.private;
88✔
597
  }
598
  if (!Object.keys(entry.extendedProperties).length) {
106✔
599
    delete entry.extendedProperties;
86✔
600
  }
601

602
  setIfFirstProperty(entry, "colorId", "x-google-color-id");
106✔
603

604
  return entry;
106✔
605
}
606

607
export function jsonToDate(propName, dateObj, defaultTimezone, requestedZone = null) {
76✔
608
  if (!dateObj) {
152✔
609
    return null;
2✔
610
  }
611

612
  let params = {};
150✔
613
  let targetZone = requestedZone || dateObj.timeZone;
150✔
614

615
  if (targetZone && targetZone != "UTC") {
150✔
616
    params.tzid = dateObj.timeZone;
48✔
617
  }
618

619
  if (dateObj.date) {
150✔
620
    return [propName, params, "date", dateObj.date];
88✔
621
  } else {
622
    let timeString = stripFractional(dateObj.dateTime);
62✔
623
    if (defaultTimezone && targetZone && targetZone != defaultTimezone.tzid) {
62✔
624
      let time = ICAL.Time.fromDateTimeString(timeString);
10✔
625
      time.zone = defaultTimezone;
10✔
626

627
      let zone = TimezoneService.get(targetZone);
10✔
628
      if (zone) {
10✔
629
        timeString = time.convertToZone(zone).toString();
8✔
630
      } else {
631
        throw new Error(`Could not find zone ${targetZone}`);
2✔
632
      }
633
    }
634

635
    return [propName, params, "date-time", timeString];
60✔
636
  }
637
}
638

639
function dateToJson(property) {
640
  if (!property) {
50✔
641
    return null;
2✔
642
  }
643
  let dateobj = {};
48✔
644

645
  if (property.type == "date") {
48✔
646
    dateobj.date = property.getFirstValue().toString();
18✔
647
  } else {
648
    dateobj.dateTime = property.getFirstValue().toString();
30✔
649
    dateobj.timeZone = property.getFirstParameter("tzid");
30✔
650
  }
651

652
  return dateobj;
48✔
653
}
654

655
async function jsonToEvent({ entry, calendar, defaultReminders, defaultTimezone, accessRole, confSolutionCache }) {
656
  function setIf(prop, type, value, params = {}) {
517✔
657
    if (value) {
1,034✔
658
      veventprops.push([prop, params, type, value]);
636✔
659
    }
660
  }
661
  function pushPropIf(prop) {
662
    if (prop) {
68!
663
      veventprops.push(prop);
68✔
664
    }
665
  }
666

667
  let privateProps = entry.extendedProperties?.private || {};
68✔
668
  let sharedProps = entry.extendedProperties?.shared || {};
68✔
669

670
  let veventprops = [];
68✔
671
  let veventcomps = [];
68✔
672
  let vevent = ["vevent", veventprops, veventcomps];
68✔
673

674
  let uid = entry.iCalUID || (entry.recurringEventId || entry.id) + "@google.com";
68✔
675

676
  setIf("uid", "text", uid);
68✔
677
  setIf("created", "date-time", stripFractional(entry.created));
68✔
678
  setIf("last-modified", "date-time", stripFractional(entry.updated));
68✔
679
  setIf("dtstamp", "date-time", stripFractional(entry.updated));
68✔
680

681
  // Not pretty, but Google doesn't have a straightforward way to differentiate. As of writing,
682
  // they even have bugs in their own UI about displaying the string properly.
683
  if (entry.description?.match(CONTAINS_HTML_RE)) {
68✔
684
    let altrep = "data:text/html," + encodeURIComponent(entry.description);
2✔
685
    let parser = new window.DOMParser();
2✔
686
    let plain = parser.parseFromString(entry.description, "text/html").documentElement.textContent;
2✔
687
    veventprops.push(["description", { altrep }, "text", plain]);
2✔
688
  } else {
689
    veventprops.push(["description", {}, "text", entry.description ?? ""]);
66✔
690
  }
691

692
  setIf("location", "text", entry.location);
68✔
693
  setIf("status", "text", entry.status?.toUpperCase());
68✔
694

695
  if (entry.originalStartTime) {
68✔
696
    veventprops.push(jsonToDate("recurrence-id", entry.originalStartTime, defaultTimezone));
20✔
697
  }
698

699
  let isFreeBusy = accessRole == "freeBusyReader";
68✔
700
  let summary = isFreeBusy ? messenger.i18n.getMessage("busyTitle", calendar.name) : entry.summary;
68✔
701
  setIf("summary", "text", summary);
68✔
702
  setIf("class", "text", entry.visibility?.toUpperCase());
68✔
703
  setIf("sequence", "integer", entry.sequence);
68✔
704
  setIf("url", "uri", entry.htmlLink);
68✔
705
  setIf("transp", "text", entry.transparency?.toUpperCase());
68✔
706

707
  if (entry.eventType != "default") {
68✔
708
    setIf("x-google-event-type", "text", entry.eventType);
4✔
709
  }
710

711
  setIf("x-google-color-id", "text", entry.colorId);
68✔
712
  setIf("x-google-confdata", "text", entry.conferenceData ? JSON.stringify(entry.conferenceData) : null);
68✔
713

714
  if (entry.conferenceData && confSolutionCache) {
68✔
715
    let solution = entry.conferenceData.conferenceSolution;
8✔
716
    confSolutionCache[solution.key.type] = solution;
8✔
717
  }
718

719
  pushPropIf(jsonToDate("dtstart", entry.start, defaultTimezone));
68✔
720
  if (!entry.endTimeUnspecified) {
68✔
721
    veventprops.push(jsonToDate("dtend", entry.end, defaultTimezone));
52✔
722
  }
723

724
  // Organizer
725
  if (entry.organizer) {
68✔
726
    let id = entry.organizer.email
30✔
727
      ? "mailto:" + entry.organizer.email
728
      : "urn:id:" + entry.organizer.id;
729

730
    let orgparams = {};
30✔
731
    if (entry.organizer.displayName) {
30✔
732
      // eslint-disable-next-line id-length
733
      orgparams.cn = entry.organizer.displayName;
28✔
734
    }
735
    veventprops.push(["organizer", orgparams, "uri", id]);
30✔
736
  }
737

738
  // Recurrence properties
739
  for (let recItem of entry.recurrence || []) {
68✔
740
    let prop = ICAL.Property.fromString(recItem);
30✔
741
    veventprops.push(prop.jCal);
30✔
742
  }
743

744
  for (let attendee of entry.attendees || []) {
68✔
745
    let id = "mailto:" + attendee.email;
46✔
746
    let params = {
46✔
747
      role: attendee.optional ? "OPT-PARTICIPANT" : "REQ-PARTICIPANT",
23✔
748
      partstat: ATTENDEE_STATUS_MAP[attendee.responseStatus],
749
      cutype: attendee.resource ? "RESOURCE" : "INDIVIDUAL",
23✔
750
    };
751

752
    if (attendee.displayName) {
46✔
753
      // eslint-disable-next-line id-length
754
      params.cn = attendee.displayName;
28✔
755
    }
756

757
    veventprops.push(["attendee", params, "uri", id]);
46✔
758
  }
759

760
  // Reminders
761
  if (entry.reminders) {
68✔
762
    if (entry.reminders.useDefault) {
30✔
763
      veventcomps.push(...defaultReminders.map(alarmEntry => jsonToAlarm(alarmEntry, true)));
20✔
764

765
      if (!defaultReminders?.length) {
20✔
766
        // There are no default reminders, but we want to use the default in case the user changes
767
        // it in the future. Until we have VALARM extension which allow for a default settings, we
768
        // use an x-prop
769
        veventprops.push(["x-default-alarm", {}, "boolean", true]);
12✔
770
      }
771
    }
772

773
    if (entry.reminders.overrides) {
30✔
774
      for (let reminderEntry of entry.reminders.overrides) {
26✔
775
        veventcomps.push(jsonToAlarm(reminderEntry));
54✔
776
      }
777
    }
778
  }
779

780
  // We can set these directly as they are UTC RFC3339 timestamps, which works with jCal date-times
781
  setIf("x-moz-lastack", "date-time", transformDateXprop(stripFractional(privateProps["X-MOZ-LASTACK"])));
68✔
782
  setIf("x-moz-snooze-time", "date-time", transformDateXprop(stripFractional(privateProps["X-MOZ-SNOOZE-TIME"])));
68✔
783

784
  let snoozeObj;
785
  try {
68✔
786
    snoozeObj = JSON.parse(privateProps["X-GOOGLE-SNOOZE-RECUR"]);
68✔
787
  } catch (e) {
788
    // Ok to swallow
789
  }
790

791
  for (let [rid, value] of Object.entries(snoozeObj || {})) {
68✔
792
    setIf(
10✔
793
      "x-moz-snooze-time-" + rid,
794
      "date-time",
795
      ICAL.design.icalendar.value["date-time"].fromICAL(value)
796
    );
797
  }
798

799
  // Google does not support categories natively, but allows us to store data as an
800
  // "extendedProperty", and here it's going to be retrieved again
801
  let categories = categoriesStringToArray(sharedProps["X-MOZ-CATEGORIES"]);
68✔
802
  if (categories && categories.length) {
68✔
803
    veventprops.push(["categories", {}, "text", ...categories]);
28✔
804
  }
805

806
  // Attachments (read-only)
807
  for (let attach of (entry.attachments || [])) {
68✔
808
    let props = { "managed-id": attach.fileId, filename: attach.title };
18✔
809
    if (attach.mimeType) {
18!
810
      props.fmttype = attach.mimeType;
18✔
811
    }
812

813
    veventprops.push(["attach", props, "uri", attach.fileUrl]);
18✔
814
  }
815

816
  let shell = {
68✔
817
    id: uid,
818
    type: "event",
819
    metadata: {
820
      etag: entry.etag,
821
      path: entry.id,
822
    },
823
    format: "jcal",
824
    item: addVCalendar(vevent)
825
  };
826

827
  return shell;
68✔
828
}
829

830
export function jsonToAlarm(entry, isDefault = false) {
27✔
831
  let valarmprops = [];
62✔
832
  let valarm = ["valarm", valarmprops, []];
62✔
833

834
  let dur = new ICAL.Duration({
62✔
835
    minutes: -entry.minutes,
836
  });
837
  dur.normalize();
62✔
838

839
  valarmprops.push(["action", {}, "text", ALARM_ACTION_MAP[entry.method]]);
62✔
840
  valarmprops.push(["description", {}, "text", "alarm"]);
62✔
841
  valarmprops.push(["trigger", {}, "duration", dur.toString()]);
62✔
842

843
  if (isDefault) {
62✔
844
    valarmprops.push(["x-default-alarm", {}, "boolean", true]);
8✔
845
  }
846
  return valarm;
62✔
847
}
848

849
export class ItemSaver {
850
  #confSolutionCache = {};
72✔
851

852
  missingParents = [];
72✔
853
  parentItems = Object.create(null);
72✔
854

855
  constructor(calendar) {
856
    this.calendar = calendar;
72✔
857
    this.console = calendar.console;
72✔
858
  }
859

860
  async parseItemStream(data) {
861
    if (data.kind == "calendar#events") {
36✔
862
      return this.parseEventStream(data);
16✔
863
    } else if (data.kind == "tasks#tasks") {
20✔
864
      return this.parseTaskStream(data);
16✔
865
    } else {
866
      throw new Error("Invalid stream type: " + (data?.kind || data?.status));
4✔
867
    }
868
  }
869

870
  async parseEventStream(data) {
871
    if (data.timeZone) {
42✔
872
      this.console.log("Timezone from event stream is " + data.timeZone);
16✔
873
      this.calendar.setCalendarPref("timeZone", data.timeZone);
16✔
874
    }
875

876
    if (data.items?.length) {
42✔
877
      this.console.log(`Parsing ${data.items.length} received events`);
24✔
878
    } else {
879
      this.console.log("No events have been changed");
18✔
880
      return;
18✔
881
    }
882

883
    let exceptionItems = [];
24✔
884

885
    let defaultTimezone = TimezoneService.get(data.timeZone);
24✔
886

887
    // In the first pass, we go through the data and sort into parent items and exception items, as
888
    // the parent item might be after the exception in the stream.
889
    await Promise.all(
24✔
890
      data.items.map(async entry => {
891
        let item = await jsonToEvent({
30✔
892
          entry,
893
          calendar: this.calendar,
894
          defaultReminders: data.defaultReminders || [],
30✔
895
          defaultTimezone,
896
          confSolutionCache: this.#confSolutionCache
897
        });
898
        item.item = addVCalendar(item.item);
30✔
899

900
        if (entry.originalStartTime) {
30✔
901
          exceptionItems.push(item);
16✔
902
        } else {
903
          this.parentItems[item.id] = item;
14✔
904
        }
905
      })
906
    );
907

908
    for (let exc of exceptionItems) {
24✔
909
      let item = this.parentItems[exc.id];
16✔
910

911
      if (item) {
16✔
912
        this.processException(exc, item);
4✔
913
      } else {
914
        this.missingParents.push(exc);
12✔
915
      }
916
    }
917
  }
918

919
  async parseTaskStream(data) {
920
    if (data.items?.length) {
18✔
921
      this.console.log(`Parsing ${data.items.length} received tasks`);
2✔
922
    } else {
923
      this.console.log("No tasks have been changed");
16✔
924
      return;
16✔
925
    }
926

927
    await Promise.all(
2✔
928
      data.items.map(async entry => {
929
        let item = await jsonToTask({ entry, calendar: this.calendar });
2✔
930
        item.item = addVCalendar(item.item);
2✔
931
        await this.commitItem(item);
2✔
932
      })
933
    );
934
  }
935

936
  processException(exc, item) {
937
    let itemCalendar = new ICAL.Component(item.item);
10✔
938
    let itemEvent = itemCalendar.getFirstSubcomponent("vevent");
10✔
939

940
    let exceptionCalendar = new ICAL.Component(exc.item);
10✔
941
    let exceptionEvent = exceptionCalendar.getFirstSubcomponent("vevent");
10✔
942

943
    if (itemEvent.getFirstPropertyValue("status") == "CANCELLED") {
10✔
944
      // Cancelled parent items don't have the full amount of information, specifically no
945
      // recurrence info. Since they are cancelled anyway, we can just ignore processing this
946
      // exception.
947
      return;
2✔
948
    }
949

950
    if (exceptionEvent.getFirstPropertyValue("status") == "CANCELLED") {
8✔
951
      let recId = exceptionEvent.getFirstProperty("recurrence-id");
2✔
952
      let exdate = itemEvent.addPropertyWithValue("exdate", recId.getFirstValue().clone());
2✔
953
      let recIdZone = recId.getParameter("tzid");
2✔
954
      if (recIdZone) {
2!
UNCOV
955
        exdate.setParameter("tzid", recIdZone);
×
956
      }
957
    } else {
958
      itemCalendar.addSubcomponent(exceptionEvent);
6✔
959
    }
960

961
    this.parentItems[item.id] = item;
8✔
962
  }
963

964
  async commitItem(item) {
965
    // This is a normal item. If it was canceled, then it should be deleted, otherwise it should be
966
    // either added or modified. The relaxed mode of the cache calendar takes care of the latter two
967
    // cases.
968
    let vcalendar = new ICAL.Component(item.item);
22✔
969
    let vcomp = vcalendar.getFirstSubcomponent("vevent") || vcalendar.getFirstSubcomponent("vtodo");
22✔
970

971
    if (vcomp.getFirstPropertyValue("status") == "CANCELLED") {
22✔
972
      // Catch the error here if the event is already removed from the calendar
973
      await messenger.calendar.items.remove(this.calendar.cacheId, item.id).catch(e => {});
4✔
974
    } else {
975
      await messenger.calendar.items.create(this.calendar.cacheId, item);
18✔
976
    }
977
  }
978

979
  /**
980
   * Handle all remaining exceptions in the item saver. Ensures that any missing parent items are
981
   * searched for or created.
982
   */
983
  async complete() {
984
    await Promise.all(
52✔
985
      this.missingParents.map(async exc => {
986
        let excCalendar = new ICAL.Component(exc.item);
12✔
987
        let excEvent = excCalendar.getFirstSubcomponent("vevent");
12✔
988

989
        let item;
990
        if (exc.id in this.parentItems) {
12✔
991
          // Parent item could have been on a later page, check again
992
          item = this.parentItems[exc.id];
2✔
993
        } else {
994
          // Otherwise check if we happen to have it in the database
995
          item = await messenger.calendar.items.get(this.calendar.cacheId, exc.id, {
10✔
996
            returnFormat: "jcal",
997
          });
998
          if (item) {
10✔
999
            this.parentItems[exc.id] = item;
4✔
1000
          }
1001
        }
1002

1003
        if (item) {
12✔
1004
          delete item.calendarId;
6✔
1005
          this.processException(exc, item);
6✔
1006
        } else if (excEvent.getFirstPropertyValue("status") != "CANCELLED") {
6✔
1007
          // If the item could not be found, it could be that the user is invited to an instance of
1008
          // a recurring event. Unless this is a cancelled exception, create a mock parent item with
1009
          // one positive RDATE.
1010

1011
          // Copy dtstart and rdate, same timezone
1012
          let recId = excEvent.getFirstProperty("recurrence-id");
4✔
1013
          let dtStart = excEvent.updatePropertyWithValue("dtstart", recId.getFirstValue().clone());
4✔
1014
          let rDate = excEvent.updatePropertyWithValue("rdate", recId.getFirstValue().clone());
4✔
1015
          let recTzid = recId.getParameter("tzid");
4✔
1016
          if (recTzid) {
4✔
1017
            dtStart.setParameter("tzid", recTzid);
2✔
1018
            rDate.setParameter("tzid", recTzid);
2✔
1019
          }
1020

1021
          excEvent.removeAllProperties("recurrence-id");
4✔
1022
          excEvent.updatePropertyWithValue("x-moz-faked-master", "1");
4✔
1023

1024
          // Promote the item to a parent item we'll commit later
1025
          this.parentItems[exc.id] = exc;
4✔
1026
        }
1027
      })
1028
    );
1029
    this.missingParents = [];
52✔
1030

1031
    // Commit all parents, they have collected all the exceptions by now
1032
    await Promise.all(
52✔
1033
      Object.values(this.parentItems).map(parent => {
1034
        return this.commitItem(parent);
20✔
1035
      })
1036
    );
1037
    this.parentItems = Object.create(null);
52✔
1038

1039
    // Save the conference icon cache
1040
    let conferenceSolutions = await this.calendar.getCalendarPref("conferenceSolutions", {});
52✔
1041

1042
    await Promise.all(Object.entries(this.#confSolutionCache).map(async ([key, solution]) => {
52✔
1043
      let savedSolution = conferenceSolutions[key];
8✔
1044

1045
      if (savedSolution && savedSolution.iconUri == solution.iconUri) {
8✔
1046
        // The icon uri has not changed, take the icon from our cache
1047
        solution.iconCache = savedSolution.iconCache;
2✔
1048
      }
1049

1050
      if (!solution.iconCache) {
8✔
1051
        try {
6✔
1052
          solution.iconCache = Array.from(await fetch(solution.iconUri).then(resp => resp.bytes()));
6✔
1053
        } catch (e) {
1054
          // Ok to fail
1055
        }
1056
      }
1057
    }));
1058

1059
    Object.assign(conferenceSolutions, this.#confSolutionCache);
52✔
1060
    await this.calendar.setCalendarPref("conferenceSolutions", conferenceSolutions);
52✔
1061
  }
1062
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc