mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-24 21:01:22 +08:00
feat: open-source 小浣熊阅读 for iOS
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user