import Foundation import SwiftUI #if SWIFT_PACKAGE import LegadoCore #endif enum AppBookChapterRepository { static func live() -> any BookChapterRepository { let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] .appendingPathComponent("Legado", isDirectory: true) guard let database = try? LegadoDatabase(url: base.appendingPathComponent("legado.sqlite3")) else { return FileBookChapterRepository.live() } return SQLiteBookChapterRepository( database: database, legacyDirectory: base.appendingPathComponent("Chapters", isDirectory: true) ) } } @MainActor final class BookLibraryModel: ObservableObject { @Published private(set) var books: [Book] = [] @Published private(set) var groups: [BookGroup] = [] @Published private(set) var errorMessage: String? @Published private(set) var updateMessage: String? @Published private(set) var isCheckingUpdates = false private let repository: any BookRepository private let localDirectory: URL let localFiles: LocalFileService private let localService = LocalBookService.live() private let updateCoordinator: BookUpdateCoordinator private let chapterRepository: any BookChapterRepository private let groupRepository: any BookGroupRepository private let pendingProgressKey = "pendingBookReadingProgress" private var pendingProgress: [String: BookProgress] private var bookSaveTask: Task? private var bookSaveGeneration = 0 private var persistedBooks: [Book] = [] init( repository: any BookRepository, localDirectory: URL? = nil, updateCoordinator: BookUpdateCoordinator = .init(), chapterRepository: any BookChapterRepository = FileBookChapterRepository.live(), groupRepository: any BookGroupRepository = InMemoryBookGroupRepository() ) { self.repository = repository self.localDirectory = localDirectory ?? FileManager.default.temporaryDirectory.appendingPathComponent("LegadoLocalBooks") localFiles = LocalFileService(directory: self.localDirectory) self.updateCoordinator = updateCoordinator self.chapterRepository = chapterRepository self.groupRepository = groupRepository pendingProgress = (UserDefaults.standard.data(forKey: pendingProgressKey) .flatMap { try? JSONDecoder().decode([String: BookProgress].self, from: $0) }) ?? [:] } static func live() -> BookLibraryModel { let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] .appendingPathComponent("Legado", isDirectory: true) let legacyBooks = base.appendingPathComponent("books.json") guard let database = try? LegadoDatabase(url: base.appendingPathComponent("legado.sqlite3")) else { return BookLibraryModel( repository: FileBookRepository(fileURL: legacyBooks), localDirectory: base.appendingPathComponent("LocalBooks", isDirectory: true), groupRepository: FileBookGroupRepository(fileURL: base.appendingPathComponent("book-groups.json")) ) } let chapters = SQLiteBookChapterRepository( database: database, legacyDirectory: base.appendingPathComponent("Chapters", isDirectory: true) ) return BookLibraryModel( repository: SQLiteBookRepository(database: database, legacyFileURL: legacyBooks), localDirectory: base.appendingPathComponent("LocalBooks", isDirectory: true), updateCoordinator: BookUpdateCoordinator(repository: chapters), chapterRepository: chapters, groupRepository: SQLiteBookGroupRepository( database: database, legacyFileURL: base.appendingPathComponent("book-groups.json") ) ) } static func demo(books: [Book]) -> BookLibraryModel { BookLibraryModel(repository: InMemoryBookRepository(books: books)) } func load() async { do { async let storedBooks = repository.load() async let storedGroups = groupRepository.load() books = try await storedBooks persistedBooks = books groups = try await storedGroups if groups.isEmpty { groups = [.init(groupId: BookGroup.idAll, groupName: "全部", order: 0)] try await persistGroups() } } catch { errorMessage = error.localizedDescription } } func contains(_ book: Book) -> Bool { books.contains { $0.bookUrl == book.bookUrl } } func addOrUpdate(_ book: Book) async { var book = book if let existing = books.first(where: { $0.bookUrl == book.bookUrl }), existing.durChapterTime > book.durChapterTime { applyProgress(BookProgress(book: existing), to: &book) } if let pending = pendingProgress.removeValue(forKey: book.bookUrl) { if pending.durChapterTime > book.durChapterTime { applyProgress(pending, to: &book) } persistPendingProgress() } var updated = books if let index = updated.firstIndex(where: { $0.bookUrl == book.bookUrl }) { updated[index] = book } else { updated.append(book) } await persist(updated) } func replace(oldBookURL: String, with book: Book) async { guard books.contains(where: { $0.bookUrl == oldBookURL }) else { await addOrUpdate(book) return } var updated = books.filter { $0.bookUrl == oldBookURL || $0.bookUrl != book.bookUrl } guard let index = updated.firstIndex(where: { $0.bookUrl == oldBookURL }) else { return } updated[index] = book await persist(updated) if oldBookURL != book.bookUrl, books.contains(where: { $0.bookUrl == book.bookUrl }) { try? await chapterRepository.remove(bookURL: oldBookURL) } } func updateProgress(for book: Book, chapter: BookChapter, position: Int = 0) async { guard let index = books.firstIndex(where: { $0.bookUrl == book.bookUrl }) else { var reading = book reading.durChapterIndex = chapter.index reading.durChapterTitle = chapter.title reading.durChapterPos = max(position, 0) reading.durChapterTime = Int64(Date().timeIntervalSince1970 * 1_000) pendingProgress[book.bookUrl] = BookProgress(book: reading) persistPendingProgress() return } var updated = books updated[index].durChapterIndex = chapter.index updated[index].durChapterTitle = chapter.title updated[index].durChapterPos = max(position, 0) updated[index].durChapterTime = Int64(Date().timeIntervalSince1970 * 1_000) updated[index].lastCheckCount = 0 updated[index].totalChapterNum = max(updated[index].totalChapterNum, chapter.index + 1) await persist(updated) } private func applyProgress(_ progress: BookProgress, to book: inout Book) { book.durChapterIndex = progress.durChapterIndex book.durChapterTitle = progress.durChapterTitle book.durChapterPos = progress.durChapterPos book.durChapterTime = progress.durChapterTime } private func persistPendingProgress() { if pendingProgress.isEmpty { UserDefaults.standard.removeObject(forKey: pendingProgressKey) } else if let data = try? JSONEncoder().encode(pendingProgress) { UserDefaults.standard.set(data, forKey: pendingProgressKey) } } func checkForUpdates(sources: [BookSource], onlyRead: Bool = false) async { guard !isCheckingUpdates else { return } isCheckingUpdates = true updateMessage = nil defer { isCheckingUpdates = false } let sourceByURL = Dictionary(uniqueKeysWithValues: sources.map { ($0.bookSourceUrl, $0) }) let requestedBooks = books.filter { $0.canUpdate && $0.origin != "local" && (!onlyRead || $0.durChapterTime > 0) } var checked = 0 var added = 0 var failed = 0 for requestedBook in requestedBooks { guard let source = sourceByURL[requestedBook.origin], source.enabled else { failed += 1 continue } let report = await updateCoordinator.refresh(book: requestedBook, source: source) checked += 1 if report.refreshed { // Network requests suspend this actor. Merge the new directory // into the current shelf, including any progress or edits made // while the request was running, and never resurrect removals. guard let index = books.firstIndex(where: { $0.bookUrl == requestedBook.bookUrl && $0.origin == requestedBook.origin }) else { continue } var updated = books updated[index] = BookUpdateMerger().merge( book: updated[index], oldChapters: [], newChapters: report.chapters, now: report.book.lastCheckTime ).book added += report.addedChapterCount await persist(updated) } else { failed += 1 } } if checked == 0 && failed == 0 { updateMessage = "书架里暂时没有可更新的网络书籍。" } else if failed > 0 { updateMessage = "已检查 \(checked) 本,发现 \(added) 个新章节;另有 \(failed) 本因书源不可用或解析失败未更新。" } else { updateMessage = added > 0 ? "更新完成,共发现 \(added) 个新章节。" : "更新完成,已是最新目录。" } } func remove(at offsets: IndexSet) async { var updated = books updated.remove(atOffsets: offsets) await persist(updated) } var visibleGroups: [BookGroup] { groups.filter(\.show).sorted { $0.order == $1.order ? $0.groupId < $1.groupId : $0.order < $1.order } } func books(in group: BookGroup) -> [Book] { switch group.groupId { case BookGroup.idAll: return books case BookGroup.idLocal: return books.filter { $0.origin == "local" } case BookGroup.idNetNone: return books.filter { $0.origin != "local" && $0.group == 0 } case BookGroup.idLocalNone: return books.filter { $0.origin == "local" && $0.group == 0 } default: return group.groupId > 0 ? books.filter { ($0.group & group.groupId) != 0 } : books } } func createGroup(name: String) async { let name = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !name.isEmpty, !groups.contains(where: { $0.groupName == name }) else { return } let highest = groups.map(\.groupId).filter { $0 > 0 }.max() ?? 0 let nextID: Int64 = highest == 0 ? 1 : highest << 1 groups.append(.init(groupId: nextID, groupName: name, order: groups.count)) do { try await persistGroups() } catch { errorMessage = error.localizedDescription } } func renameGroup(_ group: BookGroup, name: String) async { let name = name.trimmingCharacters(in: .whitespacesAndNewlines) guard !name.isEmpty, let index = groups.firstIndex(where: { $0.id == group.id }) else { return } groups[index].groupName = name do { try await persistGroups() } catch { errorMessage = error.localizedDescription } } func setGroupVisible(_ group: BookGroup, visible: Bool) async { guard let index = groups.firstIndex(where: { $0.id == group.id }) else { return } groups[index].show = visible do { try await persistGroups() } catch { errorMessage = error.localizedDescription } } func moveGroups(from offsets: IndexSet, to destination: Int) async { var editable = groups.filter { $0.groupId > 0 }.sorted { $0.order < $1.order } editable.move(fromOffsets: offsets, toOffset: destination) for (order, group) in editable.enumerated() { if let index = groups.firstIndex(where: { $0.id == group.id }) { groups[index].order = order + 1 } } do { try await persistGroups() } catch { errorMessage = error.localizedDescription } } func deleteGroup(_ group: BookGroup) async { guard group.groupId > 0 else { return } groups.removeAll { $0.id == group.id } var updated = books for index in updated.indices { updated[index].group &= ~group.groupId } await persist(updated) do { try await persistGroups() } catch { errorMessage = error.localizedDescription } } func setBook(_ book: Book, in group: BookGroup, included: Bool) async { guard group.groupId > 0, let index = books.firstIndex(where: { $0.id == book.id }) else { return } var updated = books if included { updated[index].group |= group.groupId } else { updated[index].group &= ~group.groupId } await persist(updated) } func setBook(_ book: Book, groupIDs: Set) async { guard let index = books.firstIndex(where: { $0.id == book.id }) else { return } let customMask = groups.reduce(Int64(0)) { value, group in group.groupId > 0 ? value | group.groupId : value } let selectedMask = groupIDs.reduce(Int64(0), |) var updated = books updated[index].group = (updated[index].group & ~customMask) | selectedMask await persist(updated) } func remove(bookURL: String) async { await persist(books.filter { $0.bookUrl != bookURL }) try? await chapterRepository.remove(bookURL: bookURL) } func importLocalFiles(_ urls: [URL]) async { var failures: [String] = [] for url in urls { let granted = url.startAccessingSecurityScopedResource() defer { if granted { url.stopAccessingSecurityScopedResource() } } do { let stored = try await localFiles.importFile(from: url) do { try await addLocalFile(at: stored) } catch { if let staged = try? await localFiles.stageRemoval(stored) { try? await localFiles.finishRemoval(staged) } throw error } } catch { failures.append("\(url.lastPathComponent):\(error.localizedDescription)") } } errorMessage = failures.isEmpty ? nil : failures.joined(separator: "\n") } func addLocalFile(at url: URL) async throws { guard !books.contains(where: { $0.bookUrl == url.absoluteString }) else { return } let report = try await Task.detached { [localService] in try localService.openTextFile(at: url) }.value guard !books.contains(where: { $0.bookUrl == url.absoluteString }) else { return } guard await persist(books + [report.book]) else { throw LocalFileError.saveFailed(errorMessage ?? "无法写入数据库") } } func renameLocalFile(at url: URL, to name: String) async throws { let target = try await localFiles.rename(url, to: name) guard target != url else { return } var updated = books for index in updated.indices where updated[index].origin == "local" && updated[index].bookUrl == url.absoluteString { updated[index].bookUrl = target.absoluteString updated[index].originName = target.lastPathComponent } guard await persist(updated) else { _ = try await localFiles.rename(target, to: url.lastPathComponent) throw LocalFileError.saveFailed(errorMessage ?? "无法写入数据库") } if let progress = pendingProgress.removeValue(forKey: url.absoluteString) { pendingProgress[target.absoluteString] = progress persistPendingProgress() } try? await chapterRepository.remove(bookURL: url.absoluteString) } func deleteLocalFile(at url: URL) async throws { let staged: StagedLocalFile? if url.isFileURL, books.contains(where: { $0.origin == "local" && $0.bookUrl == url.absoluteString }), !FileManager.default.fileExists(atPath: url.path) { // Stale shelf paths can belong to a previous app container. Only // remove their metadata; existing files still require containment. staged = nil } else { staged = try await localFiles.stageRemoval(url) } guard await persist(books.filter { $0.bookUrl != url.absoluteString }) else { if let staged { try await localFiles.restore(staged) } throw LocalFileError.saveFailed(errorMessage ?? "无法写入数据库") } pendingProgress[url.absoluteString] = nil persistPendingProgress() try? await chapterRepository.remove(bookURL: url.absoluteString) if let staged { try await localFiles.finishRemoval(staged) } } func clearError() { errorMessage = nil } func clearUpdateMessage() { updateMessage = nil } func reportError(_ error: Error) { errorMessage = error.localizedDescription } @discardableResult private func persist(_ updated: [Book]) async -> Bool { // Publish before suspending so the next mutation sees the latest // shelf. Serialize disk writes to keep their order identical to it. books = updated bookSaveGeneration &+= 1 let generation = bookSaveGeneration let previousSave = bookSaveTask let save = Task { @MainActor [weak self, repository] in _ = await previousSave?.value do { try await repository.save(updated) self?.persistedBooks = updated if self?.bookSaveGeneration == generation { self?.errorMessage = nil } return true } catch { if let self, bookSaveGeneration == generation { books = persistedBooks errorMessage = error.localizedDescription } return false } } bookSaveTask = save let succeeded = await save.value if bookSaveGeneration == generation { bookSaveTask = nil } return succeeded } private func persistGroups() async throws { try await groupRepository.save(groups) } }