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

kewisch / gdata-provider / 17644119385

11 Sep 2025 12:13PM UTC coverage: 89.862% (-1.4%) from 91.286%
17644119385

push

github

kewisch
chore: Bump compatibility to 144.*

997 of 1066 branches covered (93.53%)

1303 of 1450 relevant lines covered (89.86%)

79.0 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
} 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)) {
284✔
37
    let recId = comp.getFirstPropertyValue("recurrence-id");
282✔
38
    if (!instance && !recId) {
282✔
39
      return comp;
268✔
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] == "-") {
222✔
49
    // No value, or already a jCal/rfc3339 time
50
    return value;
166✔
51
  } else if (value instanceof ICAL.Time) {
56✔
52
    // Core needs to allow x-props with params, then this will simply be parsed an ICAL.Time
53
    return value.toString();
36✔
54
  } else if (value.length == 16 && value.endsWith("Z")) {
20✔
55
    // An ICAL string value like 20240102T030405Z
56
    return ICAL.design.icalendar.value["date-time"].fromICAL(value);
12✔
57
  }
58

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

73
  return null;
2✔
74
}
75

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

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

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

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

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

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

119
  return entry;
6✔
120
}
121

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

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

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

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

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

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

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

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

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

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

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

204
  return false;
78✔
205
}
206

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

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

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

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

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

255
  return reminders;
224✔
256
}
257

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

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

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

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

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

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

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

308
  return needsAttendeeUpdate;
112✔
309
}
310

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

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

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

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

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

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

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

346
  return att;
80✔
347
}
348

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

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

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

369
  return recrules;
224✔
370
}
371

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

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

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

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

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

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

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

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

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

424
  setIfFirstProperty(entry, "due", "due", dueDate => dueDate.toString());
20✔
425
  setIfFirstProperty(entry, "completed", "completed", completedDate => completedDate.toString());
20✔
426

427
  return entry;
20✔
428
}
429

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

653
  return entry;
112✔
654
}
655

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

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

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

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

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

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

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

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

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

713
  return dateobj;
60✔
714
}
715

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

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

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

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

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

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

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

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

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

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

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

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

780
  pushPropIf(jsonToDate("dtstart", entry.start, defaultTimezone));
70✔
781
  if (!entry.endTimeUnspecified) {
70✔
782
    veventprops.push(jsonToDate("dtend", entry.end, defaultTimezone));
52✔
783
  }
784

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

888
  return shell;
70✔
889
}
890

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

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

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

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

910
export class ItemSaver {
911
  #confSolutionCache = {};
76✔
912

913
  missingParents = [];
76✔
914
  parentItems = Object.create(null);
76✔
915

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

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

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

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

944
    let exceptionItems = [];
26✔
945

946
    let defaultTimezone = TimezoneService.get(data.timeZone);
26✔
947

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

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

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

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

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

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

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

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

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

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

1022
    this.parentItems[item.id] = item;
8✔
1023
  }
1024

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

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

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

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

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

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

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

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

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

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

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

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

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

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