diff --git a/App/ImportingExporting.md b/App/ImportingExporting.md new file mode 100644 index 00000000..b56cb1b5 --- /dev/null +++ b/App/ImportingExporting.md @@ -0,0 +1,90 @@ +# Importing and Exporting Meetings + +Biscotti can export your meetings to a CSV file, and import meetings from one — so +you can get your data out, or bring meeting notes in from other apps (Granola, +Otter, Notion exports, or your own scripts). + +Only meetings are covered: the title, date, summary, notes, and transcript. Audio +files, tags, people, and calendar data are not exported or imported. + +Both features live in **Settings → Import/Export**. + +## The CSV file + +The first row of the file is a header. Export writes exactly these columns, in +this order: + +``` +id,title,created,summary,notes,transcript +``` + +Import is lenient about the header: + +- Column names are matched ignoring case and surrounding spaces. +- These aliases from other apps' exports are understood: `document_id` → `id`, + `document_title` → `title`, `document_created` → `created`. A file with only + the `document_*` names imports fine. +- Any other column is ignored — extra columns from another app's export don't + hurt. + +`id` is each meeting's unique identifier. It can be a UUID, or any unique string +(another app's document ID works). Dates in the `created` column can be: + +- ISO-8601 with fractional seconds: `2026-01-03T14:26:42.017Z` +- ISO-8601 without fractional seconds: `2026-01-03T14:26:42Z` (offsets like + `-05:00` are fine in both forms) +- A bare calendar date: `2026-01-03` (read as local midnight) +- A bare integer (epoch time): below 100000000000 it is read as seconds, + otherwise as milliseconds + +Anything else makes that row invalid — it is skipped with a warning, and the +rest of the file still imports. + +A file with only a header row (no data rows) is valid to import: nothing is +imported, and Biscotti reports "Imported 0 meetings." + +## The transcript format + +The transcript column is plain text. Biscotti's own format marks each speaker +turn with a bracketed timestamp and the speaker's name: + +``` +[0:23] Steve +Let's get started. + +[0:31] Priya +I pushed the fix this morning. +``` + +On import, any line that isn't a header line becomes part of the current +speaker's turn. A plain transcript with no headers at all imports as one +"Unknown Speaker" per line at 0:00 — you can rename speakers in the meeting +afterwards. The form `[0:23] Steve: hello there` (name and text on one line, as +some other apps write it) is also understood. + +## Duplicates + +Import never updates or overwrites an existing meeting. A row whose `id` is +already in your library (or appears twice in the same file — the first +occurrence wins) is skipped with a warning. + +## Warnings and errors + +- **Warnings** (you can continue or cancel): rows that were skipped because + they already exist, were missing a required value (`id`, `title`, or + `created`), had the same ID as an earlier row, or had a different number of + fields than the header; rows with no summary, notes, or transcript (these + still import); a column appearing alongside its alias. +- **Errors** (the import is blocked): the file can't be read, isn't UTF-8 text, + has no header row, is missing `id`, `title`, or `created` columns, isn't + valid CSV, or contains no meetings that can be imported. + +## Export + +Export writes **every** meeting, newest first, to a file named +`Biscotti_export_{timestamp}.csv` (for example +`Biscotti_export_2026-09-01-142642.csv`). You pick where to save it. + +The `id` column always carries Biscotti's own UUID for each meeting — an +imported meeting's original (non-UUID) identifier is never re-exported. If you +round-trip a file through another tool, use Biscotti's UUIDs as the identity. diff --git a/ManualTestApp/Results/manual_test_results.json b/ManualTestApp/Results/manual_test_results.json index 931e9cf7..f3f7972a 100644 --- a/ManualTestApp/Results/manual_test_results.json +++ b/ManualTestApp/Results/manual_test_results.json @@ -304,7 +304,7 @@ "mcp_real_client" : { "status" : "pass", "stepID" : "mcp_real_client", - "timestamp" : "2026-09-01T15:10:38Z" + "timestamp" : "2026-09-01T00:30:00Z" }, "tx_ai_test_passed" : { "status" : "pass", diff --git a/Packages/BiscottiKit/Package.swift b/Packages/BiscottiKit/Package.swift index 0213c1d2..cdc5b029 100644 --- a/Packages/BiscottiKit/Package.swift +++ b/Packages/BiscottiKit/Package.swift @@ -8,6 +8,8 @@ let package = Package( .library(name: "BiscottiKit", targets: ["BiscottiKit"]), .library(name: "DataStore", targets: ["DataStore"]), .library(name: "DesignSystem", targets: ["DesignSystem"]), + .library(name: "Formatting", targets: ["Formatting"]), + .library(name: "ImportExport", targets: ["ImportExport"]), .library(name: "Permissions", targets: ["Permissions"]), .library(name: "Recording", targets: ["Recording"]), .library(name: "TranscriptionService", targets: ["TranscriptionService"]), @@ -68,10 +70,36 @@ let package = Package( swiftSettings: warningsAsErrors ), .target( - name: "DesignSystem", + name: "Formatting", dependencies: [ "DataStore" ], + swiftSettings: warningsAsErrors + ), + .testTarget( + name: "FormattingTests", + dependencies: ["Formatting", "DataStore"], + swiftSettings: warningsAsErrors + ), + .target( + name: "ImportExport", + dependencies: [ + "DataStore", + "Formatting" + ], + swiftSettings: warningsAsErrors + ), + .testTarget( + name: "ImportExportTests", + dependencies: ["ImportExport", "DataStore", "Formatting"], + swiftSettings: warningsAsErrors + ), + .target( + name: "DesignSystem", + dependencies: [ + "DataStore", + "Formatting" + ], resources: [.process("Resources")], swiftSettings: warningsAsErrors ), @@ -143,6 +171,7 @@ let package = Package( dependencies: [ "AppLinks", "DataStore", + "ImportExport", "Intelligence", "MCPServer", "Permissions", @@ -194,6 +223,8 @@ let package = Package( "Calendar", "DataStore", "DesignSystem", + "Formatting", + "ImportExport", "Intelligence", "MCPServer", "MeetingCatalog", @@ -214,7 +245,8 @@ let package = Package( "AppCore", "Calendar", "DataStore", - "DesignSystem" + "DesignSystem", + "Formatting" ], swiftSettings: warningsAsErrors ), @@ -226,6 +258,7 @@ let package = Package( "BiscottiTestSupport", "Calendar", "DataStore", + "Formatting", "MeetingCatalog", "Permissions", "Recording", @@ -272,6 +305,7 @@ let package = Package( "Calendar", "DataStore", "DesignSystem", + "Formatting", "Intelligence", "MarkdownEditorUI", "SummaryPromptUI", @@ -289,6 +323,7 @@ let package = Package( "BiscottiTestSupport", "Calendar", "DataStore", + "Formatting", "Intelligence", "MeetingCatalog", "Permissions", @@ -307,7 +342,8 @@ let package = Package( "AppCore", "Calendar", "DataStore", - "DesignSystem" + "DesignSystem", + "Formatting" ], swiftSettings: warningsAsErrors ), @@ -319,6 +355,7 @@ let package = Package( "BiscottiTestSupport", "Calendar", "DataStore", + "Formatting", "MeetingCatalog", "Permissions", "Recording", @@ -335,6 +372,7 @@ let package = Package( "Calendar", "DataStore", "DesignSystem", + "Formatting", "HomeUI", "MeetingListUI", "MeetingDetailUI", @@ -387,6 +425,7 @@ let package = Package( "Calendar", "DataStore", "DesignSystem", + "ImportExport", "Intelligence", "LocalLLM", "MCPServer", @@ -405,6 +444,7 @@ let package = Package( "BiscottiTestSupport", "Calendar", "DataStore", + "ImportExport", "Intelligence", "MCPServer", "MeetingCatalog", @@ -425,7 +465,8 @@ let package = Package( "AppCore", "Calendar", "DataStore", - "DesignSystem" + "DesignSystem", + "Formatting" ], swiftSettings: warningsAsErrors ), @@ -609,6 +650,7 @@ let package = Package( dependencies: [ "AppLinks", "DataStore", + "Formatting", .product(name: "MCP", package: "swift-sdk"), .product(name: "NIOCore", package: "swift-nio"), .product(name: "NIOConcurrencyHelpers", package: "swift-nio"), diff --git a/Packages/BiscottiKit/Sources/AppCore/AppCore+ImportExport.swift b/Packages/BiscottiKit/Sources/AppCore/AppCore+ImportExport.swift new file mode 100644 index 00000000..2c07a403 --- /dev/null +++ b/Packages/BiscottiKit/Sources/AppCore/AppCore+ImportExport.swift @@ -0,0 +1,161 @@ +import DataStore +import Foundation +import ImportExport +import os + +/// What a committed import actually did (architecture §5). The skip +/// counts are derived from the scan's warnings — the commit itself never +/// re-reads the file. +public struct ImportCommitSummary: Sendable, Equatable { + public let imported: Int + public let skippedExisting: Int + public let skippedMisformatted: Int + + public init( + imported: Int, + skippedExisting: Int, + skippedMisformatted: Int + ) { + self.imported = imported + self.skippedExisting = skippedExisting + self.skippedMisformatted = skippedMisformatted + } +} + +// MARK: - CSV import/export actions (architecture §5) + +public extension AppCore { + /// Scans a CSV file for import without writing anything (functional + /// spec §2.1). The heavy parsing runs in a detached task so a large + /// file never blocks the main actor. + func scanMeetingImport(at url: URL) async -> ImportScanResult { + // A failed identity fetch degrades to no duplicate detection. + // That is acceptable because the same broken store makes the + // subsequent commit throw, so no data can be silently duplicated — + // but the failure is logged so "store broken" is distinguishable + // from "no duplicates" in the field (architecture §8). + let existing: ExistingMeetingIdentity + do { + existing = try await store.existingMeetingIdentity() + } catch { + importExportActionLog.error( + "identity fetch failed; scanning without duplicate detection: \(String(describing: error), privacy: .public)" + ) + existing = ExistingMeetingIdentity() + } + + return await Task.detached(priority: .userInitiated) { + MeetingCSVImporter.scan(fileURL: url, existing: existing) + }.value + } + + /// Inserts the scan's drafts as one import batch, then refreshes the + /// sidebar. The file is never re-read or re-validated — exactly what + /// the scan produced is what lands in the store. + func commitMeetingImport( + _ result: ImportScanResult + ) async throws -> ImportCommitSummary { + let skippedExisting = Self.skippedExisting(from: result.warnings) + let skippedMisformatted = Self.skippedMisformatted(from: result.warnings) + + // A header-only file (valid header, zero data rows) scans clean + // with no drafts — `canProceed == false` only because there is + // nothing to insert. It is not a failure: per functional spec + // §3.3/§3.4 it commits zero meetings, so return early without + // minting a batch or touching the store. + guard !result.drafts.isEmpty else { + return ImportCommitSummary( + imported: 0, + skippedExisting: skippedExisting, + skippedMisformatted: skippedMisformatted + ) + } + + let batchID = try await store.nextImportBatchID() + let imported = try await store.insertImportedMeetings( + result.drafts, batchID: batchID + ) + await reloadSummaries() + importExportActionLog.info( + "CSV import committed \(imported) meetings (batch \(batchID))" + ) + return ImportCommitSummary( + imported: imported, + skippedExisting: skippedExisting, + skippedMisformatted: skippedMisformatted + ) + } + + /// Streams every meeting to a CSV file in `directory` (the temporary + /// directory by default) and returns its URL (functional spec §5.2). + /// The exporter is stateless, so it is built per call. Tests pass a + /// unique directory: the generated filename has second granularity, + /// so same-second exports to one directory would collide. + func exportMeetingsCSV( + to directory: URL = URL.temporaryDirectory + ) async throws -> URL { + try await MeetingCSVExporter(store: store).export(to: directory) + } +} + +private extension AppCore { + static func skippedExisting(from warnings: [ImportWarning]) -> Int { + warnings.reduce(0) { total, warning in + if case let .alreadyInDatabase(count) = warning { + total + count + } else { + total + } + } + } + + static func skippedMisformatted(from warnings: [ImportWarning]) -> Int { + warnings.reduce(0) { total, warning in + if case let .misformattedRows(count, _) = warning { + total + count + } else { + total + } + } + } +} + +/// Counts and row numbers only — never file contents (architecture §8). +private let importExportActionLog = Logger( + subsystem: "net.scosman.biscotti", + category: "ImportExport" +) + +// MARK: - Debug-build bulk delete (functional spec §6.1) + +#if DEBUG + public extension AppCore { + /// The imported/remaining meeting split, for the debug delete + /// confirmation copy. A store failure logs and reports (0, 0) + /// rather than throwing — but the log keeps the degraded count + /// from masquerading as a genuine empty state (architecture §8). + func importedMeetingCounts() async -> (imported: Int, remaining: Int) { + do { + return try await store.importedMeetingCounts() + } catch { + importExportActionLog.error( + "importedMeetingCounts failed: \(String(describing: error), privacy: .public)" + ) + return (imported: 0, remaining: 0) + } + } + + /// Deletes every imported meeting (`importBatch != nil`), + /// cascading transcripts and search-index entries, then refreshes + /// the sidebar. Debug builds only. + @discardableResult + func deleteImportedMeetings() async throws -> Int { + let deleted = try await store.deleteImportedMeetings() + await reloadSummaries() + importExportActionLog.info( + "Debug bulk delete removed \(deleted) imported meetings" + ) + return deleted + } + } +#endif diff --git a/Packages/BiscottiKit/Sources/AppShellUI/AppShellViewModel.swift b/Packages/BiscottiKit/Sources/AppShellUI/AppShellViewModel.swift index 472ab70a..46fc340a 100644 --- a/Packages/BiscottiKit/Sources/AppShellUI/AppShellViewModel.swift +++ b/Packages/BiscottiKit/Sources/AppShellUI/AppShellViewModel.swift @@ -3,6 +3,7 @@ import AppKit import Calendar import DataStore import DesignSystem +import Formatting import Foundation import HomeUI import MeetingDetailUI diff --git a/Packages/BiscottiKit/Sources/DataStore/DataStore+ImportExport.swift b/Packages/BiscottiKit/Sources/DataStore/DataStore+ImportExport.swift new file mode 100644 index 00000000..4d3ced5c --- /dev/null +++ b/Packages/BiscottiKit/Sources/DataStore/DataStore+ImportExport.swift @@ -0,0 +1,243 @@ +import Foundation +import SwiftData + +// MARK: - Import Write Path + +public extension DataStore { + /// Everything already in the database that an import must not duplicate + /// (functional spec §2.4). One fetch, narrowed to the two columns it + /// reads — the same treatment as `nextImportBatchID`. + func existingMeetingIdentity() throws -> ExistingMeetingIdentity { + var descriptor = FetchDescriptor() + descriptor.propertiesToFetch = [\.id, \.externalID] + let meetings = try context.fetch(descriptor) + return ExistingMeetingIdentity( + meetingIDs: Set(meetings.map(\.id)), + externalIDs: Set(meetings.compactMap(\.externalID)) + ) + } + + /// Returns a fresh import batch ID: epoch milliseconds at `now`, + /// incremented while a meeting already carries that exact `importBatch` + /// — two imports inside the same millisecond cannot share a batch. + /// + /// The value is only claimed once `insertImportedMeetings` writes it, + /// with `await` points in between, so two overlapping imports could in + /// principle draw the same ID. That cannot happen through the UI (the + /// section's buttons are disabled while an import is in flight), and + /// the field's only reader today is the debug bulk delete, which + /// ignores batch boundaries. + func nextImportBatchID(now: Date = Date()) throws -> Int { + var descriptor = FetchDescriptor( + predicate: #Predicate { $0.importBatch != nil } + ) + // Narrow the fetch to the one column needed: only collisions with + // the batch value matter, never the meetings themselves. + descriptor.propertiesToFetch = [\.importBatch] + let used = try Set( + context.fetch(descriptor).compactMap(\.importBatch) + ) + + var candidate = Int(now.timeIntervalSince1970 * 1000) + while used.contains(candidate) { + candidate += 1 + } + return candidate + } + + /// Inserts the scanner's drafts as meetings (plus transcript records for + /// non-empty transcript drafts), all stamped with `batchID`. One save at + /// the end — a failure leaves the store unchanged. Returns the number of + /// meetings inserted. + /// + /// The scanner has already deduplicated against the identity it was + /// handed (functional spec §2.4), but the write path does not trust + /// that: a draft whose meeting ID already exists — in the store or in + /// an earlier draft of this batch — is skipped, because `Meeting.id` + /// carries no unique constraint and a duplicate row would corrupt the + /// store silently. + @discardableResult + func insertImportedMeetings( + _ drafts: [ImportedMeetingDraft], batchID: Int + ) throws -> Int { + guard !drafts.isEmpty else { return 0 } + + var idDescriptor = FetchDescriptor() + idDescriptor.propertiesToFetch = [\.id] + let existingIDs = try Set( + context.fetch(idDescriptor).map(\.id) + ) + + var inserted = 0 + var batchIDs = Set() + for draft in drafts { + guard existingIDs.contains(draft.meetingID) == false, + batchIDs.insert(draft.meetingID).inserted + else { continue } + + let meeting = Meeting( + id: draft.meetingID, + title: draft.title, + createdAt: draft.created + ) + // Imported titles/summaries are authored content: calendar + // association must never overwrite the title, and the AI + // auto-run must never overwrite the summary. + meeting.editedTitle = true + meeting.summary = draft.summary + meeting.editedSummary = !draft.summary.isEmpty + meeting.notes = draft.notes + meeting.externalID = draft.externalID + meeting.importBatch = batchID + context.insert(meeting) + + if !draft.transcript.isEmpty { + insertImportedTranscript(draft.transcript, into: meeting) + } + inserted += 1 + } + + guard inserted > 0 else { return 0 } + try save() + return inserted + } + + /// Builds and inserts the transcript record for a non-empty imported + /// transcript, links it to the meeting, and marks it preferred + /// (functional spec §2.2/§4.2). + private func insertImportedTranscript( + _ transcript: [TranscriptSegmentDraft], into meeting: Meeting + ) { + let record = TranscriptRecord( + transcriptionMethodId: "imported", + language: "", + speakerCount: Set(transcript.map(\.speakerID)).count + ) + context.insert(record) + + for (index, segment) in transcript.enumerated() { + // Imported segments carry no durations (functional spec §4.2). + let segmentRecord = TranscriptSegmentRecord( + index: index, + speakerID: segment.speakerID, + speakerLabel: segment.speakerLabel, + startTime: segment.startTime, + endTime: segment.startTime, + text: segment.text, + noSpeechProbability: 0 + ) + context.insert(segmentRecord) + record.segments.append(segmentRecord) + } + + meeting.transcripts.append(record) + meeting.preferredTranscriptID = record.id + } +} + +// MARK: - Export Read Path + +public extension DataStore { + /// IDs of every meeting, sorted by effective date + /// (`startDate ?? createdAt`) descending — newest first, the export + /// order. The coalesce is not expressible in a SwiftData predicate, so + /// the sort happens in memory (same approach as `meetingSummaries`). + func meetingIDsForExport() throws -> [UUID] { + let meetings = try context.fetch(FetchDescriptor()) + return meetings + .sorted { + ($0.startDate ?? $0.createdAt) > ($1.startDate ?? $1.createdAt) + } + .map(\.id) + } + + /// Hydrates export data for the given IDs, preserving the input order so + /// chunked exports keep the newest-first sequence. IDs that no longer + /// resolve to a meeting are skipped. + func exportData(for ids: [UUID]) throws -> [MeetingExportData] { + let fetched = try context.fetch( + FetchDescriptor( + predicate: #Predicate { ids.contains($0.id) } + ) + ) + // `Meeting.id` has no unique constraint; if duplicate rows ever + // entered the store, first-wins here keeps this lookup from + // trapping on them. + let byID = Dictionary( + fetched.map { ($0.id, $0) }, + uniquingKeysWith: { first, _ in first } + ) + + var results: [MeetingExportData] = [] + for id in ids { + guard let meeting = byID[id] else { continue } + try results.append(exportData(for: meeting)) + } + return results + } + + private func exportData(for meeting: Meeting) throws -> MeetingExportData { + // The same read model the detail surface uses: index-sorted + // segments and speaker assignments resolved to person names with + // dangling person IDs dropped (`mapTranscript`). + let transcript = try meeting.preferredTranscriptID + .flatMap { preferredID in + meeting.transcripts.first { $0.id == preferredID } + } + .map { try mapTranscript($0) } + + return MeetingExportData( + id: meeting.id, + title: meeting.title, + date: meeting.startDate ?? meeting.createdAt, + summary: meeting.summary, + notes: meeting.notes, + segments: transcript?.segments ?? [], + speakerNames: transcript?.speakerAssignments.mapValues(\.name) ?? [:] + ) + } +} + +// MARK: - Debug-Build Bulk Delete (functional spec §6.1) + +public extension DataStore { + /// Counts the imported (`importBatch != nil`) and remaining meetings via + /// two `fetchCount` calls — no objects materialized. + func importedMeetingCounts() throws -> (imported: Int, remaining: Int) { + let imported = try context.fetchCount( + FetchDescriptor( + predicate: #Predicate { $0.importBatch != nil } + ) + ) + let remaining = try context.fetchCount( + FetchDescriptor( + predicate: #Predicate { $0.importBatch == nil } + ) + ) + return (imported, remaining) + } + + /// Deletes every meeting whose `importBatch` is non-nil — the whole + /// imported population, not one batch — removing each one's search-index + /// entry before the delete, exactly as `delete(meetingID:)` does. + /// Transcripts, segments, words, audio refs, and calendar snapshots go + /// with them via the existing cascade rules. Returns the deleted count. + @discardableResult + func deleteImportedMeetings() throws -> Int { + let meetings = try context.fetch( + FetchDescriptor( + predicate: #Predicate { $0.importBatch != nil } + ) + ) + guard !meetings.isEmpty else { return 0 } + + for meeting in meetings { + // Eagerly remove from FTS5 index so it stays consistent even + // before the next search-triggered sync. + try? searchIndex.removeMeeting(uuid: meeting.id) + context.delete(meeting) + } + try save() + return meetings.count + } +} diff --git a/Packages/BiscottiKit/Sources/DataStore/DataStore+ReadModels.swift b/Packages/BiscottiKit/Sources/DataStore/DataStore+ReadModels.swift index 955e3696..2e9a85fb 100644 --- a/Packages/BiscottiKit/Sources/DataStore/DataStore+ReadModels.swift +++ b/Packages/BiscottiKit/Sources/DataStore/DataStore+ReadModels.swift @@ -1107,7 +1107,11 @@ public extension DataStore { .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } } - private func mapTranscript(_ record: TranscriptRecord) throws -> TranscriptData { + /// Maps a transcript record to its read model: segments in index + /// order, speaker assignments resolved to people (dangling person IDs + /// dropped). Internal: shared by the detail read models and the CSV + /// export read path so the two never drift. + internal func mapTranscript(_ record: TranscriptRecord) throws -> TranscriptData { let sortedSegments = record.segments.sorted(by: { $0.index < $1.index }) let segments = sortedSegments.map { seg in SegmentData( diff --git a/Packages/BiscottiKit/Sources/DataStore/ImportDrafts.swift b/Packages/BiscottiKit/Sources/DataStore/ImportDrafts.swift new file mode 100644 index 00000000..ea3c1269 --- /dev/null +++ b/Packages/BiscottiKit/Sources/DataStore/ImportDrafts.swift @@ -0,0 +1,108 @@ +import Foundation + +/// A transcript segment produced by `TranscriptTextFormatting.parse`, +/// consumed by the import write path (architecture §2.1). +/// +/// Defined in DataStore so both the `Formatting` module (which produces +/// drafts) and the import write path (which consumes them) depend downward +/// onto it. +public struct TranscriptSegmentDraft: Sendable, Equatable { + public let speakerID: Int + public let speakerLabel: String + public let startTime: TimeInterval + public let text: String + + public init( + speakerID: Int, + speakerLabel: String, + startTime: TimeInterval, + text: String + ) { + self.speakerID = speakerID + self.speakerLabel = speakerLabel + self.startTime = startTime + self.text = text + } +} + +/// One importable meeting, as produced by the CSV scanner's single pass +/// over the file (architecture §2.1). Consumed by +/// `DataStore.insertImportedMeetings(_:batchID:)`. +public struct ImportedMeetingDraft: Sendable, Equatable { + /// Parsed UUID from the row's `id`, or a freshly minted one when the + /// raw string was not a UUID. + public let meetingID: UUID + /// The raw `id` string when it was not a UUID; `nil` otherwise. + public let externalID: String? + public let title: String + public let created: Date + public let summary: String + public let notes: String + /// Empty means "no transcript record" — the row's transcript column + /// was blank or parsed to zero segments. + public let transcript: [TranscriptSegmentDraft] + + public init( + meetingID: UUID, + externalID: String? = nil, + title: String, + created: Date, + summary: String = "", + notes: String = "", + transcript: [TranscriptSegmentDraft] = [] + ) { + self.meetingID = meetingID + self.externalID = externalID + self.title = title + self.created = created + self.summary = summary + self.notes = notes + self.transcript = transcript + } +} + +/// Everything already in the database that an import must not duplicate +/// (functional spec §2.4). Handed to the scanner so it stays store-free. +public struct ExistingMeetingIdentity: Sendable, Equatable { + public let meetingIDs: Set + public let externalIDs: Set + + public init(meetingIDs: Set = [], externalIDs: Set = []) { + self.meetingIDs = meetingIDs + self.externalIDs = externalIDs + } +} + +/// Everything the CSV exporter needs for one meeting (architecture §2.2). +public struct MeetingExportData: Sendable, Equatable { + public let id: UUID + public let title: String + /// The meeting's effective date: `startDate ?? createdAt`. + public let date: Date + public let summary: String + public let notes: String + /// Segments of the preferred transcript in index order; empty when the + /// meeting has no transcript. + public let segments: [SegmentData] + /// Resolved person names by diarization speaker ID, from the preferred + /// transcript's speaker assignments. + public let speakerNames: [Int: String] + + public init( + id: UUID, + title: String, + date: Date, + summary: String = "", + notes: String = "", + segments: [SegmentData] = [], + speakerNames: [Int: String] = [:] + ) { + self.id = id + self.title = title + self.date = date + self.summary = summary + self.notes = notes + self.segments = segments + self.speakerNames = speakerNames + } +} diff --git a/Packages/BiscottiKit/Sources/DataStore/Models/Meeting.swift b/Packages/BiscottiKit/Sources/DataStore/Models/Meeting.swift index a1a8c055..221be414 100644 --- a/Packages/BiscottiKit/Sources/DataStore/Models/Meeting.swift +++ b/Packages/BiscottiKit/Sources/DataStore/Models/Meeting.swift @@ -35,6 +35,17 @@ import SwiftData /// Additive field -- defaults nil, no migration needed. public var recordingDuration: TimeInterval? + /// 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. + /// Additive field -- defaults nil, no migration needed. + 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. + /// Additive field -- defaults nil, no migration needed. + public var importBatch: Int? + @Relationship(deleteRule: .cascade) public var audioFiles: [AudioFileRef] = [] diff --git a/Packages/BiscottiKit/Sources/DesignSystem/AudioTransport.swift b/Packages/BiscottiKit/Sources/DesignSystem/AudioTransport.swift index aa31792e..45e1690d 100644 --- a/Packages/BiscottiKit/Sources/DesignSystem/AudioTransport.swift +++ b/Packages/BiscottiKit/Sources/DesignSystem/AudioTransport.swift @@ -1,3 +1,4 @@ +import Formatting import SwiftUI /// Standard audio transport card: play/pause + scrubber + elapsed/total + diff --git a/Packages/BiscottiKit/Sources/Formatting/ISO8601Formatting.swift b/Packages/BiscottiKit/Sources/Formatting/ISO8601Formatting.swift new file mode 100644 index 00000000..9fa3ccd5 --- /dev/null +++ b/Packages/BiscottiKit/Sources/Formatting/ISO8601Formatting.swift @@ -0,0 +1,81 @@ +import Foundation + +/// ISO-8601 formatting for the CSV import/export surface (functional spec §1.3). +/// +/// Rendering is always UTC with millisecond precision. Parsing is lenient: +/// it accepts the rendered form, second-precision ISO-8601, a bare epoch +/// integer (seconds or milliseconds), or a bare calendar date at local +/// midnight — so CSVs from other apps and scripts import without editing. +public enum ISO8601Formatting { + /// `"2026-01-03T14:26:42.017Z"` — UTC, milliseconds, always. + /// + /// `ISO8601DateFormatter` is created per call — it is not `Sendable`, + /// so a cached `static let` would not compile under Swift 6 strict + /// concurrency, and the cost is irrelevant next to file I/O. It is + /// preferred over `Date.ISO8601FormatStyle` because the format style + /// truncates sub-millisecond `Date` error downward (a `.017` instant + /// renders `.016`), which would not round-trip through parse. + public static func string(from date: Date) -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + formatter.timeZone = TimeZone(identifier: "UTC") + return formatter.string(from: date) + } + + /// Lenient parse per functional spec §1.3, in order: + /// ISO-8601 with fractional seconds; ISO-8601 without; a whole-string + /// integer (epoch — milliseconds when `abs(value) >= 100_000_000_000`, + /// otherwise seconds); a whole-string `yyyy-MM-dd` date at local + /// midnight. Nil when nothing matches. + public static func date(from string: String) -> Date? { + let trimmed = string.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + if let dateTime = parseDateTime(trimmed) { + return dateTime + } + + // The bare-integer branch must match the whole string so a value + // like "12:30" or "2026x" is never mistaken for an epoch count — + // `Int(trimmed)` already guarantees that; the regex's only extra + // rejection is a leading plus ("+5"). The magnitude comparison, + // not `abs`, keeps `Int.min` from trapping. + if let value = Int(trimmed), trimmed.wholeMatch(of: /-?\d+/) != nil { + let seconds = value >= epochMillisecondsThreshold + || value <= -epochMillisecondsThreshold + ? Double(value) / 1000 + : Double(value) + return Date(timeIntervalSince1970: seconds) + } + + // The bare-date branch must match the whole string: + // ISO8601DateFormatter parses a `yyyy-MM-dd` *prefix* and ignores + // trailing characters, so a zone-less datetime would silently lose + // its time component instead of being rejected (the + // ToolDateFormatting precedent). + guard trimmed.wholeMatch(of: /\d{4}-\d{2}-\d{2}/) != nil else { return nil } + + let dateFormatter = ISO8601DateFormatter() + dateFormatter.formatOptions = [.withFullDate] + // Local midnight: ISO8601DateFormatter defaults to GMT, so pin the + // zone explicitly for the calendar day to land in the user's zone. + dateFormatter.timeZone = TimeZone.current + return dateFormatter.date(from: trimmed) + } + + // MARK: - Internals + + /// 1e11 seconds is the year 5138, so anything at or above the threshold + /// can only be epoch milliseconds (functional spec §1.3). + private static let epochMillisecondsThreshold = 100_000_000_000 + + private static func parseDateTime(_ string: String) -> Date? { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = formatter.date(from: string) { + return date + } + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: string) + } +} diff --git a/Packages/BiscottiKit/Sources/DesignSystem/TimeFormatting.swift b/Packages/BiscottiKit/Sources/Formatting/TimeFormatting.swift similarity index 100% rename from Packages/BiscottiKit/Sources/DesignSystem/TimeFormatting.swift rename to Packages/BiscottiKit/Sources/Formatting/TimeFormatting.swift diff --git a/Packages/BiscottiKit/Sources/Formatting/TranscriptTextFormatting.swift b/Packages/BiscottiKit/Sources/Formatting/TranscriptTextFormatting.swift new file mode 100644 index 00000000..e71b43f3 --- /dev/null +++ b/Packages/BiscottiKit/Sources/Formatting/TranscriptTextFormatting.swift @@ -0,0 +1,194 @@ +import DataStore +import Foundation + +/// The single plain-text transcript format (functional spec §4), used for +/// the meeting detail Copy button, the CSV `transcript` column, and parsed +/// back on import. Deliberately both human-readable and machine-parseable: +/// a speaker turn is a `[M:SS] Name` header line followed by the spoken +/// text, with one blank line between turns. +public enum TranscriptTextFormatting { + // MARK: - Display name + + /// The display name for a segment's speaker: the assigned person name + /// when the segment's `speakerID` is mapped in `names`, otherwise the + /// original diarization `speakerLabel`. + /// + /// Shared by the transcript row (`TranscriptListView`) and `render` so + /// on-screen and copied text resolve names identically. + public static func displayName( + for segment: SegmentData, names: [Int: String] + ) -> String { + if let sid = segment.speakerID, let assignedName = names[sid] { + return assignedName + } + return segment.speakerLabel + } + + // MARK: - Render + + /// Builds the plain-text rendering of a transcript. + /// + /// Format per turn: + /// ``` + /// [0:23] Steve + /// Let's get started. + /// ``` + /// Blank line between turns. Consecutive segments sharing the same + /// non-nil `speakerID` collapse into one turn, their text joined by a + /// space — segment boundaries are diarization artifacts, and a header + /// per fragment makes both the Copy output and the CSV unreadable. + /// Segments with nil `speakerID` never collapse. Blank segments are + /// dropped. Timestamps are `M:SS`, or `H:MM:SS` from one hour up. + public static func render( + _ segments: [SegmentData], names: [Int: String] = [:] + ) -> String { + var turns: [String] = [] + var currentSpeakerID: Int? + var currentName = "" + var currentStartTime: TimeInterval = 0 + var currentText = "" + + func flushTurn() { + if !currentText.isEmpty { + let timeText = TimeFormatting.formatPlaybackTime(currentStartTime) + turns.append("[\(timeText)] \(currentName)\n\(currentText)") + } + currentSpeakerID = nil + currentName = "" + currentText = "" + } + + for segment in segments { + let trimmed = segment.text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + + let name = displayName(for: segment, names: names) + if let speakerID = segment.speakerID, speakerID == currentSpeakerID { + currentText += " " + trimmed + } else { + flushTurn() + currentSpeakerID = segment.speakerID + currentName = name + currentStartTime = segment.startTime + currentText = trimmed + } + } + flushTurn() + + return turns.joined(separator: "\n\n") + } + + // MARK: - Parse + + /// Parses free text — Biscotti's own rendered format or plain text from + /// another app — into segment drafts (functional spec §4.2). + /// + /// A line matching `[] ` is a header: it sets the + /// current speaker name and timestamp for every following line. Every + /// non-header line becomes one segment carrying the current speaker and + /// timestamp; line breaks make new segments, nothing is merged. When the + /// name portion contains a colon, the text before the first colon is the + /// speaker name and the text after is a content line for that speaker + /// (`[0:23] Steve: hello`). Before any header is seen, the speaker is + /// `Unknown Speaker` at `0`. Each distinct name gets a sequential + /// speaker ID in order of first appearance, so the existing + /// speaker-mapping UI works on imported transcripts. + public static func parse(_ text: String) -> [TranscriptSegmentDraft] { + // Built once per parse: `Regex` is not `Sendable`, so a `static let` + // would not compile under Swift 6 strict concurrency — a local + // binding has no such restriction. The inner groups are + // non-capturing so the output stays a 2-tuple. + let headerPattern = /\[((?:(?:\d{1,2}):)?(?:\d{1,3}):(?:\d{2}))\]\s*/ + + var drafts: [TranscriptSegmentDraft] = [] + var speakerIDsByName: [String: Int] = [:] + var currentName = unknownSpeakerName + var currentTime: TimeInterval = 0 + + func speakerID(for name: String) -> Int { + if let existing = speakerIDsByName[name] { + return existing + } + let assigned = speakerIDsByName.count + speakerIDsByName[name] = assigned + return assigned + } + + for line in text.split(whereSeparator: \.isNewline) { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { continue } + + if let header = parseHeader(trimmed, pattern: headerPattern) { + currentName = header.name.isEmpty ? unknownSpeakerName : header.name + currentTime = header.time + if let inlineText = header.inlineText { + drafts.append( + TranscriptSegmentDraft( + speakerID: speakerID(for: currentName), + speakerLabel: currentName, + startTime: currentTime, + text: inlineText + ) + ) + } + } else { + drafts.append( + TranscriptSegmentDraft( + speakerID: speakerID(for: currentName), + speakerLabel: currentName, + startTime: currentTime, + text: trimmed + ) + ) + } + } + return drafts + } + + // MARK: - Internals + + private static let unknownSpeakerName = "Unknown Speaker" + + private struct ParsedHeader { + let name: String + let time: TimeInterval + let inlineText: String? + } + + /// Matches a `[M:SS]`, `[MM:SS]`, `[H:MM:SS]`, or `[HH:MM:SS]` prefix; + /// whatever follows the closing bracket is the speaker name. Seconds + /// are always two digits — that is what separates a header from + /// ordinary prose that happens to contain brackets. + private static func parseHeader( + _ line: String, + pattern: Regex<(Substring, Substring)> + ) -> ParsedHeader? { + guard let match = line.prefixMatch(of: pattern) else { return nil } + + let time = seconds(fromTimestamp: String(match.output.1)) + let trailing = line[match.range.upperBound...] + .trimmingCharacters(in: .whitespacesAndNewlines) + guard let colon = trailing.firstIndex(of: ":") else { + return ParsedHeader(name: trailing, time: time, inlineText: nil) + } + // `[0:23] Steve: hello there` — several other apps emit that shape. + let name = String(trailing[.. TimeInterval { + let parts = timestamp.split(separator: ":").map { Int($0) ?? 0 } + if parts.count == 3 { + return TimeInterval(parts[0] * 3600 + parts[1] * 60 + parts[2]) + } + return TimeInterval(parts[0] * 60 + parts[1]) + } +} diff --git a/Packages/BiscottiKit/Sources/HomeUI/HomeViewModel.swift b/Packages/BiscottiKit/Sources/HomeUI/HomeViewModel.swift index ec82c316..cb5bde8c 100644 --- a/Packages/BiscottiKit/Sources/HomeUI/HomeViewModel.swift +++ b/Packages/BiscottiKit/Sources/HomeUI/HomeViewModel.swift @@ -2,6 +2,7 @@ import AppCore import Calendar import DataStore import DesignSystem +import Formatting import Foundation /// View model for the Home screen. diff --git a/Packages/BiscottiKit/Sources/ImportExport/CSVColumns.swift b/Packages/BiscottiKit/Sources/ImportExport/CSVColumns.swift new file mode 100644 index 00000000..2eecfcc0 --- /dev/null +++ b/Packages/BiscottiKit/Sources/ImportExport/CSVColumns.swift @@ -0,0 +1,36 @@ +/// The CSV column contract (functional spec §1.1–§1.2): the canonical +/// column set and order, the aliases import resolves, and the header row +/// export writes. Shared by the importer (header resolution), the exporter +/// (header row), and warning copy (alias names). +enum CSVColumns { + static let id = "id" + static let title = "title" + static let created = "created" + static let summary = "summary" + static let notes = "notes" + static let transcript = "transcript" + + /// Canonical column names in the exact order export writes them. + static let canonical = [id, title, created, summary, notes, transcript] + + /// Columns a header must provide, in canonical order. + static let required = [id, title, created] + + /// Header-cell aliases from other apps' exports, mapped to their + /// canonical column. + static let aliases = [ + "document_id": id, + "document_title": title, + "document_created": created + ] + + /// The alias for a canonical column name, when one exists. + static func alias(for canonical: String) -> String? { + aliases.first { $0.value == canonical }?.key + } + + /// The canonical header row, CRLF-terminated. + static var headerRow: String { + CSVWriter.row(canonical) + } +} diff --git a/Packages/BiscottiKit/Sources/ImportExport/CSVParser.swift b/Packages/BiscottiKit/Sources/ImportExport/CSVParser.swift new file mode 100644 index 00000000..7760ec05 --- /dev/null +++ b/Packages/BiscottiKit/Sources/ImportExport/CSVParser.swift @@ -0,0 +1,194 @@ +import Foundation + +/// RFC 4180 CSV parsing, lenient where other tools' exports demand it +/// (architecture §4.1): mixed CRLF/LF/CR row separators, ragged rows +/// passed through for the importer to normalize, and a tolerated leading +/// BOM. Terminators inside quoted fields are literal content. +enum CSVParseError: Error, Equatable { + /// A quoted field was never closed. `row` is 1-based, counting the + /// header as row 1. + case unterminatedQuote(row: Int) + + var row: Int { + switch self { + case let .unterminatedQuote(row): row + } + } +} + +enum CSVParser { + static func parse(_ text: String) throws(CSVParseError) -> [[String]] { + // Iterated by index rather than copied into an Array: the copy + // would be a second full buffer (4 bytes per scalar) on top of + // the string itself. + let scalars = text.unicodeScalars + // The import flow strips the BOM from the raw bytes before decoding; + // this second strip keeps the parser correct for any caller. + var index = scalars.startIndex + if index < scalars.endIndex, scalars[index] == "\u{FEFF}" { + index = scalars.index(after: index) + } + + var scanner = Scanner() + while index < scalars.endIndex { + if scanner.consume(scalars[index]) { + index = indexAfterRowTerminator(scalars, at: index) + } else { + index = scalars.index(after: index) + } + } + + if scanner.isUnterminated { + throw CSVParseError.unterminatedQuote(row: scanner.rowIndex) + } + scanner.finishRowIfPending() + return scanner.rows + } + + /// A CR may be half of a CRLF pair — skip its LF too. + private static func indexAfterRowTerminator( + _ scalars: String.UnicodeScalarView, + at index: String.UnicodeScalarView.Index + ) -> String.UnicodeScalarView.Index { + if scalars[index] == "\r" { + let next = scalars.index(after: index) + if next < scalars.endIndex, scalars[next] == "\n" { + return scalars.index(after: next) + } + } + return scalars.index(after: index) + } +} + +/// The field/row state machine. `consume` feeds one scalar and returns +/// whether it ended a row. +private struct Scanner { + private enum State { + case fieldStart + case inUnquoted + case inQuoted + } + + private var state = State.fieldStart + /// A quote seen inside a quoted field is ambiguous until the next + /// scalar: a second quote is a literal quote, anything else closes + /// the field. + private var quotePending = false + /// Whether the current row exists at all — it takes a field + /// character, a comma, or an opening quote to make one. Tracked + /// explicitly because "row has content" is not the same test: a lone + /// quoted empty field (`""`) is a row with no content. + private var rowPending = false + private var fields: [String] = [] + private var field = String.UnicodeScalarView() + + var rows: [[String]] = [] + var rowIndex = 1 + + var isUnterminated: Bool { + state == .inQuoted && !quotePending + } + + mutating func consume(_ scalar: Unicode.Scalar) -> Bool { + switch state { + case .fieldStart: consumeAtFieldStart(scalar) + case .inUnquoted: consumeInUnquoted(scalar) + case .inQuoted: consumeInQuoted(scalar) + } + } + + /// Emits the final row only when one is pending: a trailing + /// terminator already ended the last row, leaving nothing pending. + mutating func finishRowIfPending() { + if rowPending { + endField() + rows.append(fields) + } + } + + private mutating func consumeAtFieldStart(_ scalar: Unicode.Scalar) -> Bool { + switch scalar { + case "\"": + rowPending = true + state = .inQuoted + case ",": + rowPending = true + endField() + case "\r", "\n": + endRow() + return true + default: + rowPending = true + field.append(scalar) + state = .inUnquoted + } + return false + } + + private mutating func consumeInUnquoted(_ scalar: Unicode.Scalar) -> Bool { + switch scalar { + case ",": + endField() + case "\r", "\n": + endRow() + return true + default: + field.append(scalar) + } + return false + } + + private mutating func consumeInQuoted(_ scalar: Unicode.Scalar) -> Bool { + if quotePending { + return consumeAfterPendingQuote(scalar) + } + if scalar == "\"" { + quotePending = true + } else { + // Everything else — commas and newlines included — is + // literal content inside a quoted field. + field.append(scalar) + } + return false + } + + private mutating func consumeAfterPendingQuote(_ scalar: Unicode.Scalar) -> Bool { + switch scalar { + case "\"": + field.append("\"") + quotePending = false + return false + case ",": + endField() + return false + case "\r", "\n": + endRow() + return true + default: + // Lenient: content after a closing quote keeps the quote as + // a literal rather than failing the file. The field then + // continues as unquoted text so a later comma still closes it. + field.append("\"") + field.append(scalar) + quotePending = false + state = .inUnquoted + return false + } + } + + private mutating func endField() { + fields.append(String(field)) + field.removeAll() + state = .fieldStart + quotePending = false + rowPending = true + } + + private mutating func endRow() { + endField() + rows.append(fields) + fields = [] + rowPending = false + rowIndex += 1 + } +} diff --git a/Packages/BiscottiKit/Sources/ImportExport/CSVWriter.swift b/Packages/BiscottiKit/Sources/ImportExport/CSVWriter.swift new file mode 100644 index 00000000..a2054fc1 --- /dev/null +++ b/Packages/BiscottiKit/Sources/ImportExport/CSVWriter.swift @@ -0,0 +1,19 @@ +import Foundation + +/// RFC 4180 CSV writing (architecture §4.1): a field is quoted when it +/// contains a comma, double quote, CR, or LF; embedded quotes are doubled; +/// rows are comma-joined and CRLF-terminated. Newlines inside a field are +/// preserved as-is (LF) — only the row separator is CRLF. +enum CSVWriter { + static func field(_ value: String) -> String { + let needsQuoting = value.unicodeScalars.contains { scalar in + scalar == "," || scalar == "\"" || scalar == "\r" || scalar == "\n" + } + guard needsQuoting else { return value } + return "\"" + value.replacingOccurrences(of: "\"", with: "\"\"") + "\"" + } + + static func row(_ fields: [String]) -> String { + fields.map(field).joined(separator: ",") + "\r\n" + } +} diff --git a/Packages/BiscottiKit/Sources/ImportExport/ImportExportLog.swift b/Packages/BiscottiKit/Sources/ImportExport/ImportExportLog.swift new file mode 100644 index 00000000..de7ad432 --- /dev/null +++ b/Packages/BiscottiKit/Sources/ImportExport/ImportExportLog.swift @@ -0,0 +1,5 @@ +import os + +/// Module-wide logger (architecture §8). Only counts and row numbers are +/// logged — never file contents, titles, notes, summaries, or transcripts. +let importExportLog = Logger(subsystem: "net.scosman.biscotti", category: "ImportExport") diff --git a/Packages/BiscottiKit/Sources/ImportExport/ImportScanResult.swift b/Packages/BiscottiKit/Sources/ImportExport/ImportScanResult.swift new file mode 100644 index 00000000..c5301982 --- /dev/null +++ b/Packages/BiscottiKit/Sources/ImportExport/ImportScanResult.swift @@ -0,0 +1,139 @@ +import DataStore +import Foundation + +/// The outcome of `MeetingCSVImporter.scan` (architecture §4.2): the +/// importable drafts plus everything the review alert needs. Commit +/// inserts exactly `drafts` — the file is never re-read or re-validated. +public struct ImportScanResult: Sendable, Equatable { + /// Importable rows only, in file order. Misformatted and duplicate + /// rows were excluded during the scan. + public let drafts: [ImportedMeetingDraft] + public let warnings: [ImportWarning] + public let criticalErrors: [ImportCriticalError] + + public init( + drafts: [ImportedMeetingDraft] = [], + warnings: [ImportWarning] = [], + criticalErrors: [ImportCriticalError] = [] + ) { + self.drafts = drafts + self.warnings = warnings + self.criticalErrors = criticalErrors + } + + /// True when the import may commit: no critical errors and at least + /// one importable draft. + public var canProceed: Bool { + criticalErrors.isEmpty && !drafts.isEmpty + } + + /// True when the review alert must be shown before committing + /// (functional spec §3.3). + public var needsReview: Bool { + !warnings.isEmpty || !criticalErrors.isEmpty + } +} + +/// A file-level problem that blocks the whole import (functional spec +/// §3.1). A problem with an individual row never does — that is an +/// `ImportWarning`. +public enum ImportCriticalError: Sendable, Equatable { + /// The file could not be read; the payload is the underlying error's + /// description. + case unreadableFile(String) + case notUTF8 + case emptyFile + /// Canonical names of the required columns the header lacks, in + /// canonical order. + case missingColumns([String]) + /// Structurally malformed CSV; `row` is 1-based counting the header as + /// row 1. + case malformedCSV(row: Int) + case nothingToImport + + /// The alert copy for this problem. + public var message: String { + switch self { + case let .unreadableFile(reason): + "The file could not be read: \(reason)" + case .notUTF8: + "The file is not valid UTF-8 text." + case .emptyFile: + "The file is empty." + case let .missingColumns(columns): + "Required columns are missing: \(columns.joined(separator: ", "))." + case let .malformedCSV(row): + "The file is not valid CSV (unterminated quoted field at row \(row))." + case .nothingToImport: + "No meetings in this file can be imported." + } + } +} + +/// A recoverable problem (functional spec §3.2): rows skipped or caveats +/// recorded, but the import can proceed after review. Row numbers are +/// 1-based counting the header as row 1, matching what a spreadsheet +/// shows; example lists are capped at 5. +public enum ImportWarning: Sendable, Equatable { + /// Rows with a blank `id`, a blank `title`, or a missing/unparseable + /// `created`. Skipped. + case misformattedRows(count: Int, exampleRows: [Int]) + /// Rows whose summary, notes, and transcript are all blank. Imported. + case emptyContent(count: Int) + /// Rows whose meeting already exists in the database. Skipped. + case alreadyInDatabase(count: Int) + /// Rows whose ID an earlier row in the same file claimed. Skipped — + /// the first occurrence wins. + case duplicateInFile(count: Int, exampleRows: [Int]) + /// Rows with a different field count than the header: short rows were + /// padded with empty values, long rows dropped their extra fields. + case raggedRows(count: Int, exampleRows: [Int]) + /// Canonical names where both the canonical column and its alias were + /// present; the canonical column is used. + case ambiguousColumns([String]) + + /// The alert copy for this warning. + public var message: String { + switch self { + case let .misformattedRows(count, exampleRows): + "\(count) \(rowNoun(count)) \(rowVerb(count)) missing a required value " + + "and will be skipped\(examples(exampleRows))." + case let .emptyContent(count): + "\(count) \(rowNoun(count)) \(hasVerb(count)) no summary, notes, or transcript." + case let .alreadyInDatabase(count): + count == 1 + ? "1 meeting already exists in your database, it will be skipped." + : "\(count) meetings already exist in your database, these will be skipped." + case let .duplicateInFile(count, exampleRows): + "\(count) \(rowNoun(count)) \(hasVerb(count)) the same ID as an earlier " + + "row; the first occurrence wins\(examples(exampleRows))." + case let .raggedRows(count, exampleRows): + "\(count) \(rowNoun(count)) \(hasVerb(count)) a different number of " + + "fields than the header\(examples(exampleRows))." + case let .ambiguousColumns(columns): + columns.compactMap { column -> String? in + guard let alias = CSVColumns.alias(for: column) else { return nil } + return "Columns '\(column)' and '\(alias)' are both present; '\(column)' is used." + } + .joined(separator: " ") + } + } + + private func rowNoun(_ count: Int) -> String { + count == 1 ? "row" : "rows" + } + + private func rowVerb(_ count: Int) -> String { + count == 1 ? "is" : "are" + } + + private func hasVerb(_ count: Int) -> String { + count == 1 ? "has" : "have" + } + + private func examples(_ rows: [Int]) -> String { + guard !rows.isEmpty else { return "" } + let list = rows.map(String.init).joined(separator: ", ") + return rows.count == 1 ? " (row \(list))" : " (rows \(list))" + } +} diff --git a/Packages/BiscottiKit/Sources/ImportExport/MeetingCSVExporter.swift b/Packages/BiscottiKit/Sources/ImportExport/MeetingCSVExporter.swift new file mode 100644 index 00000000..bf5b7623 --- /dev/null +++ b/Packages/BiscottiKit/Sources/ImportExport/MeetingCSVExporter.swift @@ -0,0 +1,126 @@ +import DataStore +import Formatting +import Foundation + +/// Writes every meeting in the store to a CSV file (functional spec §5), +/// streaming in chunks so memory stays bounded regardless of library size +/// — a few thousand meetings with long transcripts would otherwise be +/// hundreds of megabytes of `String` (architecture §4.3). +public struct MeetingCSVExporter: Sendable { + private let source: any MeetingExportSource + private let chunkSize: Int + + public init(store: DataStore, chunkSize: Int = 50) { + self.init(source: store, chunkSize: chunkSize) + } + + /// Test entry point: any source, so a failing chunk fetch can exercise + /// the partial-file cleanup path. + init(source: any MeetingExportSource, chunkSize: Int) { + self.source = source + // A non-positive chunk size is a caller mistake, not a reason to + // trap a release build. + self.chunkSize = max(1, chunkSize) + } + + /// Writes the CSV to `directory` and returns the file URL. + /// + /// The file is named `Biscotti_export_{yyyy-MM-dd-HHmmss}.csv` (local + /// time) at creation, so the temp file already carries the name the + /// save dialog will offer. Any failure removes the partial file. + public func export( + to directory: URL = URL.temporaryDirectory, + now: Date = Date() + ) async throws -> URL { + let fileURL = directory.appending(path: Self.fileName(for: now)) + + // Creates or truncates, and throws the real underlying error when + // the directory is missing or the file is unwritable — no + // Bool-returning createFile to reinterpret. + try Data().write(to: fileURL) + + var completed = false + defer { + if !completed { + try? FileManager.default.removeItem(at: fileURL) + } + } + + do { + let handle = try FileHandle(forWritingTo: fileURL) + var exported = 0 + do { + try handle.write(contentsOf: Data(CSVColumns.headerRow.utf8)) + + let ids = try await source.meetingIDsForExport() + for start in stride(from: 0, to: ids.count, by: chunkSize) { + let end = min(start + chunkSize, ids.count) + let meetings = try await source.exportData( + for: Array(ids[start ..< end]) + ) + + var text = "" + for meeting in meetings { + text += Self.row(for: meeting) + } + try handle.write(contentsOf: Data(text.utf8)) + exported += meetings.count + } + // Close explicitly on the success path so a close failure + // surfaces instead of reporting a successful export of a + // possibly unwritten file. + try handle.close() + } catch { + try? handle.close() + throw error + } + + completed = true + importExportLog.info("CSV export wrote \(exported) meetings") + return fileURL + } catch { + importExportLog.error( + "CSV export failed: \(String(describing: error), privacy: .public)" + ) + throw error + } + } + + // MARK: - Row Building + + private static func row(for meeting: MeetingExportData) -> String { + CSVWriter.row([ + meeting.id.uuidString, + meeting.title, + ISO8601Formatting.string(from: meeting.date), + meeting.summary, + meeting.notes, + TranscriptTextFormatting.render( + meeting.segments, + names: meeting.speakerNames + ) + ]) + } + + /// `Biscotti_export_2026-09-01-142642.csv` — local time (functional + /// spec §5.2). The formatter is created per call: `DateFormatter` is + /// not `Sendable`, so a cached static would not compile under Swift 6 + /// strict concurrency (the `ISO8601Formatting` precedent). + static func fileName(for now: Date) -> String { + let formatter = DateFormatter() + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd-HHmmss" + formatter.timeZone = .current + return "Biscotti_export_\(formatter.string(from: now)).csv" + } +} + +/// The store surface the exporter reads. Internal so tests can stand in a +/// failing source; `DataStore` conforms through its existing +/// import/export methods. +protocol MeetingExportSource: Sendable { + func meetingIDsForExport() async throws -> [UUID] + func exportData(for ids: [UUID]) async throws -> [MeetingExportData] +} + +extension DataStore: MeetingExportSource {} diff --git a/Packages/BiscottiKit/Sources/ImportExport/MeetingCSVImporter.swift b/Packages/BiscottiKit/Sources/ImportExport/MeetingCSVImporter.swift new file mode 100644 index 00000000..499cb9f2 --- /dev/null +++ b/Packages/BiscottiKit/Sources/ImportExport/MeetingCSVImporter.swift @@ -0,0 +1,338 @@ +import DataStore +import Formatting +import Foundation + +/// Scans a CSV file into importable meeting drafts (functional spec §2.1). +/// Pure and store-free — the caller hands in the existing identity rather +/// than a database, so every error and warning case is a plain unit test. +/// The file is read exactly once; a commit inserts exactly what the scan +/// produced. +public enum MeetingCSVImporter { + /// Scans the CSV file at `fileURL`. Never throws — every failure is a + /// `criticalError` in the result, so the caller has one code path. + public static func scan( + fileURL: URL, + existing: ExistingMeetingIdentity + ) -> ImportScanResult { + do { + return try scan(data: Data(contentsOf: fileURL), existing: existing) + } catch { + importExportLog.error( + "CSV scan could not read the file: \(error.localizedDescription, privacy: .public)" + ) + return ImportScanResult( + criticalErrors: [.unreadableFile(error.localizedDescription)] + ) + } + } + + /// Byte-level entry point so tests (and any future non-file source) + /// share the exact production path. + static func scan( + data: Data, + existing: ExistingMeetingIdentity + ) -> ImportScanResult { + guard let text = text(from: data) else { + importExportLog.error("CSV scan failed: the file is not valid UTF-8") + return ImportScanResult(criticalErrors: [.notUTF8]) + } + + let rows: [[String]] + do { + rows = try CSVParser.parse(text) + } catch { + importExportLog.error("CSV scan failed: malformed CSV at row \(error.row)") + return ImportScanResult(criticalErrors: [.malformedCSV(row: error.row)]) + } + guard !rows.isEmpty else { + return ImportScanResult(criticalErrors: [.emptyFile]) + } + + let header = resolveHeader(rows[0]) + if !header.missingColumns.isEmpty { + importExportLog.error( + "CSV scan failed: missing columns \(header.missingColumns.joined(separator: ", "), privacy: .public)" + ) + return ImportScanResult( + criticalErrors: [.missingColumns(header.missingColumns)] + ) + } + + let outcome = scanRows(Array(rows.dropFirst()), header: header, existing: existing) + // `.nothingToImport` requires at least one real data row — the + // `dataRowCount` a blank line never increments — so a header-only + // file (with or without trailing blank lines) stays on the clean + // commit-zero path (functional spec §3.3/§3.4). + let criticalErrors: [ImportCriticalError] = + outcome.drafts.isEmpty && outcome.dataRowCount > 0 ? [.nothingToImport] : [] + + importExportLog.info( + "CSV scan: \(outcome.drafts.count) importable rows, \(outcome.misformattedRowNumbers.count) misformatted, \(outcome.alreadyInDatabaseCount) already in database, \(outcome.duplicateRowNumbers.count) duplicate in file" + ) + return ImportScanResult( + drafts: outcome.drafts, + warnings: warnings(from: outcome, ambiguousColumns: header.ambiguousColumns), + criticalErrors: criticalErrors + ) + } + + // MARK: - Decoding + + /// Strips a UTF-8 BOM, then decodes. Nil when the bytes are not UTF-8. + private static func text(from data: Data) -> String? { + var bytes = data + if Array(bytes.prefix(3)) == [0xEF, 0xBB, 0xBF] { + bytes.removeFirst(3) + } + return String(data: bytes, encoding: .utf8) + } + + // MARK: - Header Resolution + + /// Resolves the header per functional spec §1.2: trim + lowercase each + /// cell, map aliases, keep the first occurrence of each canonical + /// column, and let the canonical name win over its alias when both are + /// present (recording the ambiguity). + private static func resolveHeader(_ headerCells: [String]) -> HeaderResolution { + let normalized = headerCells.map { + $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + var canonicalIndex: [String: Int] = [:] + var aliasIndex: [String: Int] = [:] + for (index, cell) in normalized.enumerated() { + if CSVColumns.canonical.contains(cell) { + if canonicalIndex[cell] == nil { canonicalIndex[cell] = index } + } else if let canonical = CSVColumns.aliases[cell] { + if aliasIndex[canonical] == nil { aliasIndex[canonical] = index } + } + } + + var columnIndex: [String: Int] = [:] + var ambiguousColumns: [String] = [] + for column in CSVColumns.canonical { + if let index = canonicalIndex[column] { + columnIndex[column] = index + if aliasIndex[column] != nil { ambiguousColumns.append(column) } + } else { + columnIndex[column] = aliasIndex[column] + } + } + + return HeaderResolution( + columnIndex: columnIndex, + width: headerCells.count, + ambiguousColumns: ambiguousColumns, + missingColumns: CSVColumns.required.filter { columnIndex[$0] == nil } + ) + } + + // MARK: - Row Scan + + private static func scanRows( + _ rows: [[String]], + header: HeaderResolution, + existing: ExistingMeetingIdentity + ) -> RowScanOutcome { + var outcome = RowScanOutcome() + var claims = ClaimTracker() + + for (offset, rawFields) in rows.enumerated() { + let rowNumber = offset + 2 + // A blank line parses to a single empty field (a line of + // spaces to a single blank one). It carries no data — + // dropping it keeps a hand-edited file's stray blank lines + // (a trailing "\r\n\r\n") from being reported as ragged or + // misformatted rows. + if rawFields.count == 1, isBlank(rawFields[0]) { continue } + outcome.dataRowCount += 1 + var fields = rawFields + if fields.count != header.width { + outcome.raggedRowNumbers.append(rowNumber) + resize(&fields, to: header.width) + } + + switch scanRow(fields, header: header, existing: existing, claims: &claims) { + case let .draft(draft): + if isBlank(draft.summary), isBlank(draft.notes), draft.transcript.isEmpty { + outcome.emptyContentCount += 1 + } + outcome.drafts.append(draft) + case .misformatted: + outcome.misformattedRowNumbers.append(rowNumber) + case .alreadyInDatabase: + outcome.alreadyInDatabaseCount += 1 + case .duplicateInFile: + outcome.duplicateRowNumbers.append(rowNumber) + } + } + return outcome + } + + /// Scans one width-normalized data row (architecture §4.2 steps 4–7). + private static func scanRow( + _ fields: [String], + header: HeaderResolution, + existing: ExistingMeetingIdentity, + claims: inout ClaimTracker + ) -> RowScan { + func cell(_ column: String) -> String { + header.columnIndex[column].map { fields[$0] } ?? "" + } + + let id = cell(CSVColumns.id).trimmingCharacters(in: .whitespacesAndNewlines) + let title = cell(CSVColumns.title).trimmingCharacters(in: .whitespacesAndNewlines) + guard !id.isEmpty, + !title.isEmpty, + let created = ISO8601Formatting.date(from: cell(CSVColumns.created)) + else { + return .misformatted + } + + switch resolveIdentity(for: id, existing: existing, claims: &claims) { + case .alreadyInDatabase: + return .alreadyInDatabase + case .duplicateInFile: + return .duplicateInFile + case let .resolved(meetingID, externalID): + return .draft( + ImportedMeetingDraft( + meetingID: meetingID, + externalID: externalID, + title: title, + created: created, + summary: cell(CSVColumns.summary), + notes: cell(CSVColumns.notes), + transcript: TranscriptTextFormatting.parse(cell(CSVColumns.transcript)) + ) + ) + } + } + + /// UUID rows match against meeting IDs, non-UUID strings against + /// external IDs — first against the database, then against earlier + /// rows in this file (functional spec §2.4). + private static func resolveIdentity( + for id: String, + existing: ExistingMeetingIdentity, + claims: inout ClaimTracker + ) -> IdentityResolution { + if let parsed = UUID(uuidString: id) { + if existing.meetingIDs.contains(parsed) { return .alreadyInDatabase } + if !claims.meetingIDs.insert(parsed).inserted { return .duplicateInFile } + return .resolved(parsed, externalID: nil) + } + if existing.externalIDs.contains(id) { return .alreadyInDatabase } + if !claims.externalIDs.insert(id).inserted { return .duplicateInFile } + return .resolved(UUID(), externalID: id) + } + + // MARK: - Warnings + + private static func warnings( + from outcome: RowScanOutcome, + ambiguousColumns: [String] + ) -> [ImportWarning] { + var warnings: [ImportWarning] = [] + if !ambiguousColumns.isEmpty { + warnings.append(.ambiguousColumns(ambiguousColumns)) + } + appendRowWarning( + &warnings, + rowNumbers: outcome.raggedRowNumbers, + make: ImportWarning.raggedRows + ) + appendRowWarning( + &warnings, + rowNumbers: outcome.misformattedRowNumbers, + make: ImportWarning.misformattedRows + ) + if outcome.alreadyInDatabaseCount > 0 { + warnings.append(.alreadyInDatabase(count: outcome.alreadyInDatabaseCount)) + } + appendRowWarning( + &warnings, + rowNumbers: outcome.duplicateRowNumbers, + make: ImportWarning.duplicateInFile + ) + if outcome.emptyContentCount > 0 { + warnings.append(.emptyContent(count: outcome.emptyContentCount)) + } + return warnings + } + + private static func appendRowWarning( + _ warnings: inout [ImportWarning], + rowNumbers: [Int], + make: (Int, [Int]) -> ImportWarning + ) { + guard !rowNumbers.isEmpty else { return } + warnings.append( + make(rowNumbers.count, Array(rowNumbers.prefix(maxExamples))) + ) + } + + // MARK: - Helpers + + /// Pads a short row with empty values, drops a long row's extras — + /// the caller has already counted the row as ragged (functional spec + /// §3.2). + private static func resize(_ fields: inout [String], to width: Int) { + if fields.count < width { + fields.append(contentsOf: Array(repeating: "", count: width - fields.count)) + } else { + fields.removeLast(fields.count - width) + } + } + + /// Example row numbers are capped at 5 (functional spec §3.3). + private static let maxExamples = 5 + + private static func isBlank(_ string: String) -> Bool { + string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } +} + +/// Resolved header: canonical column name → index into the raw header +/// cells, the raw width (ragged-row baseline), and the two warning/error +/// payloads header resolution can produce. +private struct HeaderResolution { + let columnIndex: [String: Int] + let width: Int + let ambiguousColumns: [String] + let missingColumns: [String] +} + +/// Tallies from the single pass over the data rows. +private struct RowScanOutcome { + var drafts: [ImportedMeetingDraft] = [] + /// Data rows that survived the blank-line skip — the denominator + /// for the `.nothingToImport` gate. + var dataRowCount = 0 + var raggedRowNumbers: [Int] = [] + var misformattedRowNumbers: [Int] = [] + var alreadyInDatabaseCount = 0 + var duplicateRowNumbers: [Int] = [] + var emptyContentCount = 0 +} + +/// First-wins identity claims for IDs earlier rows in this file took. +private struct ClaimTracker { + var meetingIDs = Set() + var externalIDs = Set() +} + +private enum IdentityResolution { + /// A usable identity: the parsed UUID (or a minted one) plus the + /// raw string to store as `externalID` when it was not a UUID. + case resolved(UUID, externalID: String?) + case alreadyInDatabase + case duplicateInFile +} + +private enum RowScan { + case draft(ImportedMeetingDraft) + case misformatted + case alreadyInDatabase + case duplicateInFile +} diff --git a/Packages/BiscottiKit/Sources/MCPServer/MeetingToolProvider.swift b/Packages/BiscottiKit/Sources/MCPServer/MeetingToolProvider.swift index 3a533f30..fa58b213 100644 --- a/Packages/BiscottiKit/Sources/MCPServer/MeetingToolProvider.swift +++ b/Packages/BiscottiKit/Sources/MCPServer/MeetingToolProvider.swift @@ -1,5 +1,6 @@ import AppLinks import DataStore +import Formatting import Foundation import MCP @@ -140,8 +141,8 @@ actor MeetingToolProvider { let segments = Self.windowed( preferred.segments, startSeconds: startSeconds, endSeconds: endSeconds ) - let text = TranscriptTextFormatter.text( - segments: segments, + let text = TranscriptTextFormatting.render( + segments, names: preferred.speakerAssignments.mapValues(\.name) ) mcpServerLog.debug( diff --git a/Packages/BiscottiKit/Sources/MCPServer/TranscriptTextFormatter.swift b/Packages/BiscottiKit/Sources/MCPServer/TranscriptTextFormatter.swift deleted file mode 100644 index dea29671..00000000 --- a/Packages/BiscottiKit/Sources/MCPServer/TranscriptTextFormatter.swift +++ /dev/null @@ -1,61 +0,0 @@ -import DataStore -import Foundation - -/// Renders transcript segments as the plain-text transcript format of -/// functional spec §5.3: one block per speaker turn — a `[MM:SS] Name` header -/// line, the spoken text below it, a blank line between turns. Consecutive -/// segments from the same speaker collapse into one turn; `HH:MM:SS` is used -/// from one hour up. -/// -/// Deliberately independent of `Intelligence.TranscriptFormatter` (untimestamped, -/// drags in LocalLLM) and `MeetingDetailUI` (SwiftUI) — ~40 lines of pure code -/// beat either coupling (architecture §6.4). -enum TranscriptTextFormatter { - static func text(segments: [SegmentData], names: [Int: String]) -> String { - var turns: [String] = [] - var currentSpeakerID: Int? - var currentName = "" - var currentStartTime: TimeInterval = 0 - var currentText = "" - - func flushTurn() { - if !currentText.isEmpty { - turns.append("[\(timestamp(currentStartTime))] \(currentName)\n\(currentText)") - } - currentSpeakerID = nil - currentName = "" - currentText = "" - } - - for segment in segments { - let trimmed = segment.text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { continue } - - let name = segment.speakerID.flatMap { names[$0] } ?? segment.speakerLabel - if let speakerID = segment.speakerID, speakerID == currentSpeakerID { - currentText += " " + trimmed - } else { - flushTurn() - currentSpeakerID = segment.speakerID - currentName = name - currentStartTime = segment.startTime - currentText = trimmed - } - } - flushTurn() - - return turns.joined(separator: "\n\n") - } - - /// `MM:SS` below one hour, `HH:MM:SS` from 3600 s up. - static func timestamp(_ interval: TimeInterval) -> String { - let totalSeconds = max(0, Int(interval.rounded(.down))) - let hours = totalSeconds / 3600 - let minutes = (totalSeconds % 3600) / 60 - let seconds = totalSeconds % 60 - if hours > 0 { - return String(format: "%02d:%02d:%02d", hours, minutes, seconds) - } - return String(format: "%02d:%02d", minutes, seconds) - } -} diff --git a/Packages/BiscottiKit/Sources/MeetingDetailUI/EventPreviewViewModel.swift b/Packages/BiscottiKit/Sources/MeetingDetailUI/EventPreviewViewModel.swift index 9c1199e8..98e38b67 100644 --- a/Packages/BiscottiKit/Sources/MeetingDetailUI/EventPreviewViewModel.swift +++ b/Packages/BiscottiKit/Sources/MeetingDetailUI/EventPreviewViewModel.swift @@ -2,6 +2,7 @@ import AppCore import AppKit import Calendar import DesignSystem +import Formatting import Foundation /// The primary call-to-action for the event preview, determined by diff --git a/Packages/BiscottiKit/Sources/MeetingDetailUI/MeetingDetailViewModel.swift b/Packages/BiscottiKit/Sources/MeetingDetailUI/MeetingDetailViewModel.swift index 0993740f..0dceb899 100644 --- a/Packages/BiscottiKit/Sources/MeetingDetailUI/MeetingDetailViewModel.swift +++ b/Packages/BiscottiKit/Sources/MeetingDetailUI/MeetingDetailViewModel.swift @@ -4,6 +4,7 @@ import AppLinks import Calendar import DataStore import DesignSystem +import Formatting import Foundation import Intelligence import SummaryPromptUI @@ -343,7 +344,7 @@ public final class MeetingDetailViewModel { !transcript.segments.isEmpty else { return } - let text = TranscriptContent.plainText( + let text = TranscriptTextFormatting.render( transcript.segments, names: displayedSpeakerNames ) @@ -605,8 +606,8 @@ public extension MeetingDetailViewModel { /// Speaker ID -> display name map derived from the displayed /// transcript's speaker assignments. Passed to `TranscriptListView` - /// (and `TranscriptContent`) for name replacement in each row and - /// for the view's `Equatable` re-render trigger. + /// (and `TranscriptTextFormatting`) for name replacement in each row + /// and for the view's `Equatable` re-render trigger. var displayedSpeakerNames: [Int: String] { displayedTranscript?.speakerAssignments.mapValues(\.name) ?? [:] } diff --git a/Packages/BiscottiKit/Sources/MeetingDetailUI/TranscriptContent.swift b/Packages/BiscottiKit/Sources/MeetingDetailUI/TranscriptContent.swift index 22fa9a35..2f83d60d 100644 --- a/Packages/BiscottiKit/Sources/MeetingDetailUI/TranscriptContent.swift +++ b/Packages/BiscottiKit/Sources/MeetingDetailUI/TranscriptContent.swift @@ -3,60 +3,12 @@ import DesignSystem import Foundation import SwiftUI -/// Pure builders for transcript display and clipboard export. +/// Pure builders for transcript speaker colors. /// -/// Deterministic, side-effect-free functions over `[SegmentData]` -/// — easy to unit-test without any view or view model. +/// Deterministic, side-effect-free functions over `SegmentData` +/// — easy to unit-test without any view or view model. Name resolution +/// and plain-text rendering live in `Formatting.TranscriptTextFormatting`. public enum TranscriptContent { - // MARK: - Display name - - /// The display name for a segment's speaker: the assigned person name - /// when the segment's `speakerID` is mapped in `names`, otherwise the - /// original diarization `speakerLabel`. - /// - /// Shared by the transcript row (`TranscriptListView`) and `plainText` - /// so on-screen and copied text resolve names identically. - /// - /// - Parameters: - /// - segment: The segment whose speaker name to resolve. - /// - names: A map of diarization speaker ID to assigned display name. - public static func displayName( - for segment: SegmentData, names: [Int: String] - ) -> String { - if let sid = segment.speakerID, let assignedName = names[sid] { - return assignedName - } - return segment.speakerLabel - } - - // MARK: - Plain text (for pasteboard) - - /// Builds a plain-text rendering of the transcript for clipboard copy. - /// - /// Format per turn: - /// ``` - /// MM:SS - /// - /// ``` - /// Blank line between turns. - /// - /// - Parameters: - /// - segments: The transcript segments to render. - /// - names: Optional speaker-ID-to-name map; same semantics as - /// `displayName(for:names:)`. - public static func plainText( - _ segments: [SegmentData], - names: [Int: String] = [:] - ) -> String { - segments.map { segment in - let name = displayName(for: segment, names: names) - let timeText = TimeFormatting.formatPlaybackTime(segment.startTime) - let trimmedText = segment.text.drop(while: \.isWhitespace) - return "\(name) \(timeText)\n\(trimmedText)" - } - .joined(separator: "\n\n") - } - // MARK: - Speaker color /// Stable per-speaker color from the shared avatar palette. diff --git a/Packages/BiscottiKit/Sources/MeetingDetailUI/TranscriptListView.swift b/Packages/BiscottiKit/Sources/MeetingDetailUI/TranscriptListView.swift index 4f10bc4f..08b37544 100644 --- a/Packages/BiscottiKit/Sources/MeetingDetailUI/TranscriptListView.swift +++ b/Packages/BiscottiKit/Sources/MeetingDetailUI/TranscriptListView.swift @@ -1,5 +1,6 @@ import DataStore import DesignSystem +import Formatting import SwiftUI /// Displays a transcript as a recycling `List` of per-segment rows, @@ -102,7 +103,7 @@ struct TranscriptListView: View { ForEach(segments) { segment in TranscriptSegmentRow( segment: segment, - speakerName: TranscriptContent.displayName( + speakerName: TranscriptTextFormatting.displayName( for: segment, names: speakerNames ), speakerColor: TranscriptContent.speakerColor( diff --git a/Packages/BiscottiKit/Sources/MeetingListUI/MeetingListView.swift b/Packages/BiscottiKit/Sources/MeetingListUI/MeetingListView.swift index dde4bb95..6445f312 100644 --- a/Packages/BiscottiKit/Sources/MeetingListUI/MeetingListView.swift +++ b/Packages/BiscottiKit/Sources/MeetingListUI/MeetingListView.swift @@ -2,6 +2,7 @@ import AppCore import AppKit import DataStore import DesignSystem +import Formatting import SwiftUI /// The Meetings screen's left-bar list: a native `List` with pinned diff --git a/Packages/BiscottiKit/Sources/MeetingListUI/MeetingListViewModel.swift b/Packages/BiscottiKit/Sources/MeetingListUI/MeetingListViewModel.swift index 5d840d3a..b78b84de 100644 --- a/Packages/BiscottiKit/Sources/MeetingListUI/MeetingListViewModel.swift +++ b/Packages/BiscottiKit/Sources/MeetingListUI/MeetingListViewModel.swift @@ -1,6 +1,7 @@ import AppCore import DataStore import DesignSystem +import Formatting import Foundation /// A group of meetings for list display with a section header. diff --git a/Packages/BiscottiKit/Sources/MenuBarUI/MenuBarViewModel.swift b/Packages/BiscottiKit/Sources/MenuBarUI/MenuBarViewModel.swift index ee6e9bb3..532fbfd2 100644 --- a/Packages/BiscottiKit/Sources/MenuBarUI/MenuBarViewModel.swift +++ b/Packages/BiscottiKit/Sources/MenuBarUI/MenuBarViewModel.swift @@ -3,6 +3,7 @@ import AppKit import Calendar import DataStore import DesignSystem +import Formatting import Foundation /// View model for the `MenuBarExtra` content and label views. diff --git a/Packages/BiscottiKit/Sources/SettingsUI/SettingsCalendarSection.swift b/Packages/BiscottiKit/Sources/SettingsUI/SettingsCalendarSection.swift index 03157c3c..56cc5374 100644 --- a/Packages/BiscottiKit/Sources/SettingsUI/SettingsCalendarSection.swift +++ b/Packages/BiscottiKit/Sources/SettingsUI/SettingsCalendarSection.swift @@ -7,7 +7,7 @@ import SwiftUI extension SettingsView { var calendarSection: some View { - Section(Self.sectionTitles[5]) { + Section(Self.sectionTitles[6]) { if viewModel.calendarState == .authorized { if viewModel.calendarGroups.isEmpty { settingsCalendarEmptyState diff --git a/Packages/BiscottiKit/Sources/SettingsUI/SettingsImportExportSection.swift b/Packages/BiscottiKit/Sources/SettingsUI/SettingsImportExportSection.swift new file mode 100644 index 00000000..83117a71 --- /dev/null +++ b/Packages/BiscottiKit/Sources/SettingsUI/SettingsImportExportSection.swift @@ -0,0 +1,197 @@ +import DesignSystem +import SwiftUI + +// MARK: - Import/Export section (functional spec §6) + +extension SettingsView { + /// "Learn more" target: the user-facing guide shipped in the repo + /// (`App/ImportingExporting.md`), read on GitHub — the same + /// treatment as the MCP row's help link. + static let importExportGuideURL = URL( + string: "https://github.com/scosman/Biscotti/blob/main/App/ImportingExporting.md" + )! + + static let importRowTitle = "Import Meetings" + static let importRowSubtitle = "Import meetings from other apps, via CSV." + static let exportRowTitle = "Export Meetings" + static let exportRowSubtitle = "Export all meetings to CSV." + static let learnMoreTitle = "Learn more" + + var importExportSection: some View { + Section(Self.sectionTitles[5]) { + importMeetingsRow + exportMeetingsRow + } + .alert( + viewModel.importAlert?.title ?? "", + isPresented: importExportAlertBinding, + presenting: viewModel.importAlert + ) { state in + alertButtons(for: state) + } message: { state in + Text(state.body) + } + } + + private var importMeetingsRow: some View { + HStack { + VStack(alignment: .leading, spacing: Tokens.spacingXS) { + Text(Self.importRowTitle) + learnMoreSubtitle(Self.importRowSubtitle) + } + + Spacer() + + if viewModel.importInFlight { + ProgressView() + .controlSize(.small) + } else { + Button("Import") { + Task { await viewModel.beginImport() } + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(viewModel.importExportBusy) + } + } + } + + private var exportMeetingsRow: some View { + HStack { + VStack(alignment: .leading, spacing: Tokens.spacingXS) { + Text(Self.exportRowTitle) + learnMoreSubtitle(Self.exportRowSubtitle) + } + + Spacer() + + if viewModel.exportInFlight { + ProgressView() + .controlSize(.small) + } else { + Button("Export") { + Task { await viewModel.beginExport() } + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(viewModel.importExportBusy) + } + } + } + + /// Subtitle with an inline "Learn more" link. Colored directly on + /// the label, not via `.tint`: link and Form-row styling resolve + /// their own (system blue) accent and ignore a tint set at this + /// scope — the same construction as the MCP row's "How to connect". + private func learnMoreSubtitle(_ text: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: Tokens.spacingSM) { + Text(text) + .font(Tokens.metadataFont) + .foregroundStyle(Tokens.secondaryText) + Button { + NSWorkspace.shared.open(Self.importExportGuideURL) + } label: { + Text(Self.learnMoreTitle) + .font(Tokens.metadataFont) + .foregroundStyle(.sage) + } + .buttonStyle(.borderless) + .controlSize(.small) + } + } + + // MARK: - Alert + + @ViewBuilder + private func alertButtons(for state: ImportAlertState) -> some View { + switch state { + case .blocked, .result, .failure: + // The cancel role lets Escape dismiss a single-button alert. + Button("OK", role: .cancel) { viewModel.dismissImportAlert() } + + case .review: + // Cancel is the default action and Continue is secondary + // (functional spec §3.3). This deliberately differs from the + // MCP row's confirm alert, whose *confirm* button is the + // default action — here the spec makes Cancel the safe + // default. + Button("Cancel", role: .cancel) { + viewModel.cancelImportReview() + } + .keyboardShortcut(.defaultAction) + Button("Continue") { + Task { await viewModel.confirmImport() } + } + + #if DEBUG + case .confirmDeleteImported: + // Never presented through this modifier (the binding + // excludes it); the branch keeps the switch exhaustive. + Button("OK", role: .cancel) { viewModel.dismissImportAlert() } + #endif + } + } + + /// Presents every import/export alert except the debug delete + /// confirmation, which the Debug section's own modifier presents. + private var importExportAlertBinding: Binding { + Binding( + get: { + guard let state = viewModel.importAlert else { return false } + #if DEBUG + return !state.isDeleteConfirmation + #else + return true + #endif + }, + set: { shown in + if !shown { viewModel.dismissImportAlert() } + } + ) + } +} + +// MARK: - Debug: Delete Imported Meetings row (functional spec §6.1) + +#if DEBUG + extension SettingsView { + /// Debug-only bulk delete of imported meetings, styled like its + /// Debug-section neighbours. Never compiled into release builds. + var deleteImportedMeetingsRow: some View { + Button { + Task { await viewModel.promptDeleteImportedMeetings() } + } label: { + Label("Delete Imported Meetings", systemImage: "trash") + } + .foregroundStyle(.sage) + .alert( + viewModel.importAlert?.title ?? "", + isPresented: deleteImportedAlertBinding, + presenting: viewModel.importAlert + ) { _ in + // Cancel is the default action (functional spec §6.1), + // matching the review alert (§3.3). + Button("Cancel", role: .cancel) { + viewModel.dismissImportAlert() + } + .keyboardShortcut(.defaultAction) + Button("Delete", role: .destructive) { + Task { await viewModel.confirmDeleteImportedMeetings() } + } + } message: { state in + Text(state.body) + } + } + + /// Presents only the debug delete confirmation — the Import/Export + /// section's modifier handles every other state. + private var deleteImportedAlertBinding: Binding { + Binding( + get: { viewModel.importAlert?.isDeleteConfirmation == true }, + set: { shown in + if !shown { viewModel.dismissImportAlert() } + } + ) + } + } +#endif diff --git a/Packages/BiscottiKit/Sources/SettingsUI/SettingsView.swift b/Packages/BiscottiKit/Sources/SettingsUI/SettingsView.swift index 0eb8e84c..020c3e9f 100644 --- a/Packages/BiscottiKit/Sources/SettingsUI/SettingsView.swift +++ b/Packages/BiscottiKit/Sources/SettingsUI/SettingsView.swift @@ -44,6 +44,7 @@ public struct SettingsView: View { "Notifications", "AI Enhancements", "Custom Vocabulary", + "Import/Export", "Calendars" ] @@ -74,6 +75,7 @@ public struct SettingsView: View { notificationsSection aiEnhancementsSection customVocabularySection + importExportSection calendarSection #if DEBUG @@ -246,6 +248,8 @@ public struct SettingsView: View { Label("Clear Selected LLM", systemImage: "arrow.uturn.backward") } .foregroundStyle(.sage) + + deleteImportedMeetingsRow } } #endif diff --git a/Packages/BiscottiKit/Sources/SettingsUI/SettingsViewModel+ImportExport.swift b/Packages/BiscottiKit/Sources/SettingsUI/SettingsViewModel+ImportExport.swift new file mode 100644 index 00000000..0a3b1c06 --- /dev/null +++ b/Packages/BiscottiKit/Sources/SettingsUI/SettingsViewModel+ImportExport.swift @@ -0,0 +1,397 @@ +import AppCore +import AppKit +import DataStore +import Foundation +import ImportExport +import UniformTypeIdentifiers + +/// Drives the Import/Export section's `.alert` (architecture §6.1). +/// `.result` and `.failure` carry their own titles so the import and +/// debug-delete outcomes (and the import vs export failures) can each +/// name themselves; otherwise this matches the architecture sketch. +enum ImportAlertState: Equatable, Identifiable { + /// Critical errors. Single dismiss button; nothing is imported + /// (functional spec §3.3). + case blocked(title: String, body: String) + /// Warnings only. Cancel (default action) / Continue. + case review(body: String) + /// Post-commit summary or debug-delete outcome (functional spec §3.4). + case result(title: String, body: String) + /// Commit, export, or save failure (architecture §8). + case failure(title: String, body: String) + #if DEBUG + /// Debug bulk-delete confirmation (functional spec §6.1). Cancel + /// (default) / Delete (destructive). + case confirmDeleteImported(title: String, body: String) + #endif + + var id: String { + switch self { + case let .blocked(title, body): "blocked|\(title)|\(body)" + case let .review(body): "review|\(body)" + case let .result(title, body): "result|\(title)|\(body)" + case let .failure(title, body): "failure|\(title)|\(body)" + #if DEBUG + case let .confirmDeleteImported(title, body): + "confirmDelete|\(title)|\(body)" + #endif + } + } +} + +extension ImportAlertState { + /// The review alert's title. Lives here (not on the @MainActor view + /// model) because `title` is read from this nonisolated enum. + static let reviewTitle = "Import This File?" + + /// The alert title. `.review` has no payload title — it always asks + /// the same question. + var title: String { + switch self { + case let .blocked(title, _), + let .result(title, _), + let .failure(title, _): + title + case .review: + Self.reviewTitle + #if DEBUG + case let .confirmDeleteImported(title, _): + title + #endif + } + } + + /// The alert body text. + var body: String { + switch self { + case let .blocked(_, body), + let .result(_, body), + let .failure(_, body): + body + case let .review(body): + body + #if DEBUG + case let .confirmDeleteImported(_, body): + body + #endif + } + } + + #if DEBUG + /// True only for the debug delete confirmation. The Import/Export + /// section's alert modifier excludes this state; the Debug + /// section's own modifier presents it — the two never double-fire. + var isDeleteConfirmation: Bool { + if case .confirmDeleteImported = self { return true } + return false + } + #endif +} + +// MARK: - Import/Export actions + +public extension SettingsViewModel { + /// Runs the import flow (functional spec §2.1): open panel → scan → + /// blocked / review / commit straight through. Nothing is written + /// before the user clears the review alert. + func beginImport() async { + guard !importExportBusy else { return } + importExportBusy = true + defer { importExportBusy = false } + + guard let url = presentOpenPanel() else { return } + // The spinner covers the scan/commit, not the file chooser + // (architecture §6.1). + importInFlight = true + defer { importInFlight = false } + + let result = await appCore.scanMeetingImport(at: url) + + if !result.criticalErrors.isEmpty { + importAlert = .blocked( + title: Self.importBlockedTitle, + body: Self.blockedBody(for: result) + ) + return + } + if !result.warnings.isEmpty { + pendingImport = result + importAlert = .review(body: Self.reviewBody(for: result)) + return + } + + // No errors and no warnings. This deliberately includes the + // header-only file (valid header, zero data rows), which scans + // clean with zero drafts: the commit inserts nothing and reports + // "Imported 0 meetings" (functional spec §3.3/§3.4). + await commitImport(result) + } + + /// Continue from the review alert: commits the held scan result + /// exactly as scanned (functional spec §2.1 step 3). + func confirmImport() async { + guard !importExportBusy, let result = pendingImport else { return } + pendingImport = nil + importExportBusy = true + importInFlight = true + defer { + importExportBusy = false + importInFlight = false + } + await commitImport(result) + } + + /// Cancel from the review alert: drops the held scan result without + /// writing anything. + func cancelImportReview() { + pendingImport = nil + importAlert = nil + } + + /// Runs the export flow (functional spec §5.2): spinner → generate + /// off the main actor → save panel → move, or delete the temp file. + func beginExport() async { + guard !importExportBusy else { return } + importExportBusy = true + exportInFlight = true + defer { + importExportBusy = false + exportInFlight = false + } + + let tempURL: URL + do { + tempURL = try await appCore.exportMeetingsCSV() + } catch { + importAlert = .failure( + title: Self.exportFailedTitle, + body: "The export failed: \(Self.detail(for: error))." + ) + return + } + + // The spinner clears before the save dialog opens (functional + // spec §5.2); importExportBusy stays raised until the move + // settles so neither button can start a second operation. + exportInFlight = false + finishExport(tempURL) + } + + /// Clears the presented alert (the alert-dismissal binding) and drops + /// any held scan result — only the review alert's Continue may commit + /// one, so no dismissal path may leave it pending. + func dismissImportAlert() { + pendingImport = nil + importAlert = nil + } +} + +extension SettingsViewModel { + func commitImport(_ result: ImportScanResult) async { + do { + let summary = try await appCore.commitMeetingImport(result) + importAlert = .result( + title: Self.importResultTitle, + body: Self.resultBody(for: summary) + ) + } catch { + importAlert = .failure( + title: Self.importFailedTitle, + body: "The import failed: \(Self.detail(for: error))." + ) + } + } + + func finishExport(_ tempURL: URL) { + guard let destination = presentSavePanel(tempURL.lastPathComponent) + else { + // Cancelling the save panel is not an error; the temp file + // is deleted (functional spec §5.2). + try? FileManager.default.removeItem(at: tempURL) + return + } + + do { + // `path(percentEncoded: false)`: the encoded form of a path + // with a space or non-ASCII character never matches a real + // file, which would send an already-confirmed replace down + // the move path and lose the export. + if FileManager.default.fileExists( + atPath: destination.path(percentEncoded: false) + ) { + // The save panel already asked about replacing an existing + // file, so the replacement is confirmed. replaceItemAt + // keeps the original intact when the swap fails (disk + // full, volume error) — the user confirmed replacing the + // old file, not losing it. + // The returned URL (where the new item landed) is + // deliberately ignored: on the local filesystem it is + // the destination itself. + _ = try FileManager.default.replaceItemAt( + destination, withItemAt: tempURL + ) + // A successful replace consumes the new item; a + // cross-volume swap may have copied it instead, so clean + // up best-effort. + try? FileManager.default.removeItem(at: tempURL) + } else { + try FileManager.default.moveItem(at: tempURL, to: destination) + } + } catch { + importAlert = .failure( + title: Self.exportFailedTitle, + body: "The file could not be saved: \(Self.detail(for: error))." + ) + // A failed move still consumes the temp file (architecture §8). + try? FileManager.default.removeItem(at: tempURL) + } + } +} + +// MARK: - Live panels (functional spec §6, §5.2) + +extension SettingsViewModel { + /// Live open panel: `.csv` files only, single selection. + @MainActor + static func presentCSVOpenPanel() -> URL? { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.commaSeparatedText] + panel.allowsMultipleSelection = false + panel.canChooseFiles = true + panel.canChooseDirectories = false + guard panel.runModal() == .OK else { return nil } + return panel.url + } + + /// Live save panel, restricted to `.csv`, pre-filled with the generated + /// export filename, and opened in the user's Downloads folder (falling + /// back to the system-default directory when it cannot be resolved). + @MainActor + static func presentCSVSavePanel(fileName: String) -> URL? { + let panel = NSSavePanel() + panel.nameFieldStringValue = fileName + // Keeps the `.csv` extension attached even when the user strips it. + panel.allowedContentTypes = [.commaSeparatedText] + panel.canCreateDirectories = true + panel.directoryURL = try? FileManager.default.url( + for: .downloadsDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: false + ) + guard panel.runModal() == .OK else { return nil } + return panel.url + } +} + +// MARK: - Alert copy (verbatim-identity policy: these statics == the spec text) + +extension SettingsViewModel { + static let importBlockedTitle = "Cannot Import This File" + static let importResultTitle = "Import Complete" + static let importFailedTitle = "Import Failed" + static let exportFailedTitle = "Export Failed" + + /// Body for the blocking alert: each distinct problem with its count + /// and up to 5 example row numbers (functional spec §3.3) — the + /// critical errors first, then, when the scan also produced warnings + /// (an all-duplicates file, a file whose every row was skipped), each + /// warning with its count, so "nothing to import" always says why. + static func blockedBody(for result: ImportScanResult) -> String { + (result.criticalErrors.map(\.message) + + result.warnings.map(\.message)) + .joined(separator: "\n") + } + + /// Body for the review alert: each warning with its count, then how + /// many meetings will actually be imported (functional spec §3.3). + static func reviewBody(for result: ImportScanResult) -> String { + let noun = result.drafts.count == 1 ? "meeting" : "meetings" + return result.warnings.map(\.message).joined(separator: "\n") + + "\n\n\(result.drafts.count) \(noun) will be imported." + } + + /// Body for the result alert (functional spec §3.4). + static func resultBody(for summary: ImportCommitSummary) -> String { + var lines = [ + "Imported \(summary.imported) " + + (summary.imported == 1 ? "meeting" : "meetings") + "." + ] + if summary.skippedExisting > 0 { + lines.append( + "\(summary.skippedExisting) " + + (summary.skippedExisting == 1 ? "row was" : "rows were") + + " skipped because those meetings already exist." + ) + } + if summary.skippedMisformatted > 0 { + lines.append( + "\(summary.skippedMisformatted) " + + (summary.skippedMisformatted == 1 ? "row was" : "rows were") + + " skipped because they were missing a required value." + ) + } + return lines.joined(separator: "\n") + } + + /// A short, user-facing reason for a store or exporter error. The + /// exporter propagates underlying Foundation errors as-is; only the + /// store wraps its own reason strings. + static func detail(for error: Error) -> String { + switch error { + case let DataStoreError.saveFailed(reason): + reason + default: + error.localizedDescription + } + } +} + +// MARK: - Debug: Delete Imported Meetings (functional spec §6.1) + +#if DEBUG + extension SettingsViewModel { + /// Counts imported meetings and either presents the confirmation + /// (exact copy: "Delete N meetings?" / "This will delete N + /// meetings (and leave M meetings).") or, when there are none, + /// the "No imported meetings to delete." result. + func promptDeleteImportedMeetings() async { + let counts = await appCore.importedMeetingCounts() + guard counts.imported > 0 else { + importAlert = .result( + title: Self.deleteImportedAlertTitle, + body: Self.deleteImportedNoneBody + ) + return + } + importAlert = .confirmDeleteImported( + title: "Delete \(counts.imported) meetings?", + body: "This will delete \(counts.imported) meetings " + + "(and leave \(counts.remaining) meetings)." + ) + } + + /// Delete from the confirmation: deletes, refreshes, and reports + /// "Deleted N meetings." + func confirmDeleteImportedMeetings() async { + do { + let deleted = try await appCore.deleteImportedMeetings() + importAlert = .result( + title: Self.deleteImportedAlertTitle, + body: "Deleted \(deleted) " + + (deleted == 1 ? "meeting" : "meetings") + "." + ) + } catch { + importAlert = .failure( + title: Self.deleteImportedAlertTitle, + body: "The delete failed: \(Self.detail(for: error))." + ) + } + } + } + + extension SettingsViewModel { + static let deleteImportedAlertTitle = "Delete Imported Meetings" + static let deleteImportedNoneBody = "No imported meetings to delete." + } +#endif diff --git a/Packages/BiscottiKit/Sources/SettingsUI/SettingsViewModel.swift b/Packages/BiscottiKit/Sources/SettingsUI/SettingsViewModel.swift index 239e3f5e..dc7b25a9 100644 --- a/Packages/BiscottiKit/Sources/SettingsUI/SettingsViewModel.swift +++ b/Packages/BiscottiKit/Sources/SettingsUI/SettingsViewModel.swift @@ -3,6 +3,7 @@ import AppKit import Calendar import DataStore import Foundation +import ImportExport import Intelligence import LocalLLM import MCPServer @@ -53,6 +54,16 @@ public final class SettingsViewModel { /// `SMAppService.mainApp.status == .enabled`. Injected in tests. private let readLaunchAtLoginStatus: @MainActor () -> Bool + /// Seam for the CSV open panel. Defaults to a live `NSOpenPanel` + /// limited to `.csv`, single selection (functional spec §6). + /// Injected in tests. + let presentOpenPanel: @MainActor () -> URL? + + /// Seam for the export save panel, handed the generated filename. + /// Defaults to a live `NSSavePanel` pre-filled with that name + /// (functional spec §5.2). Injected in tests. + let presentSavePanel: @MainActor (String) -> URL? + // MARK: - General /// Launch at login toggle state. Reflects `SMAppService.mainApp.status` @@ -117,6 +128,31 @@ public final class SettingsViewModel { /// The set of enabled calendar IDs. nil = all enabled. public private(set) var enabledCalendarIDs: Set? + // MARK: - Import/Export + + /// True while an import or export operation (scan, commit, CSV + /// generation, or save panel) is in flight. Disables both section + /// buttons (functional spec §6). Internal setter: written by the + /// import/export actions extension. + public internal(set) var importExportBusy = false + + /// True while CSV generation runs; the Export row shows a spinner in + /// place of its button (functional spec §5.2). + public internal(set) var exportInFlight = false + + /// True while a CSV file is being scanned or committed; the Import + /// row shows a spinner in place of its button. + public internal(set) var importInFlight = false + + /// The scan result held between the review alert and its Cancel / + /// Continue decision (functional spec §2.1). Internal (not public): + /// only the section's own views and `@testable` tests read it. + var pendingImport: ImportScanResult? + + /// The alert currently presented by the Import/Export rows (or, in + /// debug builds, the delete-imported confirmation). + var importAlert: ImportAlertState? + // MARK: - Permissions /// True while a system-audio tone-probe is running. @@ -132,9 +168,18 @@ public final class SettingsViewModel { /// - readLaunchAtLoginStatus: Closure returning the system's /// launch-at-login state. Defaults to `SMAppService.mainApp.status`. /// Override in tests for determinism. + /// - presentOpenPanel: Closure presenting the CSV open panel and + /// returning the chosen file, or nil when cancelled. Defaults to a + /// live `NSOpenPanel`. Override in tests. + /// - presentSavePanel: Closure presenting the save panel for the + /// generated export filename and returning the chosen destination, + /// or nil when cancelled. Defaults to a live `NSSavePanel`. + /// Override in tests. public init( core: AppCore, - readLaunchAtLoginStatus: (@MainActor () -> Bool)? = nil + readLaunchAtLoginStatus: (@MainActor () -> Bool)? = nil, + presentOpenPanel: (@MainActor () -> URL?)? = nil, + presentSavePanel: (@MainActor (String) -> URL?)? = nil ) { appCore = core self.readLaunchAtLoginStatus = readLaunchAtLoginStatus ?? { @@ -144,6 +189,12 @@ public final class SettingsViewModel { false #endif } + self.presentOpenPanel = presentOpenPanel ?? { + Self.presentCSVOpenPanel() + } + self.presentSavePanel = presentSavePanel ?? { fileName in + Self.presentCSVSavePanel(fileName: fileName) + } } // MARK: - General actions diff --git a/Packages/BiscottiKit/Tests/AppCoreTests/AppCoreImportExportTests.swift b/Packages/BiscottiKit/Tests/AppCoreTests/AppCoreImportExportTests.swift new file mode 100644 index 00000000..97386925 --- /dev/null +++ b/Packages/BiscottiKit/Tests/AppCoreTests/AppCoreImportExportTests.swift @@ -0,0 +1,208 @@ +import AppCore +import BiscottiTestSupport +import Foundation +import ImportExport +import Testing +@testable import DataStore + +@Suite("AppCore -- CSV import/export") +@MainActor +struct AppCoreImportExportTests { + // MARK: - Scan + + @Test("scanMeetingImport returns drafts for a clean file") + func scanCleanFile() async throws { + let fix = try makeCoreFixture(testName: "CSVScanClean") + defer { fix.cleanup() } + + let url = try Self.writeCSV( + "id,title,created,summary\nabc-1,Standup,2026-01-03T14:26:42Z,Hello" + ) + defer { try? FileManager.default.removeItem(at: url) } + let result = await fix.core.scanMeetingImport(at: url) + + #expect(result.criticalErrors.isEmpty) + #expect(result.warnings.isEmpty) + #expect(result.drafts.count == 1) + #expect(result.drafts.first?.externalID == "abc-1") + #expect(result.drafts.first?.title == "Standup") + } + + @Test("scanMeetingImport on an unreadable path reports .unreadableFile") + func scanUnreadableFile() async throws { + let fix = try makeCoreFixture(testName: "CSVScanUnreadable") + defer { fix.cleanup() } + + let missing = FileManager.default.temporaryDirectory + .appendingPathComponent("missing-\(UUID().uuidString).csv") + let result = await fix.core.scanMeetingImport(at: missing) + + #expect(result.drafts.isEmpty) + guard case .unreadableFile = result.criticalErrors.first else { + Issue.record( + "expected .unreadableFile, got \(result.criticalErrors)" + ) + return + } + } + + // MARK: - Commit + + @Test("commitMeetingImport inserts the drafts and reloads summaries") + func commitInsertsAndReloads() async throws { + let fix = try makeCoreFixture(testName: "CSVCommit") + defer { fix.cleanup() } + + let url = try Self.writeCSV( + "id,title,created\n" + + "abc-1,Standup,2026-01-03T14:26:42Z\n" + + "abc-2,Retro,2026-01-04T09:00:00Z\n" + ) + defer { try? FileManager.default.removeItem(at: url) } + let result = await fix.core.scanMeetingImport(at: url) + let summary = try await fix.core.commitMeetingImport(result) + + #expect(summary.imported == 2) + #expect(summary.skippedExisting == 0) + #expect(summary.skippedMisformatted == 0) + #expect(fix.core.summaries.count == 2) + // The batch stamp is what the debug delete (and a future + // un-import) keys on. Read on-actor: the @Model is not Sendable. + let stampedID = result.drafts[0].meetingID + let stamped = try await fix.store.read { store in + try store.meeting(id: stampedID)?.importBatch != nil + } + #expect(stamped) + } + + @Test("header-only CSV commits zero meetings and leaves the store untouched") + func commitHeaderOnly() async throws { + let fix = try makeCoreFixture(testName: "CSVCommitHeaderOnly") + defer { fix.cleanup() } + + // The carried-in Phase 3 state: valid header, zero data rows + // scans to no errors, no warnings, no drafts — and canProceed + // is false only because there is nothing to insert. The commit + // must handle it deliberately: zero imported, no store writes. + let url = try Self.writeCSV("id,title,created,summary,notes,transcript") + defer { try? FileManager.default.removeItem(at: url) } + let result = await fix.core.scanMeetingImport(at: url) + #expect(result.criticalErrors.isEmpty) + #expect(result.warnings.isEmpty) + #expect(result.drafts.isEmpty) + #expect(result.canProceed == false) + #expect(result.needsReview == false) + + let summary = try await fix.core.commitMeetingImport(result) + #expect(summary.imported == 0) + #expect(try await fix.store.meetingSummaries().isEmpty) + #expect(fix.core.summaries.isEmpty) + } + + @Test("commit derives skip counts from the scan's warnings") + func commitDerivesSkipCounts() async throws { + let fix = try makeCoreFixture(testName: "CSVCommitSkips") + defer { fix.cleanup() } + + let existingID = try await fix.store.createMeeting(title: "Existing") + let url = try Self.writeCSV( + "id,title,created\n" + + "\(existingID.uuidString),Existing,2026-01-03T14:26:42Z\n" + + "abc-new,Fresh,2026-01-04T09:00:00Z\n" + + ",No ID,2026-01-05T09:00:00Z\n" + ) + defer { try? FileManager.default.removeItem(at: url) } + let result = await fix.core.scanMeetingImport(at: url) + let summary = try await fix.core.commitMeetingImport(result) + + #expect(summary.imported == 1) + #expect(summary.skippedExisting == 1) + #expect(summary.skippedMisformatted == 1) + #expect(fix.core.summaries.count == 2) + } + + // MARK: - Export + + @Test("exportMeetingsCSV writes the canonical header to the chosen directory") + func exportWritesHeader() async throws { + let fix = try makeCoreFixture(testName: "CSVExport") + defer { fix.cleanup() } + + _ = try await fix.store.createMeeting(title: "One") + + // A per-test directory: the generated filename has second + // granularity, so same-second exports must not share one. + let directory = FileManager.default.temporaryDirectory + .appending(path: "csv-export-\(UUID().uuidString)", directoryHint: .isDirectory) + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: directory) } + + let url = try await fix.core.exportMeetingsCSV(to: directory) + + #expect(url.lastPathComponent.hasPrefix("Biscotti_export_")) + #expect(url.lastPathComponent.hasSuffix(".csv")) + + let text = try String(contentsOf: url, encoding: .utf8) + #expect(text.hasPrefix("id,title,created,summary,notes,transcript\r\n")) + #expect(text.components(separatedBy: .newlines).count >= 2) + } + + // MARK: - Debug bulk delete + + #if DEBUG + @Test("importedMeetingCounts splits imported from remaining") + func countsSplitImportedAndRemaining() async throws { + let fix = try makeCoreFixture(testName: "CSVDebugCounts") + defer { fix.cleanup() } + + _ = try await fix.store.createMeeting(title: "Recorded") + try await fix.store.insertImportedMeetings( + [Self.draft(title: "Imported 1"), Self.draft(title: "Imported 2")], + batchID: 1 + ) + + let counts = await fix.core.importedMeetingCounts() + #expect(counts.imported == 2) + #expect(counts.remaining == 1) + } + + @Test("deleteImportedMeetings removes only imported meetings and reloads") + func deleteRemovesOnlyImported() async throws { + let fix = try makeCoreFixture(testName: "CSVDebugDelete") + defer { fix.cleanup() } + + let recordedID = try await fix.store.createMeeting(title: "Recorded") + try await fix.store.insertImportedMeetings( + [Self.draft(title: "Imported")], + batchID: 1 + ) + await fix.core.reloadSummaries() + #expect(fix.core.summaries.count == 2) + + let deleted = try await fix.core.deleteImportedMeetings() + #expect(deleted == 1) + #expect(fix.core.summaries.count == 1) + #expect(try await fix.store.meetingExists(id: recordedID)) + } + #endif + + // MARK: - Helpers + + private static func writeCSV(_ text: String) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("csv-\(UUID().uuidString).csv") + try Data(text.utf8).write(to: url) + return url + } + + private static func draft(title: String) -> ImportedMeetingDraft { + ImportedMeetingDraft( + meetingID: UUID(), + title: title, + created: Date(timeIntervalSince1970: 1_767_000_000) + ) + } +} diff --git a/Packages/BiscottiKit/Tests/AppCoreTests/MinuteTickTests.swift b/Packages/BiscottiKit/Tests/AppCoreTests/MinuteTickTests.swift index e1032957..dde3c829 100644 --- a/Packages/BiscottiKit/Tests/AppCoreTests/MinuteTickTests.swift +++ b/Packages/BiscottiKit/Tests/AppCoreTests/MinuteTickTests.swift @@ -2,6 +2,7 @@ import BiscottiTestSupport import Calendar import DataStore import DesignSystem +import Formatting import Foundation import Testing @testable import AppCore diff --git a/Packages/BiscottiKit/Tests/DataStoreTests/ImportExportStoreTests.swift b/Packages/BiscottiKit/Tests/DataStoreTests/ImportExportStoreTests.swift new file mode 100644 index 00000000..fc94718a --- /dev/null +++ b/Packages/BiscottiKit/Tests/DataStoreTests/ImportExportStoreTests.swift @@ -0,0 +1,520 @@ +import Foundation +import Testing +import Transcription +@testable import DataStore + +// MARK: - Shared Helpers + +private func makeStore() throws -> DataStore { + try DataStore(storage: .inMemory) +} + +private func makeDraft( + id: UUID = UUID(), + externalID: String? = nil, + title: String = "Imported", + created: Date = Date(timeIntervalSince1970: 1_700_000_000), + summary: String = "", + notes: String = "", + transcript: [TranscriptSegmentDraft] = [] +) -> ImportedMeetingDraft { + ImportedMeetingDraft( + meetingID: id, + externalID: externalID, + title: title, + created: created, + summary: summary, + notes: notes, + transcript: transcript + ) +} + +private func makeSegments() -> [TranscriptSegmentDraft] { + [ + TranscriptSegmentDraft( + speakerID: 0, speakerLabel: "Steve", startTime: 23, text: "Let's get started." + ), + TranscriptSegmentDraft( + speakerID: 1, speakerLabel: "Priya", startTime: 31, text: "I pushed the fix." + ), + TranscriptSegmentDraft( + speakerID: 0, speakerLabel: "Steve", startTime: 40, text: "Great." + ) + ] +} + +private func twoSpeakerResult( + texts: [String] = ["Hello world", "Hi there"] +) -> TranscriptResult { + TranscriptResult( + transcriptionMethodId: "v1", + language: "en", + speakerCount: 2, + segments: texts.enumerated().map { index, text in + TranscriptSegment( + speakerID: index, + speakerLabel: "Speaker \(index)", + startTime: TimeInterval(index) * 3.5, + endTime: TimeInterval(index + 1) * 3.5, + text: text, + confidence: 0.9, + noSpeechProbability: 0.01, + words: nil + ) + }, + speakerEmbeddings: [:], + processingDuration: 1.0 + ) +} + +// MARK: - Import Write Path + +@Suite("Import write path") +struct ImportWritePathTests { + @Test("Insert creates meetings with the given UUIDs and import fields") + func insertCreatesMeetings() async throws { + let store = try makeStore() + let uuidA = UUID() + let uuidB = UUID() + let batchID = try await store.nextImportBatchID() + + let inserted = try await store.insertImportedMeetings( + [ + makeDraft(id: uuidA, title: "Meeting A"), + makeDraft(id: uuidB, externalID: "granola-42", title: "Meeting B") + ], + batchID: batchID + ) + #expect(inserted == 2) + + try await store.read { store in + let meetingA = try #require(try store.meeting(id: uuidA)) + #expect(meetingA.title == "Meeting A") + #expect(meetingA.editedTitle) + #expect(meetingA.externalID == nil) + #expect(meetingA.importBatch == batchID) + #expect(meetingA.startDate == nil) + #expect(meetingA.endDate == nil) + + let meetingB = try #require(try store.meeting(id: uuidB)) + #expect(meetingB.externalID == "granola-42") + #expect(meetingB.importBatch == batchID) + } + } + + @Test("Insert stores created as createdAt and gates editedSummary on content") + func insertSummaryAndCreated() async throws { + let store = try makeStore() + let created = Date(timeIntervalSince1970: 1_750_000_000) + let withSummary = UUID() + let withoutSummary = UUID() + + _ = try await store.insertImportedMeetings( + [ + makeDraft(id: withSummary, created: created, summary: "Recap", notes: "note"), + makeDraft(id: withoutSummary, created: created) + ], + batchID: 1 + ) + + try await store.read { store in + let withSummaryMeeting = try #require(try store.meeting(id: withSummary)) + #expect(withSummaryMeeting.createdAt == created) + #expect(withSummaryMeeting.summary == "Recap") + #expect(withSummaryMeeting.editedSummary == true) + #expect(withSummaryMeeting.notes == "note") + + let withoutSummaryMeeting = try #require(try store.meeting(id: withoutSummary)) + #expect(withoutSummaryMeeting.editedSummary == false) + } + } + + @Test("Insert creates a transcript record with ordered segments and sets preferred") + func insertCreatesTranscript() async throws { + let store = try makeStore() + let meetingID = UUID() + + _ = try await store.insertImportedMeetings( + [makeDraft(id: meetingID, transcript: makeSegments())], + batchID: 1 + ) + + try await store.read { store in + let meeting = try #require(try store.meeting(id: meetingID)) + #expect(meeting.transcripts.count == 1) + + let record = try #require(meeting.transcripts.first) + #expect(record.transcriptionMethodId == "imported") + #expect(record.language == "") + // Two distinct speaker IDs (0, 1) across three segments. + #expect(record.speakerCount == 2) + #expect(meeting.preferredTranscriptID == record.id) + + let segments = record.segments.sorted { $0.index < $1.index } + #expect(segments.count == 3) + #expect(segments.map(\.index) == [0, 1, 2]) + #expect(segments[0].text == "Let's get started.") + #expect(segments[1].speakerLabel == "Priya") + // Imported segments carry no durations. + #expect(segments[2].endTime == segments[2].startTime) + } + } + + @Test("Insert with empty transcript creates no transcript record") + func insertWithoutTranscript() async throws { + let store = try makeStore() + let meetingID = UUID() + + let inserted = try await store.insertImportedMeetings( + [makeDraft(id: meetingID)], + batchID: 1 + ) + #expect(inserted == 1) + + try await store.read { store in + let meeting = try #require(try store.meeting(id: meetingID)) + #expect(meeting.transcripts.isEmpty) + #expect(meeting.preferredTranscriptID == nil) + } + } + + @Test("Insert with no drafts returns zero") + func insertNothing() async throws { + let store = try makeStore() + let inserted = try await store.insertImportedMeetings([], batchID: 1) + #expect(inserted == 0) + } + + @Test("Insert skips drafts whose meeting ID is already taken") + func insertSkipsTakenIDs() async throws { + let store = try makeStore() + let recordedID = try await store.createMeeting(title: "Recorded") + let inBatchDup = UUID() + + let inserted = try await store.insertImportedMeetings( + [ + makeDraft(id: recordedID, title: "Clash"), + makeDraft(id: inBatchDup, title: "First"), + makeDraft(id: inBatchDup, title: "Second"), + makeDraft(title: "Fresh") + ], + batchID: 1 + ) + // One clash with the store, one clash within the batch, one insert. + #expect(inserted == 2) + + try await store.read { store in + // The recorded meeting is untouched, and the in-batch first + // occurrence wins. + let recorded = try #require(try store.meeting(id: recordedID)) + #expect(recorded.title == "Recorded") + let inBatchWinner = try #require(try store.meeting(id: inBatchDup)) + #expect(inBatchWinner.title == "First") + } + #expect(try await store.existingMeetingIdentity().meetingIDs.count == 3) + } + + @Test("existingMeetingIdentity returns both ID sets") + func existingIdentity() async throws { + let store = try makeStore() + let importedID = UUID() + _ = try await store.insertImportedMeetings( + [ + makeDraft(id: importedID), + makeDraft(externalID: "otter-7", title: "From Otter") + ], + batchID: 1 + ) + _ = try await store.createMeeting(title: "Recorded") + + let identity = try await store.existingMeetingIdentity() + #expect(identity.meetingIDs.contains(importedID)) + #expect(identity.externalIDs == ["otter-7"]) + // The recorded meeting contributes its UUID but no external ID. + #expect(identity.meetingIDs.count == 3) + } + + @Test("nextImportBatchID is epoch milliseconds of now") + func batchIDFromNow() async throws { + let store = try makeStore() + let now = Date(timeIntervalSince1970: 1_750_000_000.123) + let batchID = try await store.nextImportBatchID(now: now) + #expect(batchID == 1_750_000_000_123) + } + + @Test("nextImportBatchID increments past a colliding existing batch") + func batchIDCollision() async throws { + let store = try makeStore() + let now = Date(timeIntervalSince1970: 1_750_000_000.0) + let colliding = Int(1_750_000_000_000) + _ = try await store.insertImportedMeetings( + [makeDraft()], + batchID: colliding + ) + + let batchID = try await store.nextImportBatchID(now: now) + #expect(batchID == colliding + 1) + } + + @Test("importedMeetingCounts returns the imported/remaining split") + func importedCounts() async throws { + let store = try makeStore() + _ = try await store.createMeeting(title: "Recorded A") + _ = try await store.createMeeting(title: "Recorded B") + _ = try await store.insertImportedMeetings( + [makeDraft(), makeDraft(title: "Second")], + batchID: 1 + ) + + let counts = try await store.importedMeetingCounts() + #expect(counts.imported == 2) + #expect(counts.remaining == 2) + } +} + +// MARK: - Bulk Delete & Export Read Path + +@Suite("Imported meeting delete and export read path") +struct ImportDeleteExportTests { + @Test("meetingIDsForExport sorts by effective date descending") + func exportOrdering() async throws { + let store = try makeStore() + let base = Date(timeIntervalSince1970: 1_000_000) + + // startDate wins over createdAt; createdAt-only meetings interleave. + let middleStart = try await store.createMeeting( + title: "Middle", start: base.addingTimeInterval(1000) + ) + let oldestStart = try await store.createMeeting( + title: "Recorded old", start: base.addingTimeInterval(-5000) + ) + let newestStart = try await store.createMeeting( + title: "Newest", start: base.addingTimeInterval(5000) + ) + // Imported meeting: no startDate, sorts by createdAt. + let importedCreated = base.addingTimeInterval(3000) + let importedID = UUID() + _ = try await store.insertImportedMeetings( + [makeDraft(id: importedID, created: importedCreated)], + batchID: 1 + ) + + let ids = try await store.meetingIDsForExport() + #expect(ids == [newestStart, importedID, middleStart, oldestStart]) + } + + @Test("deleteImportedMeetings removes only imported meetings and cascades transcripts") + func deleteRemovesOnlyImported() async throws { + let store = try makeStore() + let importedID = UUID() + _ = try await store.insertImportedMeetings( + [makeDraft(id: importedID, transcript: makeSegments())], + batchID: 1 + ) + let recordedID = try await store.createMeeting(title: "Recorded") + + let deleted = try await store.deleteImportedMeetings() + #expect(deleted == 1) + #expect(try await store.meetingExists(id: importedID) == false) + #expect(try await store.meetingExists(id: recordedID) == true) + + try await store.read { store in + let transcripts = try store.fetchAllTranscripts() + let segments = try store.fetchAllSegments() + #expect(transcripts.isEmpty) + #expect(segments.isEmpty) + } + } + + @Test("deleteImportedMeetings clears search-index entries for deleted meetings") + func deleteClearsSearchIndex() async throws { + let store = try makeStore() + let importedID = UUID() + _ = try await store.insertImportedMeetings( + [makeDraft(id: importedID, title: "Xylophone Imported")], + batchID: 1 + ) + _ = try await store.createMeeting(title: "Xylophone Recorded") + + // Both are indexed once a search runs. + #expect(try await store.searchHits("xylophone", limit: 10).count == 2) + + _ = try await store.deleteImportedMeetings() + + let hits = try await store.searchHits("xylophone", limit: 10) + #expect(hits.count == 1) + #expect(hits.first?.id != importedID) + } + + @Test("deleteImportedMeetings with none imported returns zero") + func deleteNothing() async throws { + let store = try makeStore() + _ = try await store.createMeeting(title: "Recorded") + let deleted = try await store.deleteImportedMeetings() + #expect(deleted == 0) + } + + @Test("exportData resolves speaker names and returns segments in index order") + func exportDataResolution() async throws { + let store = try makeStore() + let meetingID = try await store.createMeeting(title: "Standup") + let transcriptID = try await store.addTranscript( + twoSpeakerResult(), + vocabularyUsed: [], + mappedEventIdentifier: nil, + to: meetingID + ) + try await store.setPreferredTranscript(transcriptID, for: meetingID) + let personID = try await store.findOrCreatePerson(name: "Alice Smith", email: nil) + try await store.setSpeakerAssignment( + speakerID: 0, personID: personID, for: transcriptID + ) + + let data = try await store.exportData(for: [meetingID]) + #expect(data.count == 1) + + let export = try #require(data.first) + #expect(export.id == meetingID) + #expect(export.title == "Standup") + #expect(export.segments.count == 2) + #expect(export.segments[0].text == "Hello world") + #expect(export.segments[1].text == "Hi there") + #expect(export.speakerNames[0] == "Alice Smith") + #expect(export.speakerNames[1] == nil) + } + + @Test("exportData preserves input order and skips unknown IDs") + func exportDataOrder() async throws { + let store = try makeStore() + let firstID = try await store.createMeeting(title: "A") + let secondID = try await store.createMeeting(title: "B") + + let data = try await store.exportData(for: [secondID, UUID(), firstID]) + #expect(data.map(\.id) == [secondID, firstID]) + } + + @Test("exportData for a meeting with no transcript yields empty segments") + func exportDataNoTranscript() async throws { + let store = try makeStore() + let meetingID = try await store.createMeeting(title: "Bare") + + let createdAt: Date? = try await store.read { store in + try store.meeting(id: meetingID)?.createdAt + } + + let export = try #require( + try await store.exportData(for: [meetingID]).first + ) + #expect(export.segments.isEmpty) + #expect(export.speakerNames.isEmpty) + #expect(export.date == createdAt) + } + + @Test("exportData with several transcripts exports the preferred one") + func exportDataUsesPreferredTranscript() async throws { + let store = try makeStore() + let meetingID = try await store.createMeeting(title: "Standup") + _ = try await store.addTranscript( + twoSpeakerResult(), + vocabularyUsed: [], + mappedEventIdentifier: nil, + to: meetingID + ) + let secondID = try await store.addTranscript( + twoSpeakerResult(texts: ["Second version A", "Second version B"]), + vocabularyUsed: [], + mappedEventIdentifier: nil, + to: meetingID + ) + try await store.setPreferredTranscript(secondID, for: meetingID) + + let export = try #require( + try await store.exportData(for: [meetingID]).first + ) + #expect(export.segments.map(\.text) == ["Second version A", "Second version B"]) + } + + @Test("exportData with transcripts but no preferred ID yields empty segments") + func exportDataWithoutPreferredID() async throws { + let store = try makeStore() + let meetingID = try await store.createMeeting(title: "Standup") + _ = try await store.addTranscript( + twoSpeakerResult(), + vocabularyUsed: [], + mappedEventIdentifier: nil, + to: meetingID + ) + + let export = try #require( + try await store.exportData(for: [meetingID]).first + ) + #expect(export.segments.isEmpty) + #expect(export.speakerNames.isEmpty) + } + + @Test("exportData sorts segments by index, not storage order") + func exportDataSortsSegmentsByIndex() async throws { + let store = try makeStore() + let meetingID = try await store.createMeeting(title: "Standup") + + try await store.read { store in + let meeting = try #require(try store.meeting(id: meetingID)) + let record = TranscriptRecord( + transcriptionMethodId: "manual", + language: "", + speakerCount: 1 + ) + store.context.insert(record) + // Appended out of index order on purpose: relationship order + // must not become segment order. + let late = TranscriptSegmentRecord( + index: 1, + speakerID: 0, + speakerLabel: "Steve", + startTime: 10, + endTime: 10, + text: "Second", + noSpeechProbability: 0 + ) + let early = TranscriptSegmentRecord( + index: 0, + speakerID: 0, + speakerLabel: "Steve", + startTime: 0, + endTime: 0, + text: "First", + noSpeechProbability: 0 + ) + store.context.insert(late) + store.context.insert(early) + record.segments.append(late) + record.segments.append(early) + meeting.transcripts.append(record) + meeting.preferredTranscriptID = record.id + try store.save() + } + + let export = try #require( + try await store.exportData(for: [meetingID]).first + ) + #expect(export.segments.map(\.text) == ["First", "Second"]) + } + + @Test("exportData survives duplicate meeting IDs already in the store") + func exportDataDuplicateMeetingIDs() async throws { + let store = try makeStore() + let dup = UUID() + + // The write path now prevents this state, but rows written before + // that guard existed (or by another tool) must not trap a read. + try await store.read { store in + store.context.insert(Meeting(id: dup, title: "First")) + store.context.insert(Meeting(id: dup, title: "Second")) + try store.save() + } + + let data = try await store.exportData(for: [dup, dup]) + #expect(data.count == 2) + #expect(data.map(\.id) == [dup, dup]) + } +} diff --git a/Packages/BiscottiKit/Tests/FormattingTests/ISO8601FormattingTests.swift b/Packages/BiscottiKit/Tests/FormattingTests/ISO8601FormattingTests.swift new file mode 100644 index 00000000..936184f2 --- /dev/null +++ b/Packages/BiscottiKit/Tests/FormattingTests/ISO8601FormattingTests.swift @@ -0,0 +1,118 @@ +import Formatting +import Foundation +import Testing + +@Suite("ISO8601Formatting -- string(from:)") +struct ISO8601FormattingRenderTests { + @Test("renders UTC with millisecond precision and Z suffix") + func rendersExactForm() { + // 2026-01-03T14:26:42.017Z + let date = Date(timeIntervalSince1970: 1_767_450_402.017) + #expect(ISO8601Formatting.string(from: date) == "2026-01-03T14:26:42.017Z") + } + + @Test("rendered string parses back to the same instant") + func roundTrip() throws { + let date = Date(timeIntervalSince1970: 1_767_450_402.017) + let parsed = try #require(ISO8601Formatting.date(from: ISO8601Formatting.string(from: date))) + #expect(abs(parsed.timeIntervalSince1970 - date.timeIntervalSince1970) < 0.001) + } +} + +@Suite("ISO8601Formatting -- date(from:)") +struct ISO8601FormattingParseTests { + @Test("parses ISO-8601 with fractional seconds and Z zone") + func fractionalZ() throws { + let parsed = try #require(ISO8601Formatting.date(from: "2026-01-03T14:26:42.017Z")) + #expect(abs(parsed.timeIntervalSince1970 - 1_767_450_402.017) < 0.001) + } + + @Test("parses ISO-8601 with fractional seconds and numeric offset") + func fractionalOffset() throws { + let parsed = try #require(ISO8601Formatting.date(from: "2026-01-03T09:26:42.017-05:00")) + #expect(abs(parsed.timeIntervalSince1970 - 1_767_450_402.017) < 0.001) + } + + @Test("parses ISO-8601 without fractional seconds") + func secondPrecision() throws { + let zoned = try #require(ISO8601Formatting.date(from: "2026-01-03T14:26:42Z")) + #expect(zoned.timeIntervalSince1970 == 1_767_450_402) + let offset = try #require(ISO8601Formatting.date(from: "2026-01-03T09:26:42-05:00")) + #expect(offset.timeIntervalSince1970 == 1_767_450_402) + } + + @Test("parses a bare calendar date as local midnight") + func bareDateIsLocalMidnight() throws { + let parsed = try #require(ISO8601Formatting.date(from: "2026-01-03")) + let components = Foundation.Calendar.current.dateComponents( + [.year, .month, .day, .hour, .minute], from: parsed + ) + #expect(components.year == 2026) + #expect(components.month == 1) + #expect(components.day == 3) + #expect(components.hour == 0) + #expect(components.minute == 0) + } + + @Test("parses a bare epoch-seconds integer") + func epochSeconds() throws { + let parsed = try #require(ISO8601Formatting.date(from: "1767450402")) + #expect(parsed.timeIntervalSince1970 == 1_767_450_402) + } + + @Test("parses a bare epoch-milliseconds integer") + func epochMilliseconds() throws { + let parsed = try #require(ISO8601Formatting.date(from: "1767450402017")) + #expect(abs(parsed.timeIntervalSince1970 - 1_767_450_402.017) < 0.001) + } + + @Test("the 1e11 boundary splits seconds from milliseconds") + func epochThreshold() throws { + // One below the threshold: seconds (year 5137). + let asSeconds = try #require(ISO8601Formatting.date(from: "99999999999")) + #expect(asSeconds.timeIntervalSince1970 == 99_999_999_999) + // At the threshold: milliseconds (1973-03-03). + let asMilliseconds = try #require(ISO8601Formatting.date(from: "100000000000")) + #expect(asMilliseconds.timeIntervalSince1970 == 100_000_000) + } + + @Test("Int.min and Int.max epoch integers parse without trapping") + func epochExtremes() throws { + // The full-precision literals parse as Int.min / Int.max; both + // magnitudes are far above the millisecond threshold. + let minParsed = try #require( + ISO8601Formatting.date(from: "-9223372036854775808") + ) + #expect(minParsed.timeIntervalSince1970 == Double(Int.min) / 1000) + let maxParsed = try #require( + ISO8601Formatting.date(from: "9223372036854775807") + ) + #expect(maxParsed.timeIntervalSince1970 == Double(Int.max) / 1000) + } + + @Test("a negative epoch integer below the threshold is seconds") + func negativeEpochSeconds() throws { + let parsed = try #require(ISO8601Formatting.date(from: "-1767450402")) + #expect(parsed.timeIntervalSince1970 == -1_767_450_402) + } + + @Test("a leading plus is not accepted as an epoch integer") + func leadingPlusRejected() { + #expect(ISO8601Formatting.date(from: "+5") == nil) + } + + @Test("trims surrounding whitespace before matching") + func trimsWhitespace() throws { + let parsed = try #require(ISO8601Formatting.date(from: " 2026-01-03T14:26:42Z\r\n")) + #expect(parsed.timeIntervalSince1970 == 1_767_450_402) + } + + @Test("rejects non-date strings") + func rejectsGarbage() { + #expect(ISO8601Formatting.date(from: "yesterday") == nil) + #expect(ISO8601Formatting.date(from: "2026-13-45") == nil) + #expect(ISO8601Formatting.date(from: "2026-01-03T14:26") == nil) + #expect(ISO8601Formatting.date(from: "") == nil) + #expect(ISO8601Formatting.date(from: " ") == nil) + } +} diff --git a/Packages/BiscottiKit/Tests/DesignSystemTests/TimeFormattingTests.swift b/Packages/BiscottiKit/Tests/FormattingTests/TimeFormattingTests.swift similarity index 99% rename from Packages/BiscottiKit/Tests/DesignSystemTests/TimeFormattingTests.swift rename to Packages/BiscottiKit/Tests/FormattingTests/TimeFormattingTests.swift index 3a039ee6..a760f6b9 100644 --- a/Packages/BiscottiKit/Tests/DesignSystemTests/TimeFormattingTests.swift +++ b/Packages/BiscottiKit/Tests/FormattingTests/TimeFormattingTests.swift @@ -1,6 +1,6 @@ +import Formatting import Foundation import Testing -@testable import DesignSystem @Suite("TimeFormatting -- relativeTimeText") struct RelativeTimeTextTests { diff --git a/Packages/BiscottiKit/Tests/FormattingTests/TranscriptTextFormattingTests.swift b/Packages/BiscottiKit/Tests/FormattingTests/TranscriptTextFormattingTests.swift new file mode 100644 index 00000000..0e8a957b --- /dev/null +++ b/Packages/BiscottiKit/Tests/FormattingTests/TranscriptTextFormattingTests.swift @@ -0,0 +1,276 @@ +import DataStore +import Formatting +import Foundation +import Testing + +// MARK: - Display name + +@Suite("TranscriptTextFormatting -- displayName") +struct TranscriptTextDisplayNameTests { + private func segment(speakerID: Int?, label: String) -> SegmentData { + SegmentData( + id: UUID(), + speakerID: speakerID, + speakerLabel: label, + startTime: 0, + endTime: 5, + text: "x" + ) + } + + @Test("shows assigned name instead of Speaker N") + func nameReplacement() { + let names: [Int: String] = [0: "Daniel", 1: "Priya"] + + #expect( + TranscriptTextFormatting.displayName( + for: segment(speakerID: 0, label: "Speaker 0"), + names: names + ) == "Daniel" + ) + #expect( + TranscriptTextFormatting.displayName( + for: segment(speakerID: 1, label: "Speaker 1"), + names: names + ) == "Priya" + ) + } + + @Test("keeps the label for unmapped speakers") + func unmappedKeepsLabel() { + #expect( + TranscriptTextFormatting.displayName( + for: segment(speakerID: 1, label: "Speaker 1"), + names: [0: "Daniel"] + ) == "Speaker 1" + ) + } + + @Test("segment without speakerID shows its label") + func nilSpeakerIDUsesLabel() { + #expect( + TranscriptTextFormatting.displayName( + for: segment(speakerID: nil, label: "Unknown"), + names: [0: "Daniel"] + ) == "Unknown" + ) + } +} + +// MARK: - Render + +@Suite("TranscriptTextFormatting -- render") +struct TranscriptTextRenderTests { + private func segment( + _ text: String, speaker: Int?, label: String, start: TimeInterval + ) -> SegmentData { + SegmentData( + id: UUID(), + speakerID: speaker, + speakerLabel: label, + startTime: start, + endTime: start + 5, + text: text + ) + } + + @Test("renders one header line per turn with a blank line between") + func multiTurn() { + let text = TranscriptTextFormatting.render([ + segment("Hello", speaker: 0, label: "Speaker 0", start: 14), + segment("Hi", speaker: 1, label: "Speaker 1", start: 31) + ]) + #expect(text == "[0:14] Speaker 0\nHello\n\n[0:31] Speaker 1\nHi") + } + + @Test("collapses consecutive same-speaker segments into one turn") + func collapsesSameSpeaker() { + let text = TranscriptTextFormatting.render([ + segment("Hello", speaker: 0, label: "Speaker 0", start: 4), + segment("more words", speaker: 0, label: "Speaker 0", start: 8), + segment("Sure", speaker: 1, label: "Speaker 1", start: 31) + ]) + #expect(text == "[0:04] Speaker 0\nHello more words\n\n[0:31] Speaker 1\nSure") + } + + @Test("nil-speaker segments never collapse") + func nilSpeakerNeverCollapses() { + let text = TranscriptTextFormatting.render([ + segment("One", speaker: nil, label: "Unknown", start: 0), + segment("Two", speaker: nil, label: "Unknown", start: 5), + segment("Three", speaker: 0, label: "Speaker 0", start: 10) + ]) + #expect(text == "[0:00] Unknown\nOne\n\n[0:05] Unknown\nTwo\n\n[0:10] Speaker 0\nThree") + } + + @Test("mapped names win; unmapped speakers keep their label") + func nameResolution() { + let text = TranscriptTextFormatting.render( + [ + segment("Hello", speaker: 0, label: "Speaker 0", start: 0), + segment("Hi", speaker: 1, label: "Speaker 1", start: 5) + ], + names: [0: "Ada L."] + ) + #expect(text == "[0:00] Ada L.\nHello\n\n[0:05] Speaker 1\nHi") + } + + @Test("timestamps switch to H:MM:SS at one hour") + func hourPlusTimestamps() { + let text = TranscriptTextFormatting.render([ + segment("Before the hour", speaker: 0, label: "S", start: 3599.9), + segment("At the hour", speaker: 1, label: "S", start: 3600), + segment("Late", speaker: 2, label: "S", start: 4322) + ]) + #expect(text == "[59:59] S\nBefore the hour\n\n[1:00:00] S\nAt the hour\n\n[1:12:02] S\nLate") + } + + @Test("drops blank segments and renders empty input as empty string") + func blankSegments() { + #expect(TranscriptTextFormatting.render([]) == "") + let whitespaceOnly = TranscriptTextFormatting.render([ + segment(" \n ", speaker: 0, label: "Speaker 0", start: 0), + segment("", speaker: 0, label: "Speaker 0", start: 5) + ]) + #expect(whitespaceOnly == "") + } +} + +// MARK: - Parse + +@Suite("TranscriptTextFormatting -- parse") +struct TranscriptTextParseTests { + @Test("plain text with no headers imports as Unknown Speaker at 0") + func plainText() { + let drafts = TranscriptTextFormatting.parse("Just talking\nStill talking") + #expect(drafts.count == 2) + #expect( + drafts.map(\.speakerLabel) + == ["Unknown Speaker", "Unknown Speaker"] + ) + #expect(drafts.map(\.startTime) == [0, 0]) + #expect(drafts.map(\.speakerID) == [0, 0]) + } + + @Test("parses our own rendered format") + func ownFormat() { + let drafts = TranscriptTextFormatting.parse( + "[0:23] Steve\nLet's get started.\n\n[0:31] Priya\nI pushed the fix this morning." + ) + #expect(drafts.count == 2) + #expect(drafts[0].speakerLabel == "Steve") + #expect(drafts[0].startTime == 23) + #expect(drafts[0].text == "Let's get started.") + #expect(drafts[1].speakerLabel == "Priya") + #expect(drafts[1].startTime == 31) + #expect(drafts[1].text == "I pushed the fix this morning.") + } + + @Test("splits 'Name: inline text' headers into speaker plus content") + func inlineTextAfterColon() { + let drafts = TranscriptTextFormatting.parse("[0:23] Steve: hello there") + #expect(drafts.count == 1) + #expect(drafts[0].speakerLabel == "Steve") + #expect(drafts[0].startTime == 23) + #expect(drafts[0].text == "hello there") + } + + @Test("parses H:MM:SS and HH:MM:SS headers") + func hourPlusHeaders() { + let drafts = TranscriptTextFormatting.parse( + "[1:02:03] A\none\n\n[12:00:02] B\ntwo\n\n[2:05] C\nthree" + ) + #expect(drafts.map(\.startTime) == [3723, 43202, 125]) + #expect(drafts.map(\.speakerLabel) == ["A", "B", "C"]) + } + + @Test("one line makes one segment; nothing is merged") + func oneSegmentPerLine() { + let drafts = TranscriptTextFormatting.parse("[0:10] A\nfirst\nsecond\nthird") + #expect(drafts.count == 3) + #expect(drafts.map(\.text) == ["first", "second", "third"]) + #expect(drafts.map(\.speakerID) == [0, 0, 0]) + } + + @Test("splits on CRLF, LF, and CR; drops blank lines") + func lineSplits() { + let drafts = TranscriptTextFormatting.parse("a\r\nb\rc\n\nd\r\n\r\n") + #expect(drafts.map(\.text) == ["a", "b", "c", "d"]) + } + + @Test("speaker IDs are sequential in first-appearance order") + func sequentialSpeakerIDs() { + let drafts = TranscriptTextFormatting.parse( + "[0:00] B\none\n\n[0:10] A\ntwo\n\n[0:20] B\nthree" + ) + #expect(drafts.map(\.speakerLabel) == ["B", "A", "B"]) + #expect(drafts.map(\.speakerID) == [0, 1, 0]) + } + + @Test("header with no name means Unknown Speaker") + func namelessHeader() { + let drafts = TranscriptTextFormatting.parse("[0:42]\nsome words") + #expect(drafts.count == 1) + #expect(drafts[0].speakerLabel == "Unknown Speaker") + #expect(drafts[0].startTime == 42) + } + + @Test("text before the first header is Unknown Speaker at 0") + func textBeforeFirstHeader() { + let drafts = TranscriptTextFormatting.parse("preamble\n[0:05] A\nanswer") + #expect(drafts.count == 2) + #expect(drafts[0].speakerLabel == "Unknown Speaker") + #expect(drafts[0].startTime == 0) + #expect(drafts[1].speakerLabel == "A") + #expect(drafts[1].startTime == 5) + } + + @Test("bracketed non-timestamps stay content lines") + func bracketedNonTimestamps() { + let drafts = TranscriptTextFormatting.parse("[see attached]\n[1:2] too short") + #expect(drafts.map(\.text) == ["[see attached]", "[1:2] too short"]) + #expect(drafts.map(\.speakerLabel) == ["Unknown Speaker", "Unknown Speaker"]) + } + + @Test("empty input yields no segments") + func emptyInput() { + #expect(TranscriptTextFormatting.parse("").isEmpty) + #expect(TranscriptTextFormatting.parse(" \n \r\n ").isEmpty) + } +} + +// MARK: - Round-trip + +@Suite("TranscriptTextFormatting -- round-trip") +struct TranscriptTextRoundTripTests { + @Test("render then parse preserves speakers, times, and words") + func roundTrip() { + let segments = [ + SegmentData( + id: UUID(), speakerID: 0, speakerLabel: "Speaker 0", + startTime: 23, endTime: 30, text: "Let's get started." + ), + SegmentData( + id: UUID(), speakerID: 0, speakerLabel: "Speaker 0", + startTime: 31, endTime: 40, text: "Second fragment." + ), + SegmentData( + id: UUID(), speakerID: 1, speakerLabel: "Speaker 1", + startTime: 3723, endTime: 3740, text: "Late in the meeting." + ) + ] + + let drafts = TranscriptTextFormatting.parse(TranscriptTextFormatting.render(segments)) + + // Same-speaker fragments collapse into one turn on render, so the + // round-trip preserves speakers, timestamps, and words — not + // fragment boundaries (architecture §3.3). + #expect(drafts.count == 2) + #expect(drafts[0].speakerLabel == "Speaker 0") + #expect(drafts[0].startTime == 23) + #expect(drafts[0].text == "Let's get started. Second fragment.") + #expect(drafts[1].speakerLabel == "Speaker 1") + #expect(drafts[1].startTime == 3723) + #expect(drafts[1].text == "Late in the meeting.") + } +} diff --git a/Packages/BiscottiKit/Tests/HomeUITests/HomeViewModelTests.swift b/Packages/BiscottiKit/Tests/HomeUITests/HomeViewModelTests.swift index 3257dee8..94ce3e2c 100644 --- a/Packages/BiscottiKit/Tests/HomeUITests/HomeViewModelTests.swift +++ b/Packages/BiscottiKit/Tests/HomeUITests/HomeViewModelTests.swift @@ -2,6 +2,7 @@ import BiscottiTestSupport import Calendar import DataStore import DesignSystem +import Formatting import Foundation import Testing @testable import AppCore diff --git a/Packages/BiscottiKit/Tests/ImportExportTests/CSVParserTests.swift b/Packages/BiscottiKit/Tests/ImportExportTests/CSVParserTests.swift new file mode 100644 index 00000000..00f7b027 --- /dev/null +++ b/Packages/BiscottiKit/Tests/ImportExportTests/CSVParserTests.swift @@ -0,0 +1,100 @@ +import Testing +@testable import ImportExport + +@Suite("CSVParser") +struct CSVParserTests { + @Test("Embedded commas stay inside quoted fields") + func quotedCommas() throws { + #expect(try CSVParser.parse("a,\"b,c\",d") == [["a", "b,c", "d"]]) + } + + @Test("Embedded newlines stay inside quoted fields") + func quotedNewlines() throws { + #expect(try CSVParser.parse("\"line1\nline2\",next") == [["line1\nline2", "next"]]) + #expect(try CSVParser.parse("\"a\r\nb\",x") == [["a\r\nb", "x"]]) + #expect(try CSVParser.parse("\"a\rb\",x") == [["a\rb", "x"]]) + } + + @Test("Doubled quotes decode to a single quote") + func doubledQuotes() throws { + #expect(try CSVParser.parse("\"say \"\"hi\"\"\",plain") == [["say \"hi\"", "plain"]]) + } + + @Test("CRLF separators split rows") + func crlfSeparators() throws { + #expect(try CSVParser.parse("a,b\r\nc,d") == [["a", "b"], ["c", "d"]]) + } + + @Test("LF separators split rows") + func lfSeparators() throws { + #expect(try CSVParser.parse("a,b\nc,d") == [["a", "b"], ["c", "d"]]) + } + + @Test("Bare CR separators split rows") + func crSeparators() throws { + #expect(try CSVParser.parse("a,b\rc,d") == [["a", "b"], ["c", "d"]]) + // A CR followed by LF is one terminator, not two. + #expect(try CSVParser.parse("a\r\r\nb") == [["a"], [""], ["b"]]) + } + + @Test("A trailing row terminator does not create a phantom empty row") + func trailingTerminator() throws { + #expect(try CSVParser.parse("a,b\r\n") == [["a", "b"]]) + #expect(try CSVParser.parse("a,b\n") == [["a", "b"]]) + #expect(try CSVParser.parse("a,b\r") == [["a", "b"]]) + } + + @Test("A leading BOM is ignored") + func leadingBOM() throws { + #expect(try CSVParser.parse("\u{FEFF}a,b") == [["a", "b"]]) + } + + @Test("Ragged rows are passed through as-is") + func raggedRows() throws { + #expect(try CSVParser.parse("a,b,c\n1,2") == [["a", "b", "c"], ["1", "2"]]) + #expect(try CSVParser.parse("a,b\n1,2,3") == [["a", "b"], ["1", "2", "3"]]) + } + + @Test("An unterminated quote throws with the 1-based row number") + func unterminatedQuote() { + #expect(throws: CSVParseError.unterminatedQuote(row: 1)) { + try CSVParser.parse("\"oops") + } + #expect(throws: CSVParseError.unterminatedQuote(row: 2)) { + try CSVParser.parse("a,b\n\"oops,more") + } + } + + @Test("Empty input yields no rows") + func emptyInput() throws { + #expect(try CSVParser.parse("") == []) + } + + @Test("The final row is kept without a terminator") + func finalRowWithoutTerminator() throws { + #expect(try CSVParser.parse("a,b") == [["a", "b"]]) + #expect(try CSVParser.parse("a,") == [["a", ""]]) + } + + @Test("An empty quoted field is an empty string") + func emptyQuotedField() throws { + #expect(try CSVParser.parse("a,\"\",b") == [["a", "", "b"]]) + } + + @Test("A lone quoted empty field is one row with one empty field") + func loneQuotedEmptyField() throws { + #expect(try CSVParser.parse("\"\"") == [[""]]) + } + + @Test("A doubled quote at the start of a quoted field is a literal quote") + func doubledQuoteAtFieldStart() throws { + // `"""x"` = open quote, escaped pair (one literal quote), x, + // closing quote — the case the quotePending flag exists for. + #expect(try CSVParser.parse("\"\"\"x\",y") == [["\"x", "y"]]) + } + + @Test("Text after a closing quote is kept, with the quote as content") + func textAfterClosingQuote() throws { + #expect(try CSVParser.parse("\"closed\"tail,x") == [["closed\"tail", "x"]]) + } +} diff --git a/Packages/BiscottiKit/Tests/ImportExportTests/CSVWriterTests.swift b/Packages/BiscottiKit/Tests/ImportExportTests/CSVWriterTests.swift new file mode 100644 index 00000000..fdfa2b62 --- /dev/null +++ b/Packages/BiscottiKit/Tests/ImportExportTests/CSVWriterTests.swift @@ -0,0 +1,37 @@ +import Testing +@testable import ImportExport + +@Suite("CSVWriter") +struct CSVWriterTests { + @Test("Plain fields are not quoted") + func plainFieldsStayUnquoted() { + #expect(CSVWriter.field("hello") == "hello") + #expect(CSVWriter.field("a b:c") == "a b:c") + #expect(CSVWriter.field("") == "") + } + + @Test("Fields with commas, quotes, CR, or LF are quoted") + func specialFieldsAreQuoted() { + #expect(CSVWriter.field("a,b") == "\"a,b\"") + #expect(CSVWriter.field("a\nb") == "\"a\nb\"") + #expect(CSVWriter.field("a\rb") == "\"a\rb\"") + } + + @Test("Embedded quotes are doubled") + func embeddedQuotesAreDoubled() { + #expect(CSVWriter.field("he said \"hi\"") == "\"he said \"\"hi\"\"\"") + } + + @Test("Rows are comma-joined and CRLF-terminated") + func rowFormat() { + #expect(CSVWriter.row(["a", "b,c", "d"]) == "a,\"b,c\",d\r\n") + #expect(CSVWriter.row(["a", ""]) == "a,\r\n") + } + + @Test("Writer output round-trips through the parser") + func roundTrip() throws { + let fields = ["plain", "with,comma", "quote \" here", "line1\nline2", ""] + let parsed = try CSVParser.parse(CSVWriter.row(fields)) + #expect(parsed == [fields]) + } +} diff --git a/Packages/BiscottiKit/Tests/ImportExportTests/ImportMessageCopyTests.swift b/Packages/BiscottiKit/Tests/ImportExportTests/ImportMessageCopyTests.swift new file mode 100644 index 00000000..2c23c3a0 --- /dev/null +++ b/Packages/BiscottiKit/Tests/ImportExportTests/ImportMessageCopyTests.swift @@ -0,0 +1,104 @@ +import DataStore +import Foundation +import Testing +@testable import ImportExport + +/// Verbatim alert copy (functional spec §3.2/§3.3): counts, singular and +/// plural verbs, and the capped example-row suffixes. The warnings' +/// `message` strings are what the review and blocked alerts render. +@Suite("Import message copy") +struct ImportMessageCopyTests { + @Test("misformattedRows copy is singular/plural-correct with examples") + func misformattedRowsCopy() { + #expect( + ImportWarning.misformattedRows(count: 1, exampleRows: [2]).message + == "1 row is missing a required value and will be skipped (row 2)." + ) + #expect( + ImportWarning.misformattedRows(count: 3, exampleRows: [2, 3, 4]).message + == "3 rows are missing a required value and will be skipped (rows 2, 3, 4)." + ) + #expect( + ImportWarning.misformattedRows(count: 1, exampleRows: []).message + == "1 row is missing a required value and will be skipped." + ) + } + + @Test("emptyContent copy is singular/plural-correct") + func emptyContentCopy() { + #expect( + ImportWarning.emptyContent(count: 1).message + == "1 row has no summary, notes, or transcript." + ) + #expect( + ImportWarning.emptyContent(count: 2).message + == "2 rows have no summary, notes, or transcript." + ) + } + + @Test("alreadyInDatabase copy is singular/plural-correct") + func alreadyInDatabaseCopy() { + #expect( + ImportWarning.alreadyInDatabase(count: 1).message + == "1 meeting already exists in your database, it will be skipped." + ) + #expect( + ImportWarning.alreadyInDatabase(count: 3).message + == "3 meetings already exist in your database, these will be skipped." + ) + } + + @Test("duplicateInFile copy carries first-wins and example rows") + func duplicateInFileCopy() { + #expect( + ImportWarning.duplicateInFile(count: 1, exampleRows: [3]).message + == "1 row has the same ID as an earlier row; the first occurrence wins (row 3)." + ) + #expect( + ImportWarning.duplicateInFile(count: 2, exampleRows: [4, 5]).message + == "2 rows have the same ID as an earlier row; the first occurrence wins (rows 4, 5)." + ) + } + + @Test("raggedRows copy carries the header comparison and example rows") + func raggedRowsCopy() { + #expect( + ImportWarning.raggedRows(count: 1, exampleRows: [2]).message + == "1 row has a different number of fields than the header (row 2)." + ) + #expect( + ImportWarning.raggedRows(count: 2, exampleRows: [2, 3]).message + == "2 rows have a different number of fields than the header (rows 2, 3)." + ) + } + + @Test("ambiguousColumns copy names the canonical winner") + func ambiguousColumnsCopy() { + #expect( + ImportWarning.ambiguousColumns(["id"]).message + == "Columns 'id' and 'document_id' are both present; 'id' is used." + ) + } + + @Test("critical error copy matches the spec wording") + func criticalErrorCopy() { + #expect( + ImportCriticalError.unreadableFile("boom").message + == "The file could not be read: boom" + ) + #expect(ImportCriticalError.notUTF8.message == "The file is not valid UTF-8 text.") + #expect(ImportCriticalError.emptyFile.message == "The file is empty.") + #expect( + ImportCriticalError.missingColumns(["id", "created"]).message + == "Required columns are missing: id, created." + ) + #expect( + ImportCriticalError.malformedCSV(row: 4).message + == "The file is not valid CSV (unterminated quoted field at row 4)." + ) + #expect( + ImportCriticalError.nothingToImport.message + == "No meetings in this file can be imported." + ) + } +} diff --git a/Packages/BiscottiKit/Tests/ImportExportTests/MeetingCSVExporterTests.swift b/Packages/BiscottiKit/Tests/ImportExportTests/MeetingCSVExporterTests.swift new file mode 100644 index 00000000..d2b1f7f4 --- /dev/null +++ b/Packages/BiscottiKit/Tests/ImportExportTests/MeetingCSVExporterTests.swift @@ -0,0 +1,365 @@ +import DataStore +import Formatting +import Foundation +import Testing +@testable import ImportExport + +@Suite("MeetingCSVExporter") +struct MeetingCSVExporterTests { + private func makeStore() throws -> DataStore { + try DataStore(storage: .inMemory) + } + + private func makeTempDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString, directoryHint: .isDirectory) + try FileManager.default.createDirectory( + at: url, + withIntermediateDirectories: true + ) + return url + } + + private func readText(_ url: URL) throws -> String { + try String(contentsOf: url, encoding: .utf8) + } + + private func seedMeeting( + _ store: DataStore, + id: UUID = UUID(), + title: String, + created: Date = Date(timeIntervalSince1970: 1_750_000_000), + summary: String = "", + notes: String = "", + transcript: [TranscriptSegmentDraft] = [] + ) async throws -> UUID { + try await store.insertImportedMeetings( + [ + ImportedMeetingDraft( + meetingID: id, + title: title, + created: created, + summary: summary, + notes: notes, + transcript: transcript + ) + ], + batchID: 1 + ) + return id + } + + private func rows(in text: String) throws -> [[String]] { + try CSVParser.parse(text) + } + + @Test("An empty store exports a header-only file") + func emptyStoreHeaderOnly() async throws { + let store = try makeStore() + let directory = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let url = try await MeetingCSVExporter(store: store).export(to: directory) + + #expect(try readText(url) == "id,title,created,summary,notes,transcript\r\n") + } + + @Test("Meetings export newest-first") + func newestFirstOrder() async throws { + let store = try makeStore() + let directory = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let base = 1_750_000_000.0 + let oldest = try await seedMeeting( + store, title: "Old", created: Date(timeIntervalSince1970: base) + ) + let newest = try await seedMeeting( + store, title: "New", created: Date(timeIntervalSince1970: base + 1000) + ) + let middle = try await seedMeeting( + store, title: "Middle", created: Date(timeIntervalSince1970: base + 500) + ) + + let url = try await MeetingCSVExporter(store: store).export(to: directory) + let dataRows = try Array(rows(in: readText(url)).dropFirst()) + + #expect(dataRows.compactMap { UUID(uuidString: $0[0]) } == [newest, middle, oldest]) + #expect(dataRows.map { $0[1] } == ["New", "Middle", "Old"]) + } + + @Test("Fields containing commas, quotes, and newlines are escaped") + func escaping() async throws { + let store = try makeStore() + let directory = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let id = try await seedMeeting( + store, + title: "Q3 Review, part 2", + summary: "He said \"hi\"", + notes: "line1\nline2" + ) + + let url = try await MeetingCSVExporter(store: store).export(to: directory) + let row = try #require(try rows(in: readText(url)).last) + + #expect(row[0] == id.uuidString) + #expect(row[1] == "Q3 Review, part 2") + #expect(row[3] == "He said \"hi\"") + #expect(row[4] == "line1\nline2") + } + + @Test("A meeting with no transcript writes an empty final field") + func noTranscriptEmptyField() async throws { + let store = try makeStore() + let directory = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + _ = try await store.createMeeting(title: "Bare") + + let url = try await MeetingCSVExporter(store: store).export(to: directory) + let row = try #require(try rows(in: readText(url)).last) + + #expect(row.count == 6) + #expect(row[5] == "") + } + + @Test("Chunked exports are byte-identical to single-chunk exports") + func chunking() async throws { + let store = try makeStore() + for index in 0 ..< 5 { + _ = try await seedMeeting( + store, + title: "Meeting \(index)", + created: Date(timeIntervalSince1970: 1_750_000_000 + Double(index)), + transcript: [ + TranscriptSegmentDraft( + speakerID: 0, + speakerLabel: "Speaker 0", + startTime: TimeInterval(index * 10), + text: "Turn \(index)." + ) + ] + ) + } + + let smallChunksDirectory = try makeTempDirectory() + let bigChunksDirectory = try makeTempDirectory() + defer { + try? FileManager.default.removeItem(at: smallChunksDirectory) + try? FileManager.default.removeItem(at: bigChunksDirectory) + } + + let small = try await MeetingCSVExporter(store: store, chunkSize: 2) + .export(to: smallChunksDirectory) + let big = try await MeetingCSVExporter(store: store, chunkSize: 50) + .export(to: bigChunksDirectory) + + let smallText = try readText(small) + let bigText = try readText(big) + #expect(smallText == bigText) + #expect(try rows(in: smallText).count == 6) + } + + @Test("The filename is Biscotti_export_{yyyy-MM-dd-HHmmss}.csv in local time") + func fileNameShape() throws { + var components = DateComponents() + components.year = 2026 + components.month = 9 + components.day = 1 + components.hour = 14 + components.minute = 26 + components.second = 42 + + let now = try #require(Calendar.current.date(from: components)) + #expect( + MeetingCSVExporter.fileName(for: now) + == "Biscotti_export_2026-09-01-142642.csv" + ) + } + + @Test("An export scans back to the same meetings and transcript text") + func exportScansBack() async throws { + let store = try makeStore() + let withTranscript = UUID() + let segments = [ + TranscriptSegmentDraft( + speakerID: 0, speakerLabel: "Steve", startTime: 23, text: "Let's get started." + ), + TranscriptSegmentDraft( + speakerID: 1, speakerLabel: "Priya", startTime: 31, text: "I pushed the fix." + ), + TranscriptSegmentDraft( + speakerID: 0, speakerLabel: "Steve", startTime: 40, text: "Great." + ) + ] + _ = try await seedMeeting( + store, + id: withTranscript, + title: "Standup", + summary: "Recap", + transcript: segments + ) + let bare = try await seedMeeting(store, title: "Bare") + + let directory = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let url = try await MeetingCSVExporter(store: store).export(to: directory) + + let result = MeetingCSVImporter.scan( + fileURL: url, + existing: ExistingMeetingIdentity() + ) + #expect(result.criticalErrors.isEmpty) + // The bare meeting has no summary, notes, or transcript. + #expect(result.warnings == [.emptyContent(count: 1)]) + #expect(Set(result.drafts.map(\.meetingID)) == [withTranscript, bare]) + + let transcriptDraft = try #require( + result.drafts.first { $0.meetingID == withTranscript } + ) + #expect(transcriptDraft.title == "Standup") + #expect(transcriptDraft.summary == "Recap") + #expect( + transcriptDraft.created == Date(timeIntervalSince1970: 1_750_000_000) + ) + + // Round-trip: rendering the scanned segments reproduces the + // rendered transcript the export wrote (speakers, times, words). + #expect( + TranscriptTextFormatting.render(segmentData(transcriptDraft.transcript)) + == TranscriptTextFormatting.render(segmentData(segments)) + ) + } + + private func segmentData( + _ drafts: [TranscriptSegmentDraft] + ) -> [SegmentData] { + drafts.map { + SegmentData( + id: UUID(), + speakerID: $0.speakerID, + speakerLabel: $0.speakerLabel, + startTime: $0.startTime, + endTime: $0.startTime, + text: $0.text + ) + } + } +} + +/// The exporter's failure paths: creation failures propagate the real +/// error, and any failure after the file exists removes the partial file. +@Suite("MeetingCSVExporter failure paths") +struct MeetingCSVExporterFailureTests { + private func makeTempDirectory() throws -> URL { + let url = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString, directoryHint: .isDirectory) + try FileManager.default.createDirectory( + at: url, + withIntermediateDirectories: true + ) + return url + } + + /// Stand-in source so a failing chunk fetch can exercise the + /// partial-file cleanup deterministically. + private struct StubSource: MeetingExportSource { + var ids: [UUID] = [] + var data: [UUID: MeetingExportData] = [:] + /// When set, any chunk containing this ID throws. + var errorTriggerID: UUID? + + func meetingIDsForExport() throws -> [UUID] { + ids + } + + func exportData(for ids: [UUID]) throws -> [MeetingExportData] { + if let errorTriggerID, ids.contains(errorTriggerID) { + throw CocoaError(.fileWriteUnknown) + } + return ids.compactMap { data[$0] } + } + } + + private func exportData( + id: UUID, title: String + ) -> MeetingExportData { + MeetingExportData( + id: id, + title: title, + date: Date(timeIntervalSince1970: 1_750_000_000) + ) + } + + @Test("Exporting into a directory that does not exist throws") + func nonexistentDirectoryThrows() async throws { + let store = try DataStore(storage: .inMemory) + let missing = FileManager.default.temporaryDirectory + .appending(path: "missing-\(UUID().uuidString)", directoryHint: .isDirectory) + + await #expect(throws: Error.self) { + _ = try await MeetingCSVExporter(store: store).export(to: missing) + } + } + + @Test("Exporting onto an unwritable file throws and leaves it untouched") + func unwritableTargetThrows() async throws { + let store = try DataStore(storage: .inMemory) + _ = try await store.createMeeting(title: "One") + + let directory = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let now = Date(timeIntervalSince1970: 1_750_000_000) + let target = directory.appending( + path: MeetingCSVExporter.fileName(for: now) + ) + try Data("old contents".utf8).write(to: target) + try FileManager.default.setAttributes( + [.posixPermissions: 0o444], ofItemAtPath: target.path + ) + defer { + try? FileManager.default.setAttributes( + [.posixPermissions: 0o644], ofItemAtPath: target.path + ) + } + + await #expect(throws: Error.self) { + _ = try await MeetingCSVExporter(store: store).export( + to: directory, now: now + ) + } + // The pre-existing file was not truncated or replaced. + #expect(try String(contentsOf: target, encoding: .utf8) == "old contents") + } + + @Test("A failure after the file was created removes the partial file") + func failedChunkRemovesPartialFile() async throws { + let trigger = UUID() + var source = StubSource() + source.ids = [UUID(), UUID(), trigger, UUID(), UUID()] + source.data = Dictionary( + uniqueKeysWithValues: source.ids.map { + ($0, exportData(id: $0, title: "M\($0.uuidString.prefix(4))")) + } + ) + source.errorTriggerID = trigger + + let directory = try makeTempDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let now = Date(timeIntervalSince1970: 1_750_000_000) + + // chunkSize 2 puts the trigger in the second chunk: the file is + // created and the header written before the failure lands. + let exporter = MeetingCSVExporter(source: source, chunkSize: 2) + await #expect(throws: Error.self) { + _ = try await exporter.export(to: directory, now: now) + } + + let partial = directory.appending( + path: MeetingCSVExporter.fileName(for: now) + ) + #expect(!FileManager.default.fileExists(atPath: partial.path)) + } +} diff --git a/Packages/BiscottiKit/Tests/ImportExportTests/MeetingCSVImporterTests.swift b/Packages/BiscottiKit/Tests/ImportExportTests/MeetingCSVImporterTests.swift new file mode 100644 index 00000000..cc3ef5db --- /dev/null +++ b/Packages/BiscottiKit/Tests/ImportExportTests/MeetingCSVImporterTests.swift @@ -0,0 +1,408 @@ +import DataStore +import Formatting +import Foundation +import Testing +@testable import ImportExport + +// MARK: - Shared Helpers + +private let canonicalHeader = "id,title,created,summary,notes,transcript" + +private func scanCSV( + _ csv: String, + existing: ExistingMeetingIdentity = ExistingMeetingIdentity() +) -> ImportScanResult { + MeetingCSVImporter.scan(data: Data(csv.utf8), existing: existing) +} + +private func scanBytes( + _ bytes: [UInt8], + existing: ExistingMeetingIdentity = ExistingMeetingIdentity() +) -> ImportScanResult { + MeetingCSVImporter.scan(data: Data(bytes), existing: existing) +} + +private func onlyDraft( + _ result: ImportScanResult +) throws -> ImportedMeetingDraft { + #expect(result.drafts.count == 1) + return try #require(result.drafts.first) +} + +// MARK: - Header Resolution & Row Validation + +@Suite("MeetingCSVImporter.scan — header and rows") +struct MeetingCSVImporterRowTests { + @Test("An alias-only CSV imports (document_id/document_title/document_created)") + func aliasOnlyColumns() throws { + let result = scanCSV( + "document_id,document_title,document_created,summary\n" + + "granola-1,Weekly Sync,2026-01-03T14:26:42Z,Recap" + ) + + #expect(result.criticalErrors.isEmpty) + #expect(result.warnings.isEmpty) + #expect(result.canProceed) + #expect(!result.needsReview) + + let draft = try onlyDraft(result) + #expect(draft.externalID == "granola-1") + #expect(draft.title == "Weekly Sync") + #expect(draft.summary == "Recap") + #expect(draft.created == ISO8601Formatting.date(from: "2026-01-03T14:26:42Z")) + } + + @Test("Unknown columns are ignored; header matching is case- and whitespace-lenient") + func unknownColumnsIgnored() throws { + let result = scanCSV( + "extra1, ID ,Title,Created,extra2,Summary,Notes,Transcript\n" + + "x,\(UUID()),Standup,2026-01-03,ignored,Recap,Note," + ) + + #expect(result.warnings.isEmpty) + #expect(result.criticalErrors.isEmpty) + + let draft = try onlyDraft(result) + #expect(draft.title == "Standup") + #expect(draft.summary == "Recap") + #expect(draft.notes == "Note") + #expect(draft.transcript.isEmpty) + } + + @Test("A missing required column blocks with missingColumns in canonical order") + func missingColumns() { + let missingCreated = scanCSV("id,title,summary\n\(UUID()),T,2026-01-03") + #expect(missingCreated.criticalErrors == [.missingColumns(["created"])]) + #expect(!missingCreated.canProceed) + + let missingEverything = scanCSV("summary,notes\na,b") + #expect( + missingEverything.criticalErrors + == [.missingColumns(["id", "title", "created"])] + ) + } + + @Test("Both a canonical column and its alias present warns and the canonical wins") + func ambiguousColumns() throws { + let canonicalID = UUID() + let result = scanCSV( + "document_id,id,title,created\n" + + "alias-value,\(canonicalID),T,2026-01-03" + ) + + #expect(result.warnings == [.ambiguousColumns(["id"]), .emptyContent(count: 1)]) + + let draft = try onlyDraft(result) + #expect(draft.meetingID == canonicalID) + #expect(draft.externalID == nil) + } + + @Test("Rows with a blank id, blank title, or unparseable date are skipped with row numbers") + func misformattedRows() throws { + let goodID = UUID() + let result = scanCSV( + canonicalHeader + "\n" + + ",No ID,2026-01-03,,,\n" + + "\(UUID()), ,2026-01-03,,,\n" + + "\(UUID()),No Date,yesterday,,,\n" + + "\(goodID),Good,2026-01-03,,," + ) + + #expect( + result.warnings.contains( + .misformattedRows(count: 3, exampleRows: [2, 3, 4]) + ) + ) + #expect(result.criticalErrors.isEmpty) + + let draft = try onlyDraft(result) + #expect(draft.meetingID == goodID) + #expect(draft.title == "Good") + } + + @Test("Example row numbers are capped at five") + func exampleRowsCapped() { + var csv = canonicalHeader + "\n" + for _ in 0 ..< 7 { + csv += "\(UUID()),T,yesterday,,,\n" + } + + let result = scanCSV(csv) + #expect( + result.warnings.contains( + .misformattedRows(count: 7, exampleRows: [2, 3, 4, 5, 6]) + ) + ) + } + + @Test("A row whose UUID id already exists in the database is skipped") + func uuidAlreadyInDatabase() { + let existingID = UUID() + let result = scanCSV( + canonicalHeader + "\n\(existingID),T,2026-01-03,,,", + existing: ExistingMeetingIdentity(meetingIDs: [existingID]) + ) + + #expect(result.warnings == [.alreadyInDatabase(count: 1)]) + #expect(result.drafts.isEmpty) + #expect(result.criticalErrors == [.nothingToImport]) + #expect(!result.canProceed) + } + + @Test("A row whose non-UUID id already exists as an externalID is skipped") + func externalIDAlreadyInDatabase() { + let result = scanCSV( + canonicalHeader + "\notter-7,T,2026-01-03,,,", + existing: ExistingMeetingIdentity(externalIDs: ["otter-7"]) + ) + + #expect(result.warnings == [.alreadyInDatabase(count: 1)]) + #expect(result.criticalErrors == [.nothingToImport]) + } + + @Test("A non-UUID id gets a minted UUID and the raw string as externalID") + func nonUUIDIDMinted() throws { + let result = scanCSV(canonicalHeader + "\ngranola-42,T,2026-01-03,,,") + + let draft = try onlyDraft(result) + #expect(draft.externalID == "granola-42") + } + + @Test("A UUID id becomes the meeting ID directly; id and title are trimmed") + func uuidIDUsedDirectly() throws { + let id = UUID() + let result = scanCSV( + canonicalHeader + "\n \(id.uuidString) , Spaced Title ,2026-01-03,,," + ) + + // Full-width row: no ragged-row warning rides along. + #expect( + !result.warnings.contains { + if case .raggedRows = $0 { return true } + return false + } + ) + let draft = try onlyDraft(result) + #expect(draft.meetingID == id) + #expect(draft.externalID == nil) + #expect(draft.title == "Spaced Title") + } + + @Test("A duplicate id within the file skips the later row; the first wins") + func duplicateInFile() throws { + let id = UUID() + let result = scanCSV( + canonicalHeader + "\n" + + "\(id),First,2026-01-03,,,\n" + + "\(id),Second,2026-01-04,,," + ) + + #expect( + result.warnings == [ + .duplicateInFile(count: 1, exampleRows: [3]), + .emptyContent(count: 1) + ] + ) + + let draft = try onlyDraft(result) + #expect(draft.title == "First") + } + + @Test("A row with no summary, notes, or transcript imports with an emptyContent warning") + func emptyContent() throws { + let id = UUID() + let result = scanCSV(canonicalHeader + "\n\(id),T,2026-01-03,,,") + + #expect(result.warnings == [.emptyContent(count: 1)]) + + let draft = try onlyDraft(result) + #expect(draft.meetingID == id) + #expect(draft.summary.isEmpty) + #expect(draft.notes.isEmpty) + #expect(draft.transcript.isEmpty) + } + + @Test("A quoted transcript field with embedded newlines parses into segments") + func multilineTranscript() throws { + let result = scanCSV( + canonicalHeader + "\n" + + "\(UUID()),T,2026-01-03,,,\"[0:23] Steve\nHello there\n[0:31] Priya\nHi\"" + ) + + let draft = try onlyDraft(result) + let segments = draft.transcript + #expect(segments.count == 2) + #expect(segments[0].speakerLabel == "Steve") + #expect(segments[0].startTime == 23) + #expect(segments[0].text == "Hello there") + #expect(segments[1].speakerLabel == "Priya") + #expect(segments[1].startTime == 31) + #expect(segments[1].text == "Hi") + } + + @Test("When every row is bad the scan reports nothingToImport") + func nothingToImport() { + let result = scanCSV( + canonicalHeader + "\n,yesterday,,,,\n,also bad,,,," + ) + + // Full-width rows: the blank ids make both rows misformatted and + // nothing else. + #expect(result.warnings == [.misformattedRows(count: 2, exampleRows: [2, 3])]) + #expect(result.criticalErrors == [.nothingToImport]) + #expect(result.drafts.isEmpty) + #expect(!result.canProceed) + #expect(result.needsReview) + } + + @Test("Drafts keep file order") + func fileOrderPreserved() { + let first = UUID() + let second = UUID() + let result = scanCSV( + canonicalHeader + "\n\(first),A,2026-01-03,summary,,\n\(second),B,2026-01-04,summary,," + ) + + #expect(result.drafts.map(\.meetingID) == [first, second]) + } + + @Test("Epoch-millisecond dates parse") + func epochMilliseconds() throws { + let result = scanCSV(canonicalHeader + "\n\(UUID()),T,1750000000123,,,") + + let draft = try onlyDraft(result) + #expect(draft.created == Date(timeIntervalSince1970: 1_750_000_000.123)) + } +} + +// MARK: - Ragged Rows & File-Level Errors + +@Suite("MeetingCSVImporter.scan — ragged rows and file errors") +struct MeetingCSVImporterFileTests { + @Test("A short row is padded and still imports") + func raggedShortRowPadded() throws { + let id = UUID() + let result = scanCSV(canonicalHeader + "\n\(id),T,2026-01-03,Recap") + + #expect(result.warnings == [.raggedRows(count: 1, exampleRows: [2])]) + + let draft = try onlyDraft(result) + #expect(draft.summary == "Recap") + #expect(draft.notes.isEmpty) + } + + @Test("A short row whose padding blanks a required field is also misformatted") + func raggedShortRowMissingRequired() { + let result = scanCSV(canonicalHeader + "\n\(UUID()),T") + + #expect(result.warnings.contains(.raggedRows(count: 1, exampleRows: [2]))) + #expect( + result.warnings.contains(.misformattedRows(count: 1, exampleRows: [2])) + ) + #expect(result.criticalErrors == [.nothingToImport]) + } + + @Test("A long row drops its extra fields") + func raggedLongRowTruncated() throws { + let id = UUID() + let result = scanCSV(canonicalHeader + "\n\(id),T,2026-01-03,Recap,Note,text,EXTRA") + + #expect(result.warnings == [.raggedRows(count: 1, exampleRows: [2])]) + + let draft = try onlyDraft(result) + #expect(draft.meetingID == id) + #expect(draft.summary == "Recap") + #expect(draft.notes == "Note") + #expect(draft.transcript.map(\.text) == ["text"]) + } + + @Test("An unreadable file reports unreadableFile") + func unreadableFile() throws { + let result = MeetingCSVImporter.scan( + fileURL: URL(fileURLWithPath: "/nonexistent/biscotti-test-import.csv"), + existing: ExistingMeetingIdentity() + ) + + let first = try #require(result.criticalErrors.first) + guard case .unreadableFile = first else { + Issue.record("Expected unreadableFile, got \(first)") + return + } + #expect(!result.canProceed) + } + + @Test("Bytes that are not UTF-8 report notUTF8") + func notUTF8() { + #expect(scanBytes([0xFF, 0xFE, 0x41, 0x00]).criticalErrors == [.notUTF8]) + } + + @Test("Empty data reports emptyFile") + func emptyFile() { + #expect(scanBytes([]).criticalErrors == [.emptyFile]) + } + + @Test("A malformed CSV reports malformedCSV with the row") + func malformedCSV() { + let result = scanCSV(canonicalHeader + "\n\"unterminated,more") + #expect(result.criticalErrors == [.malformedCSV(row: 2)]) + } + + @Test("Blank lines among the data are ignored, not warned about") + func blankLinesDropped() throws { + let id = UUID() + let result = scanCSV( + canonicalHeader + "\n" + + "\(id),T,2026-01-03,summary,,\n" + + "\n" + + "\n" + ) + + #expect(result.criticalErrors.isEmpty) + #expect(result.warnings.isEmpty) + + let draft = try onlyDraft(result) + #expect(draft.meetingID == id) + } + + @Test("Trailing blank lines keep a header-only file on the commit-zero path") + func headerOnlyWithTrailingBlanks() { + // Same user content as a plain header-only file: no critical + // error, so the flow commits and reports "Imported 0 meetings." + // rather than blocking. + let result = scanCSV(canonicalHeader + "\n\n") + + #expect(result.criticalErrors.isEmpty) + #expect(result.warnings.isEmpty) + #expect(result.drafts.isEmpty) + } + + @Test("Whitespace-only lines are dropped like blank lines, not double-reported") + func whitespaceOnlyLinesDropped() throws { + let id = UUID() + let result = scanCSV( + canonicalHeader + "\n" + + " \n" + + "\(id),T,2026-01-03,summary,,\n" + + "\t\n" + ) + + #expect(result.criticalErrors.isEmpty) + #expect(result.warnings.isEmpty) + + let draft = try onlyDraft(result) + #expect(draft.meetingID == id) + } + + @Test("A UTF-8 BOM before the header does not block the import") + func bomPrefixedFile() throws { + let id = UUID() + let csv = canonicalHeader + "\n\(id),T,2026-01-03,summary,," + let result = scanBytes([0xEF, 0xBB, 0xBF] + Array(csv.utf8)) + + #expect(result.criticalErrors.isEmpty) + #expect(result.warnings.isEmpty) + + let draft = try onlyDraft(result) + #expect(draft.meetingID == id) + } +} diff --git a/Packages/BiscottiKit/Tests/MCPServerTests/MCPToolRoundTripTests.swift b/Packages/BiscottiKit/Tests/MCPServerTests/MCPToolRoundTripTests.swift index a28bd622..bb227745 100644 --- a/Packages/BiscottiKit/Tests/MCPServerTests/MCPToolRoundTripTests.swift +++ b/Packages/BiscottiKit/Tests/MCPServerTests/MCPToolRoundTripTests.swift @@ -93,7 +93,7 @@ struct MCPToolRoundTripTests { let transcriptStructured = try #require( (transcript.body["result"] as? [String: Any])?["structuredContent"] as? [String: Any] ) - #expect((transcriptStructured["text"] as? String)?.contains("[00:04]") == true) + #expect((transcriptStructured["text"] as? String)?.contains("[0:04]") == true) } } diff --git a/Packages/BiscottiKit/Tests/MCPServerTests/MCPTranscriptWindowTests.swift b/Packages/BiscottiKit/Tests/MCPServerTests/MCPTranscriptWindowTests.swift index 16cbb13e..675c63b5 100644 --- a/Packages/BiscottiKit/Tests/MCPServerTests/MCPTranscriptWindowTests.swift +++ b/Packages/BiscottiKit/Tests/MCPServerTests/MCPTranscriptWindowTests.swift @@ -19,7 +19,7 @@ struct MCPTranscriptWindowTests { try await Self.transcript(port: fixture.port, arguments: arguments) } - let fullText = "[00:04] Speaker 0\nFirst\n\n[00:10] Speaker 1\nSecond\n\n[00:20] Speaker 0\nThird" + let fullText = "[0:04] Speaker 0\nFirst\n\n[0:10] Speaker 1\nSecond\n\n[0:20] Speaker 0\nThird" // No params: today's full output. let full = try await transcript(["id": meetingID.uuidString]) @@ -32,24 +32,24 @@ struct MCPTranscriptWindowTests { let sub = try await transcript([ "id": meetingID.uuidString, "start_seconds": 9, "end_seconds": 20 ]) - #expect(sub["text"] as? String == "[00:10] Speaker 1\nSecond") + #expect(sub["text"] as? String == "[0:10] Speaker 1\nSecond") #expect(sub["word_count"] as? Int == 1) // Start inclusive: [10, 20) still catches the 10–15 segment. let inclusive = try await transcript([ "id": meetingID.uuidString, "start_seconds": 10, "end_seconds": 20 ]) - #expect(inclusive["text"] as? String == "[00:10] Speaker 1\nSecond") + #expect(inclusive["text"] as? String == "[0:10] Speaker 1\nSecond") // Bounds are independently optional: start alone, end alone. let startAlone = try await transcript([ "id": meetingID.uuidString, "start_seconds": 15 ]) - #expect(startAlone["text"] as? String == "[00:20] Speaker 0\nThird") + #expect(startAlone["text"] as? String == "[0:20] Speaker 0\nThird") let endAlone = try await transcript([ "id": meetingID.uuidString, "end_seconds": 10 ]) - #expect(endAlone["text"] as? String == "[00:04] Speaker 0\nFirst") + #expect(endAlone["text"] as? String == "[0:04] Speaker 0\nFirst") } } diff --git a/Packages/BiscottiKit/Tests/MCPServerTests/MeetingToolDetailTests.swift b/Packages/BiscottiKit/Tests/MCPServerTests/MeetingToolDetailTests.swift index e2230302..60130134 100644 --- a/Packages/BiscottiKit/Tests/MCPServerTests/MeetingToolDetailTests.swift +++ b/Packages/BiscottiKit/Tests/MCPServerTests/MeetingToolDetailTests.swift @@ -226,7 +226,7 @@ struct MeetingToolDetailTests { #expect(object["id"] as? String == meetingID.uuidString) #expect(object["transcript_id"] as? String == transcriptID.uuidString) #expect( - object["text"] as? String == "[00:04] Ada L.\nHello more words\n\n[00:31] Speaker 1\nHi" + object["text"] as? String == "[0:04] Ada L.\nHello more words\n\n[0:31] Speaker 1\nHi" ) #expect(object["word_count"] as? Int == 4) #expect(object["character_count"] as? Int == 21) diff --git a/Packages/BiscottiKit/Tests/MCPServerTests/TranscriptTextFormatterTests.swift b/Packages/BiscottiKit/Tests/MCPServerTests/TranscriptTextFormatterTests.swift deleted file mode 100644 index 1e086c54..00000000 --- a/Packages/BiscottiKit/Tests/MCPServerTests/TranscriptTextFormatterTests.swift +++ /dev/null @@ -1,95 +0,0 @@ -import DataStore -import Foundation -import Testing -@testable import MCPServer - -/// The transcript text format of functional spec §5.3: turn collapsing, -/// name resolution, timestamps. -@Suite("TranscriptTextFormatter") -struct TranscriptTextFormatterTests { - private func segment( - _ text: String, speaker: Int?, label: String, start: TimeInterval - ) -> SegmentData { - SegmentData( - id: UUID(), - speakerID: speaker, - speakerLabel: label, - startTime: start, - endTime: start + 5, - text: text - ) - } - - @Test("collapses consecutive same-speaker segments into one turn") - func collapsesSameSpeaker() { - let text = TranscriptTextFormatter.text( - segments: [ - segment("Hello", speaker: 0, label: "Speaker 0", start: 4), - segment("more words", speaker: 0, label: "Speaker 0", start: 8), - segment("Sure", speaker: 1, label: "Speaker 1", start: 31) - ], - names: [:] - ) - #expect(text == "[00:04] Speaker 0\nHello more words\n\n[00:31] Speaker 1\nSure") - } - - @Test("mapped names win; unmapped speakers keep their label") - func nameResolution() { - let text = TranscriptTextFormatter.text( - segments: [ - segment("Hello", speaker: 0, label: "Speaker 0", start: 0), - segment("Hi", speaker: 1, label: "Speaker 1", start: 5) - ], - names: [0: "Ada L."] - ) - #expect(text == "[00:00] Ada L.\nHello\n\n[00:05] Speaker 1\nHi") - } - - @Test("nil-speaker segments always start their own turn") - func nilSpeakerTurns() { - let text = TranscriptTextFormatter.text( - segments: [ - segment("One", speaker: nil, label: "Unknown", start: 0), - segment("Two", speaker: nil, label: "Unknown", start: 5), - segment("Three", speaker: 0, label: "Speaker 0", start: 10) - ], - names: [:] - ) - #expect(text == "[00:00] Unknown\nOne\n\n[00:05] Unknown\nTwo\n\n[00:10] Speaker 0\nThree") - } - - @Test("timestamp switches to HH:MM:SS at 3600 seconds") - func timestampBoundary() { - let text = TranscriptTextFormatter.text( - segments: [ - segment("Before the hour", speaker: 0, label: "S", start: 3599.9), - segment("At the hour", speaker: 1, label: "S", start: 3600) - ], - names: [:] - ) - #expect(text == "[59:59] S\nBefore the hour\n\n[01:00:00] S\nAt the hour") - } - - @Test("empty and whitespace-only segments render no text") - func emptyInput() { - #expect(TranscriptTextFormatter.text(segments: [], names: [:]) == "") - let whitespaceOnly = TranscriptTextFormatter.text( - segments: [ - segment(" \n ", speaker: 0, label: "Speaker 0", start: 0), - segment("", speaker: 0, label: "Speaker 0", start: 5) - ], - names: [:] - ) - #expect(whitespaceOnly == "") - } - - @Test("timestamp formats") - func timestampFormats() { - #expect(TranscriptTextFormatter.timestamp(0) == "00:00") - #expect(TranscriptTextFormatter.timestamp(4.2) == "00:04") - #expect(TranscriptTextFormatter.timestamp(604) == "10:04") - #expect(TranscriptTextFormatter.timestamp(3600) == "01:00:00") - #expect(TranscriptTextFormatter.timestamp(3661.9) == "01:01:01") - #expect(TranscriptTextFormatter.timestamp(43202) == "12:00:02") - } -} diff --git a/Packages/BiscottiKit/Tests/MeetingDetailUITests/CalendarCardTests.swift b/Packages/BiscottiKit/Tests/MeetingDetailUITests/CalendarCardTests.swift index f80ea079..05d334a5 100644 --- a/Packages/BiscottiKit/Tests/MeetingDetailUITests/CalendarCardTests.swift +++ b/Packages/BiscottiKit/Tests/MeetingDetailUITests/CalendarCardTests.swift @@ -1,6 +1,7 @@ import BiscottiTestSupport import DataStore import DesignSystem +import Formatting import Foundation import Testing @testable import AppCore diff --git a/Packages/BiscottiKit/Tests/MeetingDetailUITests/CopyTranscriptTests.swift b/Packages/BiscottiKit/Tests/MeetingDetailUITests/CopyTranscriptTests.swift index 4686b4bf..41e8c51b 100644 --- a/Packages/BiscottiKit/Tests/MeetingDetailUITests/CopyTranscriptTests.swift +++ b/Packages/BiscottiKit/Tests/MeetingDetailUITests/CopyTranscriptTests.swift @@ -87,7 +87,7 @@ struct CopyTranscriptTests { let pasted = NSPasteboard.general.string(forType: .string) #expect( pasted - == "Alice 0:14\nHello everyone.\n\nBob 0:31\nGood morning." + == "[0:14] Alice\nHello everyone.\n\n[0:31] Bob\nGood morning." ) } diff --git a/Packages/BiscottiKit/Tests/MeetingDetailUITests/SpeakerMappingTests.swift b/Packages/BiscottiKit/Tests/MeetingDetailUITests/SpeakerMappingTests.swift index 5ea0ec20..dce05fcb 100644 --- a/Packages/BiscottiKit/Tests/MeetingDetailUITests/SpeakerMappingTests.swift +++ b/Packages/BiscottiKit/Tests/MeetingDetailUITests/SpeakerMappingTests.swift @@ -1,6 +1,7 @@ import AppKit import BiscottiTestSupport import DataStore +import Formatting import Foundation import Intelligence import Testing @@ -415,7 +416,7 @@ struct TranscriptCacheNameTests { // transcript (the path used for display and copy) shows "Speaker 0". #expect(viewModel.displayedSpeakerNames[0] == nil) let segments1 = try #require(viewModel.displayedTranscript?.segments) - let text1 = TranscriptContent.plainText( + let text1 = TranscriptTextFormatting.render( segments1, names: viewModel.displayedSpeakerNames ) #expect(text1.contains("Speaker 0")) @@ -434,7 +435,7 @@ struct TranscriptCacheNameTests { // shows "Daniel" in place of "Speaker 0". #expect(viewModel.displayedSpeakerNames[0] == "Daniel") let segments2 = try #require(viewModel.displayedTranscript?.segments) - let text2 = TranscriptContent.plainText( + let text2 = TranscriptTextFormatting.render( segments2, names: viewModel.displayedSpeakerNames ) #expect(text2.contains("Daniel")) diff --git a/Packages/BiscottiKit/Tests/MeetingDetailUITests/SpeakerNameRenderingTests.swift b/Packages/BiscottiKit/Tests/MeetingDetailUITests/SpeakerNameRenderingTests.swift index 412f4928..347eaf15 100644 --- a/Packages/BiscottiKit/Tests/MeetingDetailUITests/SpeakerNameRenderingTests.swift +++ b/Packages/BiscottiKit/Tests/MeetingDetailUITests/SpeakerNameRenderingTests.swift @@ -3,73 +3,6 @@ import Foundation import Testing @testable import MeetingDetailUI -// MARK: - Name replacement (display-name resolution) - -@Suite("TranscriptContent.displayName") -struct SpeakerNameReplacementTests { - private func segment(speakerID: Int?, label: String) -> SegmentData { - SegmentData( - id: UUID(), - speakerID: speakerID, - speakerLabel: label, - startTime: 0, - endTime: 5, - text: "x" - ) - } - - @Test("shows assigned name instead of Speaker N") - func nameReplacement() { - let names: [Int: String] = [0: "Daniel", 1: "Priya"] - - #expect( - TranscriptContent.displayName( - for: segment(speakerID: 0, label: "Speaker 0"), - names: names - ) == "Daniel" - ) - #expect( - TranscriptContent.displayName( - for: segment(speakerID: 1, label: "Speaker 1"), - names: names - ) == "Priya" - ) - } - - @Test("keeps Speaker N for unmapped speakers") - func unmappedKeepsLabel() { - // Only map speaker 0; speaker 1 stays unmapped. - let names = [0: "Daniel"] - - #expect( - TranscriptContent.displayName( - for: segment(speakerID: 1, label: "Speaker 1"), - names: names - ) == "Speaker 1" - ) - } - - @Test("empty names map shows original label") - func emptyNamesMap() { - #expect( - TranscriptContent.displayName( - for: segment(speakerID: 0, label: "Speaker 0"), - names: [:] - ) == "Speaker 0" - ) - } - - @Test("segment without speakerID shows its label") - func nilSpeakerIDUsesLabel() { - #expect( - TranscriptContent.displayName( - for: segment(speakerID: nil, label: "Unknown"), - names: [0: "Daniel"] - ) == "Unknown" - ) - } -} - // MARK: - Color stability @Suite("TranscriptContent speaker color stability") @@ -146,56 +79,3 @@ struct SpeakerColorStabilityTests { #expect(color == labelColor) } } - -// MARK: - Plain text with names - -@Suite("TranscriptContent.plainText with names") -struct PlainTextWithNamesTests { - @Test("substitutes names in plain text") - func nameSubstitution() { - let segments = [ - SegmentData( - id: UUID(), - speakerID: 0, - speakerLabel: "Speaker 0", - startTime: 14, - endTime: 25, - text: "Hello" - ), - SegmentData( - id: UUID(), - speakerID: 1, - speakerLabel: "Speaker 1", - startTime: 31, - endTime: 45, - text: "Hi" - ) - ] - - let result = TranscriptContent.plainText( - segments, names: [0: "Daniel", 1: "Priya"] - ) - - #expect(result.contains("Daniel")) - #expect(result.contains("Priya")) - #expect(!result.contains("Speaker 0")) - #expect(!result.contains("Speaker 1")) - } - - @Test("unmapped speakers keep original label in plain text") - func unmappedKeepsLabel() { - let segments = [ - SegmentData( - id: UUID(), - speakerID: 0, - speakerLabel: "Speaker 0", - startTime: 0, - endTime: 5, - text: "test" - ) - ] - - let result = TranscriptContent.plainText(segments, names: [:]) - #expect(result.contains("Speaker 0")) - } -} diff --git a/Packages/BiscottiKit/Tests/MeetingDetailUITests/TranscriptContentTests.swift b/Packages/BiscottiKit/Tests/MeetingDetailUITests/TranscriptContentTests.swift index b2555d18..bc6fd30a 100644 --- a/Packages/BiscottiKit/Tests/MeetingDetailUITests/TranscriptContentTests.swift +++ b/Packages/BiscottiKit/Tests/MeetingDetailUITests/TranscriptContentTests.swift @@ -4,75 +4,6 @@ import SwiftUI import Testing @testable import MeetingDetailUI -// MARK: - Plain text - -@Suite("TranscriptContent.plainText") -struct TranscriptContentPlainTextTests { - @Test("formats multi-turn transcript") - func multiTurn() { - let segments = [ - SegmentData( - id: UUID(), - speakerLabel: "Speaker 0", - startTime: 14, - endTime: 25, - text: "Hello" - ), - SegmentData( - id: UUID(), - speakerLabel: "Speaker 1", - startTime: 31, - endTime: 45, - text: "Hi" - ) - ] - - let result = TranscriptContent.plainText(segments) - - #expect(result == "Speaker 0 0:14\nHello\n\nSpeaker 1 0:31\nHi") - } - - @Test("formats H:MM:SS for times >= 1 hour") - func hourFormat() { - let segments = [ - SegmentData( - id: UUID(), - speakerLabel: "Speaker 0", - startTime: 3723, // 1:02:03 - endTime: 3780, - text: "Late in the meeting" - ) - ] - - let result = TranscriptContent.plainText(segments) - - #expect(result == "Speaker 0 1:02:03\nLate in the meeting") - } - - @Test("single segment has no trailing separator") - func singleSegment() { - let segments = [ - SegmentData( - id: UUID(), - speakerLabel: "Alice", - startTime: 0, - endTime: 5, - text: "Solo" - ) - ] - - let result = TranscriptContent.plainText(segments) - - #expect(result == "Alice 0:00\nSolo") - } - - @Test("empty segments returns empty string") - func emptySegments() { - let result = TranscriptContent.plainText([]) - #expect(result.isEmpty) - } -} - // MARK: - Speaker color @Suite("TranscriptContent.speakerColor") diff --git a/Packages/BiscottiKit/Tests/MeetingListUITests/MeetingListB4Tests.swift b/Packages/BiscottiKit/Tests/MeetingListUITests/MeetingListB4Tests.swift index 8ac7fb56..8fc28b4f 100644 --- a/Packages/BiscottiKit/Tests/MeetingListUITests/MeetingListB4Tests.swift +++ b/Packages/BiscottiKit/Tests/MeetingListUITests/MeetingListB4Tests.swift @@ -1,6 +1,7 @@ import BiscottiTestSupport import DataStore import DesignSystem +import Formatting import Foundation import Testing @testable import AppCore diff --git a/Packages/BiscottiKit/Tests/SettingsUITests/SettingsImportExportTests.swift b/Packages/BiscottiKit/Tests/SettingsUITests/SettingsImportExportTests.swift new file mode 100644 index 00000000..93b346d3 --- /dev/null +++ b/Packages/BiscottiKit/Tests/SettingsUITests/SettingsImportExportTests.swift @@ -0,0 +1,581 @@ +import AppCore +import BiscottiTestSupport +import Foundation +import ImportExport +import Testing +@testable import DataStore +@testable import SettingsUI + +/// Records panel invocations, stubs a chosen result, and samples the +/// view model's state at the moment each panel is on screen — which is +/// the only observable window into the in-flight busy/spinner flags. +@MainActor +private final class PanelSpy { + var openPanelResult: URL? + var savePanelResult: URL? + private(set) var openPanelCount = 0 + private(set) var savePanelNames: [String] = [] + private(set) var busyWhenSaveShown: [Bool] = [] + private(set) var exportSpinnerWhenSaveShown: [Bool] = [] + weak var viewModel: SettingsViewModel? + + func presentOpen() -> URL? { + openPanelCount += 1 + return openPanelResult + } + + func presentSave(name: String) -> URL? { + savePanelNames.append(name) + busyWhenSaveShown.append(viewModel?.importExportBusy ?? false) + exportSpinnerWhenSaveShown.append(viewModel?.exportInFlight ?? true) + return savePanelResult + } +} + +@Suite("SettingsViewModel -- Import/Export") +@MainActor +struct SettingsImportExportTests { + // MARK: - Import flow + + @Test("cancelling the open panel does nothing") + func openPanelCancelIsNoOp() async throws { + let fix = try makeCoreFixture(testName: "IOCancel") + defer { fix.cleanup() } + + let spy = PanelSpy() + spy.openPanelResult = nil + let viewModel = ImportExportTestSupport.makeViewModel(fix: fix, spy: spy) + + await viewModel.beginImport() + + #expect(spy.openPanelCount == 1) + #expect(viewModel.importAlert == nil) + #expect(viewModel.pendingImport == nil) + #expect(!viewModel.importExportBusy) + #expect(!viewModel.importInFlight) + #expect(try await fix.store.meetingSummaries().isEmpty) + } + + @Test("critical errors present .blocked and commit nothing") + func criticalErrorsBlock() async throws { + let fix = try makeCoreFixture(testName: "IOBlocked") + defer { fix.cleanup() } + + let spy = PanelSpy() + let csv = try ImportExportTestSupport.writeCSV("id,title\nabc-1,Standup") + defer { try? FileManager.default.removeItem(at: csv) } + spy.openPanelResult = csv + let viewModel = ImportExportTestSupport.makeViewModel(fix: fix, spy: spy) + + await viewModel.beginImport() + + guard case let .blocked(title, body) = viewModel.importAlert else { + Issue.record("expected .blocked, got \(String(describing: viewModel.importAlert))") + return + } + #expect(title == "Cannot Import This File") + #expect(body == "Required columns are missing: created.") + #expect(viewModel.pendingImport == nil) + #expect(try await fix.store.meetingSummaries().isEmpty) + } + + @Test("an all-duplicates file blocks and the body names the counts") + func allDuplicatesBlockedBodyNamesCounts() async throws { + let fix = try makeCoreFixture(testName: "IOAllDup") + defer { fix.cleanup() } + + let existingID = try await fix.store.createMeeting(title: "Existing") + let spy = PanelSpy() + let csv = try ImportExportTestSupport.writeCSV( + "id,title,created\n\(existingID.uuidString),Existing,2026-01-03T14:26:42Z" + ) + defer { try? FileManager.default.removeItem(at: csv) } + spy.openPanelResult = csv + let viewModel = ImportExportTestSupport.makeViewModel(fix: fix, spy: spy) + + await viewModel.beginImport() + + // The blocking alert explains *why* nothing can import: the + // critical error plus the warning counts (functional spec §3.3). + guard case let .blocked(title, body) = viewModel.importAlert else { + Issue.record("expected .blocked, got \(String(describing: viewModel.importAlert))") + return + } + #expect(title == "Cannot Import This File") + #expect(body == """ + No meetings in this file can be imported. + 1 meeting already exists in your database, it will be skipped. + """) + #expect(viewModel.pendingImport == nil) + #expect(try await fix.store.meetingSummaries().count == 1) + } + + @Test("warnings present .review; Cancel keeps the store untouched, Continue commits") + func warningsReviewCancelContinue() async throws { + let fix = try makeCoreFixture(testName: "IOReview") + defer { fix.cleanup() } + + let existingID = try await fix.store.createMeeting(title: "Existing") + let csv = "id,title,created\n" + + "\(existingID.uuidString),Existing,2026-01-03T14:26:42Z\n" + + "abc-new,Fresh,2026-01-04T09:00:00Z\n" + + let spy = PanelSpy() + let viewModel = ImportExportTestSupport.makeViewModel(fix: fix, spy: spy) + + // First pass: review appears, Cancel leaves the store untouched. + let firstCSV = try ImportExportTestSupport.writeCSV(csv) + defer { try? FileManager.default.removeItem(at: firstCSV) } + spy.openPanelResult = firstCSV + await viewModel.beginImport() + guard case .review = viewModel.importAlert else { + Issue.record("expected .review, got \(String(describing: viewModel.importAlert))") + return + } + #expect(viewModel.pendingImport?.drafts.count == 1) + viewModel.cancelImportReview() + #expect(viewModel.importAlert == nil) + #expect(viewModel.pendingImport == nil) + #expect(try await fix.store.meetingSummaries().count == 1) + + // Second pass: Continue commits exactly the held scan result. + let secondCSV = try ImportExportTestSupport.writeCSV(csv) + defer { try? FileManager.default.removeItem(at: secondCSV) } + spy.openPanelResult = secondCSV + await viewModel.beginImport() + guard case .review = viewModel.importAlert else { + Issue.record("expected .review again, got \(String(describing: viewModel.importAlert))") + return + } + await viewModel.confirmImport() + + guard case let .result(title, body) = viewModel.importAlert else { + Issue.record("expected .result, got \(String(describing: viewModel.importAlert))") + return + } + #expect(title == "Import Complete") + #expect(body == """ + Imported 1 meeting. + 1 row was skipped because those meetings already exist. + """) + #expect(try await fix.store.meetingSummaries().count == 2) + #expect(fix.core.summaries.count == 2) + } + + @Test("dismissing the review alert through the binding drops the held scan") + func dismissalDropsPendingImport() async throws { + let fix = try makeCoreFixture(testName: "IODismiss") + defer { fix.cleanup() } + + let existingID = try await fix.store.createMeeting(title: "Existing") + let spy = PanelSpy() + let csv = try ImportExportTestSupport.writeCSV( + "id,title,created\n" + + "\(existingID.uuidString),Existing,2026-01-03T14:26:42Z\n" + + "abc-fresh,Fresh,2026-01-04T09:00:00Z" + ) + defer { try? FileManager.default.removeItem(at: csv) } + spy.openPanelResult = csv + let viewModel = ImportExportTestSupport.makeViewModel(fix: fix, spy: spy) + + await viewModel.beginImport() + guard case .review = viewModel.importAlert else { + Issue.record("expected .review, got \(String(describing: viewModel.importAlert))") + return + } + #expect(viewModel.pendingImport != nil) + + // The alert binding's dismissal path (not the Cancel button). + viewModel.dismissImportAlert() + + #expect(viewModel.importAlert == nil) + #expect(viewModel.pendingImport == nil) + // With nothing held, a stray Continue is a no-op. + await viewModel.confirmImport() + #expect(viewModel.importAlert == nil) + #expect(try await fix.store.meetingSummaries().count == 1) + } + + @Test("clean file commits straight through with no review alert") + func cleanFileCommitsDirectly() async throws { + let fix = try makeCoreFixture(testName: "IOClean") + defer { fix.cleanup() } + + let spy = PanelSpy() + let csv = try ImportExportTestSupport.writeCSV( + "id,title,created,summary\nabc-1,Standup,2026-01-03T14:26:42Z,Hello" + ) + defer { try? FileManager.default.removeItem(at: csv) } + spy.openPanelResult = csv + let viewModel = ImportExportTestSupport.makeViewModel(fix: fix, spy: spy) + + await viewModel.beginImport() + + guard case let .result(_, body) = viewModel.importAlert else { + Issue.record("expected .result, got \(String(describing: viewModel.importAlert))") + return + } + #expect(body == "Imported 1 meeting.") + #expect(viewModel.pendingImport == nil) + #expect(try await fix.store.meetingSummaries().count == 1) + } + + @Test("header-only CSV reports 'Imported 0 meetings.' and writes nothing") + func headerOnlyCommitsZero() async throws { + let fix = try makeCoreFixture(testName: "IOHeaderOnly") + defer { fix.cleanup() } + + // The carried-in Phase 3 state: canProceed == false, + // needsReview == false, drafts.isEmpty. The wiring must land it + // deliberately in the clean path — zero meetings committed, the + // §3.4 result alert shown, and no review alert on the way. + let spy = PanelSpy() + let csv = try ImportExportTestSupport.writeCSV( + "id,title,created,summary,notes,transcript" + ) + defer { try? FileManager.default.removeItem(at: csv) } + spy.openPanelResult = csv + let viewModel = ImportExportTestSupport.makeViewModel(fix: fix, spy: spy) + + await viewModel.beginImport() + + guard case let .result(title, body) = viewModel.importAlert else { + Issue.record("expected .result, got \(String(describing: viewModel.importAlert))") + return + } + #expect(title == "Import Complete") + #expect(body == "Imported 0 meetings.") + #expect(try await fix.store.meetingSummaries().isEmpty) + #expect(!viewModel.importExportBusy) + } + + // MARK: - Alert copy + + @Test("resultBody lists skipped rows per spec wording") + func resultBodyCopy() { + let single = SettingsViewModel.resultBody( + for: ImportCommitSummary(imported: 1, skippedExisting: 0, skippedMisformatted: 0) + ) + #expect(single == "Imported 1 meeting.") + + let full = SettingsViewModel.resultBody( + for: ImportCommitSummary(imported: 42, skippedExisting: 3, skippedMisformatted: 2) + ) + #expect(full == """ + Imported 42 meetings. + 3 rows were skipped because those meetings already exist. + 2 rows were skipped because they were missing a required value. + """) + } + + @Test("reviewBody lists each warning, then how many meetings will import") + func reviewBodyCopy() { + let draft = ImportedMeetingDraft( + meetingID: UUID(), + title: "Standup", + created: Date(timeIntervalSince1970: 1_767_000_000) + ) + let result = ImportScanResult( + drafts: [draft], + warnings: [.alreadyInDatabase(count: 2)] + ) + + #expect( + SettingsViewModel.reviewBody(for: result) == """ + 2 meetings already exist in your database, these will be skipped. + + 1 meeting will be imported. + """ + ) + } +} + +// Serialized: these tests drive AppCore's default temp-directory export +// path, whose filename has second granularity — one test's cancel-path +// delete must never land inside another's export-to-move window. (The +// AppCore suite's export test passes its own directory and does not +// share this state.) +@Suite("SettingsViewModel -- Export flow", .serialized) +@MainActor +struct SettingsExportFlowTests { + @Test("export moves the temp file to the chosen destination") + func exportMovesFile() async throws { + let fix = try makeCoreFixture(testName: "IOExportMove") + defer { fix.cleanup() } + + _ = try await fix.store.createMeeting(title: "One") + + let destination = FileManager.default.temporaryDirectory + .appendingPathComponent("dest-\(UUID().uuidString).csv") + defer { try? FileManager.default.removeItem(at: destination) } + + let spy = PanelSpy() + spy.savePanelResult = destination + let viewModel = ImportExportTestSupport.makeViewModel(fix: fix, spy: spy) + + await viewModel.beginExport() + + // The save panel received the generated filename, was shown while + // the operation was busy (both buttons disabled) and after the + // spinner had cleared (functional spec §5.2). + #expect(spy.savePanelNames.count == 1) + #expect(spy.savePanelNames[0].hasPrefix("Biscotti_export_")) + #expect(spy.savePanelNames[0].hasSuffix(".csv")) + #expect(spy.busyWhenSaveShown == [true]) + #expect(spy.exportSpinnerWhenSaveShown == [false]) + + #expect(FileManager.default.fileExists(atPath: destination.path)) + #expect(viewModel.importAlert == nil) + #expect(!viewModel.importExportBusy) + #expect(!viewModel.exportInFlight) + } + + @Test("export over an existing destination replaces it") + func exportReplacesExistingDestination() async throws { + let fix = try makeCoreFixture(testName: "IOExportReplace") + defer { fix.cleanup() } + + let destination = FileManager.default.temporaryDirectory + .appendingPathComponent("replace-\(UUID().uuidString).csv") + try Data("old contents".utf8).write(to: destination) + defer { try? FileManager.default.removeItem(at: destination) } + + let spy = PanelSpy() + spy.savePanelResult = destination + let viewModel = ImportExportTestSupport.makeViewModel(fix: fix, spy: spy) + + await viewModel.beginExport() + + // The old file is replaced by the CSV, not left behind or lost. + let saved = try String(contentsOf: destination, encoding: .utf8) + #expect(saved.hasPrefix("id,title,created,summary,notes,transcript\r\n")) + #expect(viewModel.importAlert == nil) + guard let fileName = spy.savePanelNames.first else { + Issue.record("save panel was never shown") + return + } + let tempFile = FileManager.default.temporaryDirectory + .appendingPathComponent(fileName) + #expect(!FileManager.default.fileExists(atPath: tempFile.path)) + } + + @Test("export replaces an existing file at a path containing a space") + func exportReplacesExistingDestinationInSpacedPath() async throws { + let fix = try makeCoreFixture(testName: "IOExportSpace") + defer { fix.cleanup() } + + _ = try await fix.store.createMeeting(title: "One") + + // A directory whose name contains a space: its percent-encoded + // path never matches a real file, which used to send the + // already-confirmed replace down the move path and fail. + let spacedDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("My Exports \(UUID().uuidString)") + try FileManager.default.createDirectory( + at: spacedDirectory, + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: spacedDirectory) } + + let destination = spacedDirectory + .appendingPathComponent("biscotti export.csv") + try Data("old contents".utf8).write(to: destination) + + let spy = PanelSpy() + spy.savePanelResult = destination + let viewModel = ImportExportTestSupport.makeViewModel(fix: fix, spy: spy) + + await viewModel.beginExport() + + let saved = try String(contentsOf: destination, encoding: .utf8) + #expect(saved.hasPrefix("id,title,created,summary,notes,transcript\r\n")) + #expect(viewModel.importAlert == nil) + } + + @Test("cancelling the save panel deletes the temp file and shows no alert") + func exportCancelDeletesTemp() async throws { + let fix = try makeCoreFixture(testName: "IOExportCancel") + defer { fix.cleanup() } + + let spy = PanelSpy() + spy.savePanelResult = nil + let viewModel = ImportExportTestSupport.makeViewModel(fix: fix, spy: spy) + + await viewModel.beginExport() + + // Exactly one export file existed; the cancel path must have + // removed it. + guard let fileName = spy.savePanelNames.first else { + Issue.record("save panel was never shown") + return + } + let tempFile = FileManager.default.temporaryDirectory + .appendingPathComponent(fileName) + #expect(!FileManager.default.fileExists(atPath: tempFile.path)) + #expect(viewModel.importAlert == nil) + #expect(!viewModel.importExportBusy) + } + + @Test("a failed move surfaces .failure and deletes the temp file") + func exportMoveFailureAlerts() async throws { + let fix = try makeCoreFixture(testName: "IOExportMoveFail") + defer { fix.cleanup() } + + // A destination whose parent directory does not exist makes the + // move fail. + let destination = URL(fileURLWithPath: "/nonexistent-\(UUID().uuidString)/out.csv") + let spy = PanelSpy() + spy.savePanelResult = destination + let viewModel = ImportExportTestSupport.makeViewModel(fix: fix, spy: spy) + + await viewModel.beginExport() + + guard case let .failure(title, _) = viewModel.importAlert else { + Issue.record("expected .failure, got \(String(describing: viewModel.importAlert))") + return + } + #expect(title == "Export Failed") + guard let fileName = spy.savePanelNames.first else { + Issue.record("save panel was never shown") + return + } + let tempFile = FileManager.default.temporaryDirectory + .appendingPathComponent(fileName) + #expect(!FileManager.default.fileExists(atPath: tempFile.path)) + #expect(!viewModel.importExportBusy) + } +} + +#if DEBUG + @Suite("SettingsViewModel -- Debug: Delete Imported Meetings") + @MainActor + struct SettingsDebugDeleteImportedTests { + @Test("delete prompt uses the exact N/M copy") + func deletePromptCopy() async throws { + let fix = try makeCoreFixture(testName: "IODeletePrompt") + defer { fix.cleanup() } + + _ = try await fix.store.createMeeting(title: "Recorded") + try await fix.store.insertImportedMeetings( + [ImportExportTestSupport.draft(title: "A"), ImportExportTestSupport.draft(title: "B")], + batchID: 1 + ) + + let viewModel = ImportExportTestSupport.makeViewModel(fix: fix, spy: PanelSpy()) + await viewModel.promptDeleteImportedMeetings() + + #expect( + viewModel.importAlert + == .confirmDeleteImported( + title: "Delete 2 meetings?", + body: "This will delete 2 meetings (and leave 1 meetings)." + ) + ) + } + + @Test("zero imported meetings shows the none-to-delete result") + func deletePromptZero() async throws { + let fix = try makeCoreFixture(testName: "IODeleteZero") + defer { fix.cleanup() } + + let viewModel = ImportExportTestSupport.makeViewModel(fix: fix, spy: PanelSpy()) + await viewModel.promptDeleteImportedMeetings() + + #expect( + viewModel.importAlert + == .result( + title: "Delete Imported Meetings", + body: "No imported meetings to delete." + ) + ) + #expect(try await fix.store.meetingSummaries().isEmpty) + } + + @Test("cancel from the delete confirmation performs no delete") + func deleteCancelPerformsNoDelete() async throws { + let fix = try makeCoreFixture(testName: "IODeleteCancel") + defer { fix.cleanup() } + + try await fix.store.insertImportedMeetings( + [ImportExportTestSupport.draft(title: "A")], + batchID: 1 + ) + await fix.core.reloadSummaries() + + let viewModel = ImportExportTestSupport.makeViewModel(fix: fix, spy: PanelSpy()) + await viewModel.promptDeleteImportedMeetings() + guard case .confirmDeleteImported = viewModel.importAlert else { + Issue.record( + "expected .confirmDeleteImported, got \(String(describing: viewModel.importAlert))" + ) + return + } + + // The Cancel button's action. + viewModel.dismissImportAlert() + + #expect(viewModel.importAlert == nil) + #expect(fix.core.summaries.count == 1) + let counts = await fix.core.importedMeetingCounts() + #expect(counts.imported == 1) + } + + @Test("confirming the delete removes imported meetings and reports the count") + func deleteConfirmRemoves() async throws { + let fix = try makeCoreFixture(testName: "IODeleteConfirm") + defer { fix.cleanup() } + + let recordedID = try await fix.store.createMeeting(title: "Recorded") + try await fix.store.insertImportedMeetings( + [ImportExportTestSupport.draft(title: "A")], + batchID: 1 + ) + await fix.core.reloadSummaries() + + let viewModel = ImportExportTestSupport.makeViewModel(fix: fix, spy: PanelSpy()) + await viewModel.confirmDeleteImportedMeetings() + + #expect( + viewModel.importAlert + == .result( + title: "Delete Imported Meetings", + body: "Deleted 1 meeting." + ) + ) + #expect(fix.core.summaries.count == 1) + #expect(try await fix.store.meetingExists(id: recordedID)) + } + } +#endif + +// MARK: - Shared helpers + +private enum ImportExportTestSupport { + @MainActor + static func makeViewModel( + fix: CoreFixture, spy: PanelSpy + ) -> SettingsViewModel { + let viewModel = SettingsViewModel( + core: fix.core, + presentOpenPanel: { spy.presentOpen() }, + presentSavePanel: { name in spy.presentSave(name: name) } + ) + spy.viewModel = viewModel + return viewModel + } + + static func writeCSV(_ text: String) throws -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("io-\(UUID().uuidString).csv") + try Data(text.utf8).write(to: url) + return url + } + + static func draft(title: String) -> ImportedMeetingDraft { + ImportedMeetingDraft( + meetingID: UUID(), + title: title, + created: Date(timeIntervalSince1970: 1_767_000_000) + ) + } +} diff --git a/Packages/BiscottiKit/Tests/SettingsUITests/SettingsLayoutTests.swift b/Packages/BiscottiKit/Tests/SettingsUITests/SettingsLayoutTests.swift index 781c26d3..ecd0978d 100644 --- a/Packages/BiscottiKit/Tests/SettingsUITests/SettingsLayoutTests.swift +++ b/Packages/BiscottiKit/Tests/SettingsUITests/SettingsLayoutTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import SettingsUI @@ -8,7 +9,7 @@ struct SettingsLayoutTests { /// the titles AND their order as they appear on screen. The physical /// section ordering in `body` (which computed property appears first) /// is verified in the Phase 12 manual pass. - @Test("section titles match spec order: General, Permissions, Notifications, AI, Vocab, Calendars") + @Test("section titles match spec order: General, Permissions, Notifications, AI, Vocab, Import/Export, Calendars") func sectionTitlesMatchSpec() { let expected = [ "General", @@ -16,6 +17,7 @@ struct SettingsLayoutTests { "Notifications", "AI Enhancements", "Custom Vocabulary", + "Import/Export", "Calendars" ] #expect(SettingsView.sectionTitles == expected) @@ -38,4 +40,20 @@ struct SettingsLayoutTests { func customVocabularyHeaderCaption() { #expect(SettingsView.customVocabularyHeaderCaption == "Beta") } + + /// The Import/Export row titles and subtitles render directly in the + /// section, and the "Learn more" link opens the user-facing guide. + @Test("Import/Export row copy and guide URL match the spec") + func importExportRowCopy() { + #expect(SettingsView.importRowTitle == "Import Meetings") + #expect(SettingsView.importRowSubtitle == "Import meetings from other apps, via CSV.") + #expect(SettingsView.exportRowTitle == "Export Meetings") + #expect(SettingsView.exportRowSubtitle == "Export all meetings to CSV.") + #expect( + SettingsView.importExportGuideURL + == URL( + string: "https://github.com/scosman/Biscotti/blob/main/App/ImportingExporting.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..2f0cb871 --- /dev/null +++ b/specs/projects/csv_import_export/architecture.md @@ -0,0 +1,570 @@ +--- +status: complete +--- + +# 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 a per-call `ISO8601DateFormatter` (UTC, +`.withFractionalSeconds`). The originally prescribed `Date.ISO8601FormatStyle` +(with `.time(includingFractionalSeconds: true)`) was found during +implementation to truncate the sub-millisecond `Date` representation error +downward — a `.017` instant renders `.016` — which breaks both the exact +output string (§1.3) and the render→parse round-trip; `ISO8601DateFormatter` +rounds to the nearest millisecond and round-trips exactly. + +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 `[