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:
+18
@@ -546,6 +546,24 @@ nothing for anybody else.
|
|||||||
- **iCal import** through `CalendarEvent/parse` (a file of any number of
|
- **iCal import** through `CalendarEvent/parse` (a file of any number of
|
||||||
events), from the calendar's own menu, into that calendar. The events are
|
events), from the calendar's own menu, into that calendar. The events are
|
||||||
filed rather than scheduled: no invitations go out to anyone named in them.
|
filed rather than scheduled: no invitations go out to anyone named in them.
|
||||||
|
- **Re-importing updates rather than duplicates**, as a contacts import does.
|
||||||
|
An event is recognised by its UID, per calendar, and what the file carries
|
||||||
|
wins -- so a corrected export corrects what the first attempt got wrong.
|
||||||
|
|
||||||
|
Two things are deliberately left alone: **who accepted**, and **edits to a
|
||||||
|
single occurrence**. Both are answers and decisions taken here 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 throw away work silently and
|
||||||
|
return no error anywhere. A corrected export therefore fixes the time, the
|
||||||
|
title and the location, and leaves the RSVPs and the "just this Wednesday"
|
||||||
|
changes where they are.
|
||||||
|
|
||||||
|
The cost runs both ways and is worth knowing. An attendee added at the source
|
||||||
|
since the last import does not arrive, because nothing here can tell that
|
||||||
|
apart from an answer given in ihasmail. And an import still sends no
|
||||||
|
scheduling messages, so an event a re-import moves is moved *here* --
|
||||||
|
everybody else's copy still says the old time until whoever is organising
|
||||||
|
sends the update from the event itself.
|
||||||
- **Subscribed calendars** by URL — a timetable, a rota, a public holiday list.
|
- **Subscribed calendars** by URL — a timetable, a rota, a public holiday list.
|
||||||
Added in Settings › Calendar & contacts, read-only, and shown beside your own
|
Added in Settings › Calendar & contacts, read-only, and shown beside your own
|
||||||
with their own colour.
|
with their own colour.
|
||||||
|
|||||||
@@ -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" },
|
"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" },
|
"Exported {n} events": { one: "{n} Termin exportiert", other: "{n} Termine exportiert" },
|
||||||
"Imported {n} events": { one: "{n} Termin importiert", other: "{n} Termine importiert" },
|
"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" },
|
"Updated {n} events, nothing new": { one: "{n} Termin aktualisiert, nichts Neues", other: "{n} Termine aktualisiert, nichts Neues" },
|
||||||
"{n} were already here": { one: "{n} war bereits vorhanden", other: "{n} waren bereits vorhanden" },
|
|
||||||
"{n} messages": { one: "{n} Nachricht", other: "{n} Nachrichten" },
|
"{n} messages": { one: "{n} Nachricht", other: "{n} Nachrichten" },
|
||||||
"{n} selected": { one: "{n} ausgewählt", other: "{n} ausgewählt" },
|
"{n} selected": { one: "{n} ausgewählt", other: "{n} ausgewählt" },
|
||||||
"{n} conversations": { one: "{n} Konversation", other: "{n} Konversationen" },
|
"{n} conversations": { one: "{n} Konversation", other: "{n} Konversationen" },
|
||||||
|
|||||||
@@ -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" },
|
"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" },
|
"Exported {n} events": { one: "{n} evento exportado", other: "{n} eventos exportados" },
|
||||||
"Imported {n} events": { one: "{n} evento importado", other: "{n} eventos importados" },
|
"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" },
|
"Updated {n} events, nothing new": { one: "{n} evento actualizado, nada nuevo", other: "{n} eventos actualizados, nada nuevo" },
|
||||||
"{n} were already here": { one: "{n} ya estaba aquí", other: "{n} ya estaban aquí" },
|
|
||||||
"{n} messages": { one: "{n} mensaje", other: "{n} mensajes" },
|
"{n} messages": { one: "{n} mensaje", other: "{n} mensajes" },
|
||||||
"{n} selected": { one: "{n} seleccionado", other: "{n} seleccionados" },
|
"{n} selected": { one: "{n} seleccionado", other: "{n} seleccionados" },
|
||||||
"{n} conversations": { one: "{n} conversación", other: "{n} conversaciones" },
|
"{n} conversations": { one: "{n} conversación", other: "{n} conversaciones" },
|
||||||
|
|||||||
@@ -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" },
|
"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" },
|
"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" },
|
"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 d’importé", other: "Déjà présents : {n} événements, rien d’importé" },
|
"Updated {n} events, nothing new": { one: "{n} événement mis à jour, rien de nouveau", other: "{n} événements mis à jour, rien de nouveau" },
|
||||||
"{n} were already here": { one: "{n} était déjà présent", other: "{n} étaient déjà présents" },
|
|
||||||
"{n} messages": { one: "{n} message", other: "{n} messages" },
|
"{n} messages": { one: "{n} message", other: "{n} messages" },
|
||||||
"{n} selected": { one: "{n} sélectionné", other: "{n} sélectionnés" },
|
"{n} selected": { one: "{n} sélectionné", other: "{n} sélectionnés" },
|
||||||
"{n} conversations": { one: "{n} conversation", other: "{n} conversations" },
|
"{n} conversations": { one: "{n} conversation", other: "{n} conversations" },
|
||||||
|
|||||||
@@ -1388,8 +1388,7 @@ export const catalog: Catalog = {
|
|||||||
"Your administrator changed {n} settings": { other: "管理者が {n} 件の設定を変更しました" },
|
"Your administrator changed {n} settings": { other: "管理者が {n} 件の設定を変更しました" },
|
||||||
"Exported {n} events": { other: "{n} 件の予定をエクスポートしました" },
|
"Exported {n} events": { other: "{n} 件の予定をエクスポートしました" },
|
||||||
"Imported {n} events": { other: "{n} 件の予定をインポートしました" },
|
"Imported {n} events": { other: "{n} 件の予定をインポートしました" },
|
||||||
"Already here: {n} events, nothing imported": { other: "すでに存在: {n} 件、インポートなし" },
|
"Updated {n} events, nothing new": { other: "{n} 件の予定を更新しました。新規はありません" },
|
||||||
"{n} were already here": { other: "{n} 件はすでに存在していました" },
|
|
||||||
/*
|
/*
|
||||||
* One form each, because Japanese has one. Intl.PluralRules returns
|
* One form each, because Japanese has one. Intl.PluralRules returns
|
||||||
* `other` for every number, so `one`, `few` and `many` would never be
|
* `other` for every number, so `one`, `few` and `many` would never be
|
||||||
|
|||||||
@@ -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" },
|
"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" },
|
"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" },
|
"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" },
|
"Updated {n} events, nothing new": { one: "{n} afspraak bijgewerkt, niets nieuws", other: "{n} afspraken bijgewerkt, niets nieuws" },
|
||||||
"{n} were already here": { one: "{n} was er al", other: "{n} waren er al" },
|
|
||||||
"{n} messages": { one: "{n} bericht", other: "{n} berichten" },
|
"{n} messages": { one: "{n} bericht", other: "{n} berichten" },
|
||||||
"{n} selected": { one: "{n} geselecteerd", other: "{n} geselecteerd" },
|
"{n} selected": { one: "{n} geselecteerd", other: "{n} geselecteerd" },
|
||||||
"{n} conversations": { one: "{n} gesprek", other: "{n} gesprekken" },
|
"{n} conversations": { one: "{n} gesprek", other: "{n} gesprekken" },
|
||||||
|
|||||||
@@ -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" },
|
"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" },
|
"Exported {n} events": { one: "{n} evento exportado", other: "{n} eventos exportados" },
|
||||||
"Imported {n} events": { one: "{n} evento importado", other: "{n} eventos importados" },
|
"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" },
|
"Updated {n} events, nothing new": { one: "{n} evento atualizado, nada novo", other: "{n} eventos atualizados, nada novo" },
|
||||||
"{n} were already here": { one: "{n} já estava aqui", other: "{n} já estavam aqui" },
|
|
||||||
"{n} messages": { one: "{n} mensagem", other: "{n} mensagens" },
|
"{n} messages": { one: "{n} mensagem", other: "{n} mensagens" },
|
||||||
"{n} selected": { one: "{n} selecionada", other: "{n} selecionadas" },
|
"{n} selected": { one: "{n} selecionada", other: "{n} selecionadas" },
|
||||||
"{n} conversations": { one: "{n} conversa", other: "{n} conversas" },
|
"{n} conversations": { one: "{n} conversa", other: "{n} conversas" },
|
||||||
|
|||||||
@@ -1382,8 +1382,7 @@ export const catalog: Catalog = {
|
|||||||
"Your administrator changed {n} settings": { one: "Администратор изменил {n} настройку", few: "Администратор изменил {n} настройки", many: "Администратор изменил {n} настроек", other: "Администратор изменил {n} настройки" },
|
"Your administrator changed {n} settings": { one: "Администратор изменил {n} настройку", few: "Администратор изменил {n} настройки", many: "Администратор изменил {n} настроек", other: "Администратор изменил {n} настройки" },
|
||||||
"Exported {n} events": { 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} события" },
|
"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} события, ничего не импортировано" },
|
"Updated {n} events, nothing new": { one: "Обновлено {n} событие, новых нет", few: "Обновлено {n} события, новых нет", many: "Обновлено {n} событий, новых нет", other: "Обновлено {n} события, новых нет" },
|
||||||
"{n} were already here": { one: "{n} уже было здесь", few: "{n} уже были здесь", many: "{n} уже были здесь", other: "{n} уже были здесь" },
|
|
||||||
/*
|
/*
|
||||||
* Three forms, which is the whole reason plural() takes a map rather than
|
* 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
|
* (one, other). Intl.PluralRules picks: 1 is `one`, 2-4 are `few`, 5-20
|
||||||
|
|||||||
@@ -1376,8 +1376,7 @@ export const catalog: Catalog = {
|
|||||||
"Your administrator changed {n} settings": { one: "Адміністратор змінив {n} налаштування", few: "Адміністратор змінив {n} налаштування", many: "Адміністратор змінив {n} налаштувань", other: "Адміністратор змінив {n} налаштування" },
|
"Your administrator changed {n} settings": { one: "Адміністратор змінив {n} налаштування", few: "Адміністратор змінив {n} налаштування", many: "Адміністратор змінив {n} налаштувань", other: "Адміністратор змінив {n} налаштування" },
|
||||||
"Exported {n} events": { 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} події" },
|
"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} події, нічого не імпортовано" },
|
"Updated {n} events, nothing new": { one: "Оновлено {n} подію, нових немає", few: "Оновлено {n} події, нових немає", many: "Оновлено {n} подій, нових немає", other: "Оновлено {n} події, нових немає" },
|
||||||
"{n} were already here": { one: "{n} уже була тут", few: "{n} уже були тут", many: "{n} уже були тут", other: "{n} уже були тут" },
|
|
||||||
/*
|
/*
|
||||||
* Ukrainian takes the same three forms as Russian and the same rule, but
|
* 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
|
* not the same words. Sharing a plural structure is not sharing a
|
||||||
|
|||||||
@@ -1387,8 +1387,7 @@ export const catalog: Catalog = {
|
|||||||
"Your administrator changed {n} settings": { other: "管理员更改了 {n} 项设置" },
|
"Your administrator changed {n} settings": { other: "管理员更改了 {n} 项设置" },
|
||||||
"Exported {n} events": { other: "已导出 {n} 个日程" },
|
"Exported {n} events": { other: "已导出 {n} 个日程" },
|
||||||
"Imported {n} events": { other: "已导入 {n} 个日程" },
|
"Imported {n} events": { other: "已导入 {n} 个日程" },
|
||||||
"Already here: {n} events, nothing imported": { other: "已存在 {n} 个,未导入" },
|
"Updated {n} events, nothing new": { other: "已更新 {n} 个日程,无新增" },
|
||||||
"{n} were already here": { other: "{n} 个已存在" },
|
|
||||||
/*
|
/*
|
||||||
* One form each, because Chinese has one. Intl.PluralRules returns `other`
|
* One form each, because Chinese has one. Intl.PluralRules returns `other`
|
||||||
* for every number, so `one`, `few` and `many` would never be selected —
|
* for every number, so `one`, `few` and `many` would never be selected —
|
||||||
|
|||||||
@@ -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
|
* @param parsed what `CalendarEvent/parse` answers with; a bare object rather
|
||||||
* than an array is the single-event shape, which Stalwart also returns.
|
* than an array is the single-event shape, which Stalwart also returns.
|
||||||
* @param notCreated refusals to hand back instead of creations.
|
* @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
|
* @param max the ceiling on objects in one call, refused the way Stalwart
|
||||||
* refuses it: the whole call, creating nothing.
|
* refuses it: the whole call, creating nothing.
|
||||||
* @param failOn which `/set` call (0-based) answers with an error instead.
|
* @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 sets: SetArgs[] = [];
|
||||||
const existing = opts.existing ?? [];
|
const existing = opts.existing ?? [];
|
||||||
const fetchMock = vi.fn(async (_url: string, init: RequestInit) => {
|
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") {
|
if (name === "CalendarEvent/set") {
|
||||||
const nth = sets.length;
|
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);
|
const keys = Object.keys((args.create ?? {}) as object);
|
||||||
// Whole-call refusals, both of them: nothing in this call is created.
|
const patched = Object.keys((args.update ?? {}) as object);
|
||||||
if (opts.max != null && keys.length > opts.max) {
|
/* 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];
|
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];
|
if (opts.failOn === nth) return ["error", { type: "serverFail", description: "the roof fell in" }, id];
|
||||||
const notCreated = opts.notCreated ?? {};
|
const notCreated = opts.notCreated ?? {};
|
||||||
|
const notUpdated = opts.notUpdated ?? {};
|
||||||
return [name, {
|
return [name, {
|
||||||
accountId: "a1", oldState: "1", newState: "2",
|
accountId: "a1", oldState: "1", newState: "2",
|
||||||
created: Object.fromEntries(keys.filter((k) => !(k in notCreated)).map((k) => [k, { id: `new-${k}` }])),
|
created: Object.fromEntries(keys.filter((k) => !(k in notCreated)).map((k) => [k, { id: `new-${k}` }])),
|
||||||
notCreated,
|
notCreated,
|
||||||
|
updated: Object.fromEntries(patched.filter((k) => !(k in notUpdated)).map((k) => [k, null])),
|
||||||
|
notUpdated,
|
||||||
}, id];
|
}, id];
|
||||||
}
|
}
|
||||||
// The scan for UIDs already in the calendar: a query for the account's
|
// 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 () => {
|
it("creates every event in one call when the file fits in one, not one call each", async () => {
|
||||||
const sets = server(PARSED);
|
const sets = server(PARSED);
|
||||||
const n = await useCalendar.getState().importIcs("x", "cal1");
|
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(sets).toHaveLength(1);
|
||||||
expect(Object.keys(sets[0]!.create!)).toEqual(["e0", "e1"]);
|
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 () => {
|
it("takes a single event, which is what a one-event file parses to", async () => {
|
||||||
const sets = server(PARSED[0]);
|
const sets = server(PARSED[0]);
|
||||||
const n = await useCalendar.getState().importIcs("x", "cal1");
|
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"]);
|
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 () => {
|
it("counts what got in when only some of it did", async () => {
|
||||||
server(PARSED, { notCreated: { e1: { type: "invalidProperties" } } });
|
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 () => {
|
it("splits it into calls the server will accept, and files all of it", async () => {
|
||||||
const sets = server(many(1200), { max: MAX });
|
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]);
|
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 () => {
|
it("splits by what the session advertises, not by a number of its own", async () => {
|
||||||
client.session!.capabilities[CAP.core] = { maxObjectsInGet: 40, maxObjectsInSet: 40 };
|
client.session!.capabilities[CAP.core] = { maxObjectsInGet: 40, maxObjectsInSet: 40 };
|
||||||
const sets = server(many(100), { max: 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]);
|
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
|
* 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,
|
* nothing looked. Importing an export twice left second copies of everything,
|
||||||
* which the reporter's colleague hit during testing (#173, decided there:
|
* 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", () => {
|
describe("re-importing events the calendar already has", () => {
|
||||||
const here = (uid: string, calendarId = "cal1") => ({ id: `srv-${uid}`, uid, calendarIds: { [calendarId]: true } });
|
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]")] });
|
const sets = server(PARSED, { existing: [here("[email protected]")] });
|
||||||
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 1, skipped: 1 });
|
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 1, updated: 1 });
|
||||||
// Only the second event, which has no uid of its own, was sent.
|
// 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)"]);
|
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 () => {
|
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
|
// 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.
|
// same event legitimately being in two of them is not a duplicate.
|
||||||
const sets = server(PARSED, { existing: [here("[email protected]", "cal2")] });
|
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);
|
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));
|
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 () => {
|
it("updates the lot when the whole file is already here, creating nothing", async () => {
|
||||||
// A file whose every event carries a uid the calendar holds: there is
|
|
||||||
// nothing to create, and nothing wrong either.
|
|
||||||
const both = [PARSED[0], { ...PARSED[1], uid: "[email protected]" }];
|
const both = [PARSED[0], { ...PARSED[1], uid: "[email protected]" }];
|
||||||
const sets = server(both, { existing: [here("[email protected]"), here("[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 });
|
await expect(useCalendar.getState().importIcs("x", "cal1")).resolves.toEqual({ created: 0, updated: 2 });
|
||||||
expect(sets).toHaveLength(0);
|
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
@@ -296,8 +296,8 @@ interface CalendarState {
|
|||||||
findByUid(uid: string): Promise<CalendarEvent | null>;
|
findByUid(uid: string): Promise<CalendarEvent | null>;
|
||||||
parseIcs(blobId: Id): Promise<CalendarEvent[]>;
|
parseIcs(blobId: Id): Promise<CalendarEvent[]>;
|
||||||
importEvent(event: Partial<CalendarEvent>, calendarId: Id): Promise<Id>;
|
importEvent(event: Partial<CalendarEvent>, calendarId: Id): Promise<Id>;
|
||||||
/** Import a whole .ics file. Says how many it created, and how many were already here. */
|
/** Import a whole .ics file. Says how many it created and how many it updated. */
|
||||||
importIcs(text: string, calendarId: Id): Promise<{ created: number; skipped: number }>;
|
importIcs(text: string, calendarId: Id): Promise<{ created: number; updated: number }>;
|
||||||
/** The whole calendar as one .ics document, and how many events went into it. */
|
/** The whole calendar as one .ics document, and how many events went into it. */
|
||||||
exportIcs(calendarId: Id): Promise<{ text: string; count: number }>;
|
exportIcs(calendarId: Id): Promise<{ text: string; count: number }>;
|
||||||
applyChanges(types: Set<string>): void;
|
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
|
* 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
|
* 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;
|
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"]);
|
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) => ({
|
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
|
* year of events one at a time would refetch the calendar a few hundred
|
||||||
* times. One invalidate here, after the last batch.
|
* times. One invalidate here, after the last batch.
|
||||||
*
|
*
|
||||||
* No scheduling messages. Importing a file is filing something you already
|
* No scheduling messages, on a create or an update. Importing a file is
|
||||||
* have, and mailing its participants would be a surprise to everyone.
|
* 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) {
|
async importIcs(text, calendarId) {
|
||||||
const accountId = get().accountId!;
|
const accountId = get().accountId!;
|
||||||
const up = await client.upload(accountId, new Blob([text], { type: "text/calendar" }), { type: "text/calendar" });
|
const up = await client.upload(accountId, new Blob([text], { type: "text/calendar" }), { type: "text/calendar" });
|
||||||
const events = await get().parseIcs(up.blobId);
|
const events = await get().parseIcs(up.blobId);
|
||||||
if (!events.length) throw new Error("it has no events in it");
|
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> = {};
|
const create: Record<string, unknown> = {};
|
||||||
let skipped = 0;
|
const update: Record<Id, unknown> = {};
|
||||||
events.forEach((e, i) => {
|
events.forEach((e, i) => {
|
||||||
const rest = forImport(e);
|
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
|
* about. Re-importing an export used to leave second copies of
|
||||||
* everything; asked for on #173, decided there.
|
* everything; asked for on #173, decided there.
|
||||||
*/
|
*/
|
||||||
if (rest.uid && already.has(rest.uid)) {
|
const existing = rest.uid ? already.get(rest.uid) : undefined;
|
||||||
skipped++;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
create[`e${i}`] = { "@type": "Event", ...rest, uid: rest.uid || crypto.randomUUID(), calendarIds: { [calendarId]: true } };
|
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.
|
* Creates and updates share one budget. Stalwart counts every object in a
|
||||||
if (!Object.keys(create).length) return { created: 0, skipped };
|
* `/set` against `maxObjectsInSet` together, so batching the two separately
|
||||||
const keys = Object.keys(create);
|
* 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 created = 0;
|
||||||
|
let updated = 0;
|
||||||
let refused: SetError | undefined;
|
let refused: SetError | undefined;
|
||||||
try {
|
try {
|
||||||
for (const part of chunk(keys, client.maxObjectsInSet)) {
|
for (const part of chunk(keys, client.maxObjectsInSet)) {
|
||||||
const sub: Record<string, unknown> = {};
|
const subCreate: Record<string, unknown> = {};
|
||||||
for (const k of part) sub[k] = create[k];
|
const subUpdate: Record<string, unknown> = {};
|
||||||
const res = await client.call<SetResponse<CalendarEvent>>("CalendarEvent/set", { accountId, create: sub, sendSchedulingMessages: false });
|
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;
|
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) {
|
} 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
|
// in the calendar, and an error saying only that the import failed sends
|
||||||
// someone looking for events that are already there.
|
// someone looking for events that are already there.
|
||||||
if (!created) throw err;
|
if (!created && !updated) throw err;
|
||||||
throw new Error(`${created} of ${keys.length} events were imported before this happened: ${(err as Error).message}`);
|
throw new Error(`${created + updated} of ${keys.length} events were imported before this happened: ${(err as Error).message}`);
|
||||||
} finally {
|
} finally {
|
||||||
if (created) get().invalidate();
|
if (created || updated) get().invalidate();
|
||||||
}
|
}
|
||||||
// Nothing at all got in: say why rather than report importing zero events
|
// Nothing at all got in: say why rather than report importing zero events
|
||||||
// as though the file had been empty.
|
// as though the file had been empty.
|
||||||
if (!created) throw new Error(refused ? setErrorMessage(refused) : "the server did not accept any of its events");
|
if (!created && !updated) throw new Error(refused ? setErrorMessage(refused) : "the server did not accept any of its events");
|
||||||
return { created, skipped };
|
return { created, updated };
|
||||||
},
|
},
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -65,16 +65,19 @@ export function CalendarSidebar() {
|
|||||||
const calendarId = importInto.current;
|
const calendarId = importInto.current;
|
||||||
if (!calendarId) return;
|
if (!calendarId) return;
|
||||||
try {
|
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
|
* The two counts are kept apart on purpose, the way the contacts import
|
||||||
* file of 240 reads as a failure when 200 of them were simply already
|
* keeps them. "Imported 40 events" over a file of 240 reads as a failure
|
||||||
* here, and a re-import where everything is already here would otherwise
|
* when the other 200 were updated, and a re-import of a corrected export
|
||||||
* report importing nothing at all.
|
* -- 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" }));
|
const imported = plural(created, { one: "Imported {n} event", other: "Imported {n} events" });
|
||||||
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" })}`);
|
const refreshed = plural(updated, { one: "{n} updated", other: "{n} updated" });
|
||||||
else toast.success(plural(created, { one: "Imported {n} event", other: "Imported {n} events" }));
|
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) {
|
} catch (err) {
|
||||||
toast.error(t("Could not import this file: {error}", { error: (err as Error).message }));
|
toast.error(t("Could not import this file: {error}", { error: (err as Error).message }));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user