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
+357
View File
@@ -0,0 +1,357 @@
import SwiftUI
struct BookDetailView: View {
@EnvironmentObject private var library: BookLibraryModel
@EnvironmentObject private var sourceLibrary: SourceLibraryModel
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@StateObject private var model: BookDetailViewModel
@State private var showsSourceSwitch = false
@State private var showsRemoveConfirmation = false
@State private var showsTableOfContents = false
init(searchBook: SearchBook, source: BookSource) {
_model = StateObject(wrappedValue: BookDetailViewModel(searchBook: searchBook, source: source))
}
init(book: Book, source: BookSource) {
_model = StateObject(wrappedValue: BookDetailViewModel(book: book, source: source))
}
init(demoBook: Book, source: BookSource, chapters: [BookChapter]) {
_model = StateObject(wrappedValue: BookDetailViewModel(demoBook: demoBook, source: source, chapters: chapters))
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
hero
Divider()
if let intro = MetadataTextSanitizer().clean(model.book.intro), !intro.isEmpty {
VStack(alignment: .leading, spacing: 10) {
Text("简介").font(.headline)
Text(intro).font(.subheadline).foregroundStyle(.secondary).lineSpacing(5)
}
Divider()
}
chapterCard
if model.isCaching || model.cacheCompleted > 0 { cacheProgressCard }
}
.padding(.horizontal, 20)
.padding(.bottom, 30)
}
.background(ReadingTheme.canvas)
.navigationTitle("书籍详情")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button("换源", systemImage: "arrow.triangle.2.circlepath") { showsSourceSwitch = true }
.disabled(sourceLibrary.sources.filter(\.enabled).count < 2)
Button("允许更新", systemImage: model.book.canUpdate ? "checkmark" : "circle") {
model.toggleCanUpdate()
Task { await saveCurrentBook() }
}
Button("刷新", systemImage: "arrow.clockwise") {
Task {
let oldURL = model.book.bookUrl
let wasInLibrary = library.contains(model.book)
await model.refresh(sources: sourceLibrary.sources)
if wasInLibrary, oldURL != model.book.bookUrl {
await library.replace(oldBookURL: oldURL, with: model.book)
} else if wasInLibrary {
await saveCurrentBook()
}
}
}
if model.source.loginUrl?.isEmpty == false {
NavigationLink {
SourceLoginView(source: model.source)
} label: {
Label("登录", systemImage: "person.badge.key")
}
}
ReadingShareLink(item: model.book.bookUrl) {
Label("分享", systemImage: "square.and.arrow.up")
}
Divider()
Button("缓存接下来 10 章", systemImage: "arrow.down.circle") { model.cacheNextTen() }
.disabled(model.chapters.isEmpty || model.isCaching)
Button("缓存剩余章节", systemImage: "square.and.arrow.down.on.square") { model.cacheRemaining() }
.disabled(model.chapters.isEmpty || model.isCaching)
if model.isCaching {
Divider()
Button("停止缓存", systemImage: "stop.circle", role: .destructive) { model.cancelCaching() }
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
.task {
model.restoreSourceAfterReading(from: library.books, sources: sourceLibrary.sources)
let oldURL = model.book.bookUrl
let shouldPersistUpdate = library.contains(model.book)
if let stored = library.books.first(where: { $0.bookUrl == oldURL }) {
model.restoreLibraryBook(from: stored, sources: sourceLibrary.sources)
}
await model.load(sources: sourceLibrary.sources)
if shouldPersistUpdate {
if let stored = library.books.first(where: { $0.bookUrl == model.book.bookUrl }) {
model.restoreUserSettings(from: stored)
model.restoreReadingProgress(from: stored)
}
if oldURL != model.book.bookUrl {
await library.replace(oldBookURL: oldURL, with: model.book)
} else {
await saveCurrentBook()
}
}
}
.sheet(isPresented: $showsSourceSwitch) {
ReadingNavigationStack {
SourceSwitchView(book: model.book, sources: sourceLibrary.sources) { report, source in
let oldURL = model.book.bookUrl
model.applySourceSwitch(report, source: source)
Task { await library.replace(oldBookURL: oldURL, with: report.book) }
}
}
}
.sheet(isPresented: $showsTableOfContents) {
BookTableOfContentsView(book: model.book, chapters: model.chapters, source: model.source)
}
.confirmationDialog("从书架移除《\(model.book.name)》?", isPresented: $showsRemoveConfirmation) {
Button("移出书架", role: .destructive) {
Task { await library.remove(bookURL: model.book.bookUrl) }
}
Button("取消", role: .cancel) { }
}
}
private var cacheProgressCard: some View {
ReadingCard {
VStack(alignment: .leading, spacing: 10) {
HStack {
Label(model.isCaching ? "正在缓存章节" : "章节缓存完成", systemImage: model.isCaching ? "arrow.down.circle.fill" : "checkmark.circle.fill")
.font(.headline)
Spacer()
Text("\(model.cacheCompleted)/\(model.cacheTotal)")
.font(.caption.monospacedDigit()).foregroundStyle(.secondary)
}
ProgressView(value: Double(model.cacheCompleted), total: Double(max(model.cacheTotal, 1)))
.tint(ReadingTheme.accent)
if model.isCaching {
Button("停止缓存", role: .destructive) { model.cancelCaching() }
.font(.subheadline)
}
}
}
}
private var hero: some View {
VStack(alignment: .leading, spacing: 20) {
HStack(alignment: .top, spacing: 18) {
BookCoverArtwork(
title: model.book.name,
author: model.book.author,
remoteURL: model.book.coverUrl,
width: 88,
height: 126
)
VStack(alignment: .leading, spacing: 8) {
Text(model.book.name).font(.title2.weight(.semibold)).lineLimit(3)
Text(model.book.author.isEmpty ? "未知作者" : model.book.author)
.font(.subheadline).foregroundStyle(.secondary)
if let kind = model.book.kind, !kind.isEmpty {
Text(kind).font(.caption).foregroundStyle(.secondary)
}
Text(model.source.bookSourceName)
.font(.caption).foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
if let latest = model.book.latestChapterTitle, !latest.isEmpty {
Text("更新至 \(latest)")
.font(.caption).foregroundStyle(.secondary).lineLimit(2)
}
Group {
if dynamicTypeSize.isAccessibilitySize {
VStack(spacing: 12) { readingActions }
} else {
HStack(spacing: 12) { readingActions }
}
}
.controlSize(.large)
}
.padding(.top, 16)
}
@ViewBuilder
private var readingActions: some View {
NavigationLink {
readingDestination
} label: {
Text(model.book.durChapterIndex > 0 || model.book.durChapterPos > 0 ? "继续阅读" : "开始阅读")
.font(.body.weight(.medium))
.frame(maxWidth: .infinity, minHeight: 28)
}
.buttonStyle(.borderedProminent)
.disabled(model.chapters.isEmpty)
Button {
if library.contains(model.book) {
showsRemoveConfirmation = true
} else {
Task { await saveCurrentBook() }
}
} label: {
Label(
library.contains(model.book) ? "已在书架" : "加入书架",
systemImage: library.contains(model.book) ? "checkmark" : "plus"
)
.font(.subheadline)
.frame(maxWidth: dynamicTypeSize.isAccessibilitySize ? .infinity : nil, minHeight: 28)
}
.buttonStyle(.bordered)
}
@ViewBuilder
private var readingDestination: some View {
let index = min(max(model.book.durChapterIndex, 0), max(model.chapters.count - 1, 0))
if ProcessInfo.processInfo.arguments.contains("-uiDemo") {
ReaderView(demoBook: model.book, chapters: model.chapters, initialIndex: index, content: DemoContent.readerText)
} else {
ReaderView(book: model.book, chapters: model.chapters, initialIndex: index, source: model.source)
}
}
private func saveCurrentBook() async {
await library.addOrUpdate(model.book)
if let stored = library.books.first(where: { $0.bookUrl == model.book.bookUrl }) {
model.restoreReadingProgress(from: stored)
}
}
private var chapterCard: some View {
VStack(alignment: .leading, spacing: 0) {
HStack {
Text("目录").font(.headline)
Spacer()
Text("\(model.chapters.count) 章").font(.caption).foregroundStyle(.secondary)
}
.padding(.bottom, 12)
if model.state == .loading || model.state == .idle {
HStack { Spacer(); ProgressView("正在解析目录…"); Spacer() }.padding(.vertical, 28)
} else if model.state == .findingSource {
HStack { Spacer(); ProgressView("当前源不可用,正在自动寻找可用源…"); Spacer() }
.padding(.vertical, 28)
} else if model.state == .failed {
ReadingUnavailableView(
"没有找到可用目录",
systemImage: "exclamationmark.arrow.triangle.2.circlepath",
description: Text("已自动尝试其他可搜索书源,可稍后刷新重试或手动换源。")
)
.padding(.vertical, 12)
} else {
ForEach(Array(model.chapters.filter { !$0.isVolume }.prefix(8).enumerated()), id: \.element.id) { offset, chapter in
if offset > 0 { Divider() }
if chapter.isVolume {
Text(chapter.title).font(.subheadline.bold()).foregroundStyle(ReadingTheme.accent)
.padding(.vertical, 12)
} else {
NavigationLink {
ReaderView(
book: model.book,
chapters: model.chapters,
initialIndex: chapter.index,
source: model.source
)
} label: {
HStack(spacing: 12) {
Text(String(format: "%02d", chapter.index + 1))
.font(.caption.monospacedDigit()).foregroundStyle(.tertiary).frame(width: 28)
Text(chapter.title).font(.subheadline).foregroundStyle(.primary).lineLimit(2)
Spacer()
if chapter.isVip { Image(systemName: "lock.fill").font(.caption).foregroundStyle(.orange) }
Image(systemName: "chevron.right").font(.caption.bold()).foregroundStyle(.tertiary)
}
.padding(.vertical, 12)
}
.buttonStyle(.plain)
}
}
if model.chapters.count > 8 {
Divider()
Button {
showsTableOfContents = true
} label: {
Label("查看全部目录", systemImage: "list.bullet")
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
}
}
}
}
}
}
private struct BookTableOfContentsView: View {
@Environment(\.dismiss) private var dismiss
let book: Book
let chapters: [BookChapter]
let source: BookSource
@State private var searchText = ""
@State private var descending = false
private var displayedChapters: [BookChapter] {
let filtered = searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? chapters
: chapters.filter { $0.title.localizedCaseInsensitiveContains(searchText) }
return descending ? Array(filtered.reversed()) : filtered
}
var body: some View {
ReadingNavigationStack {
List(displayedChapters) { chapter in
if chapter.isVolume {
Text(chapter.title).font(.headline).foregroundStyle(ReadingTheme.accent)
} else {
NavigationLink {
ReaderView(book: book, chapters: chapters, initialIndex: chapter.index, source: source)
} label: {
HStack(spacing: 12) {
Text("\(chapter.index + 1)")
.font(.caption.monospacedDigit()).foregroundStyle(.secondary).frame(width: 42, alignment: .trailing)
Text(chapter.title).lineLimit(2)
Spacer()
if chapter.index == book.durChapterIndex {
Image(systemName: "location.fill").foregroundStyle(ReadingTheme.accent)
}
if chapter.isVip { Image(systemName: "lock.fill").foregroundStyle(.orange) }
}
}
}
}
.searchable(text: $searchText, prompt: "搜索目录")
.navigationTitle("目录")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("关闭") { dismiss() } }
ToolbarItem(placement: .topBarTrailing) {
Button(descending ? "正序" : "倒序", systemImage: "arrow.up.arrow.down") {
descending.toggle()
}
}
}
.safeAreaInset(edge: .bottom) {
HStack {
Text(book.durChapterTitle ?? "尚未阅读").lineLimit(1)
Spacer()
Text("\(min(book.durChapterIndex + 1, chapters.count))/\(chapters.count)")
.monospacedDigit().foregroundStyle(.secondary)
}
.font(.caption)
.padding(.horizontal, 16).padding(.vertical, 10)
.background(.bar)
}
}
}
}
@@ -0,0 +1,257 @@
import SwiftUI
#if SWIFT_PACKAGE
import LegadoCore
#endif
@MainActor
final class BookDetailViewModel: ObservableObject {
enum State: Equatable { case idle, loading, findingSource, ready, failed }
@Published private(set) var state: State = .idle
@Published private(set) var book: Book
@Published private(set) var chapters: [BookChapter] = []
@Published private(set) var diagnostics: [RuleDiagnostic] = []
@Published private(set) var isCaching = false
@Published private(set) var cacheCompleted = 0
@Published private(set) var cacheTotal = 0
@Published private(set) var source: BookSource
private var searchBook: SearchBook?
private let service: NetworkBookService
private let updateCoordinator: BookUpdateCoordinator
private let contentCoordinator: ChapterContentCoordinator
private let sourceSwitchService: BookSourceSwitchService
private var cacheTask: Task<Void, Never>?
init(
searchBook: SearchBook,
source: BookSource,
service: NetworkBookService = .init(),
chapterRepository: any BookChapterRepository = AppBookChapterRepository.live()
) {
self.searchBook = searchBook
self.source = source
self.service = service
let chapters = chapterRepository
updateCoordinator = BookUpdateCoordinator(repository: chapters, loader: service)
contentCoordinator = ChapterContentCoordinator(loader: service)
sourceSwitchService = BookSourceSwitchService(
detailLoader: service,
chapterLoader: service,
contentLoader: service,
chapterRepository: chapters
)
var book = Book(
bookUrl: searchBook.bookURL,
name: searchBook.name,
author: searchBook.author,
origin: source.bookSourceUrl
)
book.originName = source.bookSourceName
book.coverUrl = searchBook.coverURL
book.intro = searchBook.intro
book.kind = searchBook.kind
self.book = book
}
init(book: Book, source: BookSource, service: NetworkBookService = .init()) {
searchBook = nil
self.book = book
self.source = source
self.service = service
let chapters = AppBookChapterRepository.live()
updateCoordinator = BookUpdateCoordinator(repository: chapters, loader: service)
contentCoordinator = ChapterContentCoordinator(loader: service)
sourceSwitchService = BookSourceSwitchService(
detailLoader: service,
chapterLoader: service,
contentLoader: service,
chapterRepository: chapters
)
}
init(demoBook: Book, source: BookSource, chapters: [BookChapter]) {
searchBook = nil
book = demoBook
self.source = source
service = .init()
updateCoordinator = .init()
contentCoordinator = .init()
sourceSwitchService = .init()
self.chapters = chapters
state = .ready
isCaching = true
cacheCompleted = 2
cacheTotal = chapters.filter { !$0.isVolume }.count
}
func load(sources: [BookSource] = []) async {
guard state == .idle else { return }
state = .loading
if searchBook == nil {
let stored = await updateCoordinator.storedChapters(bookURL: book.bookUrl)
if !stored.isEmpty {
chapters = stored
state = .ready
}
}
if let searchBook {
let detail = await service.loadDetail(searchBook: searchBook, source: source)
let details = detail.book
book.name = details.name
book.author = details.author
book.originName = details.originName
if !details.tocUrl.isEmpty { book.tocUrl = details.tocUrl }
book.type = details.type
book.kind = details.kind ?? book.kind
book.coverUrl = details.coverUrl ?? book.coverUrl
book.intro = details.intro ?? book.intro
book.charset = details.charset ?? book.charset
book.wordCount = details.wordCount ?? book.wordCount
book.latestChapterTitle = details.latestChapterTitle ?? book.latestChapterTitle
book.originOrder = details.originOrder
book.variable = details.variable ?? book.variable
diagnostics += detail.diagnostics
}
let toc = await updateCoordinator.refresh(book: book, source: source)
if !toc.chapters.isEmpty { chapters = toc.chapters }
diagnostics += toc.diagnostics
book = toc.book
if !chapters.isEmpty {
state = .ready
return
}
state = .findingSource
if let available = await sourceSwitchService.firstAvailableSource(for: book, sources: sources) {
applySourceSwitch(available.report, source: available.source)
} else {
state = .failed
}
}
func cacheNextTen() { startCaching(limit: 10) }
func cacheRemaining() { startCaching(limit: nil) }
func cancelCaching() {
cacheTask?.cancel()
cacheTask = nil
isCaching = false
}
func applySourceSwitch(_ report: BookSourceSwitchReport, source: BookSource) {
cancelCaching()
searchBook = nil
book = report.book
chapters = report.chapters
diagnostics = report.diagnostics
self.source = source
state = .ready
}
func toggleCanUpdate() { book.canUpdate.toggle() }
func restoreLibraryBook(from stored: Book, sources: [BookSource] = []) {
guard state == .idle, stored.bookUrl == book.bookUrl else { return }
if stored.origin != source.id,
let resolved = sources.first(where: { $0.id == stored.origin }) {
source = resolved
searchBook = nil
}
book = stored
book.origin = source.id
book.originName = source.bookSourceName
}
func restoreSourceAfterReading(from books: [Book], sources: [BookSource]) {
guard state == .ready,
let stored = books.first(where: { $0.bookUrl == book.bookUrl })
?? books.first(where: { $0.name == book.name && $0.author == book.author }),
stored.bookUrl != book.bookUrl || stored.origin != book.origin,
let source = sources.first(where: { $0.id == stored.origin }) else { return }
book = stored
self.source = source
searchBook = nil
chapters = []
diagnostics = []
state = .idle
}
func restoreUserSettings(from stored: Book) {
guard stored.bookUrl == book.bookUrl else { return }
book.group = stored.group
book.order = stored.order
book.customTag = stored.customTag
book.customCoverUrl = stored.customCoverUrl
book.customIntro = stored.customIntro
book.canUpdate = stored.canUpdate
book.readConfig = stored.readConfig
book.syncTime = stored.syncTime
}
func restoreReadingProgress(from stored: Book) {
guard stored.bookUrl == book.bookUrl, stored.durChapterTime > book.durChapterTime else { return }
let chapterIndex = stored.durChapterTitle.flatMap { title in
chapters.firstIndex { !$0.isVolume && $0.title == title }
} ?? stored.durChapterIndex
guard chapters.indices.contains(chapterIndex), !chapters[chapterIndex].isVolume else { return }
book.durChapterIndex = chapterIndex
book.durChapterTitle = chapters[chapterIndex].title
book.durChapterPos = stored.durChapterPos
book.durChapterTime = stored.durChapterTime
}
func refresh(sources: [BookSource] = []) async {
cancelCaching()
chapters = []
diagnostics = []
state = .idle
await load(sources: sources)
}
private func startCaching(limit: Int?) {
cancelCaching()
let start = min(max(book.durChapterIndex, 0), max(chapters.count - 1, 0))
let jobs = ChapterCachePlanner.plan(chapters: chapters, startIndex: start, limit: limit)
guard !jobs.isEmpty else { return }
isCaching = true
cacheCompleted = 0
cacheTotal = jobs.count
let coordinator = contentCoordinator
let currentBook = book
let currentSource = source
cacheTask = Task { [weak self] in
await withTaskGroup(of: Bool.self) { group in
var next = 0
func enqueue() {
guard next < jobs.count else { return }
let job = jobs[next]
next += 1
group.addTask {
guard !Task.isCancelled else { return false }
return await coordinator.prefetch(
book: currentBook,
chapter: job.chapter,
source: currentSource,
nextChapterURL: job.nextChapterURL
)
}
}
for _ in 0..<min(3, jobs.count) { enqueue() }
while await group.next() != nil {
guard !Task.isCancelled else {
group.cancelAll()
break
}
self?.cacheCompleted += 1
enqueue()
}
}
guard !Task.isCancelled else { return }
self?.isCaching = false
self?.cacheTask = nil
}
}
}
+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)
}
}
@@ -0,0 +1,64 @@
import SwiftUI
#if SWIFT_PACKAGE
import LegadoCore
#endif
@MainActor
final class BookshelfReadViewModel: ObservableObject {
@Published private(set) var chapters: [BookChapter] = []
@Published private(set) var resolvedBook: Book?
@Published private(set) var isLoading = false
@Published private(set) var errorMessage: String?
private let localService: LocalBookService
private let updateCoordinator: BookUpdateCoordinator
init(
localService: LocalBookService = .init(),
updateCoordinator: BookUpdateCoordinator = .init(repository: AppBookChapterRepository.live())
) {
self.localService = localService
self.updateCoordinator = updateCoordinator
}
func load(book: Book, source: BookSource?) async {
guard chapters.isEmpty, !isLoading else { return }
isLoading = true
defer { isLoading = false }
if book.origin == "local" {
do {
let report = try await Task.detached { [localService] in
try localService.loadChapters(book: book)
}.value
chapters = report.chapters
resolvedBook = book
} catch {
errorMessage = error.localizedDescription
}
return
}
let stored = await updateCoordinator.storedChapters(bookURL: book.bookUrl)
if !stored.isEmpty, matchesReadingProgress(book, chapters: stored) {
chapters = stored
resolvedBook = book
return
}
guard let source else {
errorMessage = "目录与阅读进度不一致,且当前书籍的书源不存在。请先恢复或更换书源。"
return
}
let report = await updateCoordinator.refresh(book: book, source: source)
if report.refreshed, matchesReadingProgress(report.book, chapters: report.chapters) {
chapters = report.chapters
resolvedBook = report.book
} else {
errorMessage = report.diagnostics.first?.message ?? "目录刷新后仍与阅读进度不一致,请稍后重试。"
}
}
private func matchesReadingProgress(_ book: Book, chapters: [BookChapter]) -> Bool {
guard book.totalChapterNum == 0 || book.totalChapterNum == chapters.count else { return false }
guard let title = book.durChapterTitle, !title.isEmpty else { return true }
return chapters.indices.contains(book.durChapterIndex)
&& chapters[book.durChapterIndex].title == title
}
}
@@ -0,0 +1,152 @@
import SwiftUI
@MainActor
final class LocalBookDetailViewModel: ObservableObject {
@Published private(set) var chapters: [BookChapter] = []
@Published private(set) var isLoading = false
@Published private(set) var errorMessage: String?
private let service = LocalBookService.live()
func load(book: Book) async {
guard chapters.isEmpty, !isLoading else { return }
isLoading = true
defer { isLoading = false }
do {
let report = try await Task.detached { [service] in try service.loadChapters(book: book) }.value
chapters = report.chapters
} catch {
errorMessage = error.localizedDescription
}
}
}
struct LocalBookDetailView: View {
@EnvironmentObject private var library: BookLibraryModel
@Environment(\.dismiss) private var dismiss
let book: Book
@StateObject private var model = LocalBookDetailViewModel()
@State private var showsTableOfContents = false
@State private var showsRemoveConfirmation = false
var body: some View {
ScrollView {
VStack(spacing: 16) {
VStack(spacing: 14) {
BookCoverArtwork(title: book.name, author: book.author, width: 108, height: 154)
Text(book.name).font(.title2.bold())
HStack {
StatusPill(text: "本地 TXT", systemImage: "doc.text.fill")
if let charset = book.charset { StatusPill(text: charset, color: ReadingTheme.coral, systemImage: "character") }
}
Text(book.originName).font(.caption).foregroundStyle(.secondary)
}
.padding(.top, 12)
ReadingCard {
VStack(alignment: .leading, spacing: 0) {
HStack {
Label("目录", systemImage: "list.bullet.rectangle.portrait.fill").font(.headline)
Spacer()
Text("\(model.chapters.count) 章").font(.caption).foregroundStyle(.secondary)
}
.padding(.bottom, 12)
if model.isLoading {
HStack { Spacer(); ProgressView("正在识别章节…"); Spacer() }.padding(.vertical, 28)
} else if let error = model.errorMessage {
ReadingUnavailableView("无法读取本地书", systemImage: "doc.badge.ellipsis", description: Text(error))
} else {
ForEach(model.chapters.prefix(8)) { chapter in
Divider()
NavigationLink {
ReaderView(book: book, chapters: model.chapters, initialIndex: chapter.index)
} label: {
HStack(spacing: 12) {
Text(String(format: "%02d", chapter.index + 1))
.font(.caption.monospacedDigit()).foregroundStyle(.tertiary).frame(width: 28)
Text(chapter.title).font(.subheadline).foregroundStyle(.primary).lineLimit(2)
Spacer()
Image(systemName: "chevron.right").font(.caption.bold()).foregroundStyle(.tertiary)
}
.padding(.vertical, 12)
}
}
if model.chapters.count > 8 {
Divider()
Button("查看全部目录", systemImage: "list.bullet") {
showsTableOfContents = true
}
.frame(maxWidth: .infinity).padding(.vertical, 10)
}
}
}
}
}
.padding(.horizontal, 16).padding(.bottom, 30)
}
.background(ReadingTheme.canvas)
.navigationTitle(book.name)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu {
Button("全部目录", systemImage: "list.bullet") { showsTableOfContents = true }
Button("移出书架", systemImage: "trash", role: .destructive) { showsRemoveConfirmation = true }
} label: { Image(systemName: "ellipsis.circle") }
}
}
.task { await model.load(book: book) }
.sheet(isPresented: $showsTableOfContents) {
LocalBookTableOfContentsView(book: book, chapters: model.chapters)
}
.confirmationDialog("从书架移除《\(book.name)》?", isPresented: $showsRemoveConfirmation) {
Button("移出书架", role: .destructive) {
Task { await library.remove(bookURL: book.bookUrl); dismiss() }
}
Button("取消", role: .cancel) {}
}
}
}
private struct LocalBookTableOfContentsView: View {
@Environment(\.dismiss) private var dismiss
let book: Book
let chapters: [BookChapter]
@State private var query = ""
@State private var descending = false
private var displayed: [BookChapter] {
let filtered = query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? chapters
: chapters.filter { $0.title.localizedCaseInsensitiveContains(query) }
return descending ? Array(filtered.reversed()) : filtered
}
var body: some View {
ReadingNavigationStack {
List(displayed) { chapter in
NavigationLink {
ReaderView(book: book, chapters: chapters, initialIndex: chapter.index)
} label: {
HStack {
Text("\(chapter.index + 1)").font(.caption.monospacedDigit()).foregroundStyle(.secondary).frame(width: 42, alignment: .trailing)
Text(chapter.title).lineLimit(2)
Spacer()
if chapter.index == book.durChapterIndex {
Image(systemName: "location.fill").foregroundStyle(ReadingTheme.accent)
}
}
}
}
.searchable(text: $query, prompt: "搜索目录")
.navigationTitle("目录")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("关闭") { dismiss() } }
ToolbarItem(placement: .topBarTrailing) {
Button(descending ? "正序" : "倒序", systemImage: "arrow.up.arrow.down") { descending.toggle() }
}
}
}
}
}
+156
View File
@@ -0,0 +1,156 @@
import SwiftUI
@MainActor
final class SourceSwitchViewModel: ObservableObject {
enum State: Equatable { case idle, searching, ready, empty }
@Published private(set) var state: State = .idle
@Published private(set) var candidates: [BookSourceCandidate] = []
@Published private(set) var switchingID: String?
@Published private(set) var errorMessage: String?
private let book: Book
var bookName: String { book.name }
private let service: BookSourceSwitchService
private var selectionGeneration = 0
init(book: Book, service: BookSourceSwitchService = .init(), candidates: [BookSourceCandidate] = []) {
self.book = book
self.service = service
self.candidates = candidates
state = candidates.isEmpty ? .idle : .ready
}
func load(sources: [BookSource]) async {
guard state == .idle else { return }
state = .searching
for await update in await service.candidateUpdates(for: book, sources: sources) {
guard !Task.isCancelled else { state = candidates.isEmpty ? .idle : .ready; return }
candidates = update
}
guard !Task.isCancelled else { state = candidates.isEmpty ? .idle : .ready; return }
state = candidates.isEmpty ? .empty : .ready
}
func select(_ candidate: BookSourceCandidate, sources: [BookSource]) async -> (BookSourceSwitchReport, BookSource)? {
guard switchingID == nil,
let source = sources.first(where: { $0.bookSourceUrl == candidate.book.origin }) else { return nil }
switchingID = candidate.id
let generation = selectionGeneration
errorMessage = nil
defer { switchingID = nil }
let report = await service.prepareSwitch(current: book, candidate: candidate.book, source: source)
guard generation == selectionGeneration, !Task.isCancelled else { return nil }
guard let report else {
errorMessage = "候选书源无法读取目录或当前章节正文,请选择其他书源。已保留当前书源和阅读位置。"
return nil
}
return (report, source)
}
func clearError() { errorMessage = nil }
func cancelSelection() { selectionGeneration &+= 1 }
}
struct SourceSwitchView: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var model: SourceSwitchViewModel
let sources: [BookSource]
let onSwitched: @MainActor (BookSourceSwitchReport, BookSource) -> Void
init(
book: Book,
sources: [BookSource],
demoCandidates: [BookSourceCandidate] = [],
onSwitched: @escaping @MainActor (BookSourceSwitchReport, BookSource) -> Void
) {
self.sources = sources
self.onSwitched = onSwitched
_model = StateObject(wrappedValue: SourceSwitchViewModel(book: book, candidates: demoCandidates))
}
var body: some View {
Group {
if model.candidates.isEmpty && (model.state == .searching || model.state == .idle) {
VStack(spacing: 14) {
ProgressView()
Text("正在从其他书源查找同名作品…").foregroundStyle(.secondary)
}
} else if model.state == .empty {
ReadingUnavailableView(
"没有可用换源",
systemImage: "arrow.triangle.2.circlepath",
description: Text("仅显示书名一致的候选,并优先匹配作者。请确认其他书源已启用且支持搜索。")
)
} else {
List {
if model.state == .searching {
HStack(spacing: 10) {
ProgressView()
Text("仍在查找其他书源,可直接选择已找到的结果")
.font(.caption).foregroundStyle(.secondary)
}
}
ForEach(model.candidates) { candidate in candidateRow(candidate) }
}
.listStyle(.insetGrouped)
.readingScrollContentBackgroundHidden()
.background(ReadingTheme.canvas)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(ReadingTheme.canvas)
.navigationTitle("为《\(model.bookName)》换源")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
}
.task { await model.load(sources: sources) }
.onDisappear { model.cancelSelection() }
.alert("换源失败", isPresented: Binding(
get: { model.errorMessage != nil },
set: { if !$0 { model.clearError() } }
)) {
Button("好") { model.clearError() }
} message: {
Text(model.errorMessage ?? "")
}
}
private func candidateRow(_ candidate: BookSourceCandidate) -> some View {
Button {
Task {
if let (report, source) = await model.select(candidate, sources: sources) {
onSwitched(report, source)
dismiss()
}
}
} label: {
HStack(spacing: 13) {
Image(systemName: "network")
.foregroundStyle(ReadingTheme.accent)
.frame(width: 42, height: 42)
.background(ReadingTheme.accentSoft.opacity(0.55), in: RoundedRectangle(cornerRadius: 13))
VStack(alignment: .leading, spacing: 5) {
Text(candidate.book.originName).font(.headline).foregroundStyle(.primary)
Text(candidate.book.author.isEmpty ? "作者信息缺失" : candidate.book.author)
.font(.subheadline).foregroundStyle(.secondary)
if model.switchingID == candidate.id {
Text("正在验证目录和正文…").font(.caption).foregroundStyle(.secondary)
} else if let latest = candidate.book.latestChapterTitle, !latest.isEmpty {
Text(latest).font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
}
Spacer()
if model.switchingID == candidate.id {
ProgressView()
} else {
Image(systemName: "arrow.right.circle.fill").foregroundStyle(ReadingTheme.coral)
}
}
.padding(.vertical, 5)
}
.disabled(model.switchingID != nil)
.accessibilityIdentifier("sourceSwitch.candidate.\(candidate.book.origin)")
}
}