From 89ebb90b6b64f7eb11013ba5b3767a9b84f60408 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 15:45:58 +0000 Subject: [PATCH 01/10] specs: add csv_import_export project overview Start the CSV import/export spec project: the project overview capturing the requested columns, import flow (batch ID, warnings/errors, two-phase scan-then-import), export flow, and the Settings section. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019uJ5vvSZQeALLcULAatrfX --- .../csv_import_export/project_overview.md | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 specs/projects/csv_import_export/project_overview.md diff --git a/specs/projects/csv_import_export/project_overview.md b/specs/projects/csv_import_export/project_overview.md new file mode 100644 index 00000000..8d1550fe --- /dev/null +++ b/specs/projects/csv_import_export/project_overview.md @@ -0,0 +1,59 @@ +--- +status: draft +--- + +# CSV Import/Export + +I want to add a CSV import/export functionality. + +Just for the meetings table. + +Columns: It's important we use these exact column names. + +- columns: `id,title,created,summary,notes,transcript` (used for export) +- Allow some flexibility on import without any UI (should have test case for a csv with these columns and no id/title/created) + - `document_id` -> `id` + - `document_title` -> `title` + - `document_created` -> `created` + +## Import + +- Add "import_batch" field to meeting data model, default null. When we import a CSV we generate a batch ID (epoch-time), and set this same value on all. This lets us un-import the CSV. +- Should ignore extra columns. +- Flow/Errors/Warnings + - Warnings and errors + - Critical errors if CSV is missing key fields: ID, title, or created. Or if any rows are missing these (or invalid values in these). + - Warning: if a meeting doesn't have at least one of summary/notes/transcript, it's a warning. + - Warning: on IDs that already exist in DB, something like "N meetings already exist in your database, these will be skipped." + - Flow + - First scan into a data structure. + - Scan data into data field + - Scan also collects error/warning summary. + - Shows an alert to user if warnings/errors are non-zero. If critical, the alert blocks. If only warnings it shows a warning with clear description of the issue(s) (default action is cancel, secondary to "Continue"). + - Import (if no errors, or they click Continue on the alert) + - Use the in-memory data structure, import to DB. No new scan of file, use the exact data we already parsed. +- Create "App/ImportingExporting.md", a short guide for this explaining the process and fields we expect in CSV. Can link to it from the app. +- Note: some reasonable parsing flexibility — the CSVs could be coming from other apps/scripts. IDs just need to be unique, not a UUID (do we support this?). + +## Export + +- Exports meetings to CSV, with save-dialog to pick location. +- Has header row `id,title,created,summary,notes,transcript` +- Filename `Biscotti_export_{timestamp}.csv` +- Sorted newest first +- Async: saves to tmp returning a PATH, callback when done (UI shows spinner). The UI will offer an option on where to save it, which does the file move. +- Export transcript should use the same formatter as the "Copy" button does. + +## General + +- CSV will need newlines: escaping is important +- Date format: `2026-01-03T14:26:42.017Z` — whatever format this is, we should use and support. + +## Settings + +- New section below custom vocab +- Section title "Import/Export" +- "Learn more" links to the GitHub "App/ImportingExporting.md" on main. Usual "green text" link format we use in settings. +- 2 rows + - "Import Meetings", "Import meetings from other apps, via CSV. Learn more.", [Import], button opens file selector dialog + - "Export Meetings", "Export all meetings to CSV. Learn more." [Export], button shows spinner while generating file, then save dialog when ready. From 5b74d38ca9ca36fc7422cd99c7fabd03fa5f7760 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 16:00:10 +0000 Subject: [PATCH 02/10] specs: draft functional spec for CSV import/export Covers the CSV contract (columns, aliases, dates, escaping), the scan-then-commit import flow with its error/warning taxonomy, the shared bracketed transcript format used by export, Copy and import parsing, the async export flow, the Settings section, and the user-facing guide. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019uJ5vvSZQeALLcULAatrfX --- .../csv_import_export/functional_spec.md | 335 ++++++++++++++++++ 1 file changed, 335 insertions(+) create mode 100644 specs/projects/csv_import_export/functional_spec.md diff --git a/specs/projects/csv_import_export/functional_spec.md b/specs/projects/csv_import_export/functional_spec.md new file mode 100644 index 00000000..60737370 --- /dev/null +++ b/specs/projects/csv_import_export/functional_spec.md @@ -0,0 +1,335 @@ +--- +status: draft +--- + +# Functional Spec: CSV Import/Export + +Meetings can be exported to a CSV file and imported from one. The feature exists so +users can get their data out of Biscotti, and get meeting notes *in* from other apps +(Granola, Otter, Notion exports, ad-hoc scripts). Only the meetings table is covered — +no tags, people, audio, or calendar data. + +Entry points are two rows in a new **Import/Export** section in Settings. + +## 1. The CSV contract + +### 1.1 Columns + +The canonical column set, in this exact order, is what export writes as its header row: + +``` +id,title,created,summary,notes,transcript +``` + +| Column | Meaning | Required on import | +|---|---|---| +| `id` | Stable unique identifier for the meeting | Yes | +| `title` | Meeting title | Yes | +| `created` | When the meeting happened | Yes | +| `summary` | Markdown summary | No | +| `notes` | User notes (markdown) | No | +| `transcript` | Plain-text transcript (see §4) | No | + +### 1.2 Import column resolution + +Import needs a header row. Header cells are matched leniently, since CSVs arrive from +other tools: + +- A UTF-8 BOM at the start of the file is stripped before parsing. +- Header cells are trimmed of surrounding whitespace and matched case-insensitively. +- These aliases resolve to canonical columns: + - `document_id` → `id` + - `document_title` → `title` + - `document_created` → `created` +- Any column that is neither canonical nor a known alias is ignored entirely. +- If both a canonical name and its alias are present (e.g. `id` and `document_id`), + the canonical column wins and a warning is recorded. + +A CSV whose only identity columns are `document_id`, `document_title`, and +`document_created` — with no `id`/`title`/`created` at all — must import successfully. +(Explicit test case.) + +### 1.3 Dates + +Export writes: `2026-01-03T14:26:42.017Z` — ISO-8601, UTC, milliseconds, `Z` suffix. + +Import accepts, in this order: + +1. ISO-8601 with fractional seconds and a zone: `2026-01-03T14:26:42.017Z`, + `2026-01-03T09:26:42.017-05:00` +2. ISO-8601 without fractional seconds: `2026-01-03T14:26:42Z`, `…-05:00` +3. A bare calendar date `2026-01-03` — interpreted as local midnight +4. A bare integer — epoch time. Values below `100000000000` (1e11) are read as + **seconds**; values at or above it are read as **milliseconds**. (1e11 seconds is + the year 5138, so the split is unambiguous in practice.) + +Anything else is a critical error (§3.1). + +### 1.4 Escaping (both directions) + +RFC 4180. A field is quoted when it contains a comma, a double quote, CR, or LF. +Embedded double quotes are doubled (`""`). Quoted fields may span multiple lines — +this matters because transcripts, notes, and summaries routinely contain newlines. + +- **Export** separates rows with CRLF and writes UTF-8 **without** a BOM. Newlines + *inside* a field are preserved as-is (LF). +- **Import** accepts CRLF, LF, or CR row separators, and tolerates a trailing newline + at end of file. The file must be valid UTF-8. + +## 2. Import + +### 2.1 Two-phase flow + +Import is strictly **scan, then commit** — the file is read exactly once. + +1. **Scan.** Read and parse the whole file into an in-memory structure: one parsed + record per row, plus a collected summary of errors and warnings. Nothing is + written to the database during the scan. +2. **Review.** If the scan produced any errors or warnings, show an alert (§3.3). + Critical errors block; warnings offer Cancel (default) / Continue. +3. **Commit.** Insert the already-parsed records. The file is *not* re-read, re-parsed, + or re-validated — the exact data produced by the scan is what lands in the database. + +### 2.2 What an imported row becomes + +For each row that survives the scan and is not skipped: + +- **`id`** — trimmed. If it parses as a UUID, it becomes the meeting's `id` directly. + If it does not, a fresh UUID is minted for the meeting and the raw string is stored + in a new `externalID` field. This is how non-UUID IDs from other apps are supported + while keeping the UUID primary key. +- **`title`** — trimmed, stored as the meeting title. `editedTitle` is set `true` + (imported titles are authored content and must never be overwritten by calendar + association). +- **`created`** — parsed per §1.3, stored as the meeting's `createdAt`. `startDate` and + `endDate` stay nil, so the meeting's effective date (`startDate ?? createdAt`) is the + imported value and it sorts correctly in the list. +- **`summary`** — stored as the meeting summary. When non-empty, `editedSummary` is set + `true` (imported summaries are authored content). +- **`notes`** — stored as the meeting notes. +- **`transcript`** — when non-blank, parsed per §4 into a `TranscriptRecord` with + `transcriptionMethodId` `"imported"` and made the meeting's preferred transcript. + When blank, no transcript record is created. +- **`importBatch`** — set to this import's batch ID (§2.3) on every meeting in the run. +- No audio files, no recording duration, no calendar snapshot, no participants or tags. + +Imported meetings are indexed for search exactly like recorded ones. + +### 2.3 Import batch + +A new nullable `importBatch` field on the meeting model, default null (all existing and +all recorded meetings have null). + +Every meeting created by a single import run gets the same batch ID: epoch +**milliseconds** at the moment the commit starts. Milliseconds rather than seconds so +two imports in quick succession cannot collide; if the generated value somehow matches +an existing batch, it is incremented until unique. + +The field exists purely to make a future "undo this import" possible. **No un-import UI +is built in this project** and nothing reads the field yet. + +### 2.4 Duplicate handling + +A row is a duplicate, and is skipped, when: + +- Its `id` is a UUID that already exists as a meeting ID in the database, **or** +- Its `id` is a non-UUID string that already exists as an `externalID` in the database, + **or** +- An earlier row in the same file already claimed that same ID — the **first** + occurrence wins, later ones are skipped. + +Skipped rows produce a warning (§3.2), never an error. Nothing existing is ever +updated, merged, or overwritten — import only ever inserts. + +### 2.5 No AI on import + +Auto-enhancements (summarization, speaker-name inference) run only after a recording is +transcribed. Imported meetings never enter that path, so nothing needs to suppress them +— an imported meeting behaves like any other meeting with no audio. + +## 3. Errors and warnings + +### 3.1 Critical errors (block the import) + +Any of these means nothing is imported: + +- The file cannot be read, or is not valid UTF-8. +- The file is empty or has no header row. +- After alias resolution, any of `id`, `title`, or `created` is missing from the header. +- The CSV is structurally malformed (e.g. an unterminated quoted field). +- **Any** row has a blank `id`, a blank `title`, or a `created` value that is missing or + unparseable per §1.3. + +Note that a single bad row blocks the entire file — this is deliberate: a partial import +of a file the user believed was clean is harder to reason about and harder to undo than a +rejection that names the bad rows. + +### 3.2 Warnings (import can proceed) + +- **No content:** a row where `summary`, `notes`, and `transcript` are all blank. + Reported as a count. +- **Already in the database:** rows skipped per §2.4. Reported as + "N meetings already exist in your database, these will be skipped." +- **Duplicate IDs within the file:** reported as a count, first-wins noted. +- **Ragged rows:** a row with a different field count than the header. Short rows are + padded with empty values; long rows have their extra fields dropped. Reported as a + count. (If padding leaves a required field blank, that becomes a critical error + per §3.1.) +- **Ambiguous columns:** both a canonical column and its alias present (§1.2). + +### 3.3 The review alert + +- **Any critical errors** → blocking alert. Title names the failure; body lists each + distinct problem with its count and up to 5 example row numbers. Single dismiss + button. Nothing is imported. +- **Warnings only** → warning alert. Body lists each warning with its count in plain + language. Buttons: **Cancel** (default action) and **Continue** (secondary). +- **Neither** → no alert; import proceeds immediately. + +Row numbers in messages are 1-based and count the header as row 1, so they match what +the user sees in a spreadsheet. + +### 3.4 The result alert + +After a commit completes, an alert reports what happened, e.g. +"Imported 42 meetings." — with a second line when anything was skipped: +"3 rows were skipped because those meetings already exist." + +If the commit itself fails (a database error), an alert reports the failure. A failed +commit leaves the database unchanged. + +## 4. Transcript text format + +One format, used for the transcript column on export, for the meeting detail **Copy** +button, and parsed on import. It is deliberately both human-readable and machine- +parseable. + +### 4.1 Rendering + +A speaker turn is a header line followed by the spoken text: + +``` +[0:23] Steve +Let's get started. + +[0:31] Priya +I pushed the fix this morning. +``` + +- Timestamp is `M:SS`, or `H:MM:SS` from one hour up (matching the app's existing + playback-time formatting). +- Speaker name is the assigned person's name where the speaker has been mapped, + otherwise the diarization label (`Speaker 0`) — the same resolution the transcript + view uses on screen. +- One blank line between turns. + +This replaces the current Copy output (`Steve 0:23` on the header line). The change is +intentional: the bracketed form is unambiguous to parse. + +### 4.2 Parsing + +Input is free text, which may be Biscotti's own format or plain text from another app. + +1. Split into lines on CRLF, LF, or CR. Trim each line. Skip blank lines. +2. A line matching `[] ` is a **header**: it sets the current speaker + name and current timestamp for every following line, until the next header. + Timestamps parse as `M:SS`, `MM:SS`, `H:MM:SS`, or `HH:MM:SS`. + - If the name portion contains a colon (`[0:23] Steve: hello there`), the text before + the first colon is the speaker name and the text after it is treated as a content + line — several other apps emit that shape. +3. Every non-header line becomes one transcript segment carrying the current speaker and + timestamp. **Line breaks make new segments** — no merging of consecutive lines. +4. Before any header is seen, the current speaker is `Unknown Speaker` and the current + timestamp is `0:00`. A plain transcript with no headers at all therefore imports as a + sequence of `Unknown Speaker` segments at `0:00` — which is all it can be. +5. Each distinct speaker name gets a sequential speaker ID (0, 1, 2 …) in order of first + appearance, so the existing speaker-mapping UI works on imported transcripts. +6. Segment `startTime` is the current header's timestamp; `endTime` equals `startTime` + (imported transcripts carry no durations). +7. A transcript that yields zero segments produces no transcript record. + +Round-trip is exact: export renders one header per segment, so parsing an exported +transcript reproduces the same segments, speakers, and times. + +## 5. Export + +### 5.1 Content + +- **Every** meeting in the database, no filtering. +- Sorted by effective date (`startDate ?? createdAt`) **descending** — newest first. +- Header row exactly `id,title,created,summary,notes,transcript`. + +Per row: + +| Column | Value | +|---|---| +| `id` | The meeting's UUID. `externalID` is **never** exported — our UUID is the identity we hand out. | +| `title` | The meeting title. | +| `created` | Effective date, formatted per §1.3 (`2026-01-03T14:26:42.017Z`). | +| `summary` | Summary markdown, empty when there is none. | +| `notes` | Notes markdown, empty when there is none. | +| `transcript` | The preferred transcript rendered per §4.1 with speaker names resolved. Empty when the meeting has no transcript. | + +A database with no meetings exports a header-only file. + +### 5.2 Flow + +Export is asynchronous and does not block the UI: + +1. The user presses **Export**. The button is replaced by a spinner. +2. Generation runs off the main actor and writes the CSV to a file in the temporary + directory, returning that path on completion. +3. On completion the spinner clears and a save dialog opens, pre-filled with the + filename `Biscotti_export_{timestamp}.csv` where `{timestamp}` is local time as + `yyyy-MM-dd-HHmmss` (e.g. `Biscotti_export_2026-09-01-142642.csv`). +4. Confirming moves the temporary file to the chosen location. Cancelling deletes the + temporary file. +5. A generation or move failure surfaces as an alert; the temporary file is cleaned up. + +## 6. Settings UI + +A new **Import/Export** section, placed after Custom Vocabulary and before Calendars. + +Two rows, each a title, a descriptive subtitle ending in a "Learn more" link, and a +trailing button: + +| Title | Subtitle | Button | +|---|---|---| +| Import Meetings | Import meetings from other apps, via CSV. *Learn more.* | **Import** | +| Export Meetings | Export all meetings to CSV. *Learn more.* | **Export** | + +- **Learn more** uses the app's established green (`.sage`) settings-link treatment and + opens `https://github.com/scosman/Biscotti/blob/main/App/ImportingExporting.md` + in the browser — the same pattern as the MCP help link. +- **Import** opens a file-selection dialog limited to `.csv`, single selection. Choosing + a file runs the flow in §2.1. +- **Export** shows a spinner in place of the button while generating, then opens the save + dialog (§5.2). Both buttons are disabled while an operation is in flight. + +## 7. Documentation + +A new `App/ImportingExporting.md`, short and user-facing, covering: + +- What can be imported and exported (meetings only, and which fields) +- The exact column names, the accepted aliases, and that extra columns are ignored +- Accepted date formats +- The transcript format, with a small example, and what happens to plain-text transcripts +- Duplicate handling (existing IDs are skipped, never overwritten) +- What the warnings and errors mean +- How export names and sorts its file + +## 8. Out of scope + +- Un-importing / undoing a batch (the `importBatch` field is stored for it, nothing more) +- Importing or exporting tags, participants, organizers, audio, or calendar data +- Updating or merging into existing meetings (import only inserts) +- Non-comma dialects (TSV, semicolon), other encodings, XLSX +- A progress bar or cancellation for long imports/exports (spinner only) +- Import from the menu bar, a drag-and-drop target, or a URL scheme + +## 9. Constraints + +- The whole file is held in memory during scan and commit; memory use is proportional to + file size. Acceptable for the expected scale (thousands of meetings, tens of MB). +- Import commits as one batch, after which the meeting list and search index refresh. +- Export generation runs off the main actor; the UI stays responsive throughout. From 8279d376e7f6b4b2865cadcd5850deb9c05555bb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 17:45:07 +0000 Subject: [PATCH 03/10] specs: add architecture and implementation plan for CSV import/export Architecture: a Foundation-only Formatting module (TimeFormatting moved down from DesignSystem, plus ISO-8601 and the shared "[0:23] Steve" transcript render/parse), an ImportExport module (RFC 4180 parser, pure scanner, chunk-streaming exporter), DataStore fields and APIs, and the Settings wiring. Also folds in two spec changes: misformatted rows are now a skip-with- warning rather than a file-level critical error, and a debug-build "Delete Imported Meetings" affordance. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019uJ5vvSZQeALLcULAatrfX --- .../csv_import_export/architecture.md | 566 ++++++++++++++++++ .../csv_import_export/functional_spec.md | 69 ++- .../csv_import_export/implementation_plan.md | 32 + 3 files changed, 649 insertions(+), 18 deletions(-) create mode 100644 specs/projects/csv_import_export/architecture.md create mode 100644 specs/projects/csv_import_export/implementation_plan.md diff --git a/specs/projects/csv_import_export/architecture.md b/specs/projects/csv_import_export/architecture.md new file mode 100644 index 00000000..54bdbf39 --- /dev/null +++ b/specs/projects/csv_import_export/architecture.md @@ -0,0 +1,566 @@ +--- +status: draft +--- + +# Architecture: CSV Import/Export + +Single architecture doc — the components are numerous but individually shallow (a CSV +state machine, two formatters, a scanner, an exporter, a settings section). None has +enough internal complexity to justify its own component design. + +## 1. Module layout + +Two new modules in `BiscottiKit`, plus additive changes to four existing ones. + +``` +Formatting (new) Foundation + DataStore. Pure, no UI, no I/O. + ├── TimeFormatting moved here verbatim from DesignSystem + ├── ISO8601Formatting CSV date render + lenient parse + └── TranscriptTextFormatting render + parse the "[0:23] Steve" format + +ImportExport (new) Foundation + DataStore + Formatting. No UI, no AppKit. + ├── CSVParser / CSVWriter RFC 4180 + ├── MeetingCSVImporter scan → ImportScanResult (pure; no store access) + └── MeetingCSVExporter streams the CSV to a temp file + +DataStore (changed) externalID + importBatch fields; import/export read+write API +AppCore (changed) owns the importer/exporter, exposes three async actions +SettingsUI (changed) new Import/Export section, panels, alerts +MeetingDetailUI (changed) Copy uses the shared renderer; local plainText deleted +MCPServer (changed) local TranscriptTextFormatter deleted; uses the shared one +DesignSystem (changed) depends on Formatting; TimeFormatting no longer declared here +``` + +Dependency direction stays acyclic: `Formatting → DataStore`, `ImportExport → +{DataStore, Formatting}`, `DesignSystem → {DataStore, Formatting}`, `AppCore → +ImportExport`, `SettingsUI → AppCore`. + +### 1.1 Why a `Formatting` module + +`TimeFormatting` is already pure Foundation — it imports nothing but `Foundation` and +happens to live in `DesignSystem`, which drags in SwiftUI. Three non-UI consumers now +need timestamp rendering (export, MCP, import parsing), so the enum moves down into a +Foundation-only module rather than being duplicated a third time (`MCPServer` already +carries a private copy today). + +`Formatting` depends on `DataStore` for `SegmentData` and the transcript draft types. +That mirrors `DesignSystem`, which already depends on `DataStore`. + +**Cost of the move:** `TimeFormatting` is referenced by 10 sources and 5 test files. +Each gains one `import Formatting` line — Swift does not re-export transitively, and +`@_exported import` is underscored API we should not adopt. The change is purely +mechanical and the compiler finds every site. + +## 2. Data model + +Two additive properties on `Meeting`: + +```swift +/// The row's `id` from an imported CSV when it was not a UUID. Nil for +/// recorded meetings and for imports whose ID parsed as a UUID. +public var externalID: String? + +/// Epoch milliseconds identifying the import run that created this meeting. +/// Nil for every recorded meeting. Written once, never read yet — it exists +/// so a future "undo this import" can find the batch. +public var importBatch: Int? +``` + +Both are optional with nil defaults, so SwiftData handles them without a migration +stage (the existing `DataStoreMigrationPlan` comment covers exactly this case). No +`DataStoreSchemaV2` is needed. + +`importBatch` is `Int?` holding epoch **milliseconds** — SwiftData stores it as an +integer, it sorts naturally, and it needs no formatter. + +### 2.1 Write model (defined in DataStore) + +The scanner produces these; `DataStore` consumes them. They live in `DataStore` so that +`Formatting` (which produces the segment drafts) and `ImportExport` (which produces the +meeting drafts) both depend *downward* onto them — defining them in `ImportExport` would +force `DataStore` to depend on `ImportExport` and create a cycle. + +```swift +public struct TranscriptSegmentDraft: Sendable, Equatable { + public let speakerID: Int + public let speakerLabel: String + public let startTime: TimeInterval + public let text: String +} + +public struct ImportedMeetingDraft: Sendable, Equatable { + public let meetingID: UUID // parsed UUID, or freshly minted + public let externalID: String? // raw id when it was not a UUID + public let title: String + public let created: Date + public let summary: String + public let notes: String + public let transcript: [TranscriptSegmentDraft] // empty = no transcript record +} + +/// Everything already in the database that an import must not duplicate. +public struct ExistingMeetingIdentity: Sendable, Equatable { + public let meetingIDs: Set + public let externalIDs: Set +} +``` + +### 2.2 Read model for export + +```swift +public struct MeetingExportData: Sendable, Equatable { + public let id: UUID + public let title: String + public let date: Date // startDate ?? createdAt + public let summary: String + public let notes: String + public let segments: [SegmentData] // preferred transcript, empty when none + public let speakerNames: [Int: String] // resolved person names by speaker ID +} +``` + +### 2.3 New DataStore methods + +```swift +// Import +public func existingMeetingIdentity() throws -> ExistingMeetingIdentity +public func nextImportBatchID(now: Date = Date()) throws -> Int +public func insertImportedMeetings( + _ drafts: [ImportedMeetingDraft], batchID: Int +) throws -> Int + +// Export +public func meetingIDsForExport() throws -> [UUID] // newest first +public func exportData(for ids: [UUID]) throws -> [MeetingExportData] + +// Debug-build bulk delete (functional spec §6.1) +public func importedMeetingCounts() throws -> (imported: Int, remaining: Int) +public func deleteImportedMeetings() throws -> Int +``` + +`existingMeetingIdentity()` fetches all meetings and maps `id` / `externalID` into two +sets. At the expected scale (thousands) this is a single cheap fetch; if it ever needs +to scale, `FetchDescriptor.propertiesToFetch` narrows it without changing the signature. + +`nextImportBatchID` returns `Int(now.timeIntervalSince1970 * 1000)`, incrementing while +a meeting with that exact `importBatch` already exists, so two imports inside the same +millisecond cannot share a batch. + +`insertImportedMeetings` creates, per draft: a `Meeting` (with `editedTitle = true`, +`editedSummary = !summary.isEmpty`, `importBatch = batchID`, `startDate`/`endDate` nil), +and when `transcript` is non-empty a `TranscriptRecord` with +`transcriptionMethodId = "imported"`, `language = ""`, `speakerCount` = distinct speaker +IDs, plus one `TranscriptSegmentRecord` per draft segment with `index` set in order and +`endTime = startTime`. `preferredTranscriptID` points at the new record. One `save()` at +the end of the batch. Returns the number of meetings inserted. + +`importedMeetingCounts()` runs two `fetchCount` calls with +`#Predicate { $0.importBatch != nil }` and its negation — no objects +materialized. `deleteImportedMeetings()` fetches the matching meetings, and for each one +removes its search-index entry (`searchIndex.removeMeeting(uuid:)`) before +`context.delete`, exactly as `delete(meetingID:)` does, then saves once. Transcripts, +segments, words, audio refs, and calendar snapshots go with them via the existing +`.cascade` delete rules. + +**Search index:** nothing extra is needed for insertion. `syncSearchIndex()` is lazy and driven by +SwiftData history at query time, so imported meetings are indexed on the next search. + +## 3. `Formatting` module + +### 3.1 `TimeFormatting` + +Moved from `DesignSystem` unchanged, along with its test file. `DesignSystem` gains a +dependency on `Formatting`; `AudioTransport.formatTime` keeps delegating to it. + +### 3.2 `ISO8601Formatting` + +```swift +public enum ISO8601Formatting { + /// "2026-01-03T14:26:42.017Z" — UTC, milliseconds, always. + public static func string(from date: Date) -> String + /// Lenient parse per functional spec §1.3. Nil when nothing matches. + public static func date(from string: String) -> Date? +} +``` + +Rendering uses `Date.ISO8601FormatStyle` with +`.time(includingFractionalSeconds: true)` — unlike `ISO8601DateFormatter` it is +`Sendable`, so it can be a `static let` under Swift 6 strict concurrency. + +Parsing tries, in order: ISO-8601 with fractional seconds; ISO-8601 without; a +whole-string `^-?\d+$` integer (epoch — milliseconds when `abs(value) >= 100_000_000_000`, +otherwise seconds); a whole-string `\d{4}-\d{2}-\d{2}` date at local midnight. The +whole-string match on the bare-date branch matters: `ISO8601DateFormatter` happily +parses a `yyyy-MM-dd` *prefix* and silently drops the time, which is the bug +`ToolDateFormatting` already documents. Input is trimmed before matching. + +`ISO8601DateFormatter` instances are created per call (not `Sendable` as statics); the +cost is irrelevant next to file I/O. + +### 3.3 `TranscriptTextFormatting` + +```swift +public enum TranscriptTextFormatting { + public static func displayName(for segment: SegmentData, names: [Int: String]) -> String + public static func render(_ segments: [SegmentData], names: [Int: String] = [:]) -> String + public static func parse(_ text: String) -> [TranscriptSegmentDraft] +} +``` + +**Render.** Emits `[