Export a calendar as an iCAL file

The mirror of the import from #173, and the last thing contacts had that
calendars did not -- an address book could always be exported, a calendar
never could.

It is written here rather than asked for. The import hands parsing to the
server because Stalwart has a CalendarEvent/parse and reimplementing an .ics
reader in a browser would be foolish; there is no method the other way, in
Stalwart or in the JMAP calendar drafts, so the file is built from the RFC
8984 objects the server already returns. Most of that is renaming: 8984 was
written as a restatement of 5545, and the comments say which way it went
wherever the two disagree.

The masters, not the occurrences. The query runs without expandRecurrences,
so a weekly meeting leaves as one VEVENT carrying its RRULE rather than as a
year of identical ones -- an export that had flattened the rule would import
somewhere else as a pile nobody can maintain. A changed occurrence goes out
as its own VEVENT with the same UID and a RECURRENCE-ID, which is how
iCalendar has always said it; a cancelled one becomes an EXDATE.

Three decisions worth stating rather than leaving to be found:

No VTIMEZONE components. A TZID names the IANA zone the server holds and
nothing defines it beside it, because defining it means shipping a zone
database to describe rules the reader's own system already knows. Every
client that matters resolves IANA names. The alternative -- converting to
UTC -- would be worse than a validator's complaint: a weekly 09:00 that
becomes 08:00 for half the year is a wrong calendar.

UNTIL follows DTSTART's kind, a date for an all-day series and a UTC instant
otherwise. Sending a local time there is the usual way to make a series stop
a day early in another timezone.

Overrides are applied at the top level only. A recurrence override is a JSON
patch, and one addressing locations/x/name is not something this flattens.

Closes #216.
This commit is contained in:
2026-09-02 09:21:16 -07:00
parent cb69115be1
commit 1a6158aa70
14 changed files with 617 additions and 8 deletions
+3
View File
@@ -55,6 +55,8 @@ import type { Catalog } from "@/lib/i18n";
*/
export const catalog: Catalog = {
strings: {
"Export iCAL file": "iCAL-Datei exportieren",
"Could not export this calendar: {error}": "Dieser Kalender konnte nicht exportiert werden: {error}",
// ── Actions ────────────────────────────────────────────────────────
"Archive": "Archivieren",
"Archive (e)": "Archivieren (e)",
@@ -1076,6 +1078,7 @@ export const catalog: Catalog = {
"Nothing unread here": "Hier ist nichts ungelesen",
},
plurals: {
"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" },
+3
View File
@@ -47,6 +47,8 @@ import type { Catalog } from "@/lib/i18n";
*/
export const catalog: Catalog = {
strings: {
"Export iCAL file": "Exportar archivo iCAL",
"Could not export this calendar: {error}": "No se pudo exportar este calendario: {error}",
// ── Actions ────────────────────────────────────────────────────────
"Archive": "Archivar",
"Archive (e)": "Archivar (e)",
@@ -1049,6 +1051,7 @@ export const catalog: Catalog = {
"Nothing unread here": "Aquí no hay nada sin leer",
},
plurals: {
"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í" },
+3
View File
@@ -52,6 +52,8 @@ import type { Catalog } from "@/lib/i18n";
*/
export const catalog: Catalog = {
strings: {
"Export iCAL file": "Exporter un fichier iCAL",
"Could not export this calendar: {error}": "Impossible dexporter ce calendrier : {error}",
// ── Actions ────────────────────────────────────────────────────────
"Archive": "Archiver",
"Archive (e)": "Archiver (e)",
@@ -1054,6 +1056,7 @@ export const catalog: Catalog = {
"Nothing unread here": "Rien de non lu ici",
},
plurals: {
"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" },
+3
View File
@@ -46,6 +46,8 @@ import type { Catalog } from "@/lib/i18n";
*/
export const catalog: Catalog = {
strings: {
"Export iCAL file": "iCAL ファイルをエクスポート",
"Could not export this calendar: {error}": "このカレンダーをエクスポートできませんでした: {error}",
// ── Actions ────────────────────────────────────────────────────────
"Archive": "アーカイブ",
"Archive (e)": "アーカイブ (e)",
@@ -1057,6 +1059,7 @@ export const catalog: Catalog = {
"Nothing unread here": "ここに未読はありません",
},
plurals: {
"Exported {n} events": { other: "{n} 件の予定をエクスポートしました" },
"Imported {n} events": { other: "{n} 件の予定をインポートしました" },
"Already here: {n} events, nothing imported": { other: "すでに存在: {n} 件、インポートなし" },
"{n} were already here": { other: "{n} 件はすでに存在していました" },
+3
View File
@@ -43,6 +43,8 @@ import type { Catalog } from "@/lib/i18n";
*/
export const catalog: Catalog = {
strings: {
"Export iCAL file": "iCAL-bestand exporteren",
"Could not export this calendar: {error}": "Kon deze agenda niet exporteren: {error}",
// ── Actions ────────────────────────────────────────────────────────
"Archive": "Archiveren",
"Archive (e)": "Archiveren (e)",
@@ -1045,6 +1047,7 @@ export const catalog: Catalog = {
"Nothing unread here": "Hier is niets ongelezen",
},
plurals: {
"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" },
+3
View File
@@ -50,6 +50,8 @@ import type { Catalog } from "@/lib/i18n";
*/
export const catalog: Catalog = {
strings: {
"Export iCAL file": "Exportar arquivo iCAL",
"Could not export this calendar: {error}": "Não foi possível exportar esta agenda: {error}",
// ── Actions ────────────────────────────────────────────────────────
"Archive": "Arquivar",
"Archive (e)": "Arquivar (e)",
@@ -1052,6 +1054,7 @@ export const catalog: Catalog = {
"Nothing unread here": "Não há nada não lido aqui",
},
plurals: {
"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" },
+3
View File
@@ -49,6 +49,8 @@ import type { Catalog } from "@/lib/i18n";
*/
export const catalog: Catalog = {
strings: {
"Export iCAL file": "Экспортировать файл iCAL",
"Could not export this calendar: {error}": "Не удалось экспортировать этот календарь: {error}",
// ── Actions ────────────────────────────────────────────────────────
"Archive": "Архивировать",
"Archive (e)": "Архивировать (e)",
@@ -1051,6 +1053,7 @@ export const catalog: Catalog = {
"Nothing unread here": "Здесь нет непрочитанного",
},
plurals: {
"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} уже были здесь" },
+3
View File
@@ -43,6 +43,8 @@ import type { Catalog } from "@/lib/i18n";
*/
export const catalog: Catalog = {
strings: {
"Export iCAL file": "Експортувати файл iCAL",
"Could not export this calendar: {error}": "Не вдалося експортувати цей календар: {error}",
// ── Actions ────────────────────────────────────────────────────────
"Archive": "Архівувати",
"Archive (e)": "Архівувати (e)",
@@ -1045,6 +1047,7 @@ export const catalog: Catalog = {
"Nothing unread here": "Тут немає непрочитаного",
},
plurals: {
"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} уже були тут" },
+3
View File
@@ -45,6 +45,8 @@ import type { Catalog } from "@/lib/i18n";
*/
export const catalog: Catalog = {
strings: {
"Export iCAL file": "导出 iCAL 文件",
"Could not export this calendar: {error}": "无法导出此日历:{error}",
// ── Actions ────────────────────────────────────────────────────────
"Archive": "归档",
"Archive (e)": "归档 (e)",
@@ -1056,6 +1058,7 @@ export const catalog: Catalog = {
"Nothing unread here": "这里没有未读邮件",
},
plurals: {
"Exported {n} events": { other: "已导出 {n} 个日程" },
"Imported {n} events": { other: "已导入 {n} 个日程" },
"Already here: {n} events, nothing imported": { other: "已存在 {n} 个,未导入" },
"{n} were already here": { other: "{n} 个已存在" },