feat: open-source 小浣熊阅读 for iOS

This commit is contained in:
Aminark
2026-10-14 11:37:36 +08:00
commit ffc9e9d91d
193 changed files with 41382 additions and 0 deletions
+429
View File
@@ -0,0 +1,429 @@
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)
}
}