mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-20 19:21:22 +08:00
430 lines
18 KiB
Swift
430 lines
18 KiB
Swift
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)
|
||
}
|
||
}
|