193 lines
6.5 KiB
Swift
193 lines
6.5 KiB
Swift
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[..<index]), knowledge: knowledge))
|
|
}
|
|
applyFilter()
|
|
}
|
|
|
|
private func applyFilter() {
|
|
let words = HistorySearch.fold(query).split(whereSeparator: \.isWhitespace).map(String.init)
|
|
guard !words.isEmpty else {
|
|
visibleRows = rows
|
|
return
|
|
}
|
|
visibleRows = rows.filter { row in
|
|
let haystack = HistorySearch.fold(row.entry.source + " " + row.entry.wrote)
|
|
return words.allSatisfy { haystack.contains($0) }
|
|
}
|
|
}
|
|
|
|
/// Live feedback for the editor; `id` is the entry being edited (nil for a new one).
|
|
public func warnings(for kind: DictionaryEntry.Kind, editing id: UUID?) -> [EntryWarning] {
|
|
let entries = store.entries
|
|
let others: [DictionaryEntry]
|
|
if let id, let index = entries.firstIndex(where: { $0.id == id }) {
|
|
others = Array(entries[..<index])
|
|
} else {
|
|
others = entries
|
|
}
|
|
return EntryWarnings.check(kind, among: others, knowledge: knowledge)
|
|
}
|
|
|
|
@discardableResult
|
|
public func add(_ kind: DictionaryEntry.Kind) -> 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()
|
|
}
|
|
}
|