Files
legado-ios/App/Features/Books/BookLibraryModel.swift
T

430 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<Bool, Never>?
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<Int64>) 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)
}
}