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

kewisch / gdata-provider / 18511421677

14 Oct 2025 10:08PM UTC coverage: 89.925%. Remained the same
18511421677

push

github

kewisch
fix: Adjust editing UI for Gmail events

1012 of 1081 branches covered (93.62%)

1312 of 1459 relevant lines covered (89.92%)

78.97 hits per line

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

99.24
/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
  toRFC3339
13
} from "./utils.js";
14

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

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

31
const FOUR_WEEKS_IN_MINUTES = 40320;
8✔
32

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

35

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

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

60
  return null;
8✔
61
}
62
export function transformDateXpropICAL(value) {
63
  if (!value || (value.length == 16 && value.endsWith("Z"))) {
14✔
64
    // No value, or an ICAL string value like 20240102T030405Z
65
    return value;
6✔
66
  } else if (value instanceof ICAL.Time) {
8✔
67
    // Convert to an ICAL String
68
    return value.toICALString();
4✔
69
  } else if (value[4] == "-") {
4✔
70
    // A jCal/rfc3339 time
71
    return ICAL.design.icalendar.value["date-time"].toICAL(value);
2✔
72
  }
73

74
  return null;
2✔
75
}
76

77
export function itemToJson(item, calendar, isImport, isCreate) {
78
  if (item.type == "event") {
34✔
79
    return eventToJson(item, calendar, isImport, isCreate);
26✔
80
  } else if (item.type == "task") {
8✔
81
    return taskToJson(item, calendar, isImport, isCreate);
6✔
82
  } else {
83
    throw new Error("Unknown item type: " + item.type);
2✔
84
  }
85
}
86

87
function eventToJson(item, calendar, isImport, isCreate) {
88
  let veventprops = [];
26✔
89
  let oldItem = {
26✔
90
    format: "jcal",
91
    item: ["vcalendar", [], [["vevent", veventprops, []]]],
92
  };
93

94
  if (item.instance) {
26✔
95
    // patchEvent needs to find the relevant instance, which means we need the recurrence-id on the
96
    // old item.
97
    veventprops.push(
2✔
98
      ["recurrence-id", {}, item.instance.length == 10 ? "date" : "date-time", item.instance]
1!
99
    );
100
  }
101

102
  let entry = patchEvent(item, oldItem, isImport, isCreate);
26✔
103
  if (item.id) {
22✔
104
    entry.iCalUID = item.id;
20✔
105
  }
106
  return entry;
22✔
107
}
108

109
function taskToJson(item, calendar, isImport, isCreate) {
110
  let oldItem = {
6✔
111
    format: "jcal",
112
    item: ["vcalendar", [], [["vtodo", [], []]]],
113
  };
114

115
  let entry = patchTask(item, oldItem, isImport, isCreate);
6✔
116
  if (item.id) {
6✔
117
    entry.id = item.id;
4✔
118
  }
119

120
  return entry;
6✔
121
}
122

123
export function jsonToItem(data) {
124
  if (data.entry?.kind == "tasks#task") {
52✔
125
    return jsonToTask(data);
10✔
126
  } else if (data.entry?.kind == "calendar#event") {
42✔
127
    return jsonToEvent(data);
38✔
128
  } else {
129
    data.calendar.console.error(`Invalid item type ${data.entry?.kind}`);
4✔
130
    return null;
4✔
131
  }
132
}
133

134
function jsonToTask({ entry, calendar }) {
135
  function setIf(prop, type, value, params = {}) {
54✔
136
    if (value) {
120✔
137
      vtodoprops.push([prop, params, type, value]);
100✔
138
    }
139
  }
140

141
  let vtodoprops = [];
12✔
142
  let vtodocomps = [];
12✔
143
  let vtodo = ["vtodo", vtodoprops, vtodocomps];
12✔
144

145
  setIf("uid", "text", entry.id);
12✔
146
  setIf("last-modified", "date-time", stripFractional(entry.updated));
12✔
147
  setIf("dtstamp", "date-time", stripFractional(entry.updated));
12✔
148

149
  setIf("summary", "text", entry.title);
12✔
150
  setIf("description", "text", entry.notes);
12✔
151
  setIf("url", "uri", entry.webViewLink);
12✔
152

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

156
  let status;
157
  if (entry.deleted) {
12✔
158
    status = "CANCELLED";
2✔
159
  } else if (entry.status == "needsAction") {
10✔
160
    status = "NEEDS-ACTION";
2✔
161
  } else {
162
    status = "COMPLETED";
8✔
163
  }
164
  vtodoprops.push(["status", {}, "text", status]);
12✔
165
  if (status == "COMPLETED") {
12✔
166
    vtodoprops.push(["percent-complete", {}, "integer", 100]);
8✔
167
  }
168
  setIf("completed", "date-time", stripFractional(entry.completed));
12✔
169
  setIf("due", "date-time", stripFractional(entry.due));
12✔
170

171
  for (let link of entry.links || []) {
12✔
172
    vtodoprops.push([
8✔
173
      "attach",
174
      { filename: link.description, "x-google-type": link.type },
175
      "uri",
176
      link.link,
177
    ]);
178
  }
179

180
  return {
12✔
181
    id: entry.id,
182
    type: "task",
183
    metadata: {
184
      etag: entry.etag,
185
      path: entry.id,
186
    },
187
    format: "jcal",
188
    item: addVCalendar(vtodo)
189
  };
190
}
191

192
function haveRemindersChanged(remindersEntry, oldRemindersEntry) {
193
  if (
112✔
194
    remindersEntry.useDefault != oldRemindersEntry.useDefault ||
105✔
195
    remindersEntry.overrides?.length != oldRemindersEntry.overrides?.length
196
  ) {
197
    return true;
20✔
198
  }
199

200
  let reminderMap = new Set(remindersEntry.overrides?.map(entry => entry.method + entry.minutes) ?? []);
322!
201
  if (oldRemindersEntry.overrides?.some(entry => !reminderMap.has(entry.method + entry.minutes))) {
294✔
202
    return true;
14✔
203
  }
204

205
  return false;
78✔
206
}
207

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

212
  let reminders = { overrides: [], useDefault: false };
224✔
213
  for (let valarm of vevent.getAllSubcomponents("valarm")) {
224✔
214
    if (valarm.getFirstPropertyValue("x-default-alarm")) {
966✔
215
      // This is one of the Google default alarms on the calendar, it should therefore not be
216
      // serialized. We need to do this because Lightning does not have the concept of a default
217
      // alarm.
218
      reminders.useDefault = true;
136✔
219
      continue;
136✔
220
    }
221
    if (reminders.overrides.length == 5) {
830✔
222
      // Need to continue here, there may be x-default-alarms further on.
223
      continue;
136✔
224
    }
225

226
    let override = {};
694✔
227
    override.method = ALARM_ACTION_MAP_REV[valarm.getFirstPropertyValue("action")] || "popup";
694✔
228
    let trigger = valarm.getFirstProperty("trigger");
694✔
229
    let related = trigger.getParameter("related");
694✔
230
    if (trigger.type == "date-time") {
694✔
231
      override.minutes = Math.floor(
136✔
232
        vevent
233
          .getFirstPropertyValue("dtstart")
234
          .subtractDateTz(trigger.getFirstValue())
235
          .toSeconds() / 60
236
      );
237
    } else if (related == "END") {
558✔
238
      let dtend = vevent.getFirstPropertyValue("dtend");
136✔
239
      let length = dtend
136✔
240
        ? dtend.subtractDateTz(vevent.getFirstPropertyValue("dtstart")).toSeconds()
241
        : 0;
242
      override.minutes = -Math.floor((trigger.getFirstValue().toSeconds() + length) / 60);
136✔
243
    } else {
244
      override.minutes = -Math.floor(trigger.getFirstValue().toSeconds() / 60);
422✔
245
    }
246

247
    override.minutes = Math.min(Math.max(0, override.minutes), FOUR_WEEKS_IN_MINUTES);
694✔
248
    reminders.overrides.push(override);
694✔
249
  }
250

251
  if (!reminders.overrides.length && vevent.getFirstPropertyValue("x-default-alarm")) {
224✔
252
    delete reminders.overrides;
6✔
253
    reminders.useDefault = true;
6✔
254
  }
255

256
  return reminders;
224✔
257
}
258

259
function haveAttendeesChanged(event, oldEvent) {
260
  function attendeeChanged(oldAtt, newAtt) {
261
    return newAtt?.getParameter("cn") != oldAtt?.getParameter("cn") ||
212✔
262
      newAtt?.getParameter("role") != oldAtt?.getParameter("role") ||
263
      newAtt?.getParameter("cutype") != oldAtt?.getParameter("cutype") ||
264
      newAtt?.getParameter("partstat") != oldAtt?.getParameter("partstat");
265
  }
266

267
  let oldAttendees = new Map(
112✔
268
    oldEvent.getAllProperties("attendee").map(attendee => [attendee.getFirstValue(), attendee])
146✔
269
  );
270
  let newAttendees = event.getAllProperties("attendee");
112✔
271
  let needsAttendeeUpdate = newAttendees.length != oldAttendees.size;
112✔
272
  let newOrganizer = event.getFirstProperty("organizer");
112✔
273
  let newOrganizerId = newOrganizer?.getFirstParameter("partstat") ? newOrganizer?.getFirstValue() : null;
112✔
274
  let oldOrganizer = oldEvent.getFirstProperty("organizer");
112✔
275

276
  if (!needsAttendeeUpdate) {
112✔
277
    for (let attendee of newAttendees) {
92✔
278
      let attendeeId = attendee.getFirstValue();
122✔
279
      let oldAttendee = oldAttendees.get(attendeeId);
122✔
280
      if (!oldAttendee) {
122✔
281
        needsAttendeeUpdate = true;
2✔
282
        break;
2✔
283
      }
284

285
      if (attendeeId == newOrganizerId) {
120✔
286
        // Thunderbird sets the participation status on the organizer, not the attendee
287
        attendee = newOrganizer;
2✔
288
        oldAttendee = oldOrganizer;
2✔
289
      }
290

291
      if (attendeeChanged(attendee, oldAttendee)) {
120✔
292
        needsAttendeeUpdate = true;
10✔
293
        break;
10✔
294
      }
295
      oldAttendees.delete(attendeeId);
110✔
296
    }
297

298
    if (attendeeChanged(oldOrganizer, newOrganizer)) {
92✔
299
      // There are no new attendees, but the organizer changed participations status so we might
300
      // need to add it.
301
      needsAttendeeUpdate = true;
4✔
302
    }
303

304
    if (oldAttendees.size > 0) {
92✔
305
      needsAttendeeUpdate = true;
12✔
306
    }
307
  }
308

309
  return needsAttendeeUpdate;
112✔
310
}
311

312
function convertAttendee(attendee, organizer, isOrganizer, isCreate) {
313
  function setIf(att, prop, value) {
314
    if (value) {
208✔
315
      att[prop] = value;
54✔
316
    }
317
  }
318

319
  let att = {};
80✔
320
  let attendeeId = attendee.getFirstValue();
80✔
321
  let organizerId = organizer?.getFirstValue();
80✔
322

323
  if (!isCreate && attendeeId == organizerId) {
80✔
324
    // On creation, the participation status is set on the attendee object. On participation
325
    // changes, it is wrongly set on the organizer, not the attendee.
326
    attendee = organizer;
8✔
327
  }
328

329
  att.email = attendeeId.startsWith("mailto:") ? attendeeId.substr(7) : null;
80✔
330
  let emailParam = attendee.getFirstParameter("email");
80✔
331
  if (!att.email && emailParam) {
80✔
332
    att.email = emailParam.startsWith("mailto:") ? emailParam.substr(7) : emailParam;
4✔
333
  }
334

335
  setIf(att, "displayName", attendee.getFirstParameter("cn"));
80✔
336

337
  if (!isOrganizer) {
80✔
338
    att.optional = attendee.getFirstParameter("role") == "OPT-PARTICIPANT";
64✔
339
    att.resource = attendee.getFirstParameter("cutype") == "RESOURCE";
64✔
340
    att.responseStatus =
64✔
341
      ATTENDEE_STATUS_MAP_REV[attendee.getFirstParameter("partstat")] || "needsAction";
33✔
342

343
    setIf(att, "comment", attendee.getFirstParameter("comment"));
64✔
344
    setIf(att, "additionalGuests", attendee.getFirstParameter("x-num-guests"));
64✔
345
  }
346

347
  return att;
80✔
348
}
349

350
function convertRecurrence(vevent) {
351
  let recrules = new Set();
224✔
352

353
  for (let rrule of vevent.getAllProperties("rrule")) {
224✔
354
    recrules.add(rrule.toICALString());
26✔
355
  }
356

357
  // EXDATEs and RDATEs require special casing, since they might contain a TZID. To avoid the need
358
  // for conversion of TZID strings, convert to UTC before serialization.
359
  for (let rdate of vevent.getAllProperties("rdate")) {
224✔
360
    rdate.setValue(rdate.getFirstValue().convertToZone(ICAL.Timezone.utcTimezone));
26✔
361
    rdate.removeParameter("tzid");
26✔
362
    recrules.add(rdate.toICALString());
26✔
363
  }
364
  for (let exdate of vevent.getAllProperties("exdate")) {
224✔
365
    exdate.setValue(exdate.getFirstValue().convertToZone(ICAL.Timezone.utcTimezone));
26✔
366
    exdate.removeParameter("tzid");
26✔
367
    recrules.add(exdate.toICALString());
26✔
368
  }
369

370
  return recrules;
224✔
371
}
372

373
function convertRecurringSnoozeTime(vevent) {
374
  // This is an evil workaround since we don't have a really good system to save the snooze time
375
  // for recurring alarms or even retrieve them from the event. This should change when we have
376
  // multiple alarms support.
377
  let snoozeObj = {};
28✔
378

379
  let lastAckString = transformDateXprop(vevent.getFirstPropertyValue("x-moz-lastack"));
28✔
380
  let lastAck = lastAckString ? ICAL.Time.fromDateTimeString(lastAckString) : null;
28✔
381

382
  for (let property of vevent.getAllProperties()) {
28✔
383
    if (property.name.startsWith("x-moz-snooze-time-")) {
272✔
384
      let snoozeDateString = transformDateXprop(property.getFirstValue());
8✔
385
      let snoozeDate = snoozeDateString ? ICAL.Time.fromDateTimeString(snoozeDateString) : null;
8✔
386

387
      if (snoozeDate && (!lastAck || snoozeDate.compare(lastAck) >= 0)) {
8✔
388
        snoozeObj[property.name.substr(18)] = transformDateXpropICAL(property.getFirstValue());
4✔
389
      }
390
    }
391
  }
392
  return Object.keys(snoozeObj).length ? JSON.stringify(snoozeObj) : null;
28✔
393
}
394

395
export function patchItem(item, oldItem, isImport, isCreate) {
396
  if (item.type == "event") {
110✔
397
    return patchEvent(...arguments);
92✔
398
  } else if (item.type == "task") {
18✔
399
    return patchTask(...arguments);
16✔
400
  } else {
401
    throw new Error("Unknown item type: " + item.type);
2✔
402
  }
403
}
404

405
function patchTask(item, oldItem, isImport, isCreate) {
406
  function setIfFirstProperty(obj, prop, jprop, transform = null) {
22✔
407
    let oldValue = oldTask.getFirstPropertyValue(jprop);
110✔
408
    let newValue = task.getFirstPropertyValue(jprop);
110✔
409

410
    if (oldValue?.toString() !== newValue?.toString()) {
110✔
411
      obj[prop] = transform ? transform(newValue) : newValue;
34✔
412
    }
413
  }
414

415
  let entry = {};
22✔
416
  let task = findRelevantInstance(new ICAL.Component(item.item), item.instance, "vtodo");
22✔
417
  let oldTask = findRelevantInstance(new ICAL.Component(oldItem.item), item.instance, "vtodo");
22✔
418

419
  setIfFirstProperty(entry, "title", "summary");
22✔
420
  setIfFirstProperty(entry, "status", "status", val => {
22✔
421
    return val == "COMPLETED" ? "completed" : "needsAction";
6✔
422
  });
423
  setIfFirstProperty(entry, "notes", "description");
22✔
424

425
  setIfFirstProperty(entry, "due", "due", toRFC3339);
22✔
426
  setIfFirstProperty(entry, "completed", "completed", toRFC3339);
22✔
427

428
  return entry;
22✔
429
}
430

431
function patchEvent(item, oldItem, isImport, isCreate) {
432
  function setIfFirstProperty(obj, prop, jprop = null, transform = null) {
404✔
433
    let oldValue = oldEvent.getFirstPropertyValue(jprop || prop);
1,028✔
434
    let newValue = event.getFirstPropertyValue(jprop || prop);
1,028✔
435

436
    if (oldValue?.toString() !== newValue?.toString()) {
1,028✔
437
      obj[prop] = transform ? transform(newValue) : newValue;
166✔
438
    }
439
  }
440

441
  function getActualEnd(endEvent) {
442
    let endProp = endEvent.getFirstProperty("dtend");
232✔
443
    let end = endProp?.getFirstValue("dtend");
232✔
444
    let duration = endEvent.getFirstPropertyValue("duration");
232✔
445
    if (!end && duration) {
232✔
446
      let startProp = endEvent.getFirstProperty("dtstart");
2✔
447
      let start = startProp.getFirstValue();
2✔
448
      end = start.clone();
2✔
449
      end.addDuration(duration);
2✔
450

451
      return new ICAL.Property(["dtend", startProp.jCal[1], startProp.jCal[2], end.toString()]);
2✔
452
    }
453
    return endProp;
230✔
454
  }
455

456
  function setIfDateChanged(obj, prop, jprop) {
457
    let oldProp = oldEvent.getFirstProperty(jprop);
116✔
458
    let newProp = event.getFirstProperty(jprop);
116✔
459

460
    let oldDate = oldProp?.getFirstValue();
116✔
461
    let newDate = newProp?.getFirstValue();
116✔
462

463
    let oldZone = oldProp?.getFirstParameter("tzid");
116✔
464
    let newZone = newProp?.getFirstParameter("tzid");
116✔
465

466
    if (oldZone != newZone || !oldDate ^ !newDate || (oldDate && newDate && oldDate.compare(newDate) != 0)) {
116✔
467
      obj[prop] = dateToJson(newProp);
24✔
468
    }
469
  }
470

471
  function setIfEndDateChanged(obj, prop) {
472
    let oldProp = getActualEnd(oldEvent);
116✔
473
    let newProp = getActualEnd(event);
116✔
474

475
    let oldDate = oldProp?.getFirstValue();
116✔
476
    let newDate = newProp?.getFirstValue();
116✔
477

478
    let oldZone = oldProp?.getFirstParameter("tzid");
116✔
479
    let newZone = newProp?.getFirstParameter("tzid");
116✔
480

481
    if (oldZone != newZone || !oldDate ^ !newDate || (oldDate && newDate && oldDate.compare(newDate) != 0)) {
116✔
482
      obj[prop] = dateToJson(newProp);
26✔
483
    }
484
  }
485

486
  let entry = { extendedProperties: { shared: {}, private: {} } };
118✔
487

488
  let event = findRelevantInstance(new ICAL.Component(item.item), item.instance, "vevent");
118✔
489
  let oldEvent = findRelevantInstance(new ICAL.Component(oldItem.item), item.instance, "vevent");
118✔
490

491
  if (!event) {
118✔
492
    throw new Error(`Missing vevent in toplevel component ${item.item?.[0]}`);
2✔
493
  }
494

495
  setIfFirstProperty(entry, "summary");
116✔
496
  setIfFirstProperty(entry, "location");
116✔
497

498
  let oldDesc = oldEvent?.getFirstProperty("description");
116✔
499
  let newDesc = event?.getFirstProperty("description");
116✔
500
  let newHTML = newDesc?.getParameter("altrep");
116✔
501

502
  if (
116✔
503
    oldDesc?.getFirstValue() != newDesc?.getFirstValue() ||
108✔
504
    oldDesc?.getParameter("altrep") != newHTML
505
  ) {
506
    if (newHTML?.startsWith("data:text/html,")) {
18✔
507
      entry.description = decodeURIComponent(newHTML.slice("data:text/html,".length));
2✔
508
    } else {
509
      entry.description = newDesc?.getFirstValue();
16✔
510
    }
511
  }
512

513
  setIfDateChanged(entry, "start", "dtstart");
116✔
514
  setIfEndDateChanged(entry, "end");
116✔
515

516
  if (entry.end === null) {
116✔
517
    delete entry.end;
2✔
518
    entry.endTimeUnspecified = true;
2✔
519
  }
520

521
  if (event.getFirstProperty("rrule") || event.getFirstProperty("rdate")) {
116✔
522
    let oldRecurSnooze = convertRecurringSnoozeTime(oldEvent);
14✔
523
    let newRecurSnooze = convertRecurringSnoozeTime(event);
14✔
524
    if (oldRecurSnooze != newRecurSnooze) {
14✔
525
      entry.extendedProperties.private["X-GOOGLE-SNOOZE-RECUR"] = newRecurSnooze;
4✔
526
    }
527
  }
528

529
  if (item.instance) {
116✔
530
    entry.recurringEventId = item.id.replace(/@google.com$/, "");
4✔
531
    entry.originalStartTime = dateToJson(event.getFirstProperty("recurrence-id"));
4✔
532
  } else {
533
    let oldRecurrenceSet = convertRecurrence(oldEvent);
112✔
534
    let newRecurrence = [...convertRecurrence(event)];
112✔
535
    if (
112✔
536
      oldRecurrenceSet.size != newRecurrence.length ||
111✔
537
      newRecurrence.some(elem => !oldRecurrenceSet.has(elem))
34✔
538
    ) {
539
      entry.recurrence = newRecurrence;
6✔
540
    }
541
  }
542

543
  setIfFirstProperty(entry, "sequence");
116✔
544
  setIfFirstProperty(entry, "transparency", "transp", transparency => transparency?.toLowerCase());
116✔
545

546
  if (!isImport) {
116✔
547
    // We won't let an invitation item set PUBLIC visiblity
548
    setIfFirstProperty(entry, "visibility", "class", visibility => visibility?.toLowerCase());
112✔
549
  }
550

551
  setIfFirstProperty(entry, "status", "status", status => status?.toLowerCase());
116✔
552
  if (entry.status == "cancelled") {
116✔
553
    throw new Error("NS_ERROR_LOSS_OF_SIGNIFICANT_DATA");
2✔
554
  }
555
  if (entry.status == "none") {
114✔
556
    delete entry.status;
2✔
557
  }
558

559
  // Organizer
560
  let organizer = event.getFirstProperty("organizer");
114✔
561
  let organizerId = organizer?.getFirstValue();
114✔
562
  let oldOrganizer = oldEvent.getFirstProperty("organizer");
114✔
563
  let oldOrganizerId = oldOrganizer?.getFirstValue();
114✔
564
  if (!oldOrganizerId && organizerId) {
114✔
565
    // This can be an import, add the organizer
566
    entry.organizer = convertAttendee(organizer, organizer, true, isCreate);
16✔
567
  } else if (oldOrganizerId && oldOrganizerId.toLowerCase() != organizerId?.toLowerCase()) {
98✔
568
    // Google requires a move() operation to do this, which is not yet implemented
569
    throw new Error(`[calGoogleCalendar(${entry.summary})] Changing organizer requires a move, which is not implemented (changing from ${oldOrganizerId} to ${organizerId})`);
2✔
570
  }
571

572
  // Attendees
573
  if (haveAttendeesChanged(event, oldEvent)) {
112✔
574
    let attendees = event.getAllProperties("attendee");
34✔
575
    entry.attendees = attendees.map(attendee => convertAttendee(attendee, organizer, false, isCreate));
62✔
576

577
    // The participations status changed on the organizer, which means the organizer is now
578
    // participating in some way. We need to make sure the organizer is an attendee.
579
    if (
34✔
580
      organizer && oldOrganizer &&
45✔
581
      organizer.getParameter("partstat") != oldOrganizer.getParameter("partstat") &&
582
      !attendees.some(attendee => attendee.getFirstValue() == organizerId)
4✔
583
    ) {
584
        entry.attendees.push(convertAttendee(organizer, organizer, false, isCreate));
2✔
585
    }
586
  }
587

588
  let oldReminders = convertReminders(oldEvent);
112✔
589
  let reminders = convertReminders(event);
112✔
590
  if (haveRemindersChanged(reminders, oldReminders)) {
112✔
591
    entry.reminders = reminders;
34✔
592
  }
593

594
  // Categories
595
  function getAllCategories(vevent) {
596
    return vevent.getAllProperties("categories").reduce((acc, comp) => acc.concat(comp.getValues()), []);
224✔
597
  }
598

599
  let oldCatSet = new Set(getAllCategories(oldEvent));
112✔
600
  let newCatArray = getAllCategories(event);
112✔
601
  let newCatSet = new Set(newCatArray);
112✔
602

603
  if (
112✔
604
    oldCatSet.size != newCatSet.size ||
104✔
605
    oldCatSet.difference(newCatSet).size != 0
606
  ) {
607
    entry.extendedProperties.shared["X-MOZ-CATEGORIES"] = categoriesArrayToString(newCatArray);
16✔
608
  }
609

610
  // Conference info - we only support create and delete
611
  let confnew = event.getFirstPropertyValue("x-google-confnew");
112✔
612
  let confdata = event.getFirstPropertyValue("x-google-confdata");
112✔
613
  let oldConfData = oldEvent.getFirstPropertyValue("x-google-confdata");
112✔
614

615
  if (oldConfData && !confdata) {
112✔
616
    entry.conferenceData = null;
2✔
617
  } else if (confnew) {
110✔
618
    entry.conferenceData = {
2✔
619
      createRequest: {
620
        requestId: crypto.randomUUID(),
621
        conferenceSolutionKey: {
622
          type: confnew
623
        }
624
      }
625
    };
626
  }
627

628
  // Last ack and snooze time are always in UTC, when serialized they'll contain a Z
629
  setIfFirstProperty(
112✔
630
    entry.extendedProperties.private,
631
    "X-MOZ-LASTACK",
632
    "x-moz-lastack",
633
    transformDateXprop
634
  );
635
  setIfFirstProperty(
112✔
636
    entry.extendedProperties.private,
637
    "X-MOZ-SNOOZE-TIME",
638
    "x-moz-snooze-time",
639
    transformDateXprop
640
  );
641

642
  if (!Object.keys(entry.extendedProperties.shared).length) {
112✔
643
    delete entry.extendedProperties.shared;
96✔
644
  }
645
  if (!Object.keys(entry.extendedProperties.private).length) {
112✔
646
    delete entry.extendedProperties.private;
88✔
647
  }
648
  if (!Object.keys(entry.extendedProperties).length) {
112✔
649
    delete entry.extendedProperties;
86✔
650
  }
651

652
  setIfFirstProperty(entry, "colorId", "x-google-color-id");
112✔
653

654
  return entry;
112✔
655
}
656

657
export function jsonToDate(propName, dateObj, defaultTimezone, requestedZone = null) {
77✔
658
  if (!dateObj) {
154✔
659
    return null;
2✔
660
  }
661

662
  let params = {};
152✔
663
  let targetZone = requestedZone || dateObj.timeZone;
152✔
664

665
  if (targetZone && targetZone != "UTC") {
152✔
666
    params.tzid = dateObj.timeZone;
48✔
667
  }
668

669
  if (dateObj.date) {
152✔
670
    return [propName, params, "date", dateObj.date];
90✔
671
  } else {
672
    let timeString = stripFractional(dateObj.dateTime);
62✔
673
    if (defaultTimezone && targetZone && targetZone != defaultTimezone.tzid) {
62✔
674
      let time = ICAL.Time.fromDateTimeString(timeString);
10✔
675
      time.zone = defaultTimezone;
10✔
676

677
      let zone = TimezoneService.get(targetZone);
10✔
678
      if (zone) {
10✔
679
        timeString = time.convertToZone(zone).toString();
8✔
680
      } else {
681
        throw new Error(`Could not find zone ${targetZone}`);
2✔
682
      }
683
    }
684

685
    return [propName, params, "date-time", timeString];
60✔
686
  }
687
}
688

689
export function dateToJson(property) {
690
  if (!property) {
64✔
691
    return null;
4✔
692
  }
693
  let dateobj = {};
60✔
694

695
  if (property.type == "date") {
60✔
696
    dateobj.date = property.getFirstValue().toString();
20✔
697
  } else {
698
    let dateTime = property.getFirstValue();
40✔
699
    dateobj.dateTime = dateTime.toString();
40✔
700

701
    let zone = TimezoneService.get(property.getFirstParameter("tzid"));
40✔
702
    if (zone) {
40✔
703
      dateobj.timeZone = zone.tzid;
38✔
704
    } else if (zone == TimezoneService.localTimezone) {
2!
705
      let currentZone = TimezoneService.get(messenger.calendar.timezones.currentZone);
×
706
      dateobj.dateTime = dateTime.convertToZone(currentZone).toString();
×
707
      dateobj.timeZone = currentZone.tzid;
×
708
    } else {
709
      let utcTime = dateTime.convertToZone(TimezoneService.get("UTC"));
2✔
710
      dateobj.dateTime = utcTime.toString();
2✔
711
    }
712
  }
713

714
  return dateobj;
60✔
715
}
716

717
async function jsonToEvent({ entry, calendar, defaultReminders, defaultTimezone, accessRole, confSolutionCache }) {
718
  function setIf(prop, type, value, params = {}) {
532✔
719
    if (value) {
1,064✔
720
      veventprops.push([prop, params, type, value]);
666✔
721
    }
722
  }
723
  function pushPropIf(prop) {
724
    if (prop) {
70!
725
      veventprops.push(prop);
70✔
726
    }
727
  }
728

729
  let privateProps = entry.extendedProperties?.private || {};
70✔
730
  let sharedProps = entry.extendedProperties?.shared || {};
70✔
731

732
  let veventprops = [];
70✔
733
  let veventcomps = [];
70✔
734
  let vevent = ["vevent", veventprops, veventcomps];
70✔
735

736
  let uid = entry.iCalUID || (entry.recurringEventId || entry.id) + "@google.com";
70✔
737

738
  setIf("uid", "text", uid);
70✔
739
  setIf("created", "date-time", stripFractional(entry.created));
70✔
740
  setIf("last-modified", "date-time", stripFractional(entry.updated));
70✔
741
  setIf("dtstamp", "date-time", stripFractional(entry.updated));
70✔
742

743
  // Not pretty, but Google doesn't have a straightforward way to differentiate. As of writing,
744
  // they even have bugs in their own UI about displaying the string properly.
745
  if (entry.description?.match(CONTAINS_HTML_RE)) {
70✔
746
    let altrep = "data:text/html," + encodeURIComponent(entry.description);
2✔
747
    let parser = new window.DOMParser();
2✔
748
    let plain = parser.parseFromString(entry.description, "text/html").documentElement.textContent;
2✔
749
    veventprops.push(["description", { altrep }, "text", plain]);
2✔
750
  } else {
751
    veventprops.push(["description", {}, "text", entry.description ?? ""]);
68✔
752
  }
753

754
  setIf("location", "text", entry.location);
70✔
755
  setIf("status", "text", entry.status?.toUpperCase());
70✔
756

757
  if (entry.originalStartTime) {
70✔
758
    veventprops.push(jsonToDate("recurrence-id", entry.originalStartTime, defaultTimezone));
20✔
759
  }
760

761
  let isFreeBusy = accessRole == "freeBusyReader";
70✔
762
  let summary = isFreeBusy ? messenger.i18n.getMessage("busyTitle", calendar.name) : entry.summary;
70✔
763
  setIf("summary", "text", summary);
70✔
764
  setIf("class", "text", entry.visibility?.toUpperCase());
70✔
765
  setIf("sequence", "integer", entry.sequence);
70✔
766
  setIf("url", "uri", entry.htmlLink);
70✔
767
  setIf("transp", "text", entry.transparency?.toUpperCase());
70✔
768

769
  if (entry.eventType != "default") {
70✔
770
    setIf("x-google-event-type", "text", entry.eventType);
4✔
771
  }
772

773
  setIf("x-google-color-id", "text", entry.colorId);
70✔
774
  setIf("x-google-confdata", "text", entry.conferenceData ? JSON.stringify(entry.conferenceData) : null);
70✔
775

776
  if (entry.conferenceData && confSolutionCache) {
70✔
777
    let solution = entry.conferenceData.conferenceSolution;
10✔
778
    confSolutionCache[solution.key.type] = solution;
10✔
779
  }
780

781
  pushPropIf(jsonToDate("dtstart", entry.start, defaultTimezone));
70✔
782
  // gmail events have an end time even if it's marked as unspecified, and trying to PATCH it fails
783
  if (!entry.endTimeUnspecified || entry.eventType === "fromGmail") {
70✔
784
    veventprops.push(jsonToDate("dtend", entry.end, defaultTimezone));
52✔
785
  }
786

787
  // Organizer
788
  if (entry.organizer) {
70✔
789
    let id = entry.organizer.email
32✔
790
      ? "mailto:" + entry.organizer.email
791
      : "urn:id:" + entry.organizer.id;
792

793
    let orgparams = {};
32✔
794
    if (entry.organizer.displayName) {
32✔
795
      // eslint-disable-next-line id-length
796
      orgparams.cn = entry.organizer.displayName;
30✔
797
    }
798
    veventprops.push(["organizer", orgparams, "uri", id]);
32✔
799
  }
800

801
  // Recurrence properties
802
  for (let recItem of entry.recurrence || []) {
70✔
803
    let prop = ICAL.Property.fromString(recItem);
30✔
804
    veventprops.push(prop.jCal);
30✔
805
  }
806

807
  for (let attendee of entry.attendees || []) {
70✔
808
    let id = "mailto:" + attendee.email;
48✔
809
    let params = {
48✔
810
      role: attendee.optional ? "OPT-PARTICIPANT" : "REQ-PARTICIPANT",
24✔
811
      partstat: ATTENDEE_STATUS_MAP[attendee.responseStatus],
812
      cutype: attendee.resource ? "RESOURCE" : "INDIVIDUAL",
24✔
813
    };
814

815
    if (attendee.displayName) {
48✔
816
      // eslint-disable-next-line id-length
817
      params.cn = attendee.displayName;
30✔
818
    }
819

820
    veventprops.push(["attendee", params, "uri", id]);
48✔
821
  }
822

823
  // Reminders
824
  if (entry.reminders) {
70✔
825
    if (entry.reminders.useDefault) {
32✔
826
      veventcomps.push(...defaultReminders.map(alarmEntry => jsonToAlarm(alarmEntry, true)));
20✔
827

828
      if (!defaultReminders?.length) {
20✔
829
        // There are no default reminders, but we want to use the default in case the user changes
830
        // it in the future. Until we have VALARM extension which allow for a default settings, we
831
        // use an x-prop
832
        veventprops.push(["x-default-alarm", {}, "boolean", true]);
12✔
833
      }
834
    }
835

836
    if (entry.reminders.overrides) {
32✔
837
      for (let reminderEntry of entry.reminders.overrides) {
28✔
838
        veventcomps.push(jsonToAlarm(reminderEntry));
56✔
839
      }
840
    }
841
  }
842

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

847
  let snoozeObj;
848
  try {
70✔
849
    snoozeObj = JSON.parse(privateProps["X-GOOGLE-SNOOZE-RECUR"]);
70✔
850
  } catch (e) {
851
    // Ok to swallow
852
  }
853

854
  for (let [rid, value] of Object.entries(snoozeObj || {})) {
70✔
855
    setIf(
10✔
856
      "x-moz-snooze-time-" + rid,
857
      "date-time",
858
      ICAL.design.icalendar.value["date-time"].fromICAL(value)
859
    );
860
  }
861

862
  // Google does not support categories natively, but allows us to store data as an
863
  // "extendedProperty", and here it's going to be retrieved again
864
  let categories = categoriesStringToArray(sharedProps["X-MOZ-CATEGORIES"]);
70✔
865
  if (categories && categories.length) {
70✔
866
    veventprops.push(["categories", {}, "text", ...categories]);
30✔
867
  }
868

869
  // Attachments (read-only)
870
  for (let attach of (entry.attachments || [])) {
70✔
871
    let props = { "managed-id": attach.fileId, filename: attach.title };
18✔
872
    if (attach.mimeType) {
18!
873
      props.fmttype = attach.mimeType;
18✔
874
    }
875

876
    veventprops.push(["attach", props, "uri", attach.fileUrl]);
18✔
877
  }
878

879
  let shell = {
70✔
880
    id: uid,
881
    type: "event",
882
    metadata: {
883
      etag: entry.etag,
884
      path: entry.id,
885
    },
886
    format: "jcal",
887
    item: addVCalendar(vevent)
888
  };
889

890
  return shell;
70✔
891
}
892

893
export function jsonToAlarm(entry, isDefault = false) {
28✔
894
  let valarmprops = [];
64✔
895
  let valarm = ["valarm", valarmprops, []];
64✔
896

897
  let dur = new ICAL.Duration({
64✔
898
    minutes: -entry.minutes,
899
  });
900
  dur.normalize();
64✔
901

902
  valarmprops.push(["action", {}, "text", ALARM_ACTION_MAP[entry.method]]);
64✔
903
  valarmprops.push(["description", {}, "text", "alarm"]);
64✔
904
  valarmprops.push(["trigger", {}, "duration", dur.toString()]);
64✔
905

906
  if (isDefault) {
64✔
907
    valarmprops.push(["x-default-alarm", {}, "boolean", true]);
8✔
908
  }
909
  return valarm;
64✔
910
}
911

912
export class ItemSaver {
913
  #confSolutionCache = {};
76✔
914

915
  missingParents = [];
76✔
916
  parentItems = Object.create(null);
76✔
917

918
  constructor(calendar) {
919
    this.calendar = calendar;
76✔
920
    this.console = calendar.console;
76✔
921
  }
922

923
  async parseItemStream(data) {
924
    if (data.kind == "calendar#events") {
38✔
925
      return this.parseEventStream(data);
18✔
926
    } else if (data.kind == "tasks#tasks") {
20✔
927
      return this.parseTaskStream(data);
16✔
928
    } else {
929
      throw new Error("Invalid stream type: " + (data?.kind || data?.status));
4✔
930
    }
931
  }
932

933
  async parseEventStream(data) {
934
    if (data.timeZone) {
46✔
935
      this.console.log("Timezone from event stream is " + data.timeZone);
18✔
936
      this.calendar.setCalendarPref("timeZone", data.timeZone);
18✔
937
    }
938

939
    if (data.items?.length) {
46✔
940
      this.console.log(`Parsing ${data.items.length} received events`);
26✔
941
    } else {
942
      this.console.log("No events have been changed");
20✔
943
      return;
20✔
944
    }
945

946
    let exceptionItems = [];
26✔
947

948
    let defaultTimezone = TimezoneService.get(data.timeZone);
26✔
949

950
    // In the first pass, we go through the data and sort into parent items and exception items, as
951
    // the parent item might be after the exception in the stream.
952
    await Promise.all(
26✔
953
      data.items.map(async entry => {
954
        let item = await jsonToEvent({
32✔
955
          entry,
956
          calendar: this.calendar,
957
          defaultReminders: data.defaultReminders || [],
32✔
958
          defaultTimezone,
959
          confSolutionCache: this.#confSolutionCache
960
        });
961
        item.item = addVCalendar(item.item);
32✔
962

963
        if (entry.originalStartTime) {
32✔
964
          exceptionItems.push(item);
16✔
965
        } else {
966
          this.parentItems[item.id] = item;
16✔
967
        }
968
      })
969
    );
970

971
    for (let exc of exceptionItems) {
26✔
972
      let item = this.parentItems[exc.id];
16✔
973

974
      if (item) {
16✔
975
        this.processException(exc, item);
4✔
976
      } else {
977
        this.missingParents.push(exc);
12✔
978
      }
979
    }
980
  }
981

982
  async parseTaskStream(data) {
983
    if (data.items?.length) {
18✔
984
      this.console.log(`Parsing ${data.items.length} received tasks`);
2✔
985
    } else {
986
      this.console.log("No tasks have been changed");
16✔
987
      return;
16✔
988
    }
989

990
    await Promise.all(
2✔
991
      data.items.map(async entry => {
992
        let item = await jsonToTask({ entry, calendar: this.calendar });
2✔
993
        item.item = addVCalendar(item.item);
2✔
994
        await this.commitItem(item);
2✔
995
      })
996
    );
997
  }
998

999
  processException(exc, item) {
1000
    let itemCalendar = new ICAL.Component(item.item);
10✔
1001
    let itemEvent = itemCalendar.getFirstSubcomponent("vevent");
10✔
1002

1003
    let exceptionCalendar = new ICAL.Component(exc.item);
10✔
1004
    let exceptionEvent = exceptionCalendar.getFirstSubcomponent("vevent");
10✔
1005

1006
    if (itemEvent.getFirstPropertyValue("status") == "CANCELLED") {
10✔
1007
      // Cancelled parent items don't have the full amount of information, specifically no
1008
      // recurrence info. Since they are cancelled anyway, we can just ignore processing this
1009
      // exception.
1010
      return;
2✔
1011
    }
1012

1013
    if (exceptionEvent.getFirstPropertyValue("status") == "CANCELLED") {
8✔
1014
      let recId = exceptionEvent.getFirstProperty("recurrence-id");
2✔
1015
      let exdate = itemEvent.addPropertyWithValue("exdate", recId.getFirstValue().clone());
2✔
1016
      let recIdZone = recId.getParameter("tzid");
2✔
1017
      if (recIdZone) {
2!
1018
        exdate.setParameter("tzid", recIdZone);
×
1019
      }
1020
    } else {
1021
      itemCalendar.addSubcomponent(exceptionEvent);
6✔
1022
    }
1023

1024
    this.parentItems[item.id] = item;
8✔
1025
  }
1026

1027
  async commitItem(item) {
1028
    // This is a normal item. If it was canceled, then it should be deleted, otherwise it should be
1029
    // either added or modified. The relaxed mode of the cache calendar takes care of the latter two
1030
    // cases.
1031
    let vcalendar = new ICAL.Component(item.item);
24✔
1032
    let vcomp = vcalendar.getFirstSubcomponent("vevent") || vcalendar.getFirstSubcomponent("vtodo");
24✔
1033

1034
    if (vcomp.getFirstPropertyValue("status") == "CANCELLED") {
24✔
1035
      // Catch the error here if the event is already removed from the calendar
1036
      await messenger.calendar.items.remove(this.calendar.cacheId, item.id).catch(e => {});
4✔
1037
    } else {
1038
      await messenger.calendar.items.create(this.calendar.cacheId, item);
20✔
1039
    }
1040
  }
1041

1042
  /**
1043
   * Handle all remaining exceptions in the item saver. Ensures that any missing parent items are
1044
   * searched for or created.
1045
   */
1046
  async complete() {
1047
    await Promise.all(
56✔
1048
      this.missingParents.map(async exc => {
1049
        let excCalendar = new ICAL.Component(exc.item);
12✔
1050
        let excEvent = excCalendar.getFirstSubcomponent("vevent");
12✔
1051

1052
        let item;
1053
        if (exc.id in this.parentItems) {
12✔
1054
          // Parent item could have been on a later page, check again
1055
          item = this.parentItems[exc.id];
2✔
1056
        } else {
1057
          // Otherwise check if we happen to have it in the database
1058
          item = await messenger.calendar.items.get(this.calendar.cacheId, exc.id, {
10✔
1059
            returnFormat: "jcal",
1060
          });
1061
          if (item) {
10✔
1062
            this.parentItems[exc.id] = item;
4✔
1063
          }
1064
        }
1065

1066
        if (item) {
12✔
1067
          delete item.calendarId;
6✔
1068
          this.processException(exc, item);
6✔
1069
        } else if (excEvent.getFirstPropertyValue("status") != "CANCELLED") {
6✔
1070
          // If the item could not be found, it could be that the user is invited to an instance of
1071
          // a recurring event. Unless this is a cancelled exception, create a mock parent item with
1072
          // one positive RDATE.
1073

1074
          // Copy dtstart and rdate, same timezone
1075
          let recId = excEvent.getFirstProperty("recurrence-id");
4✔
1076
          let dtStart = excEvent.updatePropertyWithValue("dtstart", recId.getFirstValue().clone());
4✔
1077
          let rDate = excEvent.updatePropertyWithValue("rdate", recId.getFirstValue().clone());
4✔
1078
          let recTzid = recId.getParameter("tzid");
4✔
1079
          if (recTzid) {
4✔
1080
            dtStart.setParameter("tzid", recTzid);
2✔
1081
            rDate.setParameter("tzid", recTzid);
2✔
1082
          }
1083

1084
          excEvent.removeAllProperties("recurrence-id");
4✔
1085
          excEvent.updatePropertyWithValue("x-moz-faked-master", "1");
4✔
1086

1087
          // Promote the item to a parent item we'll commit later
1088
          this.parentItems[exc.id] = exc;
4✔
1089
        }
1090
      })
1091
    );
1092
    this.missingParents = [];
56✔
1093

1094
    // Commit all parents, they have collected all the exceptions by now
1095
    await Promise.all(
56✔
1096
      Object.values(this.parentItems).map(parent => {
1097
        return this.commitItem(parent);
22✔
1098
      })
1099
    );
1100
    this.parentItems = Object.create(null);
56✔
1101

1102
    // Save the conference icon cache
1103
    let conferenceSolutions = await this.calendar.getCalendarPref("conferenceSolutions") ?? {};
56✔
1104

1105
    await Promise.all(Object.entries(this.#confSolutionCache).map(async ([key, solution]) => {
56✔
1106
      let savedSolution = conferenceSolutions[key];
10✔
1107

1108
      if (savedSolution && savedSolution.iconUri == solution.iconUri) {
10✔
1109
        // The icon uri has not changed, take the icon from our cache
1110
        solution.iconCache = savedSolution.iconCache;
2✔
1111
      }
1112

1113
      if (!solution.iconCache) {
10✔
1114
        try {
8✔
1115
          solution.iconCache = Array.from(await fetch(solution.iconUri).then(resp => resp.bytes()));
8✔
1116
        } catch (e) {
1117
          // Ok to fail
1118
        }
1119
      }
1120
    }));
1121

1122
    Object.assign(conferenceSolutions, this.#confSolutionCache);
56✔
1123
    await this.calendar.setCalendarPref("conferenceSolutions", conferenceSolutions);
56✔
1124
  }
1125
}
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