From cf3bedac9a980f2395e22de11a8c82faab398de3 Mon Sep 17 00:00:00 2001
From: SeoJCalive <180237232+SeoJCalive@users.noreply.github.com>
Date: Thu, 22 Oct 2026 09:45:59 +0900
Subject: [PATCH 1/3] Add iCalendar (.ics) to CSV conversion and back
- Read VEVENT and VTODO into 14 CSV columns: ISO 8601 times with the
original offset, all-day as dates, TZID in its own column, RRULE,
EXDATE and RECURRENCE-ID kept raw; JSON and other data formats via CSV.
- Resolve offsets from a same-calendar VTIMEZONE without RRULE first,
then the system time-zone database, else keep floating time.
- Write CSV back to iCalendar (VEVENT/VTODO, VTIMEZONE built from the
zone's real transitions, 75-octet folding, no BOM).
- Register ics/ical on the data donut, add an "iCalendar" preset on the
back of the spreadsheets donut (settings schema 33), update the data
engine description in every UI language and the docs.
Closes #340
---
README.md | 2 +-
docs/ENGINES.md | 25 +-
src/Filee.App/Assets/i18n/ar.json | 2 +-
src/Filee.App/Assets/i18n/en.json | 2 +-
src/Filee.App/Assets/i18n/es.json | 2 +-
src/Filee.App/Assets/i18n/hi.json | 2 +-
src/Filee.App/Assets/i18n/ja.json | 2 +-
src/Filee.App/Assets/i18n/ko.json | 2 +-
src/Filee.App/Assets/i18n/pl.json | 2 +-
src/Filee.App/Assets/i18n/pt-BR.json | 2 +-
src/Filee.App/Assets/i18n/ru.json | 2 +-
src/Filee.App/Assets/i18n/zh-CN.json | 2 +-
src/Filee.Core/Formats/FormatRegistry.cs | 1 +
src/Filee.Core/Presets/BuiltInData.cs | 7 +-
src/Filee.Core/Settings/AppSettings.cs | 2 +-
src/Filee.Core/Settings/UserDataStore.cs | 29 +
src/Filee.Engines/Data/DataConverter.cs | 34 +-
src/Filee.Engines/Data/ICalendar.cs | 597 +++++++++++++++++++
tests/Filee.Core.Tests/UserDataStoreTests.cs | 71 +++
tests/Filee.Engines.Tests/ICalendarTests.cs | 464 ++++++++++++++
20 files changed, 1227 insertions(+), 25 deletions(-)
create mode 100644 src/Filee.Engines/Data/ICalendar.cs
create mode 100644 tests/Filee.Engines.Tests/ICalendarTests.cs
diff --git a/README.md b/README.md
index 937fa768d..d651b1167 100644
--- a/README.md
+++ b/README.md
@@ -111,7 +111,7 @@ slides show what's new.
| **Archives**: ZIP, ANSYS Workbench (WBPZ), 7Z, RAR, TAR, TAR.GZ, TAR.BZ2, TAR.XZ, GZ, ISO, CAB, DMG, ALZ, EGG, … | extract, ZIP, 7Z, TAR, TAR.GZ; any files → one ZIP |
| **CAD**: DWG, DXF | PDF, SVG, PNG, DWG ↔ DXF |
| **Fonts**: TTF, OTF, WOFF, WOFF2, EOT | each other |
-| **Data**: JSON, JSON Lines, YAML, TOML, XML · vCard | each other, CSV and XLSX (tables); vCard ↔ CSV |
+| **Data**: JSON, JSON Lines, YAML, TOML, XML · vCard, iCalendar | each other, CSV and XLSX (tables); vCard, iCalendar ↔ CSV |
| **3D models**: GLB, glTF, STL, OBJ (+ MTL), 3MF, PLY; FBX read | each other, PNG preview |
| **Geospatial vectors**: Shapefile, GeoJSON, KML, KMZ, GPX · CSV with coordinates | each other and CSV / XLSX / JSON; projected data (UTM, Web Mercator, Lambert, Albers) comes out as WGS 84 longitude / latitude |
diff --git a/docs/ENGINES.md b/docs/ENGINES.md
index 3ba038b0c..59e4e3930 100644
--- a/docs/ENGINES.md
+++ b/docs/ENGINES.md
@@ -21,7 +21,7 @@ Filee chooses engines automatically. Settings → *Engines* shows their status,
| PDF text | built in (PdfPig) | **PDF → TXT, Markdown** (reading order, also two columns; Markdown keeps headings, emphasis, links and the pictures; no OCR) |
| PDF slides | built in (PDFium, PdfPig) | **PDF → PPTX**: a picture of each page on its own slide (looks like the PDF, not editable), the page's text in the speaker notes |
| E-mail | built in (MimeKit, MsgReader) | **EML, Outlook MSG → HTML** (headers + body, inline pictures), **TXT**, **ZIP** (the attachments); **MSG → EML**; **MHTML → HTML** (the saved page in one file, pictures inlined; on to PDF, DOCX, HWPX, TXT, Markdown, EPUB from there) |
-| Data | built in (System.Text.Json, YamlDotNet, Tomlyn, System.Xml) | **JSON, JSON Lines, YAML, TOML, XML, CSV ↔ each other** (CSV for tables); **vCard ↔ CSV**; spreadsheets and contacts reach JSON / YAML / TOML / XML through CSV |
+| Data | built in (System.Text.Json, YamlDotNet, Tomlyn, System.Xml) | **JSON, JSON Lines, YAML, TOML, XML, CSV ↔ each other** (CSV for tables); **vCard, iCalendar ↔ CSV**; spreadsheets, contacts and events reach JSON / YAML / TOML / XML through CSV |
| **E-books** | built in | **EPUB, MOBI/AZW/AZW3/PRC, FB2, HTMLZ, TXTZ → EPUB, HWPX (→ PDF), TXT, HTML, Markdown, FB2, HTMLZ, TXTZ**; HTML, Markdown, TXT, DOCX → EPUB/FB2/HTMLZ/TXTZ; HTML → TXT; comics CBZ/CBR/CB7/CBT/CBC → PDF, EPUB, CBZ; PDF → CBZ; AZW4 → PDF; **EPUB ↔ KEPUB** (Kobo) |
| CAD (ACadSharp + built-in renderer) | built in (library) | **DWG ↔ DXF**; DWG/DXF → **PDF** (vector), **SVG**, PNG, JPG, WEBP, TIFF, BMP, GIF, ICO, AVIF |
| **Subtitles** | built in | **SRT, ASS/SSA, WebVTT, SAMI (SMI), LRC, TTML, MicroDVD, SCC → SRT, ASS, WebVTT, SAMI, LRC, TTML, MicroDVD**, always UTF-8 (Korean CP949 / EUC-KR files read correctly) |
@@ -748,7 +748,7 @@ saved as octet-stream, and scripts, frames, remote pictures, fonts and style she
(windows-1252, `file:///C:/` locations, VML fallbacks), a `cid:`-only page in EUC-KR, a picture on disk named by a
`file:` URI that must not be read, and the routes to HTML, TXT, DOCX, HWPX and PDF.
-## Data (JSON, YAML, TOML, XML, CSV, vCard)
+## Data (JSON, YAML, TOML, XML, CSV, vCard, iCalendar)
`Data/DataConverter.cs` (engine id `data`) reads every format into one tree (`Data/DataTree.cs`: objects with their
keys in file order, arrays, text, true / false, integers of any size, floats, TOML dates and null) and writes it
@@ -766,10 +766,11 @@ options: the output is always pretty-printed.
| CSV (`.csv`) | Filee (`Office/Sheets/CsvFormat.cs`) | first row = keys, one object per row; plain numbers (not `007`) and `true` / `false` typed, the rest text; delimiter and encoding detected as for spreadsheets | only tables: an array of objects (columns = every key, first seen first; an object inside a row becomes `parent.child` columns, one level only), an object of arrays (columns), an array of arrays (rows) or one flat object (one row); objects that only wrap the array (`{"users": […]}`, `
…`) are looked through. Anything else stops with a message saying what is not a table |
**`.csv` and `.xml` ownership.** CSV stays a spreadsheet format: the spreadsheets donut, XLSX / ODS routes and
-"Merge into PDF" are unchanged, and the data engine only adds edges `csv ↔ json / jsonl / yaml / toml / xml`. Routes
-combine: XLSX → CSV → JSON, YAML → CSV → XLSX, vCard → CSV → JSON. The spreadsheets donut gets "JSON". `.xml` had no
-owner before; XML-based formats Filee converts have their own extensions (`.svg`, `.fb2`, `.dbk`, `.opml` …), so a
-plain `.xml` file is treated as data and opens the data donut.
+"Merge into PDF" are unchanged. The data engine adds `csv ↔ json / jsonl / yaml / toml / xml` edges, and vCard and
+iCalendar each convert to and from CSV. Routes combine: XLSX → CSV → JSON, YAML → CSV → XLSX, vCard → CSV → JSON,
+and ICS → CSV → JSON. The spreadsheets donut gets "JSON". `.xml` had no owner before; XML-based formats Filee
+converts have their own extensions (`.svg`, `.fb2`, `.dbk`, `.opml` …), so a plain `.xml` file is treated as data
+and opens the data donut.
**vCard** (`Data/VCard.cs`, own parser): vCard 2.1, 3.0 and 4.0 → CSV with the columns Full name, Given name, Family
name, Organization, Title, Email, Phone, Address, Birthday, Note (one row per contact; several values joined with
@@ -781,6 +782,18 @@ a missing full name is made from the name parts (홍길동, "Ada Lovelace"). CSV
`Name`, `Company`, `Mobile`, `E-mail` … and Korean / Chinese headers such as 이름, 전화번호, 이메일, 姓名, 电话) and writes
vCard 3.0 in UTF-8, lines folded at 75 bytes; an address goes into the street field.
+**iCalendar** (`Data/ICalendar.cs`, own parser): VEVENTs and VTODOs ↔ CSV; one component per row, with these 14 columns
+in order: `Type`, `Title`, `Start`, `End`, `All day`, `Time zone`, `Location`, `Description`, `Organizer`,
+`Attendees`, `RRULE`, `EXDATE`, `Recurrence ID`, `UID`. Times use ISO 8601, retaining the original offset;
+all-day values are dates only, and a DTSTART TZID is kept in its own `Time zone` column. If a TZID cannot be
+resolved, the local time stays floating without an offset. A VTIMEZONE in the same calendar without RRULE takes
+precedence; RRULE-based zones are resolved through the system time-zone database instead, so historical years are
+not guaranteed to match. CSV → ICS writes the normalized columns as VEVENTs or VTODOs. A numeric offset without a
+`Time zone` is written in UTC. Each VTIMEZONE is built from the system rule in force at that zone's earliest timed
+Start (or End when no Start); offsets in other years, including past years, may therefore be lost. The original
+VTIMEZONE definition is not preserved, and only these columns are supported rather than arbitrary calendar data.
+VALARM, X-properties, VJOURNAL, VFREEBUSY, binary attachments and other unsupported properties are lost.
+
## CAD drawings (DWG, DXF)
Built in, no AutoCAD or ODA software (`Filee.Engines/Cad`). [ACadSharp](https://github.com/DomCR/ACadSharp) reads
diff --git a/src/Filee.App/Assets/i18n/ar.json b/src/Filee.App/Assets/i18n/ar.json
index 7ee866f47..b5ba7f250 100644
--- a/src/Filee.App/Assets/i18n/ar.json
+++ b/src/Filee.App/Assets/i18n/ar.json
@@ -125,7 +125,7 @@
"engine.pdf-text.description": "استخراج نصوص PDF بترتيب القراءة، بما في ذلك الصفحات ذات العمودين (دون تعرّف ضوئي OCR للمستندات الممسوحة).",
"engine.pdf-slides.description": "تحويل PDF إلى شرائح PowerPoint: تصبح كل صفحة صورة على شريحة مستقلة (بنفس مظهر PDF وغير قابلة للتحرير)، مع نص الصفحة في ملاحظات المتحدث.",
"engine.email.description": "تحويل رسائل البريد (EML وOutlook MSG) إلى HTML ونص مع المرسل والمستلمين والتاريخ والصور، وتجميع المرفقات في ZIP.\nتحويل MSG إلى EML، وصفحات الويب المحفوظة (MHTML) إلى ملف HTML واحد مع صورها.",
- "engine.data.description": "التحويل بين JSON وJSON Lines وYAML وTOML وXML وCSV.\nتحويل جهات اتصال vCard إلى CSV والعكس.\nتُحذف التعليقات. تدعم CSV البيانات الجدولية فقط.",
+ "engine.data.description": "التحويل بين JSON وJSON Lines وYAML وTOML وXML وCSV.\nتحويل جهات اتصال vCard وأحداث iCalendar إلى CSV والعكس.\nتُحذف التعليقات. تدعم CSV البيانات الجدولية فقط.",
"engine.vector.description": "تحويل SVG وSVGZ إلى PDF متجهي وصور وDXF؛ وتتبع الصور إلى SVG؛ وقراءة ملفات Illustrator المحفوظة بمحتوى PDF.",
"engine.icns.description": "تحويل أيقونات تطبيقات macOS (ICNS) من الصور وإليها.",
"engine.font.description": "التحويل بين الخطوط: TTF وOTF وWOFF وWOFF2 وEOT.",
diff --git a/src/Filee.App/Assets/i18n/en.json b/src/Filee.App/Assets/i18n/en.json
index d6024c303..29286d6f4 100644
--- a/src/Filee.App/Assets/i18n/en.json
+++ b/src/Filee.App/Assets/i18n/en.json
@@ -132,7 +132,7 @@
"engine.pdf-text.description": "Extracts the text of PDFs in reading order, also from two-column pages (no OCR for scans).",
"engine.pdf-slides.description": "Turns PDFs into PowerPoint slides: each page becomes a picture on its own slide (looks like the PDF, not editable), with the page text in the speaker notes.",
"engine.email.description": "E-mails (EML, Outlook MSG) to HTML and text with sender, recipients and date, pictures included; attachments into a ZIP.\nMSG to EML. Saved web pages (MHTML) to one HTML file with their pictures.",
- "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML and CSV into each other.\nvCard contacts to CSV and back.\nComments are dropped. CSV holds tables only.",
+ "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML and CSV into each other.\nvCard contacts and iCalendar events to CSV and back.\nComments are dropped. CSV holds tables only.",
"engine.vector.description": "SVG and SVGZ to vector PDF, images and DXF; pictures traced into SVG; Illustrator files saved with PDF content.",
"engine.icns.description": "macOS app icons (ICNS) from and to images.",
"engine.font.description": "Fonts: TTF, OTF, WOFF, WOFF2 and EOT into each other.",
diff --git a/src/Filee.App/Assets/i18n/es.json b/src/Filee.App/Assets/i18n/es.json
index 89007a3ee..3b276693f 100644
--- a/src/Filee.App/Assets/i18n/es.json
+++ b/src/Filee.App/Assets/i18n/es.json
@@ -125,7 +125,7 @@
"engine.pdf-text.description": "Extrae el texto de los PDF en orden de lectura, también de páginas en dos columnas (sin OCR para escaneos).",
"engine.pdf-slides.description": "Convierte PDF en diapositivas de PowerPoint: cada página pasa a ser una imagen en su propia diapositiva (con el aspecto del PDF, no editable), con el texto de la página en las notas del orador.",
"engine.email.description": "Correos electrónicos (EML, Outlook MSG) a HTML y texto con remitente, destinatarios y fecha, imágenes incluidas; los adjuntos van a un ZIP.\nMSG a EML. Páginas web guardadas (MHTML) a un solo archivo HTML con sus imágenes.",
- "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML y CSV entre sí.\nContactos vCard a CSV y viceversa.\nLos comentarios se descartan. CSV solo admite tablas.",
+ "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML y CSV entre sí.\nContactos vCard y eventos de iCalendar a CSV y viceversa.\nLos comentarios se descartan. CSV solo admite tablas.",
"engine.vector.description": "SVG y SVGZ a PDF vectorial, imágenes y DXF; imágenes trazadas a SVG; archivos de Illustrator guardados con contenido PDF.",
"engine.icns.description": "Iconos de apps de macOS (ICNS) desde y hacia imágenes.",
"engine.font.description": "Tipografías: TTF, OTF, WOFF, WOFF2 y EOT entre sí.",
diff --git a/src/Filee.App/Assets/i18n/hi.json b/src/Filee.App/Assets/i18n/hi.json
index dde786072..0f46f06f5 100644
--- a/src/Filee.App/Assets/i18n/hi.json
+++ b/src/Filee.App/Assets/i18n/hi.json
@@ -125,7 +125,7 @@
"engine.pdf-text.description": "PDF का टेक्स्ट पढ़ने के क्रम में निकालता है, दो-कॉलम वाले पेजों से भी (स्कैन के लिए OCR नहीं)।",
"engine.pdf-slides.description": "PDF को PowerPoint स्लाइड में बदलता है: हर पेज अपनी स्लाइड पर एक तस्वीर बन जाता है (PDF जैसा दिखता है, संपादन योग्य नहीं), और पेज का टेक्स्ट स्पीकर नोट्स में रहता है।",
"engine.email.description": "ई-मेल (EML, Outlook MSG) को भेजने वाले, प्राप्तकर्ताओं और तारीख के साथ HTML और टेक्स्ट में बदलता है, तस्वीरों सहित; अटैचमेंट ZIP में।\nMSG से EML। सहेजे गए वेब पेज (MHTML) उनकी तस्वीरों सहित एक HTML फ़ाइल में।",
- "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML और CSV एक-दूसरे में।\nvCard संपर्क CSV में और वापस।\nकमेंट हटा दिए जाते हैं। CSV में केवल टेबल रहती हैं।",
+ "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML और CSV एक-दूसरे में।\nvCard संपर्क और iCalendar इवेंट CSV में और वापस।\nकमेंट हटा दिए जाते हैं। CSV में केवल टेबल रहती हैं।",
"engine.vector.description": "SVG और SVGZ से वेक्टर PDF, इमेज और DXF; तस्वीरों को SVG में ट्रेस करना; Illustrator फ़ाइलें PDF सामग्री के साथ सहेजना।",
"engine.icns.description": "इमेज से और इमेज में macOS ऐप आइकन (ICNS)।",
"engine.font.description": "फ़ॉन्ट: TTF, OTF, WOFF, WOFF2 और EOT एक-दूसरे में।",
diff --git a/src/Filee.App/Assets/i18n/ja.json b/src/Filee.App/Assets/i18n/ja.json
index afc5f5d33..2229a6b6c 100644
--- a/src/Filee.App/Assets/i18n/ja.json
+++ b/src/Filee.App/Assets/i18n/ja.json
@@ -125,7 +125,7 @@
"engine.pdf-text.description": "PDFのテキストを読み順に抽出します。2段組みのページにも対応します(スキャン画像のOCRには非対応)。",
"engine.pdf-slides.description": "PDFをPowerPointのスライドに変換します。各ページを画像にし、1ページずつ別のスライドに配置します(見た目はPDFと同じですが、編集はできません)。ページ内のテキストは発表者ノートに入ります。",
"engine.email.description": "メール(EML、Outlook MSG)を、差出人、宛先、日付、画像を含めてHTMLとテキストに変換します。添付ファイルはZIPにまとめます。\nMSGからEMLへの変換にも対応します。画像を含む保存済みのWebページ(MHTML)を、1つのHTMLファイルに変換します。",
- "engine.data.description": "JSON、JSON Lines、YAML、TOML、XML、CSVを相互変換します。\nvCardの連絡先とCSVも相互変換できます。\nコメントは削除されます。CSVで扱えるのは表形式のデータだけです。",
+ "engine.data.description": "JSON、JSON Lines、YAML、TOML、XML、CSVを相互変換します。\nvCardの連絡先とiCalendarの予定をCSVと相互変換できます。\nコメントは削除されます。CSVで扱えるのは表形式のデータだけです。",
"engine.vector.description": "SVGとSVGZをベクターPDF、画像、DXFに変換します。画像をトレースしてSVGを作成し、PDF互換で保存されたIllustratorファイルにも対応します。",
"engine.icns.description": "macOSアプリのアイコン(ICNS)を画像と相互変換します。",
"engine.font.description": "フォント形式(TTF、OTF、WOFF、WOFF2、EOT)を相互変換します。",
diff --git a/src/Filee.App/Assets/i18n/ko.json b/src/Filee.App/Assets/i18n/ko.json
index 115bfc7e1..0e833dd02 100644
--- a/src/Filee.App/Assets/i18n/ko.json
+++ b/src/Filee.App/Assets/i18n/ko.json
@@ -132,7 +132,7 @@
"engine.pdf-text.description": "PDF의 글자를 읽는 순서대로 뽑아내요.\n2단 편집도 순서대로 읽어요(스캔 이미지 글자 인식은 안 해요).",
"engine.pdf-slides.description": "PDF를 파워포인트 슬라이드로 바꿔요. 페이지마다 그림 한 장으로 슬라이드에 넣어서 PDF와 똑같이 보여요(편집은 안 돼요).\n페이지 글자는 슬라이드 노트에 넣어요.",
"engine.email.description": "이메일(EML, Outlook MSG)을 보낸 사람·받는 사람·날짜와 본문 그림까지 담아 HTML·텍스트로 바꾸고, 첨부 파일은 ZIP으로 모아요.\nMSG는 EML로도 바꾸고, 저장한 웹 페이지(MHTML)는 그림까지 담긴 HTML 파일 하나로 바꿔요.",
- "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML, CSV를 서로 바꿔요.\nvCard 연락처를 CSV로, CSV를 vCard로 바꿔요.\n주석은 빠져요. CSV는 표 모양 데이터만 담아요.",
+ "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML, CSV를 서로 바꿔요.\nvCard 연락처와 iCalendar 일정을 CSV로, CSV를 각각의 형식으로 바꿔요.\n주석은 빠져요. CSV는 표 모양 데이터만 담아요.",
"engine.vector.description": "SVG·SVGZ를 벡터 PDF, 이미지, DXF로 바꾸고 그림을 SVG로 벡터화해요. PDF 내용이 들어 있는 일러스트레이터 파일도 변환해요.",
"engine.icns.description": "macOS 앱 아이콘(ICNS)을 이미지로, 이미지를 ICNS로.",
"engine.font.description": "글꼴: TTF, OTF, WOFF, WOFF2, EOT를 서로 변환해요.",
diff --git a/src/Filee.App/Assets/i18n/pl.json b/src/Filee.App/Assets/i18n/pl.json
index 2e2da36b6..396e274f0 100644
--- a/src/Filee.App/Assets/i18n/pl.json
+++ b/src/Filee.App/Assets/i18n/pl.json
@@ -132,7 +132,7 @@
"engine.pdf-text.description": "Wyodrębnia tekst z PDF w kolejności czytania, także ze stron dwukolumnowych (bez OCR skanów).",
"engine.pdf-slides.description": "Zamienia PDF na slajdy PowerPoint: każda strona staje się obrazem na osobnym slajdzie (wygląda jak PDF, bez edycji), a tekst strony trafia do notatek prelegenta.",
"engine.email.description": "E-maile (EML, Outlook MSG) do HTML i tekstu z nadawcą, odbiorcami i datą, razem z obrazami; załączniki do ZIP.\nMSG do EML. Zapisane strony internetowe (MHTML) do jednego pliku HTML razem z obrazami.",
- "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML i CSV między sobą.\nKontakty vCard do CSV i z powrotem.\nKomentarze są pomijane. CSV przechowuje tylko tabele.",
+ "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML i CSV między sobą.\nKontakty vCard i wydarzenia iCalendar do CSV i z powrotem.\nKomentarze są pomijane. CSV przechowuje tylko tabele.",
"engine.vector.description": "SVG i SVGZ do wektorowego PDF, obrazów i DXF; obrazy wektoryzowane do SVG; pliki Illustratora zapisane z zawartością PDF.",
"engine.icns.description": "Ikony aplikacji macOS (ICNS) z obrazów i do obrazów.",
"engine.font.description": "Czcionki: TTF, OTF, WOFF, WOFF2 i EOT między sobą.",
diff --git a/src/Filee.App/Assets/i18n/pt-BR.json b/src/Filee.App/Assets/i18n/pt-BR.json
index 0b7cd7cd0..84fd14425 100644
--- a/src/Filee.App/Assets/i18n/pt-BR.json
+++ b/src/Filee.App/Assets/i18n/pt-BR.json
@@ -132,7 +132,7 @@
"engine.pdf-text.description": "Extrai o texto de PDFs na ordem de leitura, também de páginas em duas colunas (sem OCR para digitalizações).",
"engine.pdf-slides.description": "Transforma PDFs em slides do PowerPoint: cada página vira uma imagem no próprio slide (parece o PDF, não é editável), com o texto da página nas anotações do apresentador.",
"engine.email.description": "E-mails (EML, MSG do Outlook) para HTML e texto, com remetente, destinatários e data, imagens incluídas; anexos num ZIP.\nMSG para EML. Páginas da web salvas (MHTML) para um único arquivo HTML com as imagens.",
- "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML e CSV entre si.\nContatos vCard para CSV e de volta.\nComentários são descartados. CSV guarda só tabelas.",
+ "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML e CSV entre si.\nContatos vCard e eventos iCalendar para CSV e de volta.\nComentários são descartados. CSV guarda só tabelas.",
"engine.vector.description": "SVG e SVGZ para PDF vetorial, imagens e DXF; imagens vetorizadas em SVG; arquivos do Illustrator salvos com conteúdo PDF.",
"engine.icns.description": "Ícones de apps do macOS (ICNS) a partir de imagens e para imagens.",
"engine.font.description": "Fontes: TTF, OTF, WOFF, WOFF2 e EOT entre si.",
diff --git a/src/Filee.App/Assets/i18n/ru.json b/src/Filee.App/Assets/i18n/ru.json
index 3eb526c74..833252fd1 100644
--- a/src/Filee.App/Assets/i18n/ru.json
+++ b/src/Filee.App/Assets/i18n/ru.json
@@ -132,7 +132,7 @@
"engine.pdf-text.description": "Достаёт текст из PDF в порядке чтения, в том числе со страниц в две колонки (сканы не распознаются).",
"engine.pdf-slides.description": "Превращает PDF в слайды PowerPoint: каждая страница становится картинкой на своём слайде (выглядит как PDF, но не редактируется), текст страницы — в заметках докладчика.",
"engine.email.description": "Письма (EML, Outlook MSG) — в HTML и текст с отправителем, получателями и датой, вместе с картинками; вложения — в ZIP.\nMSG в EML. Сохранённые веб-страницы (MHTML) — в один HTML-файл с картинками.",
- "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML и CSV друг в друга.\nКонтакты vCard в CSV и обратно.\nКомментарии отбрасываются. CSV хранит только таблицы.",
+ "engine.data.description": "JSON, JSON Lines, YAML, TOML, XML и CSV друг в друга.\nКонтакты vCard и события iCalendar в CSV и обратно.\nКомментарии отбрасываются. CSV хранит только таблицы.",
"engine.vector.description": "SVG и SVGZ — в векторный PDF, картинки и DXF; векторизация картинок в SVG; файлы Illustrator, сохранённые с PDF-содержимым.",
"engine.icns.description": "Значки приложений macOS (ICNS) из картинок и в картинки.",
"engine.font.description": "Шрифты: TTF, OTF, WOFF, WOFF2 и EOT друг в друга.",
diff --git a/src/Filee.App/Assets/i18n/zh-CN.json b/src/Filee.App/Assets/i18n/zh-CN.json
index 6247a2b0a..bda80bac7 100644
--- a/src/Filee.App/Assets/i18n/zh-CN.json
+++ b/src/Filee.App/Assets/i18n/zh-CN.json
@@ -132,7 +132,7 @@
"engine.pdf-text.description": "按阅读顺序提取 PDF 中的文字,双栏页面也能正确排序(不对扫描件做文字识别)。",
"engine.pdf-slides.description": "将 PDF 转换为 PowerPoint 幻灯片。每页作为一张图片放在单独的幻灯片上,外观与 PDF 相同(不可编辑),页面文字放入演讲者备注。",
"engine.email.description": "将电子邮件(EML、Outlook MSG)连同发件人、收件人、日期和图片转为 HTML 和文本,附件打包为 ZIP。\nMSG 也可转为 EML。保存的网页(MHTML)可连同图片转为单个 HTML 文件。",
- "engine.data.description": "JSON、JSON Lines、YAML、TOML、XML、CSV 互相转换。\nvCard 联系人与 CSV 互转。\n注释不保留。CSV 仅支持表格数据。",
+ "engine.data.description": "JSON、JSON Lines、YAML、TOML、XML、CSV 互相转换。\nvCard 联系人和 iCalendar 日程与 CSV 互转。\n注释不保留。CSV 仅支持表格数据。",
"engine.vector.description": "将 SVG、SVGZ 转为矢量 PDF、图片和 DXF,并可将图片描摹为 SVG。也支持包含 PDF 内容的 Illustrator 文件。",
"engine.icns.description": "macOS 应用图标(ICNS)与图片互转。",
"engine.font.description": "字体:TTF、OTF、WOFF、WOFF2 和 EOT 互相转换。",
diff --git a/src/Filee.Core/Formats/FormatRegistry.cs b/src/Filee.Core/Formats/FormatRegistry.cs
index 1ec467361..ac0549dfa 100644
--- a/src/Filee.Core/Formats/FormatRegistry.cs
+++ b/src/Filee.Core/Formats/FormatRegistry.cs
@@ -163,6 +163,7 @@ public static class FormatRegistry
new("toml", "TOML", FormatCategory.Data, ["toml"]),
new("xml", "XML", FormatCategory.Data, ["xml"]),
new("vcf", "vCard", FormatCategory.Data, ["vcf", "vcard"]),
+ new("ics", "iCalendar", FormatCategory.Data, ["ics", "ical"]),
// E-books
new("epub", "EPUB", FormatCategory.Ebook, ["epub"]),
diff --git a/src/Filee.Core/Presets/BuiltInData.cs b/src/Filee.Core/Presets/BuiltInData.cs
index 4a3c96c03..cf04e3a13 100644
--- a/src/Filee.Core/Presets/BuiltInData.cs
+++ b/src/Filee.Core/Presets/BuiltInData.cs
@@ -339,7 +339,7 @@ private static Preset Tool(string id, string nameKey, string pattern, Action
/// Structured data and contacts targets (added in settings schema 14, see SettingsMigrations). EML is for Outlook
- /// MSG files, vCard for contact lists in CSV; neither is on a donut.
+ /// MSG files; vCard and iCalendar are on the back of the spreadsheets donut.
///
public static List DataPresets() =>
[
@@ -349,6 +349,7 @@ public static List DataPresets() =>
new() { Id = "to-xml", Name = "XML", TargetFormat = "xml" },
new() { Id = "to-eml", Name = "EML", TargetFormat = "eml" },
new() { Id = "to-vcf", Name = "vCard", TargetFormat = "vcf" },
+ new() { Id = "to-ics", Name = "iCalendar", TargetFormat = "ics" },
];
///
@@ -419,7 +420,7 @@ .. WithSubtitleProfile(CatalogProfiles()),
NameKey = "profile.spreadsheets",
Extensions = ExtensionsOf(FormatCategory.Spreadsheet),
PresetIds = ["to-pdf", "to-xlsx", "to-csv", "to-json", "to-ods", "to-hwpx", "to-hwp", "to-html", "merge-pdf"],
- BackPresetIds = ["to-docx", "to-md", "to-txt", "to-yaml", "to-xml", "to-vcf", "to-png", "zip-all"],
+ BackPresetIds = ["to-docx", "to-md", "to-txt", "to-yaml", "to-xml", "to-vcf", "to-ics", "to-png", "zip-all"],
};
/// PowerPoint and OpenDocument slides (split from "office" in settings schema 5).
@@ -501,7 +502,7 @@ public static List CatalogProfiles() =>
},
];
- /// JSON, YAML, TOML, XML and vCard files (added in settings schema 14, after "fonts").
+ /// JSON, YAML, TOML, XML, vCard and iCalendar files (added in settings schema 14, after "fonts").
public static ToolbarProfile DataProfile() => new()
{
Id = "data",
diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs
index 4f488dcba..3df95d287 100644
--- a/src/Filee.Core/Settings/AppSettings.cs
+++ b/src/Filee.Core/Settings/AppSettings.cs
@@ -112,7 +112,7 @@ public static bool IsFlipKeyAllowed(string key, IEnumerable gest
public sealed class AppSettings
{
/// Current schema version. Increase when the format changes and add a migration.
- public const int CurrentSchemaVersion = 32;
+ public const int CurrentSchemaVersion = 33;
public int SchemaVersion { get; set; } = CurrentSchemaVersion;
diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs
index 7aff0c842..69a4c9094 100644
--- a/src/Filee.Core/Settings/UserDataStore.cs
+++ b/src/Filee.Core/Settings/UserDataStore.cs
@@ -750,6 +750,35 @@ public static bool ApplyToLibrary(int from, List presets, List presets, List profiles)
+ {
+ var changed = false;
+ if (presets.All(p => p.Id != "to-ics"))
+ {
+ var preset = BuiltInData.DataPresets().Single(p => p.Id == "to-ics");
+ var after = presets.FindIndex(p => p.Id == "to-vcf");
+ presets.Insert(after < 0 ? presets.Count : after + 1, preset);
+ changed = true;
+ }
+
+ if (profiles.FirstOrDefault(p => p.Id == "spreadsheets") is { BackPresetIds: { } back }
+ && !back.Contains("to-ics") && back.Count < ToolbarProfile.MaxSlices)
+ {
+ var after = back.IndexOf("to-vcf");
+ var beforeZip = back.IndexOf("zip-all");
+ back.Insert(after >= 0 ? after + 1 : beforeZip >= 0 ? beforeZip : back.Count, "to-ics");
+ changed = true;
+ }
return changed;
}
diff --git a/src/Filee.Engines/Data/DataConverter.cs b/src/Filee.Engines/Data/DataConverter.cs
index 258b2f31e..c0b81dba3 100644
--- a/src/Filee.Engines/Data/DataConverter.cs
+++ b/src/Filee.Engines/Data/DataConverter.cs
@@ -1,8 +1,8 @@
-// Structured data between JSON, JSON Lines, YAML, TOML and XML (and CSV for tables), plus vCard contacts ↔ CSV,
+// Structured data between JSON, JSON Lines, YAML, TOML and XML (and CSV for tables), plus vCard contacts and iCalendar ↔ CSV,
// in-process. Every format is read into one tree (DataTree) and written from it, so structure and types carry over as
// far as the target can hold them; see the format files for what each one loses (TOML has no null, XML no types,
// CSV only tables). Output is UTF-8 without BOM (CSV: with BOM, like the spreadsheet engine), indented by two spaces.
-// Spreadsheets reach JSON and the others through CSV (XLSX → CSV → JSON), contacts through CSV as well.
+// Spreadsheets reach JSON and the others through CSV (XLSX → CSV → JSON), contacts and calendars through CSV as well.
using System.Text;
using Filee.Core.Conversion;
@@ -12,7 +12,7 @@
namespace Filee.Engines.Data;
-/// JSON, JSON Lines, YAML, TOML, XML and CSV into each other; vCard ↔ CSV.
+/// JSON, JSON Lines, YAML, TOML, XML and CSV into each other; vCard and iCalendar ↔ CSV.
public sealed class DataConverter : IConverter
{
private static readonly string[] Trees = ["json", "jsonl", "yaml", "toml", "xml"];
@@ -28,10 +28,12 @@ public sealed class DataConverter : IConverter
.. Trees.Select(to => new ConversionEdge("csv", to)),
new("vcf", "csv"),
new("csv", "vcf"),
+ new("ics", "csv"),
+ new("csv", "ics"),
];
public EngineStatus GetStatus() =>
- EngineStatus.Available("JSON, JSON Lines, YAML, TOML, XML, CSV ↔ each other; vCard ↔ CSV",
+ EngineStatus.Available("JSON, JSON Lines, YAML, TOML, XML, CSV ↔ each other; vCard and iCalendar ↔ CSV",
$"{EngineVersions.BuiltIn} · {EngineVersions.Library("YamlDotNet", typeof(YamlDotNet.RepresentationModel.YamlStream))}"
+ $" · {EngineVersions.Library("Tomlyn", typeof(Tomlyn.TomlSerializer))}");
@@ -63,6 +65,30 @@ public Task> ConvertAsync(ConversionStep step, IProgress Parameters)
+ {
+ public string Parameter(string name) => Parameters.GetValueOrDefault(name, "");
+ }
+
+ internal sealed class Component(string type, Dictionary? zones = null)
+ {
+ public string Type { get; } = type;
+ public List Properties { get; } = [];
+ public Dictionary Zones { get; } = zones ?? new(StringComparer.OrdinalIgnoreCase);
+ public string[]? Cells { get; init; }
+
+ public Property? First(string name) => Properties.FirstOrDefault(p => p.Name == name);
+ public IEnumerable All(string name) => Properties.Where(p => p.Name == name);
+ }
+
+ internal sealed class ZoneDefinition
+ {
+ public bool HasRule { get; set; }
+ public List<(DateTime Local, TimeSpan From, TimeSpan To)> Transitions { get; } = [];
+ }
+
+ private static Property? Parse(string line)
+ {
+ var quoted = false;
+ var colon = -1;
+ for (var i = 0; i < line.Length; i++)
+ {
+ if (line[i] == '"')
+ quoted = !quoted;
+ else if (line[i] == ':' && !quoted)
+ {
+ colon = i;
+ break;
+ }
+ }
+ if (colon <= 0)
+ return null;
+ var parts = new List();
+ var start = 0;
+ quoted = false;
+ for (var i = 0; i < colon; i++)
+ {
+ if (line[i] == '"')
+ quoted = !quoted;
+ if (line[i] == ';' && !quoted)
+ {
+ parts.Add(line[start..i]);
+ start = i + 1;
+ }
+ }
+ parts.Add(line[start..colon]);
+ if (parts[0].Length == 0 || parts[0].Any(char.IsWhiteSpace))
+ return null;
+ var parameters = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var part in parts.Skip(1))
+ {
+ var equals = part.IndexOf('=');
+ if (equals > 0)
+ parameters[part[..equals].ToUpperInvariant()] = part[(equals + 1)..].Trim('"');
+ }
+ return new Property(parts[0].ToUpperInvariant(), line[(colon + 1)..], line, parameters);
+ }
+
+ private static IEnumerable Unfold(string text)
+ {
+ var current = new StringBuilder();
+ var started = false;
+ foreach (var line in text.Split(["\r\n", "\n", "\r"], StringSplitOptions.None))
+ {
+ if (started && line.Length > 0 && line[0] is ' ' or '\t')
+ {
+ current.Append(line.AsSpan(1));
+ continue;
+ }
+ if (started)
+ yield return current.ToString();
+ current.Clear().Append(line);
+ started = true;
+ }
+ if (started)
+ yield return current.ToString();
+ }
+
+ public static List Read(byte[] bytes)
+ {
+ var result = new List();
+ var stack = new Stack();
+ Dictionary? zones = null;
+ foreach (var line in Unfold(DocumentReaders.DecodeText(bytes)))
+ {
+ var property = Parse(line);
+ if (property is null)
+ continue;
+ if (property.Name == "BEGIN")
+ {
+ var type = property.Value.ToUpperInvariant();
+ if (type == "VCALENDAR" && stack.Count == 0)
+ zones = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ stack.Push(new Component(type, zones));
+ }
+ else if (property.Name == "END")
+ {
+ if (stack.Count == 0 || stack.Peek().Type != property.Value.ToUpperInvariant())
+ continue;
+ var closed = stack.Pop();
+ if (closed.Type is "STANDARD" or "DAYLIGHT" && stack.Count > 0 && stack.Peek().Type == "VTIMEZONE")
+ {
+ var from = closed.First("TZOFFSETFROM")?.Value;
+ var to = closed.First("TZOFFSETTO")?.Value;
+ var dates = closed.All("DTSTART").Concat(closed.All("RDATE")).SelectMany(p => p.Value.Split(','));
+ if (TryOffset(from, out var oldOffset) && TryOffset(to, out var newOffset))
+ stack.Peek().Properties.Add(new Property("X-FILEE-OBSERVANCE",
+ $"{string.Join(',', dates)}|{oldOffset:c}|{newOffset:c}|{(closed.First("RRULE") is null ? "0" : "1")}",
+ "", new Dictionary()));
+ }
+ if (stack.Count == 1 && stack.Peek().Type == "VCALENDAR")
+ {
+ if (closed.Type is "VEVENT" or "VTODO")
+ result.Add(closed);
+ else if (closed.Type == "VTIMEZONE")
+ AddZone(closed);
+ }
+ if (closed.Type == "VCALENDAR")
+ zones = null;
+ }
+ else if (stack.Count > 0)
+ stack.Peek().Properties.Add(property);
+ }
+ return result;
+ }
+
+ private static void AddZone(Component component)
+ {
+ var id = component.First("TZID")?.Value;
+ if (string.IsNullOrEmpty(id))
+ return;
+ var definition = new ZoneDefinition();
+ // Observances are accumulated while reading, without exposing nested properties on the parent.
+ foreach (var entry in component.Properties.Where(p => p.Name == "X-FILEE-OBSERVANCE"))
+ {
+ var parts = entry.Value.Split('|');
+ if (parts.Length < 3 || !TimeSpan.TryParse(parts[1], CultureInfo.InvariantCulture, out var from)
+ || !TimeSpan.TryParse(parts[2], CultureInfo.InvariantCulture, out var to))
+ continue;
+ definition.HasRule |= parts.Length > 3 && parts[3] == "1";
+ foreach (var date in parts[0].Split(','))
+ if (DateTime.TryParseExact(date, "yyyyMMdd'T'HHmmss", CultureInfo.InvariantCulture,
+ DateTimeStyles.None, out var local))
+ definition.Transitions.Add((local, from, to));
+ }
+ component.Zones[id] = definition;
+ }
+
+ private static bool TryOffset(string? value, out TimeSpan offset)
+ {
+ offset = default;
+ if (value is not { Length: 5 or 7 } || value[0] is not ('+' or '-')
+ || !int.TryParse(value.AsSpan(1, 2), out var hours)
+ || !int.TryParse(value.AsSpan(3, 2), out var minutes))
+ return false;
+ var seconds = 0;
+ if (value.Length == 7 && !int.TryParse(value.AsSpan(5, 2), out seconds))
+ return false;
+ offset = new TimeSpan(hours, minutes, seconds) * (value[0] == '-' ? -1 : 1);
+ return true;
+ }
+
+ private static string Unescape(string value)
+ {
+ var result = new StringBuilder(value.Length);
+ for (var i = 0; i < value.Length; i++)
+ {
+ if (value[i] == '\\' && i + 1 < value.Length)
+ result.Append(value[++i] is 'n' or 'N' ? '\n' : value[i]);
+ else
+ result.Append(value[i]);
+ }
+ return result.ToString();
+ }
+
+ private static string Person(Property property)
+ {
+ var address = property.Value.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase)
+ ? property.Value[7..] : property.Value;
+ var name = property.Parameter("CN");
+ return name.Length == 0 ? address : $"{name} <{address}>";
+ }
+
+ private static readonly string[] DateFormats = ["yyyyMMdd", "yyyyMMdd'T'HHmmss", "yyyyMMdd'T'HHmmss'Z'"];
+
+ private static bool ParseIcsDate(Property property, out DateTime value, out bool date, out bool utc)
+ {
+ date = property.Parameter("VALUE").Equals("DATE", StringComparison.OrdinalIgnoreCase) || property.Value.Length == 8;
+ utc = property.Value.EndsWith('Z');
+ return DateTime.TryParseExact(property.Value, DateFormats, CultureInfo.InvariantCulture,
+ DateTimeStyles.None, out value);
+ }
+
+ private static TimeSpan? Offset(string id, DateTime local, Dictionary zones)
+ {
+ if (zones.TryGetValue(id, out var definition) && !definition.HasRule && definition.Transitions.Count > 0)
+ {
+ var ordered = definition.Transitions.OrderBy(t => t.Local).ToList();
+ var found = ordered.LastOrDefault(t => t.Local <= local);
+ return found == default ? ordered[0].From : found.To;
+ }
+ try { return TimeZoneInfo.FindSystemTimeZoneById(id).GetUtcOffset(local); }
+ catch (TimeZoneNotFoundException) { return null; }
+ catch (InvalidTimeZoneException) { return null; }
+ }
+
+ private static string DateCell(Property? property, Dictionary zones)
+ {
+ if (property is null)
+ return "";
+ if (!ParseIcsDate(property, out var value, out var date, out var utc))
+ return property.Value;
+ if (date)
+ return value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
+ var text = value.ToString("yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture);
+ if (utc)
+ return text + "Z";
+ var id = property.Parameter("TZID");
+ var offset = id.Length == 0 ? null : Offset(id, value, zones);
+ return offset is null ? text : new DateTimeOffset(value, offset.Value).ToString("yyyy-MM-dd'T'HH:mm:sszzz", CultureInfo.InvariantCulture);
+ }
+
+ private static bool Duration(string text, out TimeSpan duration)
+ {
+ duration = default;
+ var sign = 1;
+ if (text.StartsWith('-') || text.StartsWith('+'))
+ {
+ sign = text[0] == '-' ? -1 : 1;
+ text = text[1..];
+ }
+ if (!text.StartsWith('P'))
+ return false;
+ text = text[1..];
+ var time = false;
+ var digits = "";
+ var any = false;
+ foreach (var ch in text)
+ {
+ if (ch == 'T' && !time)
+ {
+ time = true;
+ continue;
+ }
+ if (char.IsAsciiDigit(ch))
+ {
+ digits += ch;
+ continue;
+ }
+ if (digits.Length == 0 || !int.TryParse(digits, out var number))
+ return false;
+ duration += ch switch
+ {
+ 'W' when !time => TimeSpan.FromDays(number * 7d),
+ 'D' when !time => TimeSpan.FromDays(number),
+ 'H' when time => TimeSpan.FromHours(number),
+ 'M' when time => TimeSpan.FromMinutes(number),
+ 'S' when time => TimeSpan.FromSeconds(number),
+ _ => TimeSpan.MinValue,
+ };
+ if (duration < TimeSpan.Zero)
+ return false;
+ digits = "";
+ any = true;
+ }
+ duration *= sign;
+ return any && digits.Length == 0;
+ }
+
+ public static List> ToRows(List components)
+ {
+ var rows = new List> { Columns.ToList() };
+ foreach (var component in components)
+ {
+ var start = component.First("DTSTART");
+ var end = component.First(component.Type == "VTODO" ? "DUE" : "DTEND");
+ var endCell = DateCell(end, component.Zones);
+ if (end is null && start is not null && ParseIcsDate(start, out var startDate, out var date, out var utc)
+ && component.First("DURATION") is { } span && Duration(span.Value, out var duration))
+ {
+ var computed = startDate + duration;
+ var synthetic = new Property(start.Name, computed.ToString(date ? "yyyyMMdd" : "yyyyMMdd'T'HHmmss", CultureInfo.InvariantCulture)
+ + (utc && !date ? "Z" : ""), "", start.Parameters);
+ endCell = DateCell(synthetic, component.Zones);
+ }
+ rows.Add(
+ [
+ component.Type == "VTODO" ? "todo" : "event",
+ Unescape(component.First("SUMMARY")?.Value ?? ""),
+ DateCell(start, component.Zones),
+ endCell,
+ start?.Parameter("VALUE").Equals("DATE", StringComparison.OrdinalIgnoreCase) == true ? "true" : "false",
+ start is not null && start.Parameter("VALUE") != "DATE" && start.Value.Length != 8 ? start.Parameter("TZID") : "",
+ Unescape(component.First("LOCATION")?.Value ?? ""),
+ Unescape(component.First("DESCRIPTION")?.Value ?? ""),
+ component.First("ORGANIZER") is { } organizer ? Person(organizer) : "",
+ string.Join("\n", component.All("ATTENDEE").Select(Person)),
+ component.First("RRULE")?.Value ?? "",
+ string.Join("\n", component.All("EXDATE").Select(p => p.Raw)),
+ component.First("RECURRENCE-ID")?.Raw ?? "",
+ component.First("UID")?.Value ?? "",
+ ]);
+ }
+ return rows;
+ }
+
+ private static int Field(string name)
+ {
+ for (var i = 0; i < Columns.Length; i++)
+ if (name.Equals(Columns[i], StringComparison.OrdinalIgnoreCase))
+ return i;
+ return name.ToLowerInvariant() switch
+ {
+ "summary" or "subject" or "제목" or "标题" => 1,
+ "begin" or "시작" or "开始" => 2,
+ "finish" or "종료" or "结束" => 3,
+ "장소" or "地点" => 6,
+ "notes" or "설명" or "描述" => 7,
+ _ => -1,
+ };
+ }
+
+ public static List FromRows(List> rows)
+ {
+ if (rows.Count == 0)
+ throw new InvalidDataException("The table has no iCalendar events or to-dos.");
+ var fields = rows[0].Select(Field).ToArray();
+ if (!fields.Contains(1) && !fields.Contains(2))
+ throw new InvalidDataException("The table has no iCalendar events or to-dos.");
+ var result = new List();
+ foreach (var row in rows.Skip(1))
+ {
+ var cells = new string[Columns.Length];
+ Array.Fill(cells, "");
+ for (var i = 0; i < Math.Min(fields.Length, row.Count); i++)
+ if (fields[i] >= 0 && cells[fields[i]].Length == 0 && !rows[0].Take(i).Any(h => Field(h) == fields[i]))
+ cells[fields[i]] = row[i];
+ var todo = cells[0].Equals("todo", StringComparison.OrdinalIgnoreCase);
+ if (!todo && !TryCsvDate(cells[2], out _, out _))
+ continue;
+ result.Add(new Component(todo ? "VTODO" : "VEVENT") { Cells = cells });
+ }
+ return result;
+ }
+
+ private static bool TryCsvDate(string text, out DateTimeOffset instant, out bool date)
+ {
+ date = text.Length == 10 && DateOnly.TryParseExact(text, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out _);
+ if (date)
+ {
+ instant = new DateTimeOffset(DateTime.ParseExact(text, "yyyy-MM-dd", CultureInfo.InvariantCulture), TimeSpan.Zero);
+ return true;
+ }
+ return DateTimeOffset.TryParseExact(text, ["yyyy-MM-dd'T'HH:mm:ss'Z'", "yyyy-MM-dd'T'HH:mm:sszzz", "yyyy-MM-dd'T'HH:mm:ss"],
+ CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out instant);
+ }
+
+ private static string Escape(string text) =>
+ text.Replace("\\", "\\\\").Replace("\r\n", "\n").Replace("\r", "\n")
+ .Replace("\n", "\\n").Replace(";", "\\;").Replace(",", "\\,");
+
+ private static void Line(StringBuilder output, string text)
+ {
+ var bytes = 0;
+ foreach (var rune in text.EnumerateRunes())
+ {
+ var size = rune.Utf8SequenceLength;
+ if (bytes + size > 75)
+ {
+ output.Append("\r\n ");
+ bytes = 1;
+ }
+ output.Append(rune.ToString());
+ bytes += size;
+ }
+ output.Append("\r\n");
+ }
+
+ private static string DateLine(string name, string text, string zone, TimeZoneInfo? systemZone)
+ {
+ if (!TryCsvDate(text, out var instant, out var date))
+ return "";
+ if (date)
+ return $"{name};VALUE=DATE:{instant:yyyyMMdd}";
+ var raw = DateTime.ParseExact(text[..19], "yyyy-MM-dd'T'HH:mm:ss", CultureInfo.InvariantCulture);
+ if (systemZone is not null && zone.Length > 0)
+ {
+ var local = text.Length == 19 || (!text.EndsWith('Z') && instant.Offset == systemZone.GetUtcOffset(raw))
+ ? raw : TimeZoneInfo.ConvertTime(instant, systemZone).DateTime;
+ return $"{name};TZID={zone}:{local:yyyyMMdd'T'HHmmss}";
+ }
+ if (text.Length == 19)
+ return $"{name}:{raw:yyyyMMdd'T'HHmmss}";
+ return $"{name}:{instant.ToUniversalTime():yyyyMMdd'T'HHmmss'Z'}";
+ }
+
+ private static string PersonLine(string name, string cell)
+ {
+ var marker = cell.LastIndexOf(" <", StringComparison.Ordinal);
+ var display = marker >= 0 && cell.EndsWith('>') ? cell[..marker] : "";
+ var address = display.Length > 0 ? cell[(marker + 2)..^1] : cell;
+ if (!address.Contains(':'))
+ address = "mailto:" + address;
+ if (display.Length == 0)
+ return name + ":" + address;
+ if (display.IndexOfAny([';', ':', ',']) >= 0)
+ display = "\"" + display + "\"";
+ return $"{name};CN={display}:{address}";
+ }
+
+ public static string Write(List components)
+ {
+ var output = new StringBuilder();
+ Line(output, "BEGIN:VCALENDAR");
+ Line(output, "VERSION:2.0");
+ Line(output, "PRODID:-//Filee//Filee//EN");
+ var zones = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var component in components)
+ {
+ var cells = component.Cells!;
+ if (cells[5].Length == 0)
+ continue;
+ try
+ {
+ var zone = TimeZoneInfo.FindSystemTimeZoneById(cells[5]);
+ var parsedStart = TryCsvDate(cells[2], out var start, out var startDate);
+ var hasStart = cells[2].Length > 0 && parsedStart && !startDate;
+ var candidate = hasStart
+ ? start : TryCsvDate(cells[3], out var end, out var endDate) && !endDate ? end : (DateTimeOffset?)null;
+ if (candidate is { } instant && (!zones.TryGetValue(cells[5], out var previous)
+ || hasStart && !previous.HasStart || hasStart == previous.HasStart && instant < previous.Instant))
+ zones[cells[5]] = (zone, instant, hasStart);
+ }
+ catch (TimeZoneNotFoundException) { }
+ catch (InvalidTimeZoneException) { }
+ }
+ foreach (var (id, definition) in zones)
+ foreach (var line in BuildVTimeZone(definition.Zone, definition.Instant, id).Split("\r\n", StringSplitOptions.RemoveEmptyEntries))
+ Line(output, line);
+
+ foreach (var component in components)
+ {
+ var cells = component.Cells!;
+ TimeZoneInfo? zone = null;
+ if (zones.TryGetValue(cells[5], out var definition))
+ zone = definition.Zone;
+ Line(output, "BEGIN:" + component.Type);
+ var uid = cells[13].Length == 0
+ ? Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join('\0', cells))))[..16] + "@filee"
+ : cells[13];
+ Line(output, "UID:" + uid);
+ Line(output, "DTSTAMP:" + DateTime.UtcNow.ToString("yyyyMMdd'T'HHmmss'Z'", CultureInfo.InvariantCulture));
+ if (cells[1].Length > 0)
+ Line(output, "SUMMARY:" + Escape(cells[1]));
+ var start = DateLine("DTSTART", cells[2], cells[5], zone);
+ if (start.Length > 0)
+ Line(output, start);
+ var end = DateLine(component.Type == "VTODO" ? "DUE" : "DTEND", cells[3], cells[5], zone);
+ if (end.Length > 0)
+ Line(output, end);
+ if (cells[6].Length > 0)
+ Line(output, "LOCATION:" + Escape(cells[6]));
+ if (cells[7].Length > 0)
+ Line(output, "DESCRIPTION:" + Escape(cells[7]));
+ if (cells[8].Length > 0)
+ Line(output, PersonLine("ORGANIZER", cells[8]));
+ foreach (var attendee in cells[9].Split('\n', StringSplitOptions.RemoveEmptyEntries))
+ Line(output, PersonLine("ATTENDEE", attendee));
+ if (cells[10].Length > 0)
+ Line(output, "RRULE:" + cells[10]);
+ foreach (var line in cells[11].Split('\n', StringSplitOptions.RemoveEmptyEntries))
+ Line(output, line);
+ foreach (var line in cells[12].Split('\n', StringSplitOptions.RemoveEmptyEntries))
+ Line(output, line);
+ Line(output, "END:" + component.Type);
+ }
+ Line(output, "END:VCALENDAR");
+ return output.ToString();
+ }
+
+ private sealed record Transition(DateTimeOffset Instant, DateTime Local, TimeSpan From, TimeSpan To)
+ {
+ public bool Daylight => To > From;
+ }
+
+ private static List Transitions(TimeZoneInfo zone, int year)
+ {
+ var found = new List();
+ var until = new DateTimeOffset(year + 1, 1, 1, 0, 0, 0, TimeSpan.Zero);
+ var previous = new DateTimeOffset(year, 1, 1, 0, 0, 0, TimeSpan.Zero);
+ var offset = zone.GetUtcOffset(previous);
+ for (var next = previous.AddHours(1); next <= until; next = next.AddHours(1))
+ {
+ var current = zone.GetUtcOffset(next);
+ if (current != offset)
+ {
+ var lower = previous;
+ var upper = next;
+ while ((upper - lower).TotalMinutes > 1)
+ {
+ var middle = lower.AddMinutes(Math.Floor((upper - lower).TotalMinutes / 2));
+ if (zone.GetUtcOffset(middle) == offset)
+ lower = middle;
+ else
+ upper = middle;
+ }
+ found.Add(new Transition(upper, upper.UtcDateTime + offset, offset, current));
+ }
+ previous = next;
+ offset = current;
+ }
+ return found;
+ }
+
+ private static string Rule(Transition transition, TimeZoneInfo zone, int year)
+ {
+ var day = transition.Local;
+ var weekday = day.DayOfWeek.ToString()[..2].ToUpperInvariant();
+ var nth = (day.Day - 1) / 7 + 1;
+ var candidates = new[]
+ {
+ $"BYDAY=-1{weekday}",
+ $"BYDAY={nth}{weekday}",
+ $"BYMONTHDAY={day.Day}",
+ };
+ var matches = Enumerable.Range(year, 3).SelectMany(y => Transitions(zone, y))
+ .Where(t => t.Daylight == transition.Daylight && t.Local.Month == day.Month).ToList();
+ foreach (var candidate in candidates)
+ {
+ if (matches.Count != 3)
+ break;
+ if (matches.All(t => candidate == $"BYMONTHDAY={t.Local.Day}" ||
+ candidate == $"BYDAY={(t.Local.AddDays(7).Month != t.Local.Month ? "-1" : (t.Local.Day - 1) / 7 + 1)}{t.Local.DayOfWeek.ToString()[..2].ToUpperInvariant()}"))
+ return $"FREQ=YEARLY;BYMONTH={day.Month};{candidate}";
+ }
+ return $"FREQ=YEARLY;BYMONTH={day.Month};BYDAY={nth}{weekday}";
+ }
+
+ private static string OffsetText(TimeSpan offset) =>
+ $"{(offset < TimeSpan.Zero ? "-" : "+")}{Math.Abs((int)offset.TotalHours):00}{Math.Abs(offset.Minutes):00}";
+
+ /// Builds observances from actual UTC transitions, rather than adjustment-rule metadata.
+ internal static string BuildVTimeZone(TimeZoneInfo zone, DateTimeOffset instant, string? tzid = null)
+ {
+ var year = instant.Year;
+ var transitions = Transitions(zone, year);
+ var output = new StringBuilder();
+ Line(output, "BEGIN:VTIMEZONE");
+ Line(output, "TZID:" + (tzid ?? zone.Id));
+ if (transitions.Count < 2)
+ {
+ var offset = OffsetText(zone.GetUtcOffset(instant));
+ Line(output, "BEGIN:STANDARD");
+ Line(output, "DTSTART:19700101T000000");
+ Line(output, "TZOFFSETFROM:" + offset);
+ Line(output, "TZOFFSETTO:" + offset);
+ Line(output, "END:STANDARD");
+ }
+ else
+ {
+ foreach (var transition in transitions)
+ {
+ var type = transition.Daylight ? "DAYLIGHT" : "STANDARD";
+ Line(output, "BEGIN:" + type);
+ Line(output, "DTSTART:" + transition.Local.ToString("yyyyMMdd'T'HHmmss", CultureInfo.InvariantCulture));
+ Line(output, "TZOFFSETFROM:" + OffsetText(transition.From));
+ Line(output, "TZOFFSETTO:" + OffsetText(transition.To));
+ Line(output, "RRULE:" + Rule(transition, zone, year));
+ Line(output, "END:" + type);
+ }
+ }
+ Line(output, "END:VTIMEZONE");
+ return output.ToString();
+ }
+}
diff --git a/tests/Filee.Core.Tests/UserDataStoreTests.cs b/tests/Filee.Core.Tests/UserDataStoreTests.cs
index 142da41e2..18a8e1bad 100644
--- a/tests/Filee.Core.Tests/UserDataStoreTests.cs
+++ b/tests/Filee.Core.Tests/UserDataStoreTests.cs
@@ -1044,6 +1044,77 @@ public void Version_30_libraries_get_the_live_photo_presets_after_extract_live_v
Assert.Single(presets, p => p.Id == "live-photo-iphone");
}
+ [Fact]
+ public void Version_32_libraries_learn_icalendar_extensions_and_preset_without_overriding_user_choices()
+ {
+ using var fullDir = new TempDir();
+ var full = new UserDataStore(fullDir.Path);
+ full.Load();
+ full.Settings.SchemaVersion = 32;
+ var data = full.Profiles.Single(p => p.Id == "data");
+ data.Extensions.RemoveAll(e => e is "ics" or "ical");
+ full.Profiles.Insert(0, new ToolbarProfile { Id = "my-calendar", Name = "My calendar", Extensions = ["ics"] });
+ var fullBack = full.Profiles.Single(p => p.Id == "spreadsheets").BackPresetIds!;
+ fullBack.Remove("to-ics");
+ fullBack.AddRange(full.Presets.Select(p => p.Id).Where(id => !fullBack.Contains(id))
+ .Take(ToolbarProfile.MaxSlices - fullBack.Count));
+ var fullBackBeforeMigration = fullBack.ToList();
+ full.Presets.RemoveAll(p => p.Id == "to-ics");
+ full.SaveSettings();
+ full.SaveLibrary();
+
+ var upgradedFull = new UserDataStore(fullDir.Path);
+ upgradedFull.Load();
+ Assert.Contains("ical", upgradedFull.Profiles.Single(p => p.Id == "data").Extensions);
+ Assert.DoesNotContain("ics", upgradedFull.Profiles.Single(p => p.Id == "data").Extensions);
+ Assert.Contains("ics", upgradedFull.Profiles.Single(p => p.Id == "my-calendar").Extensions);
+ Assert.Equal(fullBackBeforeMigration, upgradedFull.Profiles.Single(p => p.Id == "spreadsheets").BackPresetIds);
+ Assert.Single(upgradedFull.Presets, p => p.Id == "to-ics");
+
+ using var roomDir = new TempDir();
+ var room = new UserDataStore(roomDir.Path);
+ room.Load();
+ room.Settings.SchemaVersion = 32;
+ foreach (var profile in room.Profiles)
+ profile.Extensions.RemoveAll(e => e is "ics" or "ical");
+ room.Profiles.Single(p => p.Id == "spreadsheets").BackPresetIds!.Remove("to-ics");
+ room.Presets.RemoveAll(p => p.Id == "to-ics");
+ room.SaveSettings();
+ room.SaveLibrary();
+
+ var upgradedRoom = new UserDataStore(roomDir.Path);
+ upgradedRoom.Load();
+ var sheets = upgradedRoom.Profiles.Single(p => p.Id == "spreadsheets").BackPresetIds!;
+ Assert.Equal(sheets.IndexOf("to-vcf") + 1, sheets.IndexOf("to-ics"));
+ Assert.Single(upgradedRoom.Presets, p => p.Id == "to-ics");
+ upgradedRoom.Profiles.Single(p => p.Id == "data").Extensions.Remove("ics");
+ upgradedRoom.SaveLibrary();
+
+ var reloadedRoom = new UserDataStore(roomDir.Path);
+ reloadedRoom.Load();
+ Assert.DoesNotContain("ics", reloadedRoom.Profiles.Single(p => p.Id == "data").Extensions);
+ Assert.Equal(1, reloadedRoom.Profiles.Single(p => p.Id == "spreadsheets").BackPresetIds!.Count(id => id == "to-ics"));
+ Assert.Single(reloadedRoom.Presets, p => p.Id == "to-ics");
+
+ using var deletedDir = new TempDir();
+ var deleted = new UserDataStore(deletedDir.Path);
+ deleted.Load();
+ deleted.Settings.SchemaVersion = 32;
+ deleted.Profiles.RemoveAll(p => p.Id == "data");
+ foreach (var profile in deleted.Profiles)
+ profile.Extensions.RemoveAll(e => e is "ics" or "ical");
+ deleted.Profiles.Single(p => p.Id == "spreadsheets").BackPresetIds!.Remove("to-ics");
+ deleted.Presets.RemoveAll(p => p.Id == "to-ics");
+ deleted.SaveSettings();
+ deleted.SaveLibrary();
+
+ var upgradedDeleted = new UserDataStore(deletedDir.Path);
+ upgradedDeleted.Load();
+ Assert.DoesNotContain(upgradedDeleted.Profiles, p => p.Id == "data");
+ Assert.DoesNotContain(upgradedDeleted.Profiles.SelectMany(p => p.Extensions), e => e is "ics" or "ical");
+ Assert.Single(upgradedDeleted.Presets, p => p.Id == "to-ics");
+ }
+
[Fact]
public void Live_photo_options_survive_saving_and_unknown_kinds_read_as_none()
{
diff --git a/tests/Filee.Engines.Tests/ICalendarTests.cs b/tests/Filee.Engines.Tests/ICalendarTests.cs
new file mode 100644
index 000000000..cac202dd0
--- /dev/null
+++ b/tests/Filee.Engines.Tests/ICalendarTests.cs
@@ -0,0 +1,464 @@
+using System.Globalization;
+using System.Text;
+using System.Text.Json;
+using Filee.Core.Conversion;
+using Filee.Core.Formats;
+using Filee.Core.Presets;
+using Filee.Core.Settings;
+using Filee.Engines.Data;
+using Filee.Engines.Office.Sheets;
+
+namespace Filee.Engines.Tests;
+
+public class ICalendarTests(EngineFixture fx) : IClassFixture
+{
+ private static string Calendar(params string[] lines) =>
+ string.Join("\r\n", ["BEGIN:VCALENDAR", "VERSION:2.0", .. lines, "END:VCALENDAR", ""]);
+
+ private static string Event(string uid, params string[] lines) =>
+ string.Join("\r\n", ["BEGIN:VEVENT", "UID:" + uid, .. lines, "END:VEVENT"]);
+
+ private static List> Rows(string text) => CsvFormat.Parse(text.TrimStart('\uFEFF'), ',');
+
+ private async Task Convert(string text, string extension, string target)
+ {
+ var path = Path.Combine(fx.NewFolder(), "source." + extension);
+ await File.WriteAllTextAsync(path, text, TestContext.Current.CancellationToken);
+ var job = await fx.ConvertAsync([path], new Preset { TargetFormat = target });
+ Assert.Equal(JobState.Completed, job.State);
+ return job.Outputs.Single();
+ }
+
+ private async Task>> ReadCalendar(string text) =>
+ Rows(await File.ReadAllTextAsync(await Convert(text, "ics", "csv"), TestContext.Current.CancellationToken));
+
+ private static string Cell(List> rows, int row, string name) => rows[row][rows[0].IndexOf(name)];
+
+ private static string Csv(params string[][] rows) =>
+ string.Join("\r\n", rows.Select(row => string.Join(",", row.Select(value =>
+ value.IndexOfAny([',', '"', '\n', '\r']) < 0 ? value : "\"" + value.Replace("\"", "\"\"") + "\"")))) + "\r\n";
+
+ [Fact]
+ public async Task Google_export_preserves_columns_recurrence_attendees_folding_and_skips_alarm()
+ {
+ var zone = string.Join("\r\n", "BEGIN:VTIMEZONE", "TZID:Asia/Seoul", "BEGIN:STANDARD",
+ "DTSTART:19700101T000000", "TZOFFSETFROM:+0900", "TZOFFSETTO:+0900", "END:STANDARD", "END:VTIMEZONE");
+ var rows = await ReadCalendar(Calendar(zone, Event("007",
+ "SUMMARY:123", "DTSTART:20261021T000000Z", "DTEND:20261021T010000Z", "LOCATION:true",
+ "DESCRIPTION:긴 설명이 접힌 줄의 전반부 ", "\t후반부\\n둘째 줄",
+ "RRULE:FREQ=WEEKLY;COUNT=3", "EXDATE;TZID=Asia/Seoul:20261028T090000,20261104T090000",
+ "EXDATE;TZID=Asia/Seoul:20261111T090000",
+ "ATTENDEE;CN=\"Kim, A\":mailto:a@example.com", "ATTENDEE:mailto:b@example.com",
+ "BEGIN:VALARM", "DESCRIPTION:do not replace event description", "END:VALARM"),
+ Event("seoul", "DTSTART;TZID=Asia/Seoul:20261021T090000")));
+ Assert.Equal(ICalendar.Columns, rows[0]);
+ Assert.Equal(3, rows.Count);
+ Assert.Equal("2026-10-21T00:00:00Z", Cell(rows, 1, "Start"));
+ Assert.Equal("2026-10-21T09:00:00+09:00", Cell(rows, 2, "Start"));
+ Assert.Equal("123", Cell(rows, 1, "Title"));
+ Assert.Equal("true", Cell(rows, 1, "Location"));
+ Assert.Equal("007", Cell(rows, 1, "UID"));
+ Assert.Equal("긴 설명이 접힌 줄의 전반부 후반부\n둘째 줄", Cell(rows, 1, "Description"));
+ Assert.Equal("FREQ=WEEKLY;COUNT=3", Cell(rows, 1, "RRULE"));
+ Assert.Equal("EXDATE;TZID=Asia/Seoul:20261028T090000,20261104T090000\nEXDATE;TZID=Asia/Seoul:20261111T090000", Cell(rows, 1, "EXDATE"));
+ Assert.Equal("Kim, A \nb@example.com", Cell(rows, 1, "Attendees"));
+ }
+
+ [Fact]
+ public async Task Apple_all_day_exclusive_end_duration_and_unknown_properties()
+ {
+ var rows = await ReadCalendar(Calendar(
+ Event("all", "DTSTART;VALUE=DATE:20261021", "DTEND;VALUE=DATE:20261022", "X-APPLE-STRUCTURED-LOCATION:ignored"),
+ Event("duration", "DTSTART:20261021T090000Z", "DURATION:PT1H30M"),
+ Event("weeks", "DTSTART;VALUE=DATE:20261021", "DURATION:P2W")));
+ Assert.Equal("2026-10-22", Cell(rows, 1, "End"));
+ Assert.Equal("true", Cell(rows, 1, "All day"));
+ Assert.Equal("", Cell(rows, 1, "Time zone"));
+ Assert.Equal("2026-10-21T10:30:00Z", Cell(rows, 2, "End"));
+ Assert.Equal("2026-11-04", Cell(rows, 3, "End"));
+ }
+
+ [Fact]
+ public async Task Outlook_windows_zone_custom_zone_unknown_rules_and_recurrence_instance()
+ {
+ var fixedZone = string.Join("\r\n", "BEGIN:VTIMEZONE", "TZID:Customized Time Zone", "BEGIN:STANDARD",
+ "DTSTART:19700101T000000", "TZOFFSETFROM:+0530", "TZOFFSETTO:+0530", "END:STANDARD", "END:VTIMEZONE");
+ var unknown = string.Join("\r\n", "BEGIN:VTIMEZONE", "TZID:Unknown/Rule", "BEGIN:STANDARD",
+ "DTSTART:19700101T000000", "TZOFFSETFROM:+0100", "TZOFFSETTO:+0200",
+ "RRULE:FREQ=YEARLY;BYMONTH=1;BYDAY=1SU", "END:STANDARD", "END:VTIMEZONE");
+ var rows = await ReadCalendar(Calendar(fixedZone, unknown,
+ Event("windows", "DTSTART;TZID=Korea Standard Time:20261021T090000"),
+ Event("custom", "DTSTART;TZID=Customized Time Zone:20261021T090000"),
+ Event("unknown", "DTSTART;TZID=Unknown/Rule:20261021T090000"),
+ Event("exception", "DTSTART:20261021T100000Z", "RECURRENCE-ID;TZID=Asia/Seoul:20261021T090000")));
+ Assert.Equal("2026-10-21T09:00:00+09:00", Cell(rows, 1, "Start"));
+ Assert.Equal("2026-10-21T09:00:00+05:30", Cell(rows, 2, "Start"));
+ Assert.Equal("2026-10-21T09:00:00", Cell(rows, 3, "Start"));
+ Assert.Equal("Unknown/Rule", Cell(rows, 3, "Time zone"));
+ Assert.Equal("RECURRENCE-ID;TZID=Asia/Seoul:20261021T090000", Cell(rows, 4, "Recurrence ID"));
+ }
+
+ [Fact]
+ public async Task Calendar_order_is_event_todo_event_across_two_calendars()
+ {
+ var text = Calendar(Event("A", "DTSTART:20261021T090000Z"), "BEGIN:VTODO", "UID:B", "END:VTODO")
+ + Calendar(Event("C", "DTSTART:20261021T090000Z"));
+ var rows = await ReadCalendar(text);
+ Assert.Equal([("event", "A"), ("todo", "B"), ("event", "C")],
+ rows.Skip(1).Select(row => (row[0], row[13])).ToArray());
+ }
+
+ [Fact]
+ public async Task File_timezone_wins_and_transitions_are_scoped_to_each_calendar()
+ {
+ static string Zone(string id, string from, string to) =>
+ string.Join("\r\n", "BEGIN:VTIMEZONE", "TZID:" + id, "BEGIN:STANDARD",
+ "DTSTART:20261021T100000", "TZOFFSETFROM:" + from, "TZOFFSETTO:" + to,
+ "END:STANDARD", "END:VTIMEZONE");
+ var text = Calendar(Zone("America/New_York", "+0100", "+0100"),
+ Event("file", "DTSTART;TZID=America/New_York:20261021T090000"))
+ + Calendar(Zone("Custom/Change", "+0100", "+0200"),
+ Event("before", "DTSTART;TZID=Custom/Change:20261021T090000"),
+ Event("after", "DTSTART;TZID=Custom/Change:20261021T110000"))
+ + Calendar(Zone("America/New_York", "+0300", "+0300"),
+ Event("second", "DTSTART;TZID=America/New_York:20261021T090000"));
+ var rows = await ReadCalendar(text);
+ Assert.EndsWith("+01:00", Cell(rows, 1, "Start"));
+ Assert.EndsWith("+01:00", Cell(rows, 2, "Start"));
+ Assert.EndsWith("+02:00", Cell(rows, 3, "Start"));
+ Assert.EndsWith("+03:00", Cell(rows, 4, "Start"));
+ }
+
+ [Fact]
+ public async Task Rdate_transitions_before_between_after_and_tab_fold_with_escapes()
+ {
+ var zone = string.Join("\n", "BEGIN:VTIMEZONE", "TZID:Custom/Rdate", "BEGIN:STANDARD",
+ "DTSTART:20261021T100000", "RDATE:20261121T100000",
+ "TZOFFSETFROM:+0100", "TZOFFSETTO:+0200", "END:STANDARD",
+ "BEGIN:DAYLIGHT", "DTSTART:20261221T100000", "RDATE:20270121T100000",
+ "TZOFFSETFROM:+0200", "TZOFFSETTO:+0300", "END:DAYLIGHT", "END:VTIMEZONE");
+ var rows = await ReadCalendar(Calendar(zone,
+ Event("first", "DTSTART;TZID=Custom/Rdate:20261020T090000", @"SUMMARY:a\\b\;c\,d\ne\Nlast"),
+ Event("middle", "DTSTART;TZID=Custom/Rdate:20261101T090000"),
+ Event("last", "DTSTART;TZID=Custom/Rdate:20270122T090000")).Replace("\r\n", "\n"));
+ Assert.EndsWith("+01:00", Cell(rows, 1, "Start"));
+ Assert.EndsWith("+02:00", Cell(rows, 2, "Start"));
+ Assert.EndsWith("+03:00", Cell(rows, 3, "Start"));
+ Assert.Equal("a\\b;c,d\ne\nlast", Cell(rows, 1, "Title"));
+ }
+
+ [Fact]
+ public async Task Todo_only_due_duration_without_dates_and_mixed_end_zones()
+ {
+ var rows = await ReadCalendar(Calendar(
+ "BEGIN:VTODO", "UID:due", "DUE;VALUE=DATE:20261030", "END:VTODO",
+ "BEGIN:VTODO", "UID:duration", "DTSTART:20261021T090000Z", "DURATION:PT1H", "END:VTODO",
+ "BEGIN:VTODO", "UID:empty", "END:VTODO",
+ Event("mixed", "DTSTART;TZID=Asia/Seoul:20261021T090000",
+ "DTEND;TZID=America/New_York:20261021T090000")));
+ Assert.Equal(5, rows.Count);
+ Assert.Equal("", Cell(rows, 1, "Start"));
+ Assert.Equal("2026-10-30", Cell(rows, 1, "End"));
+ Assert.Equal("false", Cell(rows, 1, "All day"));
+ Assert.Equal("2026-10-21T10:00:00Z", Cell(rows, 2, "End"));
+ Assert.Equal("", Cell(rows, 3, "End"));
+ Assert.Equal("Asia/Seoul", Cell(rows, 4, "Time zone"));
+ Assert.EndsWith("-04:00", Cell(rows, 4, "End"));
+ var output = await File.ReadAllTextAsync(await Convert(Csv(ICalendar.Columns,
+ rows[1].ToArray()), "csv", "ics"), TestContext.Current.CancellationToken);
+ Assert.DoesNotContain("DTSTART", output);
+ Assert.Contains("DUE;VALUE=DATE:20261030", output);
+ }
+
+ [Fact]
+ public async Task Todo_only_calendar_converts_to_one_todo_row()
+ {
+ var rows = await ReadCalendar(Calendar(
+ "BEGIN:VTODO", "UID:only", "DUE;VALUE=DATE:20261030", "END:VTODO"));
+ Assert.Equal(2, rows.Count);
+ Assert.Equal("todo", Cell(rows, 1, "Type"));
+ }
+
+ [Fact]
+ public async Task Malformed_input_keeps_only_closed_components_and_raw_unparseable_dates()
+ {
+ var rows = await ReadCalendar(Calendar(
+ "garbage", "NO-COLON",
+ Event("closed", "DTSTART:tomorrow", "DTEND:tomorrow", "EXDATE:abc"),
+ Event("bad-duration", "DTSTART:20261021T090000Z", "DURATION:abc"),
+ Event("missing-start", "DTEND:20261021T090000Z"))
+ + "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:unclosed");
+ Assert.Equal(4, rows.Count);
+ Assert.Equal("tomorrow", Cell(rows, 1, "Start"));
+ Assert.Equal("tomorrow", Cell(rows, 1, "End"));
+ Assert.Equal("EXDATE:abc", Cell(rows, 1, "EXDATE"));
+ Assert.Equal("", Cell(rows, 2, "End"));
+ Assert.Equal("", Cell(rows, 3, "Start"));
+ Assert.Equal("2026-10-21T09:00:00Z", Cell(rows, 3, "End"));
+ }
+
+ [Fact]
+ public async Task Ambiguous_new_york_time_uses_standard_offset()
+ {
+ var rows = await ReadCalendar(Calendar(Event("dst", "DTSTART;TZID=America/New_York:20261101T013000")));
+ Assert.Equal("2026-11-01T01:30:00-05:00", Cell(rows, 1, "Start"));
+ }
+
+ [Fact]
+ public async Task Empty_calendar_and_invalid_table_fail_without_output()
+ {
+ var ics = Path.Combine(fx.NewFolder(), "empty.ics");
+ await File.WriteAllTextAsync(ics, Calendar("BEGIN:VJOURNAL", "END:VJOURNAL"), TestContext.Current.CancellationToken);
+ var csv = Path.Combine(fx.NewFolder(), "empty.csv");
+ await File.WriteAllTextAsync(csv, "Title,Start\r\nA,\r\nB,10/21/2026\r\n", TestContext.Current.CancellationToken);
+ foreach (var (path, target) in new[] { (ics, "csv"), (csv, "ics") })
+ {
+ var job = await fx.ConvertAsync([path], new Preset { TargetFormat = target });
+ Assert.Equal(JobState.Failed, job.State);
+ Assert.Equal("error.conversion_failed", job.Files.Single().ErrorKey);
+ Assert.Contains("no iCalendar events", job.Files.Single().ErrorDetail);
+ Assert.Empty(job.Outputs);
+ Assert.Empty(Directory.GetFiles(Path.GetDirectoryName(path)!, "*." + target));
+ }
+ var missing = Path.Combine(fx.NewFolder(), "unknown.csv");
+ await File.WriteAllTextAsync(missing, "Unknown,Other\r\nA,B\r\n", TestContext.Current.CancellationToken);
+ Assert.Equal(JobState.Failed, (await fx.ConvertAsync([missing], new Preset { TargetFormat = "ics" })).State);
+ }
+
+ [Fact]
+ public async Task Json_route_has_two_data_steps_and_uses_csv_type_inference()
+ {
+ var planner = fx.Catalog.CreatePlanner();
+ var route = planner.Plan("ics", "json");
+ Assert.NotNull(route);
+ Assert.Equal(2, route.Steps.Count);
+ Assert.All(route.Steps, step => Assert.Equal("data", step.Converter.Id));
+ var path = await Convert(Calendar(Event("007", "SUMMARY:123", "LOCATION:true",
+ "DTSTART;VALUE=DATE:20261021")), "ics", "json");
+ using var document = JsonDocument.Parse(await File.ReadAllTextAsync(path, TestContext.Current.CancellationToken));
+ var row = document.RootElement.EnumerateArray().Single();
+ Assert.Equal(ICalendar.Columns, row.EnumerateObject().Select(p => p.Name).ToArray());
+ Assert.True(row.GetProperty("All day").GetBoolean());
+ Assert.True(row.GetProperty("Location").GetBoolean());
+ Assert.Equal(123, row.GetProperty("Title").GetInt32());
+ Assert.Equal("007", row.GetProperty("UID").GetString());
+ }
+
+ [Fact]
+ public async Task Csv_writer_skips_invalid_events_end_and_unknown_columns_and_uses_leftmost_alias()
+ {
+ var text = await File.ReadAllTextAsync(await Convert(
+ Csv(["Title", "Summary", "Start", "End", "Unknown"],
+ ["A", "B", "2026-10-21", "soon", "secret"],
+ ["skip", "x", "", "", "secret"],
+ ["skip2", "y", "10/21/2026", "", "secret"]),
+ "csv", "ics"), TestContext.Current.CancellationToken);
+ Assert.Equal(1, text.Split("BEGIN:VEVENT").Length - 1);
+ Assert.Contains("SUMMARY:A", text);
+ Assert.DoesNotContain("SUMMARY:B", text);
+ Assert.DoesNotContain("DTEND", text);
+ Assert.DoesNotContain("secret", text);
+ }
+
+ [Theory]
+ [InlineData("Summary", "Begin", "Finish")]
+ [InlineData("Subject", "시작", "종료")]
+ [InlineData("제목", "开始", "结束")]
+ [InlineData("标题", "Begin", "Finish")]
+ [InlineData("title", "START", "End")]
+ public async Task Accepted_aliases_and_case_insensitive_headers(string title, string start, string end)
+ {
+ var output = await File.ReadAllTextAsync(await Convert(Csv([title, start, end],
+ ["alias", "2026-10-21", "2026-10-22"]), "csv", "ics"), TestContext.Current.CancellationToken);
+ Assert.Contains("SUMMARY:alias", output);
+ Assert.Contains("DTSTART;VALUE=DATE:20261021", output);
+ Assert.Contains("DTEND;VALUE=DATE:20261022", output);
+ }
+
+ [Theory]
+ [InlineData("Location", "장소")]
+ [InlineData("Location", "地点")]
+ [InlineData("Description", "Notes")]
+ [InlineData("Description", "설명")]
+ [InlineData("Description", "描述")]
+ public async Task Location_and_description_aliases(string field, string alias)
+ {
+ var output = await File.ReadAllTextAsync(await Convert(Csv(["Title", "Start", alias],
+ ["A", "2026-10-21", "sample"]), "csv", "ics"), TestContext.Current.CancellationToken);
+ Assert.Contains(field.ToUpperInvariant() + ":sample", output);
+ }
+
+ [Fact]
+ public async Task Output_contract_uid_stamp_bom_folding_timezone_and_text_escaping()
+ {
+ var row = new[] { "event", "제목, 쉼표; 역슬래시\\ 줄\n둘", "2026-07-21T09:00:00+09:00",
+ "2026-07-21T10:00:00+09:00", "false", "Asia/Seoul", "", new string('가', 55),
+ "Kim, A ", "Kim; A ", "FREQ=DAILY;COUNT=2",
+ "EXDATE;TZID=Asia/Seoul:20260722T090000", "RECURRENCE-ID;TZID=Asia/Seoul:20260720T090000", "" };
+ var csv = Csv(ICalendar.Columns, row);
+ var before = DateTime.UtcNow.AddSeconds(-1);
+ var first = await Convert(csv, "csv", "ics");
+ var bytes = await File.ReadAllBytesAsync(first, TestContext.Current.CancellationToken);
+ var after = DateTime.UtcNow.AddSeconds(1);
+ Assert.False(bytes is [0xEF, 0xBB, 0xBF, ..]);
+ var text = Encoding.UTF8.GetString(bytes);
+ Assert.StartsWith("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//Filee//Filee//EN\r\n", text);
+ Assert.Contains("BEGIN:VTIMEZONE\r\nTZID:Asia/Seoul\r\n", text);
+ Assert.Contains("TZOFFSETFROM:+0900\r\nTZOFFSETTO:+0900", text);
+ Assert.Contains("SUMMARY:제목\\, 쉼표\\; 역슬래시\\\\ 줄\\n둘", text);
+ Assert.Contains("ORGANIZER;CN=\"Kim, A\":mailto:a@example.com", text);
+ Assert.Contains("ATTENDEE;CN=\"Kim; A\":mailto:b@example.com", text);
+ Assert.Contains("EXDATE;TZID=Asia/Seoul:20260722T090000", text);
+ Assert.Contains("RECURRENCE-ID;TZID=Asia/Seoul:20260720T090000", text);
+ Assert.DoesNotContain("\n", text.Replace("\r\n", ""));
+ Assert.All(text.Split("\r\n"), line => Assert.True(Encoding.UTF8.GetByteCount(line) <= 75, line));
+ var unfolded = text.Replace("\r\n ", "");
+ var stamp = unfolded.Split("\r\n").Single(line => line.StartsWith("DTSTAMP:"))[8..];
+ var parsed = DateTime.ParseExact(stamp, "yyyyMMdd'T'HHmmss'Z'", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal);
+ Assert.InRange(parsed, before.AddSeconds(-1), after);
+ var uid = unfolded.Split("\r\n").Single(line => line.StartsWith("UID:"));
+ var second = await File.ReadAllTextAsync(await Convert(csv, "csv", "ics"), TestContext.Current.CancellationToken);
+ Assert.Contains(uid, second.Replace("\r\n ", ""));
+ var roundtrip = await ReadCalendar(text);
+ Assert.Equal("Kim, A ", Cell(roundtrip, 1, "Organizer"));
+ Assert.Equal("Kim; A ", Cell(roundtrip, 1, "Attendees"));
+ }
+
+ [Fact]
+ public async Task Long_combining_sequence_folds_within_seventy_five_octets_and_roundtrips()
+ {
+ var title = "e" + new string('\u0301', 40);
+ var text = await File.ReadAllTextAsync(await Convert(
+ Csv(["Title", "Start"], [title, "2026-10-21"]), "csv", "ics"), TestContext.Current.CancellationToken);
+ Assert.All(text.Split("\r\n"), line => Assert.True(Encoding.UTF8.GetByteCount(line) <= 75, line));
+ var rows = await ReadCalendar(text);
+ Assert.Equal(title, Cell(rows, 1, "Title"));
+ }
+
+ [Fact]
+ public async Task Calendar_defaults_type_ignores_all_day_and_preserves_due_only_todo()
+ {
+ var rows = new[]
+ {
+ new[] { "", "blank", "2026-10-21T09:00:00Z", "", "true", "", "", "", "", "", "", "", "", "one" },
+ new[] { "meeting", "other", "2026-10-21", "", "", "", "", "", "", "", "", "", "", "two" },
+ new[] { "todo", "due", "", "2026-10-21T09:00:00+09:00", "false", "Asia/Seoul", "", "", "", "", "", "", "", "three" },
+ };
+ var text = await File.ReadAllTextAsync(await Convert(Csv([ICalendar.Columns, .. rows]), "csv", "ics"), TestContext.Current.CancellationToken);
+ Assert.Equal(2, text.Split("BEGIN:VEVENT").Length - 1);
+ Assert.Contains("DTSTART:20261021T090000Z", text);
+ Assert.Contains("DTSTART;VALUE=DATE:20261021", text);
+ Assert.Contains("BEGIN:VTODO", text);
+ var back = await ReadCalendar(text);
+ Assert.Equal("", Cell(back, 3, "Start"));
+ Assert.Equal("2026-10-21T09:00:00+09:00", Cell(back, 3, "End"));
+ Assert.Equal("", Cell(back, 3, "Time zone"));
+ }
+
+ [Fact]
+ public async Task Canonical_rows_roundtrip_all_fourteen_columns()
+ {
+ var rows = new[]
+ {
+ new[] { "event", "all", "2026-10-21", "2026-10-22", "true", "", "", "", "", "", "", "", "", "all" },
+ new[] { "event", "seoul", "2026-10-21T09:00:00+09:00", "2026-10-21T10:00:00+09:00", "false", "Asia/Seoul", "", "", "", "", "", "", "", "seoul" },
+ new[] { "event", "summer", "2026-07-21T09:00:00-04:00", "2026-07-21T10:00:00-04:00", "false", "America/New_York", "", "", "", "", "", "", "", "summer" },
+ new[] { "event", "winter", "2026-12-21T09:00:00-05:00", "2026-12-21T10:00:00-05:00", "false", "America/New_York", "", "", "", "", "", "", "", "winter" },
+ new[] { "event", "overlap", "2026-11-01T01:30:00-05:00", "", "false", "America/New_York", "", "", "", "", "", "", "", "overlap" },
+ new[] { "event", "gap", "2026-03-08T02:30:00-05:00", "", "false", "America/New_York", "", "", "", "", "", "", "", "gap" },
+ new[] { "event", "utc", "2026-10-21T00:00:00Z", "", "false", "", "", "", "", "", "", "", "", "utc" },
+ new[] { "event", "float", "2026-10-21T09:00:00", "", "false", "", "", "", "", "", "", "", "", "float" },
+ new[] { "todo", "task", "", "2026-10-21T09:00:00", "false", "", "", "", "", "", "", "", "", "todo" },
+ new[] { "event", "a,b;c\\d\nsecond", "2026-10-21T09:00:00Z", "", "false", "", "", "x,y;z\\q\nagain",
+ "name ", "one \ntwo ", "FREQ=WEEKLY;COUNT=2",
+ "EXDATE:20261028T090000Z\nEXDATE:20261104T090000Z", "RECURRENCE-ID:20261021T090000Z", "details" },
+ new[] { "event", "uri", "2026-10-21T09:00:00Z", "", "false", "", "", "", "urn:organizer:abc", "", "", "", "", "uri" },
+ };
+ var original = Csv([ICalendar.Columns, .. rows]);
+ var output = await Convert(original, "csv", "ics");
+ var back = Rows(await File.ReadAllTextAsync(await Convert(await File.ReadAllTextAsync(output, TestContext.Current.CancellationToken),
+ "ics", "csv"), TestContext.Current.CancellationToken));
+ Assert.Equal(rows.Length + 1, back.Count);
+ for (var i = 0; i < rows.Length; i++)
+ Assert.Equal(rows[i], back[i + 1]);
+ }
+
+ [Fact]
+ public async Task Noncanonical_rows_follow_loss_and_offset_rules()
+ {
+ var rows = new[]
+ {
+ new[] { "event", "generated", "2026-10-21T09:00:00Z", "", "false", "", "", "", "", "", "", "", "", "" },
+ new[] { "event", "bad end", "2026-10-21T09:00:00Z", "tomorrow", "false", "", "", "", "", "", "", "", "", "bad" },
+ new[] { "event", "floating end", "2026-10-21T09:00:00+09:00", "2026-10-21T10:00:00", "false", "Asia/Seoul", "", "", "", "", "", "", "", "floating" },
+ new[] { "event", "overlap front", "2026-11-01T01:30:00-04:00", "", "false", "America/New_York", "", "", "", "", "", "", "", "front" },
+ new[] { "event", "unknown", "2026-10-21T09:00:00+09:00", "", "false", "Unknown/Area", "", "", "", "", "", "", "", "unknown" },
+ new[] { "event", "numeric", "2026-10-21T09:00:00+09:00", "", "false", "", "", "", "", "", "", "", "", "numeric" },
+ new[] { "event", "invalid exdates", "2026-10-21T09:00:00Z", "", "false", "", "", "", "", "",
+ "", "EXDATEX:20261028T090000\nEXDATE :20261029T090000", "", "exdates" },
+ new[] { "event", "missing start", "", "2026-10-21T09:00:00Z", "false", "", "", "", "", "", "", "", "", "missing" },
+ };
+ var path = await Convert(Csv([ICalendar.Columns, .. rows]), "csv", "ics");
+ var back = await ReadCalendar(await File.ReadAllTextAsync(path, TestContext.Current.CancellationToken));
+ Assert.Equal(8, back.Count);
+ Assert.EndsWith("@filee", Cell(back, 1, "UID"));
+ Assert.Equal("", Cell(back, 2, "End"));
+ Assert.Equal("2026-10-21T10:00:00+09:00", Cell(back, 3, "End"));
+ Assert.Equal("2026-11-01T01:30:00-05:00", Cell(back, 4, "Start"));
+ Assert.Equal("", Cell(back, 5, "Time zone"));
+ Assert.Equal("2026-10-21T00:00:00Z", Cell(back, 5, "Start"));
+ Assert.Equal("2026-10-21T00:00:00Z", Cell(back, 6, "Start"));
+ Assert.Equal("", Cell(back, 7, "EXDATE"));
+ }
+
+ [Fact]
+ public async Task Fixed_zone_uses_earliest_start_or_todo_end_and_reverts_historical_offsets()
+ {
+ var row1 = new[] { "todo", "old", "", "1985-01-01T09:00:00+05:30", "false", "Asia/Kathmandu", "", "", "", "", "", "", "", "old" };
+ var row2 = new[] { "event", "new", "2026-01-01T09:00:00+05:45", "", "false", "Asia/Kathmandu", "", "", "", "", "", "", "", "new" };
+ var solo = await File.ReadAllTextAsync(await Convert(Csv(ICalendar.Columns, row1),
+ "csv", "ics"), TestContext.Current.CancellationToken);
+ Assert.Contains("TZOFFSETFROM:+0530\r\nTZOFFSETTO:+0530", solo);
+ var oldEvent = (string[])row1.Clone();
+ oldEvent[0] = "event";
+ oldEvent[2] = oldEvent[3];
+ var output = await File.ReadAllTextAsync(await Convert(Csv(ICalendar.Columns, oldEvent, row2),
+ "csv", "ics"), TestContext.Current.CancellationToken);
+ Assert.Contains("TZOFFSETFROM:+0530\r\nTZOFFSETTO:+0530", output);
+ var back = await ReadCalendar(output);
+ Assert.EndsWith("+05:30", Cell(back, 2, "Start"));
+ }
+
+ [Fact]
+ public void Timezone_builder_observes_transition_wall_times_and_validates_rules()
+ {
+ var utc = new DateTimeOffset(2026, 7, 1, 0, 0, 0, TimeSpan.Zero);
+ var newYork = ICalendar.BuildVTimeZone(TimeZoneInfo.FindSystemTimeZoneById("America/New_York"), utc);
+ Assert.Contains("BEGIN:DAYLIGHT\r\nDTSTART:20260308T020000\r\nTZOFFSETFROM:-0500\r\nTZOFFSETTO:-0400\r\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU", newYork);
+ Assert.Contains("BEGIN:STANDARD\r\nDTSTART:20261101T020000\r\nTZOFFSETFROM:-0400\r\nTZOFFSETTO:-0500\r\nRRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU", newYork);
+ var berlin = ICalendar.BuildVTimeZone(TimeZoneInfo.FindSystemTimeZoneById("Europe/Berlin"), utc);
+ Assert.Contains("BEGIN:DAYLIGHT\r\nDTSTART:20260329T020000\r\nTZOFFSETFROM:+0100\r\nTZOFFSETTO:+0200\r\nRRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU", berlin);
+ Assert.Contains("BEGIN:STANDARD\r\nDTSTART:20261025T030000\r\nTZOFFSETFROM:+0200\r\nTZOFFSETTO:+0100\r\nRRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU", berlin);
+ var rules = new[]
+ {
+ TimeZoneInfo.AdjustmentRule.CreateAdjustmentRule(
+ new DateTime(2020, 1, 1), new DateTime(2030, 12, 31), TimeSpan.FromHours(1),
+ TimeZoneInfo.TransitionTime.CreateFixedDateRule(new DateTime(1, 1, 1, 2, 0, 0), 3, 21),
+ TimeZoneInfo.TransitionTime.CreateFixedDateRule(new DateTime(1, 1, 1, 3, 0, 0), 9, 21)),
+ };
+ var custom = TimeZoneInfo.CreateCustomTimeZone("Fixed/Date", TimeSpan.FromHours(1), "Fixed/Date", "Standard", "Daylight", rules);
+ var fixedRule = ICalendar.BuildVTimeZone(custom, utc);
+ Assert.Contains("BYMONTH=3;BYMONTHDAY=21", fixedRule);
+ Assert.Contains("BYMONTH=9;BYMONTHDAY=21", fixedRule);
+ }
+
+ [Fact]
+ public void Format_registration_and_new_data_donut_include_ics_and_ical()
+ {
+ Assert.Equal("ics", FormatRegistry.Detect("x.ics")?.Id);
+ Assert.Equal("ics", FormatRegistry.Detect("x.ICAL")?.Id);
+ var profile = BuiltInData.CreateProfiles().Single(p => p.Id == "data");
+ Assert.Contains("ics", profile.Extensions);
+ Assert.Contains("ical", profile.Extensions);
+ }
+}
From d8813c8bacb99e8305cccf60ee7ba888c7353e40 Mon Sep 17 00:00:00 2001
From: SeoJCalive <180237232+SeoJCalive@users.noreply.github.com>
Date: Thu, 22 Oct 2026 10:19:37 +0900
Subject: [PATCH 2/3] Use Moscow in the historical-offset iCalendar test
Windows has no history for Nepal Standard Time (always +05:45), so the
1985 +05:30 case failed there. Moscow (+04:00 in 2013, +03:00 now) has
the same history on Windows and in the IANA database.
---
tests/Filee.Engines.Tests/ICalendarTests.cs | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/tests/Filee.Engines.Tests/ICalendarTests.cs b/tests/Filee.Engines.Tests/ICalendarTests.cs
index cac202dd0..40321b933 100644
--- a/tests/Filee.Engines.Tests/ICalendarTests.cs
+++ b/tests/Filee.Engines.Tests/ICalendarTests.cs
@@ -414,19 +414,19 @@ public async Task Noncanonical_rows_follow_loss_and_offset_rules()
[Fact]
public async Task Fixed_zone_uses_earliest_start_or_todo_end_and_reverts_historical_offsets()
{
- var row1 = new[] { "todo", "old", "", "1985-01-01T09:00:00+05:30", "false", "Asia/Kathmandu", "", "", "", "", "", "", "", "old" };
- var row2 = new[] { "event", "new", "2026-01-01T09:00:00+05:45", "", "false", "Asia/Kathmandu", "", "", "", "", "", "", "", "new" };
+ var row1 = new[] { "todo", "old", "", "2013-01-01T09:00:00+04:00", "false", "Europe/Moscow", "", "", "", "", "", "", "", "old" };
+ var row2 = new[] { "event", "new", "2026-01-01T09:00:00+03:00", "", "false", "Europe/Moscow", "", "", "", "", "", "", "", "new" };
var solo = await File.ReadAllTextAsync(await Convert(Csv(ICalendar.Columns, row1),
"csv", "ics"), TestContext.Current.CancellationToken);
- Assert.Contains("TZOFFSETFROM:+0530\r\nTZOFFSETTO:+0530", solo);
+ Assert.Contains("TZOFFSETFROM:+0400\r\nTZOFFSETTO:+0400", solo);
var oldEvent = (string[])row1.Clone();
oldEvent[0] = "event";
oldEvent[2] = oldEvent[3];
var output = await File.ReadAllTextAsync(await Convert(Csv(ICalendar.Columns, oldEvent, row2),
"csv", "ics"), TestContext.Current.CancellationToken);
- Assert.Contains("TZOFFSETFROM:+0530\r\nTZOFFSETTO:+0530", output);
+ Assert.Contains("TZOFFSETFROM:+0400\r\nTZOFFSETTO:+0400", output);
var back = await ReadCalendar(output);
- Assert.EndsWith("+05:30", Cell(back, 2, "Start"));
+ Assert.EndsWith("+04:00", Cell(back, 2, "Start"));
}
[Fact]
From 1c064631c41f0d5fb1b000ac77d63a2c60288966 Mon Sep 17 00:00:00 2001
From: SeoJCalive <180237232+SeoJCalive@users.noreply.github.com>
Date: Thu, 22 Oct 2026 15:48:30 +0900
Subject: [PATCH 3/3] Keep CSV cells from adding their own iCalendar lines
A CSV cell could break out of its property when converting CSV to ICS:
a line break in UID or RRULE, or extra lines in the EXDATE and
Recurrence ID columns, became content lines of their own (an extra
VEVENT, an unclosed VALARM).
- UID, RRULE and Time zone with CR or LF are dropped (UID falls back to
the generated one); a time zone with ':', ';' or '"' is dropped too.
- EXDATE and Recurrence ID columns keep only EXDATE / RECURRENCE-ID
lines.
- Organizer and attendee cells lose CR/LF, and quotes are removed from
the CN display name.
Filee's own ICS -> CSV output writes those three cells on one line, so
round trips are unchanged.
---
src/Filee.Engines/Data/ICalendar.cs | 31 ++---
tests/Filee.Engines.Tests/ICalendarTests.cs | 118 ++++++++++++++++++++
2 files changed, 136 insertions(+), 13 deletions(-)
diff --git a/src/Filee.Engines/Data/ICalendar.cs b/src/Filee.Engines/Data/ICalendar.cs
index 9bdebe857..55b984857 100644
--- a/src/Filee.Engines/Data/ICalendar.cs
+++ b/src/Filee.Engines/Data/ICalendar.cs
@@ -418,6 +418,7 @@ private static string DateLine(string name, string text, string zone, TimeZoneIn
private static string PersonLine(string name, string cell)
{
+ cell = cell.Replace("\r", "").Replace("\n", "");
var marker = cell.LastIndexOf(" <", StringComparison.Ordinal);
var display = marker >= 0 && cell.EndsWith('>') ? cell[..marker] : "";
var address = display.Length > 0 ? cell[(marker + 2)..^1] : cell;
@@ -425,6 +426,7 @@ private static string PersonLine(string name, string cell)
address = "mailto:" + address;
if (display.Length == 0)
return name + ":" + address;
+ display = display.Replace("\"", "");
if (display.IndexOfAny([';', ':', ',']) >= 0)
display = "\"" + display + "\"";
return $"{name};CN={display}:{address}";
@@ -440,18 +442,19 @@ public static string Write(List components)
foreach (var component in components)
{
var cells = component.Cells!;
- if (cells[5].Length == 0)
+ var zoneId = cells[5].IndexOfAny(['\r', '\n', ':', ';', '"']) < 0 ? cells[5] : "";
+ if (zoneId.Length == 0)
continue;
try
{
- var zone = TimeZoneInfo.FindSystemTimeZoneById(cells[5]);
+ var zone = TimeZoneInfo.FindSystemTimeZoneById(zoneId);
var parsedStart = TryCsvDate(cells[2], out var start, out var startDate);
var hasStart = cells[2].Length > 0 && parsedStart && !startDate;
var candidate = hasStart
? start : TryCsvDate(cells[3], out var end, out var endDate) && !endDate ? end : (DateTimeOffset?)null;
- if (candidate is { } instant && (!zones.TryGetValue(cells[5], out var previous)
+ if (candidate is { } instant && (!zones.TryGetValue(zoneId, out var previous)
|| hasStart && !previous.HasStart || hasStart == previous.HasStart && instant < previous.Instant))
- zones[cells[5]] = (zone, instant, hasStart);
+ zones[zoneId] = (zone, instant, hasStart);
}
catch (TimeZoneNotFoundException) { }
catch (InvalidTimeZoneException) { }
@@ -463,21 +466,22 @@ public static string Write(List components)
foreach (var component in components)
{
var cells = component.Cells!;
+ var zoneId = cells[5].IndexOfAny(['\r', '\n', ':', ';', '"']) < 0 ? cells[5] : "";
TimeZoneInfo? zone = null;
- if (zones.TryGetValue(cells[5], out var definition))
+ if (zones.TryGetValue(zoneId, out var definition))
zone = definition.Zone;
Line(output, "BEGIN:" + component.Type);
- var uid = cells[13].Length == 0
+ var uid = cells[13].IndexOfAny(['\r', '\n']) >= 0 || cells[13].Length == 0
? Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join('\0', cells))))[..16] + "@filee"
: cells[13];
Line(output, "UID:" + uid);
Line(output, "DTSTAMP:" + DateTime.UtcNow.ToString("yyyyMMdd'T'HHmmss'Z'", CultureInfo.InvariantCulture));
if (cells[1].Length > 0)
Line(output, "SUMMARY:" + Escape(cells[1]));
- var start = DateLine("DTSTART", cells[2], cells[5], zone);
+ var start = DateLine("DTSTART", cells[2], zoneId, zone);
if (start.Length > 0)
Line(output, start);
- var end = DateLine(component.Type == "VTODO" ? "DUE" : "DTEND", cells[3], cells[5], zone);
+ var end = DateLine(component.Type == "VTODO" ? "DUE" : "DTEND", cells[3], zoneId, zone);
if (end.Length > 0)
Line(output, end);
if (cells[6].Length > 0)
@@ -488,12 +492,13 @@ public static string Write(List components)
Line(output, PersonLine("ORGANIZER", cells[8]));
foreach (var attendee in cells[9].Split('\n', StringSplitOptions.RemoveEmptyEntries))
Line(output, PersonLine("ATTENDEE", attendee));
- if (cells[10].Length > 0)
+ if (cells[10].Length > 0 && cells[10].IndexOfAny(['\r', '\n']) < 0)
Line(output, "RRULE:" + cells[10]);
- foreach (var line in cells[11].Split('\n', StringSplitOptions.RemoveEmptyEntries))
- Line(output, line);
- foreach (var line in cells[12].Split('\n', StringSplitOptions.RemoveEmptyEntries))
- Line(output, line);
+ foreach (var (index, name) in new[] { (11, "EXDATE"), (12, "RECURRENCE-ID") })
+ foreach (var line in cells[index].Split(["\r\n", "\n", "\r"], StringSplitOptions.RemoveEmptyEntries))
+ if (line.StartsWith(name + ":", StringComparison.OrdinalIgnoreCase)
+ || line.StartsWith(name + ";", StringComparison.OrdinalIgnoreCase))
+ Line(output, line);
Line(output, "END:" + component.Type);
}
Line(output, "END:VCALENDAR");
diff --git a/tests/Filee.Engines.Tests/ICalendarTests.cs b/tests/Filee.Engines.Tests/ICalendarTests.cs
index 40321b933..dc6a6e188 100644
--- a/tests/Filee.Engines.Tests/ICalendarTests.cs
+++ b/tests/Filee.Engines.Tests/ICalendarTests.cs
@@ -324,6 +324,124 @@ public async Task Output_contract_uid_stamp_bom_folding_timezone_and_text_escapi
Assert.Equal("Kim; A ", Cell(roundtrip, 1, "Attendees"));
}
+ [Fact]
+ public async Task Injected_rrule_and_exdate_cannot_create_events_or_alarms()
+ {
+ var row = Enumerable.Repeat("", ICalendar.Columns.Length).ToArray();
+ row[1] = "Safe";
+ row[2] = "2026-10-22T09:00:00Z";
+ row[10] = "FREQ=DAILY\nBEGIN:VALARM";
+ row[11] = "EXDATE:20261022T090000Z\r\nEND:VEVENT\nBEGIN:VEVENT\rSUMMARY:Injected"
+ + "\nexdate;VALUE=DATE:20261023\rEXDATEX:20261024T090000Z";
+ var second = (string[])row.Clone();
+ second[1] = "Second";
+ second[10] = "FREQ=WEEKLY\rX-INJECT:1";
+ second[11] = "";
+ var text = (await File.ReadAllTextAsync(await Convert(Csv(ICalendar.Columns, row, second),
+ "csv", "ics"), TestContext.Current.CancellationToken)).Replace("\r\n ", "");
+ var lines = text.Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
+ Assert.Equal(2, lines.Count(line => line == "BEGIN:VEVENT"));
+ Assert.DoesNotContain("VALARM", text);
+ Assert.DoesNotContain("Injected", text);
+ Assert.DoesNotContain("RRULE:", text);
+ Assert.Contains("EXDATE:20261022T090000Z", lines);
+ Assert.Contains("exdate;VALUE=DATE:20261023", lines);
+ Assert.DoesNotContain("EXDATEX:20261024T090000Z", lines);
+ var stack = new Stack();
+ foreach (var line in lines)
+ {
+ if (line.StartsWith("BEGIN:", StringComparison.Ordinal))
+ stack.Push(line[6..]);
+ if (line.StartsWith("END:", StringComparison.Ordinal))
+ Assert.Equal(stack.Pop(), line[4..]);
+ }
+ Assert.Empty(stack);
+ var names = new HashSet(StringComparer.Ordinal)
+ {
+ "BEGIN", "END", "VERSION", "PRODID", "UID", "DTSTAMP", "SUMMARY",
+ "DTSTART", "DTEND", "DUE", "LOCATION", "DESCRIPTION", "ORGANIZER",
+ "ATTENDEE", "RRULE", "EXDATE", "RECURRENCE-ID", "TZID",
+ "TZOFFSETFROM", "TZOFFSETTO", "TZNAME", "RDATE",
+ };
+ foreach (var line in lines)
+ {
+ Assert.Matches(@"^[A-Za-z-]+(?:;[^:\r\n]+)?:[^\r\n]*$", line);
+ Assert.Contains(line.Split([';', ':'], 2)[0].ToUpperInvariant(), names);
+ }
+ }
+
+ [Theory]
+ [InlineData("bad\nBEGIN:VEVENT")]
+ [InlineData("bad\rEND:VEVENT")]
+ public async Task Uid_with_line_break_uses_generated_uid(string uid)
+ {
+ var text = (await File.ReadAllTextAsync(await Convert(Csv(["Title", "Start", "UID"],
+ ["Safe", "2026-10-21", uid]), "csv", "ics"), TestContext.Current.CancellationToken))
+ .Replace("\r\n ", "");
+ Assert.Matches(@"(?m)^UID:[0-9a-f]{16}@filee\r?$", text);
+ Assert.Contains("SUMMARY:Safe\r\nDTSTART;VALUE=DATE:20261021", text);
+ Assert.Equal(1, text.Split("BEGIN:VEVENT").Length - 1);
+ }
+
+ [Theory]
+ [InlineData("Asia/Seoul\nX-INJECT:1")]
+ [InlineData("Asia/Seoul\rX-INJECT:1")]
+ [InlineData("Asia/Seoul:X")]
+ [InlineData("Asia/Seoul;X")]
+ [InlineData("Asia/Seoul\"X")]
+ public async Task Invalid_timezone_cell_is_discarded_for_dates_and_definitions(string timezone)
+ {
+ var text = await File.ReadAllTextAsync(await Convert(Csv(["Title", "Start", "Time zone"],
+ ["Safe", "2026-10-21T09:00:00+09:00", timezone]), "csv", "ics"),
+ TestContext.Current.CancellationToken);
+ Assert.DoesNotContain("TZID", text);
+ Assert.DoesNotContain("VTIMEZONE", text);
+ Assert.Contains("SUMMARY:Safe\r\nDTSTART:20261021T000000Z", text);
+ Assert.Equal(1, text.Split("BEGIN:VEVENT").Length - 1);
+ }
+
+ [Fact]
+ public async Task Invalid_timezone_cannot_borrow_another_events_valid_zone()
+ {
+ var text = (await File.ReadAllTextAsync(await Convert(Csv(
+ ["Title", "Start", "Time zone"],
+ ["Valid", "2026-10-21T09:00:00+09:00", "Asia/Seoul"],
+ ["Invalid", "2026-10-21T09:00:00+09:00", "Asia/Seoul;X"]),
+ "csv", "ics"), TestContext.Current.CancellationToken)).Replace("\r\n ", "");
+ Assert.Contains("BEGIN:VTIMEZONE\r\nTZID:Asia/Seoul", text);
+ Assert.Contains("SUMMARY:Valid\r\nDTSTART;TZID=Asia/Seoul:20261021T090000", text);
+ Assert.Contains("SUMMARY:Invalid\r\nDTSTART:20261021T000000Z", text);
+ }
+
+ [Fact]
+ public async Task Recurrence_id_accepts_only_its_own_content_lines()
+ {
+ var text = (await File.ReadAllTextAsync(await Convert(Csv(["Title", "Start", "Recurrence ID"],
+ ["Safe", "2026-10-21", "RECURRENCE-ID;TZID=Asia/Seoul:20261021T090000\nX-INJECT:1"
+ + "\rrecurrence-id:20261022T090000Z\r\nRECURRENCE-IDX:20261023T090000Z"]),
+ "csv", "ics"), TestContext.Current.CancellationToken)).Replace("\r\n ", "");
+ Assert.Contains("RECURRENCE-ID;TZID=Asia/Seoul:20261021T090000\r\n", text);
+ Assert.Contains("recurrence-id:20261022T090000Z\r\n", text);
+ Assert.DoesNotContain("X-INJECT", text);
+ Assert.DoesNotContain("RECURRENCE-IDX", text);
+ }
+
+ [Fact]
+ public async Task People_remove_quotes_and_line_breaks_without_new_content_lines()
+ {
+ var text = (await File.ReadAllTextAsync(await Convert(Csv(
+ ["Title", "Start", "Organizer", "Attendees"],
+ ["Safe", "2026-10-21", "Lee\r\nX-INJECT:1, B ",
+ "Kim \"A\", B \r\nPark\rInjected, C "]),
+ "csv", "ics"), TestContext.Current.CancellationToken)).Replace("\r\n ", "");
+ Assert.Contains("ORGANIZER;CN=\"LeeX-INJECT:1, B\":mailto:o@example.com\r\n", text);
+ Assert.Contains("ATTENDEE;CN=\"Kim A, B\":mailto:a@example.com\r\n", text);
+ Assert.Contains("ATTENDEE;CN=\"ParkInjected, C\":mailto:b@example.com\r\n", text);
+ Assert.DoesNotContain("\r\nX-INJECT:", text);
+ Assert.DoesNotContain("\r\nInjected,", text);
+ Assert.Equal(2, text.Split("ATTENDEE;").Length - 1);
+ }
+
[Fact]
public async Task Long_combining_sequence_folds_within_seventy_five_octets_and_roundtrips()
{