import Combine import Foundation import SstCore /// "mm:ss.t" for the counter. public enum StopwatchFormat { public static func string(_ seconds: Double) -> String { let tenths = Int(max(0, seconds) * 10) return String(format: "%02d:%02d.%d", (tenths / 600) % 100, (tenths / 10) % 60, tenths % 10) } } public struct TextSegment: Equatable, Sendable { public let text: String /// Set when this piece was written by the dictionary correction. public let correction: AppliedCorrection? public init(text: String, correction: AppliedCorrection? = nil) { self.text = text self.correction = correction } } public enum CorrectionMarkup { /// Splits `finalText` into plain and corrected pieces. Offsets are UTF-16; out-of-range or overlapping ones are skipped. public static func segments(finalText: String, corrections: [AppliedCorrection]) -> [TextSegment] { let ns = finalText as NSString guard ns.length > 0 else { return [] } var result: [TextSegment] = [] var cursor = 0 for correction in corrections.sorted(by: { $0.start < $1.start }) { let end = correction.start + correction.length guard correction.length > 0, correction.start >= cursor, end <= ns.length else { continue } if correction.start > cursor { result.append(TextSegment(text: ns.substring(with: NSRange(location: cursor, length: correction.start - cursor)))) } result.append(TextSegment(text: ns.substring(with: NSRange(location: correction.start, length: correction.length)), correction: correction)) cursor = end } if cursor < ns.length { result.append(TextSegment(text: ns.substring(from: cursor))) } return result } } /// The searchable, day-grouped list behind the transcripts view. @MainActor public final class HistoryViewModel: ObservableObject { private let store: HistoryStore private let calendar: Calendar @Published public var query = "" { didSet { refresh() } } @Published public var selection: UUID? @Published public private(set) var days: [HistoryDay] = [] @Published public private(set) var totalCount = 0 public init(store: HistoryStore, calendar: Calendar = .current) { self.store = store self.calendar = calendar refresh() } public func refresh() { let visible = HistorySearch.filter(store.entries, query: query) days = HistoryGrouping.byDay(visible, calendar: calendar) totalCount = store.entries.count if let selection, !visible.contains(where: { $0.id == selection }) { self.selection = nil } } public var selectedEntry: HistoryEntry? { store.entries.first { $0.id == selection } } public func delete(id: UUID) { store.remove(id: id) refresh() } } /// Dictionary rows with warnings, search, editing and undo. @MainActor public final class DictionaryViewModel: ObservableObject { public struct Row: Identifiable, Equatable { public let id: UUID public let entry: DictionaryEntry public let warnings: [EntryWarning] public var worst: EntryWarning.Level? { warnings.map(\.level).max() } } private let store: DictionaryStore private let knowledge: WordKnowledge private var undoStack: [DictionaryFile] = [] @Published public var query = "" { didSet { applyFilter() } } @Published public private(set) var rows: [Row] = [] @Published public private(set) var visibleRows: [Row] = [] @Published public private(set) var canUndo = false @Published public var errorMessage: String? public init(store: DictionaryStore, knowledge: WordKnowledge) { self.store = store self.knowledge = knowledge reload() } /// Rebuilds the rows from the store (call after the store changed). public func reload() { let entries = store.entries rows = entries.enumerated().map { index, entry in // Only earlier entries count as "already there", so a duplicate is flagged on the later one. Row(id: entry.id, entry: entry, warnings: EntryWarnings.check(entry.kind, among: Array(entries[.. [EntryWarning] { let entries = store.entries let others: [DictionaryEntry] if let id, let index = entries.firstIndex(where: { $0.id == id }) { others = Array(entries[.. UUID? { var created: UUID? mutate { created = $0.append(kind) } return created } public func update(id: UUID, to kind: DictionaryEntry.Kind) { mutate { $0.update(id: id, kind: kind) } } public func remove(id: UUID) { guard store.entries.contains(where: { $0.id == id }) else { return } mutate { $0.remove(id: id) } } public func undo() { guard let snapshot = undoStack.last else { return } do { try store.apply { $0 = snapshot } undoStack.removeLast() canUndo = !undoStack.isEmpty } catch { errorMessage = error.localizedDescription } reload() } /// The file was edited elsewhere: older snapshots no longer describe it, so undo is dropped. public func externalChangeDetected() { undoStack.removeAll() canUndo = false } private func mutate(_ change: (inout DictionaryFile) -> Void) { let snapshot = store.file do { try store.apply(change) undoStack.append(snapshot) canUndo = true } catch { errorMessage = error.localizedDescription } reload() } }