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

kewisch / gdata-provider / 14550577690

19 Apr 2025 03:23PM UTC coverage: 91.152% (+0.04%) from 91.113%
14550577690

push

github

kewisch
fix: Only save overriden properties, fall back to manifest

905 of 957 branches covered (94.57%)

1226 of 1345 relevant lines covered (91.15%)

79.29 hits per line

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

99.79
/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)) {
260✔
37
    let recId = comp.getFirstPropertyValue("recurrence-id");
258✔
38
    if (!instance && !recId) {
258✔
39
      return comp;
244✔
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] == "-") {
166✔
49
    // No value, or already a jCal/rfc3339 time
50
    return value;
130✔
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 (
102✔
179
    remindersEntry.useDefault != oldRemindersEntry.useDefault ||
96✔
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) ?? []);
302!
186
  if (oldRemindersEntry.overrides?.some(entry => !reminderMap.has(entry.method + entry.minutes))) {
274✔
187
    return true;
14✔
188
  }
189

190
  return false;
70✔
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 };
204✔
198
  for (let valarm of vevent.getAllSubcomponents("valarm")) {
204✔
199
    if (valarm.getFirstPropertyValue("x-default-alarm")) {
896✔
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;
126✔
204
      continue;
126✔
205
    }
206
    if (reminders.overrides.length == 5) {
770✔
207
      // Need to continue here, there may be x-default-alarms further on.
208
      continue;
126✔
209
    }
210

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

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

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

241
  return reminders;
204✔
242
}
243

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

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

262
      if (attendeeId == organizerId) {
116✔
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 (
116✔
269
        attendee.getParameter("cn") != oldAttendee.getParameter("cn") ||
226✔
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);
106✔
278
    }
279
    if (oldAttendees.size > 0) {
86✔
280
      needsAttendeeUpdate = true;
12✔
281
    }
282
  }
283

284
  return needsAttendeeUpdate;
102✔
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();
200✔
326

327
  for (let rrule of vevent.getAllProperties("rrule")) {
200✔
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")) {
200✔
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")) {
200✔
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;
200✔
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") {
98✔
362
    return patchEvent(...arguments);
82✔
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) {
363✔
398
    let oldValue = oldEvent.getFirstPropertyValue(jprop || prop);
930✔
399
    let newValue = event.getFirstPropertyValue(jprop || prop);
930✔
400

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

406
  function getActualEnd(endEvent) {
407
    let endProp = endEvent.getFirstProperty("dtend");
208✔
408
    let end = endProp?.getFirstValue("dtend");
208✔
409
    let duration = endEvent.getFirstPropertyValue("duration");
208✔
410
    if (!end && duration) {
208✔
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;
206✔
419
  }
420

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

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

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

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

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

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

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

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

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

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

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

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

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

467
  if (
104✔
468
    oldDesc?.getFirstValue() != newDesc?.getFirstValue() ||
97✔
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");
104✔
479
  setIfEndDateChanged(entry, "end");
104✔
480

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

486
  if (event.getFirstProperty("rrule") || event.getFirstProperty("rdate")) {
104✔
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) {
104✔
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);
100✔
499
    let newRecurrence = [...convertRecurrence(event)];
100✔
500
    if (
100✔
501
      oldRecurrenceSet.size != newRecurrence.length ||
99✔
502
      newRecurrence.some(elem => !oldRecurrenceSet.has(elem))
22✔
503
    ) {
504
      entry.recurrence = newRecurrence;
6✔
505
    }
506
  }
507

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

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

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

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

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

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

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

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

559
  // Last ack and snooze time are always in UTC, when serialized they'll contain a Z
560
  setIfFirstProperty(
102✔
561
    entry.extendedProperties.private,
562
    "X-MOZ-LASTACK",
563
    "x-moz-lastack",
564
    transformDateXprop
565
  );
566
  setIfFirstProperty(
102✔
567
    entry.extendedProperties.private,
568
    "X-MOZ-SNOOZE-TIME",
569
    "x-moz-snooze-time",
570
    transformDateXprop
571
  );
572

573
  if (!Object.keys(entry.extendedProperties.shared).length) {
102✔
574
    delete entry.extendedProperties.shared;
88✔
575
  }
576
  if (!Object.keys(entry.extendedProperties.private).length) {
102✔
577
    delete entry.extendedProperties.private;
84✔
578
  }
579
  if (!Object.keys(entry.extendedProperties).length) {
102✔
580
    delete entry.extendedProperties;
82✔
581
  }
582

583
  setIfFirstProperty(entry, "colorId", "x-google-color-id");
102✔
584

585
  return entry;
102✔
586
}
587

588
export function jsonToDate(propName, dateObj, defaultTimezone, requestedZone = null) {
74✔
589
  if (!dateObj) {
148✔
590
    return null;
2✔
591
  }
592

593
  let params = {};
146✔
594
  let targetZone = requestedZone || dateObj.timeZone;
146✔
595

596
  if (targetZone && targetZone != "UTC") {
146✔
597
    params.tzid = dateObj.timeZone;
48✔
598
  }
599

600
  if (dateObj.date) {
146✔
601
    return [propName, params, "date", dateObj.date];
84✔
602
  } else {
603
    let timeString = stripFractional(dateObj.dateTime);
62✔
604
    if (defaultTimezone && targetZone && targetZone != defaultTimezone.tzid) {
62✔
605
      let time = ICAL.Time.fromDateTimeString(timeString);
10✔
606
      time.zone = defaultTimezone;
10✔
607

608
      let zone = TimezoneService.get(targetZone);
10✔
609
      if (zone) {
10✔
610
        timeString = time.convertToZone(zone).toString();
8✔
611
      } else {
612
        throw new Error(`Could not find zone ${targetZone}`);
2✔
613
      }
614
    }
615

616
    return [propName, params, "date-time", timeString];
60✔
617
  }
618
}
619

620
function dateToJson(property) {
621
  if (!property) {
50✔
622
    return null;
2✔
623
  }
624
  let dateobj = {};
48✔
625

626
  if (property.type == "date") {
48✔
627
    dateobj.date = property.getFirstValue().toString();
18✔
628
  } else {
629
    dateobj.dateTime = property.getFirstValue().toString();
30✔
630
    dateobj.timeZone = property.getFirstParameter("tzid");
30✔
631
  }
632

633
  return dateobj;
48✔
634
}
635

636
async function jsonToEvent({ entry, calendar, defaultReminders, defaultTimezone, accessRole }) {
637
  function setIf(prop, type, value, params = {}) {
487✔
638
    if (value) {
974✔
639
      veventprops.push([prop, params, type, value]);
576✔
640
    }
641
  }
642
  function pushPropIf(prop) {
643
    if (prop) {
64!
644
      veventprops.push(prop);
64✔
645
    }
646
  }
647

648
  let privateProps = entry.extendedProperties?.private || {};
64✔
649
  let sharedProps = entry.extendedProperties?.shared || {};
64✔
650

651
  let veventprops = [];
64✔
652
  let veventcomps = [];
64✔
653
  let vevent = ["vevent", veventprops, veventcomps];
64✔
654

655
  let uid = entry.iCalUID || (entry.recurringEventId || entry.id) + "@google.com";
64✔
656

657
  setIf("uid", "text", uid);
64✔
658
  setIf("created", "date-time", stripFractional(entry.created));
64✔
659
  setIf("last-modified", "date-time", stripFractional(entry.updated));
64✔
660
  setIf("dtstamp", "date-time", stripFractional(entry.updated));
64✔
661

662
  // Not pretty, but Google doesn't have a straightforward way to differentiate. As of writing,
663
  // they even have bugs in their own UI about displaying the string properly.
664
  if (entry.description?.match(CONTAINS_HTML_RE)) {
64✔
665
    let altrep = "data:text/html," + encodeURIComponent(entry.description);
2✔
666
    let parser = new window.DOMParser();
2✔
667
    let plain = parser.parseFromString(entry.description, "text/html").documentElement.textContent;
2✔
668
    veventprops.push(["description", { altrep }, "text", plain]);
2✔
669
  } else {
670
    veventprops.push(["description", {}, "text", entry.description ?? ""]);
62✔
671
  }
672

673
  setIf("location", "text", entry.location);
64✔
674
  setIf("status", "text", entry.status?.toUpperCase());
64✔
675

676
  if (entry.originalStartTime) {
64✔
677
    veventprops.push(jsonToDate("recurrence-id", entry.originalStartTime, defaultTimezone));
20✔
678
  }
679

680
  let isFreeBusy = accessRole == "freeBusyReader";
64✔
681
  let summary = isFreeBusy ? messenger.i18n.getMessage("busyTitle", calendar.name) : entry.summary;
64✔
682
  setIf("summary", "text", summary);
64✔
683
  setIf("class", "text", entry.visibility?.toUpperCase());
64✔
684
  setIf("sequence", "integer", entry.sequence);
64✔
685
  setIf("url", "uri", entry.htmlLink);
64✔
686
  setIf("transp", "text", entry.transparency?.toUpperCase());
64✔
687

688
  if (entry.eventType != "default") {
64✔
689
    setIf("x-google-event-type", "text", entry.eventType);
4✔
690
  }
691

692
  setIf("x-google-color-id", "text", entry.colorId);
64✔
693
  setIf("x-google-confdata", "text", entry.conferenceData ? JSON.stringify(entry.conferenceData) : null);
64✔
694

695
  pushPropIf(jsonToDate("dtstart", entry.start, defaultTimezone));
64✔
696
  if (!entry.endTimeUnspecified) {
64✔
697
    veventprops.push(jsonToDate("dtend", entry.end, defaultTimezone));
52✔
698
  }
699

700
  // Organizer
701
  if (entry.organizer) {
64✔
702
    let id = entry.organizer.email
26✔
703
      ? "mailto:" + entry.organizer.email
704
      : "urn:id:" + entry.organizer.id;
705

706
    let orgparams = {};
26✔
707
    if (entry.organizer.displayName) {
26✔
708
      // eslint-disable-next-line id-length
709
      orgparams.cn = entry.organizer.displayName;
24✔
710
    }
711
    veventprops.push(["organizer", orgparams, "uri", id]);
26✔
712
  }
713

714
  // Recurrence properties
715
  for (let recItem of entry.recurrence || []) {
64✔
716
    let prop = ICAL.Property.fromString(recItem);
30✔
717
    veventprops.push(prop.jCal);
30✔
718
  }
719

720
  for (let attendee of entry.attendees || []) {
64✔
721
    let id = "mailto:" + attendee.email;
42✔
722
    let params = {
42✔
723
      role: attendee.optional ? "OPT-PARTICIPANT" : "REQ-PARTICIPANT",
21✔
724
      partstat: ATTENDEE_STATUS_MAP[attendee.responseStatus],
725
      cutype: attendee.resource ? "RESOURCE" : "INDIVIDUAL",
21✔
726
    };
727

728
    if (attendee.displayName) {
42✔
729
      // eslint-disable-next-line id-length
730
      params.cn = attendee.displayName;
24✔
731
    }
732

733
    veventprops.push(["attendee", params, "uri", id]);
42✔
734
  }
735

736
  // Reminders
737
  if (entry.reminders) {
64✔
738
    if (entry.reminders.useDefault) {
26✔
739
      veventcomps.push(...defaultReminders.map(alarmEntry => jsonToAlarm(alarmEntry, true)));
20✔
740

741
      if (!defaultReminders?.length) {
20✔
742
        // There are no default reminders, but we want to use the default in case the user changes
743
        // it in the future. Until we have VALARM extension which allow for a default settings, we
744
        // use an x-prop
745
        veventprops.push(["x-default-alarm", {}, "boolean", true]);
12✔
746
      }
747
    }
748

749
    if (entry.reminders.overrides) {
26✔
750
      for (let reminderEntry of entry.reminders.overrides) {
22✔
751
        veventcomps.push(jsonToAlarm(reminderEntry));
50✔
752
      }
753
    }
754
  }
755

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

760
  let snoozeObj;
761
  try {
64✔
762
    snoozeObj = JSON.parse(privateProps["X-GOOGLE-SNOOZE-RECUR"]);
64✔
763
  } catch (e) {
764
    // Ok to swallow
765
  }
766

767
  for (let [rid, value] of Object.entries(snoozeObj || {})) {
64✔
768
    setIf(
10✔
769
      "x-moz-snooze-time-" + rid,
770
      "date-time",
771
      ICAL.design.icalendar.value["date-time"].fromICAL(value)
772
    );
773
  }
774

775
  // Google does not support categories natively, but allows us to store data as an
776
  // "extendedProperty", and here it's going to be retrieved again
777
  let categories = categoriesStringToArray(sharedProps["X-MOZ-CATEGORIES"]);
64✔
778
  if (categories && categories.length) {
64✔
779
    veventprops.push(["categories", {}, "text", ...categories]);
24✔
780
  }
781

782
  // Attachments (read-only)
783
  for (let attach of (entry.attachments || [])) {
64✔
784
    let props = { "managed-id": attach.fileId, filename: attach.title };
18✔
785
    if (attach.mimeType) {
18!
786
      props.fmttype = attach.mimeType;
18✔
787
    }
788

789
    veventprops.push(["attach", props, "uri", attach.fileUrl]);
18✔
790
    console.log(["attach", props, "uri", attach.fileUrl]);
18✔
791
  }
792

793
  let shell = {
64✔
794
    id: uid,
795
    type: "event",
796
    metadata: {
797
      etag: entry.etag,
798
      path: entry.id,
799
    },
800
    format: "jcal",
801
    item: addVCalendar(vevent)
802
  };
803

804
  return shell;
64✔
805
}
806

807
export function jsonToAlarm(entry, isDefault = false) {
25✔
808
  let valarmprops = [];
58✔
809
  let valarm = ["valarm", valarmprops, []];
58✔
810

811
  let dur = new ICAL.Duration({
58✔
812
    minutes: -entry.minutes,
813
  });
814
  dur.normalize();
58✔
815

816
  valarmprops.push(["action", {}, "text", ALARM_ACTION_MAP[entry.method]]);
58✔
817
  valarmprops.push(["description", {}, "text", "alarm"]);
58✔
818
  valarmprops.push(["trigger", {}, "duration", dur.toString()]);
58✔
819

820
  if (isDefault) {
58✔
821
    valarmprops.push(["x-default-alarm", {}, "boolean", true]);
8✔
822
  }
823
  return valarm;
58✔
824
}
825

826
export class ItemSaver {
827
  missingParents = [];
70✔
828
  parentItems = Object.create(null);
70✔
829

830
  constructor(calendar) {
831
    this.calendar = calendar;
70✔
832
    this.console = calendar.console;
70✔
833
  }
834

835
  async parseItemStream(data) {
836
    if (data.kind == "calendar#events") {
36✔
837
      return this.parseEventStream(data);
16✔
838
    } else if (data.kind == "tasks#tasks") {
20✔
839
      return this.parseTaskStream(data);
16✔
840
    } else {
841
      throw new Error("Invalid stream type: " + (data?.kind || data?.status));
4✔
842
    }
843
  }
844

845
  async parseEventStream(data) {
846
    if (data.timeZone) {
40✔
847
      this.console.log("Timezone from event stream is " + data.timeZone);
16✔
848
      this.calendar.setCalendarPref("timeZone", data.timeZone);
16✔
849
    }
850

851
    if (data.items?.length) {
40✔
852
      this.console.log(`Parsing ${data.items.length} received events`);
22✔
853
    } else {
854
      this.console.log("No events have been changed");
18✔
855
      return;
18✔
856
    }
857

858
    let exceptionItems = [];
22✔
859

860
    let defaultTimezone = TimezoneService.get(data.timeZone);
22✔
861

862
    // In the first pass, we go through the data and sort into parent items and exception items, as
863
    // the parent item might be after the exception in the stream.
864
    await Promise.all(
22✔
865
      data.items.map(async entry => {
866
        let item = await jsonToEvent({
26✔
867
          entry,
868
          calendar: this.calendar,
869
          defaultReminders: data.defaultReminders || [],
26✔
870
          defaultTimezone
871
        });
872
        item.item = addVCalendar(item.item);
26✔
873

874
        if (entry.originalStartTime) {
26✔
875
          exceptionItems.push(item);
16✔
876
        } else {
877
          this.parentItems[item.id] = item;
10✔
878
        }
879
      })
880
    );
881

882
    for (let exc of exceptionItems) {
22✔
883
      let item = this.parentItems[exc.id];
16✔
884

885
      if (item) {
16✔
886
        this.processException(exc, item);
4✔
887
      } else {
888
        this.missingParents.push(exc);
12✔
889
      }
890
    }
891
  }
892

893
  async parseTaskStream(data) {
894
    if (data.items?.length) {
18✔
895
      this.console.log(`Parsing ${data.items.length} received tasks`);
2✔
896
    } else {
897
      this.console.log("No tasks have been changed");
16✔
898
      return;
16✔
899
    }
900

901
    await Promise.all(
2✔
902
      data.items.map(async entry => {
903
        let item = await jsonToTask({ entry, calendar: this.calendar });
2✔
904
        item.item = addVCalendar(item.item);
2✔
905
        await this.commitItem(item);
2✔
906
      })
907
    );
908
  }
909

910
  processException(exc, item) {
911
    let itemCalendar = new ICAL.Component(item.item);
10✔
912
    let itemEvent = itemCalendar.getFirstSubcomponent("vevent");
10✔
913

914
    let exceptionCalendar = new ICAL.Component(exc.item);
10✔
915
    let exceptionEvent = exceptionCalendar.getFirstSubcomponent("vevent");
10✔
916

917
    if (itemEvent.getFirstPropertyValue("status") == "CANCELLED") {
10✔
918
      // Cancelled parent items don't have the full amount of information, specifically no
919
      // recurrence info. Since they are cancelled anyway, we can just ignore processing this
920
      // exception.
921
      return;
2✔
922
    }
923

924
    if (exceptionEvent.getFirstPropertyValue("status") == "CANCELLED") {
8✔
925
      let recId = exceptionEvent.getFirstProperty("recurrence-id");
2✔
926
      let exdate = itemEvent.addPropertyWithValue("exdate", recId.getFirstValue().clone());
2✔
927
      let recIdZone = recId.getParameter("tzid");
2✔
928
      if (recIdZone) {
2!
929
        exdate.setParameter("tzid", recIdZone);
×
930
      }
931
    } else {
932
      itemCalendar.addSubcomponent(exceptionEvent);
6✔
933
    }
934

935
    this.parentItems[item.id] = item;
8✔
936
  }
937

938
  async commitItem(item) {
939
    // This is a normal item. If it was canceled, then it should be deleted, otherwise it should be
940
    // either added or modified. The relaxed mode of the cache calendar takes care of the latter two
941
    // cases.
942
    let vcalendar = new ICAL.Component(item.item);
20✔
943
    let vcomp = vcalendar.getFirstSubcomponent("vevent") || vcalendar.getFirstSubcomponent("vtodo");
20✔
944

945
    if (vcomp.getFirstPropertyValue("status") == "CANCELLED") {
20✔
946
      // Catch the error here if the event is already removed from the calendar
947
      await messenger.calendar.items.remove(this.calendar.cacheId, item.id).catch(e => {});
4✔
948
    } else {
949
      await messenger.calendar.items.create(this.calendar.cacheId, item);
16✔
950
    }
951
  }
952

953
  /**
954
   * Handle all remaining exceptions in the item saver. Ensures that any missing parent items are
955
   * searched for or created.
956
   */
957
  async complete() {
958
    await Promise.all(
50✔
959
      this.missingParents.map(async exc => {
960
        let excCalendar = new ICAL.Component(exc.item);
12✔
961
        let excEvent = excCalendar.getFirstSubcomponent("vevent");
12✔
962

963
        let item;
964
        if (exc.id in this.parentItems) {
12✔
965
          // Parent item could have been on a later page, check again
966
          item = this.parentItems[exc.id];
2✔
967
        } else {
968
          // Otherwise check if we happen to have it in the database
969
          item = await messenger.calendar.items.get(this.calendar.cacheId, exc.id, {
10✔
970
            returnFormat: "jcal",
971
          });
972
          if (item) {
10✔
973
            this.parentItems[exc.id] = item;
4✔
974
          }
975
        }
976

977
        if (item) {
12✔
978
          delete item.calendarId;
6✔
979
          this.processException(exc, item);
6✔
980
        } else if (excEvent.getFirstPropertyValue("status") != "CANCELLED") {
6✔
981
          // If the item could not be found, it could be that the user is invited to an instance of
982
          // a recurring event. Unless this is a cancelled exception, create a mock parent item with
983
          // one positive RDATE.
984

985
          // Copy dtstart and rdate, same timezone
986
          let recId = excEvent.getFirstProperty("recurrence-id");
4✔
987
          let dtStart = excEvent.updatePropertyWithValue("dtstart", recId.getFirstValue().clone());
4✔
988
          let rDate = excEvent.updatePropertyWithValue("rdate", recId.getFirstValue().clone());
4✔
989
          let recTzid = recId.getParameter("tzid");
4✔
990
          if (recTzid) {
4✔
991
            dtStart.setParameter("tzid", recTzid);
2✔
992
            rDate.setParameter("tzid", recTzid);
2✔
993
          }
994

995
          excEvent.removeAllProperties("recurrence-id");
4✔
996
          excEvent.updatePropertyWithValue("x-moz-faked-master", "1");
4✔
997

998
          // Promote the item to a parent item we'll commit later
999
          this.parentItems[exc.id] = exc;
4✔
1000
        }
1001
      })
1002
    );
1003
    this.missingParents = [];
50✔
1004

1005
    // Commit all parents, they have collected all the exceptions by now
1006
    await Promise.all(
50✔
1007
      Object.values(this.parentItems).map(parent => {
1008
        return this.commitItem(parent);
18✔
1009
      })
1010
    );
1011
    this.parentItems = Object.create(null);
50✔
1012
  }
1013
}
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