import Foundation @MainActor final class ReadingActivityModel: ObservableObject { @Published private(set) var bookmarks: [Bookmark] = [] @Published private(set) var readRecords: [ReadRecord] = [] @Published private(set) var errorMessage: String? private let repository: any ReadingActivityRepository private let service = ReadingActivityService() private let deviceID: String private var sessions: [String: Date] = [:] init(repository: any ReadingActivityRepository, deviceID: String) { self.repository = repository self.deviceID = deviceID } static func live() -> ReadingActivityModel { let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] .appendingPathComponent("Legado", isDirectory: true) let key = "legado.reading.device-id" let defaults = UserDefaults.standard let identifier = defaults.string(forKey: key) ?? UUID().uuidString defaults.set(identifier, forKey: key) let bookmarksURL = base.appendingPathComponent("bookmarks.json") let recordsURL = base.appendingPathComponent("read-records.json") let repository: any ReadingActivityRepository if let database = try? LegadoDatabase(url: base.appendingPathComponent("legado.sqlite3")) { repository = SQLiteReadingActivityRepository( database: database, legacyBookmarksURL: bookmarksURL, legacyRecordsURL: recordsURL ) } else { repository = FileReadingActivityRepository(bookmarksURL: bookmarksURL, recordsURL: recordsURL) } return ReadingActivityModel(repository: repository, deviceID: identifier) } static func demo(bookmarks: [Bookmark] = [], records: [ReadRecord] = []) -> ReadingActivityModel { ReadingActivityModel( repository: InMemoryReadingActivityRepository(bookmarks: bookmarks, records: records), deviceID: "ios-simulator" ) } var summaries: [ReadRecordSummary] { service.summaries(readRecords) } var statistics: ReadingStatistics { service.statistics(readRecords) } func summaries(searchKey: String, sortMode: Int) -> [ReadRecordSummary] { service.displayedSummaries(readRecords, searchKey: searchKey, sortMode: sortMode) } func load() async { do { async let storedBookmarks = repository.loadBookmarks() async let storedRecords = repository.loadReadRecords() bookmarks = service.sortedBookmarks(try await storedBookmarks) readRecords = try await storedRecords } catch { errorMessage = error.localizedDescription } } func bookmarks(for book: Book) -> [Bookmark] { bookmarks.filter { $0.bookName == book.name && $0.bookAuthor == book.author } .sorted { if $0.chapterIndex == $1.chapterIndex { return $0.chapterPosition < $1.chapterPosition } return $0.chapterIndex < $1.chapterIndex } } func addBookmark(book: Book, chapter: BookChapter, position: Int, content: String) async { var timestamp = Int64(Date().timeIntervalSince1970 * 1_000) while bookmarks.contains(where: { $0.time == timestamp }) { timestamp += 1 } var bookmark = Bookmark(time: timestamp, bookName: book.name, bookAuthor: book.author) bookmark.chapterIndex = chapter.index bookmark.chapterPosition = max(position, 0) bookmark.chapterName = chapter.title bookmark.bookText = excerpt(content, around: position) var updated = bookmarks updated.append(bookmark) do { try await repository.saveBookmarks(updated) bookmarks = service.sortedBookmarks(updated) } catch { errorMessage = error.localizedDescription } } func removeBookmark(_ bookmark: Bookmark) async { let updated = bookmarks.filter { $0.id != bookmark.id } do { try await repository.saveBookmarks(updated) bookmarks = updated } catch { errorMessage = error.localizedDescription } } func updateBookmark(_ bookmark: Bookmark) async { var updated = bookmarks if let index = updated.firstIndex(where: { $0.id == bookmark.id }) { updated[index] = bookmark } else { updated.append(bookmark) } do { try await repository.saveBookmarks(updated) bookmarks = service.sortedBookmarks(updated) } catch { errorMessage = error.localizedDescription } } func beginReading(_ book: Book, now: Date = Date()) { sessions[book.bookUrl] = sessions[book.bookUrl] ?? now } func checkpoint(_ book: Book, now: Date = Date()) async { guard let start = sessions[book.bookUrl] else { sessions[book.bookUrl] = now return } sessions[book.bookUrl] = now await record(book: book, elapsed: now.timeIntervalSince(start), now: now) } func endReading(_ book: Book, now: Date = Date()) async { guard let start = sessions.removeValue(forKey: book.bookUrl) else { return } await record(book: book, elapsed: now.timeIntervalSince(start), now: now) } func cancelReading(_ book: Book) { sessions.removeValue(forKey: book.bookUrl) } func removeHistory(bookName: String) async { let updated = readRecords.filter { $0.bookName != bookName } do { try await repository.saveReadRecords(updated) readRecords = updated } catch { errorMessage = error.localizedDescription } } func clearHistory() async { do { try await repository.saveReadRecords([]) readRecords = [] } catch { errorMessage = error.localizedDescription } } func clearError() { errorMessage = nil } private func record(book: Book, elapsed: TimeInterval, now: Date) async { let milliseconds = Int64(max(elapsed, 0) * 1_000) guard milliseconds > 0 else { return } let updated = service.addingReadTime( milliseconds, deviceID: deviceID, bookName: book.name, now: Int64(now.timeIntervalSince1970 * 1_000), to: readRecords ) readRecords = updated do { try await repository.saveReadRecords(updated) } catch { errorMessage = error.localizedDescription } } private func excerpt(_ content: String, around position: Int) -> String { guard !content.isEmpty else { return "" } let center = min(max(position, 0), content.count) let start = max(center - 24, 0) let end = min(center + 96, content.count) let lower = content.index(content.startIndex, offsetBy: start) let upper = content.index(content.startIndex, offsetBy: end) return String(content[lower..