Update a re-imported event rather than skipping it

Contacts and calendars disagreed on a re-import: a vCard or LDIF entry
whose identity a book already held overwrote the card there (#242, #274),
while an event whose UID a calendar held was counted and thrown away
(#222). The asymmetry was never decided -- it was where each half stopped.

Decided on #279: calendars update too, with two properties held back.
`participants` carries every attendee's accepted/declined and
`recurrenceOverrides` holds every "just this Wednesday" edit made here.
Both are decisions taken after the file was written, and a file that
mentions them at all describes them as they were at export, so writing
either one over would destroy work silently and return no error. A
corrected export now fixes the time, the title and the location, and
leaves who said yes alone. `uid` is held back with them: it is what the
two were matched on, so it is already equal.

The scan returns uid -> id rather than a set of UIDs, since updating
needs something to address, and creates and updates now share one
`maxObjectsInSet` budget the way contacts' `writeCards` does -- 300 new
and 300 changed batched separately would be two calls of 300, neither
over a ceiling of 500 and both refused. Counts become created/updated,
reported as the contacts import reports them.

Still no scheduling messages, on an update as much as on a create. That
is a real cost -- an event a re-import moves is moved here and nowhere
else -- and it is the lesser one: an import is not the place to start
mailing a room full of people who never asked for it.

Driven against the mock end to end: a second file with the same UID
updated the event in place, took the file's title, start and location,
and left an accepted RSVP and a per-occurrence override untouched even
though the file carried participants of its own.
This commit is contained in:
2026-09-07 12:56:36 -07:00
parent 7825097333
commit 8173e22ccb
13 changed files with 220 additions and 72 deletions
+1 -2
View File
@@ -1407,8 +1407,7 @@ export const catalog: Catalog = {
"Your administrator changed {n} settings": { one: "Ihre Administration hat {n} Einstellung geändert", other: "Ihre Administration hat {n} Einstellungen geändert" },
"Exported {n} events": { one: "{n} Termin exportiert", other: "{n} Termine exportiert" },
"Imported {n} events": { one: "{n} Termin importiert", other: "{n} Termine importiert" },
"Already here: {n} events, nothing imported": { one: "Bereits vorhanden: {n} Termin, nichts importiert", other: "Bereits vorhanden: {n} Termine, nichts importiert" },
"{n} were already here": { one: "{n} war bereits vorhanden", other: "{n} waren bereits vorhanden" },
"Updated {n} events, nothing new": { one: "{n} Termin aktualisiert, nichts Neues", other: "{n} Termine aktualisiert, nichts Neues" },
"{n} messages": { one: "{n} Nachricht", other: "{n} Nachrichten" },
"{n} selected": { one: "{n} ausgewählt", other: "{n} ausgewählt" },
"{n} conversations": { one: "{n} Konversation", other: "{n} Konversationen" },
+1 -2
View File
@@ -1380,8 +1380,7 @@ export const catalog: Catalog = {
"Your administrator changed {n} settings": { one: "Tu administración cambió {n} ajuste", other: "Tu administración cambió {n} ajustes" },
"Exported {n} events": { one: "{n} evento exportado", other: "{n} eventos exportados" },
"Imported {n} events": { one: "{n} evento importado", other: "{n} eventos importados" },
"Already here: {n} events, nothing imported": { one: "Ya estaba aquí: {n} evento, no se importó nada", other: "Ya estaban aquí: {n} eventos, no se importó nada" },
"{n} were already here": { one: "{n} ya estaba aquí", other: "{n} ya estaban aquí" },
"Updated {n} events, nothing new": { one: "{n} evento actualizado, nada nuevo", other: "{n} eventos actualizados, nada nuevo" },
"{n} messages": { one: "{n} mensaje", other: "{n} mensajes" },
"{n} selected": { one: "{n} seleccionado", other: "{n} seleccionados" },
"{n} conversations": { one: "{n} conversación", other: "{n} conversaciones" },
+1 -2
View File
@@ -1385,8 +1385,7 @@ export const catalog: Catalog = {
"Your administrator changed {n} settings": { one: "Votre administration a modifié {n} paramètre", other: "Votre administration a modifié {n} paramètres" },
"Exported {n} events": { one: "{n} événement exporté", other: "{n} événements exportés" },
"Imported {n} events": { one: "{n} événement importé", other: "{n} événements importés" },
"Already here: {n} events, nothing imported": { one: "Déjà présent : {n} événement, rien dimporté", other: "Déjà présents : {n} événements, rien dimporté" },
"{n} were already here": { one: "{n} était déjà présent", other: "{n} étaient déjà présents" },
"Updated {n} events, nothing new": { one: "{n} événement mis à jour, rien de nouveau", other: "{n} événements mis à jour, rien de nouveau" },
"{n} messages": { one: "{n} message", other: "{n} messages" },
"{n} selected": { one: "{n} sélectionné", other: "{n} sélectionnés" },
"{n} conversations": { one: "{n} conversation", other: "{n} conversations" },
+1 -2
View File
@@ -1388,8 +1388,7 @@ export const catalog: Catalog = {
"Your administrator changed {n} settings": { other: "管理者が {n} 件の設定を変更しました" },
"Exported {n} events": { other: "{n} 件の予定をエクスポートしました" },
"Imported {n} events": { other: "{n} 件の予定をインポートしました" },
"Already here: {n} events, nothing imported": { other: "すでに存在: {n} 件、インポートなし" },
"{n} were already here": { other: "{n} 件はすでに存在していました" },
"Updated {n} events, nothing new": { other: "{n} 件の予定を更新しました。新規はありません" },
/*
* One form each, because Japanese has one. Intl.PluralRules returns
* `other` for every number, so `one`, `few` and `many` would never be
+1 -2
View File
@@ -1376,8 +1376,7 @@ export const catalog: Catalog = {
"Your administrator changed {n} settings": { one: "Uw beheerder heeft {n} instelling gewijzigd", other: "Uw beheerder heeft {n} instellingen gewijzigd" },
"Exported {n} events": { one: "{n} afspraak geëxporteerd", other: "{n} afspraken geëxporteerd" },
"Imported {n} events": { one: "{n} afspraak geïmporteerd", other: "{n} afspraken geïmporteerd" },
"Already here: {n} events, nothing imported": { one: "Al aanwezig: {n} afspraak, niets geïmporteerd", other: "Al aanwezig: {n} afspraken, niets geïmporteerd" },
"{n} were already here": { one: "{n} was er al", other: "{n} waren er al" },
"Updated {n} events, nothing new": { one: "{n} afspraak bijgewerkt, niets nieuws", other: "{n} afspraken bijgewerkt, niets nieuws" },
"{n} messages": { one: "{n} bericht", other: "{n} berichten" },
"{n} selected": { one: "{n} geselecteerd", other: "{n} geselecteerd" },
"{n} conversations": { one: "{n} gesprek", other: "{n} gesprekken" },
+1 -2
View File
@@ -1383,8 +1383,7 @@ export const catalog: Catalog = {
"Your administrator changed {n} settings": { one: "Sua administração alterou {n} configuração", other: "Sua administração alterou {n} configurações" },
"Exported {n} events": { one: "{n} evento exportado", other: "{n} eventos exportados" },
"Imported {n} events": { one: "{n} evento importado", other: "{n} eventos importados" },
"Already here: {n} events, nothing imported": { one: "Já estava aqui: {n} evento, nada importado", other: "Já estavam aqui: {n} eventos, nada importado" },
"{n} were already here": { one: "{n} já estava aqui", other: "{n} já estavam aqui" },
"Updated {n} events, nothing new": { one: "{n} evento atualizado, nada novo", other: "{n} eventos atualizados, nada novo" },
"{n} messages": { one: "{n} mensagem", other: "{n} mensagens" },
"{n} selected": { one: "{n} selecionada", other: "{n} selecionadas" },
"{n} conversations": { one: "{n} conversa", other: "{n} conversas" },
+1 -2
View File
@@ -1382,8 +1382,7 @@ export const catalog: Catalog = {
"Your administrator changed {n} settings": { one: "Администратор изменил {n} настройку", few: "Администратор изменил {n} настройки", many: "Администратор изменил {n} настроек", other: "Администратор изменил {n} настройки" },
"Exported {n} events": { one: "Экспортировано {n} событие", few: "Экспортировано {n} события", many: "Экспортировано {n} событий", other: "Экспортировано {n} события" },
"Imported {n} events": { one: "Импортировано {n} событие", few: "Импортировано {n} события", many: "Импортировано {n} событий", other: "Импортировано {n} события" },
"Already here: {n} events, nothing imported": { one: "Уже есть: {n} событие, ничего не импортировано", few: "Уже есть: {n} события, ничего не импортировано", many: "Уже есть: {n} событий, ничего не импортировано", other: "Уже есть: {n} события, ничего не импортировано" },
"{n} were already here": { one: "{n} уже было здесь", few: "{n} уже были здесь", many: "{n} уже были здесь", other: "{n} уже были здесь" },
"Updated {n} events, nothing new": { one: "Обновлено {n} событие, новых нет", few: "Обновлено {n} события, новых нет", many: "Обновлено {n} событий, новых нет", other: "Обновлено {n} события, новых нет" },
/*
* Three forms, which is the whole reason plural() takes a map rather than
* (one, other). Intl.PluralRules picks: 1 is `one`, 2-4 are `few`, 5-20
+1 -2
View File
@@ -1376,8 +1376,7 @@ export const catalog: Catalog = {
"Your administrator changed {n} settings": { one: "Адміністратор змінив {n} налаштування", few: "Адміністратор змінив {n} налаштування", many: "Адміністратор змінив {n} налаштувань", other: "Адміністратор змінив {n} налаштування" },
"Exported {n} events": { one: "Експортовано {n} подію", few: "Експортовано {n} події", many: "Експортовано {n} подій", other: "Експортовано {n} події" },
"Imported {n} events": { one: "Імпортовано {n} подію", few: "Імпортовано {n} події", many: "Імпортовано {n} подій", other: "Імпортовано {n} події" },
"Already here: {n} events, nothing imported": { one: "Уже є: {n} подія, нічого не імпортовано", few: "Уже є: {n} події, нічого не імпортовано", many: "Уже є: {n} подій, нічого не імпортовано", other: "Уже є: {n} події, нічого не імпортовано" },
"{n} were already here": { one: "{n} уже була тут", few: "{n} уже були тут", many: "{n} уже були тут", other: "{n} уже були тут" },
"Updated {n} events, nothing new": { one: "Оновлено {n} подію, нових немає", few: "Оновлено {n} події, нових немає", many: "Оновлено {n} подій, нових немає", other: "Оновлено {n} події, нових немає" },
/*
* Ukrainian takes the same three forms as Russian and the same rule, but
* not the same words. Sharing a plural structure is not sharing a
+1 -2
View File
@@ -1387,8 +1387,7 @@ export const catalog: Catalog = {
"Your administrator changed {n} settings": { other: "管理员更改了 {n} 项设置" },
"Exported {n} events": { other: "已导出 {n} 个日程" },
"Imported {n} events": { other: "已导入 {n} 个日程" },
"Already here: {n} events, nothing imported": { other: "已存在 {n} 个,未导入" },
"{n} were already here": { other: "{n} 个已存在" },
"Updated {n} events, nothing new": { other: "已更新 {n} 个日程,无新增" },
/*
* One form each, because Chinese has one. Intl.PluralRules returns `other`
* for every number, so `one`, `few` and `many` would never be selected —
+99 -20
View File
@@ -28,17 +28,18 @@ const PARSED = [
},
];
interface SetArgs { create?: Record<string, Record<string, unknown>>; sendSchedulingMessages?: boolean }
interface SetArgs { create?: Record<string, Record<string, unknown>>; update?: Record<string, Record<string, unknown>>; sendSchedulingMessages?: boolean }
/**
* @param parsed what `CalendarEvent/parse` answers with; a bare object rather
* than an array is the single-event shape, which Stalwart also returns.
* @param notCreated refusals to hand back instead of creations.
* @param notUpdated refusals to hand back instead of updates.
* @param max the ceiling on objects in one call, refused the way Stalwart
* refuses it: the whole call, creating nothing.
* @param failOn which `/set` call (0-based) answers with an error instead.
*/
function server(parsed: unknown, opts: { notCreated?: Record<string, unknown>; max?: number; failOn?: number; existing?: Array<{ id: string; uid: string; calendarIds: Record<string, boolean> }> } = {}) {
function server(parsed: unknown, opts: { notCreated?: Record<string, unknown>; notUpdated?: Record<string, unknown>; max?: number; failOn?: number; existing?: Array<{ id: string; uid: string; calendarIds: Record<string, boolean> }> } = {}) {
const sets: SetArgs[] = [];
const existing = opts.existing ?? [];
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
@@ -50,18 +51,28 @@ function server(parsed: unknown, opts: { notCreated?: Record<string, unknown>; m
}
if (name === "CalendarEvent/set") {
const nth = sets.length;
sets.push({ create: args.create as Record<string, Record<string, unknown>>, sendSchedulingMessages: args.sendSchedulingMessages as boolean });
sets.push({
create: args.create as Record<string, Record<string, unknown>>,
update: args.update as Record<string, Record<string, unknown>>,
sendSchedulingMessages: args.sendSchedulingMessages as boolean,
});
const keys = Object.keys((args.create ?? {}) as object);
// Whole-call refusals, both of them: nothing in this call is created.
if (opts.max != null && keys.length > opts.max) {
const patched = Object.keys((args.update ?? {}) as object);
/* Whole-call refusals, both of them: nothing in this call gets written.
Creates and updates count against the ceiling together, which is why
the store batches them together. */
if (opts.max != null && keys.length + patched.length > opts.max) {
return ["error", { type: "requestTooLarge", description: "The number of ids requested by the client exceeds the maximum number the server is willing to process in a single method call." }, id];
}
if (opts.failOn === nth) return ["error", { type: "serverFail", description: "the roof fell in" }, id];
const notCreated = opts.notCreated ?? {};
const notUpdated = opts.notUpdated ?? {};
return [name, {
accountId: "a1", oldState: "1", newState: "2",
created: Object.fromEntries(keys.filter((k) => !(k in notCreated)).map((k) => [k, { id: `new-${k}` }])),
notCreated,
updated: Object.fromEntries(patched.filter((k) => !(k in notUpdated)).map((k) => [k, null])),
notUpdated,
}, id];
}
// The scan for UIDs already in the calendar: a query for the account's
@@ -122,7 +133,7 @@ describe("importing an .ics file", () => {
it("creates every event in one call when the file fits in one, not one call each", async () => {
const sets = server(PARSED);
const n = await useCalendar.getState().importIcs("x", "cal1");
expect(n).toEqual({ created: 2, skipped: 0 });
expect(n).toEqual({ created: 2, updated: 0 });
expect(sets).toHaveLength(1);
expect(Object.keys(sets[0]!.create!)).toEqual(["e0", "e1"]);
});
@@ -163,7 +174,7 @@ describe("importing an .ics file", () => {
it("takes a single event, which is what a one-event file parses to", async () => {
const sets = server(PARSED[0]);
const n = await useCalendar.getState().importIcs("x", "cal1");
expect(n).toEqual({ created: 1, skipped: 0 });
expect(n).toEqual({ created: 1, updated: 0 });
expect(Object.keys(sets[0]!.create!)).toEqual(["e0"]);
});
@@ -179,7 +190,7 @@ describe("importing an .ics file", () => {
it("counts what got in when only some of it did", async () => {
server(PARSED, { notCreated: { e1: { type: "invalidProperties" } } });
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 1, skipped: 0 });
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 1, updated: 0 });
});
});
@@ -202,14 +213,14 @@ describe("importing a file bigger than the server will take at once", () => {
it("splits it into calls the server will accept, and files all of it", async () => {
const sets = server(many(1200), { max: MAX });
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 1200, skipped: 0 });
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 1200, updated: 0 });
expect(sets.map((s) => Object.keys(s.create!).length)).toEqual([500, 500, 200]);
});
it("splits by what the session advertises, not by a number of its own", async () => {
client.session!.capabilities[CAP.core] = { maxObjectsInGet: 40, maxObjectsInSet: 40 };
const sets = server(many(100), { max: 40 });
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 100, skipped: 0 });
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 100, updated: 0 });
expect(sets.map((s) => Object.keys(s.create!).length)).toEqual([40, 40, 20]);
});
@@ -256,23 +267,59 @@ describe("importing a file bigger than the server will take at once", () => {
* whole of what is needed to recognise an event that is already here -- and
* nothing looked. Importing an export twice left second copies of everything,
* which the reporter's colleague hit during testing (#173, decided there:
* "duplicate checks on UIDs if UID present in event"). Issue #222.
* "duplicate checks on UIDs if UID present in event"). Issue #222 made that a
* skip; #279 made it an update, because the reason to import a file a second
* time is usually that the first one was not right.
*/
describe("re-importing events the calendar already has", () => {
const here = (uid: string, calendarId = "cal1") => ({ id: `srv-${uid}`, uid, calendarIds: { [calendarId]: true } });
it("skips an event whose uid is already in this calendar", async () => {
it("updates an event whose uid is already in this calendar", async () => {
const sets = server(PARSED, { existing: [here("[email protected]")] });
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 1, skipped: 1 });
// Only the second event, which has no uid of its own, was sent.
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 1, updated: 1 });
// Only the second event, which has no uid of its own, is new.
expect(Object.values(sets[0]!.create!).map((e) => e.title)).toEqual(["Retro (no uid)"]);
// The update is addressed to the event that is here, not to the file's id.
expect(Object.keys(sets[0]!.update!)).toEqual(["[email protected]"]);
expect(sets[0]!.update!["[email protected]"]!.title).toBe("Kickoff");
});
it("holds back the answers and the per-occurrence edits, which live on the event", async () => {
/*
* The one thing #279 turned on. `participants` carries who accepted and
* `recurrenceOverrides` carries every "just this Wednesday" change made
* here; a file describes both as they were at export, so writing either one
* over throws away work with no error anywhere. Everything else in the file
* wins, which is the point of importing it again.
*/
const withPeople = [{
...PARSED[0],
title: "Kickoff (moved)",
participants: { "[email protected]": { "@type": "Participant", participationStatus: "needs-action" } },
recurrenceOverrides: { "2026-09-09T09:00:00": { title: "Skip" } },
}];
const sets = server(withPeople, { existing: [here("[email protected]")] });
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 0, updated: 1 });
const patch = sets[0]!.update!["[email protected]"]!;
expect(patch.title).toBe("Kickoff (moved)");
expect(patch).not.toHaveProperty("participants");
expect(patch).not.toHaveProperty("recurrenceOverrides");
// The identity the two were matched on is not re-asserted as a field.
expect(patch).not.toHaveProperty("uid");
});
it("does not mail anyone about an event it updated", async () => {
// Filing a file is not scheduling, on an update as much as on a create.
const sets = server(PARSED, { existing: [here("[email protected]")] });
await useCalendar.getState().importIcs("x", "cal1");
expect(sets[0]!.sendSchedulingMessages).toBe(false);
});
it("imports an event whose uid is in a different calendar", async () => {
// A UID is what makes an event the same event *across* calendars, so the
// same event legitimately being in two of them is not a duplicate.
const sets = server(PARSED, { existing: [here("[email protected]", "cal2")] });
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 2, skipped: 0 });
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 2, updated: 0 });
expect(Object.keys(sets[0]!.create!)).toHaveLength(2);
});
@@ -282,12 +329,44 @@ describe("re-importing events the calendar already has", () => {
expect(Object.values(sets[0]!.create!)[0]!.uid).toEqual(expect.any(String));
});
it("sends nothing at all when the whole file is already here", async () => {
// A file whose every event carries a uid the calendar holds: there is
// nothing to create, and nothing wrong either.
it("updates the lot when the whole file is already here, creating nothing", async () => {
const both = [PARSED[0], { ...PARSED[1], uid: "[email protected]" }];
const sets = server(both, { existing: [here("[email protected]"), here("[email protected]")] });
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 0, skipped: 2 });
expect(sets).toHaveLength(0);
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 0, updated: 2 });
expect(sets).toHaveLength(1);
expect(Object.keys(sets[0]!.create!)).toHaveLength(0);
expect(Object.keys(sets[0]!.update!)).toHaveLength(2);
});
it("reports a refusal to update, rather than reporting nothing imported", async () => {
// Everything in the file is already here, so an update is the whole of the
// import -- and a refusal of it is the only thing there is to say.
const both = [PARSED[0], { ...PARSED[1], uid: "[email protected]" }];
server(both, {
existing: [here("[email protected]"), here("[email protected]")],
notUpdated: {
"[email protected]": { type: "forbidden", description: "the calendar is read-only" },
"[email protected]": { type: "forbidden" },
},
});
await expect(useCalendar.getState().importIcs("x", "cal1")).rejects.toThrow(/read-only/);
});
it("splits creates and updates against one ceiling, not one each", async () => {
/*
* Stalwart counts every object in a `/set` against `maxObjectsInSet`
* together and refuses the whole call over it. 300 new and 300 changed
* batched separately would be two calls of 300 -- neither over 500, both
* refused.
*/
const MAX = 500;
const file = Array.from({ length: 600 }, (_, i) => ({
"@type": "Event", uid: `uid-${i}@example.org`, title: `Event ${i}`,
start: "2026-09-02T09:00:00", duration: "PT1H", timeZone: "Etc/UTC",
}));
const existing = Array.from({ length: 300 }, (_, i) => here(`uid-${i}@example.org`));
const sets = server(file, { max: MAX, existing });
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 300, updated: 300 });
expect(sets.map((s) => Object.keys(s.create ?? {}).length + Object.keys(s.update ?? {}).length)).toEqual([500, 100]);
});
});
+83 -26
View File
@@ -296,8 +296,8 @@ interface CalendarState {
findByUid(uid: string): Promise<CalendarEvent | null>;
parseIcs(blobId: Id): Promise<CalendarEvent[]>;
importEvent(event: Partial<CalendarEvent>, calendarId: Id): Promise<Id>;
/** Import a whole .ics file. Says how many it created, and how many were already here. */
importIcs(text: string, calendarId: Id): Promise<{ created: number; skipped: number }>;
/** Import a whole .ics file. Says how many it created and how many it updated. */
importIcs(text: string, calendarId: Id): Promise<{ created: number; updated: number }>;
/** The whole calendar as one .ics document, and how many events went into it. */
exportIcs(calendarId: Id): Promise<{ text: string; count: number }>;
applyChanges(types: Set<string>): void;
@@ -333,7 +333,7 @@ function forImport(event: Partial<CalendarEvent>): Partial<CalendarEvent> {
}
/**
* The UIDs a calendar already holds.
* The events a calendar already holds, for recognising a re-import.
*
* A UID is what makes an event the same event across calendars, and the import
* already keeps the file's own wherever there is one -- so the thing needed to
@@ -367,10 +367,21 @@ async function eventsInCalendar(accountId: Id, calendarId: Id, properties: strin
return found;
}
/** Just the UIDs, for deciding what a re-import would duplicate. */
async function uidsInCalendar(accountId: Id, calendarId: Id): Promise<Set<string>> {
/**
* uid -> the id of the event carrying it, for deciding what a re-import updates.
*
* The id and not just the UID, because an event already here is now updated
* rather than skipped and updating needs something to address -- the same
* arrangement, and for the same reason, as contacts' `scanBook`. A UID the
* calendar somehow holds twice keeps the first: two events with one UID is
* already a state nothing here can make sense of, and addressing one of them
* is better than writing the file over both.
*/
async function eventIdsByUid(accountId: Id, calendarId: Id): Promise<Map<string, Id>> {
const events = await eventsInCalendar(accountId, calendarId, ["uid", "calendarIds"]);
return new Set(events.map((e) => e.uid).filter(Boolean));
const byUid = new Map<string, Id>();
for (const e of events) if (e.uid && !byUid.has(e.uid)) byUid.set(e.uid, e.id);
return byUid;
}
export const useCalendar = create<CalendarState>((set, get) => ({
@@ -862,17 +873,24 @@ export const useCalendar = create<CalendarState>((set, get) => ({
* year of events one at a time would refetch the calendar a few hundred
* times. One invalidate here, after the last batch.
*
* No scheduling messages. Importing a file is filing something you already
* have, and mailing its participants would be a surprise to everyone.
* No scheduling messages, on a create or an update. Importing a file is
* filing something you already have, and mailing its participants would be a
* surprise to everyone. That is plainly right for a create and it is a real
* cost on an update -- moving an event without telling anyone leaves every
* attendee's own copy saying the old time, with nothing anywhere reporting
* the disagreement. Weighed on #279 and kept: an import is not the place to
* start sending mail on somebody's behalf, and the alternative is a file
* dropped into a calendar mailing a room full of people who never asked for
* it. Whoever is organising can send the update from the event itself.
*/
async importIcs(text, calendarId) {
const accountId = get().accountId!;
const up = await client.upload(accountId, new Blob([text], { type: "text/calendar" }), { type: "text/calendar" });
const events = await get().parseIcs(up.blobId);
if (!events.length) throw new Error("it has no events in it");
const already = await uidsInCalendar(accountId, calendarId);
const already = await eventIdsByUid(accountId, calendarId);
const create: Record<string, unknown> = {};
let skipped = 0;
const update: Record<Id, unknown> = {};
events.forEach((e, i) => {
const rest = forImport(e);
/*
@@ -883,39 +901,78 @@ export const useCalendar = create<CalendarState>((set, get) => ({
* about. Re-importing an export used to leave second copies of
* everything; asked for on #173, decided there.
*/
if (rest.uid && already.has(rest.uid)) {
skipped++;
const existing = rest.uid ? already.get(rest.uid) : undefined;
if (existing) {
/*
* An event this calendar already holds is updated from the file, the
* way a re-imported contact is (#242, #274): the reason to import a
* file a second time is usually that the first one was not right, and
* skipping meant a corrected export corrected nothing.
*
* Two properties are held back, decided on #279. `participants` carries
* every attendee's accepted/declined and `recurrenceOverrides` holds
* every "just this Wednesday" edit made here -- both are answers and
* decisions that happened after the file was written, and a file that
* mentions them at all describes them as they were at export. Writing
* either one over would destroy work nobody asked to lose, silently,
* with no error returned anywhere. So a corrected export fixes the
* time, the title and the location and leaves who said yes alone.
*
* The cost runs the other way: an attendee added at the source since
* the last import does not arrive, and nothing here can tell that apart
* from an RSVP given in ihasmail. Losing an answer somebody gave is
* worse than not gaining an attendee somebody can still be told about.
*
* `uid` is held back too -- it is what the two were matched on, so it
* is already equal, and it is the event's identity rather than a field
* of it worth re-asserting.
*/
const { uid: _u, participants: _p, recurrenceOverrides: _r, ...patch } = rest;
update[existing] = patch;
return;
}
create[`e${i}`] = { "@type": "Event", ...rest, uid: rest.uid || crypto.randomUUID(), calendarIds: { [calendarId]: true } };
});
// Everything in the file was already here. Nothing to send, and nothing
// wrong either -- say so rather than reporting an import of no events.
if (!Object.keys(create).length) return { created: 0, skipped };
const keys = Object.keys(create);
/*
* Creates and updates share one budget. Stalwart counts every object in a
* `/set` against `maxObjectsInSet` together, so batching the two separately
* would send a file of 300 new events and 300 changed ones as two calls of
* 300 and be refused for a ceiling of 500 that neither half crosses.
* Contacts' `writeCards` splits the same way for the same reason.
*/
const keys = [
...Object.keys(create).map((k) => ["create", k] as const),
...Object.keys(update).map((k) => ["update", k] as const),
];
let created = 0;
let updated = 0;
let refused: SetError | undefined;
try {
for (const part of chunk(keys, client.maxObjectsInSet)) {
const sub: Record<string, unknown> = {};
for (const k of part) sub[k] = create[k];
const res = await client.call<SetResponse<CalendarEvent>>("CalendarEvent/set", { accountId, create: sub, sendSchedulingMessages: false });
const subCreate: Record<string, unknown> = {};
const subUpdate: Record<string, unknown> = {};
for (const [kind, k] of part) {
if (kind === "create") subCreate[k] = create[k];
else subUpdate[k] = update[k];
}
const res = await client.call<SetResponse<CalendarEvent>>("CalendarEvent/set", { accountId, create: subCreate, update: subUpdate, sendSchedulingMessages: false });
created += Object.keys(res.created ?? {}).length;
refused ??= Object.values(res.notCreated ?? {})[0];
updated += Object.keys(res.updated ?? {}).length;
refused ??= Object.values(res.notCreated ?? {})[0] ?? Object.values(res.notUpdated ?? {})[0];
}
} catch (err) {
// A batch that failed with earlier ones already filed: those events are
// A batch that failed with earlier ones already written: those events are
// in the calendar, and an error saying only that the import failed sends
// someone looking for events that are already there.
if (!created) throw err;
throw new Error(`${created} of ${keys.length} events were imported before this happened: ${(err as Error).message}`);
if (!created && !updated) throw err;
throw new Error(`${created + updated} of ${keys.length} events were imported before this happened: ${(err as Error).message}`);
} finally {
if (created) get().invalidate();
if (created || updated) get().invalidate();
}
// Nothing at all got in: say why rather than report importing zero events
// as though the file had been empty.
if (!created) throw new Error(refused ? setErrorMessage(refused) : "the server did not accept any of its events");
return { created, skipped };
if (!created && !updated) throw new Error(refused ? setErrorMessage(refused) : "the server did not accept any of its events");
return { created, updated };
},
/*
+11 -8
View File
@@ -65,16 +65,19 @@ export function CalendarSidebar() {
const calendarId = importInto.current;
if (!calendarId) return;
try {
const { created, skipped } = await cal.importIcs(await file.text(), calendarId);
const { created, updated } = await cal.importIcs(await file.text(), calendarId);
/*
* The two counts are kept apart on purpose. "Imported 40 events" over a
* file of 240 reads as a failure when 200 of them were simply already
* here, and a re-import where everything is already here would otherwise
* report importing nothing at all.
* The two counts are kept apart on purpose, the way the contacts import
* keeps them. "Imported 40 events" over a file of 240 reads as a failure
* when the other 200 were updated, and a re-import of a corrected export
* -- the reason for doing this at all -- creates nothing and would
* otherwise report importing nothing at all.
*/
if (!created) toast.success(plural(skipped, { one: "Already here: {n} event, nothing imported", other: "Already here: {n} events, nothing imported" }));
else if (skipped) toast.success(`${plural(created, { one: "Imported {n} event", other: "Imported {n} events" })} · ${plural(skipped, { one: "{n} was already here", other: "{n} were already here" })}`);
else toast.success(plural(created, { one: "Imported {n} event", other: "Imported {n} events" }));
const imported = plural(created, { one: "Imported {n} event", other: "Imported {n} events" });
const refreshed = plural(updated, { one: "{n} updated", other: "{n} updated" });
if (!created) toast.success(plural(updated, { one: "Updated {n} event, nothing new", other: "Updated {n} events, nothing new" }));
else if (updated) toast.success(`${imported} · ${refreshed}`);
else toast.success(imported);
} catch (err) {
toast.error(t("Could not import this file: {error}", { error: (err as Error).message }));
}