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
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

@@ -0,0 +1,14 @@
{
"images" : [
{
"filename" : "AppIcon-1024.png",
"idiom" : "universal",
"platform" : "ios",
"size" : "1024x1024"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+6
View File
@@ -0,0 +1,6 @@
{
"images": [
{ "filename": "BrandIcon.png", "idiom": "universal" }
],
"info": { "author": "xcode", "version": 1 }
}
+6
View File
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,6 @@
{
"images": [
{ "filename": "profile-reading-header.png", "idiom": "universal" }
],
"info": { "author": "xcode", "version": 1 }
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

+342
View File
@@ -0,0 +1,342 @@
import SwiftUI
import PhotosUI
import UniformTypeIdentifiers
enum ReadingCompatibility {
static var usesLegacyUI: Bool {
#if DEBUG
ProcessInfo.processInfo.arguments.contains("-uiLegacyCompatibility")
#else
false
#endif
}
}
struct ReadingNavigationStack<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
if #available(iOS 16.0, *), !ReadingCompatibility.usesLegacyUI {
NavigationStack { content }
} else {
NavigationView { content }.navigationViewStyle(.stack)
}
}
}
struct ReadingLabeledContent<Content: View>: View {
let title: String
@ViewBuilder var content: Content
init(_ title: String, @ViewBuilder content: () -> Content) {
self.title = title
self.content = content()
}
var body: some View {
if #available(iOS 16.0, *), !ReadingCompatibility.usesLegacyUI {
LabeledContent(title) { content }
} else {
HStack {
Text(title)
Spacer()
content.foregroundStyle(.secondary).multilineTextAlignment(.trailing)
}
}
}
}
extension ReadingLabeledContent where Content == Text {
init(_ title: String, value: String) {
self.init(title) { Text(value) }
}
}
struct ReadingTextField: View {
let title: String
@Binding var text: String
var axis: Axis
init(_ title: String, text: Binding<String>, axis: Axis) {
self.title = title
_text = text
self.axis = axis
}
var body: some View {
if #available(iOS 16.0, *), !ReadingCompatibility.usesLegacyUI {
TextField(title, text: $text, axis: axis)
} else if axis == .vertical {
VStack(alignment: .leading, spacing: 6) {
Text(title).font(.caption).foregroundStyle(.secondary)
TextEditor(text: $text).frame(minHeight: 80).accessibilityLabel(title)
}
} else {
TextField(title, text: $text)
}
}
}
struct ReadingShareLink<Label: View>: View {
private let items: [Any]
private let label: Label
init(item: String, @ViewBuilder label: () -> Label) {
items = [item]
self.label = label()
}
init(item: URL, @ViewBuilder label: () -> Label) {
items = [item]
self.label = label()
}
var body: some View {
Button { presentShareSheet() } label: { label }
}
private func presentShareSheet() {
guard var presenter = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.filter({ $0.activationState == .foregroundActive })
.flatMap(\.windows)
.first(where: \.isKeyWindow)?.rootViewController else { return }
while let presented = presenter.presentedViewController { presenter = presented }
guard !(presenter is UIActivityViewController) else { return }
let activity = UIActivityViewController(activityItems: items, applicationActivities: nil)
if let popover = activity.popoverPresentationController {
popover.sourceView = presenter.view
popover.sourceRect = CGRect(x: presenter.view.bounds.midX, y: presenter.view.bounds.midY, width: 0, height: 0)
popover.permittedArrowDirections = []
}
presenter.present(activity, animated: true)
}
}
extension ReadingShareLink where Label == SwiftUI.Label<Text, Image> {
init(item: URL) {
self.init(item: item) { SwiftUI.Label("分享", systemImage: "square.and.arrow.up") }
}
}
struct ReadingPhotoPicker<Label: View>: View {
let onData: (Data) -> Void
let onError: (Error) -> Void
@ViewBuilder var label: Label
@State private var isPresented = false
var body: some View {
Button { isPresented = true } label: { label }
.sheet(isPresented: $isPresented) {
ReadingPhotoPickerController { result in
isPresented = false
guard let result else { return }
switch result {
case .success(let data): onData(data)
case .failure(let error): onError(error)
}
}
}
}
}
private struct ReadingPhotoPickerController: UIViewControllerRepresentable {
let onResult: @MainActor (Result<Data, Error>?) -> Void
func makeCoordinator() -> Coordinator { Coordinator(onResult: onResult) }
func makeUIViewController(context: Context) -> PHPickerViewController {
var configuration = PHPickerConfiguration()
configuration.filter = .images
configuration.selectionLimit = 1
let picker = PHPickerViewController(configuration: configuration)
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ controller: PHPickerViewController, context: Context) {}
@MainActor
final class Coordinator: NSObject, PHPickerViewControllerDelegate {
let onResult: @MainActor (Result<Data, Error>?) -> Void
init(onResult: @escaping @MainActor (Result<Data, Error>?) -> Void) {
self.onResult = onResult
}
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
guard let provider = results.first?.itemProvider else { onResult(nil); return }
let identifier = provider.registeredTypeIdentifiers.first {
UTType($0)?.conforms(to: .image) == true
} ?? UTType.image.identifier
provider.loadDataRepresentation(forTypeIdentifier: identifier) { [weak self] data, error in
Task { @MainActor in
if let data { self?.onResult(.success(data)) }
else { self?.onResult(.failure(error ?? CocoaError(.fileReadCorruptFile))) }
}
}
}
}
}
enum ReadingSheetDetent { case medium, large }
extension View {
@ViewBuilder
func readingNumericTransition() -> some View {
if #available(iOS 16.0, *) { contentTransition(.numericText()) }
else { self }
}
@ViewBuilder
func readingNavigationDestination<Destination: View>(
isPresented: Binding<Bool>, @ViewBuilder destination: () -> Destination
) -> some View {
if #available(iOS 16.0, *), !ReadingCompatibility.usesLegacyUI {
navigationDestination(isPresented: isPresented, destination: destination)
} else {
background(NavigationLink(isActive: isPresented, destination: destination) { EmptyView() }.hidden())
}
}
@ViewBuilder
func readingScrollContentBackgroundHidden() -> some View {
if #available(iOS 16.0, *), !ReadingCompatibility.usesLegacyUI {
scrollContentBackground(.hidden)
} else {
background(ReadingUIKitConfiguration { controller in
func clearTables(in view: UIView) {
if let table = view as? UITableView { table.backgroundColor = .clear }
for child in view.subviews { clearTables(in: child) }
}
if let parent = controller.parent { clearTables(in: parent.view) }
})
}
}
@ViewBuilder
func readingScrollDismissesKeyboard() -> some View {
if #available(iOS 16.0, *), !ReadingCompatibility.usesLegacyUI {
scrollDismissesKeyboard(.immediately)
} else {
simultaneousGesture(DragGesture().onChanged { _ in
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
})
}
}
@ViewBuilder
func readingTabBarBackground() -> some View {
if #available(iOS 16.0, *), !ReadingCompatibility.usesLegacyUI {
toolbarBackground(ReadingTheme.canvas, for: .tabBar).toolbarBackground(.visible, for: .tabBar)
} else {
background(ReadingUIKitConfiguration { controller in
let appearance = UITabBarAppearance()
appearance.configureWithOpaqueBackground()
appearance.backgroundColor = UIColor(ReadingTheme.canvas)
controller.tabBarController?.tabBar.standardAppearance = appearance
controller.tabBarController?.tabBar.scrollEdgeAppearance = appearance
})
}
}
@ViewBuilder
func readingTabBarHidden() -> some View {
if #available(iOS 16.0, *), !ReadingCompatibility.usesLegacyUI {
toolbar(.hidden, for: .tabBar)
} else {
background(ReadingHiddenTabBar())
}
}
@ViewBuilder
func readingHomeIndicatorHidden(_ hidden: Bool) -> some View {
if #available(iOS 16.0, *) {
persistentSystemOverlays(hidden ? .hidden : .automatic)
} else {
self
}
}
@ViewBuilder
func readingPresentationDetents(_ detents: [ReadingSheetDetent]) -> some View {
if #available(iOS 16.0, *), !ReadingCompatibility.usesLegacyUI {
presentationDetents(Set(detents.map { $0 == .medium ? PresentationDetent.medium : .large }))
} else {
background(ReadingUIKitConfiguration { controller in
var current: UIViewController? = controller
while let candidate = current {
if let sheet = candidate.presentationController as? UISheetPresentationController {
sheet.detents = detents.map { $0 == .medium ? .medium() : .large() }
break
}
current = candidate.parent
}
})
}
}
@ViewBuilder
func readingPresentationDragIndicator() -> some View {
if #available(iOS 16.0, *), !ReadingCompatibility.usesLegacyUI {
presentationDragIndicator(.visible)
} else {
background(ReadingUIKitConfiguration { controller in
var current: UIViewController? = controller
while let candidate = current {
if let sheet = candidate.presentationController as? UISheetPresentationController {
if !sheet.prefersGrabberVisible { sheet.prefersGrabberVisible = true }
break
}
current = candidate.parent
}
})
}
}
}
private struct ReadingUIKitConfiguration: UIViewControllerRepresentable {
let configure: (UIViewController) -> Void
func makeUIViewController(context: Context) -> Controller { Controller(configure: configure) }
func updateUIViewController(_ controller: Controller, context: Context) { controller.configure = configure }
final class Controller: UIViewController {
var configure: (UIViewController) -> Void
init(configure: @escaping (UIViewController) -> Void) {
self.configure = configure
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func loadView() { view = UIView(); view.isUserInteractionEnabled = false }
override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated); configure(self) }
override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews(); configure(self) }
}
}
private struct ReadingHiddenTabBar: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> Controller { Controller() }
func updateUIViewController(_ controller: Controller, context: Context) {}
final class Controller: UIViewController {
private weak var hiddenBar: UITabBar?
private var previouslyHidden = false
override func loadView() { view = UIView(); view.isUserInteractionEnabled = false }
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
guard let bar = tabBarController?.tabBar else { return }
hiddenBar = bar
previouslyHidden = bar.isHidden
bar.isHidden = true
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
hiddenBar?.isHidden = previouslyHidden
}
}
}
+158
View File
@@ -0,0 +1,158 @@
import Foundation
enum DemoContent {
static let sources: [BookSource] = {
var classic = BookSource(bookSourceUrl: "https://classic.example", bookSourceName: "经典文学馆")
classic.bookSourceGroup = "文学"
classic.exploreUrl = "编辑推荐::/explore/recommended?page={{page}}\n热门连载::/explore/serial?page={{page}}\n完结佳作::/explore/finished?page={{page}}"
classic.searchUrl = "/search?q={{key}}"
classic.ruleExplore = BookListRule(bookList: "$.books", name: "$.name", author: "$.author", kind: "$.kind", bookUrl: "$.url", coverUrl: "$.cover")
classic.ruleSearch = SearchRule(bookList: "$.books", name: "$.name", author: "$.author", bookUrl: "$.url")
classic.ruleBookInfo = BookInfoRule()
classic.ruleToc = TocRule()
classic.ruleContent = ContentRule()
var science = BookSource(bookSourceUrl: "https://science.example", bookSourceName: "远方书库")
science.bookSourceGroup = "科幻 · 悬疑"
science.exploreUrl = "星际远征::/explore/space?page={{page}}&&悬疑推理::/explore/mystery?page={{page}}"
science.searchUrl = "/api/search?key={{key}}"
science.ruleSearch = SearchRule(bookList: "$.data", name: "$.title", author: "$.writer", bookUrl: "$.href")
science.ruleExplore = BookListRule(bookList: "$.data", name: "$.title", author: "$.writer", kind: "$.tags", bookUrl: "$.href")
science.ruleToc = TocRule()
science.ruleContent = ContentRule()
var audio = BookSource(bookSourceUrl: "https://audio.example", bookSourceName: "听见故事")
audio.bookSourceGroup = "听书"
audio.bookSourceType = 1
audio.searchUrl = "/search, {\"method\":\"POST\",\"body\":{\"title\":\"searchKey\"}}"
audio.ruleSearch = SearchRule(bookList: "$.content", name: "$.title", author: "$.anchor", bookUrl: "$.id@js:'/book/' + result")
audio.ruleToc = TocRule()
audio.ruleContent = ContentRule()
return [classic, science, audio]
}()
static let exploreBooks: [SearchBook] = [
("11", "潮汐来信", "周暮", "治愈 · 成长"),
("12", "银河邮差", "许野", "科幻 · 冒险"),
("13", "风从旧城来", "沈灯", "都市 · 悬疑"),
("14", "万物有声", "唐溪", "自然 · 随笔"),
("15", "雾中航线", "江屿", "推理 · 剧情"),
("16", "春山可望", "苏禾", "古风 · 群像")
].map { id, name, author, kind in
var book = SearchBook(
bookURL: "https://classic.example/book/\(id)",
origin: sources[0].bookSourceUrl,
originName: sources[0].bookSourceName,
type: 0,
name: name,
author: author
)
book.kind = kind
return book
}
static let books: [Book] = {
var first = Book(bookUrl: "https://classic.example/book/1", name: "长夜里的灯", author: "林舟", origin: sources[0].bookSourceUrl)
first.originName = sources[0].bookSourceName
first.kind = "当代文学"
first.latestChapterTitle = "第十八章 · 微光"
first.totalChapterNum = 42
first.durChapterIndex = 17
first.lastCheckCount = 3
var second = Book(bookUrl: "https://science.example/book/2", name: "群星沉默时", author: "顾屿", origin: sources[1].bookSourceUrl)
second.originName = sources[1].bookSourceName
second.kind = "科幻"
second.latestChapterTitle = "第七卷 · 返航"
second.totalChapterNum = 96
second.durChapterIndex = 63
var third = Book(bookUrl: "https://audio.example/book/3", name: "山海入梦来", author: "南声", origin: sources[2].bookSourceUrl)
third.originName = sources[2].bookSourceName
third.type = 1
third.latestChapterTitle = "第十二集 · 海上月"
third.totalChapterNum = 30
third.durChapterIndex = 4
return [first, second, third]
}()
static let readerChapters: [BookChapter] = (0..<5).map { index in
BookChapter(
url: "demo://chapter/\(index)",
title: ["第一章 · 雨夜", "第二章 · 灯塔", "第三章 · 潮声", "第四章 · 来信", "第五章 · 天明"][index],
bookUrl: books[0].bookUrl,
index: index
)
}
static let sourceSwitchCandidates: [BookSourceCandidate] = {
var science = SearchBook(
bookURL: "https://science.example/book/long-night",
origin: sources[1].bookSourceUrl,
originName: sources[1].bookSourceName,
type: 0,
name: books[0].name,
author: books[0].author
)
science.latestChapterTitle = "第四十二章 · 灯火长明"
var audio = SearchBook(
bookURL: "https://audio.example/book/long-night",
origin: sources[2].bookSourceUrl,
originName: sources[2].bookSourceName,
type: 1,
name: books[0].name,
author: "主播:林舟"
)
audio.latestChapterTitle = "第四十集 · 归途"
return [.init(book: science, score: 190), .init(book: audio, score: 110)]
}()
static let readerText = """
雨落在旧城的瓦檐上,细密得像一封迟到了许多年的信。
林深推开阁楼的窗,远处的灯塔正越过雾气,一次又一次照亮沉睡的海湾。桌上的书摊开在同一页,纸边压着一枚褪色的蓝色邮票。
他记得祖父说过,所有没有寄出的故事,最后都会顺着河流抵达海上。
楼下传来水壶轻响。木质楼梯保存着雨天特有的气味,也保存着这里每个人走过的声音。林深把信纸翻到背面,看见一行很淡的铅笔字:等灯塔第三次亮起,就沿着潮声出发。
窗外,第一束光穿过雨幕。
他没有立刻动身,只是把书签夹进那一页。屋里很安静,只有雨水、钟摆,以及某个故事终于开始时微小而确定的回声。
第二束光掠过海面时,远处出现了一艘白色小船。它没有开灯,却像知道方向一样,稳稳停在旧码头边。
林深收好信,披上外套。门被推开的瞬间,潮湿的风涌进走廊,也把许多年前那个未完的夜晚重新带回眼前。
"""
static let bookmarks: [Bookmark] = {
var first = Bookmark(time: 1_786_989_000_000, bookName: books[0].name, bookAuthor: books[0].author)
first.chapterIndex = 0
first.chapterPosition = 48
first.chapterName = readerChapters[0].title
first.bookText = "远处的灯塔正越过雾气,一次又一次照亮沉睡的海湾。"
var second = Bookmark(time: 1_786_989_100_000, bookName: books[0].name, bookAuthor: books[0].author)
second.chapterIndex = 0
second.chapterPosition = 186
second.chapterName = readerChapters[0].title
second.bookText = "等灯塔第三次亮起,就沿着潮声出发。"
return [first, second]
}()
static let readRecords: [ReadRecord] = {
let calendar = Calendar.current
let today = calendar.startOfDay(for: Date())
func key(_ offset: Int) -> String {
let date = calendar.date(byAdding: .day, value: offset, to: today) ?? today
let components = calendar.dateComponents([.year, .month, .day], from: date)
return String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0)
}
return [
.init(
deviceId: "ios-simulator",
bookName: books[0].name,
readTime: 5_460_000,
dailyReadTimes: [key(-2): 1_020_000, key(-1): 1_440_000, key(0): 1_260_000]
),
.init(
deviceId: "ios-simulator",
bookName: books[1].name,
readTime: 2_220_000,
dailyReadTimes: [key(-4): 720_000, key(-1): 540_000]
)
]
}()
}
+380
View File
@@ -0,0 +1,380 @@
import SwiftUI
struct ReadingUnavailableView<Label: View, Description: View, Actions: View>: View {
private let label: Label
private let description: Description
private let actions: Actions
init(
@ViewBuilder label: () -> Label,
@ViewBuilder description: () -> Description,
@ViewBuilder actions: () -> Actions
) {
self.label = label()
self.description = description()
self.actions = actions()
}
var body: some View {
if #available(iOS 17.0, *), !ReadingCompatibility.usesLegacyUI {
ContentUnavailableView {
label
} description: {
description
} actions: {
actions
}
} else {
VStack(spacing: 12) {
label.labelStyle(ReadingUnavailableLabelStyle())
description
.font(.subheadline)
.foregroundStyle(.secondary)
actions.padding(.top, 4)
}
.multilineTextAlignment(.center)
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
extension ReadingUnavailableView where Label == SwiftUI.Label<Text, Image>, Description == Text?, Actions == EmptyView {
init(_ title: String, systemImage: String, description: Text? = nil) {
self.init {
SwiftUI.Label(title, systemImage: systemImage)
} description: {
description
} actions: {
EmptyView()
}
}
@ViewBuilder
static func search(text: String) -> some View {
if #available(iOS 17.0, *), !ReadingCompatibility.usesLegacyUI {
ContentUnavailableView.search(text: text)
} else {
ReadingUnavailableView(
"无搜索结果",
systemImage: "magnifyingglass",
description: Text("没有找到与“\(text)”相关的内容,请尝试其他关键词。")
)
}
}
}
private struct ReadingUnavailableLabelStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
VStack(spacing: 12) {
configuration.icon
.font(.system(size: 48))
.foregroundStyle(.secondary)
.accessibilityHidden(true)
configuration.title.font(.title2.bold())
}
}
}
extension View {
func readingSearchable(text: Binding<String>, isPresented: Binding<Bool>, prompt: String) -> some View {
modifier(ReadingSearchModifier(text: text, isPresented: isPresented, prompt: prompt))
}
@ViewBuilder
func readingListTopMargin(_ margin: CGFloat) -> some View {
if #available(iOS 17.0, *), !ReadingCompatibility.usesLegacyUI {
contentMargins(.top, margin, for: .scrollContent)
} else {
self
}
}
}
private struct ReadingSearchModifier: ViewModifier {
@Binding var text: String
@Binding var isPresented: Bool
let prompt: String
@FocusState private var isFocused: Bool
func body(content: Content) -> some View {
if #available(iOS 17.0, *), !ReadingCompatibility.usesLegacyUI {
content.searchable(text: $text, isPresented: $isPresented, prompt: prompt)
} else {
content
.safeAreaInset(edge: .top, spacing: 0) {
HStack(spacing: 8) {
HStack(spacing: 8) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
.accessibilityHidden(true)
TextField(prompt, text: $text)
.focused($isFocused)
.textInputAutocapitalization(.never)
.disableAutocorrection(true)
.submitLabel(.search)
if !text.isEmpty {
Button {
text = ""
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
.accessibilityLabel("清除搜索")
}
}
.padding(10)
.background(ReadingTheme.groupedCanvas, in: RoundedRectangle(cornerRadius: 10))
if isFocused {
Button("取消") {
text = ""
isFocused = false
}
}
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(ReadingTheme.canvas)
}
.onAppear { isFocused = isPresented }
.onChange(of: isPresented) { isFocused = $0 }
.onChange(of: isFocused) { isPresented = $0 }
}
}
}
enum ReadingTheme {
static let accent = adaptive(0x171717, dark: 0xF2F2F2)
static let accentSoft = adaptive(0xEEEEEF, dark: 0x29292C)
static let coral = Color(red: 0.76, green: 0.48, blue: 0.27)
static let paper = Color(red: 0.965, green: 0.945, blue: 0.895)
static let ink = Color.primary
static let canvas = adaptive(0xFCFCFC, dark: 0x111113)
static let groupedCanvas = adaptive(0xF3F3F4, dark: 0x1C1C1F)
static let surface = adaptive(0xFFFFFF, dark: 0x232326)
static let separator = adaptive(0xE6E6E8, dark: 0x363639)
static let onAccent = adaptive(0xFFFFFF, dark: 0x171717)
private static func adaptive(_ light: UInt32, dark: UInt32) -> Color {
Color(uiColor: UIColor { traits in
let hex = traits.userInterfaceStyle == .dark ? dark : light
return UIColor(
red: CGFloat((hex >> 16) & 0xff) / 255,
green: CGFloat((hex >> 8) & 0xff) / 255,
blue: CGFloat(hex & 0xff) / 255,
alpha: 1
)
})
}
}
struct ReadingPageHeader<Actions: View>: View {
let title: String
let subtitle: String
@ViewBuilder var actions: Actions
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .center, spacing: 16) {
Text(title)
.font(.largeTitle.bold())
.foregroundStyle(ReadingTheme.ink)
.accessibilityAddTraits(.isHeader)
Spacer(minLength: 0)
HStack(spacing: 10) { actions }
}
Text(subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
.padding(.top, 20)
.padding(.bottom, 28)
}
}
struct ReadingToolbarIcon: View {
let systemName: String
var body: some View {
Image(systemName: systemName)
.font(.system(size: 19, weight: .regular))
.foregroundStyle(ReadingTheme.accent)
.frame(width: 44, height: 44)
.background(ReadingTheme.groupedCanvas, in: Circle())
.accessibilityHidden(true)
}
}
enum BookReadingPresentation {
static func hasStarted(_ book: Book) -> Bool {
book.durChapterIndex > 0 || book.durChapterPos > 0 || book.durChapterTime > 0
}
static func progress(_ book: Book) -> Double {
guard hasStarted(book), book.totalChapterNum > 0 else { return 0 }
return min(1, max(0, Double(book.durChapterIndex + 1) / Double(book.totalChapterNum)))
}
static func position(_ book: Book) -> String {
if let title = book.durChapterTitle, !title.isEmpty { return title }
guard hasStarted(book) else { return "尚未阅读" }
return book.totalChapterNum > 0
? "第\(book.durChapterIndex + 1)章 · 共\(book.totalChapterNum)章"
: "已读至第\(book.durChapterIndex + 1)章"
}
static func progressLabel(_ book: Book) -> String {
guard hasStarted(book) else { return "尚未阅读" }
guard book.totalChapterNum > 0 else { return position(book) }
return "已读\(Int((progress(book) * 100).rounded()))%"
}
}
struct ReadingProgressLine: View {
let value: Double
var body: some View {
GeometryReader { geometry in
Capsule().fill(ReadingTheme.separator)
.overlay(alignment: .leading) {
Capsule().fill(ReadingTheme.accent)
.frame(width: geometry.size.width * min(1, max(0, value)))
}
}
.frame(height: 3)
.accessibilityHidden(true)
}
}
struct BookCoverArtwork: View {
let title: String
var author: String = ""
var remoteURL: String?
var width: CGFloat = 68
var height: CGFloat = 96
private var coverStyle: Int {
switch title {
case "长夜里的灯": 0
case "群星沉默时": 1
case "山海入梦来": 2
default: title.unicodeScalars.reduce(0) { ($0 + Int($1.value)) % 3 }
}
}
private var coverColor: Color {
switch coverStyle {
case 1: Color(red: 0.22, green: 0.35, blue: 0.44)
case 2: Color(red: 0.71, green: 0.44, blue: 0.34)
default: Color(red: 0.14, green: 0.15, blue: 0.16)
}
}
var body: some View {
AsyncImage(url: remoteURL.flatMap(URL.init(string:))) { phase in
if case .success(let image) = phase {
image.resizable().scaledToFill()
} else {
placeholder
}
}
.frame(width: width, height: height)
.clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous))
.overlay(alignment: .leading) {
Rectangle().fill(.black.opacity(0.15)).frame(width: max(2, width * 0.035))
}
.overlay {
RoundedRectangle(cornerRadius: 4, style: .continuous)
.strokeBorder(.black.opacity(0.08), lineWidth: 0.5)
}
.accessibilityHidden(true)
}
private var placeholder: some View {
ZStack(alignment: .topLeading) {
coverColor
Canvas { context, size in
drawArtwork(context: context, size: size)
}
VStack(alignment: .leading, spacing: width * 0.10) {
Text(coverStyle == 0 ? title.map(String.init).joined(separator: "\n") : title)
.font(.system(size: max(9, width * (coverStyle == 0 ? 0.16 : 0.18)), weight: .medium, design: .serif))
.lineLimit(coverStyle == 0 ? 7 : 4)
.minimumScaleFactor(0.65)
.frame(maxWidth: coverStyle == 0 ? width * 0.32 : .infinity, alignment: .leading)
if !author.isEmpty, width >= 50 {
Text(author)
.font(.system(size: max(7, width * 0.09)))
.foregroundStyle(.white.opacity(0.7))
.lineLimit(1)
}
}
.foregroundStyle(.white.opacity(0.94))
.padding(width * 0.15)
.padding(.top, coverStyle == 1 ? height * 0.33 : 0)
}
}
private func drawArtwork(context: GraphicsContext, size: CGSize) {
if coverStyle == 0 {
let moon = CGRect(x: size.width * 0.68, y: size.height * 0.18, width: size.width * 0.17, height: size.width * 0.17)
context.fill(Path(ellipseIn: moon), with: .color(Color(red: 0.89, green: 0.87, blue: 0.81)))
} else if coverStyle == 1 {
let normalizedPoints: [CGPoint] = [
.init(x: 0.12, y: 0.19), .init(x: 0.39, y: 0.11), .init(x: 0.62, y: 0.22),
.init(x: 0.88, y: 0.13), .init(x: 0.76, y: 0.42), .init(x: 0.33, y: 0.48)
]
let points = normalizedPoints.map { point in
CGPoint(x: point.x * size.width, y: point.y * size.height)
}
var constellation = Path()
constellation.addLines(points)
context.stroke(constellation, with: .color(.white.opacity(0.22)), lineWidth: 0.6)
for point in points {
context.fill(Path(ellipseIn: CGRect(x: point.x, y: point.y, width: 1.5, height: 1.5)), with: .color(.white.opacity(0.7)))
}
} else {
let arch = CGRect(x: size.width * 0.28, y: size.height * 0.40, width: size.width * 0.58, height: size.height * 0.48)
context.fill(Path(roundedRect: arch, cornerRadius: size.width * 0.29), with: .color(.white.opacity(0.25)))
}
for index in 0..<3 {
var horizon = Path()
let baseline = size.height * (0.78 + CGFloat(index) * 0.065)
horizon.move(to: CGPoint(x: 0, y: baseline))
horizon.addCurve(
to: CGPoint(x: size.width, y: baseline + 8),
control1: CGPoint(x: size.width * 0.3, y: baseline + 18),
control2: CGPoint(x: size.width * 0.6, y: baseline - 20)
)
context.stroke(horizon, with: .color(.white.opacity(0.17)), lineWidth: 0.7)
}
}
}
struct ReadingCard<Content: View>: View {
@ViewBuilder let content: Content
var body: some View {
content
.padding(20)
.background(ReadingTheme.groupedCanvas, in: RoundedRectangle(cornerRadius: 20, style: .continuous))
}
}
struct StatusPill: View {
let text: String
var color: Color = ReadingTheme.accent
var systemImage: String?
var body: some View {
HStack(spacing: 4) {
if let systemImage { Image(systemName: systemImage) }
Text(text)
}
.font(.caption.weight(.medium))
.foregroundStyle(color)
}
}
@@ -0,0 +1,67 @@
import SwiftUI
@MainActor
private final class CacheManagementModel: ObservableObject {
@Published private(set) var entries = 0
@Published private(set) var bytes = 0
@Published private(set) var isWorking = false
@Published var errorMessage: String?
private let cache: ChapterContentCache
init(cache: ChapterContentCache = .live()) { self.cache = cache }
func load() async {
do {
let statistics = try await cache.statistics()
entries = statistics.entries
bytes = statistics.bytes
} catch {
errorMessage = error.localizedDescription
}
}
func clear() async {
isWorking = true
defer { isWorking = false }
do {
try await cache.removeAll()
entries = 0
bytes = 0
} catch {
errorMessage = error.localizedDescription
}
}
}
struct CacheManagementView: View {
@StateObject private var model = CacheManagementModel()
var body: some View {
List {
Section("章节正文") {
ReadingLabeledContent("已缓存章节", value: "\(model.entries)")
ReadingLabeledContent("占用空间", value: ByteCountFormatter.string(fromByteCount: Int64(model.bytes), countStyle: .file))
Text("阅读网络章节后自动落盘,预加载数量可配置。缓存上限可在“其他设置 → 网络与缓存设置”调整,超限时优先清理最久未读取的章节。")
.font(.footnote).foregroundStyle(.secondary)
}
Section {
Button("清空章节缓存", systemImage: "trash", role: .destructive) {
Task { await model.clear() }
}
.disabled(model.entries == 0 || model.isWorking)
}
}
.navigationTitle("缓存管理")
.task { await model.load() }
.overlay { if model.isWorking { ProgressView() } }
.alert("缓存操作失败", isPresented: Binding(
get: { model.errorMessage != nil },
set: { if !$0 { model.errorMessage = nil } }
)) {
Button("好") { model.errorMessage = nil }
} message: {
Text(model.errorMessage ?? "未知错误")
}
}
}
@@ -0,0 +1,342 @@
import SwiftUI
import UniformTypeIdentifiers
import UIKit
@MainActor
private final class FileManagementModel: ObservableObject {
@Published var files: [LocalFileEntry] = []
@Published var isWorking = false
@Published var errorMessage: String?
@Published var readyExport: BookFileExport?
@Published var partialExport: BookFileExport?
private var activeExport: BookFileExport?
private let exporter = BookFileExporter()
func reload(_ library: BookLibraryModel) async {
do { files = try await library.localFiles.list() }
catch { errorMessage = error.localizedDescription }
}
func run(_ library: BookLibraryModel, operation: () async throws -> Void) async {
guard !isWorking else { return }
isWorking = true
defer { isWorking = false }
do { try await operation() }
catch { errorMessage = error.localizedDescription }
await reload(library)
}
func importFiles(_ urls: [URL], library: BookLibraryModel) async {
await run(library) {
await library.importLocalFiles(urls)
errorMessage = library.errorMessage
library.clearError()
}
}
func exportLocal(_ url: URL, filename: String, library: BookLibraryModel) async {
await run(library) {
let file = try await exporter.originalFile(url, filename: filename)
activeExport = file
readyExport = file
}
}
func exportCached(_ book: Book, library: BookLibraryModel) async {
await run(library) {
let chapters = try await AppBookChapterRepository.live().load(bookURL: book.bookUrl)
let file = try await exporter.cachedBook(book, chapters: chapters)
activeExport = file
if file.isPartial { partialExport = file }
else { readyExport = file }
}
}
func finishExport() {
guard let file = activeExport else { return }
activeExport = nil
readyExport = nil
partialExport = nil
Task { try? await exporter.discard(file) }
}
}
struct FileManagementView: View {
@EnvironmentObject private var library: BookLibraryModel
@StateObject private var model = FileManagementModel()
@State private var tab = 0
@State private var query = ""
@State private var sortBySize = false
@State private var importsFile = false
@State private var confirmsPartialExport = false
@State private var selecting = false
@State private var selection = Set<URL>()
@State private var renameURL: URL?
@State private var newName = ""
@State private var pendingDeletion: [URL] = []
private var localBooks: [Book] { library.books.filter { $0.origin == "local" } }
private var networkBooks: [Book] {
library.books.filter { $0.origin != "local" && (query.isEmpty || $0.name.localizedCaseInsensitiveContains(query)) }
}
private func book(for url: URL) -> Book? { localBooks.first { $0.bookUrl == url.absoluteString } }
private func filename(for url: URL) -> String {
guard let book = book(for: url), !book.originName.isEmpty else { return url.lastPathComponent }
return book.originName
}
private var displayedFiles: [LocalFileEntry] {
model.files.filter { query.isEmpty || filename(for: $0.url).localizedCaseInsensitiveContains(query) }
.sorted {
if sortBySize, $0.bytes != $1.bytes { return $0.bytes > $1.bytes }
return filename(for: $0.url).localizedStandardCompare(filename(for: $1.url)) == .orderedAscending
}
}
private var missingBooks: [Book] {
let available = Set(model.files.map(\.url.absoluteString))
return localBooks.filter { !available.contains($0.bookUrl) && (query.isEmpty || $0.name.localizedCaseInsensitiveContains(query)) }
}
private var fileList: some View {
List(selection: $selection) {
Section {
Picker("文件类型", selection: $tab) {
Text("本地文件").tag(0)
Text("缓存正文").tag(1)
}
.pickerStyle(.segmented)
.disabled(selecting)
}
if tab == 0 { localFilesSection }
else { cachedBooksSection }
if !selecting {
Section {
NavigationLink { CacheManagementView() } label: {
Label("章节缓存与空间清理", systemImage: "internaldrive")
}
}
}
}
.listStyle(.insetGrouped)
.environment(\.editMode, .constant(selecting ? .active : .inactive))
.navigationTitle("文件管理")
.searchable(text: $query, prompt: tab == 0 ? "搜索本地文件" : "搜索缓存书籍")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
if selecting {
Button("完成") { selecting = false; selection.removeAll() }
} else {
Menu {
Button("导入 TXT 文件", systemImage: "square.and.arrow.down") { importsFile = true }
if tab == 0 {
Button("选择文件", systemImage: "checkmark.circle") { selecting = true }
.disabled(displayedFiles.isEmpty)
Picker("排序", selection: $sortBySize) {
Text("按名称").tag(false)
Text("按大小").tag(true)
}
}
} label: { Image(systemName: "ellipsis.circle") }
}
}
}
.safeAreaInset(edge: .bottom) {
if selecting {
HStack {
Button(selection.count == displayedFiles.count ? "取消全选" : "全选") {
selection = selection.count == displayedFiles.count ? [] : Set(displayedFiles.map(\.url))
}
Spacer()
Button("删除 \(selection.count) 个文件", role: .destructive) { pendingDeletion = Array(selection) }
.disabled(selection.isEmpty)
}
.padding().background(.bar)
}
}
.disabled(model.isWorking)
.overlay { if model.isWorking { ProgressView("正在处理文件…").padding().background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) } }
.task { await model.reload(library) }
.onChange(of: library.books) { _ in Task { await model.reload(library) } }
.onChange(of: query) { _ in selection.removeAll() }
.refreshable { await model.reload(library) }
}
private var importAndRenameView: some View {
fileList
.fileImporter(isPresented: $importsFile, allowedContentTypes: [.text, .plainText, .data], allowsMultipleSelection: true) { result in
switch result {
case .success(let urls): Task { await model.importFiles(urls, library: library) }
case .failure(let error): model.errorMessage = error.localizedDescription
}
}
.alert("重命名文件", isPresented: Binding(get: { renameURL != nil }, set: { if !$0 { renameURL = nil } }), presenting: renameURL) { url in
TextField("文件名", text: $newName)
Button("取消", role: .cancel) { renameURL = nil }
Button("保存") {
let name = newName
renameURL = nil
Task { await model.run(library) { try await library.renameLocalFile(at: url, to: name) } }
}
} message: { _ in
Text("保留 TXT 格式,书架中的书名、书签和阅读进度不变。")
}
}
private var deletionView: some View {
importAndRenameView
.confirmationDialog("删除 \(pendingDeletion.count) 个文件?", isPresented: Binding(get: { !pendingDeletion.isEmpty }, set: { if !$0 { pendingDeletion = [] } }), titleVisibility: .visible, presenting: pendingDeletion) { urls in
Button("删除文件并移出书架", role: .destructive) {
pendingDeletion = []
Task {
await model.run(library) {
var failures: [String] = []
for url in urls {
do { try await library.deleteLocalFile(at: url) }
catch { failures.append("\(filename(for: url)):\(error.localizedDescription)") }
}
selection.removeAll()
if !failures.isEmpty { model.errorMessage = failures.joined(separator: "\n") }
}
}
}
Button("取消", role: .cancel) { pendingDeletion = [] }
} message: { _ in
Text("将删除应用内文件及对应书架条目,系统“文件”中用于导入的原文件保留。")
}
}
var body: some View {
deletionView
.onChange(of: model.partialExport?.id) { value in confirmsPartialExport = value != nil }
.onChange(of: confirmsPartialExport) { isPresented in
if !isPresented {
Task { @MainActor in
await Task.yield()
if !confirmsPartialExport, model.readyExport == nil { model.finishExport() }
}
}
}
.confirmationDialog("缓存正文不完整", isPresented: $confirmsPartialExport, titleVisibility: .visible, presenting: model.partialExport) { file in
Button("导出已缓存内容") {
model.readyExport = file
model.partialExport = nil
}
Button("取消", role: .cancel) { model.finishExport() }
} message: { file in
Text("已缓存 \(file.cachedChapters)/\(file.totalChapters) 章。导出文件会标记未缓存章节,可先返回详情缓存完整后再导出。")
}
.sheet(item: $model.readyExport, onDismiss: { model.finishExport() }) { file in
FileExportPicker(url: file.url) { model.readyExport = nil }
}
.alert("文件操作失败", isPresented: Binding(get: { model.errorMessage != nil }, set: { if !$0 { model.errorMessage = nil } })) {
Button("好") { model.errorMessage = nil }
} message: { Text(model.errorMessage ?? "未知错误") }
}
@ViewBuilder private var localFilesSection: some View {
Section {
ReadingLabeledContent("本地文件", value: "\(model.files.count) 个")
ReadingLabeledContent("占用空间", value: ByteCountFormatter.string(fromByteCount: model.files.reduce(0) { $0 + $1.bytes }, countStyle: .file))
}
Section("文件") {
if displayedFiles.isEmpty {
ReadingUnavailableView(query.isEmpty ? "还没有本地文件" : "没有匹配的文件", systemImage: "doc.text", description: Text("可从系统“文件”导入 TXT,文件会复制到应用中。"))
Button("导入 TXT 文件", systemImage: "plus") { importsFile = true }
}
ForEach(displayedFiles) { file in
Group {
if selecting { fileLabel(file) }
else if let book = book(for: file.url) {
NavigationLink { BookshelfReadView(book: book, source: nil) } label: { fileLabel(file) }
} else {
Button { Task { await model.run(library) { try await library.addLocalFile(at: file.url) } } } label: { fileLabel(file) }
}
}
.tag(file.url)
.contextMenu {
Button("导出文件", systemImage: "square.and.arrow.up") { export(file) }
Button("重命名", systemImage: "pencil") { renameURL = file.url; newName = filename(for: file.url) }
Button("删除文件", systemImage: "trash", role: .destructive) { pendingDeletion = [file.url] }
}
.swipeActions {
Button("删除", role: .destructive) { pendingDeletion = [file.url] }
Button("导出", systemImage: "square.and.arrow.up") { export(file) }.tint(.blue)
}
}
}
if !missingBooks.isEmpty {
Section("文件已丢失") {
ForEach(missingBooks) { book in
HStack {
Label(book.name, systemImage: "doc.badge.ellipsis")
Spacer()
Button("移出书架", role: .destructive) {
if let url = URL(string: book.bookUrl) { pendingDeletion = [url] }
}.buttonStyle(.borderless)
}
}
}
}
}
private var cachedBooksSection: some View {
Section {
Text("将书架中已缓存的网络正文导出为 TXT,按目录顺序排列。导出过程不下载新章节。")
.font(.footnote).foregroundStyle(.secondary)
if networkBooks.isEmpty {
ReadingUnavailableView("没有可导出的书籍", systemImage: "books.vertical", description: Text("将网络书加入书架并缓存章节后,可在这里导出。"))
}
ForEach(networkBooks) { book in
Button {
Task { await model.exportCached(book, library: library) }
} label: {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(book.name).foregroundStyle(.primary)
Text(book.author.isEmpty ? "导出缓存正文" : book.author).font(.caption).foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "square.and.arrow.up")
}
}
}
} header: { Text("缓存正文") }
}
private func fileLabel(_ file: LocalFileEntry) -> some View {
HStack(spacing: 12) {
Image(systemName: "doc.text").foregroundStyle(.tint)
VStack(alignment: .leading, spacing: 4) {
Text(filename(for: file.url)).foregroundStyle(.primary).lineLimit(2)
HStack(spacing: 8) {
Text(ByteCountFormatter.string(fromByteCount: file.bytes, countStyle: .file))
if let modified = file.modified { Text(modified, style: .date) }
}.font(.caption).foregroundStyle(.secondary)
if let book = book(for: file.url) {
Text("书架:\(book.name)").font(.caption).foregroundStyle(.secondary).lineLimit(1)
} else { Text("点按加入书架").font(.caption).foregroundStyle(.secondary) }
}
}
}
private func export(_ file: LocalFileEntry) {
Task { await model.exportLocal(file.url, filename: filename(for: file.url), library: library) }
}
}
private struct FileExportPicker: UIViewControllerRepresentable {
let url: URL
let onCompletion: () -> Void
func makeCoordinator() -> Coordinator { Coordinator(onCompletion) }
func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
let picker = UIDocumentPickerViewController(forExporting: [url], asCopy: true)
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ controller: UIDocumentPickerViewController, context: Context) {}
final class Coordinator: NSObject, UIDocumentPickerDelegate {
let completion: () -> Void
init(_ completion: @escaping () -> Void) { self.completion = completion }
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { completion() }
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { completion() }
}
}
@@ -0,0 +1,187 @@
import Foundation
@MainActor
final class ReadingActivityModel: ObservableObject {
@Published private(set) var bookmarks: [Bookmark] = []
@Published private(set) var readRecords: [ReadRecord] = []
@Published private(set) var errorMessage: String?
private let repository: any ReadingActivityRepository
private let service = ReadingActivityService()
private let deviceID: String
private var sessions: [String: Date] = [:]
init(repository: any ReadingActivityRepository, deviceID: String) {
self.repository = repository
self.deviceID = deviceID
}
static func live() -> ReadingActivityModel {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Legado", isDirectory: true)
let key = "legado.reading.device-id"
let defaults = UserDefaults.standard
let identifier = defaults.string(forKey: key) ?? UUID().uuidString
defaults.set(identifier, forKey: key)
let bookmarksURL = base.appendingPathComponent("bookmarks.json")
let recordsURL = base.appendingPathComponent("read-records.json")
let repository: any ReadingActivityRepository
if let database = try? LegadoDatabase(url: base.appendingPathComponent("legado.sqlite3")) {
repository = SQLiteReadingActivityRepository(
database: database,
legacyBookmarksURL: bookmarksURL,
legacyRecordsURL: recordsURL
)
} else {
repository = FileReadingActivityRepository(bookmarksURL: bookmarksURL, recordsURL: recordsURL)
}
return ReadingActivityModel(repository: repository, deviceID: identifier)
}
static func demo(bookmarks: [Bookmark] = [], records: [ReadRecord] = []) -> ReadingActivityModel {
ReadingActivityModel(
repository: InMemoryReadingActivityRepository(bookmarks: bookmarks, records: records),
deviceID: "ios-simulator"
)
}
var summaries: [ReadRecordSummary] { service.summaries(readRecords) }
var statistics: ReadingStatistics { service.statistics(readRecords) }
func summaries(searchKey: String, sortMode: Int) -> [ReadRecordSummary] {
service.displayedSummaries(readRecords, searchKey: searchKey, sortMode: sortMode)
}
func load() async {
do {
async let storedBookmarks = repository.loadBookmarks()
async let storedRecords = repository.loadReadRecords()
bookmarks = service.sortedBookmarks(try await storedBookmarks)
readRecords = try await storedRecords
} catch {
errorMessage = error.localizedDescription
}
}
func bookmarks(for book: Book) -> [Bookmark] {
bookmarks.filter { $0.bookName == book.name && $0.bookAuthor == book.author }
.sorted {
if $0.chapterIndex == $1.chapterIndex { return $0.chapterPosition < $1.chapterPosition }
return $0.chapterIndex < $1.chapterIndex
}
}
func addBookmark(book: Book, chapter: BookChapter, position: Int, content: String) async {
var timestamp = Int64(Date().timeIntervalSince1970 * 1_000)
while bookmarks.contains(where: { $0.time == timestamp }) { timestamp += 1 }
var bookmark = Bookmark(time: timestamp, bookName: book.name, bookAuthor: book.author)
bookmark.chapterIndex = chapter.index
bookmark.chapterPosition = max(position, 0)
bookmark.chapterName = chapter.title
bookmark.bookText = excerpt(content, around: position)
var updated = bookmarks
updated.append(bookmark)
do {
try await repository.saveBookmarks(updated)
bookmarks = service.sortedBookmarks(updated)
} catch {
errorMessage = error.localizedDescription
}
}
func removeBookmark(_ bookmark: Bookmark) async {
let updated = bookmarks.filter { $0.id != bookmark.id }
do {
try await repository.saveBookmarks(updated)
bookmarks = updated
} catch {
errorMessage = error.localizedDescription
}
}
func updateBookmark(_ bookmark: Bookmark) async {
var updated = bookmarks
if let index = updated.firstIndex(where: { $0.id == bookmark.id }) {
updated[index] = bookmark
} else {
updated.append(bookmark)
}
do {
try await repository.saveBookmarks(updated)
bookmarks = service.sortedBookmarks(updated)
} catch {
errorMessage = error.localizedDescription
}
}
func beginReading(_ book: Book, now: Date = Date()) {
sessions[book.bookUrl] = sessions[book.bookUrl] ?? now
}
func checkpoint(_ book: Book, now: Date = Date()) async {
guard let start = sessions[book.bookUrl] else {
sessions[book.bookUrl] = now
return
}
sessions[book.bookUrl] = now
await record(book: book, elapsed: now.timeIntervalSince(start), now: now)
}
func endReading(_ book: Book, now: Date = Date()) async {
guard let start = sessions.removeValue(forKey: book.bookUrl) else { return }
await record(book: book, elapsed: now.timeIntervalSince(start), now: now)
}
func cancelReading(_ book: Book) {
sessions.removeValue(forKey: book.bookUrl)
}
func removeHistory(bookName: String) async {
let updated = readRecords.filter { $0.bookName != bookName }
do {
try await repository.saveReadRecords(updated)
readRecords = updated
} catch {
errorMessage = error.localizedDescription
}
}
func clearHistory() async {
do {
try await repository.saveReadRecords([])
readRecords = []
} catch {
errorMessage = error.localizedDescription
}
}
func clearError() { errorMessage = nil }
private func record(book: Book, elapsed: TimeInterval, now: Date) async {
let milliseconds = Int64(max(elapsed, 0) * 1_000)
guard milliseconds > 0 else { return }
let updated = service.addingReadTime(
milliseconds,
deviceID: deviceID,
bookName: book.name,
now: Int64(now.timeIntervalSince1970 * 1_000),
to: readRecords
)
readRecords = updated
do {
try await repository.saveReadRecords(updated)
} catch {
errorMessage = error.localizedDescription
}
}
private func excerpt(_ content: String, around position: Int) -> String {
guard !content.isEmpty else { return "" }
let center = min(max(position, 0), content.count)
let start = max(center - 24, 0)
let end = min(center + 96, content.count)
let lower = content.index(content.startIndex, offsetBy: start)
let upper = content.index(content.startIndex, offsetBy: end)
return String(content[lower..<upper]).trimmingCharacters(in: .whitespacesAndNewlines)
}
}
@@ -0,0 +1,140 @@
import SwiftUI
struct ReadingHistoryView: View {
@EnvironmentObject private var activity: ReadingActivityModel
@EnvironmentObject private var books: BookLibraryModel
@EnvironmentObject private var sources: SourceLibraryModel
@AppStorage("enableReadRecord") private var enableReadRecord = true
@AppStorage("readRecordSort") private var sortMode = 0
@State private var searchText = ""
@State private var confirmsClear = false
@State private var pendingDelete: ReadRecordSummary?
private var displayedRecords: [ReadRecordSummary] {
activity.summaries(searchKey: searchText, sortMode: sortMode)
}
var body: some View {
Group {
if activity.summaries.isEmpty {
ReadingUnavailableView(
"还没有阅读记录",
systemImage: "clock.arrow.circlepath",
description: Text(enableReadRecord ? "翻页后会按设备累计阅读时长。" : "阅读记录当前已关闭。")
)
} else {
List {
Section {
ReadingLabeledContent("累计阅读", value: duration(activity.summaries.reduce(0) { $0 + $1.readTime }))
ReadingLabeledContent("读过", value: "\(activity.summaries.count) 本")
}
if displayedRecords.isEmpty {
ReadingUnavailableView.search(text: searchText)
} else {
ForEach(displayedRecords) { record in
NavigationLink {
destination(for: record)
} label: {
HStack(spacing: 13) {
Image(systemName: "book.pages.fill")
.foregroundStyle(ReadingTheme.accent)
.frame(width: 38, height: 38)
.background(ReadingTheme.accentSoft.opacity(0.55), in: RoundedRectangle(cornerRadius: 11))
VStack(alignment: .leading, spacing: 4) {
Text(record.bookName).font(.headline)
Text(duration(record.readTime))
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
Text(date(record.lastRead)).font(.caption).foregroundStyle(.tertiary)
}
}
.swipeActions {
Button("删除", role: .destructive) { pendingDelete = record }
}
}
}
}
.listStyle(.insetGrouped)
}
}
.navigationTitle("阅读记录")
.searchable(text: $searchText, prompt: "搜索")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Menu("阅读记录选项", systemImage: "ellipsis.circle") {
Toggle("记录阅读时长", isOn: $enableReadRecord)
Section("排序") {
sortButton("书名", value: 0)
sortButton("阅读时长", value: 1)
sortButton("最后阅读", value: 2)
}
if !activity.summaries.isEmpty {
Button("清空", systemImage: "trash", role: .destructive) { confirmsClear = true }
}
}
}
}
.alert("清空阅读记录", isPresented: $confirmsClear) {
Button("取消", role: .cancel) {}
Button("删除", role: .destructive) { Task { await activity.clearHistory() } }
} message: {
Text("确定删除全部阅读记录吗?")
}
.alert("删除阅读记录", isPresented: Binding(
get: { pendingDelete != nil },
set: { if !$0 { pendingDelete = nil } }
)) {
Button("取消", role: .cancel) { pendingDelete = nil }
Button("删除", role: .destructive) {
guard let record = pendingDelete else { return }
pendingDelete = nil
Task { await activity.removeHistory(bookName: record.bookName) }
}
} message: {
Text("确定删除“\(pendingDelete?.bookName ?? "")”的阅读记录吗?")
}
}
@ViewBuilder private func destination(for record: ReadRecordSummary) -> some View {
if let book = books.books.first(where: { $0.name == record.bookName }) {
BookshelfReadView(
book: book,
source: sources.sources.first(where: { $0.bookSourceUrl == book.origin })
)
} else {
SearchView(initialKeyword: record.bookName)
}
}
@ViewBuilder private func sortButton(_ title: String, value: Int) -> some View {
Button {
sortMode = value
} label: {
if sortMode == value { Label(title, systemImage: "checkmark") }
else { Text(title) }
}
}
private func duration(_ milliseconds: Int64) -> String {
let value = max(milliseconds, 0)
let days = value / 86_400_000
let hours = value % 86_400_000 / 3_600_000
let minutes = value % 3_600_000 / 60_000
let seconds = value % 60_000 / 1_000
var result = ""
if days > 0 { result += "\(days)天" }
if hours > 0 { result += "\(hours)小时" }
if minutes > 0 { result += "\(minutes)分钟" }
if seconds > 0 { result += "\(seconds)秒" }
return result.isEmpty ? "0秒" : result
}
private func date(_ milliseconds: Int64) -> String {
let formatter = DateFormatter()
guard milliseconds > 0 else { return "" }
formatter.locale = .current
formatter.dateFormat = "yyyy-MM-dd"
return formatter.string(from: Date(timeIntervalSince1970: Double(milliseconds) / 1_000))
}
}
@@ -0,0 +1,175 @@
import SwiftUI
struct ReadingStatisticsView: View {
@EnvironmentObject private var activity: ReadingActivityModel
@ScaledMetric(relativeTo: .largeTitle) private var durationFontSize = 42
@ScaledMetric(relativeTo: .caption2) private var chartHeight = 138
private var statistics: ReadingStatistics { activity.statistics }
var body: some View {
ScrollView {
VStack(spacing: 0) {
ReadingPageHeader(title: "阅读统计", subtitle: "每一页,都算数") { EmptyView() }
VStack(spacing: 16) {
todayCard
overviewGrid
weeklyCard
historyLink
}
.padding(.horizontal, 24)
.padding(.bottom, 32)
}
}
.background(ReadingTheme.canvas)
.navigationBarHidden(true)
}
private var todayCard: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .firstTextBaseline) {
Text("今日阅读")
.font(.subheadline)
Spacer()
Text(Date.now.formatted(.dateTime.locale(Locale(identifier: "zh_CN")).month().day()))
.font(.caption)
}
.foregroundStyle(ReadingTheme.onAccent.opacity(0.7))
Text(duration(statistics.todayReadTime, compact: false))
.font(.system(size: durationFontSize, weight: .medium))
.foregroundStyle(ReadingTheme.onAccent)
.readingNumericTransition()
.minimumScaleFactor(0.7)
.lineLimit(1)
Text(statistics.todayReadTime == 0 ? "今天还没有阅读记录" : "已记录今天的阅读时长")
.font(.caption)
.foregroundStyle(ReadingTheme.onAccent.opacity(0.65))
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(24)
.background(ReadingTheme.accent, in: RoundedRectangle(cornerRadius: 20))
}
private var overviewGrid: some View {
ReadingCard {
VStack(spacing: 18) {
HStack(alignment: .top, spacing: 20) {
statisticTile(title: "累计阅读", value: duration(statistics.totalReadTime, compact: true))
statisticTile(title: "阅读书籍", value: "\(statistics.bookCount) 本")
}
Rectangle().fill(ReadingTheme.separator).frame(height: 0.5)
HStack(alignment: .top, spacing: 20) {
statisticTile(
title: "近 7 天",
value: duration(statistics.recentDays.reduce(0) { $0 + $1.readTime }, compact: true)
)
statisticTile(title: "连续阅读", value: "\(statistics.currentStreak) 天")
}
}
}
}
private func statisticTile(title: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.caption)
.foregroundStyle(.secondary)
Text(value)
.font(.title3.weight(.semibold))
.readingNumericTransition()
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(title),\(value)")
}
private var weeklyCard: some View {
ReadingCard {
VStack(alignment: .leading, spacing: 18) {
HStack {
Text("最近 7 天")
.font(.headline)
Spacer()
Text("每日阅读时长")
.font(.caption)
.foregroundStyle(.secondary)
}
HStack(alignment: .bottom, spacing: 10) {
ForEach(statistics.recentDays, id: \.date) { day in
VStack(spacing: 7) {
Text(barLabel(day.readTime))
.font(.caption2.monospacedDigit())
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.7)
RoundedRectangle(cornerRadius: 3)
.fill(day.readTime > 0 ? ReadingTheme.accent.opacity(Calendar.current.isDateInToday(day.date) ? 1 : 0.22) : ReadingTheme.separator)
.frame(width: 22, height: barHeight(day.readTime))
Text(weekday(day.date))
.font(.caption2)
.foregroundStyle(Calendar.current.isDateInToday(day.date) ? ReadingTheme.accent : .secondary)
}
.frame(maxWidth: .infinity)
.accessibilityElement(children: .ignore)
.accessibilityLabel("\(weekday(day.date)),阅读 \(duration(day.readTime, compact: false))")
}
}
.frame(height: chartHeight, alignment: .bottom)
}
}
}
private var historyLink: some View {
NavigationLink {
ReadingHistoryView()
} label: {
ReadingCard {
HStack(spacing: 12) {
Image(systemName: "clock.arrow.circlepath").font(.title3)
Text("阅读记录").font(.subheadline.weight(.medium))
Spacer()
Image(systemName: "arrow.up.right").font(.subheadline)
}
.foregroundStyle(ReadingTheme.accent)
.frame(minHeight: 24)
.contentShape(Rectangle())
}
}
.buttonStyle(.plain)
}
private func barHeight(_ milliseconds: Int64) -> CGFloat {
let maximum = max(statistics.recentDays.map(\.readTime).max() ?? 0, 1)
guard milliseconds > 0 else { return 2 }
return 12 + 70 * CGFloat(milliseconds) / CGFloat(maximum)
}
private func barLabel(_ milliseconds: Int64) -> String {
guard milliseconds >= 60_000 else { return milliseconds > 0 ? "<1分" : "0" }
if milliseconds < 3_600_000 { return "\(milliseconds / 60_000)分" }
let hours = Double(milliseconds) / 3_600_000
return String(format: "%.1f时", hours)
}
private func weekday(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "zh_CN")
formatter.dateFormat = "EEE"
return formatter.string(from: date)
}
private func duration(_ milliseconds: Int64, compact: Bool) -> String {
let value = max(milliseconds, 0)
let hours = value / 3_600_000
let minutes = value % 3_600_000 / 60_000
if compact {
if hours > 0 { return minutes > 0 ? "\(hours)时\(minutes)分" : "\(hours)小时" }
return "\(minutes)分钟"
}
let seconds = value % 60_000 / 1_000
if hours > 0 { return minutes > 0 ? "\(hours)小时 \(minutes)分钟" : "\(hours)小时" }
if minutes > 0 { return "\(minutes)分钟" }
return "\(seconds)秒"
}
}
+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)")
}
}
+354
View File
@@ -0,0 +1,354 @@
import SwiftUI
@MainActor
final class ExploreSourceViewModel: ObservableObject {
enum State: Equatable { case idle, loading, ready, empty, failed }
let source: BookSource
@Published private(set) var kinds: [ExploreKind] = []
@Published private(set) var selectedKind: ExploreKind?
@Published private(set) var books: [SearchBook] = []
@Published private(set) var diagnostics: [RuleDiagnostic] = []
@Published private(set) var state: State = .idle
@Published private(set) var isLoadingMore = false
private let service: BookExploreService
private var page = 1
private var canLoadMore = false
private var paginationTask: Task<Void, Never>?
init(source: BookSource, service: BookExploreService = .init()) {
self.source = source
self.service = service
}
deinit { paginationTask?.cancel() }
func load() async {
guard state == .idle else { return }
await reload()
}
func reload() async {
paginationTask?.cancel()
selectedKind = nil
kinds = []
books = []
diagnostics = []
page = 1
canLoadMore = false
state = .loading
let report = await service.kinds(for: source)
guard !Task.isCancelled else { return }
kinds = report.kinds.filter { $0.navigableURL != nil }
diagnostics = report.diagnostics
guard let first = kinds.first else {
state = diagnostics.contains(where: { $0.severity == .error }) ? .failed : .empty
return
}
await select(kind: first)
}
func select(kind: ExploreKind) async {
guard kind.navigableURL != nil else { return }
paginationTask?.cancel()
selectedKind = kind
books = []
diagnostics = []
page = 1
canLoadMore = false
state = .loading
if ProcessInfo.processInfo.arguments.contains("-uiDemo") {
books = DemoContent.exploreBooks
state = .ready
canLoadMore = true
return
}
let outcome = await service.explore(source: source, kind: kind, page: page)
guard !Task.isCancelled, selectedKind?.id == kind.id else { return }
diagnostics = outcome.diagnostics
books = outcome.books
canLoadMore = !outcome.books.isEmpty
state = books.isEmpty ? (outcome.diagnostics.isEmpty ? .empty : .failed) : .ready
}
func loadNextPageIfNeeded(current book: SearchBook) {
guard book.id == books.last?.id,
canLoadMore,
!isLoadingMore,
let kind = selectedKind else { return }
isLoadingMore = true
let nextPage = page + 1
paginationTask = Task { [service, source] in
if ProcessInfo.processInfo.arguments.contains("-uiDemo") {
try? await Task.sleep(nanoseconds: 300_000_000)
guard !Task.isCancelled else { return }
canLoadMore = false
isLoadingMore = false
return
}
let outcome = await service.explore(source: source, kind: kind, page: nextPage)
guard !Task.isCancelled, selectedKind?.id == kind.id else {
isLoadingMore = false
return
}
let knownURLs = Set(books.map(\.bookURL))
books.append(contentsOf: outcome.books.filter { !knownURLs.contains($0.bookURL) })
diagnostics += outcome.diagnostics
page = nextPage
canLoadMore = !outcome.books.isEmpty
isLoadingMore = false
}
}
}
struct ExploreView: View {
@EnvironmentObject private var sourceLibrary: SourceLibraryModel
private var sources: [BookSource] {
sourceLibrary.sources
.filter { $0.enabled && $0.enabledExplore && !($0.exploreUrl?.isEmpty ?? true) }
.sorted {
if $0.customOrder != $1.customOrder { return $0.customOrder < $1.customOrder }
return $0.bookSourceName.localizedStandardCompare($1.bookSourceName) == .orderedAscending
}
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
ReadingPageHeader(title: "发现", subtitle: "下一本好书,在这里遇见") {
NavigationLink { SearchView() } label: {
ReadingToolbarIcon(systemName: "magnifyingglass")
}
.accessibilityLabel("搜索")
}
VStack(alignment: .leading, spacing: 24) {
HStack(spacing: 16) {
Image(systemName: "safari")
.font(.system(size: 30, weight: .ultraLight))
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 6) {
Text("从喜欢的书源开始").font(.headline)
Text("浏览分类与榜单,发现更多故事")
.font(.caption).foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
.padding(22)
.frame(maxWidth: .infinity, alignment: .leading)
.background(ReadingTheme.groupedCanvas, in: RoundedRectangle(cornerRadius: 20))
HStack(alignment: .firstTextBaseline) {
Text("分类与榜单").font(.title3.bold())
.accessibilityAddTraits(.isHeader)
Spacer()
Text("\(sources.count)个书源").font(.caption).foregroundStyle(.secondary)
}
if sources.isEmpty {
ReadingUnavailableView {
Label("暂无发现书源", systemImage: "books.vertical")
} description: {
Text("添加带有发现分类的书源,即可在这里浏览书籍。")
} actions: {
NavigationLink("管理书源") { SourceManagementView() }
.buttonStyle(.borderedProminent)
}
} else {
LazyVStack(spacing: 0) {
ForEach(sources) { source in
NavigationLink { ExploreSourceView(source: source) } label: {
HStack(spacing: 12) {
ExploreSourceRow(source: source)
Spacer(minLength: 0)
Image(systemName: "chevron.right")
.font(.caption).foregroundStyle(.tertiary)
}
.padding(.vertical, 8)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if source.id != sources.last?.id {
Rectangle().fill(ReadingTheme.separator).frame(height: 0.5)
.padding(.leading, 58)
}
}
}
}
NavigationLink { SourceManagementView() } label: {
HStack(spacing: 14) {
Image(systemName: "slider.horizontal.3")
.font(.system(size: 20))
.frame(width: 40, height: 44)
VStack(alignment: .leading, spacing: 5) {
Text("管理书源").font(.subheadline.weight(.semibold))
Text("添加与整理阅读来源").font(.caption).foregroundStyle(.secondary)
}
Spacer(minLength: 0)
Image(systemName: "arrow.up.right").font(.subheadline)
}
.foregroundStyle(ReadingTheme.ink)
.padding(18)
.background(ReadingTheme.groupedCanvas, in: RoundedRectangle(cornerRadius: 18))
}
.buttonStyle(.plain)
}
.padding(.horizontal, 24)
.padding(.bottom, 32)
}
}
.background(ReadingTheme.canvas)
.navigationBarHidden(true)
}
}
private struct ExploreSourceRow: View {
let source: BookSource
var body: some View {
HStack(spacing: 12) {
Image(systemName: source.bookSourceType == 1 ? "headphones" : "books.vertical")
.font(.system(size: 20))
.foregroundStyle(ReadingTheme.accent)
.frame(width: 44, height: 48)
.background(ReadingTheme.groupedCanvas, in: RoundedRectangle(cornerRadius: 12))
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text(source.bookSourceName)
.font(.body.weight(.medium))
.foregroundStyle(.primary)
.lineLimit(2)
Text(source.groups.isEmpty ? (URL(string: source.bookSourceUrl)?.host ?? source.bookSourceUrl) : source.groups.joined(separator: " · "))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
.padding(.vertical, 7)
.contentShape(Rectangle())
}
}
private struct ExploreSourceView: View {
@StateObject private var model: ExploreSourceViewModel
init(source: BookSource) {
_model = StateObject(wrappedValue: ExploreSourceViewModel(source: source))
}
var body: some View {
ScrollView {
LazyVStack(spacing: 0) {
if model.kinds.count > 1 { kindPicker }
content
if model.isLoadingMore { ProgressView("继续加载…").padding(.vertical, 12) }
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
.background(ReadingTheme.canvas)
.navigationTitle(model.source.bookSourceName)
.navigationBarTitleDisplayMode(.inline)
.task { await model.load() }
.refreshable { await model.reload() }
}
private var kindPicker: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(model.kinds) { kind in
Button(kind.title) { Task { await model.select(kind: kind) } }
.font(.subheadline.weight(.medium))
.padding(.horizontal, 12)
.frame(minHeight: 44)
.foregroundStyle(model.selectedKind?.id == kind.id ? Color.primary : .secondary)
.overlay(alignment: .bottom) {
if model.selectedKind?.id == kind.id {
Rectangle().fill(Color.primary).frame(height: 2)
}
}
.accessibilityAddTraits(model.selectedKind?.id == kind.id ? .isSelected : [])
}
}
}
}
@ViewBuilder
private var content: some View {
switch model.state {
case .idle, .loading:
ProgressView("正在加载小说…")
.frame(maxWidth: .infinity)
.padding(.vertical, 80)
case .empty:
ReadingUnavailableView(
"暂无小说",
systemImage: "books.vertical",
description: Text("当前书源或分类暂时没有内容,可以下拉刷新。")
)
.padding(.vertical, 40)
case .failed where model.books.isEmpty:
ReadingUnavailableView(
"加载失败",
systemImage: "exclamationmark.triangle",
description: Text(model.diagnostics.first?.message ?? "书源发现规则解析失败。")
)
.padding(.vertical, 40)
default:
ForEach(model.books) { book in
NavigationLink {
BookDetailView(searchBook: book, source: model.source)
} label: {
ExploreBookRow(book: book)
}
.buttonStyle(.plain)
.onAppear { model.loadNextPageIfNeeded(current: book) }
}
}
}
}
private struct ExploreBookRow: View {
let book: SearchBook
var body: some View {
HStack(alignment: .top, spacing: 13) {
BookCoverArtwork(title: book.name, author: book.author, remoteURL: book.coverURL, width: 52, height: 74)
VStack(alignment: .leading, spacing: 6) {
Text(book.name)
.font(.body.weight(.medium))
.foregroundStyle(ReadingTheme.ink)
.lineLimit(2)
Text(book.author.isEmpty ? "未知作者" : book.author)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
if let kind = book.kind, !kind.isEmpty {
Text(kind)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
if let intro = book.intro, !intro.isEmpty {
Text(intro)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
.padding(.top, 4)
}
.padding(.vertical, 12)
.overlay(alignment: .bottom) {
Rectangle().fill(ReadingTheme.separator).frame(height: 0.5)
.padding(.leading, 65)
}
}
}
+43
View File
@@ -0,0 +1,43 @@
import SwiftUI
struct RSSAddBookView: View {
@EnvironmentObject private var books: BookLibraryModel
@EnvironmentObject private var sources: SourceLibraryModel
let bookURL: String
@State private var resolved: AddBookURLResult?
@State private var existing: (Book, BookSource)?
@State private var errorMessage: String?
var body: some View {
Group {
if let existing {
BookDetailView(book: existing.0, source: existing.1)
} else if let resolved {
BookDetailView(book: resolved.book, source: resolved.source)
} else if let errorMessage {
ReadingUnavailableView("无法添加书籍", systemImage: "books.vertical.circle", description: Text(errorMessage))
} else {
VStack(spacing: 14) {
ProgressView()
Text("正在匹配书源并解析详情…").foregroundStyle(.secondary)
Text(bookURL).font(.caption.monospaced()).foregroundStyle(.tertiary).lineLimit(3)
}.padding()
}
}
.navigationTitle("添加到书架")
.task(id: bookURL) { await load() }
}
private func load() async {
if let book = books.books.first(where: { $0.bookUrl == bookURL }),
let source = sources.sources.first(where: { $0.bookSourceUrl == book.origin }) {
existing = (book, source)
return
}
do {
resolved = try await AddBookURLService().resolve(bookURL: bookURL, sources: sources.sources)
} catch {
errorMessage = error.localizedDescription
}
}
}
+242
View File
@@ -0,0 +1,242 @@
import Foundation
import SwiftUI
@MainActor
final class RSSLibraryModel: ObservableObject {
@Published private(set) var sources: [RssSource] = []
@Published private(set) var articles: [RssArticle] = []
@Published private(set) var stars: [RssStar] = []
@Published private(set) var records: [RssReadRecord] = []
@Published private(set) var isWorking = false
@Published var errorMessage: String?
private let sourceRepository: SQLiteRssSourceRepository
private let articleRepository: SQLiteRssArticleRepository
private let starRepository: SQLiteRssStarRepository
private let recordRepository: SQLiteRssReadRecordRepository
private let service: RSSService
private let importer = RSSSourceImporter()
private let database: LegadoDatabase
private var nextURL: String?
private var page = 1
private var order: Int64 = 0
init(database: LegadoDatabase, service: RSSService = .init()) {
self.database = database
sourceRepository = .init(database: database)
articleRepository = .init(database: database)
starRepository = .init(database: database)
recordRepository = .init(database: database)
self.service = service
}
static func live() -> RSSLibraryModel {
let database = try! LegadoDatabase(url: LegadoDatabase.defaultURL())
return .init(database: database)
}
func load() async {
do {
async let loadedSources = sourceRepository.all()
async let loadedStars = starRepository.all()
async let loadedRecords = recordRepository.all()
(sources, stars, records) = try await (loadedSources, loadedStars, loadedRecords)
} catch { errorMessage = error.localizedDescription }
}
func importFiles(_ urls: [URL]) async {
await perform {
var merged = self.sources
for url in urls {
let granted = url.startAccessingSecurityScopedResource()
defer { if granted { url.stopAccessingSecurityScopedResource() } }
let payload = try self.importer.decode(Data(contentsOf: url))
merged = try self.importer.merge(payload.sources, into: merged)
for remote in payload.subscriptionURLs { merged = try await self.importRemote(remote, existing: merged) }
}
try await self.sourceRepository.save(merged)
self.sources = try await self.sourceRepository.all()
}
}
func importText(_ text: String) async {
await perform {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
if let url = URL(string: trimmed), ["http", "https"].contains(url.scheme?.lowercased() ?? "") {
self.sources = try await self.importRemote(trimmed, existing: self.sources)
} else {
let payload = try self.importer.decode(Data(trimmed.utf8))
var merged = try self.importer.merge(payload.sources, into: self.sources)
for remote in payload.subscriptionURLs { merged = try await self.importRemote(remote, existing: merged) }
self.sources = merged
}
try await self.sourceRepository.save(self.sources)
}
}
func applySilentSubscription(_ data: Data) async throws {
let incoming = try importer.decode(data).sources
var updated = sources
for var source in incoming {
if let index = updated.firstIndex(where: { $0.sourceUrl == source.sourceUrl }) {
guard updated[index].lastUpdateTime < source.lastUpdateTime else { continue }
source.sourceGroup = updated[index].sourceGroup
updated[index] = source
} else { updated.append(source) }
}
try await sourceRepository.save(updated)
sources = try await sourceRepository.all()
}
func setEnabled(_ enabled: Bool, source: RssSource) async {
do { try await sourceRepository.setEnabled(enabled, sourceURL: source.sourceUrl); sources = try await sourceRepository.all() }
catch { errorMessage = error.localizedDescription }
}
func remove(_ source: RssSource) async {
do { try await sourceRepository.remove(sourceURL: source.sourceUrl); sources = try await sourceRepository.all() }
catch { errorMessage = error.localizedDescription }
}
func moveSources(from offsets: IndexSet, to destination: Int) async {
var values = sources
values.move(fromOffsets: offsets, toOffset: destination)
for index in values.indices { values[index].customOrder = index }
do { try await sourceRepository.save(values); sources = try await sourceRepository.all() }
catch { errorMessage = error.localizedDescription }
}
func save(_ value: RssSource, replacing originalURL: String?) async -> Bool {
let name = value.sourceName.trimmingCharacters(in: .whitespacesAndNewlines)
let url = value.sourceUrl.trimmingCharacters(in: .whitespacesAndNewlines)
guard !name.isEmpty, !url.isEmpty else { errorMessage = "源名称和URL不能为空"; return false }
do {
var source = value; source.sourceName = name; source.sourceUrl = url
let original = originalURL.flatMap { key in sources.first { $0.sourceUrl == key } }
let encoder = JSONEncoder()
let oldData = try encoder.encode(original)
let newData = try encoder.encode(source)
if oldData != newData { source.lastUpdateTime = Int64(Date().timeIntervalSince1970 * 1_000) }
if let originalURL, originalURL != source.sourceUrl {
try await starRepository.updateOrigin(from: originalURL, to: source.sourceUrl)
try await articleRepository.updateOrigin(from: originalURL, to: source.sourceUrl)
try await database.deleteSourceVariables(originalURL)
}
var values = sources.filter { $0.sourceUrl != originalURL && $0.sourceUrl != source.sourceUrl }
values.append(source)
try await sourceRepository.save(values)
sources = try await sourceRepository.all()
return true
} catch { errorMessage = error.localizedDescription; return false }
}
func refresh(_ source: RssSource, sortName: String? = nil, sortURL: String? = nil, key: String? = nil) async {
page = 1; order = Int64(Date().timeIntervalSince1970 * 1_000)
let name = sortName ?? source.sourceName
let url = sortURL ?? source.sortUrl?.nilIfBlank ?? source.sourceUrl
await perform {
let report = try await self.service.articles(sortName: name, sortURL: url, source: source, page: 1, key: key)
var values = report.result.articles
for index in values.indices { values[index].order = self.order; self.order -= 1 }
try await self.articleRepository.put(values)
if source.ruleNextPage?.isEmpty == false { try await self.articleRepository.clearOld(origin: source.sourceUrl, sort: name, before: self.order) }
self.nextURL = report.result.nextURL
self.articles = try await self.articleRepository.articles(origin: source.sourceUrl, sort: name)
}
}
func loadMore(_ source: RssSource, sortName: String? = nil, key: String? = nil) async {
guard let nextURL, !nextURL.isEmpty else { return }
page += 1
let name = sortName ?? source.sourceName
await perform {
let report = try await self.service.articles(sortName: name, sortURL: nextURL, source: source, page: self.page, key: key)
var values = report.result.articles
for index in values.indices { values[index].order = self.order; self.order -= 1 }
try await self.articleRepository.append(values)
self.nextURL = report.result.nextURL
self.articles = try await self.articleRepository.articles(origin: source.sourceUrl, sort: name)
}
}
func read(_ article: RssArticle) async {
do {
try await recordRepository.insertIgnoringExisting(article.record())
records = try await recordRepository.all()
if let index = articles.firstIndex(of: article) { articles[index].read = true }
}
catch { errorMessage = error.localizedDescription }
}
func readingMode(for article: RssArticle, source: RssSource) async -> RSSReadingMode {
let policy = RSSReadingPolicy()
if article.type == 0, article.description?.nilIfBlank != nil {
return policy.mode(article: article, source: source)
}
guard let rule = source.ruleContent?.nilIfBlank else {
return policy.mode(article: article, source: source)
}
do {
let content = try await service.content(article: article, rule: rule, source: source)
if article.type == 0 {
var updated = article; updated.description = content
try await articleRepository.put([updated])
if var star = try await starRepository.star(origin: article.origin, link: article.link) {
star.description = content; try await starRepository.save(star); stars = try await starRepository.all()
}
}
return policy.mode(article: article, source: source, resolvedContent: content)
} catch {
errorMessage = error.localizedDescription
if article.type == 0 { return .html(content: "加载正文失败\n\(error.localizedDescription)", baseURL: nil) }
return policy.mode(article: article, source: source)
}
}
func toggleStar(_ article: RssArticle) async {
do {
if try await starRepository.star(origin: article.origin, link: article.link) != nil {
try await starRepository.remove(origin: article.origin, link: article.link)
} else { try await starRepository.save(article.star()) }
stars = try await starRepository.all()
} catch { errorMessage = error.localizedDescription }
}
func isStarred(_ article: RssArticle) -> Bool { stars.contains { $0.origin == article.origin && $0.link == article.link } }
func removeStar(_ value: RssStar) async {
do { try await starRepository.remove(origin: value.origin, link: value.link); stars = try await starRepository.all() }
catch { errorMessage = error.localizedDescription }
}
func saveStar(_ value: RssStar) async {
do { try await starRepository.save(value); stars = try await starRepository.all() }
catch { errorMessage = error.localizedDescription }
}
func clearStars() async {
do { try await starRepository.clear(); stars = [] }
catch { errorMessage = error.localizedDescription }
}
func clearRecords() async {
do { try await recordRepository.clear(); records = [] }
catch { errorMessage = error.localizedDescription }
}
private func importRemote(_ value: String, existing: [RssSource]) async throws -> [RssSource] {
let withoutUA = value.hasSuffix("#requestWithoutUA")
let raw = withoutUA ? String(value.dropLast("#requestWithoutUA".count)) : value
guard let url = URL(string: raw) else { throw URLError(.badURL) }
var request = URLRequest(url: url)
if withoutUA { request.setValue("", forHTTPHeaderField: "User-Agent") }
let (data, response) = try await URLSessionHTTPTransport.shared.download(for: request)
guard (response as? HTTPURLResponse).map({ (200...299).contains($0.statusCode) }) != false else { throw URLError(.badServerResponse) }
let payload = try importer.decode(data)
return try importer.merge(payload.sources, into: existing)
}
private func perform(_ operation: () async throws -> Void) async {
isWorking = true; defer { isWorking = false }
do { try await operation() } catch { errorMessage = error.localizedDescription }
}
}
private extension String {
var nilIfBlank: String? { trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : self }
}
+74
View File
@@ -0,0 +1,74 @@
import SwiftUI
@MainActor
private final class RSSSourceDebugModel: ObservableObject {
@Published var input = ""
@Published var running = false
@Published var report: RSSSourceDebugReport?
private let debugger = RSSSourceDebugger()
private var task: Task<Void, Never>?
func run(_ source: RssSource) {
task?.cancel(); running = true; report = nil
task = Task { [debugger, input] in
let result = await debugger.run(source: source, input: input)
guard !Task.isCancelled else { return }
report = result; running = false
}
}
deinit { task?.cancel() }
}
struct RSSSourceDebugView: View {
let source: RssSource
@StateObject private var model = RSSSourceDebugModel()
@State private var rawTitle = ""
@State private var rawText: String?
var body: some View {
List {
Section("调试输入") {
Text(source.sourceName).font(.headline)
TextField("关键词,或 分类::地址", text: $model.input)
.textInputAutocapitalization(.never).disableAutocorrection(true)
.onSubmit { model.run(source) }
Button { model.run(source) } label: { Label(model.running ? "正在调试…" : "运行完整链路", systemImage: "play.fill") }
.disabled(model.running)
}
if model.running { Section { HStack { ProgressView(); Text("正在请求并解析列表与正文") } } }
if let report = model.report {
Section(report.passed ? "调试通过" : "发现兼容问题") {
ForEach(report.steps) { step in
DisclosureGroup {
if step.diagnostics.isEmpty { Text("无规则诊断").foregroundStyle(.secondary) }
ForEach(step.diagnostics) { item in
VStack(alignment: .leading) { Text(item.message); Text(item.code).font(.caption.monospaced()).foregroundStyle(.secondary) }
}
} label: {
Label { VStack(alignment: .leading) { Text(step.stage.title); Text(step.summary).font(.caption).foregroundStyle(.secondary).lineLimit(2) } } icon: { Image(systemName: icon(step.status)).foregroundStyle(color(step.status)) }
}
}
}
if !report.articles.isEmpty {
Section("文章样本") { ForEach(report.articles.prefix(10)) { Text($0.title) } }
}
if let preview = report.contentPreview { Section("正文样本") { Text(preview).textSelection(.enabled) } }
}
}
.navigationTitle("RSS源调试")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Menu("查看源码", systemImage: "doc.plaintext") {
Button("列表源码") { rawTitle = "列表源码"; rawText = model.report?.listSource }.disabled(model.report?.listSource == nil)
Button("正文源码") { rawTitle = "正文源码"; rawText = model.report?.contentSource }.disabled(model.report?.contentSource == nil)
}
}
}
.sheet(isPresented: Binding(get: { rawText != nil }, set: { if !$0 { rawText = nil } })) {
ReadingNavigationStack { ScrollView { Text(rawText ?? "").font(.caption.monospaced()).textSelection(.enabled).frame(maxWidth: .infinity, alignment: .leading).padding() }.navigationTitle(rawTitle).toolbar { ToolbarItem(placement: .confirmationAction) { Button("完成") { rawText = nil } } } }
}
}
private func icon(_ status: SourceDebugStatus) -> String { switch status { case .passed: "checkmark.circle.fill"; case .warning: "exclamationmark.triangle.fill"; case .failed: "xmark.circle.fill"; case .skipped: "forward.end.circle" } }
private func color(_ status: SourceDebugStatus) -> Color { switch status { case .passed: .green; case .warning: .orange; case .failed: .red; case .skipped: .secondary } }
}
@@ -0,0 +1,92 @@
import SwiftUI
struct RSSSourceEditorView: View {
@EnvironmentObject private var library: RSSLibraryModel
@Environment(\.dismiss) private var dismiss
@State private var source: RssSource
private let originalURL: String?
init(source: RssSource? = nil) {
let value = source ?? RssSource()
_source = State(initialValue: value)
originalURL = source?.sourceUrl
}
var body: some View {
Form {
Section("基本") {
TextField("名称", text: $source.sourceName)
TextField("URL", text: $source.sourceUrl).textInputAutocapitalization(.never).keyboardType(.URL)
requiredField("图标", \.sourceIcon)
optionalField("分组", \.sourceGroup)
optionalField("注释", \.sourceComment, axis: .vertical)
Toggle("启用", isOn: $source.enabled)
}
Section("请求与登录") {
optionalField("自定义变量说明", \.variableComment, axis: .vertical)
optionalField("JS库", \.jsLib, axis: .vertical)
Toggle("自动保存Cookie", isOn: optionalBool(\.enabledCookieJar, default: true))
optionalField("并发率", \.concurrentRate)
optionalField("请求头", \.header, axis: .vertical)
optionalField("登录地址", \.loginUrl)
optionalField("登录UI", \.loginUi, axis: .vertical)
optionalField("登录检测JS", \.loginCheckJs, axis: .vertical)
optionalField("封面解密JS", \.coverDecodeJs, axis: .vertical)
}
Section("分类与列表规则") {
optionalField("分类URL(名称::URL)", \.sortUrl, axis: .vertical)
Toggle("单URL源", isOn: $source.singleUrl)
Picker("列表样式", selection: $source.articleStyle) { ForEach(0..<5, id: \.self) { Text("样式 \($0)").tag($0) } }
optionalField("列表规则", \.ruleArticles, axis: .vertical)
optionalField("下一页规则", \.ruleNextPage, axis: .vertical)
optionalField("标题规则", \.ruleTitle, axis: .vertical)
optionalField("发布日期规则", \.rulePubDate, axis: .vertical)
optionalField("描述规则", \.ruleDescription, axis: .vertical)
optionalField("图片规则", \.ruleImage, axis: .vertical)
optionalField("链接规则", \.ruleLink, axis: .vertical)
optionalField("正文规则", \.ruleContent, axis: .vertical)
}
Section("WebView规则") {
optionalField("正文URL白名单", \.contentWhitelist, axis: .vertical)
optionalField("正文URL黑名单", \.contentBlacklist, axis: .vertical)
optionalField("跳转URL拦截JS", \.shouldOverrideUrlLoading, axis: .vertical)
optionalField("样式", \.style, axis: .vertical)
Toggle("启用JavaScript", isOn: $source.enableJs)
Toggle("使用Base URL加载", isOn: $source.loadWithBaseUrl)
optionalField("注入JS", \.injectJs, axis: .vertical)
optionalField("预注入JS", \.preloadJs, axis: .vertical)
optionalField("起始HTML", \.startHtml, axis: .vertical)
optionalField("起始样式", \.startStyle, axis: .vertical)
optionalField("起始JS", \.startJs, axis: .vertical)
Toggle("输出网页日志", isOn: $source.showWebLog)
}
Section("其他") {
Stepper("自定义排序:\(source.customOrder)", value: $source.customOrder)
Picker("类型", selection: $source.type) { Text("网页").tag(0); Text("图片").tag(1); Text("视频").tag(2) }
Toggle("启用预加载", isOn: $source.preload)
Toggle("优先加载缓存", isOn: $source.cacheFirst)
optionalField("搜索URL", \.searchUrl, axis: .vertical)
}
}
.navigationTitle(originalURL == nil ? "新建RSS源" : "编辑RSS源")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("保存") { Task { if await library.save(source, replacing: originalURL) { dismiss() } } }
}
}
}
@ViewBuilder private func optionalField(_ title: String, _ keyPath: WritableKeyPath<RssSource, String?>, axis: Axis = .horizontal) -> some View {
ReadingTextField(title, text: optionalText(keyPath), axis: axis).textInputAutocapitalization(.never)
}
@ViewBuilder private func requiredField(_ title: String, _ keyPath: WritableKeyPath<RssSource, String>, axis: Axis = .horizontal) -> some View {
ReadingTextField(title, text: Binding(get: { source[keyPath: keyPath] }, set: { source[keyPath: keyPath] = $0 }), axis: axis).textInputAutocapitalization(.never)
}
private func optionalText(_ keyPath: WritableKeyPath<RssSource, String?>) -> Binding<String> {
Binding(get: { source[keyPath: keyPath] ?? "" }, set: { source[keyPath: keyPath] = $0.isEmpty ? nil : $0 })
}
private func optionalBool(_ keyPath: WritableKeyPath<RssSource, Bool?>, default defaultValue: Bool) -> Binding<Bool> {
Binding(get: { source[keyPath: keyPath] ?? defaultValue }, set: { source[keyPath: keyPath] = $0 })
}
}
+594
View File
@@ -0,0 +1,594 @@
import SwiftUI
import UniformTypeIdentifiers
import AVFoundation
import UIKit
import Photos
struct RSSHomeView: View {
@EnvironmentObject private var library: RSSLibraryModel
@State private var section = 0
@State private var searchText = ""
@State private var showsFiles = false
@State private var showsTextImport = false
@State private var showsExport = false
@State private var confirmsClear = false
@State private var editingStar: RssStar?
var body: some View {
let content = AnyView(VStack(spacing: 0) {
Picker("RSS页面", selection: $section) {
Text("订阅").tag(0); Text("收藏").tag(1); Text("记录").tag(2)
}
.pickerStyle(.segmented).padding()
selectedList
})
let navigation = AnyView(content
.navigationTitle("RSS")
.searchable(text: $searchText, prompt: section == 0 ? "搜索订阅源" : "搜索文章"))
let actions = AnyView(navigation.toolbar {
ToolbarItem(placement: .primaryAction) {
Menu("RSS操作", systemImage: "ellipsis.circle") {
NavigationLink { RSSSourceEditorView() } label: { Label("新建RSS源", systemImage: "plus") }
NavigationLink { RuleSubscriptionView() } label: { Label("规则订阅", systemImage: "link") }
Button("导入文件", systemImage: "doc.badge.plus") { showsFiles = true }
Button("URL或粘贴JSON", systemImage: "link.badge.plus") { showsTextImport = true }
Button("导出全部RSS源", systemImage: "square.and.arrow.up") { showsExport = true }
}
}
ToolbarItem(placement: .primaryAction) { if section == 0 { EditButton() } }
ToolbarItem(placement: .primaryAction) {
if section == 1, !library.stars.isEmpty { Button("清空收藏", systemImage: "trash", role: .destructive) { confirmsClear = true } }
else if section == 2, !library.records.isEmpty { Button("清空记录", systemImage: "trash", role: .destructive) { confirmsClear = true } }
}
})
let importing = AnyView(actions.fileImporter(isPresented: $showsFiles, allowedContentTypes: [.json, .text], allowsMultipleSelection: true) { result in
if case .success(let urls) = result { Task { await library.importFiles(urls) } }
if case .failure(let error) = result { library.errorMessage = error.localizedDescription }
})
let sheets = AnyView(importing
.sheet(isPresented: $showsTextImport) { RSSImportSheet() }
.sheet(item: $editingStar) { value in
RSSStarEditSheet(star: value, save: { updated in
Task { await library.saveStar(updated) }
})
})
let exporting = AnyView(sheets.fileExporter(isPresented: $showsExport, document: RSSExportDocument(sources: library.sources), contentType: UTType.json, defaultFilename: "rssSources") { result in
if case .failure(let error) = result { library.errorMessage = error.localizedDescription }
})
let status = AnyView(exporting
.overlay { if library.isWorking { ProgressView().controlSize(.large) } }
.alert("RSS操作失败", isPresented: Binding(get: { library.errorMessage != nil }, set: { if !$0 { library.errorMessage = nil } })) {
Button("好") { library.errorMessage = nil }
} message: { Text(library.errorMessage ?? "") })
return status.confirmationDialog(section == 1 ? "确定清空全部RSS收藏?" : "确定清空全部RSS阅读记录?", isPresented: $confirmsClear, titleVisibility: Visibility.visible) {
Button("清空", role: .destructive) { Task { if section == 1 { await library.clearStars() } else { await library.clearRecords() } } }
Button("取消", role: .cancel) {}
}
}
@ViewBuilder
private var selectedList: some View {
switch section {
case 1: starList
case 2: recordList
default: sourceList
}
}
private var sourceList: some View {
List {
if filteredSources.isEmpty {
ReadingUnavailableView("还没有RSS源", systemImage: "dot.radiowaves.left.and.right", description: Text("从文件、URL或剪贴板导入 RSS 源。"))
}
ForEach(filteredSources) { source in
NavigationLink { RSSSourceDestinationView(source: source) } label: {
VStack(alignment: .leading, spacing: 4) {
HStack { Text(source.sourceName).font(.headline); Spacer(); if !source.enabled { Text("停用").font(.caption).foregroundStyle(.secondary) } }
Text(source.sourceUrl).font(.caption).foregroundStyle(.secondary).lineLimit(1)
if let group = source.sourceGroup, !group.isEmpty { Text(group).font(.caption2).foregroundStyle(ReadingTheme.accent) }
}.padding(.vertical, 3)
}
.swipeActions(edge: .leading) {
Button(source.enabled ? "停用" : "启用") { Task { await library.setEnabled(!source.enabled, source: source) } }.tint(.orange)
}
.swipeActions {
Button("删除", role: .destructive) { Task { await library.remove(source) } }
}
.contextMenu {
NavigationLink { RSSSourceEditorView(source: source) } label: { Label("编辑", systemImage: "pencil") }
}
.moveDisabled(!searchText.isEmpty)
}
.onMove { offsets, destination in Task { await library.moveSources(from: offsets, to: destination) } }
}.listStyle(.plain)
}
private var starList: some View {
List {
ForEach(filteredStars) { star in
Group {
if let source = library.sources.first(where: { $0.sourceUrl == star.origin }) {
NavigationLink { RSSArticleDetailView(article: star.article, source: source) } label: { RSSArticleRow(article: star.article) }
} else { RSSArticleRow(article: star.article) }
}.swipeActions(edge: .leading) { Button("编辑", systemImage: "pencil") { editingStar = star }.tint(.blue) }
}.onDelete { offsets in
let values = offsets.map { filteredStars[$0] }
Task { for value in values { await library.removeStar(value) } }
}
}.overlay { if filteredStars.isEmpty { ReadingUnavailableView("没有收藏", systemImage: "star") } }.listStyle(.plain)
}
private var recordList: some View {
List(filteredRecords) { record in
if let source = library.sources.first(where: { $0.sourceUrl == record.origin }) {
NavigationLink { RSSArticleDetailView(article: record.article, source: source) } label: { RSSArticleRow(article: record.article) }
} else { RSSArticleRow(article: record.article) }
}.overlay { if filteredRecords.isEmpty { ReadingUnavailableView("没有阅读记录", systemImage: "clock") } }.listStyle(.plain)
}
private var filteredSources: [RssSource] { library.sources.filter { searchText.isEmpty || $0.sourceName.localizedCaseInsensitiveContains(searchText) || $0.sourceUrl.localizedCaseInsensitiveContains(searchText) || ($0.sourceGroup ?? "").localizedCaseInsensitiveContains(searchText) } }
private var filteredStars: [RssStar] { library.stars.filter { searchText.isEmpty || $0.title.localizedCaseInsensitiveContains(searchText) } }
private var filteredRecords: [RssReadRecord] { library.records.filter { searchText.isEmpty || ($0.title ?? "").localizedCaseInsensitiveContains(searchText) } }
}
private struct RSSArticleListView: View {
@EnvironmentObject private var library: RSSLibraryModel
let source: RssSource
@State private var searchKey = ""
@State private var sorts: [RSSSort] = []
@State private var selectedSort = 0
var body: some View {
VStack(spacing: 0) {
if sorts.count > 1 {
ScrollView(.horizontal, showsIndicators: false) {
HStack { ForEach(Array(sorts.enumerated()), id: \.element.id) { index, sort in
Button(sort.name.isEmpty ? "默认" : sort.name) { selectedSort = index; Task { await refresh() } }
.buttonStyle(.bordered).tint(selectedSort == index ? ReadingTheme.accent : .secondary)
}}.padding(.horizontal)
}.padding(.vertical, 6)
}
articleCollection
}
.overlay { if library.articles.isEmpty, !library.isWorking { ReadingUnavailableView("没有文章", systemImage: "newspaper") } }
.navigationTitle(source.sourceName)
.searchable(text: $searchKey, prompt: "源内搜索")
.refreshable { await refresh() }
.toolbar {
ToolbarItem(placement: .primaryAction) { Button("刷新", systemImage: "arrow.clockwise") { Task { await refresh() } } }
ToolbarItem(placement: .primaryAction) { NavigationLink { RSSSourceEditorView(source: source) } label: { Label("编辑", systemImage: "pencil") } }
ToolbarItem(placement: .primaryAction) { NavigationLink { RSSSourceDebugView(source: source) } label: { Label("调试", systemImage: "stethoscope") } }
ToolbarItem(placement: .bottomBar) { Button("加载下一页", systemImage: "arrow.down") { Task { await library.loadMore(source, sortName: activeName, key: searchKey.nilIfEmpty) } } }
}
.task { sorts = await RSSSortResolver().resolve(source); await refresh() }
}
private var activeSort: RSSSort { sorts.indices.contains(selectedSort) ? sorts[selectedSort] : .init(name: "", url: source.sourceUrl) }
private var activeName: String { searchKey.nilIfEmpty == nil ? activeSort.name : "搜索" }
private func refresh() async {
let url = searchKey.nilIfEmpty == nil ? activeSort.url : (source.searchUrl?.nilIfEmpty ?? activeSort.url)
await library.refresh(source, sortName: activeName, sortURL: url, key: searchKey.nilIfEmpty)
}
@ViewBuilder private var articleCollection: some View {
if source.articleStyle == 0 {
List(library.articles) { article in
NavigationLink { RSSArticleDetailView(article: article, source: source) } label: { RSSArticleRow(article: article) }
}.listStyle(.plain)
} else {
ScrollView {
LazyVGrid(columns: articleColumns, spacing: source.articleStyle == 3 ? 20 : 8) {
ForEach(library.articles) { article in
NavigationLink { RSSArticleDetailView(article: article, source: source) } label: {
RSSArticleCard(article: article, style: source.articleStyle)
}.buttonStyle(.plain)
}
}.padding(.horizontal, source.articleStyle == 3 ? 20 : 8).padding(.bottom, 20)
}.refreshable { await refresh() }
}
}
private var articleColumns: [GridItem] {
let count = source.articleStyle == 1 ? 1 : (source.articleStyle == 4 ? 3 : 2)
return Array(repeating: GridItem(.flexible(), spacing: source.articleStyle == 3 ? 20 : 8, alignment: .top), count: count)
}
}
private struct RSSSourceDestinationView: View {
let source: RssSource
var body: some View {
if source.singleUrl {
RSSStandaloneReaderView(source: source, mode: .web(url: source.sourceUrl))
} else if let mode = try? RSSStartPageResolver().resolve(source) {
RSSStandaloneReaderView(source: source, mode: mode)
} else {
RSSArticleListView(source: source)
}
}
}
private struct RSSStandaloneReaderView: View {
let source: RssSource
let mode: RSSReadingMode
@State private var route: RSSNativeRoute?
var body: some View {
RSSWebReaderView(mode: mode, source: source, onAction: handle)
.navigationTitle(source.sourceName).navigationBarTitleDisplayMode(.inline)
.sheet(item: $route) { route in
switch route {
case .photo(let value): RSSPhotoView(value: value, source: source)
case .search(let value): ReadingNavigationStack { SearchView(initialKeyword: value) }
case .rss(let article): ReadingNavigationStack { RSSArticleDetailView(article: article, source: source) }
case .login: ReadingNavigationStack { SourceLoginView(source: source.bookSourceAdapter) }
case .sort(let value): ReadingNavigationStack { RSSArticleListView(source: value) }
case .addBook(let value): ReadingNavigationStack { RSSAddBookView(bookURL: value) }
case .download(let url, let fileName): ReadingNavigationStack { RSSDownloadView(url: url, fileName: fileName, source: source) }
case .start(let mode): ReadingNavigationStack { RSSStandaloneReaderView(source: source, mode: mode) }
}
}
}
private func handle(_ action: RSSWebAction) {
switch action {
case .showPhoto(let value): route = .photo(value)
case .searchBook(let key, _): route = .search(key)
case .addBook(let value): route = .addBook(value)
case .download(let url, let fileName): route = .download(url: url, fileName: fileName)
case .open(let name, let url, let title, _):
switch name.lowercased() {
case "login": route = .login
case "search": if let value = title ?? url { route = .search(value) }
case "sort": var value = source; value.sortUrl = url; route = .sort(value)
case "rss":
if let url { route = .rss(.init(origin: source.sourceUrl, sort: "", title: title ?? source.sourceName, link: url)) }
else if source.singleUrl { route = .start(.web(url: source.sourceUrl)) }
else if let mode = try? RSSStartPageResolver().resolve(source) { route = .start(mode) }
else { route = .sort(source) }
default: if let url, let target = URL(string: url) { UIApplication.shared.open(target) }
}
}
}
}
private struct RSSArticleRow: View {
let article: RssArticle
var body: some View {
HStack(alignment: .top, spacing: 12) {
VStack(alignment: .leading, spacing: 5) {
Text(article.title).font(.headline).foregroundStyle(article.read ? .secondary : .primary)
if let description = article.description, !description.isEmpty { Text(description).font(.caption).foregroundStyle(.secondary).lineLimit(2) }
if let date = article.pubDate, !date.isEmpty { Text(date).font(.caption2).foregroundStyle(.tertiary) }
}
Spacer()
if article.type == 1 { Image(systemName: "photo") }
else if article.type == 2 { Image(systemName: "play.rectangle") }
}.padding(.vertical, 3)
}
}
private struct RSSArticleCard: View {
let article: RssArticle
let style: Int
var body: some View {
VStack(alignment: .leading, spacing: 0) {
articleImage
Text(article.title)
.font(style == 1 || style == 3 ? .subheadline.bold() : .caption)
.foregroundStyle(article.read ? .secondary : .primary)
.lineLimit(style == 3 ? 9 : 2)
.padding(.horizontal, style == 1 ? 12 : 6).padding(.top, 10)
if let date = article.pubDate, !date.isEmpty {
Text(date).font(.caption2).foregroundStyle(.secondary).lineLimit(style == 3 ? 39 : 1)
.padding(.horizontal, style == 1 ? 12 : 6).padding(.top, 8).padding(.bottom, 10)
} else { Spacer().frame(height: 10) }
}
.background(style == 3 ? Color(.secondarySystemBackground) : .clear)
.clipShape(RoundedRectangle(cornerRadius: style == 3 ? 12 : 0, style: .continuous))
.overlay { if style == 3 { RoundedRectangle(cornerRadius: 12).stroke(.primary.opacity(0.08)) } }
}
@ViewBuilder private var articleImage: some View {
let height = imageHeight
if let raw = article.image, let url = URL(string: raw) {
AsyncImage(url: url) { phase in
if let image = phase.image { image.resizable().scaledToFill() }
else if phase.error != nil { placeholder }
else { ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) }
}.frame(maxWidth: .infinity).frame(height: height).clipped()
} else { placeholder.frame(height: height) }
}
private var placeholder: some View {
ZStack { Color(.tertiarySystemFill); Image(systemName: article.type == 2 ? "play.rectangle" : "photo").foregroundStyle(.secondary) }
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var imageHeight: CGFloat {
switch style {
case 1: return 220
case 2: return 272
case 4: return 182
default: return 180
}
}
}
private struct RSSArticleDetailView: View {
@EnvironmentObject private var library: RSSLibraryModel
let article: RssArticle
let source: RssSource
@State private var mode: RSSReadingMode?
@State private var speechText = ""
@State private var reloadID = UUID()
@StateObject private var speech = RSSSpeechController()
@State private var nativeRoute: RSSNativeRoute?
var body: some View {
Group {
if let mode {
RSSWebReaderView(mode: mode, source: source, onTextChange: { speechText = $0 }, onAction: handleWebAction)
.id(reloadID)
} else {
ProgressView("正在加载…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.navigationTitle("文章")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button(library.isStarred(article) ? "取消收藏" : "收藏", systemImage: library.isStarred(article) ? "star.fill" : "star") { Task { await library.toggleStar(article) } }
}
ToolbarItem(placement: .primaryAction) {
if let url = URL(string: article.link) { ReadingShareLink(item: url) }
}
ToolbarItem(placement: .primaryAction) {
Menu("阅读操作", systemImage: "ellipsis.circle") {
Button(speech.isSpeaking ? "停止朗读" : "朗读正文", systemImage: speech.isSpeaking ? "stop.fill" : "speaker.wave.2.fill") {
speech.isSpeaking ? speech.stop() : speech.speak(speechText)
}.disabled(speechText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
Button("刷新", systemImage: "arrow.clockwise") { reloadID = UUID() }
if let url = URL(string: article.link) { Link("浏览器打开", destination: url) }
if source.loginUrl?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
NavigationLink { SourceLoginView(source: source.bookSourceAdapter) } label: { Label("源登录", systemImage: "person.badge.key") }
}
NavigationLink { RSSSourceEditorView(source: source) } label: { Label("编辑源", systemImage: "pencil") }
NavigationLink { RSSSourceDebugView(source: source) } label: { Label("调试源", systemImage: "stethoscope") }
}
}
}
.task(id: article.link) {
await library.read(article)
mode = await library.readingMode(for: article, source: source)
}
.sheet(item: $nativeRoute) { route in
switch route {
case .photo(let value): RSSPhotoView(value: value, source: source)
case .search(let key): ReadingNavigationStack { SearchView(initialKeyword: key) }
case .rss(let target): ReadingNavigationStack { RSSArticleDetailView(article: target, source: source) }
case .login: ReadingNavigationStack { SourceLoginView(source: source.bookSourceAdapter) }
case .sort(let target): ReadingNavigationStack { RSSArticleListView(source: target) }
case .addBook(let value): ReadingNavigationStack { RSSAddBookView(bookURL: value) }
case .download(let url, let fileName): ReadingNavigationStack { RSSDownloadView(url: url, fileName: fileName, source: source) }
case .start(let value): ReadingNavigationStack { RSSStandaloneReaderView(source: source, mode: value) }
}
}
}
private func handleWebAction(_ action: RSSWebAction) {
switch action {
case .showPhoto(let value): nativeRoute = .photo(value)
case .searchBook(let key, _): nativeRoute = .search(key)
case .addBook(let value): nativeRoute = .addBook(value)
case .open(let name, let url, let title, _):
switch name.lowercased() {
case "search": if let key = title ?? url { nativeRoute = .search(key) }
case "login": nativeRoute = .login
case "sort":
var target = source
if let url {
if let title, let data = try? JSONSerialization.data(withJSONObject: [title: url]) { target.sortUrl = String(decoding: data, as: UTF8.self) }
else { target.sortUrl = url }
}
nativeRoute = .sort(target)
case "rss":
if let url { nativeRoute = .rss(.init(origin: source.sourceUrl, sort: "", title: title ?? source.sourceName, link: url)) }
else if source.singleUrl { nativeRoute = .start(.web(url: source.sourceUrl)) }
else if let mode = try? RSSStartPageResolver().resolve(source) { nativeRoute = .start(mode) }
else { nativeRoute = .sort(source) }
default: if let url, let target = URL(string: url) { UIApplication.shared.open(target) }
}
case .download(let url, let fileName): nativeRoute = .download(url: url, fileName: fileName)
}
}
}
private enum RSSNativeRoute: Identifiable {
case photo(String), search(String), rss(RssArticle), login, sort(RssSource), addBook(String), download(url: String, fileName: String), start(RSSReadingMode)
var id: String {
switch self {
case .photo(let value): return "photo:\(value)"
case .search(let value): return "search:\(value)"
case .rss(let value): return "rss:\(value.id)"
case .login: return "login"
case .sort(let value): return "sort:\(value.sourceUrl):\(value.sortUrl ?? "")"
case .addBook(let value): return "addBook:\(value)"
case .download(let url, _): return "download:\(url)"
case .start(let mode): return "start:\(String(describing: mode))"
}
}
}
private struct RSSDownloadView: View {
@Environment(\.dismiss) private var dismiss
let url: String
let fileName: String
let source: RssSource
@State private var data = Data()
@State private var loading = true
@State private var showsExporter = false
@State private var errorMessage: String?
var body: some View {
VStack(spacing: 16) {
if loading { ProgressView("正在下载…") }
else if let errorMessage { ReadingUnavailableView("下载失败", systemImage: "exclamationmark.arrow.triangle.2.circlepath", description: Text(errorMessage)) }
else {
Image(systemName: "doc.badge.arrow.up").font(.system(size: 48)).foregroundStyle(ReadingTheme.accent)
Text(fileName).font(.headline)
Text(ByteCountFormatter.string(fromByteCount: Int64(data.count), countStyle: .file)).foregroundStyle(.secondary)
Button("选择保存位置", systemImage: "folder") { showsExporter = true }.buttonStyle(.borderedProminent)
}
}.padding().navigationTitle("文件下载").toolbar { ToolbarItem(placement: .cancellationAction) { Button("关闭") { dismiss() } } }
.task(id: url) { await download() }
.fileExporter(isPresented: $showsExporter, document: RSSBinaryDocument(data: data), contentType: .data, defaultFilename: fileName) { result in if case .failure(let error) = result { errorMessage = error.localizedDescription } }
}
private func download() async {
do {
guard let target = URL(string: url) else { throw URLError(.badURL) }
var request = URLRequest(url: target)
if let headers = try? BookSourceHeaderResolver().resolve(source.bookSourceAdapter) { for (key, value) in headers { request.setValue(value, forHTTPHeaderField: key) } }
let cookie = LegadoCookieStore.shared.cookieHeader(for: url)
if !cookie.isEmpty { request.setValue(cookie, forHTTPHeaderField: "Cookie") }
let (value, response) = try await URLSessionHTTPTransport.shared.download(for: request)
if let response = response as? HTTPURLResponse, !(200...299).contains(response.statusCode) { throw URLError(.badServerResponse) }
data = value; loading = false; showsExporter = true
} catch { errorMessage = error.localizedDescription; loading = false }
}
}
private struct RSSBinaryDocument: FileDocument {
static var readableContentTypes: [UTType] { [.data] }
let data: Data
init(data: Data) { self.data = data }
init(configuration: ReadConfiguration) throws { data = configuration.file.regularFileContents ?? Data() }
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { .init(regularFileWithContents: data) }
}
private struct RSSPhotoView: View {
@Environment(\.dismiss) private var dismiss
let value: String
let source: RssSource
@State private var image: UIImage?
@State private var loading = true
@State private var message: String?
var body: some View {
ReadingNavigationStack {
GeometryReader { proxy in
ScrollView([.horizontal, .vertical]) {
Group {
if let image { Image(uiImage: image).resizable().scaledToFit() }
else if loading { ProgressView() }
else { ReadingUnavailableView("图片加载失败", systemImage: "photo.badge.exclamationmark") }
}.frame(minWidth: proxy.size.width, minHeight: proxy.size.height)
}.background(.black)
}
.navigationTitle(source.sourceName).navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) { Button("保存到照片", systemImage: "square.and.arrow.down") { save() }.disabled(image == nil) }
ToolbarItem(placement: .confirmationAction) { Button("完成") { dismiss() } }
}
.task(id: value) { await load() }
.alert("图片", isPresented: Binding(get: { message != nil }, set: { if !$0 { message = nil } })) { Button("好") { message = nil } } message: { Text(message ?? "") }
}
}
private func load() async {
do {
let data: Data
if value.lowercased().hasPrefix("data:"), let comma = value.firstIndex(of: ",") {
let metadata = value[..<comma].lowercased()
let payload = String(value[value.index(after: comma)...])
data = metadata.contains(";base64") ? (Data(base64Encoded: payload) ?? Data()) : Data((payload.removingPercentEncoding ?? payload).utf8)
} else {
guard let url = URL(string: value) else { throw URLError(.badURL) }
var request = URLRequest(url: url)
if let headers = try? BookSourceHeaderResolver().resolve(source.bookSourceAdapter) { for (key, value) in headers { request.setValue(value, forHTTPHeaderField: key) } }
let cookie = LegadoCookieStore.shared.cookieHeader(for: value)
if !cookie.isEmpty { request.setValue(cookie, forHTTPHeaderField: "Cookie") }
(data, _) = try await URLSessionHTTPTransport.shared.download(for: request)
}
guard let decoded = UIImage(data: data) else { throw URLError(.cannotDecodeContentData) }
image = decoded; loading = false
} catch { loading = false; message = "加载图片失败:\(error.localizedDescription)" }
}
private func save() {
guard let image else { return }
PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
guard status == .authorized || status == .limited else { DispatchQueue.main.async { message = "没有照片写入权限" }; return }
PHPhotoLibrary.shared().performChanges { PHAssetChangeRequest.creationRequestForAsset(from: image) } completionHandler: { success, error in
DispatchQueue.main.async { message = success ? "保存成功" : "保存失败:\(error?.localizedDescription ?? "未知错误")" }
}
}
}
}
@MainActor
private final class RSSSpeechController: NSObject, ObservableObject, AVSpeechSynthesizerDelegate {
@Published private(set) var isSpeaking = false
private let synthesizer = AVSpeechSynthesizer()
override init() { super.init(); synthesizer.delegate = self }
func speak(_ text: String) {
let value = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !value.isEmpty else { return }
synthesizer.stopSpeaking(at: .immediate)
let utterance = AVSpeechUtterance(string: value)
utterance.voice = AVSpeechSynthesisVoice(language: "zh-CN")
synthesizer.speak(utterance)
isSpeaking = true
}
func stop() { synthesizer.stopSpeaking(at: .immediate); isSpeaking = false }
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) { Task { @MainActor in self.isSpeaking = false } }
nonisolated func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) { Task { @MainActor in self.isSpeaking = false } }
}
private struct RSSImportSheet: View {
@EnvironmentObject private var library: RSSLibraryModel
@Environment(\.dismiss) private var dismiss
@State private var text = ""
var body: some View {
ReadingNavigationStack {
TextEditor(text: $text).font(.system(.body, design: .monospaced)).padding()
.navigationTitle("导入RSS源")
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
ToolbarItem(placement: .confirmationAction) { Button("导入") { Task { await library.importText(text); dismiss() } }.disabled(text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) }
}
}
}
}
private struct RSSExportDocument: FileDocument {
static var readableContentTypes: [UTType] { [.json] }
let data: Data
init(sources: [RssSource]) {
let encoder = JSONEncoder(); encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
data = (try? encoder.encode(sources)) ?? Data("[]".utf8)
}
init(configuration: ReadConfiguration) throws { data = configuration.file.regularFileContents ?? Data() }
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { FileWrapper(regularFileWithContents: data) }
}
private struct RSSStarEditSheet: View {
@Environment(\.dismiss) private var dismiss
@State private var value: RssStar
let save: (RssStar) -> Void
init(star: RssStar, save: @escaping (RssStar) -> Void) { _value = State(initialValue: star); self.save = save }
var body: some View {
ReadingNavigationStack {
Form {
TextField("标题", text: $value.title)
TextField("分组", text: $value.group)
ReadingLabeledContent("链接", value: value.link).textSelection(.enabled)
}.navigationTitle("编辑RSS收藏").toolbar {
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
ToolbarItem(placement: .confirmationAction) { Button("保存") { save(value); dismiss() }.disabled(value.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) }
}
}
}
}
private extension String {
var nilIfEmpty: String? { isEmpty ? nil : self }
}
+318
View File
@@ -0,0 +1,318 @@
import SwiftUI
@preconcurrency import WebKit
import UIKit
enum RSSWebAction {
case showPhoto(String)
case searchBook(key: String, scope: String?)
case addBook(String)
case open(name: String, url: String?, title: String?, origin: String?)
case download(url: String, fileName: String)
}
struct RSSWebReaderView: UIViewRepresentable {
let mode: RSSReadingMode
let source: RssSource
let onTextChange: (String) -> Void
let onAction: (RSSWebAction) -> Void
init(mode: RSSReadingMode, source: RssSource, onTextChange: @escaping (String) -> Void = { _ in }, onAction: @escaping (RSSWebAction) -> Void = { _ in }) {
self.mode = mode
self.source = source
self.onTextChange = onTextChange
self.onAction = onAction
}
func makeCoordinator() -> Coordinator { Coordinator(source: source, onTextChange: onTextChange, onAction: onAction) }
func makeUIView(context: Context) -> WKWebView {
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = .default()
configuration.allowsInlineMediaPlayback = true
configuration.mediaTypesRequiringUserActionForPlayback = []
configuration.defaultWebpagePreferences.allowsContentJavaScript = source.enableJs
let controller = configuration.userContentController
controller.add(context.coordinator, name: "legado")
controller.addUserScript(.init(source: context.coordinator.bridgeScript, injectionTime: .atDocumentStart, forMainFrameOnly: false))
if let preload = source.preloadJs?.trimmedNonEmpty {
controller.addUserScript(.init(source: preload, injectionTime: .atDocumentStart, forMainFrameOnly: false))
}
if let inject = source.injectJs?.trimmedNonEmpty {
controller.addUserScript(.init(source: inject, injectionTime: .atDocumentEnd, forMainFrameOnly: false))
}
let webView = WKWebView(frame: .zero, configuration: configuration)
webView.navigationDelegate = context.coordinator
webView.uiDelegate = context.coordinator
webView.allowsBackForwardNavigationGestures = true
webView.scrollView.contentInsetAdjustmentBehavior = .automatic
context.coordinator.installRulesAndLoad(mode: mode, in: webView)
return webView
}
func updateUIView(_ webView: WKWebView, context: Context) {
context.coordinator.update(source: source)
context.coordinator.onTextChange = onTextChange
context.coordinator.onAction = onAction
context.coordinator.loadIfChanged(mode: mode, in: webView)
}
static func dismantleUIView(_ webView: WKWebView, coordinator: Coordinator) {
webView.stopLoading()
webView.configuration.userContentController.removeScriptMessageHandler(forName: "legado")
webView.navigationDelegate = nil
webView.uiDelegate = nil
}
@MainActor final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler {
private var source: RssSource
var onTextChange: (String) -> Void
var onAction: (RSSWebAction) -> Void
private var signature = ""
private var rulesInstalled = false
private let htmlProcessor = RSSHTMLProcessor()
private var analyzeContent: JSONValue = .string("")
private var analyzeBaseURL = ""
private var analyzeRedirectURL: String?
private var analyzeVariables: [String: String] = [:]
init(source: RssSource, onTextChange: @escaping (String) -> Void, onAction: @escaping (RSSWebAction) -> Void) { self.source = source; self.onTextChange = onTextChange; self.onAction = onAction }
func update(source: RssSource) { self.source = source }
var bridgeScript: String {
let sourceJSON = (try? JSONEncoder().encode(source)).map { String(decoding: $0, as: UTF8.self) } ?? "{}"
return """
(() => {
const native = (action, payload) => window.webkit.messageHandlers.legado.postMessage(Object.assign({action}, payload || {}));
const get = key => window.prompt('__legado_get__', String(key)) || '';
const put = (key, value) => { value = String(value ?? ''); native('put', {key:String(key), value}); return value; };
const ruleCall = payload => {
const result = window.prompt('__legado_rule__', JSON.stringify(payload));
if (result == null || result === '') return null;
try { return JSON.parse(result); } catch (_) { return result; }
};
const analyzer = {
setContent(content, baseUrl) { if (content == null) throw new Error('内容不可空(Content cannot be null)'); ruleCall({op:'setContent', content, baseUrl:baseUrl == null ? null : String(baseUrl)}); return analyzer; },
setBaseUrl(baseUrl) { ruleCall({op:'setBaseUrl', baseUrl:baseUrl == null ? null : String(baseUrl)}); return analyzer; },
setRedirectUrl(url) { return ruleCall({op:'setRedirectUrl', url:String(url)}); },
getStringList(rule, content, isUrl) { return ruleCall({op:'getStringList', rule:rule == null ? null : String(rule), content:content === undefined ? null : content, hasContent:content !== undefined, isUrl:!!isUrl}); },
getString(rule, content, isUrl) { return ruleCall({op:'getString', rule:rule == null ? null : String(rule), content:content === undefined ? null : content, hasContent:content !== undefined && typeof content !== 'boolean', unescape:typeof content === 'boolean' ? content : false, isUrl:!!isUrl}); },
getElement(rule) { return ruleCall({op:'getElement', rule:String(rule)}); },
getElements(rule) { return ruleCall({op:'getElements', rule:String(rule)}); }
};
window.java = window.java || {};
java.get = get; java.put = put;
java.getSource = () => window.source; java.getTag = () => \(Self.jsString(source.sourceName));
java.showPhoto = src => native('showPhoto', {url:String(src)});
java.addBook = url => native('addBook', {url:String(url)});
java.searchBook = (key, scope) => native('searchBook', {key:String(key), scope:scope == null ? null : String(scope)});
java.open = (name, url, title, origin) => native('open', {name:String(name), url:url == null ? null : String(url), title:title == null ? null : String(title), origin:origin == null ? null : String(origin)});
java.setContent = analyzer.setContent; java.setBaseUrl = analyzer.setBaseUrl; java.setRedirectUrl = analyzer.setRedirectUrl;
java.getString = analyzer.getString; java.getStringList = analyzer.getStringList; java.getElement = analyzer.getElement; java.getElements = analyzer.getElements;
window.source = Object.assign(\(sourceJSON), {
getKey: () => \(Self.jsString(source.sourceUrl)),
getTag: () => \(Self.jsString(source.sourceName)),
get, put
});
document.addEventListener('contextmenu', event => {
const image = event.target && event.target.closest ? event.target.closest('img') : null;
if (!image || !image.src) return;
event.preventDefault();
native('showPhoto', {url:String(image.currentSrc || image.src)});
}, true);
})();
"""
}
func installRulesAndLoad(mode: RSSReadingMode, in webView: WKWebView) {
let policy = RSSResourcePolicy(blacklist: source.contentBlacklist, whitelist: source.contentWhitelist)
guard let json = policy.contentBlockerJSON() else {
rulesInstalled = true; loadIfChanged(mode: mode, in: webView); return
}
WKContentRuleListStore.default().compileContentRuleList(forIdentifier: "rss-\(source.sourceUrl.hashValue)", encodedContentRuleList: json) { [weak self, weak webView] list, _ in
DispatchQueue.main.async {
guard let self, let webView else { return }
if let list { webView.configuration.userContentController.add(list) }
self.rulesInstalled = true
self.loadIfChanged(mode: mode, in: webView)
}
}
}
func loadIfChanged(mode: RSSReadingMode, in webView: WKWebView) {
guard rulesInstalled else { return }
let newSignature = String(describing: mode)
guard signature != newSignature else { return }
signature = newSignature
switch mode {
case .html(let content, let baseURL):
webView.loadHTMLString(htmlProcessor.contentHTML(content, style: source.style), baseURL: baseURL.flatMap(URL.init(string:)))
case .startHTML(let content, let baseURL):
webView.loadHTMLString(htmlProcessor.startHTML(content, source: source), baseURL: baseURL.flatMap(URL.init(string:)))
case .web(let value): load(value, in: webView)
case .image(let value):
let escaped = Self.htmlEscape(value)
webView.loadHTMLString(htmlProcessor.contentHTML("<body><img src=\"\(escaped)\"></body>", style: source.style), baseURL: URL(string: value))
case .video(let value):
let escaped = Self.htmlEscape(value)
webView.loadHTMLString(htmlProcessor.contentHTML("<body><video src=\"\(escaped)\" controls autoplay playsinline></video></body>", style: source.style), baseURL: URL(string: value))
}
}
private func load(_ value: String, in webView: WKWebView) {
guard let url = URL(string: value) else { return }
var request = URLRequest(url: url, cachePolicy: source.cacheFirst ? .returnCacheDataElseLoad : .useProtocolCachePolicy, timeoutInterval: 180)
if let headers = try? BookSourceHeaderResolver().resolve(source.bookSourceAdapter) {
for (key, value) in headers { request.setValue(value, forHTTPHeaderField: key) }
webView.customUserAgent = headers.first { $0.key.caseInsensitiveCompare("User-Agent") == .orderedSame }?.value
}
if webView.customUserAgent == nil { webView.customUserAgent = ApplicationSettingsStore.shared.settings.effectiveUserAgent }
request = ApplicationSettingsStore.shared.settings.configure(request)
let cookie = LegadoCookieStore.shared.cookieHeader(for: value)
if !cookie.isEmpty { request.setValue(cookie, forHTTPHeaderField: "Cookie") }
webView.load(request)
}
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
guard let body = message.body as? [String: Any], let action = body["action"] as? String else { return }
switch action {
case "put":
if let key = body["key"] as? String { BookSourceRuntimeStore.shared.put(sourceID: source.sourceUrl, key: key, value: String(describing: body["value"] ?? "")) }
case "showPhoto":
if let raw = body["url"] as? String { DispatchQueue.main.async { self.onAction(.showPhoto(raw)) } }
case "searchBook":
if let key = body["key"] as? String { DispatchQueue.main.async { self.onAction(.searchBook(key: key, scope: body["scope"] as? String)) } }
case "addBook":
if let raw = body["url"] as? String { DispatchQueue.main.async { self.onAction(.addBook(raw)) } }
case "open":
let name = body["name"] as? String ?? ""
DispatchQueue.main.async { self.onAction(.open(name: name, url: body["url"] as? String, title: body["title"] as? String, origin: body["origin"] as? String)) }
default: break
}
}
func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo, completionHandler: @escaping @MainActor @Sendable (String?) -> Void) {
if prompt == "__legado_get__" {
completionHandler(BookSourceRuntimeStore.shared.get(sourceID: source.sourceUrl, key: defaultText ?? ""))
} else if prompt == "__legado_rule__" {
handleRulePrompt(defaultText, completionHandler: completionHandler)
} else { completionHandler(defaultText) }
}
private func handleRulePrompt(_ payload: String?, completionHandler: @escaping @MainActor @Sendable (String?) -> Void) {
guard let payload, let data = payload.data(using: .utf8), let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let operation = object["op"] as? String else { completionHandler("null"); return }
switch operation {
case "setContent":
analyzeContent = JSONValue.bridge(object["content"])
if let base = object["baseUrl"] as? String, !base.isEmpty { analyzeBaseURL = base }
completionHandler("true")
case "setBaseUrl":
if let base = object["baseUrl"] as? String, !base.isEmpty { analyzeBaseURL = base }
completionHandler("true")
case "setRedirectUrl":
let value = object["url"] as? String ?? ""
if !value.lowercased().hasPrefix("data:"), URL(string: value) != nil { analyzeRedirectURL = value }
completionHandler(Self.jsonString(analyzeRedirectURL))
default:
let rule = object["rule"] as? String ?? ""
guard !rule.isEmpty else { completionHandler(operation == "getStringList" || operation == "getElements" ? "null" : "\"\""); return }
let input = (object["hasContent"] as? Bool) == true ? JSONValue.bridge(object["content"]) : analyzeContent
let baseURL = analyzeRedirectURL ?? analyzeBaseURL
let context = RuleExecutionContext(variables: analyzeVariables, baseURL: baseURL, source: source.bookSourceAdapter)
Task {
let evaluation: RuleEvaluation
switch input {
case .string(let text):
evaluation = await RuleEngine().evaluate(rule, inText: text, baseURL: baseURL, listMode: operation == "getStringList" || operation == "getElements", context: context)
default:
evaluation = await RuleEngine().evaluate(rule, in: input, context: context)
}
self.analyzeVariables = evaluation.variables
let isURL = object["isUrl"] as? Bool == true
let values = evaluation.values.map { value -> JSONValue in
guard isURL else { return value }
let raw = value.stringValue
return .string(URL(string: raw, relativeTo: URL(string: baseURL))?.absoluteURL.absoluteString ?? raw)
}
let response: Any
switch operation {
case "getStringList": response = values.map(\.stringValue)
case "getElement": response = values.first?.foundationObject ?? NSNull()
case "getElements": response = values.map(\.foundationObject)
default:
var value = values.map(\.stringValue).joined(separator: "\n")
if object["unescape"] as? Bool == true { value = Self.unescapeHTML(value) }
response = value
}
completionHandler(Self.json(response))
}
}
}
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping @MainActor @Sendable (WKNavigationActionPolicy) -> Void) {
guard let url = navigationAction.request.url else { decisionHandler(.cancel); return }
let scheme = url.scheme?.lowercased() ?? ""
if !["http", "https", "file", "data", "about"].contains(scheme) {
UIApplication.shared.open(url); decisionHandler(.cancel); return
}
if navigationAction.navigationType == .linkActivated, shouldOverride(url.absoluteString) {
decisionHandler(.cancel); return
}
decisionHandler(.allow)
}
func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping @MainActor @Sendable (WKNavigationResponsePolicy) -> Void) {
guard let response = navigationResponse.response as? HTTPURLResponse, let url = response.url else { decisionHandler(.allow); return }
let disposition = response.value(forHTTPHeaderField: "Content-Disposition")?.lowercased() ?? ""
if disposition.contains("attachment") || !navigationResponse.canShowMIMEType {
let name = response.suggestedFilename ?? url.lastPathComponent.nilIfBlank ?? "download"
DispatchQueue.main.async { self.onAction(.download(url: url.absoluteString, fileName: name)) }
decisionHandler(.cancel)
} else { decisionHandler(.allow) }
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
webView.evaluateJavaScript("document.body ? document.body.innerText : ''") { [weak self] value, _ in
guard let text = value as? String else { return }
DispatchQueue.main.async { self?.onTextChange(text) }
}
}
private func shouldOverride(_ url: String) -> Bool {
guard let script = source.shouldOverrideUrlLoading?.trimmedNonEmpty else { return false }
let quoted = Self.jsString(url)
let expression = "(function(url){return (\(script));})(\(quoted))"
let body = "(function(url){\(script)})(\(quoted))"
let evaluator = JavaScriptRuleEvaluator()
let context = RuleExecutionContext(variables: ["url": url], baseURL: url, source: source.bookSourceAdapter)
let result = (try? evaluator.evaluate(script: expression, result: .string(url), executionContext: context))
?? (try? evaluator.evaluate(script: body, result: .string(url), executionContext: context))
let value = result?.value.stringValue.lowercased() ?? ""
return ["true", "1", "yes"].contains(value)
}
private static func jsString(_ value: String) -> String {
let data = try? JSONEncoder().encode(value)
return data.map { String(decoding: $0, as: UTF8.self) } ?? "\"\""
}
private static func htmlEscape(_ value: String) -> String {
value.replacingOccurrences(of: "&", with: "&amp;").replacingOccurrences(of: "\"", with: "&quot;").replacingOccurrences(of: "<", with: "&lt;")
}
private static func json(_ value: Any) -> String {
guard JSONSerialization.isValidJSONObject([value]), let data = try? JSONSerialization.data(withJSONObject: value, options: .fragmentsAllowed) else { return "null" }
return String(decoding: data, as: UTF8.self)
}
private static func jsonString(_ value: String?) -> String { value.map { json($0) } ?? "null" }
private static func unescapeHTML(_ value: String) -> String {
guard let data = "<span>\(value)</span>".data(using: .utf8), let attributed = try? NSAttributedString(data: data, options: [.documentType: NSAttributedString.DocumentType.html, .characterEncoding: String.Encoding.utf8.rawValue], documentAttributes: nil) else { return value }
return attributed.string
}
}
}
private extension String {
var trimmedNonEmpty: String? {
let value = trimmingCharacters(in: .whitespacesAndNewlines)
return value.isEmpty ? nil : value
}
var nilIfBlank: String? { trimmedNonEmpty }
}
+120
View File
@@ -0,0 +1,120 @@
import CoreText
import SwiftUI
import UIKit
private struct ReaderPositionPreferenceKey: PreferenceKey {
static let defaultValue: [Int: CGFloat] = [:]
static func reduce(value: inout [Int: CGFloat], nextValue: () -> [Int: CGFloat]) {
value.merge(nextValue(), uniquingKeysWith: { _, new in new })
}
}
struct ReaderStylePreset: Identifiable {
let id: String
let name: String
let background: UIColor
let foreground: UIColor
static let legadoDefaults: [ReaderStylePreset] = [
.init(id: "wechat", name: "微信读书", background: UIColor(hex: 0xC0EDC6), foreground: UIColor(hex: 0x0B0B0B)),
.init(id: "preset1", name: "预设1", background: UIColor(hex: 0xFFFFFF), foreground: UIColor(hex: 0x000000)),
.init(id: "preset2", name: "预设2", background: UIColor(hex: 0xDDC090), foreground: UIColor(hex: 0x3E3422)),
.init(id: "preset3", name: "预设3", background: UIColor(hex: 0xC2D8AA), foreground: UIColor(hex: 0x596C44)),
.init(id: "preset4", name: "预设4", background: UIColor(hex: 0xDBB8E2), foreground: UIColor(hex: 0x68516C)),
.init(id: "preset5", name: "预设5", background: UIColor(hex: 0xABCEE0), foreground: UIColor(hex: 0x3D4C54)),
.init(id: "night", name: "夜间", background: UIColor(hex: 0x000000), foreground: UIColor(hex: 0xADADAD))
]
static let eInk = ReaderStylePreset(
id: "eink",
name: "E-Ink",
background: UIColor(hex: 0xFFFFFF),
foreground: UIColor(hex: 0x000000)
)
static func resolve(_ id: String) -> ReaderStylePreset {
let migratedID = switch id {
case "dark": "night"
case "light": "preset1"
case "paper": "preset2"
default: id
}
return legadoDefaults.first(where: { $0.id == migratedID }) ?? legadoDefaults[0]
}
}
private extension UIColor {
convenience init(hex: UInt32) {
self.init(
red: CGFloat((hex >> 16) & 0xff) / 255,
green: CGFloat((hex >> 8) & 0xff) / 255,
blue: CGFloat(hex & 0xff) / 255,
alpha: 1
)
}
}
enum ReaderBackgroundStore {
static var url: URL {
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Legado/reader-background.jpg")
}
static func load() -> UIImage? {
guard let data = try? Data(contentsOf: url) else { return nil }
return UIImage(data: data)
}
static func save(_ data: Data) throws -> UIImage {
guard let image = UIImage(data: data), let encoded = image.jpegData(compressionQuality: 0.9) else {
throw CocoaError(.fileReadCorruptFile)
}
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
try encoded.write(to: url, options: .atomic)
return image
}
static func remove() { try? FileManager.default.removeItem(at: url) }
}
enum ReaderFontStore {
static var directory: URL {
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Legado/fonts", isDirectory: true)
}
static func install(from source: URL) throws -> String {
let accessing = source.startAccessingSecurityScopedResource()
defer { if accessing { source.stopAccessingSecurityScopedResource() } }
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let target = directory.appendingPathComponent(source.lastPathComponent)
if target != source {
try? FileManager.default.removeItem(at: target)
try FileManager.default.copyItem(at: source, to: target)
}
guard font(at: target.path, size: 20, weight: 0) != nil else {
throw CocoaError(.fileReadCorruptFile)
}
return target.path
}
static func font(at path: String, size: CGFloat, weight: Int) -> UIFont? {
guard !path.isEmpty,
let provider = CGDataProvider(url: URL(fileURLWithPath: path) as CFURL),
let graphicsFont = CGFont(provider) else { return nil }
CTFontManagerRegisterGraphicsFont(graphicsFont, nil)
guard let postScriptName = graphicsFont.postScriptName else { return nil }
let name = postScriptName as String
guard let base = UIFont(name: name, size: size) else { return nil }
if weight == 1,
let descriptor = base.fontDescriptor.withSymbolicTraits(.traitBold) {
return UIFont(descriptor: descriptor, size: size)
}
if weight == 2 {
let descriptor = base.fontDescriptor.addingAttributes([
.traits: [UIFontDescriptor.TraitKey.weight: UIFont.Weight.light.rawValue]
])
return UIFont(descriptor: descriptor, size: size)
}
return base
}
}
+579
View File
@@ -0,0 +1,579 @@
import SwiftUI
import UIKit
struct ReaderScrollView: UIViewRepresentable {
let configuration: ReaderTextView
let chapters: [ReaderScrollChapter]
let resetID: Int
let activeIndex: Int
let loadingAhead: Bool
let loadingBehind: Bool
let onNeedChapters: (Int) -> Void
let onPositionChanged: (Int, Int) -> Void
let onRetainChapters: (Set<Int>) -> Void
func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
func makeUIView(context: Context) -> ReaderScrollCollectionView {
context.coordinator.makeView()
}
func updateUIView(_ view: ReaderScrollCollectionView, context: Context) {
context.coordinator.parent = self
context.coordinator.update(view)
}
static func dismantleUIView(_ view: ReaderScrollCollectionView, coordinator: Coordinator) {
coordinator.displayLink?.invalidate()
view.viewportChanged = nil
}
@MainActor
final class Coordinator: NSObject, UICollectionViewDataSource, UICollectionViewDelegate,
UIGestureRecognizerDelegate {
var parent: ReaderScrollView
private(set) var blocks: [ReaderScrollBlock] = []
private var chapterLayouts: [Int: ReaderScrollChapterLayout] = [:]
private weak var view: ReaderScrollCollectionView?
var displayLink: CADisplayLink?
private var lastTimestamp: CFTimeInterval = 0
private var styleKey = ""
private var viewportHeight: CGFloat = 0
private var revision: Int?
private var restored = false
private var jumpKey: String?
private var reporting = false
private var applying = false
private var requestedAhead: Int?
private var requestedBehind: Int?
private var wasLoadingAhead = false
private var wasLoadingBehind = false
private var lastPageKey = ""
private var reachedEnd = false
#if DEBUG
private var decelerationInsertions = 0
private var continuedDecelerations = 0
private var interruptedDecelerations = 0
private var awaitingDecelerationOffset: CGFloat?
#endif
init(parent: ReaderScrollView) { self.parent = parent }
func makeView() -> ReaderScrollCollectionView {
let collection = ReaderScrollCollectionView(frame: .zero, collectionViewLayout: ReaderContinuousLayout())
collection.backgroundColor = parent.configuration.backgroundColor
collection.contentInsetAdjustmentBehavior = .never
collection.alwaysBounceVertical = true
collection.showsVerticalScrollIndicator = false
collection.decelerationRate = .normal
collection.dataSource = self
collection.delegate = self
collection.accessibilityIdentifier = "reader.continuous"
collection.register(ReaderScrollCell.self, forCellWithReuseIdentifier: "text")
let tap = UITapGestureRecognizer(target: self, action: #selector(tapped(_:)))
tap.delegate = self
collection.addGestureRecognizer(tap)
collection.viewportChanged = { [weak self, weak collection] in
guard let self, let collection else { return }
self.update(collection)
}
let link = CADisplayLink(target: self, selector: #selector(step(_:)))
link.add(to: .main, forMode: .common)
displayLink = link
view = collection
return collection
}
func update(_ view: ReaderScrollCollectionView) {
guard !applying, view.bounds.width > 0, view.bounds.height > 0 else { return }
applying = true
defer { applying = false }
let config = parent.configuration
if wasLoadingAhead, !parent.loadingAhead { requestedAhead = nil }
if wasLoadingBehind, !parent.loadingBehind { requestedBehind = nil }
wasLoadingAhead = parent.loadingAhead
wasLoadingBehind = parent.loadingBehind
view.backgroundColor = config.backgroundColor
view.loadingLabel.textColor = config.foregroundColor.withAlphaComponent(0.6)
view.loadingLabel.text = parent.chapters.isEmpty ? "正在加载正文…"
: (parent.loadingAhead || parent.loadingBehind ? "正在加载后续章节…" : nil)
view.positionLoadingLabel()
let reset = revision != parent.resetID
if reset {
revision = parent.resetID
restored = false
jumpKey = nil
requestedAhead = nil
requestedBehind = nil
lastPageKey = ""
chapterLayouts = [:]
}
let anchor = reset ? nil : captureAnchor()
let newStyleKey = config.scrollTypographyKey + "|\(view.bounds.width)"
let styleChanged = styleKey != newStyleKey
let contentChanged = styleChanged || viewportHeight != view.bounds.height || parent.chapters.count != chapterLayouts.count
|| parent.chapters.contains { chapterLayouts[$0.id]?.chapter != $0 }
if contentChanged || reset {
let oldIDs = blocks.map(\.id)
var layouts: [Int: ReaderScrollChapterLayout] = [:]
for chapter in parent.chapters {
if !styleChanged, let cached = chapterLayouts[chapter.id], cached.chapter == chapter {
layouts[chapter.id] = cached
} else {
layouts[chapter.id] = ReaderScrollChapterLayout(
chapter: chapter, configuration: config, width: view.bounds.width
)
}
}
let newBlocks = parent.chapters.flatMap { layouts[$0.id]?.blocks ?? [] }
let layout = view.collectionViewLayout as! ReaderContinuousLayout
let geometry = ReaderContinuousLayout.geometry(
blocks: newBlocks, width: view.bounds.width,
top: CGFloat(config.paddingTop), bottom: CGFloat(config.paddingBottom), viewportHeight: view.bounds.height
)
let targetY = anchor.flatMap { offset(for: $0, blocks: newBlocks, frames: geometry.frames) }
let correction = targetY.map { $0 - view.contentOffset.y } ?? 0
let newIDs = newBlocks.map(\.id)
let oldSet = Set(oldIDs), newSet = Set(newIDs)
let deletions = oldIDs.indices.filter { !newSet.contains(oldIDs[$0]) }.map { IndexPath(item: $0, section: 0) }
let insertions = newIDs.indices.filter { !oldSet.contains(newIDs[$0]) }.map { IndexPath(item: $0, section: 0) }
if !deletions.isEmpty { requestedAhead = nil; requestedBehind = nil }
chapterLayouts = layouts
styleKey = newStyleKey
viewportHeight = view.bounds.height
#if DEBUG
let wasDecelerating = view.isDecelerating
#endif
UIView.performWithoutAnimation {
view.performBatchUpdates {
self.blocks = newBlocks
view.deleteItems(at: deletions)
view.insertItems(at: insertions)
// UIKit applies the geometry and anchor compensation together.
// Setting contentOffset here would cancel an in-flight deceleration.
layout.install(frames: geometry.frames, size: geometry.size, correction: correction)
}
view.layoutIfNeeded()
}
#if DEBUG
if wasDecelerating, !insertions.isEmpty {
decelerationInsertions += 1
if view.isDecelerating { awaitingDecelerationOffset = view.contentOffset.y }
else { interruptedDecelerations += 1 }
}
#endif
for cell in view.visibleCells {
guard let cell = cell as? ReaderScrollCell,
let index = view.indexPath(for: cell)?.item, blocks.indices.contains(index) else { continue }
cell.canvas.block = blocks[index]
}
reachedEnd = false
}
if !restored, let chapter = chapterLayouts[parent.activeIndex] {
restored = true
seek(chapter: chapter.chapter.id, position: config.requestedPosition, includeHeading: config.requestedPosition == 0)
}
if let position = config.jumpPosition {
let key = "\(parent.activeIndex):\(position)"
if jumpKey != key {
jumpKey = key
seek(chapter: parent.activeIndex, position: position, includeHeading: position == 0)
}
} else { jumpKey = nil }
scheduleReport()
}
func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { blocks.count }
func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "text", for: indexPath) as! ReaderScrollCell
cell.canvas.block = blocks[indexPath.item]
return cell
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard !applying else { return }
#if DEBUG
if let offset = awaitingDecelerationOffset, scrollView.isDecelerating,
abs(scrollView.contentOffset.y - offset) > 0.5 {
continuedDecelerations += 1
awaitingDecelerationOffset = nil
}
#endif
view?.positionLoadingLabel()
scheduleReport()
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if !decelerate { scheduleReport() }
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { scheduleReport() }
private func scheduleReport() {
guard !reporting else { return }
reporting = true
let scheduledRevision = parent.resetID
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.reporting = false
guard self.parent.resetID == scheduledRevision else { self.scheduleReport(); return }
self.reportViewport()
}
}
private func reportViewport() {
guard let view, restored, let anchor = captureAnchor(),
let chapter = chapterLayouts[anchor.chapter] else { return }
parent.onPositionChanged(anchor.chapter, anchor.position)
let config = parent.configuration
let chapterRows = blocks.indices.filter { blocks[$0].chapter.chapter.id == anchor.chapter }
let layout = view.collectionViewLayout as! ReaderContinuousLayout
if let first = chapterRows.first, let last = chapterRows.last {
let start = layout.frames[first].minY, end = layout.frames[last].maxY
let height = max(view.bounds.height, 1)
let total = max(Int(ceil((end - start) / height)), 1)
let page = min(max(Int(floor((view.contentOffset.y - start) / height)) + 1, 1), total)
if config.currentPage != page { config.currentPage = page }
if config.totalPages != total { config.totalPages = total }
let ranges = chapter.chapter.content.isEmpty ? [] : [NSRange(location: 0, length: chapter.chapter.content.utf16.count)]
if config.pageRanges != ranges { config.pageRanges = ranges }
let key = "\(anchor.chapter):\(page)"
if lastPageKey != key { lastPageKey = key; config.onPageChanged() }
}
let runway = view.bounds.height * 3
if let last = parent.chapters.last, layout.collectionViewContentSize.height - view.bounds.maxY < runway,
requestedAhead != last.id {
requestedAhead = last.id
parent.onNeedChapters(1)
}
if let first = parent.chapters.first, view.contentOffset.y < runway, requestedBehind != first.id {
requestedBehind = first.id
parent.onNeedChapters(-1)
}
if !view.isDragging, !view.isDecelerating, parent.chapters.count > 12 {
let retainedRect = view.bounds.insetBy(dx: 0, dy: -view.bounds.height * 6)
let visible = blocks.indices.filter { layout.frames[$0].intersects(retainedRect) }
let indices = Set(visible.map { blocks[$0].chapter.chapter.id })
let ordered = parent.chapters.map(\.id)
if let lo = ordered.firstIndex(where: { indices.contains($0) }),
let hi = ordered.lastIndex(where: { indices.contains($0) }) {
let keep = Set(ordered[max(lo - 2, 0)...min(hi + 2, ordered.count - 1)])
if keep.count < ordered.count { parent.onRetainChapters(keep) }
}
}
#if DEBUG
view.accessibilityValue = "chapter=\(anchor.chapter) position=\(anchor.position) chapters=\(parent.chapters.count) decelInsertions=\(decelerationInsertions) continued=\(continuedDecelerations) interrupted=\(interruptedDecelerations)"
#endif
}
private struct Anchor {
let chapter: Int
let position: Int
let inHeading: Bool
let screenY: CGFloat
}
private func captureAnchor() -> Anchor? {
guard let view, !blocks.isEmpty else { return nil }
let layout = view.collectionViewLayout as! ReaderContinuousLayout
let y = max(view.contentOffset.y, layout.frames.first?.minY ?? 0)
guard let row = layout.row(at: y) else { return nil }
let block = blocks[row], chapter = block.chapter
let localY = y - layout.frames[row].minY + block.top
let glyph = chapter.manager.glyphIndex(for: CGPoint(x: 0, y: localY + 0.5), in: chapter.container)
let safeGlyph = min(glyph, max(chapter.manager.numberOfGlyphs - 1, 0))
let character = chapter.manager.characterIndexForGlyph(at: safeGlyph)
let lineY = chapter.manager.lineFragmentRect(forGlyphAt: safeGlyph, effectiveRange: nil).minY
return Anchor(
chapter: chapter.chapter.id,
position: min(max(character - chapter.bodyStart, 0), chapter.chapter.content.utf16.count),
inHeading: character < chapter.bodyStart,
screenY: layout.frames[row].minY + lineY - block.top - view.contentOffset.y
)
}
private func offset(for anchor: Anchor, blocks: [ReaderScrollBlock], frames: [CGRect]) -> CGFloat? {
guard let chapter = blocks.first(where: { $0.chapter.chapter.id == anchor.chapter })?.chapter else { return nil }
let character = anchor.inHeading ? 0 : ReaderPositionResolver.renderedLocation(
position: anchor.position, prefixLength: chapter.bodyStart, renderedLength: chapter.storage.length
)
let glyph = chapter.manager.glyphIndexForCharacter(at: character)
guard let row = blocks.firstIndex(where: {
$0.chapter.chapter.id == anchor.chapter && NSLocationInRange(glyph, $0.glyphRange)
}) else { return nil }
let lineY = chapter.manager.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil).minY
return frames[row].minY + lineY - blocks[row].top - anchor.screenY
}
private func seek(chapter: Int, position: Int, includeHeading: Bool) {
guard let view else { return }
let layout = view.collectionViewLayout as! ReaderContinuousLayout
let anchor = Anchor(chapter: chapter, position: max(position, 0), inHeading: includeHeading, screenY: 0)
guard let y = offset(for: anchor, blocks: blocks, frames: layout.frames) else { return }
let maximum = max(layout.collectionViewContentSize.height - view.bounds.height, 0)
view.setContentOffset(CGPoint(x: 0, y: min(max(y, 0), maximum)), animated: false)
}
@objc private func tapped(_ gesture: UITapGestureRecognizer) {
guard let view, !view.isDragging, !view.isDecelerating else { return }
let point = gesture.location(in: view)
if let row = (view.collectionViewLayout as? ReaderContinuousLayout)?.row(at: point.y),
blocks[row].chapter.chapter.error != nil {
parent.configuration.onTapAction(0)
return
}
let x = point.x - view.bounds.minX, y = point.y - view.bounds.minY
let column = min(max(Int(x / max(view.bounds.width / 3, 1)), 0), 2)
let row = min(max(Int(y / max(view.bounds.height / 3, 1)), 0), 2)
let index = row * 3 + column
let actions = parent.configuration.clickActions
let action = index == 4 ? 0 : (actions.indices.contains(index) ? actions[index] : 0)
if action != 1, action != 2, action != -1 { parent.configuration.onTapAction(action) }
}
@objc private func step(_ link: CADisplayLink) {
defer { lastTimestamp = link.timestamp }
guard let view, lastTimestamp > 0, parent.configuration.isAutoReading,
!parent.configuration.isAutoReadPaused, !view.isDragging, !view.isDecelerating else { return }
let elapsed = min(link.timestamp - lastTimestamp, 0.1)
let maximum = max(view.contentSize.height - view.bounds.height, 0)
let next = min(view.contentOffset.y + view.bounds.height / CGFloat(max(parent.configuration.secondsPerScreen, 1)) * elapsed, maximum)
if next > view.contentOffset.y { view.setContentOffset(CGPoint(x: 0, y: next), animated: false) }
if next >= maximum - 0.5, !reachedEnd {
reachedEnd = true
parent.configuration.onReachedEnd(parent.configuration.readerPosition)
}
}
}
}
@MainActor
final class ReaderScrollCollectionView: UICollectionView {
var viewportChanged: (() -> Void)?
let loadingLabel = UILabel()
private var viewportSize = CGSize.zero
override init(frame: CGRect, collectionViewLayout layout: UICollectionViewLayout) {
super.init(frame: frame, collectionViewLayout: layout)
loadingLabel.font = .systemFont(ofSize: 12)
loadingLabel.textAlignment = .center
loadingLabel.isUserInteractionEnabled = false
addSubview(loadingLabel)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func layoutSubviews() {
super.layoutSubviews()
positionLoadingLabel()
if viewportSize != bounds.size {
viewportSize = bounds.size
viewportChanged?()
}
}
func positionLoadingLabel() {
loadingLabel.frame = CGRect(x: 0, y: bounds.maxY - 28, width: bounds.width, height: 24)
bringSubviewToFront(loadingLabel)
}
}
@MainActor
final class ReaderContinuousLayout: UICollectionViewLayout {
private(set) var frames: [CGRect] = []
private var size = CGSize.zero
override var collectionViewContentSize: CGSize { size }
static func geometry(blocks: [ReaderScrollBlock], width: CGFloat, top: CGFloat, bottom: CGFloat, viewportHeight: CGFloat) -> (frames: [CGRect], size: CGSize) {
var y = top
let frames = blocks.map { block -> CGRect in
let frame = CGRect(x: 0, y: y, width: width, height: block.height)
y += block.height
return frame
}
let finalChapter = blocks.last?.chapter.chapter.id
let finalStart = blocks.indices.first(where: { blocks[$0].chapter.chapter.id == finalChapter })
.map { frames[$0].minY } ?? top
return (frames, CGSize(width: width, height: max(y, finalStart + viewportHeight) + bottom))
}
func install(frames: [CGRect], size: CGSize, correction: CGFloat) {
let context = UICollectionViewLayoutInvalidationContext()
context.contentOffsetAdjustment = CGPoint(x: 0, y: correction)
context.contentSizeAdjustment = CGSize(width: size.width - self.size.width, height: size.height - self.size.height)
self.frames = frames
self.size = size
invalidateLayout(with: context)
}
func row(at y: CGFloat) -> Int? {
guard !frames.isEmpty else { return nil }
var low = 0, high = frames.count
while low < high {
let middle = (low + high) / 2
if frames[middle].maxY <= y { low = middle + 1 } else { high = middle }
}
return min(low, frames.count - 1)
}
override func layoutAttributesForElements(in rect: CGRect) -> [UICollectionViewLayoutAttributes]? {
guard let start = row(at: rect.minY) else { return [] }
var result: [UICollectionViewLayoutAttributes] = []
for row in start..<frames.count {
if frames[row].minY > rect.maxY { break }
if let attributes = layoutAttributesForItem(at: IndexPath(item: row, section: 0)) { result.append(attributes) }
}
return result
}
override func layoutAttributesForItem(at indexPath: IndexPath) -> UICollectionViewLayoutAttributes? {
guard frames.indices.contains(indexPath.item) else { return nil }
let attributes = UICollectionViewLayoutAttributes(forCellWith: indexPath)
attributes.frame = frames[indexPath.item]
return attributes
}
}
@MainActor
struct ReaderScrollBlock {
struct ID: Hashable { let chapter: Int; let firstGlyph: Int }
let chapter: ReaderScrollChapterLayout
let glyphRange: NSRange
let top: CGFloat
let height: CGFloat
var id: ID { .init(chapter: chapter.chapter.id, firstGlyph: glyphRange.location) }
}
@MainActor
final class ReaderScrollChapterLayout {
let chapter: ReaderScrollChapter
let storage: NSTextStorage
let manager = NSLayoutManager()
let container: NSTextContainer
let bodyStart: Int
let left: CGFloat
private var fragments: [(range: NSRange, top: CGFloat, height: CGFloat)] = []
var blocks: [ReaderScrollBlock] {
fragments.map { .init(chapter: self, glyphRange: $0.range, top: $0.top, height: $0.height) }
}
init(chapter: ReaderScrollChapter, configuration: ReaderTextView, width: CGFloat) {
self.chapter = chapter
let typography = ReaderTypography(configuration: configuration)
left = CGFloat(configuration.paddingLeft) + 4
let textWidth = max(width - left - CGFloat(configuration.paddingRight) - 4, 1)
container = NSTextContainer(size: CGSize(width: textWidth, height: .greatestFiniteMagnitude))
container.lineFragmentPadding = 0
let attributed = NSMutableAttributedString(string: "")
if configuration.titleMode != 2, !chapter.title.isEmpty {
attributed.append(NSAttributedString(string: chapter.title + "\n", attributes: typography.headingAttributes))
}
bodyStart = attributed.length
let body = chapter.error.map { "本章加载失败\n\n\($0)\n\n轻点打开阅读菜单。\n" } ?? (chapter.content.isEmpty ? "本章暂无正文\n" : chapter.content)
attributed.append(NSAttributedString(string: body, attributes: typography.bodyAttributes))
storage = NSTextStorage(attributedString: attributed)
storage.addLayoutManager(manager)
manager.addTextContainer(container)
manager.ensureLayout(for: container)
var startGlyph = 0
var top: CGFloat = 0
let count = manager.numberOfGlyphs
manager.enumerateLineFragments(forGlyphRange: NSRange(location: 0, length: count)) { rect, _, _, range, _ in
if rect.minY - top >= 1000, range.location > startGlyph {
self.fragments.append((NSRange(location: startGlyph, length: range.location - startGlyph),
top, rect.minY - top))
startGlyph = range.location
top = rect.minY
}
}
let bottom = manager.usedRect(for: container).maxY + CGFloat(configuration.fontSize * 1.4)
fragments.append((NSRange(location: startGlyph, length: count - startGlyph), top, max(bottom - top, 1)))
}
}
@MainActor
private final class ReaderScrollCell: UICollectionViewCell {
let canvas = ReaderScrollCanvas()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
contentView.backgroundColor = .clear
contentView.addSubview(canvas)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func layoutSubviews() { super.layoutSubviews(); canvas.frame = contentView.bounds }
override func prepareForReuse() { super.prepareForReuse(); canvas.block = nil }
}
@MainActor
private final class ReaderScrollCanvas: UIView {
var block: ReaderScrollBlock? {
didSet {
if let block {
let range = block.chapter.manager.characterRange(forGlyphRange: block.glyphRange, actualGlyphRange: nil)
accessibilityLabel = block.chapter.storage.attributedSubstring(from: range).string
} else { accessibilityLabel = nil }
setNeedsDisplay()
}
}
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .clear
isOpaque = false
isAccessibilityElement = true
accessibilityTraits = .staticText
contentMode = .redraw
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func draw(_ rect: CGRect) {
guard let block else { return }
let origin = CGPoint(x: block.chapter.left, y: -block.top)
block.chapter.manager.drawBackground(forGlyphRange: block.glyphRange, at: origin)
block.chapter.manager.drawGlyphs(forGlyphRange: block.glyphRange, at: origin)
}
}
@MainActor
struct ReaderTypography {
let bodyAttributes: [NSAttributedString.Key: Any]
let headingAttributes: [NSAttributedString.Key: Any]
init(configuration c: ReaderTextView) {
let weight: UIFont.Weight = switch c.fontWeight { case 1: .bold; case 2: .light; default: .regular }
let font = ReaderFontStore.font(at: c.fontPath, size: CGFloat(c.fontSize), weight: c.fontWeight)
?? (c.fontWeight == 0 ? (UIFont(name: "STSongti-SC-Regular", size: CGFloat(c.fontSize))
?? UIFont.systemFont(ofSize: CGFloat(c.fontSize))) : UIFont.systemFont(ofSize: CGFloat(c.fontSize), weight: weight))
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = CGFloat(c.lineSpacing)
paragraph.paragraphSpacing = CGFloat(c.paragraphSpacing)
paragraph.firstLineHeadIndent = (c.paragraphIndent as NSString).size(withAttributes: [
.font: font, .kern: CGFloat(c.letterSpacing * c.fontSize)
]).width
paragraph.alignment = c.fullJustify ? .justified : .natural
var body: [NSAttributedString.Key: Any] = [
.font: font, .kern: CGFloat(c.letterSpacing * c.fontSize),
.foregroundColor: c.foregroundColor, .paragraphStyle: paragraph
]
if c.underlineMode != 0 {
var underline = NSUnderlineStyle.single
if c.underlineMode == 2 { underline.insert(.patternDash) }
body[.underlineStyle] = underline.rawValue
body[.underlineColor] = c.foregroundColor
}
bodyAttributes = body
let headingFont = ReaderFontStore.font(at: c.fontPath, size: CGFloat(max(5, c.fontSize + c.titleSize)), weight: c.fontWeight)
?? UIFont.systemFont(ofSize: CGFloat(max(5, c.fontSize + c.titleSize)), weight: weight)
let heading = NSMutableParagraphStyle()
heading.alignment = c.titleMode == 1 ? .center : .left
heading.paragraphSpacingBefore = CGFloat(c.titleTopSpacing)
heading.paragraphSpacing = CGFloat(c.titleBottomSpacing)
headingAttributes = [.font: headingFont, .foregroundColor: c.foregroundColor, .paragraphStyle: heading]
}
}
extension ReaderTextView {
var scrollTypographyKey: String {
"\(fontSize)|\(fontPath)|\(letterSpacing)|\(lineSpacing)|\(paragraphSpacing)|\(paragraphIndent)|\(paddingLeft)|\(paddingRight)|\(paddingTop)|\(paddingBottom)|\(fontWeight)|\(titleMode)|\(titleSize)|\(titleTopSpacing)|\(titleBottomSpacing)|\(underlineMode)|\(fullJustify)|\(foregroundColor.hash)"
}
}
@@ -0,0 +1,643 @@
@preconcurrency import AVFoundation
@preconcurrency import MediaPlayer
import SwiftUI
import UniformTypeIdentifiers
private final class IncrementalAudioResourceLoader: NSObject, AVAssetResourceLoaderDelegate, @unchecked Sendable {
private let queue = DispatchQueue(label: "io.legado.tts.stream-loader")
private let contentType: String?
private let expectedLength: Int64
private var data = Data()
private var pending: [AVAssetResourceLoadingRequest] = []
private var finished = false
private var failure: Error?
private var consumeTask: Task<Void, Never>?
init(stream: HttpTTSAudioStream) {
contentType = stream.contentType
expectedLength = stream.expectedContentLength ?? 0
super.init()
consumeTask = Task { [weak self] in
do {
for try await chunk in stream.chunks {
guard let self else { return }
self.queue.async { [weak self] in
self?.data.append(chunk)
self?.processPendingRequests()
}
}
self?.queue.async { [weak self] in
self?.finished = true
self?.processPendingRequests()
}
} catch {
self?.queue.async { [weak self] in
self?.failure = error
self?.finished = true
self?.processPendingRequests()
}
}
}
}
deinit { consumeTask?.cancel() }
func cancel() {
consumeTask?.cancel()
queue.async { [weak self] in
guard let self, !finished else { return }
failure = CancellationError()
finished = true
processPendingRequests()
}
}
func install(on asset: AVURLAsset) {
asset.resourceLoader.setDelegate(self, queue: queue)
}
func resourceLoader(
_ resourceLoader: AVAssetResourceLoader,
shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest
) -> Bool {
pending.append(loadingRequest)
processPendingRequests()
return true
}
func resourceLoader(
_ resourceLoader: AVAssetResourceLoader,
didCancel loadingRequest: AVAssetResourceLoadingRequest
) {
pending.removeAll { $0 === loadingRequest }
}
private func processPendingRequests() {
pending.removeAll { request in
if let info = request.contentInformationRequest {
info.contentType = contentType.flatMap { UTType(mimeType: $0)?.identifier }
info.contentLength = expectedLength
info.isByteRangeAccessSupported = false
}
if let failure {
request.finishLoading(with: failure)
return true
}
guard let dataRequest = request.dataRequest else {
if finished { request.finishLoading(); return true }
return false
}
let offset = max(Int(dataRequest.currentOffset), Int(dataRequest.requestedOffset))
if offset < data.count {
let requestedEnd = Int(dataRequest.requestedOffset) + dataRequest.requestedLength
let end = min(data.count, requestedEnd)
if end > offset { dataRequest.respond(with: data.subdata(in: offset..<end)) }
}
let deliveredEnd = max(Int(dataRequest.currentOffset), Int(dataRequest.requestedOffset))
let requestedEnd = Int(dataRequest.requestedOffset) + dataRequest.requestedLength
if deliveredEnd >= requestedEnd || finished {
request.finishLoading()
return true
}
return false
}
}
}
@MainActor
final class ReaderSpeechController: NSObject, ObservableObject, @preconcurrency AVSpeechSynthesizerDelegate, @preconcurrency AVAudioPlayerDelegate {
@Published private(set) var isSpeaking = false
@Published private(set) var isPaused = false
@Published private(set) var errorMessage: String?
@Published private(set) var completionCount = 0
@Published private(set) var currentSegmentIndex = 0
private let synthesizer = AVSpeechSynthesizer()
private let httpService = HttpTTSService()
private var audioPlayer: AVAudioPlayer?
private var streamPlayer: AVPlayer?
private var streamLoader: IncrementalAudioResourceLoader?
private var streamCompletionToken: NSObjectProtocol?
private var httpTask: Task<Void, Never>?
private var httpPrefetchTasks: [Task<Void, Never>] = []
private var httpSegments: [String] = []
private var httpSegmentIndex = 0
private var httpEngine: HttpTTS?
private var systemSegments: [String] = []
private var systemSegmentIndex = 0
private var segmentIndexOffset = 0
private var followsSystemRate = true
private var speechRate = 5
private var readByPage = false
private var streamsHTTPAudio = true
private var voiceIdentifier = ""
private var continuesAcrossChapters = false
private var notificationTokens: [NSObjectProtocol] = []
nonisolated(unsafe) private var remoteCommandTargets: [(MPRemoteCommand, Any)] = []
private var resumesAfterInterruption = false
private var isChangingSystemSegment = false
private var ignoresAudioFocus = false
private var pausesForPhoneCalls = false
private var mediaButtonPerNext = false
private var bookName = ""
private var chapterTitle = ""
private var author = ""
var onNextChapter: (() -> Void)?
var onPreviousChapter: (() -> Void)?
var onRemoteStop: (() -> Void)?
override init() {
super.init()
synthesizer.delegate = self
observeAudioSession()
installRemoteCommands()
}
deinit {
notificationTokens.forEach(NotificationCenter.default.removeObserver)
remoteCommandTargets.forEach { command, target in command.removeTarget(target) }
}
func toggle(text: String, continuesAcrossChapters: Bool = false) {
if isSpeaking || isPaused {
stop()
return
}
speak(text, continuesAcrossChapters: continuesAcrossChapters)
}
func toggleHTTP(engine: HttpTTS, text: String, continuesAcrossChapters: Bool = false) {
if isSpeaking || isPaused {
stop()
return
}
speakHTTP(engine: engine, text: text, continuesAcrossChapters: continuesAcrossChapters)
}
func stop() {
synthesizer.stopSpeaking(at: .word)
audioPlayer?.stop()
audioPlayer = nil
streamPlayer?.pause()
streamLoader?.cancel()
streamPlayer = nil
streamLoader = nil
if let streamCompletionToken {
NotificationCenter.default.removeObserver(streamCompletionToken)
self.streamCompletionToken = nil
}
httpTask?.cancel()
httpTask = nil
httpPrefetchTasks.forEach { $0.cancel() }
httpPrefetchTasks.removeAll()
httpSegments = []
httpSegmentIndex = 0
httpEngine = nil
systemSegments = []
systemSegmentIndex = 0
segmentIndexOffset = 0
continuesAcrossChapters = false
isSpeaking = false
isPaused = false
updateNowPlaying()
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
}
func configure(
followsSystemRate: Bool,
speechRate: Int,
readByPage: Bool,
streamsHTTPAudio: Bool = true,
voiceIdentifier: String = "",
ignoresAudioFocus: Bool = false,
pausesForPhoneCalls: Bool = false,
mediaButtonPerNext: Bool = false,
bookName: String = "",
chapterTitle: String = "",
author: String = ""
) {
self.followsSystemRate = followsSystemRate
self.speechRate = min(max(speechRate, 0), 45)
self.readByPage = readByPage
self.streamsHTTPAudio = streamsHTTPAudio
self.voiceIdentifier = voiceIdentifier
self.ignoresAudioFocus = ignoresAudioFocus
self.pausesForPhoneCalls = pausesForPhoneCalls
self.mediaButtonPerNext = mediaButtonPerNext
self.bookName = bookName
self.chapterTitle = chapterTitle
self.author = author
}
func speak(_ text: String, continuesAcrossChapters: Bool = false, segmentIndexOffset: Int = 0) {
stop()
errorMessage = nil
activateAudioSession()
self.continuesAcrossChapters = continuesAcrossChapters
if synthesizer.isSpeaking || synthesizer.isPaused {
synthesizer.stopSpeaking(at: .immediate)
}
guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
systemSegments = segments(for: text)
guard !systemSegments.isEmpty else { return }
systemSegmentIndex = 0
self.segmentIndexOffset = max(segmentIndexOffset, 0)
playCurrentSystemSegment()
}
func speakHTTP(
engine: HttpTTS,
text: String,
continuesAcrossChapters: Bool = false,
segmentIndexOffset: Int = 0
) {
stop()
errorMessage = nil
activateAudioSession()
self.continuesAcrossChapters = continuesAcrossChapters
httpEngine = engine
httpSegments = segments(for: text)
guard !httpSegments.isEmpty else { return }
httpSegmentIndex = 0
self.segmentIndexOffset = max(segmentIndexOffset, 0)
isSpeaking = true
playCurrentHTTPSegment()
prefetchHTTP(engine: engine, texts: Array(httpSegments.dropFirst()))
}
func prefetchHTTP(engine: HttpTTS, text: String, limit: Int = 10) {
let values = Array(segments(for: text).prefix(max(limit, 0)))
prefetchHTTP(engine: engine, texts: values)
}
func pauseOrResume(text: String, continuesAcrossChapters: Bool = false) {
if isPaused {
resumePlayback()
} else if isSpeaking {
pausePlayback()
} else {
speak(text, continuesAcrossChapters: continuesAcrossChapters)
}
}
func pauseOrResumeHTTP(engine: HttpTTS, text: String, continuesAcrossChapters: Bool = false) {
if audioPlayer != nil {
pauseOrResume(text: text, continuesAcrossChapters: continuesAcrossChapters)
} else if isSpeaking {
httpTask?.cancel()
isSpeaking = false
isPaused = true
} else if isPaused, httpEngine != nil {
isPaused = false
isSpeaking = true
playCurrentHTTPSegment()
} else {
speakHTTP(engine: engine, text: text, continuesAcrossChapters: continuesAcrossChapters)
}
}
private func segments(for text: String) -> [String] {
let values = readByPage ? [text] : text.components(separatedBy: .newlines)
return values.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }
}
private func activateAudioSession() {
let session = AVAudioSession.sharedInstance()
try? session.setCategory(.playback, mode: .spokenAudio, options: ignoresAudioFocus ? [.mixWithOthers] : [])
try? session.setActive(true)
}
private func pausePlayback() {
if let audioPlayer, audioPlayer.isPlaying {
audioPlayer.pause()
} else if let streamPlayer {
streamPlayer.pause()
} else if synthesizer.isSpeaking {
synthesizer.pauseSpeaking(at: .word)
} else if httpTask != nil {
httpTask?.cancel()
httpTask = nil
} else {
return
}
isPaused = true
isSpeaking = false
updateNowPlaying()
}
private func resumePlayback() {
activateAudioSession()
if let audioPlayer {
isSpeaking = audioPlayer.play()
} else if let streamPlayer {
streamPlayer.play()
isSpeaking = true
} else if synthesizer.isPaused {
synthesizer.continueSpeaking()
isSpeaking = true
} else if httpEngine != nil, httpSegments.indices.contains(httpSegmentIndex) {
isSpeaking = true
playCurrentHTTPSegment()
} else {
return
}
isPaused = false
updateNowPlaying()
}
private func observeAudioSession() {
let center = NotificationCenter.default
notificationTokens.append(center.addObserver(
forName: AVAudioSession.interruptionNotification,
object: AVAudioSession.sharedInstance(),
queue: .main
) { [weak self] note in
let type = note.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt
let options = note.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt ?? 0
Task { @MainActor in self?.handleAudioInterruption(type: type, options: options) }
})
notificationTokens.append(center.addObserver(
forName: AVAudioSession.routeChangeNotification,
object: AVAudioSession.sharedInstance(),
queue: .main
) { [weak self] note in
let reason = note.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt
Task { @MainActor in self?.handleRouteChange(reason: reason) }
})
}
private func handleAudioInterruption(type raw: UInt?, options rawOptions: UInt) {
guard let raw,
let type = AVAudioSession.InterruptionType(rawValue: raw) else { return }
switch type {
case .began:
if ignoresAudioFocus && !pausesForPhoneCalls { return }
resumesAfterInterruption = isSpeaking
if isSpeaking { pausePlayback() }
case .ended:
if ignoresAudioFocus && !pausesForPhoneCalls { return }
let options = AVAudioSession.InterruptionOptions(rawValue: rawOptions)
if resumesAfterInterruption, options.contains(.shouldResume) { resumePlayback() }
resumesAfterInterruption = false
@unknown default:
break
}
}
private func handleRouteChange(reason raw: UInt?) {
guard let raw,
AVAudioSession.RouteChangeReason(rawValue: raw) == .oldDeviceUnavailable else { return }
if isSpeaking { pausePlayback() }
}
private func installRemoteCommands() {
let center = MPRemoteCommandCenter.shared()
center.playCommand.isEnabled = true
center.pauseCommand.isEnabled = true
center.togglePlayPauseCommand.isEnabled = true
center.stopCommand.isEnabled = true
center.nextTrackCommand.isEnabled = true
center.previousTrackCommand.isEnabled = true
addRemoteTarget(center.playCommand) { $0.resumePlayback() }
addRemoteTarget(center.pauseCommand) { $0.pausePlayback() }
addRemoteTarget(center.togglePlayPauseCommand) { controller in
controller.isPaused ? controller.resumePlayback() : controller.pausePlayback()
}
addRemoteTarget(center.stopCommand) { controller in
controller.stop()
controller.onRemoteStop?()
}
addRemoteTarget(center.nextTrackCommand) { $0.skipForward(usesChapterPreference: true) }
addRemoteTarget(center.previousTrackCommand) { $0.skipBackward(usesChapterPreference: true) }
}
private func addRemoteTarget(_ command: MPRemoteCommand, action: @escaping @MainActor (ReaderSpeechController) -> Void) {
let target = command.addTarget { [weak self] _ in
guard self != nil else { return .noSuchContent }
Task { @MainActor [weak self] in
guard let self else { return }
action(self)
}
return .success
}
remoteCommandTargets.append((command, target))
}
func nextSegment() {
guard isSpeaking || isPaused else { return }
skipForward(usesChapterPreference: false)
}
func previousSegment() {
guard isSpeaking || isPaused else { return }
skipBackward(usesChapterPreference: false)
}
private func playCurrentSystemSegment() {
guard systemSegments.indices.contains(systemSegmentIndex) else {
finishNaturally()
return
}
let utterance = AVSpeechUtterance(string: systemSegments[systemSegmentIndex])
utterance.voice = AVSpeechSynthesisVoice(identifier: voiceIdentifier)
?? AVSpeechSynthesisVoice(language: "zh-CN")
let multiplier = followsSystemRate ? 1 : Float(speechRate + 5) / 10
utterance.rate = min(
max(AVSpeechUtteranceDefaultSpeechRate * multiplier, AVSpeechUtteranceMinimumSpeechRate),
AVSpeechUtteranceMaximumSpeechRate
)
utterance.pitchMultiplier = 1
synthesizer.speak(utterance)
currentSegmentIndex = segmentIndexOffset + systemSegmentIndex
isSpeaking = true
isPaused = false
updateNowPlaying()
}
private func skipForward(usesChapterPreference: Bool) {
if usesChapterPreference && mediaButtonPerNext {
onNextChapter?()
} else if httpEngine != nil, httpSegmentIndex + 1 < httpSegments.count {
stopCurrentHTTPPlayback()
httpSegmentIndex += 1
playCurrentHTTPSegment()
} else if !systemSegments.isEmpty, systemSegmentIndex + 1 < systemSegments.count {
changeSystemSegment(to: systemSegmentIndex + 1)
} else {
onNextChapter?()
}
}
private func skipBackward(usesChapterPreference: Bool) {
if usesChapterPreference && mediaButtonPerNext {
onPreviousChapter?()
} else if httpEngine != nil, httpSegmentIndex > 0 {
stopCurrentHTTPPlayback()
httpSegmentIndex -= 1
playCurrentHTTPSegment()
} else if !systemSegments.isEmpty, systemSegmentIndex > 0 {
changeSystemSegment(to: systemSegmentIndex - 1)
} else {
onPreviousChapter?()
}
}
private func stopCurrentHTTPPlayback() {
audioPlayer?.stop()
audioPlayer = nil
streamPlayer?.pause()
streamLoader?.cancel()
streamPlayer = nil
streamLoader = nil
if let streamCompletionToken {
NotificationCenter.default.removeObserver(streamCompletionToken)
self.streamCompletionToken = nil
}
httpTask?.cancel()
httpTask = nil
}
private func changeSystemSegment(to index: Int) {
isChangingSystemSegment = true
synthesizer.stopSpeaking(at: .immediate)
systemSegmentIndex = index
playCurrentSystemSegment()
DispatchQueue.main.async { [weak self] in self?.isChangingSystemSegment = false }
}
private func updateNowPlaying() {
guard isSpeaking || isPaused else {
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
return
}
MPNowPlayingInfoCenter.default().nowPlayingInfo = [
MPMediaItemPropertyTitle: chapterTitle,
MPMediaItemPropertyAlbumTitle: bookName,
MPMediaItemPropertyArtist: author,
MPNowPlayingInfoPropertyPlaybackRate: isSpeaking ? 1 : 0
]
}
private func playCurrentHTTPSegment() {
guard let engine = httpEngine, httpSegments.indices.contains(httpSegmentIndex) else {
stop()
return
}
let text = httpSegments[httpSegmentIndex]
currentSegmentIndex = segmentIndexOffset + httpSegmentIndex
let rate = speechRate + 5
httpTask?.cancel()
httpTask = Task { [weak self] in
guard let self else { return }
do {
if streamsHTTPAudio {
let stream = try await httpService.audioStream(engine: engine, text: text, speechRate: rate)
try Task.checkCancellation()
if stream.isIncremental, stream.expectedContentLength ?? 0 > 0 {
playIncrementalHTTPStream(stream)
} else {
var data = Data()
for try await chunk in stream.chunks { data.append(chunk) }
try Task.checkCancellation()
try playBufferedHTTPAudio(data)
}
} else {
let audio = try await httpService.audio(engine: engine, text: text, speechRate: rate)
try Task.checkCancellation()
try playBufferedHTTPAudio(audio.data)
}
isPaused = false
updateNowPlaying()
if !isSpeaking { throw CocoaError(.fileReadCorruptFile) }
} catch is CancellationError {
return
} catch {
errorMessage = error.localizedDescription
stop()
}
}
}
private func playBufferedHTTPAudio(_ data: Data) throws {
let player = try AVAudioPlayer(data: data)
player.delegate = self
player.prepareToPlay()
audioPlayer = player
isSpeaking = player.play()
}
private func playIncrementalHTTPStream(_ stream: HttpTTSAudioStream) {
let url = URL(string: "legado-tts-stream://audio/\(UUID().uuidString)")!
let asset = AVURLAsset(url: url)
let loader = IncrementalAudioResourceLoader(stream: stream)
loader.install(on: asset)
let item = AVPlayerItem(asset: asset)
let player = AVPlayer(playerItem: item)
streamLoader = loader
streamPlayer = player
streamCompletionToken = NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime,
object: item,
queue: .main
) { [weak self] _ in
Task { @MainActor in self?.incrementalPlayerDidFinish() }
}
player.play()
isSpeaking = true
}
private func incrementalPlayerDidFinish() {
stopCurrentHTTPPlayback()
httpSegmentIndex += 1
if httpSegmentIndex < httpSegments.count { playCurrentHTTPSegment() }
else { finishNaturally() }
}
private func prefetchHTTP(engine: HttpTTS, texts: [String]) {
guard !texts.isEmpty else { return }
let rate = speechRate + 5
let task = Task { [weak self] in
guard let self else { return }
for text in texts {
guard !Task.isCancelled else { return }
_ = try? await self.httpService.audio(engine: engine, text: text, speechRate: rate)
}
}
httpPrefetchTasks.append(task)
}
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
audioPlayer = nil
guard flag else {
errorMessage = "HTTP TTS 音频播放失败"
stop()
return
}
httpSegmentIndex += 1
if httpSegmentIndex < httpSegments.count {
playCurrentHTTPSegment()
} else {
finishNaturally()
}
}
private func finishNaturally() {
let shouldContinue = continuesAcrossChapters
stop()
if shouldContinue { completionCount += 1 }
}
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didFinish utterance: AVSpeechUtterance) {
guard !isChangingSystemSegment else { return }
systemSegmentIndex += 1
if systemSegmentIndex < systemSegments.count { playCurrentSystemSegment() }
else { finishNaturally() }
}
func speechSynthesizer(_ synthesizer: AVSpeechSynthesizer, didCancel utterance: AVSpeechUtterance) {
guard !isChangingSystemSegment else { return }
guard !synthesizer.isSpeaking else { return }
isSpeaking = false
isPaused = false
updateNowPlaying()
}
}
+88
View File
@@ -0,0 +1,88 @@
import SwiftUI
import UIKit
struct ReaderInteractivePopEnabler: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> Controller {
Controller()
}
func updateUIViewController(_ uiViewController: Controller, context: Context) {
uiViewController.enableInteractivePopIfAvailable()
}
static func dismantleUIViewController(_ uiViewController: Controller, coordinator: Void) {
uiViewController.restoreInteractivePopDelegate()
}
final class Controller: UIViewController {
private weak var popGesture: UIGestureRecognizer?
private var previousDelegate: (any UIGestureRecognizerDelegate)?
private var releasedDelegate = false
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
enableInteractivePopIfAvailable()
}
func enableInteractivePopIfAvailable() {
guard let navigationController,
navigationController.viewControllers.count > 1,
let gesture = navigationController.interactivePopGestureRecognizer else { return }
if gesture.delegate != nil {
previousDelegate = gesture.delegate
gesture.delegate = nil
releasedDelegate = true
}
gesture.isEnabled = true
popGesture = gesture
}
func restoreInteractivePopDelegate() {
guard releasedDelegate, let popGesture, popGesture.delegate == nil else { return }
popGesture.delegate = previousDelegate
previousDelegate = nil
releasedDelegate = false
}
}
}
struct ReaderMenuDismissShape: Shape {
var leadingExclusion: CGFloat = 24
func path(in rect: CGRect) -> Path {
Path(CGRect(
x: min(max(leadingExclusion, 0), rect.width),
y: 0,
width: max(rect.width - leadingExclusion, 0),
height: rect.height
))
}
}
@MainActor enum ReaderScreenOrientation {
static private(set) var supportedOrientations: UIInterfaceOrientationMask = .allButUpsideDown
static func apply(_ value: String) {
let mask: UIInterfaceOrientationMask = switch value {
case "1": .portrait
case "2": .landscape
case "3": .all
case "4": .portraitUpsideDown
case "5": .landscapeLeft
default: .allButUpsideDown
}
supportedOrientations = mask
guard let scene = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first(where: { $0.activationState == .foregroundActive }) else { return }
if #available(iOS 16.0, *) {
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { _ in }
scene.windows.first?.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
} else {
UIViewController.attemptRotationToDeviceOrientation()
}
}
static func restoreApplicationDefault() { apply("0") }
}
+936
View File
@@ -0,0 +1,936 @@
import SwiftUI
import UIKit
private final class ReaderLayoutTextView: UITextView {
var onLayout: ((UITextView) -> Void)?
private var reportedSize = CGSize.zero
private var reportedContentSize = CGSize.zero
override func layoutSubviews() {
super.layoutSubviews()
guard bounds.width > 0, bounds.height > 0,
bounds.size != reportedSize || contentSize != reportedContentSize else { return }
reportedSize = bounds.size
reportedContentSize = contentSize
onLayout?(self)
}
}
struct ReaderTextView: UIViewRepresentable {
let text: String
let title: String
let previousTitle: String?
let previousText: String
let isPreviousReady: Bool
let continuationTitle: String?
let continuationText: String
let isContinuationReady: Bool
let isVolume: Bool
let fontSize: Double
let fontPath: String
let letterSpacing: Double
let lineSpacing: Double
let paragraphSpacing: Double
let paragraphIndent: String
let paddingLeft: Double
let paddingRight: Double
let paddingTop: Double
let paddingBottom: Double
let headerInset: Double
let footerInset: Double
let fontWeight: Int
let titleMode: Int
let titleSize: Double
let titleTopSpacing: Double
let titleBottomSpacing: Double
let underlineMode: Int
let isSelectable: Bool
let fullJustify: Bool
let pageAnimation: Int
let isEInkMode: Bool
let clickActions: [Int]
let backgroundColor: UIColor
let foregroundColor: UIColor
let requestedPosition: Int
let jumpPosition: Int?
let renderRevision: Int
@Binding var readerPosition: Int
@Binding var pageRanges: [NSRange]
@Binding var currentPage: Int
@Binding var totalPages: Int
let isAutoReading: Bool
let isAutoReadPaused: Bool
let isLoadingNextChapter: Bool
let secondsPerScreen: Double
let onReachedEnd: (Int) -> Void
let onReachedStart: (Int) -> Void
let onEnteredPrevious: (Int) -> Void
let onEnteredContinuation: (Int) -> Void
let onDeferredRender: () -> Void
let onCancelNextLoading: () -> Void
let onPageChanged: () -> Void
let onTapAction: (Int) -> Void
func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
func makeUIView(context: Context) -> UITextView {
// Pagination uses NSLayoutManager. Start in TextKit 1 rather than
// switching a TextKit 2 view after its content size has been computed.
let view: ReaderLayoutTextView
if #available(iOS 16.0, *) {
view = ReaderLayoutTextView(usingTextLayoutManager: false)
} else {
view = ReaderLayoutTextView(frame: .zero)
}
view.onLayout = { [weak coordinator = context.coordinator] view in
coordinator?.reportPage(in: view)
coordinator?.reportPageRanges(in: view)
}
view.accessibilityIdentifier = "reader.text"
let effectivePageAnimation = isEInkMode ? 4 : pageAnimation
view.backgroundColor = backgroundColor
view.isOpaque = backgroundColor != .clear
view.delegate = context.coordinator
view.isEditable = false
view.isSelectable = false
view.alwaysBounceVertical = effectivePageAnimation == 3
view.isScrollEnabled = true
view.panGestureRecognizer.isEnabled = effectivePageAnimation == 3
view.textContainer.lineFragmentPadding = 4
view.showsVerticalScrollIndicator = false
view.isPagingEnabled = effectivePageAnimation != 3
view.decelerationRate = effectivePageAnimation == 3 ? .normal : .fast
context.coordinator.installDisplayLink(for: view)
context.coordinator.installGestures(on: view)
context.coordinator.updateInteractionMode(on: view, isScrollMode: effectivePageAnimation == 3)
return view
}
func updateUIView(_ view: UITextView, context: Context) {
context.coordinator.parent = self
let viewportSizeChanged = view.bounds.size != .zero
&& context.coordinator.lastViewportSize != view.bounds.size
if viewportSizeChanged {
context.coordinator.lastViewportSize = view.bounds.size
}
let effectivePageAnimation = isEInkMode ? 4 : pageAnimation
view.isSelectable = false
view.alwaysBounceVertical = effectivePageAnimation == 3
view.isScrollEnabled = true
view.panGestureRecognizer.isEnabled = effectivePageAnimation == 3
view.showsVerticalScrollIndicator = false
view.isPagingEnabled = effectivePageAnimation != 3
view.decelerationRate = effectivePageAnimation == 3 ? .normal : .fast
context.coordinator.updateInteractionMode(on: view, isScrollMode: effectivePageAnimation == 3)
view.backgroundColor = backgroundColor
view.isOpaque = backgroundColor != .clear
view.isSelectable = false
view.selectedRange = NSRange(location: 0, length: 0)
view.resignFirstResponder()
view.textContainerInset = .init(
top: CGFloat(paddingTop + headerInset),
left: CGFloat(paddingLeft),
bottom: CGFloat(paddingBottom + footerInset),
right: CGFloat(paddingRight)
)
let contentKey = text.hashValue
let primaryContentKey = "\(title)|\(text)".hashValue
let showsContinuation = effectivePageAnimation == 3 && isContinuationReady && continuationTitle != nil
let showsPrevious = showsContinuation && isPreviousReady && previousTitle != nil
let adjacentContentKey = "\(showsPrevious)|\(previousTitle ?? "")|\(previousText)|\(showsContinuation)|\(continuationTitle ?? "")|\(continuationText)".hashValue
let displaysTitle = titleMode != 2 || isVolume
let titlePrefix = displaysTitle && !title.isEmpty ? title + "\n" : ""
let styleKey = "\(contentKey)|\(adjacentContentKey)|\(title)|\(isVolume)|\(fontSize)|\(fontPath)|\(letterSpacing)|\(lineSpacing)|\(paragraphSpacing)|\(paragraphIndent)|\(paddingLeft)|\(paddingRight)|\(paddingTop)|\(paddingBottom)|\(headerInset)|\(footerInset)|\(fontWeight)|\(titleMode)|\(titleSize)|\(titleTopSpacing)|\(titleBottomSpacing)|\(underlineMode)|\(isSelectable)|\(fullJustify)|\(effectivePageAnimation)|\(foregroundColor.hash)"
if context.coordinator.styleKey != styleKey {
if context.coordinator.shouldDeferRender(in: view) {
context.coordinator.needsDeferredRender = true
return
}
let primaryContentChanged = context.coordinator.primaryContentKey != primaryContentKey
let visiblePosition = context.coordinator.contentCharacter(at: view.contentOffset, in: view)
context.coordinator.resetAutoReadForPageChange()
context.coordinator.resetPageRangeRetry()
context.coordinator.contentKey = contentKey
context.coordinator.primaryContentKey = primaryContentKey
context.coordinator.styleKey = styleKey
let typography = ReaderTypography(configuration: self)
let bodyAttributes = typography.bodyAttributes
let rendered = NSMutableAttributedString(string: "")
context.coordinator.previousBodyStart = nil
context.coordinator.previousBodyEnd = nil
context.coordinator.continuationSectionStart = nil
context.coordinator.continuationBodyStart = nil
if showsPrevious {
let previousHeading = displaysTitle ? (previousTitle ?? "") + "\n" : ""
rendered.append(NSAttributedString(string: previousHeading, attributes: typography.headingAttributes))
context.coordinator.previousBodyStart = rendered.length
rendered.append(NSAttributedString(
string: previousText.isEmpty ? String(repeating: "\n", count: 80) : previousText,
attributes: bodyAttributes
))
context.coordinator.previousBodyEnd = rendered.length
rendered.append(NSAttributedString(string: "\n\n", attributes: bodyAttributes))
}
context.coordinator.currentSectionStart = rendered.length
if !titlePrefix.isEmpty {
rendered.append(NSAttributedString(string: titlePrefix, attributes: typography.headingAttributes))
}
context.coordinator.renderedPrefixLength = rendered.length
rendered.append(NSAttributedString(string: text, attributes: bodyAttributes))
context.coordinator.currentBodyEnd = rendered.length
if showsContinuation {
rendered.append(NSAttributedString(string: "\n\n", attributes: bodyAttributes))
context.coordinator.continuationSectionStart = rendered.length
let continuationHeading = displaysTitle ? (continuationTitle ?? "") + "\n" : ""
rendered.append(NSAttributedString(string: continuationHeading, attributes: typography.headingAttributes))
context.coordinator.continuationBodyStart = rendered.length
rendered.append(NSAttributedString(
string: continuationText.isEmpty ? String(repeating: "\n", count: 80) : continuationText,
attributes: bodyAttributes
))
}
context.coordinator.isApplyingContent = true
view.attributedText = rendered
context.coordinator.isApplyingContent = false
context.coordinator.scroll(
to: primaryContentChanged ? requestedPosition : visiblePosition,
in: view
)
let generation = context.coordinator.layoutGeneration
DispatchQueue.main.async { [weak coordinator = context.coordinator] in
guard coordinator?.layoutGeneration == generation else { return }
coordinator?.reportPage(in: view)
coordinator?.reportPageRanges(in: view)
}
} else if viewportSizeChanged {
context.coordinator.scroll(to: readerPosition, in: view)
let generation = context.coordinator.layoutGeneration
DispatchQueue.main.async { [weak coordinator = context.coordinator] in
guard coordinator?.layoutGeneration == generation else { return }
coordinator?.reportPage(in: view)
coordinator?.reportPageRanges(in: view)
}
}
if let jumpPosition, context.coordinator.lastJumpPosition != jumpPosition {
context.coordinator.lastJumpPosition = jumpPosition
context.coordinator.scroll(to: jumpPosition, in: view)
} else if jumpPosition == nil {
context.coordinator.lastJumpPosition = nil
}
context.coordinator.updateLoadingFooter(in: view, visible: isLoadingNextChapter)
context.coordinator.updateAutoRead(
isRunning: isAutoReading,
isPaused: isAutoReadPaused,
secondsPerScreen: secondsPerScreen
)
// SwiftUI can reset the bindings after the chapter's TextKit layout has
// already completed. Reconcile them even when the text/style is unchanged.
if view.bounds.width > 0, view.bounds.height > 0 {
context.coordinator.reportPage(in: view)
if pageRanges.isEmpty { context.coordinator.reportPageRanges(in: view) }
}
}
static func dismantleUIView(_ view: UITextView, coordinator: Coordinator) {
coordinator.invalidate()
}
final class Coordinator: NSObject, UITextViewDelegate, UIGestureRecognizerDelegate {
var parent: ReaderTextView
var contentKey = 0
var primaryContentKey = 0
var styleKey = ""
var renderedPrefixLength = 0
var currentSectionStart = 0
var currentBodyEnd = 0
var previousBodyStart: Int?
var previousBodyEnd: Int?
var continuationSectionStart: Int?
var continuationBodyStart: Int?
var lastJumpPosition: Int?
private(set) var layoutGeneration = 0
var lastViewportSize = CGSize.zero
private var isAutoReading = false
private var isAutoReadPaused = false
private var secondsPerScreen = 10.0
private weak var textView: UITextView?
private var displayLink: CADisplayLink?
private var lastTimestamp: CFTimeInterval = 0
private var isUserDragging = false
private var lastDragOffsetY: CGFloat = 0
private var dragDirection = 0
private var didReachEnd = false
private var didReachStart = false
private var didEnterPrevious = false
private var didEnterContinuation = false
private var pendingPreviousPosition: Int?
private var pendingContinuationPosition: Int?
var needsDeferredRender = false
var isApplyingContent = false
private var isTurningPage = false
private var isRenderingAutoPage = false
private var lastReportedPage: Int?
private var autoPageProgress: CGFloat = 0
private var eInkElapsed: CFTimeInterval = 0
private var eInkPageInterval: CFTimeInterval = 10
private var autoPageTarget: CGFloat?
private weak var autoPageOverlay: UIImageView?
private var pageRangeRetryCount = 0
private weak var nextSwipe: UISwipeGestureRecognizer?
private weak var previousSwipe: UISwipeGestureRecognizer?
private weak var readerTap: UITapGestureRecognizer?
private weak var loadingFooter: UIView?
private var didRequestLoadingCancellation = false
init(parent: ReaderTextView) { self.parent = parent }
func installDisplayLink(for view: UITextView) {
textView = view
let link = CADisplayLink(target: self, selector: #selector(step(_:)))
link.add(to: .main, forMode: .common)
displayLink = link
}
func installGestures(on view: UITextView) {
let tap = UITapGestureRecognizer(target: self, action: #selector(tapped(_:)))
tap.delaysTouchesBegan = false
tap.cancelsTouchesInView = false
tap.delegate = self
view.addGestureRecognizer(tap)
readerTap = tap
let next = UISwipeGestureRecognizer(target: self, action: #selector(swiped(_:)))
next.direction = .left
view.addGestureRecognizer(next)
let previous = UISwipeGestureRecognizer(target: self, action: #selector(swiped(_:)))
previous.direction = .right
view.addGestureRecognizer(previous)
nextSwipe = next
previousSwipe = previous
}
func gestureRecognizer(
_ gestureRecognizer: UIGestureRecognizer,
shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer
) -> Bool {
gestureRecognizer === readerTap || otherGestureRecognizer === readerTap
}
func updateInteractionMode(on view: UITextView, isScrollMode: Bool) {
// The scroll reader owns the vertical pan gesture. Horizontal
// swipe recognizers must be disabled there or UIKit can cancel the
// pan before the user reaches the end of a long chapter.
nextSwipe?.isEnabled = !isScrollMode
previousSwipe?.isEnabled = !isScrollMode
view.panGestureRecognizer.isEnabled = isScrollMode
view.alwaysBounceVertical = isScrollMode
view.isPagingEnabled = !isScrollMode
view.showsVerticalScrollIndicator = false
}
func updateLoadingFooter(in view: UITextView, visible: Bool) {
if !visible {
loadingFooter?.removeFromSuperview()
loadingFooter = nil
view.contentInset.bottom = 0
didRequestLoadingCancellation = false
return
}
let footer: UIView
if let loadingFooter {
footer = loadingFooter
} else {
let container = UIView()
container.isUserInteractionEnabled = false
let indicator = UIActivityIndicatorView(style: .medium)
indicator.color = parent.foregroundColor.withAlphaComponent(0.65)
indicator.startAnimating()
let label = UILabel()
label.text = "正在加载下一章…"
label.textColor = parent.foregroundColor.withAlphaComponent(0.65)
label.font = .systemFont(ofSize: 13)
let stack = UIStackView(arrangedSubviews: [indicator, label])
stack.axis = .horizontal
stack.spacing = 8
stack.alignment = .center
stack.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(stack)
NSLayoutConstraint.activate([
stack.centerXAnchor.constraint(equalTo: container.centerXAnchor),
stack.centerYAnchor.constraint(equalTo: container.centerYAnchor)
])
view.addSubview(container)
loadingFooter = container
footer = container
}
view.contentInset.bottom = 64
footer.frame = CGRect(x: 0, y: view.contentSize.height, width: view.bounds.width, height: 52)
}
func shouldDeferRender(in view: UITextView) -> Bool {
parent.pageAnimation == 3 && (isUserDragging || view.isDragging || view.isDecelerating)
}
func invalidate() {
layoutGeneration &+= 1
resetAutoPage()
displayLink?.invalidate()
displayLink = nil
}
func updateAutoRead(isRunning: Bool, isPaused: Bool, secondsPerScreen: Double) {
let wasRunning = isAutoReading
let wasPaused = isAutoReadPaused
self.isAutoReading = isRunning
isAutoReadPaused = isPaused
self.secondsPerScreen = max(1, secondsPerScreen)
if wasRunning && !isRunning {
resetAutoPage()
} else if !wasRunning && isRunning {
didReachEnd = false
lastTimestamp = 0
eInkPageInterval = self.secondsPerScreen
} else if isPaused || wasPaused != isPaused {
lastTimestamp = 0
}
}
func resetAutoReadForPageChange() {
didReachEnd = false
didReachStart = false
lastReportedPage = nil
didEnterPrevious = false
didEnterContinuation = false
pendingPreviousPosition = nil
pendingContinuationPosition = nil
resetAutoPage()
}
func scroll(to characterPosition: Int, in view: UITextView) {
guard !view.text.isEmpty else { return }
layoutGeneration &+= 1
let generation = layoutGeneration
let prefix = characterPosition == 0 && parent.pageAnimation == 3
? currentSectionStart
: renderedPrefixLength
let location = ReaderPositionResolver.renderedLocation(
position: characterPosition,
prefixLength: prefix,
renderedLength: view.text.utf16.count
)
DispatchQueue.main.async { [weak self] in
guard let self, self.layoutGeneration == generation else { return }
view.layoutManager.ensureLayout(for: view.textContainer)
let glyph = view.layoutManager.glyphIndexForCharacter(at: location)
var point = view.layoutManager.boundingRect(
forGlyphRange: NSRange(location: glyph, length: 1),
in: view.textContainer
).origin
point.y += view.textContainerInset.top
let limits = self.scrollLimits(for: view)
let target = min(
max(point.y - view.adjustedContentInset.top, limits.minimum),
limits.maximum
)
view.setContentOffset(CGPoint(x: 0, y: target), animated: false)
}
}
private func readingViewportHeight(for view: UITextView) -> CGFloat {
max(
view.bounds.height
- view.adjustedContentInset.top
- view.adjustedContentInset.bottom
- view.textContainerInset.top
- view.textContainerInset.bottom,
1
)
}
private func scrollLimits(for view: UITextView) -> (minimum: CGFloat, maximum: CGFloat) {
let minimum = -view.adjustedContentInset.top
let maximum = max(
view.contentSize.height - view.bounds.height + view.adjustedContentInset.bottom,
minimum
)
return (minimum, maximum)
}
private func pageMetrics(for view: UITextView) -> (current: Int, total: Int) {
let pageHeight = readingViewportHeight(for: view)
if parent.pageAnimation == 3, currentBodyEnd > currentSectionStart {
view.layoutManager.ensureLayout(for: view.textContainer)
let glyphCount = view.layoutManager.numberOfGlyphs
if glyphCount > 0 {
let first = min(view.layoutManager.glyphIndexForCharacter(at: currentSectionStart), glyphCount - 1)
let last = min(view.layoutManager.glyphIndexForCharacter(at: currentBodyEnd - 1), glyphCount - 1)
let start = view.layoutManager.boundingRect(
forGlyphRange: NSRange(location: first, length: 1), in: view.textContainer
).minY + view.textContainerInset.top - view.adjustedContentInset.top
let end = view.layoutManager.boundingRect(
forGlyphRange: NSRange(location: last, length: 1), in: view.textContainer
).maxY + view.textContainerInset.top - view.adjustedContentInset.top
let total = max(Int(ceil((end - start) / pageHeight)), 1)
let position = max(view.contentOffset.y - start, 0)
return (min(Int(floor(position / pageHeight)) + 1, total), total)
}
}
let limits = scrollLimits(for: view)
let scrollRange = max(limits.maximum - limits.minimum, 0)
let total = max(Int(ceil(scrollRange / pageHeight)) + 1, 1)
guard total > 1 else { return (1, 1) }
let position = min(max(view.contentOffset.y - limits.minimum, 0), scrollRange)
let current = position >= scrollRange - 0.5
? total
: min(Int(floor(position / pageHeight)) + 1, total)
return (current, total)
}
private func renderedCharacter(at offset: CGPoint, in view: UITextView) -> Int {
guard !view.text.isEmpty else { return 0 }
let point = CGPoint(
x: view.textContainerInset.left + 1,
y: offset.y + view.textContainerInset.top + 1
)
let glyphCount = view.layoutManager.numberOfGlyphs
guard glyphCount > 0 else { return 0 }
let glyph = min(view.layoutManager.glyphIndex(for: point, in: view.textContainer), glyphCount - 1)
let character = view.layoutManager.characterIndexForGlyph(at: glyph)
return min(max(character, 0), view.text.utf16.count)
}
func contentCharacter(at offset: CGPoint, in view: UITextView) -> Int {
let character = renderedCharacter(at: offset, in: view)
return min(max(character - renderedPrefixLength, 0), parent.text.utf16.count)
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard !isRenderingAutoPage, !isApplyingContent else { return }
guard let view = scrollView as? UITextView, !view.text.isEmpty else { return }
if parent.isLoadingNextChapter, !didRequestLoadingCancellation {
let textBottom = max(
view.contentSize.height - view.bounds.height
+ view.adjustedContentInset.bottom - view.contentInset.bottom,
-view.adjustedContentInset.top
)
if view.contentOffset.y < textBottom - 44 {
didRequestLoadingCancellation = true
DispatchQueue.main.async { [weak self] in self?.parent.onCancelNextLoading() }
}
}
if isUserDragging {
let delta = scrollView.contentOffset.y - lastDragOffsetY
if abs(delta) > 0.5 { dragDirection = delta > 0 ? 1 : -1 }
lastDragOffsetY = scrollView.contentOffset.y
}
let metrics = pageMetrics(for: view)
reportPage(in: view)
if let lastReportedPage, lastReportedPage != metrics.current {
self.lastReportedPage = metrics.current
let generation = layoutGeneration
DispatchQueue.main.async { [weak self] in
guard let self, self.layoutGeneration == generation else { return }
self.parent.onPageChanged()
}
} else if lastReportedPage == nil {
lastReportedPage = metrics.current
}
let contentCharacter = contentCharacter(at: scrollView.contentOffset, in: view)
let renderedCharacter = renderedCharacter(at: scrollView.contentOffset, in: view)
if let previousBodyStart,
let previousBodyEnd,
renderedCharacter < currentSectionStart {
pendingContinuationPosition = nil
let position = min(max(renderedCharacter - previousBodyStart, 0), max(previousBodyEnd - previousBodyStart - 1, 0))
if isUserDragging || view.isDecelerating {
pendingPreviousPosition = position
return
}
guard !didEnterPrevious else { return }
didEnterPrevious = true
let generation = layoutGeneration
DispatchQueue.main.async { [weak self] in
guard let self, self.layoutGeneration == generation else { return }
self.parent.onEnteredPrevious(position)
}
return
}
if let continuationSectionStart,
let continuationBodyStart,
renderedCharacter >= continuationSectionStart {
pendingPreviousPosition = nil
let position = max(renderedCharacter - continuationBodyStart, 0)
if isUserDragging || view.isDecelerating {
pendingContinuationPosition = position
return
}
guard !didEnterContinuation else { return }
didEnterContinuation = true
let generation = layoutGeneration
DispatchQueue.main.async { [weak self] in
guard let self, self.layoutGeneration == generation else { return }
self.parent.onEnteredContinuation(position)
}
return
}
pendingPreviousPosition = nil
pendingContinuationPosition = nil
guard contentCharacter != parent.readerPosition else { return }
let generation = layoutGeneration
DispatchQueue.main.async { [weak self] in
guard let self, self.layoutGeneration == generation else { return }
self.parent.readerPosition = contentCharacter
}
}
func reportPage(in view: UITextView) {
guard view.bounds.width > 0, view.bounds.height > 0 else { return }
let metrics = pageMetrics(for: view)
#if DEBUG
if ProcessInfo.processInfo.arguments.contains("-uiPagingDiagnostics") {
view.accessibilityLabel = "bounds=\(view.bounds.size) content=\(view.contentSize) used=\(view.layoutManager.usedRect(for: view.textContainer)) mode=\(parent.pageAnimation) pages=\(metrics.total)"
}
#endif
guard metrics.current != parent.currentPage || metrics.total != parent.totalPages else { return }
let generation = layoutGeneration
DispatchQueue.main.async { [weak self] in
guard let self, self.layoutGeneration == generation else { return }
self.parent.currentPage = metrics.current
self.parent.totalPages = metrics.total
}
}
func reportPageRanges(in view: UITextView) {
guard view.bounds.width > 0, view.bounds.height > 0 else { return }
if parent.pageAnimation == 3 {
let ranges = parent.text.isEmpty
? []
: [NSRange(location: 0, length: parent.text.utf16.count)]
guard ranges != parent.pageRanges else { return }
DispatchQueue.main.async { [weak self] in self?.parent.pageRanges = ranges }
return
}
guard !view.text.isEmpty else { return }
view.layoutManager.ensureLayout(for: view.textContainer)
let pageHeight = readingViewportHeight(for: view)
let limits = scrollLimits(for: view)
let metrics = pageMetrics(for: view)
var ranges: [NSRange] = []
for page in 0..<metrics.total {
let offset = page == metrics.total - 1
? limits.maximum
: min(limits.minimum + CGFloat(page) * pageHeight, limits.maximum)
let rect = CGRect(
x: 0,
y: max(0, offset + view.textContainerInset.top),
width: max(view.textContainer.size.width, 1),
height: pageHeight
)
let glyphRange = view.layoutManager.glyphRange(
forBoundingRect: rect,
in: view.textContainer
)
guard glyphRange.length > 0 else { continue }
let characterRange = view.layoutManager.characterRange(
forGlyphRange: glyphRange,
actualGlyphRange: nil
)
let start = max(characterRange.location - renderedPrefixLength, 0)
let end = min(
characterRange.location + characterRange.length - renderedPrefixLength,
parent.text.utf16.count
)
guard end > start else { continue }
let range = NSRange(location: start, length: end - start)
if ranges.last?.location == range.location,
ranges.last?.length == range.length { continue }
ranges.append(range)
}
if ranges.isEmpty {
guard pageRangeRetryCount < 10 else { return }
pageRangeRetryCount += 1
let generation = layoutGeneration
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in
guard let self, self.layoutGeneration == generation else { return }
self.reportPageRanges(in: view)
}
return
}
pageRangeRetryCount = 0
guard ranges != parent.pageRanges else { return }
let generation = layoutGeneration
DispatchQueue.main.async { [weak self] in
guard let self, self.layoutGeneration == generation else { return }
self.parent.pageRanges = ranges
}
}
func resetPageRangeRetry() {
pageRangeRetryCount = 0
}
func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
isUserDragging = true
lastDragOffsetY = scrollView.contentOffset.y
dragDirection = 0
didReachEnd = false
didReachStart = false
}
func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
if parent.pageAnimation == 3, let view = scrollView as? UITextView {
triggerChapterBoundaryIfNeeded(in: view)
}
if !decelerate {
isUserDragging = false
flushPendingChapterTransition()
requestDeferredRenderIfNeeded()
}
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
if parent.pageAnimation == 3, let view = scrollView as? UITextView {
triggerChapterBoundaryIfNeeded(in: view)
}
isUserDragging = false
flushPendingChapterTransition()
requestDeferredRenderIfNeeded()
}
private func requestDeferredRenderIfNeeded() {
guard needsDeferredRender else { return }
needsDeferredRender = false
DispatchQueue.main.async { [weak self] in self?.parent.onDeferredRender() }
}
private func flushPendingChapterTransition() {
let previous = pendingPreviousPosition
let continuation = pendingContinuationPosition
pendingPreviousPosition = nil
pendingContinuationPosition = nil
let generation = layoutGeneration
if let previous, !didEnterPrevious {
didEnterPrevious = true
DispatchQueue.main.async { [weak self] in
guard let self, self.layoutGeneration == generation else { return }
self.parent.onEnteredPrevious(previous)
}
} else if let continuation, !didEnterContinuation {
didEnterContinuation = true
DispatchQueue.main.async { [weak self] in
guard let self, self.layoutGeneration == generation else { return }
self.parent.onEnteredContinuation(continuation)
}
}
}
private func triggerChapterBoundaryIfNeeded(in view: UITextView) {
let (minimum, maximum) = scrollLimits(for: view)
let atEnd = view.contentOffset.y >= maximum - 0.5
let atStart = view.contentOffset.y <= minimum + 0.5
if atEnd, dragDirection > 0, continuationSectionStart == nil, !didReachEnd {
didReachEnd = true
UIImpactFeedbackGenerator(style: .light).impactOccurred()
parent.onReachedEnd(contentCharacter(at: view.contentOffset, in: view))
} else if atStart, dragDirection < 0, previousBodyStart == nil, !didReachStart {
didReachStart = true
UIImpactFeedbackGenerator(style: .light).impactOccurred()
parent.onReachedStart(contentCharacter(at: view.contentOffset, in: view))
}
}
@objc private func tapped(_ gesture: UITapGestureRecognizer) {
guard let view = textView, gesture.state == .ended else { return }
let point = gesture.location(in: view)
// UIScrollView changes its bounds origin as content scrolls, and
// gesture locations use that content coordinate space. Convert
// back to viewport coordinates before dividing the visible screen
// into the 3×3 tap grid.
let viewportX = point.x - view.bounds.minX
let viewportY = point.y - view.bounds.minY
let column = min(max(Int(viewportX / max(view.bounds.width / 3, 1)), 0), 2)
let row = min(max(Int(viewportY / max(view.bounds.height / 3, 1)), 0), 2)
let index = row * 3 + column
let action = index == 4
? 0
: (parent.clickActions.indices.contains(index) ? parent.clickActions[index] : 0)
if parent.pageAnimation == 3, action == 1 || action == 2 {
return
}
switch action {
case 1: turnPage(1)
case 2: turnPage(-1)
case -1: break
default: parent.onTapAction(action)
}
}
@objc private func swiped(_ gesture: UISwipeGestureRecognizer) {
turnPage(gesture.direction == .left ? 1 : -1)
}
private func turnPage(_ direction: Int) {
guard !isTurningPage, let view = textView else { return }
if isAutoReading { resetAutoReadForPageChange() }
let pageHeight = readingViewportHeight(for: view)
let (minimum, maximum) = scrollLimits(for: view)
let target = min(max(view.contentOffset.y + CGFloat(direction) * pageHeight, minimum), maximum)
if direction < 0, view.contentOffset.y <= minimum + 0.5 {
parent.onReachedStart(contentCharacter(at: view.contentOffset, in: view))
return
}
if direction > 0, view.contentOffset.y >= maximum - 0.5 {
parent.onReachedEnd(contentCharacter(at: view.contentOffset, in: view))
return
}
isTurningPage = true
let completion: (Bool) -> Void = { [weak self] _ in self?.isTurningPage = false }
switch parent.isEInkMode ? 4 : parent.pageAnimation {
case 4:
view.setContentOffset(CGPoint(x: 0, y: target), animated: false)
isTurningPage = false
case 0:
let transition = CATransition()
transition.duration = 0.3
transition.type = direction > 0 ? .reveal : .moveIn
transition.subtype = direction > 0 ? .fromRight : .fromLeft
view.layer.add(transition, forKey: "reader-cover")
view.setContentOffset(CGPoint(x: 0, y: target), animated: false)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.31) { [weak self] in self?.isTurningPage = false }
case 1:
let transition = CATransition()
transition.duration = 0.3
transition.type = .push
transition.subtype = direction > 0 ? .fromRight : .fromLeft
view.layer.add(transition, forKey: "reader-slide")
view.setContentOffset(CGPoint(x: 0, y: target), animated: false)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.31) { [weak self] in self?.isTurningPage = false }
case 2:
UIView.transition(
with: view,
duration: 0.38,
options: direction > 0 ? .transitionCurlUp : .transitionCurlDown,
animations: { view.setContentOffset(CGPoint(x: 0, y: target), animated: false) },
completion: completion
)
default:
view.setContentOffset(CGPoint(x: 0, y: target), animated: true)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.32) { [weak self] in self?.isTurningPage = false }
}
}
private func resetAutoPage() {
autoPageProgress = 0
eInkElapsed = 0
eInkPageInterval = secondsPerScreen
autoPageTarget = nil
autoPageOverlay?.removeFromSuperview()
autoPageOverlay = nil
lastTimestamp = 0
}
private func prepareAutoPage(in view: UITextView) -> Bool {
let pageHeight = readingViewportHeight(for: view)
let maximum = scrollLimits(for: view).maximum
guard view.contentOffset.y < maximum - 0.5 else { return false }
let target = min(view.contentOffset.y + pageHeight, maximum)
let originalOffset = view.contentOffset
isRenderingAutoPage = true
view.setContentOffset(CGPoint(x: 0, y: target), animated: false)
view.layoutIfNeeded()
let renderer = UIGraphicsImageRenderer(bounds: view.bounds)
let image = renderer.image { context in view.layer.render(in: context.cgContext) }
view.setContentOffset(originalOffset, animated: false)
isRenderingAutoPage = false
let overlay = UIImageView(image: image)
overlay.isUserInteractionEnabled = false
overlay.contentMode = .top
overlay.clipsToBounds = true
overlay.layer.borderColor = view.tintColor.cgColor
overlay.layer.borderWidth = 0
overlay.frame = CGRect(x: 0, y: originalOffset.y, width: view.bounds.width, height: 0)
view.addSubview(overlay)
autoPageOverlay = overlay
autoPageTarget = target
return true
}
private func advancePagedAutoRead(in view: UITextView, elapsed: CFTimeInterval) {
if autoPageOverlay == nil, !prepareAutoPage(in: view) {
guard !didReachEnd else { return }
didReachEnd = true
parent.onReachedEnd(contentCharacter(at: view.contentOffset, in: view))
return
}
didReachEnd = false
let pageHeight = readingViewportHeight(for: view)
autoPageProgress += pageHeight / CGFloat(secondsPerScreen) * CGFloat(elapsed)
let revealedHeight = min(autoPageProgress, pageHeight)
if let overlay = autoPageOverlay {
overlay.frame = CGRect(
x: 0,
y: view.contentOffset.y,
width: view.bounds.width,
height: revealedHeight
)
overlay.layer.borderWidth = revealedHeight > 0 ? 0.5 : 0
}
guard autoPageProgress >= pageHeight, let target = autoPageTarget else { return }
autoPageOverlay?.removeFromSuperview()
autoPageOverlay = nil
autoPageTarget = nil
autoPageProgress = 0
view.setContentOffset(CGPoint(x: 0, y: target), animated: false)
}
private func advanceEInkAutoRead(in view: UITextView, elapsed: CFTimeInterval) {
eInkElapsed += elapsed
guard eInkElapsed >= eInkPageInterval else { return }
eInkElapsed = 0
eInkPageInterval = secondsPerScreen
let pageHeight = readingViewportHeight(for: view)
let maximum = scrollLimits(for: view).maximum
guard view.contentOffset.y < maximum - 0.5 else {
guard !didReachEnd else { return }
didReachEnd = true
parent.onReachedEnd(contentCharacter(at: view.contentOffset, in: view))
return
}
didReachEnd = false
let target = min(view.contentOffset.y + pageHeight, maximum)
view.setContentOffset(CGPoint(x: 0, y: target), animated: false)
}
@objc private func step(_ link: CADisplayLink) {
guard isAutoReading, !isAutoReadPaused, !isUserDragging, let view = textView else {
lastTimestamp = link.timestamp
return
}
guard lastTimestamp > 0 else {
lastTimestamp = link.timestamp
return
}
let elapsed = link.timestamp - lastTimestamp
lastTimestamp = link.timestamp
if parent.isEInkMode {
advanceEInkAutoRead(in: view, elapsed: elapsed)
return
}
if parent.pageAnimation != 3 {
advancePagedAutoRead(in: view, elapsed: elapsed)
return
}
let maximum = scrollLimits(for: view).maximum
let next = view.contentOffset.y
+ readingViewportHeight(for: view) / CGFloat(secondsPerScreen) * CGFloat(elapsed)
if next >= maximum - 0.5 {
view.setContentOffset(CGPoint(x: 0, y: maximum), animated: false)
guard !didReachEnd else { return }
didReachEnd = true
parent.onReachedEnd(contentCharacter(at: view.contentOffset, in: view))
} else {
didReachEnd = false
view.setContentOffset(CGPoint(x: 0, y: next), animated: false)
}
}
}
}
File diff suppressed because it is too large Load Diff
+611
View File
@@ -0,0 +1,611 @@
import SwiftUI
#if SWIFT_PACKAGE
import LegadoCore
#endif
struct ReaderParagraph: Identifiable, Equatable {
let id: Int
let text: String
}
struct ReaderScrollChapter: Identifiable, Equatable {
let id: Int
let title: String
let content: String
var error: String? = nil
}
enum ReaderPositionResolver {
static func renderedLocation(position: Int, prefixLength: Int, renderedLength: Int) -> Int {
let lastLocation = max(renderedLength, 1) - 1
let prefix = min(max(prefixLength, 0), lastLocation)
return prefix + min(max(position, 0), lastLocation - prefix)
}
}
@MainActor
final class ReaderViewModel: ObservableObject {
@Published private(set) var isLoading = false
@Published private(set) var title: String
@Published private(set) var content = ""
@Published private(set) var diagnostics: [RuleDiagnostic] = []
@Published private(set) var currentIndex: Int
@Published private(set) var chapterLoadGeneration = 0
@Published private(set) var paragraphs: [ReaderParagraph] = []
@Published private(set) var requestedPosition: Int
@Published private(set) var loadedFromCache = false
@Published private(set) var hasLoadedCurrentChapter = false
@Published private(set) var previousTitle = ""
@Published private(set) var previousContent = ""
@Published private(set) var continuationTitle = ""
@Published private(set) var continuationContent = ""
@Published private(set) var isPreviousWindowReady = false
@Published private(set) var isContinuationWindowReady = false
@Published private(set) var scrollChapters: [ReaderScrollChapter] = []
@Published private(set) var scrollRevision = 0
@Published private(set) var isLoadingScrollAhead = false
@Published private(set) var isLoadingScrollBehind = false
@Published private(set) var book: Book
private(set) var chapters: [BookChapter]
private var source: BookSource?
private let localService: LocalBookService
private let sanitizer = ContentSanitizer()
private let contentCoordinator: ChapterContentCoordinator
private var demoChapterContent: String?
private var previousLoadedIndex: Int?
private var continuationLoadedIndex: Int?
@Published private(set) var scrollRetries: Set<Int> = []
private var sourceCacheNeedsInvalidation = false
private var pendingSourceContent: ChapterContentReport?
func prepareContinuousReading(rules: [ReplaceRule]) async {
guard hasLoadedCurrentChapter else { return }
if scrollChapters.isEmpty {
scrollChapters = [scrollChapterForCurrentContent()]
}
async let ahead: Void = extendContinuousReading(direction: 1, rules: rules)
async let behind: Void = extendContinuousReading(direction: -1, rules: rules)
_ = await (ahead, behind)
}
func extendContinuousReading(direction: Int, rules: [ReplaceRule]) async {
let initialEdge = direction > 0 ? scrollChapters.last?.id : scrollChapters.first?.id
guard let initialEdge, readableIndex(from: initialEdge, direction: direction) != nil else { return }
if direction > 0 {
guard !isLoadingScrollAhead else { return }
isLoadingScrollAhead = true
} else {
guard !isLoadingScrollBehind else { return }
isLoadingScrollBehind = true
}
let revision = scrollRevision
defer {
if revision == scrollRevision {
if direction > 0 { isLoadingScrollAhead = false }
else { isLoadingScrollBehind = false }
}
}
for _ in 0..<2 {
let edge = direction > 0 ? scrollChapters.last?.id : scrollChapters.first?.id
guard let edge, let index = readableIndex(from: edge, direction: direction) else { return }
let value = await loadAdjacentChapter(chapters[index], rules: rules)
guard revision == scrollRevision, !Task.isCancelled else { return }
// The viewport may have recycled this edge while the request was
// in flight. Installing its result now would create a chapter gap.
guard scrollChapters.contains(where: { $0.id == edge }) else { return }
let chapter = ReaderScrollChapter(
id: index, title: value.title, content: value.content, error: value.error
)
if !scrollChapters.contains(where: { $0.id == index }) {
scrollChapters.append(chapter)
scrollChapters.sort { $0.id < $1.id }
}
}
}
func retryContinuousChapter(index: Int, rules: [ReplaceRule]) async {
guard chapters.indices.contains(index), !scrollRetries.contains(index) else { return }
let revision = scrollRevision
scrollRetries.insert(index)
defer { if revision == scrollRevision { scrollRetries.remove(index) } }
let value = await loadAdjacentChapter(chapters[index], rules: rules, forceRefresh: true)
guard revision == scrollRevision, !Task.isCancelled,
let slot = scrollChapters.firstIndex(where: { $0.id == index }) else { return }
scrollChapters[slot] = .init(id: index, title: value.title, content: value.content, error: value.error)
if currentIndex == index { setContinuousChapter(scrollChapters[slot]) }
}
@discardableResult
func updateContinuousPosition(chapter index: Int) -> Bool {
guard index != currentIndex,
let chapter = scrollChapters.first(where: { $0.id == index }) else { return false }
setContinuousChapter(chapter)
return true
}
func retainContinuousChapters(_ indices: Set<Int>) {
guard indices.contains(currentIndex) else { return }
scrollChapters.removeAll { !indices.contains($0.id) }
}
private func setContinuousChapter(_ chapter: ReaderScrollChapter) {
currentIndex = chapter.id
title = chapter.title
content = chapter.content
paragraphs = Self.makeParagraphs(chapter.content)
diagnostics = []
hasLoadedCurrentChapter = true
}
private func scrollChapterForCurrentContent() -> ReaderScrollChapter {
.init(id: currentIndex, title: title, content: content,
error: currentChapterError)
}
private func clearContinuousReading() {
scrollRevision &+= 1
scrollChapters = []
isLoadingScrollAhead = false
isLoadingScrollBehind = false
scrollRetries = []
}
init(
book: Book,
chapters: [BookChapter],
initialIndex: Int,
source: BookSource,
service: any ChapterContentLoading = NetworkBookService(),
cache: ChapterContentCache = .live()
) {
let resolvedIndex = Self.validIndex(initialIndex, chapters: chapters)
self.book = book
self.chapters = chapters
currentIndex = resolvedIndex
self.source = source
localService = .init()
contentCoordinator = .init(loader: service, cache: cache)
requestedPosition = resolvedIndex == book.durChapterIndex ? max(book.durChapterPos, 0) : 0
title = chapters.indices.contains(resolvedIndex) ? chapters[resolvedIndex].title : book.name
}
convenience init(demoBook book: Book, chapters: [BookChapter], initialIndex: Int, content: String) {
self.init(book: book, chapters: chapters, initialIndex: initialIndex)
self.content = content
demoChapterContent = content
paragraphs = Self.makeParagraphs(content)
hasLoadedCurrentChapter = true
}
init(
book: Book,
chapters: [BookChapter],
initialIndex: Int,
localService: LocalBookService = .init(),
cache: ChapterContentCache = .live()
) {
let resolvedIndex = Self.validIndex(initialIndex, chapters: chapters)
self.book = book
self.chapters = chapters
currentIndex = resolvedIndex
source = nil
self.localService = localService
contentCoordinator = .init(cache: cache)
requestedPosition = resolvedIndex == book.durChapterIndex ? max(book.durChapterPos, 0) : 0
title = chapters.indices.contains(resolvedIndex) ? chapters[resolvedIndex].title : book.name
}
var currentChapter: BookChapter? { chapters.indices.contains(currentIndex) ? chapters[currentIndex] : nil }
var nextChapter: BookChapter? { nextReadableChapter(after: currentIndex) }
var canGoPrevious: Bool { navigationIndex(direction: -1) != nil }
var canGoNext: Bool { navigationIndex(direction: 1) != nil }
var hasPreviousWindow: Bool { previousLoadedIndex != nil }
var hasContinuationWindow: Bool { continuationLoadedIndex != nil }
var canEditContent: Bool { source != nil }
var usesReplaceRules: Bool { book.readConfig?.useReplaceRule ?? true }
var currentChapterError: String? {
guard hasLoadedCurrentChapter, !isLoading,
content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
currentChapter?.isVolume != true else { return nil }
return scrollChapters.first(where: { $0.id == currentIndex })?.error
?? diagnostics.first(where: { $0.severity == .error })?.message
?? (source != nil ? "当前书源没有返回正文。" : nil)
}
func bookForSourceSwitch(position: Int) -> Book {
var current = book
current.durChapterIndex = currentIndex
current.durChapterTitle = currentChapter?.title
current.durChapterPos = max(position, 0)
current.totalChapterNum = chapters.count
return current
}
func applySourceSwitch(_ report: BookSourceSwitchReport, source: BookSource) {
book = report.book
chapters = report.chapters
self.source = source
demoChapterContent = nil
sourceCacheNeedsInvalidation = true
select(index: Self.validIndex(report.book.durChapterIndex, chapters: chapters), position: report.book.durChapterPos)
pendingSourceContent = report.verifiedContent
}
func load(rules: [ReplaceRule], forceRefresh: Bool = false, prefetchNextChapter: Bool = true) async {
guard (content.isEmpty || forceRefresh), !isLoading else { return }
guard let chapter = currentChapter else { return }
let requestedIndex = currentIndex
let requestedGeneration = chapterLoadGeneration
if forceRefresh {
pendingSourceContent = nil
clearContinuousReading()
content = ""
paragraphs = []
diagnostics = []
hasLoadedCurrentChapter = false
}
isLoading = true
defer {
if requestedGeneration == chapterLoadGeneration { isLoading = false }
}
let report: ChapterContentReport
let fromCache: Bool
if sourceCacheNeedsInvalidation {
await contentCoordinator.invalidate(bookURL: book.bookUrl)
guard requestedGeneration == chapterLoadGeneration, !Task.isCancelled else { return }
sourceCacheNeedsInvalidation = false
}
if let demoChapterContent {
fromCache = true
report = .init(title: chapter.title, content: demoChapterContent)
} else if let verified = pendingSourceContent, let source, !forceRefresh {
pendingSourceContent = nil
fromCache = false
report = verified
await contentCoordinator.saveContent(verified, book: book, chapter: chapter, source: source)
} else if let source {
let nextURL = nextReadableChapter(after: requestedIndex)?.url
let result = await contentCoordinator.load(
book: book,
chapter: chapter,
source: source,
nextChapterURL: nextURL,
forceRefresh: forceRefresh
)
fromCache = result.loadedFromCache
report = result.report
} else {
fromCache = false
do {
report = try await Task.detached { [localService, book, chapter] in
try localService.loadContent(book: book, chapter: chapter)
}.value
} catch {
report = .init(diagnostics: [
.init(severity: .error, code: "local.read", message: error.localizedDescription, rule: book.bookUrl)
])
}
}
guard requestedGeneration == chapterLoadGeneration, !Task.isCancelled else { return }
loadedFromCache = fromCache
if prefetchNextChapter, !fromCache, let source { schedulePreload(after: requestedIndex, source: source) }
hasLoadedCurrentChapter = true
var effectiveChapter = chapter
if let resolvedTitle = report.title { effectiveChapter.title = resolvedTitle }
let processed = sanitizer.process(
book: book,
chapter: effectiveChapter,
content: report.content,
rules: rules
)
title = processed.title
content = processed.content
paragraphs = Self.makeParagraphs(processed.content)
diagnostics = report.diagnostics + processed.diagnostics
}
func prepareAdjacentWindows() {
let previousIndex = navigationIndex(direction: -1)
if previousLoadedIndex != previousIndex {
previousLoadedIndex = previousIndex
previousTitle = previousIndex.map { chapters[$0].title } ?? ""
previousContent = ""
isPreviousWindowReady = false
}
let nextIndex = navigationIndex(direction: 1)
if continuationLoadedIndex != nextIndex {
continuationLoadedIndex = nextIndex
continuationTitle = nextIndex.map { chapters[$0].title } ?? ""
continuationContent = ""
isContinuationWindowReady = false
}
}
func loadAdjacentChapters(rules: [ReplaceRule]) async {
prepareAdjacentWindows()
let requestedIndex = currentIndex
let requestedGeneration = chapterLoadGeneration
let previousIndex = previousLoadedIndex
let nextIndex = continuationLoadedIndex
let needsPrevious = previousIndex != nil && !isPreviousWindowReady
let needsNext = nextIndex != nil && !isContinuationWindowReady
async let previousValue: (title: String, content: String, error: String?)? = needsPrevious
? loadAdjacentChapter(chapters[previousIndex!], rules: rules)
: nil
async let nextValue: (title: String, content: String, error: String?)? = needsNext
? loadAdjacentChapter(chapters[nextIndex!], rules: rules)
: nil
let resolvedNext = await nextValue
guard requestedGeneration == chapterLoadGeneration, requestedIndex == currentIndex, !Task.isCancelled else { return }
if continuationLoadedIndex == nextIndex, needsNext {
continuationTitle = resolvedNext?.title ?? nextIndex.map { chapters[$0].title } ?? ""
continuationContent = resolvedNext?.content ?? ""
isContinuationWindowReady = true
}
let resolvedPrevious = await previousValue
guard requestedGeneration == chapterLoadGeneration, requestedIndex == currentIndex, !Task.isCancelled else { return }
if previousLoadedIndex == previousIndex, needsPrevious {
previousTitle = resolvedPrevious?.title ?? previousIndex.map { chapters[$0].title } ?? ""
previousContent = resolvedPrevious?.content ?? ""
isPreviousWindowReady = true
}
}
private func loadAdjacentChapter(
_ chapter: BookChapter,
rules: [ReplaceRule],
forceRefresh: Bool = false
) async -> (title: String, content: String, error: String?) {
let report: ChapterContentReport
if let demoChapterContent {
#if DEBUG
if ProcessInfo.processInfo.arguments.contains("-uiScrollRegression") {
try? await Task.sleep(nanoseconds: 80_000_000)
}
#endif
report = .init(title: chapter.title, content: demoChapterContent)
} else if let source {
report = await contentCoordinator.load(
book: book,
chapter: chapter,
source: source,
nextChapterURL: nextReadableChapter(after: chapter.index)?.url,
forceRefresh: forceRefresh
).report
} else {
do {
report = try await Task.detached { [localService, book, chapter] in
try localService.loadContent(book: book, chapter: chapter)
}.value
} catch {
return (chapter.title, "", error.localizedDescription)
}
}
var effectiveChapter = chapter
if let resolvedTitle = report.title { effectiveChapter.title = resolvedTitle }
let processed = sanitizer.process(
book: book,
chapter: effectiveChapter,
content: report.content,
rules: rules
)
let error = processed.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
? (report.diagnostics + processed.diagnostics).first(where: { $0.severity == .error })?.message
?? (source != nil && !chapter.isVolume ? "当前书源没有返回正文。" : nil)
: nil
return (processed.title, processed.content, error)
}
func saveEditedContent(_ value: String) async throws {
guard let chapter = currentChapter, source != nil else { return }
try await contentCoordinator.saveEditedContent(book: book, chapter: chapter, title: title, content: value)
content = value
paragraphs = Self.makeParagraphs(value)
loadedFromCache = true
if let slot = scrollChapters.firstIndex(where: { $0.id == currentIndex }) {
scrollChapters[slot] = scrollChapterForCurrentContent()
}
}
func nextSpeechContent(rules: [ReplaceRule]) async -> String? {
guard let chapter = nextReadableChapter(after: currentIndex) else { return nil }
let report: ChapterContentReport
if let source {
report = await contentCoordinator.load(
book: book,
chapter: chapter,
source: source,
nextChapterURL: nextReadableChapter(after: chapter.index)?.url
).report
} else {
do {
report = try await Task.detached { [localService, book, chapter] in
try localService.loadContent(book: book, chapter: chapter)
}.value
} catch {
return nil
}
}
var effectiveChapter = chapter
if let resolvedTitle = report.title { effectiveChapter.title = resolvedTitle }
let value = sanitizer.process(
book: book,
chapter: effectiveChapter,
content: report.content,
rules: rules
).content
return value.isEmpty ? nil : value
}
@discardableResult
func toggleReplaceRules() -> Book {
var config = book.readConfig ?? BookReadConfig()
config.useReplaceRule = !usesReplaceRules
book.readConfig = config
content = ""
pendingSourceContent = nil
paragraphs = []
diagnostics = []
loadedFromCache = false
hasLoadedCurrentChapter = false
clearAdjacentChapters()
return book
}
@discardableResult
func useGlobalPageAnimation() -> Book {
var config = book.readConfig ?? BookReadConfig()
config.pageAnim = -1
book.readConfig = config
return book
}
@discardableResult
func setTTSEngine(_ value: String?) -> Book {
var config = book.readConfig ?? BookReadConfig()
config.ttsEngine = value
book.readConfig = config
return book
}
func goPrevious(position: Int = 0) { if let value = navigationIndex(direction: -1) { select(index: value, position: position) } }
func goNext(position: Int = 0) { if let value = navigationIndex(direction: 1) { select(index: value, position: position) } }
func promoteContinuation(position: Int) {
guard let value = navigationIndex(direction: 1), hasContinuationWindow else {
return
}
let oldIndex = currentIndex
let oldTitle = title
let oldContent = content
let promotedContentResolved = isContinuationWindowReady
currentIndex = value
title = continuationTitle.isEmpty ? chapters[value].title : continuationTitle
content = continuationContent
paragraphs = Self.makeParagraphs(continuationContent)
diagnostics = []
requestedPosition = max(position, 0)
loadedFromCache = promotedContentResolved
hasLoadedCurrentChapter = promotedContentResolved
previousLoadedIndex = oldIndex
previousTitle = oldTitle
previousContent = oldContent
isPreviousWindowReady = true
continuationLoadedIndex = nil
continuationTitle = ""
continuationContent = ""
isContinuationWindowReady = false
isLoading = false
chapterLoadGeneration &+= 1
}
func promotePrevious(position: Int) {
guard let value = navigationIndex(direction: -1), hasPreviousWindow else { return }
let oldIndex = currentIndex
let oldTitle = title
let oldContent = content
let promotedContentResolved = isPreviousWindowReady
currentIndex = value
title = previousTitle.isEmpty ? chapters[value].title : previousTitle
content = previousContent
paragraphs = Self.makeParagraphs(previousContent)
diagnostics = []
requestedPosition = max(position, 0)
loadedFromCache = promotedContentResolved
hasLoadedCurrentChapter = promotedContentResolved
continuationLoadedIndex = oldIndex
continuationTitle = oldTitle
continuationContent = oldContent
isContinuationWindowReady = true
previousLoadedIndex = nil
previousTitle = ""
previousContent = ""
isPreviousWindowReady = false
isLoading = false
chapterLoadGeneration &+= 1
}
func select(index: Int, position: Int = 0) {
guard chapters.indices.contains(index), !chapters[index].isVolume else { return }
currentIndex = index
pendingSourceContent = nil
title = chapters[index].title
content = ""
paragraphs = []
diagnostics = []
requestedPosition = max(position, 0)
loadedFromCache = false
hasLoadedCurrentChapter = false
clearAdjacentChapters()
isLoading = false
chapterLoadGeneration &+= 1
}
private func clearAdjacentChapters() {
clearContinuousReading()
previousLoadedIndex = nil
previousTitle = ""
previousContent = ""
isPreviousWindowReady = false
continuationLoadedIndex = nil
continuationTitle = ""
continuationContent = ""
isContinuationWindowReady = false
}
func paragraphID(at position: Int) -> Int? {
paragraphs.last(where: { $0.id <= position })?.id ?? paragraphs.first?.id
}
private func navigationIndex(direction: Int) -> Int? {
readableIndex(from: currentIndex, direction: direction)
}
private func readableIndex(from index: Int, direction: Int) -> Int? {
var value = index + direction
while chapters.indices.contains(value) {
if !chapters[value].isVolume { return value }
value += direction
}
return nil
}
private func nextReadableChapter(after index: Int) -> BookChapter? {
var value = index + 1
while chapters.indices.contains(value) {
if !chapters[value].isVolume { return chapters[value] }
value += 1
}
return nil
}
private func schedulePreload(after index: Int, source: BookSource) {
let count = ApplicationSettingsStore.shared.settings.prefetchChapters
let jobs = ChapterCachePlanner.plan(chapters: chapters, startIndex: index + 1, limit: count)
guard index + 1 < chapters.count, !jobs.isEmpty else { return }
Task { [book, contentCoordinator] in
for job in jobs {
guard !Task.isCancelled else { return }
await contentCoordinator.prefetch(book: book, chapter: job.chapter, source: source, nextChapterURL: job.nextChapterURL)
}
}
}
private static func makeParagraphs(_ content: String) -> [ReaderParagraph] {
var position = 0
var output: [ReaderParagraph] = []
for line in content.components(separatedBy: .newlines) {
if !line.isEmpty { output.append(.init(id: position, text: line)) }
position += line.utf16.count + 1
}
if output.isEmpty, !content.isEmpty { output.append(.init(id: 0, text: content)) }
return output
}
private static func validIndex(_ requested: Int, chapters: [BookChapter]) -> Int {
if chapters.indices.contains(requested), !chapters[requested].isVolume { return requested }
return chapters.firstIndex(where: { !$0.isVolume }) ?? 0
}
}
@@ -0,0 +1,225 @@
import SwiftUI
import AVFoundation
import Vision
import VisionKit
struct QRCodeImportView: View {
@Environment(\.dismiss) private var dismiss
let onValue: (String) -> Void
@State private var cameraAuthorized = false
@State private var cameraMessage = "正在检查相机…"
@State private var values: [String] = []
@State private var errorMessage: String?
@State private var isReading = false
@State private var scanError: String?
@State private var delivered = false
var body: some View {
ReadingNavigationStack {
VStack(spacing: 16) {
if cameraAuthorized, scanError == nil {
cameraScanner
.clipShape(RoundedRectangle(cornerRadius: 18))
.overlay(alignment: .bottom) {
Text("将书源或净化规则二维码放入取景框").font(.footnote)
.padding(10).background(.ultraThinMaterial, in: Capsule()).padding()
}
} else {
ReadingUnavailableView("相机扫描暂不可用", systemImage: "qrcode.viewfinder",
description: Text(scanError ?? cameraMessage))
if AVCaptureDevice.authorizationStatus(for: .video) == .denied {
Link("打开系统设置", destination: URL(string: UIApplication.openSettingsURLString)!)
}
}
if isReading { ProgressView("识别图片中的二维码…") }
ReadingPhotoPicker { data in
readPhoto(data)
} onError: { error in
errorMessage = error.localizedDescription
} label: {
Label("从照片选择二维码", systemImage: "photo")
.frame(maxWidth: .infinity)
}.buttonStyle(.borderedProminent).disabled(isReading)
Text("支持 HTTP(S) 链接、Legado 导入深链和 JSON 内容。识别后进入导入流程。")
.font(.footnote).foregroundStyle(.secondary)
if values.count > 1 {
List(values, id: \.self) { value in
Button { accept(value) } label: { Text(value).font(.caption).lineLimit(3) }
}.frame(maxHeight: 220)
}
}
.padding()
.navigationTitle("二维码导入")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } } }
.task {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized: cameraAuthorized = true
case .notDetermined: cameraAuthorized = await AVCaptureDevice.requestAccess(for: .video)
default: break
}
cameraMessage = cameraAuthorized ? "当前设备不支持实时扫描,请从照片选择二维码。" : "未获得相机权限,仍可从照片选择二维码。"
}
.alert("识别失败", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) {
Button("好", role: .cancel) {}
} message: { Text(errorMessage ?? "") }
}
}
@ViewBuilder
private var cameraScanner: some View {
if #available(iOS 16.0, *), !ReadingCompatibility.usesLegacyUI,
DataScannerViewController.isSupported, DataScannerViewController.isAvailable {
QRScanner { accept($0) } onError: { scanError = $0 }
} else {
LegacyQRScanner { accept($0) } onError: { scanError = $0 }
}
}
private func readPhoto(_ data: Data) {
Task {
isReading = true
defer { isReading = false }
do {
let found = try await Task.detached(priority: .userInitiated) {
try QRCodeDecoder().detect(in: data)
}.value
values = found
if found.count == 1 { accept(found[0]) }
else if found.isEmpty { errorMessage = "图片中没有识别到二维码,请选择清晰的原图。" }
} catch { errorMessage = error.localizedDescription }
}
}
private func accept(_ value: String) {
guard !delivered else { return }
delivered = true
dismiss()
Task { @MainActor in
try? await Task.sleep(nanoseconds: 400_000_000)
onValue(value)
}
}
}
@available(iOS 16.0, *)
private struct QRScanner: UIViewControllerRepresentable {
let onValue: (String) -> Void
let onError: (String) -> Void
func makeCoordinator() -> Coordinator { Coordinator(onValue: onValue) }
func makeUIViewController(context: Context) -> DataScannerViewController {
let scanner = DataScannerViewController(recognizedDataTypes: [.barcode(symbologies: [.qr])],
qualityLevel: .balanced, recognizesMultipleItems: false, isHighFrameRateTrackingEnabled: false,
isPinchToZoomEnabled: true, isGuidanceEnabled: true, isHighlightingEnabled: true)
scanner.delegate = context.coordinator
return scanner
}
func updateUIViewController(_ scanner: DataScannerViewController, context: Context) {
guard !scanner.isScanning else { return }
do { try scanner.startScanning() }
catch { Task { @MainActor in onError(error.localizedDescription) } }
}
static func dismantleUIViewController(_ scanner: DataScannerViewController, coordinator: Coordinator) { scanner.stopScanning() }
final class Coordinator: NSObject, DataScannerViewControllerDelegate {
let onValue: (String) -> Void
init(onValue: @escaping (String) -> Void) { self.onValue = onValue }
func dataScanner(_ dataScanner: DataScannerViewController, didAdd addedItems: [RecognizedItem], allItems: [RecognizedItem]) {
for item in addedItems {
if case .barcode(let code) = item, let value = code.payloadStringValue {
dataScanner.stopScanning()
onValue(value)
return
}
}
}
}
}
private struct LegacyQRScanner: UIViewControllerRepresentable {
let onValue: (String) -> Void
let onError: (String) -> Void
func makeUIViewController(context: Context) -> Controller {
Controller(onValue: onValue, onError: onError)
}
func updateUIViewController(_ controller: Controller, context: Context) {}
static func dismantleUIViewController(_ controller: Controller, coordinator: Void) {
controller.capture.stop()
}
final class Controller: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
let capture = VideoCapture()
let onValue: (String) -> Void
let onError: (String) -> Void
private var preview: AVCaptureVideoPreviewLayer?
private var didDeliver = false
init(onValue: @escaping (String) -> Void, onError: @escaping (String) -> Void) {
self.onValue = onValue
self.onError = onError
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
do {
guard let device = AVCaptureDevice.default(for: .video) else { throw AVError(.deviceNotConnected) }
let input = try AVCaptureDeviceInput(device: device)
let output = AVCaptureMetadataOutput()
capture.session.beginConfiguration()
defer { capture.session.commitConfiguration() }
guard capture.session.canAddInput(input), capture.session.canAddOutput(output) else {
throw AVError(.deviceInUseByAnotherApplication)
}
capture.session.addInput(input)
capture.session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: .main)
output.metadataObjectTypes = [.qr]
let preview = AVCaptureVideoPreviewLayer(session: capture.session)
preview.videoGravity = .resizeAspectFill
view.layer.addSublayer(preview)
self.preview = preview
} catch {
let message = error.localizedDescription
DispatchQueue.main.async { self.onError(message) }
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
preview?.frame = view.bounds
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if preview != nil { capture.start() }
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
capture.stop()
}
nonisolated func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput objects: [AVMetadataObject], from connection: AVCaptureConnection) {
guard let value = objects.compactMap({ ($0 as? AVMetadataMachineReadableCodeObject)?.stringValue }).first else { return }
Task { @MainActor [weak self] in
guard let self, !didDeliver else { return }
didDeliver = true
capture.stop()
onValue(value)
}
}
}
final class VideoCapture: @unchecked Sendable {
let session = AVCaptureSession()
private let queue = DispatchQueue(label: "com.epiboly.legado.qr-camera")
func start() { queue.async { self.session.startRunning() } }
func stop() { queue.async { self.session.stopRunning() } }
}
}
@@ -0,0 +1,190 @@
import SwiftUI
import UIKit
struct SyntaxCodeEditor: UIViewRepresentable {
enum Language { case json, rule, javascript, regex }
@Binding var text: String
var language: Language = .rule
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeUIView(context: Context) -> UITextView {
let view = UITextView()
view.delegate = context.coordinator
view.font = .monospacedSystemFont(ofSize: 14, weight: .regular)
view.backgroundColor = .secondarySystemGroupedBackground
view.textContainerInset = UIEdgeInsets(top: 14, left: 12, bottom: 20, right: 12)
view.autocorrectionType = .no
view.autocapitalizationType = .none
view.smartQuotesType = .no
view.smartDashesType = .no
view.smartInsertDeleteType = .no
view.keyboardDismissMode = .interactive
view.alwaysBounceVertical = true
view.accessibilityIdentifier = "syntaxCodeEditor"
if #available(iOS 16.0, *) { view.isFindInteractionEnabled = true }
let bar = UIToolbar()
bar.sizeToFit()
bar.items = [
UIBarButtonItem(image: UIImage(systemName: "magnifyingglass"), primaryAction: UIAction(title: "", subtitle: nil) { [weak view, coordinator = context.coordinator] _ in
guard let view else { return }
if #available(iOS 16.0, *) { view.findInteraction?.presentFindNavigator(showingReplace: true) }
else { coordinator.presentFindAndReplace(in: view) }
}),
UIBarButtonItem(image: UIImage(systemName: "arrow.uturn.backward"), primaryAction: UIAction(title: "", subtitle: nil) { [weak view] _ in view?.undoManager?.undo() }),
UIBarButtonItem(image: UIImage(systemName: "arrow.uturn.forward"), primaryAction: UIAction(title: "", subtitle: nil) { [weak view] _ in view?.undoManager?.redo() }),
] + ["@", "$", "{}", "[]", "##", "\n", "\t"].map { symbol in
UIBarButtonItem(title: symbol == "\n" ? "↵" : symbol == "\t" ? "⇥" : symbol,
primaryAction: UIAction(title: "", subtitle: nil) { [weak view] _ in view?.insertText(symbol) })
} + [UIBarButtonItem(systemItem: .flexibleSpace), UIBarButtonItem(title: "完成", primaryAction: UIAction(title: "", subtitle: nil) { [weak view] _ in view?.resignFirstResponder() })]
view.inputAccessoryView = bar
view.text = text
context.coordinator.highlight(view)
return view
}
func updateUIView(_ view: UITextView, context: Context) {
context.coordinator.parent = self
guard view.markedTextRange == nil else { return }
// SwiftUI can render an older binding snapshot while the next native
// keystroke is already in the text view. Wait for acknowledgement of
// the latest local edit instead of replacing it with that snapshot.
if let pending = context.coordinator.pendingLocalText {
guard text == pending else { return }
context.coordinator.pendingLocalText = nil
}
if view.text != text {
let selection = view.selectedRange
view.text = text
view.selectedRange = NSRange(location: min(selection.location, view.text.utf16.count), length: 0)
}
context.coordinator.highlight(view)
}
final class Coordinator: NSObject, UITextViewDelegate {
var parent: SyntaxCodeEditor
private var highlightedText: String?
private var highlightedStyle: UIUserInterfaceStyle?
private var highlightedLanguage: Language?
var pendingLocalText: String?
init(_ parent: SyntaxCodeEditor) { self.parent = parent }
func presentFindAndReplace(in view: UITextView) {
guard var presenter = view.window?.rootViewController else { return }
while let presented = presenter.presentedViewController { presenter = presented }
let alert = UIAlertController(title: "查找与替换", message: nil, preferredStyle: .alert)
alert.addTextField { $0.placeholder = "查找内容" }
alert.addTextField { $0.placeholder = "替换为(可留空)" }
alert.addAction(UIAlertAction(title: "查找下一个", style: .default) { [weak view, weak alert] _ in
guard let view, let query = alert?.textFields?.first?.text, !query.isEmpty else { return }
let text = (view.text ?? "") as NSString
let start = min(NSMaxRange(view.selectedRange), text.length)
var range = text.range(of: query, range: NSRange(location: start, length: text.length - start))
if range.location == NSNotFound { range = text.range(of: query) }
guard range.location != NSNotFound else { return }
view.becomeFirstResponder()
view.selectedRange = range
view.scrollRangeToVisible(range)
})
alert.addAction(UIAlertAction(title: "全部替换", style: .default) { [weak view, weak alert] _ in
guard let view, let fields = alert?.textFields,
let query = fields.first?.text, !query.isEmpty else { return }
let original = view.text ?? ""
let replacement = original.replacingOccurrences(of: query, with: fields.last?.text ?? "")
guard replacement != original else { return }
view.becomeFirstResponder()
view.selectedRange = NSRange(location: 0, length: original.utf16.count)
view.insertText(replacement)
})
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
presenter.present(alert, animated: true)
}
func textViewDidChange(_ view: UITextView) {
guard view.markedTextRange == nil else { return }
pendingLocalText = view.text
parent.text = view.text
highlight(view)
}
func textViewDidEndEditing(_ view: UITextView) {
pendingLocalText = nil
parent.text = view.text
highlight(view)
}
func highlight(_ view: UITextView) {
guard view.markedTextRange == nil,
highlightedText != view.text || highlightedStyle != view.traitCollection.userInterfaceStyle || highlightedLanguage != parent.language else { return }
highlightedText = view.text
highlightedStyle = view.traitCollection.userInterfaceStyle
highlightedLanguage = parent.language
let storage = view.textStorage
let selection = view.selectedRange
let all = NSRange(location: 0, length: storage.length)
storage.beginEditing()
storage.addAttributes([.font: UIFont.monospacedSystemFont(ofSize: 14, weight: .regular), .foregroundColor: UIColor.label], range: all)
if storage.length <= 500_000 {
let tokens: [(String, UIColor)] = [
(#"\b(?:true|false|null|undefined|return|var|let|const|if|else|function|new|for|while|try|catch|throw|await|async)\b"#, .systemPurple),
(#"\b\d+(?:\.\d+)?\b"#, .systemOrange),
(#"@(?:css|xpath|json|js|put|get):?|\$\.|&&|\|\||%%|##|\{\{|\}\}"#, .systemPink),
(#"[{}\[\]()]"#, .systemTeal),
(#""(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`"#, .systemGreen),
(#""(?:\\.|[^"\\])*"(?=\s*:)"#, .systemBlue),
]
for (pattern, color) in tokens {
guard let regex = try? NSRegularExpression(pattern: pattern) else { continue }
regex.enumerateMatches(in: view.text, range: all) { result, _, _ in
if let result { storage.addAttribute(.foregroundColor, value: color, range: result.range) }
}
}
if parent.language == .regex {
for pattern in [#"[.*+?^$|]"#, #"\\[dDsSwWbB0-9]|\{[0-9,]+\}"#] {
let regex = try? NSRegularExpression(pattern: pattern)
regex?.enumerateMatches(in: view.text, range: all) { result, _, _ in
if let result { storage.addAttribute(.foregroundColor, value: UIColor.systemPink, range: result.range) }
}
}
}
if parent.language == .javascript {
let pattern = #"(?m)^\s*//.*$|/\*[\s\S]*?\*/"#
let regex = try? NSRegularExpression(pattern: pattern)
regex?.enumerateMatches(in: view.text, range: all) { result, _, _ in
if let result { storage.addAttribute(.foregroundColor, value: UIColor.secondaryLabel, range: result.range) }
}
}
}
storage.endEditing()
view.selectedRange = selection
view.typingAttributes = [.font: UIFont.monospacedSystemFont(ofSize: 14, weight: .regular), .foregroundColor: UIColor.label]
}
}
}
struct RuleCodeFieldView: View {
let title: String
let path: String
@Binding var text: String
var language: SyntaxCodeEditor.Language = .rule
var help: String?
var body: some View {
VStack(spacing: 0) {
Text(path).font(.caption.monospaced()).foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading).padding(12)
if let help {
Text(help).font(.footnote).foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal, 12).padding(.bottom, 10)
}
SyntaxCodeEditor(text: $text, language: language)
HStack {
Text("\(text.components(separatedBy: "\n").count) 行 · \(text.utf16.count) 字符")
Spacer()
if language == .rule { Text("CSS · XPath · JSONPath · JS") }
}.font(.caption).foregroundStyle(.secondary).padding(12)
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
}
}
@@ -0,0 +1,185 @@
import SwiftUI
import UIKit
struct ReplaceRuleEditorView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var library: ReplaceRuleLibraryModel
@State private var rule: ReplaceRule
@State private var mode: Int
@State private var testTitle = "第一章 开始"
@State private var testContent = "在这里粘贴需要净化的正文。"
@State private var testBook = ""
@State private var testSource = ""
@State private var testResult: ReplaceRuleTestResult?
@State private var errorMessage: String?
@State private var isTesting = false
@State private var isSaving = false
@State private var showsDiscard = false
private let original: ReplaceRule
init(rule: ReplaceRule, startsWithTest: Bool = false) {
original = rule
_rule = State(initialValue: rule)
_mode = State(initialValue: startsWithTest ? 1 : 0)
}
var body: some View {
ReadingNavigationStack {
VStack(spacing: 0) {
Picker("模式", selection: $mode) { Text("编辑规则").tag(0); Text("测试规则").tag(1) }
.pickerStyle(.segmented).padding()
Form { if mode == 0 { configuration } else { testForm } }
}
.navigationTitle(rule.name.isEmpty ? "净化规则" : rule.name)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") { if rule != original { showsDiscard = true } else { dismiss() } }
}
ToolbarItem(placement: .topBarTrailing) {
Menu("更多", systemImage: "ellipsis.circle") {
Button("复制规则 JSON", systemImage: "doc.on.doc") { UIPasteboard.general.string = json }
Button("粘贴规则 JSON", systemImage: "doc.on.clipboard") {
do {
guard let text = UIPasteboard.general.string else { return }
var pasted = try JSONDecoder().decode(ReplaceRule.self, from: Data(text.utf8))
pasted.id = rule.id
pasted.sortOrder = rule.sortOrder
rule = pasted
} catch { errorMessage = error.localizedDescription }
}
ReadingShareLink(item: json) { Label("分享规则", systemImage: "square.and.arrow.up") }
}
}
ToolbarItem(placement: .confirmationAction) {
Button(isSaving ? "保存中" : "保存") {
isSaving = true
Task {
let saved = await library.save(rule)
isSaving = false
if saved { dismiss() }
else {
let message = library.notice?.detail ?? "保存失败"
library.notice = nil
errorMessage = message
}
}
}.disabled(isSaving || isTesting)
}
}
.alert("规则提示", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) {
Button("好", role: .cancel) {}
} message: { Text(errorMessage ?? "") }
.confirmationDialog("放弃未保存的修改?", isPresented: $showsDiscard, titleVisibility: .visible) {
Button("放弃修改", role: .destructive) { dismiss() }
}
.onChange(of: rule) { _ in testResult = nil }
.onChange(of: testTitle + testContent + testBook + testSource) { _ in testResult = nil }
}
.interactiveDismissDisabled(rule != original || isSaving)
}
private var configuration: some View {
Group {
Section("基本信息") {
TextField("名称", text: $rule.name)
TextField("分组", text: optional(\.group))
Toggle("启用", isOn: $rule.isEnabled)
}
Section("匹配与替换") {
Toggle("使用正则表达式", isOn: $rule.isRegex)
NavigationLink {
RuleCodeFieldView(title: "匹配内容", path: "pattern", text: $rule.pattern, language: .regex)
} label: { fieldPreview("匹配内容", value: rule.pattern) }
NavigationLink {
RuleCodeFieldView(title: "替换为", path: "replacement", text: $rule.replacement, language: .regex)
} label: { fieldPreview("替换为(留空即删除)", value: rule.replacement) }
Text("正则替换使用 $1、$2 引用捕获组;普通文本替换按字面匹配。")
.font(.footnote).foregroundStyle(.secondary)
}
Section("作用范围") {
Toggle("章节标题", isOn: $rule.scopeTitle)
Toggle("正文", isOn: $rule.scopeContent)
ReadingTextField("限定书名或书源(留空为全部)", text: optional(\.scope), axis: .vertical)
ReadingTextField("排除书名或书源", text: optional(\.excludeScope), axis: .vertical)
}
Section("执行设置") {
HStack {
Text("超时 (ms)")
TextField("3000", value: $rule.timeoutMillisecond, format: .number.grouping(.never))
.keyboardType(.numbersAndPunctuation).multilineTextAlignment(.trailing)
}
HStack {
Text("排序编号")
TextField("0", value: $rule.sortOrder, format: .number.grouping(.never))
.keyboardType(.numbersAndPunctuation).multilineTextAlignment(.trailing)
}
Text("超时小于等于 0 时使用 3000 ms。单次正则最多运行 60 秒。规则按排序编号依次执行;资源超限或超时会保留原文并报告错误。")
.font(.footnote).foregroundStyle(.secondary)
}
}
}
private var testForm: some View {
Group {
Section("测试上下文") {
TextField("书名(用于作用范围判断)", text: $testBook)
TextField("书源地址(用于作用范围判断)", text: $testSource).textInputAutocapitalization(.never)
TextField("章节标题", text: $testTitle)
NavigationLink {
RuleCodeFieldView(title: "测试正文", path: "测试输入", text: $testContent)
} label: { fieldPreview("正文", value: testContent) }
}
Section {
Button { runTest() } label: {
HStack { Label(isTesting ? "测试中…" : "运行当前草稿", systemImage: "play.fill"); Spacer(); if isTesting { ProgressView() } }
}.disabled(isTesting)
if !rule.isEnabled { Text("规则处于停用状态,结果将保持原文。可在编辑页启用后测试。").font(.footnote).foregroundStyle(.secondary) }
}
if let result = testResult {
Section("测试结果 · \(result.elapsedMilliseconds.formatted(.number.precision(.fractionLength(1)))) ms") {
Label(result.report.effectiveRuleIDs.isEmpty ? "未改变文本(请检查匹配、启用状态及作用范围)" : "规则已生效",
systemImage: result.report.effectiveRuleIDs.isEmpty ? "info.circle" : "checkmark.circle")
ReadingLabeledContent("标题", value: result.report.title)
Text(result.report.content.isEmpty ? "(正文为空)" : result.report.content)
.font(.body.monospaced()).textSelection(.enabled)
}
if !result.report.diagnostics.isEmpty {
Section("执行错误") {
ForEach(result.report.diagnostics) { diagnostic in Text(diagnostic.message).foregroundStyle(.red) }
}
}
}
}
}
private var json: String {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
return (try? encoder.encode(rule)).map { String(decoding: $0, as: UTF8.self) } ?? "{}"
}
private func optional(_ path: WritableKeyPath<ReplaceRule, String?>) -> Binding<String> {
Binding(get: { rule[keyPath: path] ?? "" }, set: { rule[keyPath: path] = $0.isEmpty ? nil : $0 })
}
private func fieldPreview(_ title: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 5) {
Text(title)
Text(value.isEmpty ? "未填写" : value).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(3)
}
}
private func runTest() {
let snapshot = rule
let title = testTitle, content = testContent, book = testBook, source = testSource
isTesting = true
testResult = nil
Task {
do {
let result = try await Task.detached(priority: .userInitiated) {
try ReplaceRuleTester().test(rule: snapshot, title: title, content: content, bookName: book, sourceURL: source)
}.value
if rule == snapshot, title == testTitle, content == testContent, book == testBook, source == testSource { testResult = result }
} catch { errorMessage = error.localizedDescription }
isTesting = false
}
}
}
@@ -0,0 +1,141 @@
#if SWIFT_PACKAGE
import LegadoCore
#endif
import Foundation
@MainActor
final class ReplaceRuleLibraryModel: ObservableObject {
@Published private(set) var rules: [ReplaceRule] = []
@Published var notice: Notice?
struct Notice: Identifiable {
enum Kind { case success, error }
let id = UUID()
let kind: Kind
let title: String
let detail: String
}
private let repository: any ReplaceRuleRepository
private let importer = ReplaceRuleImporter()
init(repository: any ReplaceRuleRepository) { self.repository = repository }
static func live() -> ReplaceRuleLibraryModel {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Legado", isDirectory: true)
return ReplaceRuleLibraryModel(repository: FileReplaceRuleRepository(fileURL: base.appendingPathComponent("replace-rules.json")))
}
static func demo() -> ReplaceRuleLibraryModel {
ReplaceRuleLibraryModel(repository: InMemoryReplaceRuleRepository())
}
func load() async {
do { rules = try await repository.load() }
catch { notice = .init(kind: .error, title: "净化规则读取失败", detail: error.localizedDescription) }
}
func importFiles(_ urls: [URL]) async {
do {
var updated = rules
var count = 0
for url in urls {
let granted = url.startAccessingSecurityScopedResource()
defer { if granted { url.stopAccessingSecurityScopedResource() } }
let incoming = try importer.decode(Data(contentsOf: url, options: .mappedIfSafe))
updated = importer.merge(incoming, into: updated)
count += incoming.count
}
try await repository.save(updated)
rules = updated
notice = .init(kind: .success, title: "净化规则已导入", detail: "本次读取 \(count) 条,当前共 \(rules.count) 条。")
} catch {
notice = .init(kind: .error, title: "净化规则导入失败", detail: error.localizedDescription)
}
}
func importRemoteURL(_ value: String) async { await importText(value) }
func importText(_ value: String) async {
do {
let input = try RuleImportInput.parse(value, kind: "replaceRule")
let data: Data
switch input {
case .json(let value): data = Data(value.utf8)
case .remote:
let (body, response) = try await URLSessionHTTPTransport.shared.download(for: input.request()!)
if let response = response as? HTTPURLResponse, !(200...299).contains(response.statusCode) { throw URLError(.badServerResponse) }
data = body
}
let incoming = try importer.decode(data)
let updated = importer.merge(incoming, into: rules)
try await repository.save(updated)
rules = updated
notice = .init(kind: .success, title: "净化规则已导入", detail: "本次读取 \(incoming.count) 条,当前共 \(rules.count) 条。")
} catch { notice = .init(kind: .error, title: "净化规则导入失败", detail: error.localizedDescription) }
}
func newRule(copying original: ReplaceRule? = nil) -> ReplaceRule {
let now = Int64(Date().timeIntervalSince1970 * 1_000)
let maximumID = rules.map(\.id).max() ?? 0
let id = max(now, maximumID < Int64.max ? maximumID + 1 : now)
var rule = original ?? ReplaceRule(id: id, name: "", pattern: "")
rule.id = id
if original != nil { rule.name += " 副本" }
rule.sortOrder = (rules.map(\.sortOrder).filter { $0 != Int.min && $0 < Int.max }.max() ?? -1) + 1
return rule
}
func save(_ rule: ReplaceRule) async -> Bool {
do { try ReplaceRuleValidator.validate(rule) }
catch { notice = .init(kind: .error, title: "规则无效", detail: error.localizedDescription); return false }
return await persist(importer.merge([rule], into: rules))
}
func move(from offsets: IndexSet, to destination: Int) async {
var updated = rules
updated.move(fromOffsets: offsets, toOffset: destination)
for index in updated.indices { updated[index].sortOrder = index }
await persist(updated)
}
func remove(id: Int64) async { await persist(rules.filter { $0.id != id }) }
func applySilentSubscription(_ data: Data) async throws {
let incoming = try importer.decode(data)
let changed = incoming.filter { value in
guard let old = rules.first(where: { $0.id == value.id }) else { return true }
return old.pattern != value.pattern || old.replacement != value.replacement
}
guard !changed.isEmpty else { return }
let updated = importer.merge(changed, into: rules)
try await repository.save(updated)
rules = updated
}
func setEnabled(_ enabled: Bool, for id: Int64) async {
guard let index = rules.firstIndex(where: { $0.id == id }) else { return }
var updated = rules
updated[index].isEnabled = enabled
await persist(updated)
}
func remove(at offsets: IndexSet) async {
var updated = rules
updated.remove(atOffsets: offsets)
await persist(updated)
}
@discardableResult
private func persist(_ updated: [ReplaceRule]) async -> Bool {
do {
try await repository.save(updated)
rules = updated
return true
} catch {
notice = .init(kind: .error, title: "净化规则保存失败", detail: error.localizedDescription)
return false
}
}
}
@@ -0,0 +1,130 @@
import SwiftUI
import UniformTypeIdentifiers
struct ReplaceRuleManagementView: View {
@EnvironmentObject private var library: ReplaceRuleLibraryModel
@State private var showsImporter = false
@State private var showsTextImporter = false
@State private var showsQRImporter = false
@State private var editingRule: ReplaceRule?
@State private var startsWithTest = false
@State private var query = ""
@State private var group: String?
private var filtered: [ReplaceRule] {
library.rules.filter { rule in
(group == nil || rule.group == group) && (query.isEmpty || rule.name.localizedCaseInsensitiveContains(query)
|| rule.pattern.localizedCaseInsensitiveContains(query) || (rule.group?.localizedCaseInsensitiveContains(query) ?? false))
}
}
var body: some View {
List {
if library.rules.isEmpty {
ReadingUnavailableView("还没有净化规则", systemImage: "wand.and.stars", description: Text("新建规则,或导入 Legado 净化规则。"))
Button("新建规则", systemImage: "plus") { edit(library.newRule()) }
} else {
Section {
ReadingLabeledContent("已启用", value: "\(library.rules.filter(\.isEnabled).count) / \(library.rules.count)")
Picker("分组", selection: $group) {
Text("全部").tag(String?.none)
ForEach(Array(Set(library.rules.compactMap(\.group))).sorted(), id: \.self) { Text($0).tag(String?.some($0)) }
}
}
Section {
ForEach(filtered) { rule in
HStack(spacing: 12) {
Button { edit(rule) } label: {
HStack(spacing: 12) {
Image(systemName: rule.isRegex ? "textformat.alt" : "character.cursor.ibeam")
.foregroundStyle(rule.isEnabled ? ReadingTheme.accent : .secondary)
VStack(alignment: .leading, spacing: 4) {
Text(rule.name.isEmpty ? rule.pattern : rule.name).foregroundStyle(.primary).lineLimit(1)
Text(rule.group ?? (rule.scopeTitle && rule.scopeContent ? "标题与正文" : rule.scopeTitle ? "标题" : "正文"))
.font(.caption).foregroundStyle(.secondary)
Text(rule.pattern).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1)
}
Spacer()
}
}.buttonStyle(.plain)
Toggle("启用", isOn: Binding(get: { rule.isEnabled }, set: { value in Task { await library.setEnabled(value, for: rule.id) } }))
.labelsHidden().accessibilityLabel("启用\(rule.name)")
}
.contextMenu {
Button("编辑", systemImage: "pencil") { edit(rule) }
Button("测试", systemImage: "play") { edit(rule, test: true) }
Button("复制", systemImage: "doc.on.doc") { edit(library.newRule(copying: rule)) }
Button("删除", systemImage: "trash", role: .destructive) { Task { await library.remove(id: rule.id) } }
}
}
.onDelete { offsets in
let ids = offsets.compactMap { filtered.indices.contains($0) ? filtered[$0].id : nil }
Task { for id in ids { await library.remove(id: id) } }
}
.onMove { offsets, destination in Task { await library.move(from: offsets, to: destination) } }
.moveDisabled(group != nil || !query.isEmpty)
} footer: { Text("点击规则编辑,长按可复制或测试。拖动排序决定执行顺序。").font(.footnote) }
}
}
.listStyle(.insetGrouped)
.navigationTitle("净化规则")
.searchable(text: $query, prompt: "名称、匹配内容或分组")
.toolbar {
ToolbarItem(placement: .topBarTrailing) { EditButton().disabled(library.rules.isEmpty || group != nil || !query.isEmpty) }
ToolbarItem(placement: .primaryAction) {
Menu("更多", systemImage: "plus") {
Button("新建规则", systemImage: "plus") { edit(library.newRule()) }
Button("从文件导入", systemImage: "doc.badge.plus") { showsImporter = true }
Button("URL 或粘贴 JSON", systemImage: "link.badge.plus") { showsTextImporter = true }
Button("二维码导入", systemImage: "qrcode.viewfinder") { showsQRImporter = true }
ReadingShareLink(item: exportJSON) { Label("导出全部规则", systemImage: "square.and.arrow.up") }
}
}
}
.sheet(item: $editingRule) { ReplaceRuleEditorView(rule: $0, startsWithTest: startsWithTest).environmentObject(library) }
.sheet(isPresented: $showsQRImporter) { QRCodeImportView { value in Task { await library.importText(value) } } }
.sheet(isPresented: $showsTextImporter) { ReplaceRuleTextImportView().environmentObject(library) }
.fileImporter(isPresented: $showsImporter, allowedContentTypes: [.item], allowsMultipleSelection: true) { result in
switch result {
case .success(let urls): Task { await library.importFiles(urls) }
case .failure(let error): library.notice = .init(kind: .error, title: "无法选择文件", detail: error.localizedDescription)
}
}
.alert(item: $library.notice) { notice in
Alert(title: Text(notice.title), message: Text(notice.detail), dismissButton: .default(Text("好")))
}
}
private func edit(_ rule: ReplaceRule, test: Bool = false) { startsWithTest = test; editingRule = rule }
private var exportJSON: String {
let encoder = JSONEncoder(); encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return (try? encoder.encode(library.rules)).map { String(decoding: $0, as: UTF8.self) } ?? "[]"
}
}
private struct ReplaceRuleTextImportView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var library: ReplaceRuleLibraryModel
@State private var value = ""
var body: some View {
ReadingNavigationStack {
VStack(alignment: .leading, spacing: 12) {
Text("粘贴净化规则 JSON、HTTP(S) 链接或 Legado 净化规则深链。").font(.footnote).foregroundStyle(.secondary)
SyntaxCodeEditor(text: $value, language: .json)
}.padding()
.navigationTitle("导入净化规则").navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
ToolbarItem(placement: .confirmationAction) {
Button("导入") {
let text = value
dismiss()
Task {
try? await Task.sleep(nanoseconds: 400_000_000)
await library.importText(text)
}
}.disabled(value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
}
}
@@ -0,0 +1,162 @@
import SwiftUI
@MainActor
final class RuleSubscriptionModel: ObservableObject {
@Published private(set) var values: [RuleSub] = []
@Published var errorMessage: String?
@Published private(set) var pendingIDs = Set<Int64>()
private let repository: SQLiteConfigurationRepository?
init() {
repository = (try? LegadoDatabase(url: LegadoDatabase.defaultURL())).map { SQLiteConfigurationRepository(database: $0) }
}
func load() async {
guard let repository else { errorMessage = "无法打开规则订阅数据库"; return }
do { values = try await repository.loadRuleSubs().sorted { $0.customOrder < $1.customOrder } }
catch { errorMessage = error.localizedDescription }
}
func save(_ value: RuleSub) async -> Bool {
guard !value.url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { errorMessage = "URL不能为空"; return false }
guard !values.contains(where: { $0.id != value.id && $0.url == value.url }) else { errorMessage = "URL已存在"; return false }
var updated = values
if let index = updated.firstIndex(where: { $0.id == value.id }) { updated[index] = value }
else { var item = value; item.customOrder = (updated.map(\.customOrder).max() ?? -1) + 1; updated.append(item) }
return await persist(updated)
}
func remove(at offsets: IndexSet) async { _ = await persist(values.enumerated().filter { !offsets.contains($0.offset) }.map(\.element)) }
func move(from offsets: IndexSet, to destination: Int) async {
var updated = values; updated.move(fromOffsets: offsets, toOffset: destination)
for index in updated.indices { updated[index].customOrder = index + 1 }
_ = await persist(updated)
}
func checkAutomaticUpdates(bookSources: SourceLibraryModel, rssSources: RSSLibraryModel, replaceRules: ReplaceRuleLibraryModel) async {
let now = Int64(Date().timeIntervalSince1970 * 1_000)
for index in values.indices where values[index].autoUpdate && values[index].type != 1 {
let interval = Int64(max(values[index].updateInterval, 0)) * 3_600_000
guard values[index].update + interval <= now else { continue }
var subscription = values[index]
subscription.update = now
var timestamped = values; timestamped[index] = subscription
guard await persist(timestamped) else { continue }
do {
let data = try await fetch(subscription.url)
if subscription.silentUpdate {
switch subscription.type {
case 1: try await rssSources.applySilentSubscription(data)
case 2: try await replaceRules.applySilentSubscription(data)
default: try await bookSources.applySilentSubscription(data)
}
} else if try hasUpdates(data, type: subscription.type, bookSources: bookSources, rssSources: rssSources, replaceRules: replaceRules) {
pendingIDs.insert(subscription.id)
}
} catch { errorMessage = "\(subscription.name.isEmpty ? subscription.url : subscription.name):\(error.localizedDescription)" }
}
}
func clearPending(_ id: Int64) { pendingIDs.remove(id) }
private func fetch(_ value: String) async throws -> Data {
let withoutUA = value.hasSuffix("#requestWithoutUA")
let raw = withoutUA ? String(value.dropLast("#requestWithoutUA".count)) : value
guard let url = URL(string: raw) else { throw URLError(.badURL) }
var request = URLRequest(url: url)
if withoutUA { request.setValue("", forHTTPHeaderField: "User-Agent") }
let (data, response) = try await URLSessionHTTPTransport.shared.download(for: request)
if let response = response as? HTTPURLResponse, !(200...299).contains(response.statusCode) { throw URLError(.badServerResponse) }
return data
}
private func hasUpdates(_ data: Data, type: Int, bookSources: SourceLibraryModel, rssSources: RSSLibraryModel, replaceRules: ReplaceRuleLibraryModel) throws -> Bool {
switch type {
case 1:
let incoming = try RSSSourceImporter().decode(data).sources
guard incoming.first?.sourceUrl.isEmpty == false else { throw RSSSourceImportError.invalidSource }
return incoming.contains { value in rssSources.sources.first(where: { $0.sourceUrl == value.sourceUrl }).map { $0.lastUpdateTime < value.lastUpdateTime } ?? true }
case 2:
let incoming = try ReplaceRuleImporter().decode(data)
return incoming.contains { value in
guard let old = replaceRules.rules.first(where: { $0.id == value.id }) else { return true }
return old.pattern != value.pattern || old.replacement != value.replacement
}
default:
let incoming = try BookSourceImporter().decode(data)
guard incoming.first?.bookSourceUrl.isEmpty == false else { throw BookSourceImportError.emptyDocument }
return incoming.contains { value in bookSources.sources.first(where: { $0.bookSourceUrl == value.bookSourceUrl }).map { $0.lastUpdateTime < value.lastUpdateTime } ?? true }
}
}
private func persist(_ updated: [RuleSub]) async -> Bool {
guard let repository else { errorMessage = "无法打开规则订阅数据库"; return false }
do { try await repository.saveRuleSubs(updated); values = try await repository.loadRuleSubs().sorted { $0.customOrder < $1.customOrder }; return true }
catch { errorMessage = error.localizedDescription; return false }
}
}
struct RuleSubscriptionView: View {
@EnvironmentObject private var bookSources: SourceLibraryModel
@EnvironmentObject private var replaceRules: ReplaceRuleLibraryModel
@EnvironmentObject private var model: RuleSubscriptionModel
@State private var editing: RuleSub?
private var visibleValues: [RuleSub] { model.values.filter { $0.type != 1 } }
var body: some View {
List {
if visibleValues.isEmpty { ReadingUnavailableView("没有规则订阅", systemImage: "link.badge.plus") }
ForEach(visibleValues) { value in
Button { open(value) } label: {
HStack(spacing: 12) {
Text(typeName(value.type)).font(.caption.bold()).foregroundStyle(ReadingTheme.accent).frame(width: 48)
VStack(alignment: .leading, spacing: 4) { Text(value.name.isEmpty ? value.url : value.name).foregroundStyle(.primary); Text(value.url).font(.caption).foregroundStyle(.secondary).lineLimit(1) }
Spacer()
if value.autoUpdate { Image(systemName: value.silentUpdate ? "arrow.triangle.2.circlepath.circle.fill" : "arrow.triangle.2.circlepath.circle").foregroundStyle(.secondary) }
if model.pendingIDs.contains(value.id) { Text("有更新").font(.caption2).foregroundStyle(.orange) }
}
}.swipeActions(edge: .leading) { Button("编辑", systemImage: "pencil") { editing = value }.tint(.blue) }
}.onDelete { offsets in
let ids = Set(offsets.compactMap { visibleValues.indices.contains($0) ? visibleValues[$0].id : nil })
let sourceOffsets = IndexSet(model.values.indices.filter { ids.contains(model.values[$0].id) })
Task { await model.remove(at: sourceOffsets) }
}
}
.navigationTitle("规则订阅")
.toolbar { ToolbarItem(placement: .primaryAction) { Button("新建", systemImage: "plus") { editing = RuleSub() } }; ToolbarItem(placement: .primaryAction) { EditButton() } }
.task { await model.load() }
.sheet(item: $editing) { value in RuleSubscriptionEditView(value: value) { item in await model.save(item) } }
.alert("规则订阅", isPresented: Binding(get: { model.errorMessage != nil }, set: { if !$0 { model.errorMessage = nil } })) { Button("好") { model.errorMessage = nil } } message: { Text(model.errorMessage ?? "") }
}
private func open(_ value: RuleSub) {
model.clearPending(value.id)
Task {
switch value.type {
case 2: await replaceRules.importRemoteURL(value.url)
default: await bookSources.importRemoteURL(value.url)
}
}
}
private func typeName(_ type: Int) -> String { type == 2 ? "净化" : "书源" }
}
private struct RuleSubscriptionEditView: View {
@Environment(\.dismiss) private var dismiss
@State private var value: RuleSub
@State private var saving = false
let save: (RuleSub) async -> Bool
init(value: RuleSub, save: @escaping (RuleSub) async -> Bool) { _value = State(initialValue: value); self.save = save }
var body: some View {
ReadingNavigationStack {
Form {
Picker("类型", selection: $value.type) { Text("书源").tag(0); Text("净化规则").tag(2) }
TextField("名称", text: $value.name)
ReadingTextField("订阅URL", text: $value.url, axis: .vertical).textInputAutocapitalization(.never).disableAutocorrection(true)
Toggle("自动更新", isOn: $value.autoUpdate)
if value.autoUpdate {
Stepper("更新间隔:\(value.updateInterval) 小时", value: $value.updateInterval, in: 1...720)
Toggle("静默更新", isOn: $value.silentUpdate)
}
}.navigationTitle("规则订阅").toolbar {
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
ToolbarItem(placement: .confirmationAction) { Button(saving ? "保存中…" : "保存") { saving = true; Task { if await save(value) { dismiss() }; saving = false } }.disabled(saving || value.url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) }
}
}
}
}
@@ -0,0 +1,224 @@
import SwiftUI
import UniformTypeIdentifiers
@MainActor
final class TxtTocRuleManagementModel: ObservableObject {
@Published private(set) var rules: [TxtTocRule] = []
@Published var message: String?
private let repository: SQLiteConfigurationRepository?
init() {
repository = (try? LegadoDatabase(url: LegadoDatabase.defaultURL())).map {
SQLiteConfigurationRepository(database: $0)
}
}
func load() async {
guard let repository else { message = "无法打开目录规则数据库"; return }
do {
rules = try await repository.loadTxtTocRules().sorted { $0.serialNumber < $1.serialNumber }
if rules.isEmpty { await importDefaults(report: false) }
}
catch { message = error.localizedDescription }
}
func importDefaults(report: Bool = true) async {
let defaults = TxtTocRuleDefaults.load()
guard !defaults.isEmpty else { message = "应用内置目录规则缺失"; return }
var updated = rules.filter { $0.id >= 0 }
updated.append(contentsOf: defaults)
if await persist(updated), report { message = "已恢复内置目录规则" }
}
func save(_ rule: TxtTocRule) async -> Bool {
guard !rule.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
!rule.rule.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || rule.id == -100 else {
message = "名称和正则不能为空"
return false
}
guard rule.rule.isEmpty || (try? NSRegularExpression(pattern: rule.rule, options: .anchorsMatchLines)) != nil else {
message = "目录正则语法错误"
return false
}
var updated = rules
if let index = updated.firstIndex(where: { $0.id == rule.id }) { updated[index] = rule }
else { var value = rule; value.serialNumber = (updated.map(\.serialNumber).max() ?? 0) + 1; updated.append(value) }
return await persist(updated)
}
func toggle(_ rule: TxtTocRule) async {
var value = rule; value.enable.toggle(); _ = await save(value)
}
func remove(at offsets: IndexSet) async {
_ = await persist(rules.enumerated().filter { !offsets.contains($0.offset) }.map(\.element))
}
func move(from offsets: IndexSet, to destination: Int) async {
var updated = rules
updated.move(fromOffsets: offsets, toOffset: destination)
for index in updated.indices { updated[index].serialNumber = index + 1 }
_ = await persist(updated)
}
func importData(_ data: Data) async {
do {
let decoded: [TxtTocRule]
if let values = try? JSONDecoder().decode([TxtTocRule].self, from: data) { decoded = values }
else { decoded = [try JSONDecoder().decode(TxtTocRule.self, from: data)] }
var merged = rules
for value in decoded {
if let index = merged.firstIndex(where: { $0.id == value.id }) { merged[index] = value }
else { merged.append(value) }
}
for index in merged.indices { merged[index].serialNumber = index + 1 }
if await persist(merged) { message = "已导入 \(decoded.count) 条目录规则" }
} catch { message = error.localizedDescription }
}
private func persist(_ values: [TxtTocRule]) async -> Bool {
guard let repository else { message = "无法打开目录规则数据库"; return false }
do {
try await repository.saveTxtTocRules(values)
rules = try await repository.loadTxtTocRules().sorted { $0.serialNumber < $1.serialNumber }
return true
} catch { message = error.localizedDescription; return false }
}
}
struct TxtTocRuleManagementView: View {
@StateObject private var model = TxtTocRuleManagementModel()
@State private var editing: TxtTocRule?
@State private var importing = false
@State private var exporting = false
var body: some View {
List {
if model.rules.isEmpty {
ReadingUnavailableView("没有TXT目录规则", systemImage: "list.bullet.rectangle")
}
ForEach(model.rules) { rule in
Button { editing = rule } label: {
HStack(spacing: 12) {
Image(systemName: rule.enable ? "checkmark.circle.fill" : "circle")
.foregroundStyle(rule.enable ? ReadingTheme.accent : .secondary)
VStack(alignment: .leading, spacing: 4) {
Text(rule.name).foregroundStyle(.primary)
Text(rule.rule).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(2)
}
}
}
.swipeActions(edge: .leading) {
Button(rule.enable ? "停用" : "启用") { Task { await model.toggle(rule) } }
.tint(rule.enable ? .orange : .green)
}
}
.onDelete { offsets in Task { await model.remove(at: offsets) } }
.onMove { offsets, destination in Task { await model.move(from: offsets, to: destination) } }
}
.navigationTitle("TXT目录规则")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("新建", systemImage: "plus") { editing = TxtTocRule() }
}
ToolbarItem(placement: .primaryAction) {
Menu {
Button("导入JSON", systemImage: "square.and.arrow.down") { importing = true }
Button("导出JSON", systemImage: "square.and.arrow.up") { exporting = true }
.disabled(model.rules.isEmpty)
Button("恢复内置规则", systemImage: "arrow.counterclockwise") {
Task { await model.importDefaults() }
}
EditButton()
} label: { Image(systemName: "ellipsis.circle") }
}
}
.task { await model.load() }
.sheet(item: $editing) { value in
TxtTocRuleEditView(value: value) { await model.save($0) }
}
.fileImporter(isPresented: $importing, allowedContentTypes: [.json, .plainText]) { result in
guard case .success(let url) = result else { return }
let granted = url.startAccessingSecurityScopedResource()
defer { if granted { url.stopAccessingSecurityScopedResource() } }
if let data = try? Data(contentsOf: url) { Task { await model.importData(data) } }
}
.fileExporter(
isPresented: $exporting,
document: TxtTocRuleDocument(rules: model.rules),
contentType: .json,
defaultFilename: "txtTocRules.json"
) { result in
if case .failure(let error) = result { model.message = error.localizedDescription }
}
.alert("TXT目录规则", isPresented: Binding(
get: { model.message != nil },
set: { if !$0 { model.message = nil } }
)) { Button("好") { model.message = nil } } message: { Text(model.message ?? "") }
}
}
private struct TxtTocRuleEditView: View {
@Environment(\.dismiss) private var dismiss
@State private var value: TxtTocRule
@State private var sample = ""
@State private var saving = false
let save: (TxtTocRule) async -> Bool
init(value: TxtTocRule, save: @escaping (TxtTocRule) async -> Bool) {
_value = State(initialValue: value)
_sample = State(initialValue: value.example ?? "")
self.save = save
}
private var testResult: String {
guard let expression = try? NSRegularExpression(pattern: value.rule, options: .anchorsMatchLines) else { return "正则语法错误" }
let range = NSRange(sample.startIndex..., in: sample)
let values = expression.matches(in: sample, range: range).prefix(20).compactMap { match -> String? in
guard let range = Range(match.range, in: sample) else { return nil }
return String(sample[range])
}
return values.isEmpty ? "没有匹配" : "匹配 \(values.count) 条:\n" + values.joined(separator: "\n")
}
var body: some View {
ReadingNavigationStack {
Form {
TextField("名称", text: $value.name)
ReadingTextField("目录正则", text: $value.rule, axis: .vertical)
.font(.body.monospaced()).textInputAutocapitalization(.never).disableAutocorrection(true)
ReadingTextField("标题处理JavaScript(可选)", text: Binding(
get: { value.replacement }, set: { value.replacement = $0 }
), axis: .vertical).font(.body.monospaced())
Toggle("启用", isOn: $value.enable)
Section("测试文本") {
TextEditor(text: $sample).frame(minHeight: 130)
Text(testResult).font(.caption.monospaced()).foregroundStyle(.secondary).textSelection(.enabled)
}
}
.navigationTitle("TXT目录规则")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
ToolbarItem(placement: .confirmationAction) {
Button(saving ? "保存中…" : "保存") {
value.example = sample.isEmpty ? nil : sample
saving = true
Task { if await save(value) { dismiss() }; saving = false }
}.disabled(saving)
}
}
}
}
}
private struct TxtTocRuleDocument: FileDocument {
static var readableContentTypes: [UTType] { [.json] }
let rules: [TxtTocRule]
init(rules: [TxtTocRule]) { self.rules = rules }
init(configuration: ReadConfiguration) throws { rules = [] }
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
let encoder = JSONEncoder(); encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return FileWrapper(regularFileWithContents: try encoder.encode(rules))
}
}
+248
View File
@@ -0,0 +1,248 @@
import Foundation
import SwiftUI
#if SWIFT_PACKAGE
import LegadoCore
#endif
@MainActor
final class SearchViewModel: ObservableObject {
enum State: Equatable { case idle, searching, finished, empty, unavailable }
@Published private(set) var state: State = .idle
@Published private(set) var books: [SearchBook] = []
@Published private(set) var diagnostics: [RuleDiagnostic] = []
@Published private(set) var isLoadingMore = false
@Published private(set) var hasMore = false
@Published private(set) var isPaused = false
@Published private(set) var searchKeywords: [SearchKeyword] = []
@Published private(set) var completedSourceCount = 0
@Published private(set) var totalSourceCount = 0
@Published private(set) var submittedKeyword = ""
@Published private(set) var isShowingCachedResults = false
private let service: BookSearchService
private let keywordRepository: any SearchKeywordRepository
private let searchBookRepository: any SearchBookRepository
private var searchTask: Task<Void, Never>?
private var currentKeyword = ""
private var pagedSources: [BookSource] = []
private var currentPrecision = false
private var page = 1
private var activePage = 1
private var searchGeneration = 0
private var pendingSources: [BookSource] = []
private var pendingSourceIDs = Set<String>()
private var sourceNames: [String: String] = [:]
private var accumulator = BookSearchAccumulator()
private var cachedIdentities = Set<String>()
private var liveIdentities = Set<String>()
private var collectedDiagnostics: [RuleDiagnostic] = []
private var completedCount = 0
private var foundMoreOnActivePage = false
init(service: BookSearchService = .init(),
keywordRepository: any SearchKeywordRepository = SearchKeywordRepositoryFactory.live(),
searchBookRepository: any SearchBookRepository = SearchBookRepositoryFactory.live()) {
self.service = service
self.keywordRepository = keywordRepository
self.searchBookRepository = searchBookRepository
}
deinit { searchTask?.cancel() }
func search(keyword: String, sources: [BookSource], precision: Bool = false) {
let keyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !keyword.isEmpty else { cancel(reset: true); return }
searchTask?.cancel()
searchGeneration &+= 1
currentKeyword = keyword
submittedKeyword = keyword
currentPrecision = precision
page = 1
activePage = 1
pendingSources = eligible(sources)
pendingSourceIDs = Set(pendingSources.map(\.id))
pagedSources = pendingSources.filter {
let url = $0.searchUrl ?? ""
return url.contains("{{page}}") || url.contains("searchPage") || url.contains("<")
}
sourceNames = Dictionary(pendingSources.map { ($0.id, $0.bookSourceName) }, uniquingKeysWith: { first, _ in first })
accumulator = .init()
cachedIdentities = []
liveIdentities = []
collectedDiagnostics = []
completedCount = 0
foundMoreOnActivePage = false
isShowingCachedResults = false
hasMore = true
isPaused = false
isLoadingMore = false
books = []
diagnostics = []
completedSourceCount = 0
totalSourceCount = pendingSources.count
guard totalSourceCount > 0 else { hasMore = false; state = .unavailable; return }
state = .searching
Task { await recordKeyword(keyword) }
startWorker(loadCache: true)
}
private func eligible(_ sources: [BookSource]) -> [BookSource] {
var seen = Set<String>()
return sources.filter { $0.enabled && $0.searchUrl?.isEmpty == false && seen.insert($0.id).inserted }
}
private func startWorker(loadCache: Bool = false) {
let generation = searchGeneration
let requestedPage = activePage
let keyword = currentKeyword
let sources = pendingSources.filter { pendingSourceIDs.contains($0.id) }
searchTask = Task { [service, searchBookRepository] in
var preferred = Set<String>()
if loadCache {
let cached = (try? await searchBookRepository.cachedMatches(keyword: keyword)) ?? []
guard !Task.isCancelled, generation == searchGeneration else { return }
let enabled = Dictionary(sources.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
let now = Date().timeIntervalSince1970 * 1_000
let usable = cached.filter { book in
guard let source = enabled[book.origin] else { return false }
let age = now - Double(book.time)
return age >= 0 && age < 86_400_000 && source.lastUpdateTime <= book.time
}
accumulator.add(usable)
cachedIdentities = Set(usable.map { BookSearchService.mergeIdentity(for: $0) })
preferred = Set(usable.flatMap(\.origins)).intersection(Set(enabled.keys))
publishResults()
}
var lastVisibleUpdate = ProcessInfo.processInfo.systemUptime - 1
for await outcome in service.searchOutcomes(keyword: keyword, page: requestedPage, sources: sources, preferredSourceIDs: preferred) {
guard !Task.isCancelled, generation == searchGeneration else { return }
pendingSourceIDs.remove(outcome.sourceID)
completedCount += 1
collectedDiagnostics += namedDiagnostics(outcome)
for book in outcome.books {
let identity = BookSearchService.mergeIdentity(for: book)
if requestedPage > 1 && !liveIdentities.contains(identity) { foundMoreOnActivePage = true }
liveIdentities.insert(identity)
accumulator.add([book], replacingExisting: cachedIdentities.remove(identity) != nil)
}
let now = ProcessInfo.processInfo.systemUptime
if books.isEmpty || now - lastVisibleUpdate >= 0.25 {
publishResults()
lastVisibleUpdate = now
}
if !outcome.books.isEmpty { try? await searchBookRepository.put(outcome.books) }
}
guard !Task.isCancelled, generation == searchGeneration else { return }
publishResults()
page = requestedPage
hasMore = requestedPage == 1 ? !books.isEmpty && !pagedSources.isEmpty : foundMoreOnActivePage
isLoadingMore = false
state = books.isEmpty ? .empty : .finished
}
}
private func publishResults() {
let values = accumulator.results(keyword: currentKeyword)
let visible = currentPrecision ? values.filter {
$0.name.localizedCaseInsensitiveContains(currentKeyword)
|| $0.author.localizedCaseInsensitiveContains(currentKeyword)
|| $0.kind?.localizedCaseInsensitiveContains(currentKeyword) == true
} : values
if books != visible { books = visible }
if diagnostics != collectedDiagnostics { diagnostics = collectedDiagnostics }
if completedSourceCount != completedCount { completedSourceCount = completedCount }
let cachedOnly = !visible.isEmpty && liveIdentities.isEmpty
if isShowingCachedResults != cachedOnly { isShowingCachedResults = cachedOnly }
}
func loadMore() {
guard state == .finished, hasMore, !isLoadingMore, !isPaused, !currentKeyword.isEmpty else { return }
isLoadingMore = true
searchGeneration &+= 1
activePage = page + 1
pendingSources = eligible(pagedSources)
pendingSourceIDs = Set(pendingSources.map(\.id))
foundMoreOnActivePage = false
startWorker()
}
func cancel(reset: Bool = false) {
searchTask?.cancel()
searchTask = nil
searchGeneration &+= 1
isLoadingMore = false
if reset {
accumulator = .init()
cachedIdentities = []
liveIdentities = []
collectedDiagnostics = []
pendingSources = []
pendingSourceIDs = []
isShowingCachedResults = false
isPaused = false
state = .idle
books = []
diagnostics = []
hasMore = false
completedSourceCount = 0
totalSourceCount = 0
submittedKeyword = ""
} else {
publishResults()
isPaused = true
state = books.isEmpty ? .empty : .finished
}
}
func resume() {
guard isPaused else { return }
isPaused = false
guard !pendingSourceIDs.isEmpty else { loadMore(); return }
if activePage == 1 { state = .searching }
else { isLoadingMore = true }
startWorker()
}
private func namedDiagnostics(_ outcome: SourceSearchOutcome) -> [RuleDiagnostic] {
guard let name = sourceNames[outcome.sourceID] else { return outcome.diagnostics }
return outcome.diagnostics.map {
var value = $0
value.message = "\(name):\(value.message)"
return value
}
}
private func addDiagnostic(_ diagnostic: RuleDiagnostic) {
collectedDiagnostics.append(diagnostic)
diagnostics.append(diagnostic)
}
func loadSearchKeywords() async {
do { searchKeywords = try await keywordRepository.load(order: .lastUseTime) }
catch { addDiagnostic(.init(severity: .warning, code: "search.history.load", message: error.localizedDescription, rule: "SearchKeywordDao.flowByTime")) }
}
func clearSearchKeywords() async {
do {
try await keywordRepository.deleteAll()
searchKeywords = []
UserDefaults.standard.removeObject(forKey: "bookSearchHistory")
} catch { addDiagnostic(.init(severity: .warning, code: "search.history.clear", message: error.localizedDescription, rule: "SearchKeywordDao.deleteAll")) }
}
func deleteSearchKeyword(_ word: String) async {
do {
try await keywordRepository.delete(word)
searchKeywords.removeAll { $0.word == word }
} catch { addDiagnostic(.init(severity: .warning, code: "search.history.delete", message: error.localizedDescription, rule: "SearchKeywordDao.delete")) }
}
private func recordKeyword(_ word: String) async {
do {
_ = try await keywordRepository.record(word, at: Int64(Date().timeIntervalSince1970 * 1_000))
searchKeywords = try await keywordRepository.load(order: .lastUseTime)
UserDefaults.standard.removeObject(forKey: "bookSearchHistory")
} catch { addDiagnostic(.init(severity: .warning, code: "search.history.record", message: error.localizedDescription, rule: "SearchKeywordDao.insert/update")) }
}
}
+96
View File
@@ -0,0 +1,96 @@
import SwiftUI
import UIKit
enum AppIdentity {
static let name = "小浣熊阅读"
static let authorURL = URL(string: "https://github.com/Aminark")!
static let projectURL = URL(string: "https://github.com/Aminark/legado-ios")!
}
struct AboutView: View {
private var name: String { Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? AppIdentity.name }
private var version: String { Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "—" }
private var build: String { Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "—" }
private var diagnosticSummary: String {
"\(name) \(version) (\(build))\niOS \(UIDevice.current.systemVersion)\n数据库版本 \(LegadoDatabase.schemaVersion)"
}
var body: some View {
List {
Section {
VStack(spacing: 12) {
Image("BrandIcon").resizable().scaledToFit()
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 18))
Text(name).font(.title2.weight(.semibold))
Text("快速搜书,自由阅读").font(.subheadline).foregroundStyle(.secondary)
Text("兼容阅读书源的 iOS 阅读应用")
.font(.footnote).foregroundStyle(.secondary)
}.frame(maxWidth: .infinity).padding(.vertical, 16)
ReadingLabeledContent("版本", value: version)
ReadingLabeledContent("构建", value: build)
ReadingLabeledContent("系统", value: "iOS \(UIDevice.current.systemVersion)")
ReadingShareLink(item: diagnosticSummary) { Label("分享版本信息", systemImage: "square.and.arrow.up") }
}
Section {
Link(destination: AppIdentity.authorURL) {
Label("开发者 · Aminark", systemImage: "person.crop.circle")
}
Link(destination: AppIdentity.projectURL) {
Label("项目源码 · legado-ios", systemImage: "chevron.left.forwardslash.chevron.right")
}
ReadingShareLink(item: AppIdentity.projectURL) {
Label("分享项目地址", systemImage: "square.and.arrow.up")
}
ReadingLabeledContent("许可证", value: "GPL-3.0")
ReadingLabeledContent("安装方式", value: "IPA 自行签名")
ReadingLabeledContent("App Store / TestFlight", value: "暂不提供")
} header: {
Text("项目与开发者")
} footer: {
Text("IPA 发布后可从项目 GitHub Releases 获取,并自行签名安装。项目暂不上架 App Store,也不提供 TestFlight。")
}
Section("开源许可") {
NavigationLink("开源许可") { OpenSourceLicensesView() }
}
Section("免责协议") {
Text("小浣熊阅读不提供、内置或分发网络书源,也不提供在线书库。书源由用户自行从网络公开分享获取,或自行编写后导入;在线书籍及订阅内容由对应的第三方网站提供。")
Text("书籍正文、封面、简介等内容的版权归原作者或合法权利人所有。书源可被导入、内容可被访问,不代表已获得版权授权;请仅在获得授权或法律允许的范围内使用,并支持正版阅读。")
Text("第三方书源及其内容不受本应用控制,可能因网站调整、网络环境等原因失效。本应用不保证第三方内容的准确性、完整性、安全性或书源的持续可用性,请自行核实来源。")
Text("用户应对自行导入的书源及使用行为负责,遵守适用法律法规和来源网站的使用规则,不得利用本应用侵犯他人权益、传播违法内容或未经授权复制、分发作品。")
Text("如发现涉嫌侵权内容,请停止访问、删除相关书源及本地缓存,并向内容来源网站反馈。本协议不免除任何一方依法应承担的责任。")
}
.font(.footnote)
.lineSpacing(4)
.textSelection(.enabled)
Section("数据与隐私") {
Text("书架、阅读进度、书源和诊断日志保存在本机。导入书源、阅读在线内容和刷新订阅时,会连接相应来源的网站。")
Text("应用不会自动上传诊断日志;可在日志页面手动导出。")
Text("应用不提供内置网络书源或在线书库。请使用你有权访问的内容与书源。")
}.font(.footnote)
}
.navigationTitle("关于")
.navigationBarTitleDisplayMode(.inline)
}
}
private struct OpenSourceLicensesView: View {
var body: some View {
List {
license("小浣熊阅读 / Legado", resource: "Legado-LICENSE", detail: "GNU GPL v3")
license("SwiftSoup", resource: "SwiftSoup-LICENSE", detail: "MIT License")
license("Kanna", resource: "Kanna-LICENSE", detail: "MIT License")
}.navigationTitle("开源许可").navigationBarTitleDisplayMode(.inline)
}
private func license(_ title: String, resource: String, detail: String) -> some View {
NavigationLink {
ScrollView {
Text((Bundle.main.url(forResource: resource, withExtension: "txt")
.flatMap { try? String(contentsOf: $0, encoding: .utf8) }) ?? "许可文件无法读取")
.font(.system(.footnote, design: .monospaced)).textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading).padding()
}.navigationTitle(title).navigationBarTitleDisplayMode(.inline)
} label: { ReadingLabeledContent(title, value: detail) }
}
}
@@ -0,0 +1,22 @@
import Foundation
import MetricKit
final class AppDiagnostics: NSObject, MXMetricManagerSubscriber, @unchecked Sendable {
static let shared = AppDiagnostics()
func start() {
MXMetricManager.shared.add(self)
DiagnosticLog.shared.record(category: "应用", message: "启动 \(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "")")
}
func didReceive(_ payloads: [MXDiagnosticPayload]) {
for payload in payloads {
for crash in payload.crashDiagnostics ?? [] {
DiagnosticLog.shared.record(.error, category: "系统诊断", message: "崩溃诊断:\(String(decoding: crash.jsonRepresentation(), as: UTF8.self))")
}
for hang in payload.hangDiagnostics ?? [] {
DiagnosticLog.shared.record(.warning, category: "系统诊断", message: "卡顿诊断:\(String(decoding: hang.jsonRepresentation(), as: UTF8.self))")
}
}
}
}
@@ -0,0 +1,98 @@
import SwiftUI
import UniformTypeIdentifiers
struct DiagnosticLogView: View {
@State private var entries: [DiagnosticLogEntry] = []
@State private var query = ""
@State private var level = "all"
@State private var confirmsClear = false
@State private var exportsLog = false
@State private var exportText = ""
@State private var errorMessage: String?
private var filtered: [DiagnosticLogEntry] {
entries.filter {
(level == "all" || $0.level.rawValue == level)
&& (query.isEmpty || "\($0.category) \($0.message)".localizedCaseInsensitiveContains(query))
}
}
var body: some View {
List {
Section {
Picker("级别", selection: $level) {
Text("全部").tag("all")
Text("信息").tag("info")
Text("警告").tag("warning")
Text("错误").tag("error")
}.pickerStyle(.segmented)
Text("\(entries.count) 条日志 · 下拉刷新")
.font(.footnote).foregroundStyle(.secondary)
} footer: {
Text("在“其他设置”开启记录后,会保存网络请求、JS 输出和运行错误。崩溃诊断由 iOS 延迟提供,可搜索“系统诊断”查看。")
}
if filtered.isEmpty {
ReadingUnavailableView(query.isEmpty ? "暂无日志" : "没有匹配的日志", systemImage: "doc.text.magnifyingglass")
} else {
ForEach(filtered) { entry in
VStack(alignment: .leading, spacing: 6) {
HStack {
Text(entry.category).font(.caption.weight(.semibold))
Text(levelTitle(entry.level)).font(.caption)
.foregroundStyle(entry.level == .error ? .red : .secondary)
Spacer()
Text(entry.date, format: .dateTime.month().day().hour().minute().second())
.font(.caption2).foregroundStyle(.secondary)
}
Text(entry.message).font(.system(.footnote, design: .monospaced)).textSelection(.enabled)
}.padding(.vertical, 3)
}
}
}
.navigationTitle("日志与诊断")
.navigationBarTitleDisplayMode(.inline)
.searchable(text: $query, prompt: "搜索内容或分类")
.task { reload() }
.refreshable { reload() }
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Menu("日志操作", systemImage: "ellipsis") {
Button("刷新", systemImage: "arrow.clockwise") { reload() }
Button("导出全部日志", systemImage: "square.and.arrow.up") {
exportText = DiagnosticLog.shared.export()
exportsLog = true
}.disabled(entries.isEmpty)
Button("清空日志", systemImage: "trash", role: .destructive) { confirmsClear = true }
.disabled(entries.isEmpty)
}
}
}
.confirmationDialog("清空全部日志?", isPresented: $confirmsClear, titleVisibility: .visible) {
Button("清空日志", role: .destructive) {
do { try DiagnosticLog.shared.clear(); reload() }
catch { errorMessage = error.localizedDescription }
}
}
.fileExporter(isPresented: $exportsLog, document: DiagnosticLogDocument(text: exportText), contentType: .plainText, defaultFilename: "阅读诊断日志") { result in
if case .failure(let error) = result { errorMessage = error.localizedDescription }
}
.alert("日志操作失败", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) {
Button("好") { errorMessage = nil }
} message: { Text(errorMessage ?? "") }
}
private func reload() { entries = DiagnosticLog.shared.snapshot() }
private func levelTitle(_ level: DiagnosticLogEntry.Level) -> String {
switch level { case .info: "信息"; case .warning: "警告"; case .error: "错误" }
}
}
private struct DiagnosticLogDocument: FileDocument {
static var readableContentTypes: [UTType] { [.plainText] }
var text: String
init(text: String) { self.text = text }
init(configuration: ReadConfiguration) throws {
text = String(decoding: configuration.file.regularFileContents ?? Data(), as: UTF8.self)
}
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper { .init(regularFileWithContents: Data(text.utf8)) }
}
@@ -0,0 +1,179 @@
import SwiftUI
import WebKit
struct OtherSettingsView: View {
@AppStorage("auto_refresh") private var autoRefresh = false
@AppStorage("onlyUpdateRead") private var onlyUpdateRead = false
@AppStorage("defaultHomePage") private var defaultHomePage = "bookshelf"
@State private var settings = ApplicationSettingsStore.shared.settings
@State private var maintenance: Maintenance?
@State private var isWorking = false
@State private var message: String?
private enum Maintenance: String, Identifiable {
case webCache = "清理网页缓存"
case database = "整理数据库"
var id: String { rawValue }
}
var body: some View {
Form {
Section("启动与书架") {
Picker("启动页面", selection: $defaultHomePage) {
Text("书架").tag("bookshelf")
Text("发现").tag("explore")
Text("统计").tag("statistics")
Text("设置").tag("settings")
}
Toggle("启动时更新书架", isOn: $autoRefresh)
Toggle("自动更新仅检查读过的书", isOn: $onlyUpdateRead).disabled(!autoRefresh)
}
Section("网络与存储") {
NavigationLink {
NetworkSettingsView()
} label: { Label("网络与缓存设置", systemImage: "network") }
NavigationLink {
CacheManagementView()
} label: { Label("章节缓存管理", systemImage: "internaldrive") }
Button("清理网页缓存", systemImage: "globe") { maintenance = .webCache }
Button("整理数据库", systemImage: "externaldrive.badge.checkmark") { maintenance = .database }
}
Section {
Toggle("记录运行日志", isOn: Binding(get: { settings.recordLog }, set: { enabled in
var value = ApplicationSettingsStore.shared.settings
value.recordLog = enabled
do { try ApplicationSettingsStore.shared.save(value); settings = value }
catch { message = error.localizedDescription }
}))
NavigationLink {
DiagnosticLogView()
} label: { Label("日志与诊断", systemImage: "doc.text.magnifyingglass") }
} header: { Text("诊断") } footer: {
Text("日志仅保存在本机,最多保留 500 条。网络日志不记录请求正文与 Header,URL 查询参数和常见凭据会隐藏。")
}
}
.navigationTitle("其他设置")
.navigationBarTitleDisplayMode(.inline)
.disabled(isWorking)
.overlay { if isWorking { ProgressView("正在处理…").padding().background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) } }
.onAppear { settings = ApplicationSettingsStore.shared.settings }
.confirmationDialog(maintenance?.rawValue ?? "", isPresented: Binding(
get: { maintenance != nil }, set: { if !$0 { maintenance = nil } }
), titleVisibility: .visible) {
if let operation = maintenance {
Button(operation.rawValue) { Task { await perform(operation) } }
}
Button("取消", role: .cancel) { maintenance = nil }
} message: {
Text(maintenance == .webCache ? "清理网页和图片的临时缓存,保留登录 Cookie、书架和阅读进度。" : "回收数据库中的空闲空间,保留全部书籍、规则和阅读记录。")
}
.alert("操作结果", isPresented: Binding(get: { message != nil }, set: { if !$0 { message = nil } })) {
Button("好") { message = nil }
} message: { Text(message ?? "") }
}
@MainActor private func perform(_ operation: Maintenance) async {
maintenance = nil
isWorking = true
defer { isWorking = false }
do {
switch operation {
case .webCache:
await WKWebsiteDataStore.default().removeData(ofTypes: [WKWebsiteDataTypeDiskCache, WKWebsiteDataTypeMemoryCache, WKWebsiteDataTypeOfflineWebApplicationCache], modifiedSince: .distantPast)
URLCache.shared.removeAllCachedResponses()
case .database:
let database = try LegadoDatabase(url: LegadoDatabase.defaultURL())
try await database.compact()
}
DiagnosticLog.shared.record(category: "存储", message: "\(operation.rawValue)完成")
message = "\(operation.rawValue)完成"
} catch {
DiagnosticLog.shared.record(.error, category: "存储", message: error.localizedDescription)
message = error.localizedDescription
}
}
}
struct NetworkSettingsView: View {
@Environment(\.dismiss) private var dismiss
@State private var settings = ApplicationSettingsStore.shared.settings
@State private var errorMessage: String?
@State private var isSaving = false
var body: some View {
Form {
Section {
ReadingTextField("留空使用默认浏览器 UA", text: $settings.userAgent, axis: .vertical)
.textInputAutocapitalization(.never).disableAutocorrection(true)
.accessibilityLabel("User-Agent")
} header: { Text("默认 User-Agent") } footer: {
Text("书源或 RSS 单独设置的 UA 优先于此项。")
}
Section {
TextField("例如 http://127.0.0.1:7890", text: $settings.proxy)
.keyboardType(.URL).textInputAutocapitalization(.never).disableAutocorrection(true)
.accessibilityLabel("全局代理")
} header: { Text("全局代理") } footer: {
Text("支持 HTTP、HTTPS 和 SOCKS5 地址,不含账号密码。书源代理优先于全局代理;使用代理时由代理解析域名。")
}
Section {
TextEditor(text: $settings.hosts)
.font(.system(.footnote, design: .monospaced)).frame(minHeight: 130)
.textInputAutocapitalization(.never).disableAutocorrection(true)
.accessibilityLabel("Hosts")
} header: { Text("自定义 Hosts") } footer: {
Text("每行填写 IP地址 域名,例如 192.168.1.2 books.example.com。支持 IPv4、IPv6 和 # 注释。适用于应用内 HTTP/HTTPS 请求;系统网页、登录网页和系统媒体播放器使用系统网络设置。")
}
Section("连接") {
Picker("请求超时", selection: $settings.timeoutSeconds) {
Text("跟随书源").tag(0)
ForEach([5, 10, 15, 30, 60, 120, 180, 300], id: \.self) { Text("\($0) 秒").tag($0) }
}
Stepper("搜索并发数:\(settings.concurrentSources)", value: $settings.concurrentSources, in: 1...64)
Toggle("兼容无效证书的书源", isOn: $settings.ignoreCertificateErrors)
Text("证书兼容开启时允许自签名或过期证书;关闭后由系统验证 HTTPS 证书。")
.font(.footnote).foregroundStyle(.secondary)
}
Section("章节缓存") {
Picker("容量上限", selection: $settings.chapterCacheMB) {
ForEach([16, 32, 64, 128, 256, 512, 1024, 2048, 4096], id: \.self) {
Text($0 >= 1024 ? "\($0 / 1024) GB" : "\($0) MB").tag($0)
}
}
Stepper(settings.prefetchChapters == 0 ? "关闭章节预加载" : "预加载 \(settings.prefetchChapters) 章", value: $settings.prefetchChapters, in: 0...20)
Text("超出上限时清理最久未读取的章节;容量调整在保存时生效。")
.font(.footnote).foregroundStyle(.secondary)
}
Section {
Button("恢复本页默认值") {
let recordLog = settings.recordLog
settings = .init()
settings.recordLog = recordLog
}
}
}
.navigationTitle("网络与缓存")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("保存") { Task { await save() } }.disabled(isSaving)
}
}
.disabled(isSaving)
.alert("保存失败", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) {
Button("好") { errorMessage = nil }
} message: { Text(errorMessage ?? "") }
}
@MainActor private func save() async {
isSaving = true
defer { isSaving = false }
do {
settings.recordLog = ApplicationSettingsStore.shared.settings.recordLog
try ApplicationSettingsStore.shared.save(settings)
try await ChapterContentCache.live().pruneIfNeeded()
DiagnosticLog.shared.record(category: "设置", message: "网络与缓存设置已保存")
dismiss()
} catch { errorMessage = error.localizedDescription }
}
}
+440
View File
@@ -0,0 +1,440 @@
import SwiftUI
@MainActor
final class SourceDebugViewModel: ObservableObject {
@Published var keyword = ""
@Published private(set) var isRunning = false
@Published private(set) var report: BookSourceDebugReport?
private let debugger: BookSourceDebugger
private var task: Task<Void, Never>?
init(debugger: BookSourceDebugger = .init()) {
self.debugger = debugger
}
deinit { task?.cancel() }
func run(source: BookSource) {
let keyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !keyword.isEmpty else { return }
task?.cancel()
isRunning = true
report = nil
task = Task { [debugger] in
let value = await debugger.run(source: source, keyword: keyword)
guard !Task.isCancelled else { return }
report = value
isRunning = false
}
}
}
struct SourceDebugView: View {
let source: BookSource
@StateObject private var model = SourceDebugViewModel()
var body: some View {
ScrollView {
VStack(spacing: 16) {
introCard
runCard
if model.isRunning { runningCard }
if let report = model.report { reportView(report) }
}
.padding(.horizontal, 16)
.padding(.bottom, 30)
}
.background(ReadingTheme.canvas)
.navigationTitle("书源调试")
.navigationBarTitleDisplayMode(.inline)
}
private var introCard: some View {
ReadingCard {
HStack(spacing: 13) {
Image(systemName: "stethoscope")
.font(.title2).foregroundStyle(ReadingTheme.coral)
.frame(width: 46, height: 46)
.background(ReadingTheme.coral.opacity(0.11), in: RoundedRectangle(cornerRadius: 14))
VStack(alignment: .leading, spacing: 3) {
Text(source.bookSourceName).font(.headline)
Text("依次验证搜索 → 详情 → 目录 → 第一章正文")
.font(.caption).foregroundStyle(.secondary)
}
Spacer(minLength: 0)
}
}
.padding(.top, 8)
}
private var runCard: some View {
ReadingCard {
VStack(alignment: .leading, spacing: 12) {
Text("样本关键词").font(.caption.bold()).foregroundStyle(.secondary)
TextField("输入该书源能搜到的书名", text: $model.keyword)
.textFieldStyle(.roundedBorder)
.textInputAutocapitalization(.never)
.submitLabel(.go)
.onSubmit { model.run(source: source) }
Button { model.run(source: source) } label: {
Label(model.isRunning ? "正在调试…" : "运行完整链路", systemImage: "play.fill")
.font(.headline).frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(ReadingTheme.accent)
.disabled(model.isRunning || model.keyword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
private var runningCard: some View {
ReadingCard {
HStack(spacing: 12) {
ProgressView()
VStack(alignment: .leading, spacing: 3) {
Text("端到端检查进行中").font(.subheadline.bold())
Text("请求只发送给这个书源,最多读取 5 页目录与正文。")
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
}
}
}
@ViewBuilder
private func reportView(_ report: BookSourceDebugReport) -> some View {
ReadingCard {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(report.passed ? "完整链路通过" : "发现兼容问题").font(.headline)
Text(report.passed ? "该样本已成功读取到正文" : "展开失败阶段查看规则诊断")
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
Image(systemName: report.passed ? "checkmark.seal.fill" : "exclamationmark.triangle.fill")
.font(.title2).foregroundStyle(report.passed ? .green : .orange)
}
}
VStack(spacing: 10) {
ForEach(report.steps) { step in DebugStepCard(step: step) }
}
if let preview = report.contentPreview {
ReadingCard {
VStack(alignment: .leading, spacing: 9) {
Label(report.sampleChapterTitle ?? "正文样本", systemImage: "text.quote")
.font(.headline)
Text(preview).font(.footnote).foregroundStyle(.secondary).lineSpacing(3)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
}
}
@MainActor
private final class SourceBatchTestModel: ObservableObject {
enum Status: Equatable {
case waiting
case testing
case passed(Int)
case failed(String)
}
@Published private(set) var statuses: [String: Status] = [:]
@Published private(set) var completedCount = 0
@Published private(set) var totalCount = 0
@Published private(set) var isTesting = false
@Published var selectedFailedIDs = Set<String>()
var failedCount: Int {
statuses.values.reduce(into: 0) { count, status in
if case .failed = status { count += 1 }
}
}
private let service: BookSearchService
private var task: Task<Void, Never>?
init(service: BookSearchService = .init()) {
self.service = service
}
deinit { task?.cancel() }
func start(sources: [BookSource], keyword: String) {
stop()
let keyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
guard !keyword.isEmpty, !sources.isEmpty else { return }
statuses = Dictionary(uniqueKeysWithValues: sources.map { ($0.id, .waiting) })
selectedFailedIDs = []
completedCount = 0
totalCount = sources.count
isTesting = true
task = Task { [service] in
for source in sources.prefix(16) { statuses[source.id] = .testing }
var startedCount = min(16, sources.count)
for await outcome in service.searchOutcomes(
keyword: keyword,
sources: sources,
maximumConcurrentSources: ApplicationSettingsStore.shared.settings.concurrentSources,
includeDisabled: true
) {
guard !Task.isCancelled else { return }
completedCount += 1
if outcome.books.isEmpty {
let message = outcome.diagnostics.first(where: { $0.severity == .error })?.message
?? "没有搜索到结果"
statuses[outcome.sourceID] = .failed(message)
selectedFailedIDs.insert(outcome.sourceID)
} else {
statuses[outcome.sourceID] = .passed(outcome.books.count)
}
if startedCount < sources.count {
statuses[sources[startedCount].id] = .testing
startedCount += 1
}
}
guard !Task.isCancelled else { return }
isTesting = false
task = nil
}
}
func stop() {
task?.cancel()
task = nil
isTesting = false
for (id, status) in statuses where status == .testing || status == .waiting {
statuses[id] = .waiting
}
}
func toggleSelection(_ sourceID: String) {
if !selectedFailedIDs.insert(sourceID).inserted { selectedFailedIDs.remove(sourceID) }
}
}
struct SourceBatchTestView: View {
private enum CleanupAction: String, Identifiable {
case disable
case delete
var id: String { rawValue }
}
@EnvironmentObject private var library: SourceLibraryModel
@StateObject private var model = SourceBatchTestModel()
@AppStorage("sourceTestKeyword") private var keyword = "我的"
@State private var includesDisabled = false
@State private var onlyShowsFailed = false
@State private var cleanupAction: CleanupAction?
private var testedSources: [BookSource] {
includesDisabled ? library.sources : library.sources.filter(\.enabled)
}
private var visibleSources: [BookSource] {
guard onlyShowsFailed else { return testedSources }
return testedSources.filter {
if case .failed? = model.statuses[$0.id] { return true }
return false
}
}
var body: some View {
List {
Section {
TextField("校验搜索词", text: $keyword)
.textInputAutocapitalization(.never)
Toggle("包含已停用书源", isOn: $includesDisabled)
.disabled(model.isTesting)
Toggle("只看失败", isOn: $onlyShowsFailed)
} footer: {
Text("快速测试以搜索是否能返回结果为准。部分正常书源可能不收录当前关键词,删除前请复核或更换关键词重测。")
}
if model.totalCount > 0 {
Section {
ProgressView(value: Double(model.completedCount), total: Double(model.totalCount))
HStack {
Text("已完成 \(model.completedCount)/\(model.totalCount)")
Spacer()
Text("失败 \(model.failedCount) · 已选 \(model.selectedFailedIDs.count)")
.foregroundStyle(model.failedCount == 0 ? Color.secondary : Color.red)
}
.font(.subheadline.monospacedDigit())
}
}
Section("书源") {
ForEach(visibleSources) { source in
SourceBatchTestRow(
source: source,
status: model.statuses[source.id] ?? .waiting,
selected: model.selectedFailedIDs.contains(source.id),
onToggle: { model.toggleSelection(source.id) }
)
}
}
}
.navigationTitle("书源测试")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
if model.isTesting {
Button("停止", systemImage: "stop.fill") { model.stop() }
} else {
Button("开始", systemImage: "play.fill") {
model.start(sources: testedSources, keyword: keyword)
}
.disabled(testedSources.isEmpty || keyword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
.safeAreaInset(edge: .bottom) {
if !model.isTesting, !model.selectedFailedIDs.isEmpty {
HStack {
Button("停用所选", systemImage: "pause.circle") { cleanupAction = .disable }
Spacer()
Button("删除所选", systemImage: "trash", role: .destructive) { cleanupAction = .delete }
}
.font(.subheadline.weight(.semibold))
.padding(.horizontal, 18).padding(.vertical, 12)
.background(.ultraThinMaterial)
}
}
.alert(item: $cleanupAction) { action in
Alert(
title: Text(action == .delete ? "删除失败书源?" : "停用失败书源?"),
message: Text("将处理已勾选的 \(model.selectedFailedIDs.count) 个书源。搜索不到当前关键词不一定代表书源永久失效。"),
primaryButton: .destructive(Text(action == .delete ? "删除" : "停用")) {
let ids = model.selectedFailedIDs
Task {
if action == .delete { await library.remove(sourceIDs: ids) }
else { await library.setEnabled(false, for: ids) }
model.selectedFailedIDs = []
}
},
secondaryButton: .cancel()
)
}
}
}
private struct SourceBatchTestRow: View {
let source: BookSource
let status: SourceBatchTestModel.Status
let selected: Bool
let onToggle: () -> Void
var body: some View {
HStack(spacing: 12) {
Image(systemName: icon)
.foregroundStyle(color)
.frame(width: 28)
VStack(alignment: .leading, spacing: 3) {
Text(source.bookSourceName).font(.headline)
Text(detail).font(.caption).foregroundStyle(.secondary).lineLimit(2)
}
Spacer()
if case .failed = status {
Button(action: onToggle) {
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
.font(.title3)
}
.buttonStyle(.plain)
}
}
}
private var detail: String {
switch status {
case .waiting: "等待测试"
case .testing: "正在测试…"
case .passed(let count): "通过,搜索到 \(count) 条结果"
case .failed(let message): message
}
}
private var icon: String {
switch status {
case .waiting: "clock"
case .testing: "arrow.triangle.2.circlepath"
case .passed: "checkmark.circle.fill"
case .failed: "xmark.circle.fill"
}
}
private var color: Color {
switch status {
case .waiting: .secondary
case .testing: ReadingTheme.accent
case .passed: .green
case .failed: .red
}
}
}
private struct DebugStepCard: View {
let step: SourceDebugStep
var body: some View {
DisclosureGroup {
if step.diagnostics.isEmpty {
Text("该阶段没有规则诊断。")
.font(.caption).foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 8)
} else {
VStack(alignment: .leading, spacing: 9) {
ForEach(step.diagnostics) { diagnostic in
VStack(alignment: .leading, spacing: 2) {
Text(diagnostic.message).font(.caption)
Text(diagnostic.code).font(.caption2.monospaced()).foregroundStyle(.tertiary)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.top, 8)
}
} label: {
HStack(spacing: 12) {
Image(systemName: icon)
.foregroundStyle(color)
.frame(width: 34, height: 34)
.background(color.opacity(0.1), in: Circle())
VStack(alignment: .leading, spacing: 3) {
Text(step.stage.title).font(.subheadline.bold())
Text(step.summary).font(.caption).foregroundStyle(.secondary).lineLimit(2)
}
Spacer()
if step.elapsedMilliseconds > 0 {
Text("\(step.elapsedMilliseconds) ms")
.font(.caption2.monospacedDigit()).foregroundStyle(.tertiary)
}
}
}
.padding(14)
.background(.background, in: RoundedRectangle(cornerRadius: 17, style: .continuous))
.overlay { RoundedRectangle(cornerRadius: 17, style: .continuous).stroke(.primary.opacity(0.06)) }
}
private var color: Color {
switch step.status {
case .passed: .green
case .warning: .orange
case .failed: .red
case .skipped: .secondary
}
}
private var icon: String {
switch step.status {
case .passed: "checkmark"
case .warning: "exclamationmark"
case .failed: "xmark"
case .skipped: "forward.end"
}
}
}
+213
View File
@@ -0,0 +1,213 @@
import SwiftUI
import UIKit
struct SourceEditorView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var library: SourceLibraryModel
let original: BookSource?
let onSaved: ((BookSource) -> Void)?
@State private var document: BookSourceEditorDocument
@State private var jsonText: String
@State private var mode = 0
@State private var sectionID = "basic"
@State private var errorMessage: String?
@State private var validation: [BookSourceIssue] = []
@State private var debugSource: BookSource?
@State private var isSaving = false
@State private var showsDiscard = false
private let initialJSON: String
init(source: BookSource? = nil, onSaved: ((BookSource) -> Void)? = nil) {
self.onSaved = onSaved
original = source
let doc = try! BookSourceEditorDocument(source: source ?? BookSource(bookSourceUrl: "", bookSourceName: ""))
let json = (try? doc.json()) ?? "{}"
initialJSON = json
_document = State(initialValue: doc)
_jsonText = State(initialValue: json)
}
private var isDirty: Bool { mode == 1 ? jsonText != initialJSON : (try? document.json()) != initialJSON }
private var currentSection: BookSourceEditorSection { BookSourceEditorSchema.sections.first { $0.id == sectionID }! }
var body: some View {
ReadingNavigationStack {
VStack(spacing: 0) {
Picker("编辑模式", selection: Binding(get: { mode }, set: { value in switchMode(value) })) {
Text("字段编辑").tag(0)
Text("JSON").tag(1)
}.pickerStyle(.segmented).padding(.horizontal).padding(.vertical, 8)
if mode == 1 {
SyntaxCodeEditor(text: $jsonText, language: .json)
} else {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(BookSourceEditorSchema.sections) { section in
Button(section.title) { sectionID = section.id }
.buttonStyle(.bordered)
.tint(sectionID == section.id ? ReadingTheme.accent : .secondary)
}
Button("选项") { sectionID = "options" }.buttonStyle(.bordered)
.tint(sectionID == "options" ? ReadingTheme.accent : .secondary)
}.padding(.horizontal)
}.padding(.vertical, 6)
Form {
if sectionID == "options" { options }
else {
Section(currentSection.title) {
ForEach(currentSection.fields) { field in
NavigationLink {
RuleCodeFieldView(title: field.title, path: field.path, text: string(field.path),
language: field.isJavaScript ? .javascript : .rule, help: field.help)
} label: {
VStack(alignment: .leading, spacing: 5) {
Text(field.title)
Text(document.value(at: field.path)?.stringValue.isEmpty == false ? document.value(at: field.path)!.stringValue : field.path)
.font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(2)
}
}
}
}
Section {
Text("点击字段可全屏编辑规则。支持 @css:、XPath、$. JSONPath、@js:、{{key}}、{{page}} 和 ## 替换语法。未识别的扩展字段保留在 JSON 中。")
.font(.footnote).foregroundStyle(.secondary)
}
}
if !validation.isEmpty {
Section("静态检查") {
ForEach(validation) { issue in
Label("\(issue.field):\(issue.message)", systemImage: issue.severity == .error ? "xmark.circle" : "exclamationmark.triangle")
.font(.footnote).foregroundStyle(issue.severity == .error ? .red : .orange)
}
}
}
}
}
}
.navigationTitle(original == nil ? "新建书源" : "编辑书源")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") { if isDirty { showsDiscard = true } else { dismiss() } }
}
ToolbarItem(placement: .topBarTrailing) {
Menu("更多", systemImage: "ellipsis.circle") {
Button("格式化 JSON", systemImage: "text.alignleft") { perform { try synchronize(); jsonText = try document.json() } }
Button("校验配置", systemImage: "checkmark.shield") { check() }
Button("调试当前草稿", systemImage: "stethoscope") { perform { try synchronize(); debugSource = try document.source() } }
Button("复制 JSON", systemImage: "doc.on.doc") { perform { try synchronize(); UIPasteboard.general.string = try document.json() } }
Button("粘贴 JSON", systemImage: "doc.on.clipboard") {
perform {
guard let value = UIPasteboard.general.string else { return }
let pasted = try BookSourceEditorDocument(json: value)
_ = try JSONDecoder().decode(BookSource.self, from: Data(pasted.json().utf8))
document = pasted
jsonText = try pasted.json()
}
}
}
}
ToolbarItem(placement: .confirmationAction) {
Button(isSaving ? "保存中" : "保存", action: save).disabled(isSaving)
}
}
.readingNavigationDestination(isPresented: Binding(
get: { debugSource != nil },
set: { if !$0 { debugSource = nil } }
)) {
if let debugSource { SourceDebugView(source: debugSource) }
}
.alert("无法完成操作", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) {
Button("好", role: .cancel) {}
} message: { Text(errorMessage ?? "") }
.confirmationDialog("放弃未保存的修改?", isPresented: $showsDiscard, titleVisibility: .visible) {
Button("放弃修改", role: .destructive) { dismiss() }
}
}
.interactiveDismissDisabled(isDirty || isSaving)
}
private var options: some View {
Group {
Section("书源类型") {
Picker("类型", selection: Binding(get: { Int(document.value(at: "bookSourceType")?.stringValue ?? "0") ?? 0 },
set: { document.set(.number(Double($0)), at: "bookSourceType") })) {
Text("文本").tag(0); Text("音频").tag(1); Text("图片").tag(2); Text("文件").tag(3); Text("视频").tag(4)
}
}
Section("开关") {
Toggle("启用书源", isOn: boolean("enabled", fallback: true))
Toggle("启用发现", isOn: boolean("enabledExplore", fallback: true))
Toggle("Cookie Jar", isOn: boolean("enabledCookieJar", fallback: true))
Toggle("事件监听", isOn: boolean("eventListener"))
Toggle("定制按钮", isOn: boolean("customButton"))
}
Section("排序与状态") {
ForEach(["customOrder", "weight", "lastUpdateTime", "respondTime"], id: \.self) { key in
HStack {
Text(["customOrder": "排序编号", "weight": "搜索权重", "lastUpdateTime": "更新时间 (ms)", "respondTime": "响应时间 (ms)"][key]!)
TextField(key, text: number(key)).keyboardType(.numbersAndPunctuation).multilineTextAlignment(.trailing)
}
}
}
}
}
private func string(_ path: String) -> Binding<String> {
Binding(get: { document.value(at: path)?.stringValue ?? "" }, set: { document.set($0.isEmpty ? nil : .string($0), at: path) })
}
private func boolean(_ path: String, fallback: Bool = false) -> Binding<Bool> {
Binding(get: { if case .bool(let value) = document.value(at: path) { return value }; return fallback },
set: { document.set(.bool($0), at: path) })
}
private func number(_ path: String) -> Binding<String> {
Binding(get: { document.value(at: path)?.stringValue ?? "0" }, set: { document.set(.string($0), at: path) })
}
private func synchronize() throws { if mode == 1 { document = try BookSourceEditorDocument(json: jsonText) } }
private func switchMode(_ next: Int) {
guard next != mode else { return }
perform {
if next == 0 { try synchronize() }
else { jsonText = try document.json() }
mode = next
}
}
private func perform(_ action: () throws -> Void) {
do { try action() } catch { errorMessage = readable(error) }
}
private func readable(_ error: Error) -> String {
if case DecodingError.typeMismatch(_, let context) = error {
return "\(context.codingPath.map(\.stringValue).joined(separator: ".")):字段类型不正确。\(context.debugDescription)"
}
return error.localizedDescription
}
private func check() {
perform {
try synchronize()
let source = try JSONDecoder().decode(BookSource.self, from: Data(document.json().utf8))
validation = BookSourceValidator.validate(source)
if validation.isEmpty { errorMessage = "静态检查通过。可通过“调试当前草稿”验证实际请求及解析结果。" }
}
}
private func save() {
perform {
try synchronize()
var source = try document.source()
if library.sources.contains(where: { $0.id == source.id && $0.id != original?.id }) {
throw BookSourceImportError.duplicate(source.id)
}
source.lastUpdateTime = Int64(Date().timeIntervalSince1970 * 1_000)
isSaving = true
Task {
let saved = await library.saveSource(source, replacing: original?.id)
isSaving = false
if saved { onSaved?(source); dismiss() }
else {
let message = library.notice?.detail ?? "保存失败"
library.notice = nil
errorMessage = message
}
}
}
}
}
@@ -0,0 +1,434 @@
#if SWIFT_PACKAGE
import LegadoCore
#endif
import Foundation
import SwiftUI
@MainActor
final class SourceLibraryModel: ObservableObject {
@Published private(set) var sources: [BookSource] = []
@Published private(set) var isWorking = false
@Published var notice: Notice?
@Published var pendingImport: PendingImport?
struct PendingImport: Identifiable, Sendable {
struct Item: Identifiable, Sendable {
let source: BookSource
let existing: BookSource?
var id: String { source.id }
var state: String {
guard let existing else { return "新增" }
return source.lastUpdateTime > existing.lastUpdateTime ? "更新" : "已有"
}
}
struct InvalidItem: Identifiable, Sendable {
let id = UUID()
let source: BookSource
let issues: [BookSourceIssue]
}
let id = UUID()
let items: [Item]
let invalidItems: [InvalidItem]
var validCount: Int { items.count }
var invalidCount: Int { invalidItems.count }
var totalCount: Int { validCount + invalidCount }
}
struct Notice: Identifiable {
enum Kind { case success, error }
let id = UUID()
let kind: Kind
let title: String
let detail: String
}
private let repository: any BookSourceRepository
private let importer = BookSourceImporter()
private let importTransport: any HTTPTransport
var groups: [String] {
Array(Set(sources.flatMap(\.groups))).sorted { $0.localizedStandardCompare($1) == .orderedAscending }
}
init(repository: any BookSourceRepository, importTransport: any HTTPTransport = URLSessionHTTPTransport.shared) {
self.repository = repository
self.importTransport = importTransport
}
static func live() -> SourceLibraryModel {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Legado", isDirectory: true)
let legacy = base.appendingPathComponent("book-sources.json")
guard let database = try? LegadoDatabase(url: base.appendingPathComponent("legado.sqlite3")) else {
return SourceLibraryModel(repository: FileBookSourceRepository(fileURL: legacy))
}
return SourceLibraryModel(repository: SQLiteBookSourceRepository(database: database, legacyFileURL: legacy))
}
static func demo(sources: [BookSource]) -> SourceLibraryModel {
SourceLibraryModel(repository: InMemoryBookSourceRepository(sources: sources))
}
func load() async {
guard sources.isEmpty else { return }
isWorking = true
defer { isWorking = false }
do {
sources = try await repository.load()
} catch {
notice = .init(kind: .error, title: "无法读取书源", detail: error.localizedDescription)
}
}
func importFiles(_ urls: [URL]) async {
isWorking = true
defer { isWorking = false }
var combined = sources
var inserted = 0
var replaced = 0
var warnings = 0
do {
for url in urls {
let granted = url.startAccessingSecurityScopedResource()
defer { if granted { url.stopAccessingSecurityScopedResource() } }
let result = try await Task.detached(priority: .userInitiated) { [importer, existing = combined] in
try importer.prepare(data: Data(contentsOf: url), existing: existing, duplicatePolicy: .replace)
}.value
combined = result.sources
inserted += result.inserted
replaced += result.replaced
warnings += result.warnings.count
}
try await repository.save(combined)
sources = combined
notice = .init(
kind: .success,
title: "书源导入完成",
detail: "新增 \(inserted) 个,覆盖 \(replaced) 个,警告 \(warnings) 条"
)
} catch {
notice = .init(kind: .error, title: "书源导入失败", detail: error.localizedDescription)
}
}
func importText(_ text: String) async { await importRemoteURL(text) }
func importRemoteURL(_ value: String) async {
isWorking = true
defer { isWorking = false }
do {
var budget = 100
let batch = try await resolveImport(value, depth: 0, visited: [], budget: &budget)
guard batch.invalidItems.isEmpty else {
throw BookSourceImportError.invalidSources(batch.invalidItems.flatMap(\.issues))
}
try await applyImport(JSONEncoder().encode(batch.sources), successTitle: "书源导入完成")
} catch {
notice = .init(kind: .error, title: "书源导入失败", detail: error.localizedDescription)
}
}
func remove(at offsets: IndexSet) async {
var updated = sources
updated.remove(atOffsets: offsets)
await persist(updated, successTitle: "已删除书源")
}
func remove(sourceIDs: Set<String>) async {
guard !sourceIDs.isEmpty else { return }
await persist(sources.filter { !sourceIDs.contains($0.id) }, successTitle: "已删除 \(sourceIDs.count) 个书源")
}
func setEnabled(_ enabled: Bool, for sourceID: String) async {
var updated = sources
guard let index = updated.firstIndex(where: { $0.id == sourceID }) else { return }
updated[index].enabled = enabled
await persist(updated, successTitle: nil)
}
func applySilentSubscription(_ data: Data) async throws {
let incoming = try await Task.detached(priority: .userInitiated) { [importer] in
try importer.decode(data)
}.value
var updated = sources
for var source in incoming {
if let index = updated.firstIndex(where: { $0.bookSourceUrl == source.bookSourceUrl }) {
guard updated[index].lastUpdateTime < source.lastUpdateTime else { continue }
source.bookSourceGroup = updated[index].bookSourceGroup
updated[index] = source
} else { updated.append(source) }
}
try await repository.save(updated)
sources = updated
}
func previewFiles(_ urls: [URL]) async {
isWorking = true
defer { isWorking = false }
do {
var incoming: [String: BookSource] = [:]
var invalidItems: [PendingImport.InvalidItem] = []
for url in urls {
let granted = url.startAccessingSecurityScopedResource()
defer { if granted { url.stopAccessingSecurityScopedResource() } }
let data = try await Task.detached(priority: .userInitiated) { [importer] in
try importer.normalizedData(Data(contentsOf: url))
}.value
var budget = 100
let batch = try await resolveImport(String(decoding: data, as: UTF8.self), depth: 0, visited: [], budget: &budget)
invalidItems.append(contentsOf: batch.invalidItems)
for source in batch.sources {
incoming[source.id] = source
}
}
// UIDocumentPicker is still dismissing when its completion runs.
// Presenting the review sheet in that same frame is ignored by
// SwiftUI, so stage it after the picker has left the hierarchy.
try? await Task.sleep(nanoseconds: 300_000_000)
stage(Array(incoming.values), invalidItems: invalidItems)
} catch {
notice = .init(kind: .error, title: "书源导入失败", detail: error.localizedDescription)
}
}
func previewText(_ text: String) async { await previewInput(text) }
func previewRemoteURL(_ value: String) async { await previewInput(value) }
func previewInput(_ value: String) async {
isWorking = true
defer { isWorking = false }
do {
var budget = 100
let batch = try await resolveImport(value, depth: 0, visited: [], budget: &budget)
stage(batch.sources, invalidItems: batch.invalidItems)
} catch {
notice = .init(kind: .error, title: "书源导入失败", detail: error.localizedDescription)
}
}
private nonisolated func resolveImport(_ value: String, depth: Int, visited: Set<String>, budget: inout Int) async throws -> (
sources: [BookSource], invalidItems: [PendingImport.InvalidItem]
) {
guard depth < 8 else { throw BookSourceImportError.invalidDocument("订阅清单嵌套过深") }
let input = try RuleImportInput.parse(value)
let data: Data
var visited = visited
switch input {
case .json(let json): data = Data(json.utf8)
case .remote(let url, _):
guard budget > 0 else { throw BookSourceImportError.invalidDocument("订阅清单的总链接数超过 100") }
budget -= 1
guard visited.insert(url.absoluteString).inserted else {
throw BookSourceImportError.invalidDocument("订阅清单出现循环链接")
}
let response = try await importTransport.data(for: input.request()!)
guard (200...299).contains(response.statusCode) else {
throw BookSourceImportError.invalidDocument("服务器返回 HTTP \(response.statusCode)")
}
data = response.data
}
let normalized = try importer.normalizedData(data)
if case .array(let urls) = (try JSONValue.decode(data: normalized))["sourceUrls"] {
guard !urls.isEmpty, urls.count <= 100 else { throw BookSourceImportError.invalidDocument("订阅清单需包含 1–100 个链接") }
var sources: [BookSource] = []
var invalid: [PendingImport.InvalidItem] = []
for item in urls {
guard case .string(let url) = item else { throw BookSourceImportError.invalidDocument("sourceUrls 必须是链接数组") }
let batch = try await resolveImport(url, depth: depth + 1, visited: visited, budget: &budget)
sources += batch.sources
invalid += batch.invalidItems
}
return (sources, invalid)
}
return try validatedSources(from: normalized)
}
func cancelPendingImport() { pendingImport = nil }
func commitPendingImport(
selectedIDs: Set<String>,
keepOriginalName: Bool,
keepOriginalGroup: Bool,
keepOriginalEnabled: Bool,
customGroup: String?,
addCustomGroup: Bool
) async {
guard let pendingImport else { return }
var updated = sources
var inserted = 0
var replaced = 0
for item in pendingImport.items where selectedIDs.contains(item.id) {
var source = item.source
if let existing = item.existing {
if keepOriginalName { source.bookSourceName = existing.bookSourceName }
if keepOriginalGroup { source.bookSourceGroup = existing.bookSourceGroup }
if keepOriginalEnabled {
source.enabled = existing.enabled
source.enabledExplore = existing.enabledExplore
}
source.customOrder = existing.customOrder
}
if let customGroup, !BookSource.parseGroups(customGroup).isEmpty {
if addCustomGroup { source.addGroups(customGroup) }
else { source.bookSourceGroup = BookSource.joinGroups(BookSource.parseGroups(customGroup)) }
}
if let index = updated.firstIndex(where: { $0.id == source.id }) {
updated[index] = source
replaced += 1
} else {
updated.append(source)
inserted += 1
}
}
do {
try await repository.save(updated)
sources = updated
self.pendingImport = nil
notice = .init(
kind: .success,
title: "书源导入完成",
detail: "新增 \(inserted) 个,更新 \(replaced) 个"
)
} catch {
notice = .init(kind: .error, title: "保存失败", detail: error.localizedDescription)
return
}
}
func setExploreEnabled(_ enabled: Bool, for sourceID: String) async {
var updated = sources
guard let index = updated.firstIndex(where: { $0.id == sourceID }) else { return }
updated[index].enabledExplore = enabled
await persist(updated, successTitle: nil)
}
func saveSource(_ source: BookSource, replacing oldSourceID: String? = nil) async -> Bool {
let errors = BookSourceValidator.validate(source).filter { $0.severity == .error }
guard errors.isEmpty else {
notice = .init(kind: .error, title: "书源无效", detail: errors.map(\.message).joined(separator: ";"))
return false
}
let oldSourceID = oldSourceID ?? source.id
var updated = sources
if updated.contains(where: { $0.id == oldSourceID }) {
if source.id != oldSourceID, updated.contains(where: { $0.id == source.id }) {
notice = .init(kind: .error, title: "地址冲突", detail: "该地址已有书源,请使用导入预览选择更新。")
return false
}
guard let replacement = updated.firstIndex(where: { $0.id == oldSourceID }) else { return false }
updated[replacement] = source
} else if let index = updated.firstIndex(where: { $0.id == source.id }) {
updated[index] = source
} else {
updated.append(source)
}
return await persist(updated, successTitle: "书源已保存")
}
func setEnabled(_ enabled: Bool, for sourceIDs: Set<String>) async {
guard !sourceIDs.isEmpty else { return }
var updated = sources
for index in updated.indices where sourceIDs.contains(updated[index].id) {
updated[index].enabled = enabled
}
await persist(updated, successTitle: enabled ? "已批量启用" : "已批量停用")
}
func addGroup(_ group: String, to sourceIDs: Set<String>) async {
let values = BookSource.parseGroups(group)
guard !values.isEmpty, !sourceIDs.isEmpty else { return }
var updated = sources
for index in updated.indices where sourceIDs.contains(updated[index].id) {
updated[index].addGroups(values.joined(separator: ","))
}
await persist(updated, successTitle: "已加入分组 \(values.joined(separator: "、"))")
}
func removeGroup(_ group: String, from sourceIDs: Set<String>) async {
guard !sourceIDs.isEmpty else { return }
var updated = sources
for index in updated.indices where sourceIDs.contains(updated[index].id) {
updated[index].removeGroups(group)
}
await persist(updated, successTitle: "已移出分组")
}
func renameGroup(_ oldValue: String, to newValue: String) async {
guard !BookSource.parseGroups(newValue).isEmpty else { return }
var updated = sources
for index in updated.indices where updated[index].hasGroup(oldValue) {
updated[index].replaceGroup(oldValue, with: newValue)
}
await persist(updated, successTitle: "分组已重命名")
}
func deleteGroup(_ group: String) async {
var updated = sources
for index in updated.indices where updated[index].hasGroup(group) {
updated[index].removeGroups(group)
}
await persist(updated, successTitle: "已删除分组 \(group)")
}
func handleDeepLink(_ url: URL) async {
if url.isFileURL {
await previewFiles([url])
return
}
await previewInput(url.absoluteString)
}
@discardableResult
private func persist(_ updated: [BookSource], successTitle: String?) async -> Bool {
do {
try await repository.save(updated)
sources = updated
if let successTitle {
notice = .init(kind: .success, title: successTitle, detail: "")
}
return true
} catch {
notice = .init(kind: .error, title: "保存失败", detail: error.localizedDescription)
return false
}
}
private func applyImport(_ data: Data, successTitle: String) async throws {
let result = try await Task.detached(priority: .userInitiated) { [importer, existing = sources] in
try importer.prepare(data: data, existing: existing, duplicatePolicy: .replace)
}.value
try await repository.save(result.sources)
sources = result.sources
notice = .init(
kind: .success,
title: successTitle,
detail: "新增 \(result.inserted) 个,覆盖 \(result.replaced) 个,警告 \(result.warnings.count) 条"
)
}
private nonisolated func validatedSources(from data: Data) throws -> (
sources: [BookSource],
invalidItems: [PendingImport.InvalidItem]
) {
let result = try importer.inspect(data)
return (result.sources, result.rejected.map { .init(source: $0.source, issues: $0.issues) })
}
private func stage(
_ incoming: [BookSource],
invalidItems: [PendingImport.InvalidItem] = []
) {
let existingByID = Dictionary(uniqueKeysWithValues: sources.map { ($0.id, $0) })
let unique = incoming.reduce(into: [String: BookSource]()) { $0[$1.id] = $1 }
pendingImport = .init(items: Array(unique.values)
.sorted { $0.bookSourceName.localizedStandardCompare($1.bookSourceName) == .orderedAscending }
.map { .init(source: $0, existing: existingByID[$0.id]) },
invalidItems: invalidItems)
}
}
+324
View File
@@ -0,0 +1,324 @@
@preconcurrency import WebKit
import SwiftUI
struct SourceLoginView: View {
let source: BookSource
@State private var reloadID = UUID()
@State private var status = "打开登录页后,Cookie 会自动同步给搜索与阅读请求。"
@State private var isChecking = false
var body: some View {
VStack(spacing: 0) {
HStack(spacing: 10) {
Image(systemName: "lock.shield.fill").foregroundStyle(ReadingTheme.accent)
Text(status).font(.caption).foregroundStyle(.secondary).lineLimit(2)
Spacer()
if isChecking { ProgressView().controlSize(.small) }
}
.padding(.horizontal, 14).padding(.vertical, 10)
.background(Color(uiColor: .secondarySystemBackground))
if let request = loginRequest {
SourceLoginWebView(
request: request,
sourceURL: source.bookSourceUrl,
sourceID: source.id,
loginCheckJavaScript: source.loginCheckJs,
reloadID: reloadID,
status: $status,
isChecking: $isChecking
)
} else {
ReadingUnavailableView("登录地址无效", systemImage: "link.badge.plus", description: Text(source.loginUrl ?? "书源没有配置登录地址"))
}
}
.navigationTitle("登录 · \(source.bookSourceName)")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("重新载入", systemImage: "arrow.clockwise") { reloadID = UUID() }
}
}
}
private var loginRequest: URLRequest? {
guard let value = source.loginUrl,
let prepared = try? LegadoURLBuilder().prepare(
value,
baseURL: URL(string: source.bookSourceUrl),
defaultHeaders: sourceHeaders
) else { return nil }
return prepared.urlRequest(handleCookies: source.enabledCookieJar ?? true)
}
private var sourceHeaders: [String: String] {
guard let value = source.header,
let data = value.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [:] }
return object.mapValues { String(describing: $0) }
}
}
private struct SourceLoginWebView: UIViewRepresentable {
let request: URLRequest
let sourceURL: String
let sourceID: String
let loginCheckJavaScript: String?
let reloadID: UUID
@Binding var status: String
@Binding var isChecking: Bool
func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
func makeUIView(context: Context) -> WKWebView {
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = .default()
let webView = WKWebView(frame: .zero, configuration: configuration)
webView.navigationDelegate = context.coordinator
context.coordinator.installExistingCookies(in: webView) { webView.load(request) }
return webView
}
func updateUIView(_ webView: WKWebView, context: Context) {
context.coordinator.parent = self
guard context.coordinator.lastReloadID != reloadID else { return }
context.coordinator.lastReloadID = reloadID
webView.reload()
}
final class Coordinator: NSObject, WKNavigationDelegate {
var parent: SourceLoginWebView
var lastReloadID: UUID
init(parent: SourceLoginWebView) {
self.parent = parent
lastReloadID = parent.reloadID
}
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation?) {
parent.status = "正在打开登录页…"
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation?) {
syncCookies(from: webView)
guard let script = parent.loginCheckJavaScript?.trimmingCharacters(in: .whitespacesAndNewlines),
!script.isEmpty else {
parent.status = "页面已载入;登录后 Cookie 会自动同步。"
return
}
parent.isChecking = true
webView.evaluateJavaScript(script) { [weak self] value, error in
guard let self else { return }
DispatchQueue.main.async {
self.parent.isChecking = false
if let error {
self.parent.status = "登录检测脚本失败:\(error.localizedDescription)"
} else {
self.parent.status = Self.truthy(value) ? "书源登录检测已通过" : "页面已载入,尚未检测到登录状态"
}
}
}
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation?, withError error: Error) {
parent.status = "登录页加载失败:\(error.localizedDescription)"
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation?, withError error: Error) {
parent.status = "登录页加载失败:\(error.localizedDescription)"
}
func installExistingCookies(in webView: WKWebView, completion: @escaping () -> Void) {
guard let url = URL(string: parent.sourceURL) else {
completion()
return
}
let cookies = HTTPCookieStorage.shared.cookies(for: url) ?? []
guard !cookies.isEmpty else {
completion()
return
}
let group = DispatchGroup()
for cookie in cookies {
group.enter()
webView.configuration.websiteDataStore.httpCookieStore.setCookie(cookie) { group.leave() }
}
group.notify(queue: .main, execute: completion)
}
private func syncCookies(from webView: WKWebView) {
webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { cookies in
cookies.forEach(HTTPCookieStorage.shared.setCookie)
guard let url = URL(string: self.parent.sourceURL) else { return }
let matching = HTTPCookieStorage.shared.cookies(for: url) ?? []
let header = HTTPCookie.requestHeaderFields(with: matching)["Cookie"] ?? ""
Task {
await SourceNetworkPolicy.shared.setCookieHeader(
header,
sourceID: self.parent.sourceID,
url: url
)
}
}
}
private static func truthy(_ value: Any?) -> Bool {
if let value = value as? Bool { return value }
if let value = value as? NSNumber { return value.boolValue }
guard let text = value.map(String.init(describing:))?.lowercased() else { return false }
return !text.isEmpty && !["false", "0", "null", "undefined"].contains(text)
}
}
}
final class WKSourceWebViewExecutor: NSObject, SourceWebViewRequestExecuting, @unchecked Sendable {
@MainActor
func execute(_ prepared: PreparedRequest, source: BookSource, timeout: TimeInterval) async throws -> HTTPResponse {
let override = ApplicationSettingsStore.shared.settings.timeoutSeconds
return try await BackgroundWebViewRequest(prepared: prepared, source: source, timeout: override > 0 ? Double(override) : timeout).run()
}
}
@MainActor
private final class BackgroundWebViewRequest: NSObject, WKNavigationDelegate {
private let prepared: PreparedRequest
private let source: BookSource
private let timeout: TimeInterval
private let webView: WKWebView
private var continuation: CheckedContinuation<HTTPResponse, Error>?
private var responseStatus = 200
private var responseHeaders: [String: String] = [:]
private var completed = false
private var timeoutTask: Task<Void, Never>?
init(prepared: PreparedRequest, source: BookSource, timeout: TimeInterval) {
self.prepared = prepared
self.source = source
self.timeout = timeout
let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = .nonPersistent()
webView = WKWebView(frame: .zero, configuration: configuration)
super.init()
webView.navigationDelegate = self
}
func run() async throws -> HTTPResponse {
try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
self.continuation = continuation
installRequestCookies { [weak self] in self?.startLoading() }
}
} onCancel: {
Task { @MainActor [weak self] in self?.finish(.failure(CancellationError())) }
}
}
private func startLoading() {
timeoutTask = Task { [weak self] in
let seconds = min(max(self?.timeout ?? 30, 1), 300)
try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
guard !Task.isCancelled else { return }
await MainActor.run { self?.finish(.failure(URLError(.timedOut))) }
}
webView.load(prepared.urlRequest(timeout: timeout, handleCookies: true))
}
private func installRequestCookies(completion: @escaping () -> Void) {
guard let header = prepared.headers.first(where: { $0.key.caseInsensitiveCompare("Cookie") == .orderedSame })?.value,
let host = prepared.url.host, !header.isEmpty else {
completion()
return
}
let cookies = header.split(separator: ";").compactMap { part -> HTTPCookie? in
let pair = part.split(separator: "=", maxSplits: 1).map(String.init)
guard pair.count == 2 else { return nil }
return HTTPCookie(properties: [
.name: pair[0].trimmingCharacters(in: .whitespaces),
.value: pair[1],
.domain: host,
.path: "/",
.secure: prepared.url.scheme == "https" ? "TRUE" : "FALSE"
])
}
guard !cookies.isEmpty else { completion(); return }
let group = DispatchGroup()
for cookie in cookies {
group.enter()
webView.configuration.websiteDataStore.httpCookieStore.setCookie(cookie) { group.leave() }
}
group.notify(queue: .main, execute: completion)
}
func webView(
_ webView: WKWebView,
decidePolicyFor navigationResponse: WKNavigationResponse,
decisionHandler: @escaping @MainActor (WKNavigationResponsePolicy) -> Void
) {
if let response = navigationResponse.response as? HTTPURLResponse {
responseStatus = response.statusCode
responseHeaders = response.allHeaderFields.reduce(into: [:]) { result, entry in
result[String(describing: entry.key)] = String(describing: entry.value)
}
}
decisionHandler(.allow)
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation?) {
Task { [weak self] in
guard let self else { return }
if prepared.webViewDelayMilliseconds > 0 {
try? await Task.sleep(nanoseconds: UInt64(max(0, prepared.webViewDelayMilliseconds)) * 1_000_000)
}
guard !Task.isCancelled, !completed else { return }
if let script = prepared.webJavaScript, !script.isEmpty {
do { _ = try await webView.evaluateJavaScript(script) }
catch { finish(.failure(error)); return }
}
do {
let html = try await webView.evaluateJavaScript("document.documentElement.outerHTML") as? String ?? ""
let cookies = await webView.configuration.websiteDataStore.httpCookieStore.allCookies()
cookies.forEach(HTTPCookieStorage.shared.setCookie)
let cookieHeader = HTTPCookie.requestHeaderFields(with: cookies)["Cookie"] ?? ""
if !cookieHeader.isEmpty {
await SourceNetworkPolicy.shared.setCookieHeader(cookieHeader, sourceID: source.id, url: prepared.url)
}
var headers = responseHeaders
if !cookieHeader.isEmpty { headers["Set-Cookie"] = cookieHeader }
finish(.success(.init(
data: Data(html.utf8),
statusCode: responseStatus,
headers: headers,
finalURL: webView.url ?? prepared.url,
requestedCharset: "utf-8"
)))
} catch {
finish(.failure(error))
}
}
}
func webView(_ webView: WKWebView, didFail navigation: WKNavigation?, withError error: Error) {
finish(.failure(error))
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation?, withError error: Error) {
finish(.failure(error))
}
private func finish(_ result: Result<HTTPResponse, Error>) {
guard !completed else { return }
completed = true
timeoutTask?.cancel()
webView.stopLoading()
continuation?.resume(with: result)
continuation = nil
}
}
private extension WKHTTPCookieStore {
func allCookies() async -> [HTTPCookie] {
await withCheckedContinuation { continuation in
getAllCookies { continuation.resume(returning: $0) }
}
}
}
@@ -0,0 +1,695 @@
import SwiftUI
import UIKit
import UniformTypeIdentifiers
struct SourceManagementView: View {
@EnvironmentObject private var library: SourceLibraryModel
@State private var showsImporter = false
@State private var showsNewSource = false
@State private var showsQRImporter = false
@State private var showsTextImporter = false
@State private var showsGroupManager = false
@State private var showsBatchGroups = false
@State private var selectedGroup: String?
@State private var searchText = ""
@State private var isSelecting = false
@State private var selectedSourceIDs = Set<String>()
@AppStorage("bookSourceSort") private var sortMode = "manual"
@AppStorage("bookSourceSortDescending") private var sortDescending = false
private var filteredSources: [BookSource] {
let filtered = library.sources.filter { source in
let matchesGroup: Bool
if let selectedGroup {
matchesGroup = selectedGroup.isEmpty ? source.groups.isEmpty : source.hasGroup(selectedGroup)
} else {
matchesGroup = true
}
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
return matchesGroup && (query.isEmpty
|| source.bookSourceName.localizedCaseInsensitiveContains(query)
|| source.bookSourceUrl.localizedCaseInsensitiveContains(query)
|| source.groups.contains(where: { $0.localizedCaseInsensitiveContains(query) }))
}
guard sortMode != "manual" else { return filtered }
return filtered.sorted { lhs, rhs in
sortDescending ? isOrdered(rhs, before: lhs) : isOrdered(lhs, before: rhs)
}
}
private func isOrdered(_ lhs: BookSource, before rhs: BookSource) -> Bool {
switch sortMode {
case "weight":
return lhs.weight == rhs.weight ? lhs.bookSourceName.localizedStandardCompare(rhs.bookSourceName) == .orderedAscending : lhs.weight < rhs.weight
case "name": return lhs.bookSourceName.localizedStandardCompare(rhs.bookSourceName) == .orderedAscending
case "url": return lhs.bookSourceUrl.localizedStandardCompare(rhs.bookSourceUrl) == .orderedAscending
case "update": return lhs.lastUpdateTime == rhs.lastUpdateTime ? lhs.bookSourceName < rhs.bookSourceName : lhs.lastUpdateTime > rhs.lastUpdateTime
case "respond": return lhs.respondTime == rhs.respondTime ? lhs.bookSourceName < rhs.bookSourceName : lhs.respondTime < rhs.respondTime
case "enabled": return lhs.enabled == rhs.enabled ? lhs.bookSourceName < rhs.bookSourceName : lhs.enabled && !rhs.enabled
default: return lhs.customOrder < rhs.customOrder
}
}
var body: some View {
Group {
if library.sources.isEmpty, !library.isWorking {
ReadingUnavailableView {
Label("还没有书源", systemImage: "network")
} description: {
Text("新建书源,或从文件、链接、JSON、二维码导入。")
} actions: {
Button("导入书源") { showsImporter = true }
.buttonStyle(.borderedProminent)
}
} else {
List {
Section {
Picker("分组", selection: $selectedGroup) {
Text("全部").tag(String?.none)
Text("未分组").tag(String?.some(""))
ForEach(library.groups, id: \.self) { group in
Text(group).tag(String?.some(group))
}
}
}
Section {
ForEach(filteredSources) { source in
if isSelecting {
Button {
if !selectedSourceIDs.insert(source.id).inserted { selectedSourceIDs.remove(source.id) }
} label: {
HStack(spacing: 10) {
Image(systemName: selectedSourceIDs.contains(source.id) ? "checkmark.circle.fill" : "circle")
.font(.title3)
.foregroundStyle(selectedSourceIDs.contains(source.id) ? ReadingTheme.accent : .secondary)
SourceRow(source: source, showsToggle: false) { _ in }
}
}
.buttonStyle(.plain)
} else {
NavigationLink {
SourceDetailView(source: source)
} label: {
SourceRow(source: source) { enabled in
Task { await library.setEnabled(enabled, for: source.id) }
}
}
.contextMenu {
NavigationLink {
SearchView(fixedSource: source)
} label: {
Label("搜索", systemImage: "magnifyingglass")
}
Button(source.enabledExplore ? "停用发现" : "启用发现", systemImage: "safari") {
Task { await library.setExploreEnabled(!source.enabledExplore, for: source.id) }
}
Button("删除", systemImage: "trash", role: .destructive) {
Task { await library.remove(sourceIDs: [source.id]) }
}
}
}
}
.onDelete { offsets in
let ids = Set(offsets.compactMap { filteredSources.indices.contains($0) ? filteredSources[$0].id : nil })
Task { await library.remove(sourceIDs: ids) }
}
} header: {
Text("\(filteredSources.count) 个书源")
} footer: {
Text("共 \(library.sources.count) 个书源,\(library.sources.filter(\.enabled).count) 个已启用")
}
}
.listStyle(.insetGrouped)
.readingScrollContentBackgroundHidden()
.background(ReadingTheme.groupedCanvas)
.overlay {
if library.isWorking { ProgressView() }
}
}
}
.navigationTitle("书源")
.background(ReadingTheme.groupedCanvas)
.searchable(text: $searchText, prompt: "名称、地址或分组")
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button(isSelecting ? "完成" : "选择") {
isSelecting.toggle()
if !isSelecting { selectedSourceIDs.removeAll() }
}
.disabled(library.sources.isEmpty)
}
ToolbarItem(placement: .primaryAction) {
Menu("更多", systemImage: "ellipsis.circle") {
Button("新建书源", systemImage: "plus") { showsNewSource = true }
Button("二维码导入", systemImage: "qrcode.viewfinder") { showsQRImporter = true }
Button("从文件导入", systemImage: "doc.badge.plus") { showsImporter = true }
Button("URL 或粘贴 JSON", systemImage: "link.badge.plus") { showsTextImporter = true }
Divider()
NavigationLink {
SourceBatchTestView()
} label: {
Label("测试书源", systemImage: "checkmark.circle.badge.questionmark")
}
Button("管理分组", systemImage: "folder.badge.gearshape") { showsGroupManager = true }
}
}
ToolbarItem(placement: .topBarTrailing) {
Menu("排序", systemImage: "arrow.up.arrow.down") {
Toggle("倒序", isOn: $sortDescending)
Picker("排序方式", selection: $sortMode) {
Text("手动排序").tag("manual")
Text("自动排序").tag("weight")
Text("按名称").tag("name")
Text("按网址").tag("url")
Text("按更新时间").tag("update")
Text("按响应时间").tag("respond")
Text("按启用状态").tag("enabled")
}
}
}
}
.safeAreaInset(edge: .bottom) {
if isSelecting {
HStack(spacing: 18) {
Button(selectedSourceIDs.count == filteredSources.count ? "取消全选" : "全选") {
if selectedSourceIDs.count == filteredSources.count { selectedSourceIDs.removeAll() }
else { selectedSourceIDs = Set(filteredSources.map(\.id)) }
}
Spacer()
Button("分组", systemImage: "folder") { showsBatchGroups = true }
.disabled(selectedSourceIDs.isEmpty)
Button("启用", systemImage: "checkmark.circle") {
Task { await library.setEnabled(true, for: selectedSourceIDs) }
}
.disabled(selectedSourceIDs.isEmpty)
Button("停用", systemImage: "pause.circle") {
Task { await library.setEnabled(false, for: selectedSourceIDs) }
}
.disabled(selectedSourceIDs.isEmpty)
}
.font(.subheadline.weight(.semibold))
.padding(.horizontal, 18).padding(.vertical, 12)
.background(.ultraThinMaterial)
}
}
.sheet(isPresented: $showsNewSource) { SourceEditorView().environmentObject(library) }
.sheet(isPresented: $showsQRImporter) {
QRCodeImportView { value in Task { await library.previewInput(value) } }
}
.sheet(isPresented: $showsTextImporter) {
SourceTextImportView()
.environmentObject(library)
}
.sheet(isPresented: $showsGroupManager) {
SourceGroupManagerView().environmentObject(library)
}
.sheet(isPresented: $showsBatchGroups) {
SourceBatchGroupView(sourceIDs: selectedSourceIDs).environmentObject(library)
}
.fileImporter(
isPresented: $showsImporter,
allowedContentTypes: [.item],
allowsMultipleSelection: false
) { result in
switch result {
case .success(let urls):
let files = urls.filter { !$0.hasDirectoryPath }
if files.isEmpty {
library.notice = .init(kind: .error, title: "无法选择文件", detail: "请选择书源 JSON 文件,而不是文件夹。")
} else {
Task { await library.previewFiles(files) }
}
case .failure(let error):
library.notice = .init(kind: .error, title: "无法选择文件", detail: error.localizedDescription)
}
}
}
}
private struct SourceTextImportView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var library: SourceLibraryModel
@State private var mode = 0
@State private var value = ""
var body: some View {
ReadingNavigationStack {
Form {
Picker("导入方式", selection: $mode) {
Text("URL").tag(0)
Text("粘贴 JSON").tag(1)
}
.pickerStyle(.segmented)
Section(mode == 0 ? "书源 URL" : "书源 JSON") {
if mode == 0 {
TextField("https://example.com/sources.json", text: $value)
.textInputAutocapitalization(.never)
.keyboardType(.URL)
} else {
SyntaxCodeEditor(text: $value, language: .json)
.frame(minHeight: 220)
}
}
Section {
Text("导入前可预览并选择书源。地址相同的书源将被替换。")
.font(.footnote).foregroundStyle(.secondary)
}
}
.navigationTitle("导入书源")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
ToolbarItem(placement: .confirmationAction) {
Button("导入") {
let input = value
let selectedMode = mode
dismiss()
Task {
try? await Task.sleep(nanoseconds: 350_000_000)
if selectedMode == 0 { await library.previewRemoteURL(input) }
else { await library.previewText(input) }
}
}
.disabled(value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
}
}
private struct SourceRow: View {
let source: BookSource
var showsToggle = true
let onEnabledChanged: @MainActor @Sendable (Bool) -> Void
var body: some View {
HStack(spacing: 12) {
Image(systemName: source.enabled ? "network" : "network.slash")
.font(.system(size: 20))
.frame(width: 28)
.foregroundStyle(.secondary)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text(source.bookSourceName)
.font(.body)
.foregroundStyle(source.enabled ? Color.primary : .secondary)
Text(source.groups.isEmpty ? (URL(string: source.bookSourceUrl)?.host ?? source.bookSourceUrl) : source.groups.joined(separator: " · "))
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
if showsToggle {
Toggle("启用", isOn: Binding(
get: { source.enabled },
set: { enabled in
Task { @MainActor in onEnabledChanged(enabled) }
}
))
.labelsHidden()
.tint(.green)
.accessibilityLabel("启用\(source.bookSourceName)")
}
}
.padding(.vertical, 5)
}
}
private struct SourceGroupManagerView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var library: SourceLibraryModel
@State private var newGroup = ""
@State private var renaming: String?
@State private var renamedValue = ""
var body: some View {
ReadingNavigationStack {
List {
Section("新建分组") {
HStack {
TextField("例如:文学", text: $newGroup)
Button("添加") {
let value = newGroup
newGroup = ""
let ungrouped = Set(library.sources.filter { $0.groups.isEmpty }.map(\.id))
Task { await library.addGroup(value, to: ungrouped) }
}
.disabled(BookSource.parseGroups(newGroup).isEmpty)
}
Text("新分组会分配给当前未分组书源,与 Legado 的分组管理行为一致。")
.font(.caption).foregroundStyle(.secondary)
}
Section("已有分组") {
ForEach(library.groups, id: \.self) { group in
HStack {
Label(group, systemImage: "folder.fill")
Spacer()
Text("\(library.sources.filter { $0.hasGroup(group) }.count)")
.foregroundStyle(.secondary)
Menu {
Button("重命名", systemImage: "pencil") {
renaming = group
renamedValue = group
}
Button("删除分组", systemImage: "trash", role: .destructive) {
Task { await library.deleteGroup(group) }
}
} label: { Image(systemName: "ellipsis.circle") }
}
}
}
}
.navigationTitle("书源分组")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("完成") { dismiss() } } }
.alert("重命名分组", isPresented: Binding(
get: { renaming != nil },
set: { if !$0 { renaming = nil } }
)) {
TextField("分组名称", text: $renamedValue)
Button("取消", role: .cancel) { renaming = nil }
Button("保存") {
if let old = renaming { Task { await library.renameGroup(old, to: renamedValue) } }
renaming = nil
}
}
}
}
}
private struct SourceBatchGroupView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var library: SourceLibraryModel
let sourceIDs: Set<String>
@State private var customGroup = ""
var body: some View {
ReadingNavigationStack {
List {
Section("已选择 \(sourceIDs.count) 个书源") {
HStack {
TextField("输入新分组", text: $customGroup)
Button("加入") {
let value = customGroup
customGroup = ""
Task { await library.addGroup(value, to: sourceIDs) }
}
.disabled(BookSource.parseGroups(customGroup).isEmpty)
}
}
Section("加入现有分组") {
ForEach(library.groups, id: \.self) { group in
Button { Task { await library.addGroup(group, to: sourceIDs) } } label: {
Label(group, systemImage: "folder.badge.plus")
}
}
}
Section("从分组移除") {
ForEach(library.groups, id: \.self) { group in
Button(role: .destructive) {
Task { await library.removeGroup(group, from: sourceIDs) }
} label: { Label(group, systemImage: "folder.badge.minus") }
}
}
}
.navigationTitle("批量归组")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("完成") { dismiss() } } }
}
}
}
private struct SourceDetailView: View {
@EnvironmentObject private var library: SourceLibraryModel
let source: BookSource
@State private var showsEditor = false
@State private var editedSourceID: String?
private var current: BookSource { library.sources.first { $0.id == (editedSourceID ?? source.id) } ?? source }
var body: some View {
List {
Section("基本信息") {
ReadingLabeledContent("名称", value: current.bookSourceName)
ReadingLabeledContent("地址", value: current.bookSourceUrl)
ReadingLabeledContent("分组", value: current.bookSourceGroup ?? "未分组")
ReadingLabeledContent("类型", value: String(current.bookSourceType))
}
Section("能力") {
CapabilityRow(title: "搜索", available: current.searchUrl?.isEmpty == false)
CapabilityRow(title: "发现", available: current.exploreUrl?.isEmpty == false)
CapabilityRow(title: "详情", available: current.ruleBookInfo != nil)
CapabilityRow(title: "目录", available: current.ruleToc != nil)
CapabilityRow(title: "正文", available: current.ruleContent != nil)
}
Section("状态") {
Toggle("启用书源", isOn: Binding(
get: { library.sources.first(where: { $0.id == current.id })?.enabled ?? current.enabled },
set: { value in Task { await library.setEnabled(value, for: current.id) } }
))
Toggle("启用发现", isOn: Binding(
get: { library.sources.first(where: { $0.id == current.id })?.enabledExplore ?? current.enabledExplore },
set: { value in Task { await library.setExploreEnabled(value, for: current.id) } }
))
}
Section("兼容检查") {
NavigationLink {
SearchView(fixedSource: current)
} label: {
Label("搜索此书源", systemImage: "magnifyingglass")
}
NavigationLink {
SourceDebugView(source: current)
} label: {
Label("运行端到端调试", systemImage: "stethoscope")
}
Text("使用你输入的关键词,逐步验证搜索、详情、目录和正文规则。")
.font(.footnote).foregroundStyle(.secondary)
}
if current.loginUrl?.isEmpty == false {
Section("账户") {
NavigationLink {
SourceLoginView(source: current)
} label: {
Label("登录此书源", systemImage: "person.badge.key.fill")
}
Text("登录 Cookie 与阅读请求共享;若书源提供检测脚本,会在页面载入后自动验证。")
.font(.footnote).foregroundStyle(.secondary)
}
}
if let comment = current.bookSourceComment, !comment.isEmpty {
Section("说明") { Text(comment) }
}
}
.navigationTitle(current.bookSourceName)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("编辑") { showsEditor = true }
}
}
.sheet(isPresented: $showsEditor) {
SourceEditorView(source: current) { editedSourceID = $0.id }.environmentObject(library)
}
}
}
struct SourceImportReviewView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var library: SourceLibraryModel
let pending: SourceLibraryModel.PendingImport
@State private var selectedIDs: Set<String>
@State private var importError: String?
@State private var isImporting = false
@State private var keepOriginalName = false
@State private var keepOriginalGroup = false
@State private var keepOriginalEnabled = false
@State private var showComments = false
@State private var customGroup = ""
@State private var addCustomGroup = true
init(pending: SourceLibraryModel.PendingImport) {
self.pending = pending
_selectedIDs = State(initialValue: Set(pending.items.filter { $0.existing == nil || $0.state == "更新" }.map(\.id)))
}
var body: some View {
ReadingNavigationStack {
List {
Section {
HStack(spacing: 10) {
importCountCard(
title: "有效",
count: pending.validCount,
color: .green,
systemImage: "checkmark.circle.fill"
)
importCountCard(
title: "失效",
count: pending.invalidCount,
color: .red,
systemImage: "xmark.circle.fill"
)
importCountCard(
title: "总计",
count: pending.totalCount,
color: .secondary,
systemImage: "doc.text.fill"
)
}
.listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16))
Label("仅有效书源可被选择和导入", systemImage: "checkmark.shield.fill")
.font(.footnote)
.foregroundStyle(.secondary)
}
if !pending.items.isEmpty {
Section("导入设置") {
HStack {
TextField("自定义分组", text: $customGroup)
Toggle("追加", isOn: $addCustomGroup).labelsHidden()
}
Toggle("保留原名称", isOn: $keepOriginalName)
Toggle("保留原分组", isOn: $keepOriginalGroup)
Toggle("保留原启用状态", isOn: $keepOriginalEnabled)
Toggle("显示书源备注", isOn: $showComments)
}
}
Section("有效书源(\(pending.validCount))") {
ForEach(pending.items) { item in
Button {
if !selectedIDs.insert(item.id).inserted { selectedIDs.remove(item.id) }
} label: {
HStack(alignment: .top, spacing: 10) {
Image(systemName: selectedIDs.contains(item.id) ? "checkmark.square.fill" : "square")
.foregroundStyle(selectedIDs.contains(item.id) ? ReadingTheme.accent : .secondary)
VStack(alignment: .leading, spacing: 4) {
Text(item.source.bookSourceName).foregroundStyle(.primary)
HStack {
Text(item.state)
.font(.caption.bold())
.foregroundStyle(item.state == "新增" ? .green : item.state == "更新" ? .orange : .secondary)
Text(item.source.bookSourceUrl)
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
if showComments, let comment = item.source.bookSourceComment, !comment.isEmpty {
Text(comment).font(.caption).foregroundStyle(.secondary)
}
}
}
}
}
}
if !pending.invalidItems.isEmpty {
Section("失效书源(\(pending.invalidCount))") {
ForEach(pending.invalidItems) { item in
HStack(alignment: .top, spacing: 10) {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.red)
VStack(alignment: .leading, spacing: 4) {
Text(item.source.bookSourceName.isEmpty ? "未命名书源" : item.source.bookSourceName)
.foregroundStyle(.primary)
if !item.source.bookSourceUrl.isEmpty {
Text(item.source.bookSourceUrl)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Text(item.issues.map(\.message).joined(separator: ";"))
.font(.caption)
.foregroundStyle(.red)
}
}
}
}
}
}
.navigationTitle("书源导入预检")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") {
library.cancelPendingImport()
dismiss()
}
}
ToolbarItem(placement: .topBarTrailing) {
Menu("选择") {
Button("全选") { selectedIDs = Set(pending.items.map(\.id)) }
Button("取消全选") { selectedIDs.removeAll() }
Button("仅选择新增") {
selectedIDs = Set(pending.items.filter { $0.existing == nil }.map(\.id))
}
Button("仅选择更新") {
selectedIDs = Set(pending.items.filter { $0.existing != nil }.map(\.id))
}
}
.disabled(pending.items.isEmpty)
}
ToolbarItem(placement: .confirmationAction) {
Button("导入") {
let ids = selectedIDs
isImporting = true
Task {
await library.commitPendingImport(
selectedIDs: ids,
keepOriginalName: keepOriginalName,
keepOriginalGroup: keepOriginalGroup,
keepOriginalEnabled: keepOriginalEnabled,
customGroup: customGroup.isEmpty ? nil : customGroup,
addCustomGroup: addCustomGroup
)
isImporting = false
if library.pendingImport == nil { dismiss() }
else {
let message = library.notice?.detail ?? "保存失败,请重试"
library.notice = nil
importError = message
}
}
}
.disabled(selectedIDs.isEmpty || isImporting)
}
}
}
.interactiveDismissDisabled()
.alert("导入保存失败", isPresented: Binding(get: { importError != nil }, set: { if !$0 { importError = nil } })) {
Button("好", role: .cancel) {}
} message: { Text(importError ?? "") }
}
private func importCountCard(
title: String,
count: Int,
color: Color,
systemImage: String
) -> some View {
VStack(spacing: 5) {
Label(title, systemImage: systemImage)
.font(.caption.weight(.semibold))
.foregroundStyle(color)
Text("\(count)")
.font(.title2.bold())
.foregroundStyle(.primary)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 10)
.background(color.opacity(0.1), in: RoundedRectangle(cornerRadius: 12))
}
}
private struct CapabilityRow: View {
let title: String
let available: Bool
var body: some View {
ReadingLabeledContent(title) {
Image(systemName: available ? "checkmark.circle.fill" : "minus.circle")
.foregroundStyle(available ? .green : .secondary)
.accessibilityLabel(available ? "已配置" : "未配置")
}
}
}
@@ -0,0 +1,471 @@
import SwiftUI
import UniformTypeIdentifiers
@MainActor
final class HttpTTSLibraryModel: ObservableObject {
@Published private(set) var engines: [HttpTTS] = []
@Published var errorMessage: String?
private func repository() throws -> SQLiteConfigurationRepository {
SQLiteConfigurationRepository(database: try LegadoDatabase(url: LegadoDatabase.defaultURL()))
}
func load() async {
do {
var values = try await repository().loadHttpTTS()
if values.isEmpty { values = try bundledDefaults() }
engines = sorted(values)
if !values.isEmpty { try await repository().saveHttpTTS(engines) }
} catch {
errorMessage = error.localizedDescription
}
}
func save(_ value: HttpTTS) async {
var value = value
value.lastUpdateTime = Int64(Date().timeIntervalSince1970 * 1_000)
var values = engines.filter { $0.id != value.id }
values.append(value)
await persist(values)
}
func delete(at offsets: IndexSet) async {
var values = engines
values.remove(atOffsets: offsets)
await persist(values)
}
func importData(_ data: Data) async {
do {
let decoder = JSONDecoder()
let imported: [HttpTTS]
if let array = try? decoder.decode([HttpTTS].self, from: data) {
imported = array
} else {
imported = [try decoder.decode(HttpTTS.self, from: data)]
}
var byID = Dictionary(uniqueKeysWithValues: engines.map { ($0.id, $0) })
for value in imported { byID[value.id] = value }
await persist(Array(byID.values))
} catch {
errorMessage = error.localizedDescription
}
}
func restoreDefaults() async {
do {
let defaults = try bundledDefaults()
var byID = Dictionary(uniqueKeysWithValues: engines.filter { $0.id >= 0 }.map { ($0.id, $0) })
for value in defaults { byID[value.id] = value }
await persist(Array(byID.values))
} catch {
errorMessage = error.localizedDescription
}
}
func exportData() throws -> Data {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
return try encoder.encode(engines)
}
private func bundledDefaults() throws -> [HttpTTS] {
guard let url = Bundle.main.url(forResource: "httpTTS", withExtension: "json") else {
throw CocoaError(.fileNoSuchFile)
}
return try JSONDecoder().decode([HttpTTS].self, from: Data(contentsOf: url))
}
private func persist(_ values: [HttpTTS]) async {
do {
let values = sorted(values)
try await repository().saveHttpTTS(values)
engines = values
} catch {
errorMessage = error.localizedDescription
}
}
private func sorted(_ values: [HttpTTS]) -> [HttpTTS] {
values.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
}
}
struct HttpTTSManagementView: View {
@StateObject private var model = HttpTTSLibraryModel()
@State private var editing: HttpTTS?
@State private var importsFile = false
@State private var exportsFile = false
@State private var exportDocument = HttpTTSJSONDocument(data: Data("[]".utf8))
var body: some View {
List {
ForEach(model.engines) { engine in
Button {
editing = engine
} label: {
VStack(alignment: .leading, spacing: 4) {
Text(engine.name).foregroundStyle(.primary)
Text(engine.url).font(.caption).foregroundStyle(.secondary).lineLimit(2)
}
}
}
.onDelete { offsets in Task { await model.delete(at: offsets) } }
}
.overlay {
if model.engines.isEmpty {
ReadingUnavailableView("没有在线朗读引擎", systemImage: "waveform", description: Text("可恢复内置引擎或导入 HTTP TTS JSON。"))
}
}
.navigationTitle("在线朗读引擎")
.toolbar {
ToolbarItemGroup(placement: .topBarTrailing) {
Button("新建", systemImage: "plus") { editing = HttpTTS() }
Menu("更多", systemImage: "ellipsis.circle") {
Button("导入", systemImage: "square.and.arrow.down") { importsFile = true }
Button("导出", systemImage: "square.and.arrow.up") {
do {
exportDocument = .init(data: try model.exportData())
exportsFile = true
} catch { model.errorMessage = error.localizedDescription }
}
Button("恢复内置引擎", systemImage: "arrow.counterclockwise") {
Task { await model.restoreDefaults() }
}
Button("清除朗读缓存", systemImage: "trash") {
Task { await HttpTTSAudioCache.shared.clear() }
}
}
}
}
.task { await model.load() }
.sheet(item: $editing) { engine in
ReadingNavigationStack {
HttpTTSEditorView(engine: engine) { value in
Task { await model.save(value) }
}
}
}
.fileImporter(isPresented: $importsFile, allowedContentTypes: [.json, .data]) { result in
do {
let url = try result.get()
let accessing = url.startAccessingSecurityScopedResource()
defer { if accessing { url.stopAccessingSecurityScopedResource() } }
let data = try Data(contentsOf: url)
Task { await model.importData(data) }
} catch { model.errorMessage = error.localizedDescription }
}
.fileExporter(
isPresented: $exportsFile,
document: exportDocument,
contentType: .json,
defaultFilename: "httpTTS"
) { result in
if case .failure(let error) = result { model.errorMessage = error.localizedDescription }
}
.alert("在线朗读引擎", isPresented: Binding(
get: { model.errorMessage != nil },
set: { if !$0 { model.errorMessage = nil } }
)) {
Button("确定", role: .cancel) { model.errorMessage = nil }
} message: {
Text(model.errorMessage ?? "")
}
}
}
private struct HttpTTSEditorView: View {
@Environment(\.dismiss) private var dismiss
@State private var engine: HttpTTS
let onSave: (HttpTTS) -> Void
@StateObject private var speech = ReaderSpeechController()
@State private var testText = "这是一段在线朗读引擎测试文本。"
@State private var loginInfo: String
@State private var showsLoginForm = false
init(engine: HttpTTS, onSave: @escaping (HttpTTS) -> Void) {
_engine = State(initialValue: engine)
self.onSave = onSave
_loginInfo = State(initialValue: BookSourceRuntimeStore.shared.get(sourceID: engine.sourceKey, key: "loginInfo"))
}
var body: some View {
Form {
Section("基本") {
TextField("名称", text: $engine.name)
TextEditor(text: $engine.url).frame(minHeight: 110)
TextField("Content-Type 正则", text: optional(\.contentType))
TextField("并发率", text: optional(\.concurrentRate))
Toggle("启用 Cookie", isOn: optionalBool(\.enabledCookieJar))
}
Section("请求与脚本") {
fieldLabel("Header")
TextEditor(text: optional(\.header)).frame(minHeight: 80)
fieldLabel("JS 库")
TextEditor(text: optional(\.jsLib)).frame(minHeight: 100)
fieldLabel("登录地址/脚本")
TextEditor(text: optional(\.loginUrl)).frame(minHeight: 80)
fieldLabel("登录 UI")
TextEditor(text: optional(\.loginUi)).frame(minHeight: 80)
fieldLabel("登录信息 JSON")
TextEditor(text: $loginInfo).frame(minHeight: 80)
if engine.loginUi?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false {
Button("打开登录表单", systemImage: "person.badge.key") { showsLoginForm = true }
}
fieldLabel("登录检查 JS")
TextEditor(text: optional(\.loginCheckJs)).frame(minHeight: 100)
}
Section("测试") {
TextField("测试文本", text: $testText)
Button(speech.isSpeaking ? "停止测试" : "播放测试", systemImage: speech.isSpeaking ? "stop.fill" : "play.fill") {
speech.configure(followsSystemRate: false, speechRate: 5, readByPage: true, streamsHTTPAudio: true)
speech.toggleHTTP(engine: engine, text: testText)
}
if let error = speech.errorMessage { Text(error).foregroundStyle(.red).font(.footnote) }
}
}
.navigationTitle(engine.name.isEmpty ? "新建在线朗读引擎" : engine.name)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
ToolbarItem(placement: .confirmationAction) {
Button("保存") {
BookSourceRuntimeStore.shared.put(sourceID: engine.sourceKey, key: "loginInfo", value: loginInfo)
onSave(engine)
dismiss()
}
.disabled(engine.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || engine.url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
.sheet(isPresented: $showsLoginForm) {
ReadingNavigationStack {
HttpTTSLoginView(engine: engine) { value in loginInfo = value }
}
}
.onDisappear { speech.stop() }
}
private func fieldLabel(_ value: String) -> some View {
Text(value).font(.subheadline.weight(.medium))
}
private func optional(_ keyPath: WritableKeyPath<HttpTTS, String?>) -> Binding<String> {
Binding(get: { engine[keyPath: keyPath] ?? "" }, set: { engine[keyPath: keyPath] = $0.isEmpty ? nil : $0 })
}
private func optionalBool(_ keyPath: WritableKeyPath<HttpTTS, Bool?>) -> Binding<Bool> {
Binding(get: { engine[keyPath: keyPath] == true }, set: { engine[keyPath: keyPath] = $0 })
}
}
private struct HttpTTSLoginRow: Decodable, Identifiable {
var name = ""
var type = "text"
var action: String?
var chars: [String?]?
var `default`: String?
var viewName: String?
var id: String { "\(name)-\(type)" }
private enum CodingKeys: String, CodingKey { case name, type, action, chars, `default`, viewName }
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
name = try values.decodeIfPresent(String.self, forKey: .name) ?? ""
type = try values.decodeIfPresent(String.self, forKey: .type) ?? "text"
action = try values.decodeIfPresent(String.self, forKey: .action)
chars = try values.decodeIfPresent([String?].self, forKey: .chars)
`default` = try values.decodeIfPresent(String.self, forKey: .default)
viewName = try values.decodeIfPresent(String.self, forKey: .viewName)
}
}
private struct HttpTTSLoginView: View {
@Environment(\.dismiss) private var dismiss
let engine: HttpTTS
let onSave: (String) -> Void
@State private var rows: [HttpTTSLoginRow] = []
@State private var values: [String: String] = [:]
@State private var errorMessage: String?
@State private var isRunning = false
@State private var pendingFieldAction: Task<Void, Never>?
var body: some View {
Form {
if rows.isEmpty, errorMessage == nil {
ProgressView("正在生成登录表单…")
}
ForEach(rows) { row in
switch row.type {
case "password":
SecureField(label(for: row), text: binding(for: row))
.onChange(of: values[row.name]) { _ in scheduleFieldAction(row.action) }
case "select":
Picker(label(for: row), selection: binding(for: row)) {
ForEach(options(for: row), id: \.self) { Text($0).tag($0) }
}
.onChange(of: values[row.name]) { _ in runAction(row.action) }
case "toggle":
Picker(label(for: row), selection: binding(for: row)) {
ForEach(options(for: row), id: \.self) { Text($0).tag($0) }
}
.pickerStyle(.segmented)
.onChange(of: values[row.name]) { _ in runAction(row.action) }
case "button":
Button(label(for: row)) { runAction(row.action) }
default:
TextField(label(for: row), text: binding(for: row))
.onChange(of: values[row.name]) { _ in scheduleFieldAction(row.action) }
}
}
if let errorMessage { Text(errorMessage).foregroundStyle(.red).font(.footnote) }
}
.navigationTitle("登录 · \(engine.name)")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
ToolbarItem(placement: .confirmationAction) {
Button(isRunning ? "执行中…" : "登录") { saveAndLogin() }.disabled(isRunning)
}
}
.task { loadRows() }
.onDisappear { pendingFieldAction?.cancel() }
}
private func loadRows() {
do {
values = decodeLoginInfo()
let json = try renderedLoginUI()
rows = try JSONDecoder().decode([HttpTTSLoginRow].self, from: Data(json.utf8))
for row in rows where row.type != "button" && values[row.name] == nil {
values[row.name] = row.default ?? options(for: row).first ?? ""
}
} catch {
errorMessage = error.localizedDescription
}
}
private func renderedLoginUI() throws -> String {
guard var raw = engine.loginUi?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
return "[]"
}
let script: String?
if raw.hasPrefix("@js:") {
script = String(raw.dropFirst(4))
} else if raw.hasPrefix("<js>"), raw.hasSuffix("</js>") {
script = String(raw.dropFirst(4).dropLast(5))
} else {
script = nil
}
if let script {
let result = try JavaScriptRuleEvaluator().evaluate(
script: script,
result: .object(values.mapValues(JSONValue.string)),
executionContext: .init(source: engine.bookSourceAdapter)
)
raw = result.value.stringValue
}
return raw
}
private func decodeLoginInfo() -> [String: String] {
let raw = BookSourceRuntimeStore.shared.get(sourceID: engine.sourceKey, key: "loginInfo")
guard let data = raw.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [:] }
return object.mapValues { String(describing: $0) }
}
private func binding(for row: HttpTTSLoginRow) -> Binding<String> {
Binding(get: { values[row.name] ?? row.default ?? "" }, set: { values[row.name] = $0 })
}
private func options(for row: HttpTTSLoginRow) -> [String] {
let options = row.chars?.compactMap { $0 } ?? []
return options.isEmpty ? [row.default ?? ""] : options
}
private func label(for row: HttpTTSLoginRow) -> String {
guard let value = row.viewName, value.count >= 2,
value.first == "'", value.last == "'" else { return row.name }
return String(value.dropFirst().dropLast())
}
private func runAction(_ action: String?) {
guard let action, !action.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
isRunning = true
errorMessage = nil
let engine = engine
let values = values
Task { @MainActor in
defer { isRunning = false }
do {
_ = try await Task.detached {
try JavaScriptRuleEvaluator().evaluate(
script: "\(engine.loginUrl ?? "")\n\(action)",
result: .object(values.mapValues(JSONValue.string)),
executionContext: .init(source: engine.bookSourceAdapter)
)
}.value
if engine.loginUi?.hasPrefix("@js:") == true { loadRows() }
} catch {
errorMessage = error.localizedDescription
}
}
}
private func scheduleFieldAction(_ action: String?) {
guard action?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false else { return }
pendingFieldAction?.cancel()
pendingFieldAction = Task { @MainActor in
do {
try await Task.sleep(nanoseconds: 600_000_000)
runAction(action)
} catch { }
}
}
private func saveAndLogin() {
isRunning = true
errorMessage = nil
do {
let data = try JSONSerialization.data(withJSONObject: values, options: [.sortedKeys])
let raw = String(decoding: data, as: UTF8.self)
BookSourceRuntimeStore.shared.put(sourceID: engine.sourceKey, key: "loginInfo", value: raw)
let engine = engine
let values = values
Task { @MainActor in
do {
if let login = engine.loginUrl?.trimmingCharacters(in: .whitespacesAndNewlines), !login.isEmpty {
_ = try await Task.detached {
try JavaScriptRuleEvaluator().evaluate(
script: "\(login)\nif (typeof login === 'function') login.apply(this);",
result: .object(values.mapValues(JSONValue.string)),
executionContext: .init(source: engine.bookSourceAdapter)
)
}.value
}
onSave(raw)
dismiss()
} catch {
errorMessage = error.localizedDescription
isRunning = false
}
}
} catch {
errorMessage = error.localizedDescription
isRunning = false
}
}
}
private struct HttpTTSJSONDocument: FileDocument {
static var readableContentTypes: [UTType] { [.json] }
var data: Data
init(data: Data) { self.data = data }
init(configuration: ReadConfiguration) throws {
data = configuration.file.regularFileContents ?? Data()
}
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
FileWrapper(regularFileWithContents: data)
}
}
+83
View File
@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>小浣熊阅读</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>Legado 书源</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>public.json</string>
<string>public.plain-text</string>
<string>public.data</string>
</array>
</dict>
</array>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>小浣熊阅读</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>0.1.9</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>legado</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSCameraUsageDescription</key>
<string>用于扫描书源和净化规则的导入二维码。</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>用于保存阅读页面中的图片到照片图库。</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<true/>
</dict>
<key>UILaunchScreen</key>
<dict/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
+82
View File
@@ -0,0 +1,82 @@
import SwiftUI
@main
struct LegadoApp: App {
@UIApplicationDelegateAdaptor(LegadoAppDelegate.self) private var appDelegate
@StateObject private var sourceLibrary: SourceLibraryModel
@StateObject private var bookLibrary: BookLibraryModel
@StateObject private var replaceRuleLibrary: ReplaceRuleLibraryModel
@StateObject private var readingActivity: ReadingActivityModel
@StateObject private var rssLibrary: RSSLibraryModel
@StateObject private var ruleSubscriptions: RuleSubscriptionModel
init() {
AppDiagnostics.shared.start()
SourceWebViewExecutorRegistry.shared.register(WKSourceWebViewExecutor())
let demo = ProcessInfo.processInfo.arguments.contains("-uiDemo")
_sourceLibrary = StateObject(wrappedValue: demo
? SourceLibraryModel.demo(sources: DemoContent.sources)
: SourceLibraryModel.live())
_bookLibrary = StateObject(wrappedValue: demo
? BookLibraryModel.demo(books: DemoContent.books)
: BookLibraryModel.live())
_replaceRuleLibrary = StateObject(wrappedValue: demo
? ReplaceRuleLibraryModel.demo()
: ReplaceRuleLibraryModel.live())
_readingActivity = StateObject(wrappedValue: demo
? ReadingActivityModel.demo(bookmarks: DemoContent.bookmarks, records: DemoContent.readRecords)
: ReadingActivityModel.live())
_rssLibrary = StateObject(wrappedValue: RSSLibraryModel.live())
_ruleSubscriptions = StateObject(wrappedValue: RuleSubscriptionModel())
}
var body: some Scene {
WindowGroup {
RootTabView()
.environmentObject(sourceLibrary)
.environmentObject(bookLibrary)
.environmentObject(replaceRuleLibrary)
.environmentObject(readingActivity)
.environmentObject(rssLibrary)
.environmentObject(ruleSubscriptions)
.task {
await sourceLibrary.load()
if let index = ProcessInfo.processInfo.arguments.firstIndex(of: "-uiAutomationImportURL"),
ProcessInfo.processInfo.arguments.indices.contains(index + 1),
let url = URL(string: ProcessInfo.processInfo.arguments[index + 1]) {
await sourceLibrary.handleDeepLink(url)
if ProcessInfo.processInfo.arguments.contains("-uiAutomation") {
sourceLibrary.notice = nil
}
}
await bookLibrary.load()
await replaceRuleLibrary.load()
await readingActivity.load()
await ruleSubscriptions.load()
if UserDefaults.standard.bool(forKey: "auto_refresh") {
await bookLibrary.checkForUpdates(sources: sourceLibrary.sources, onlyRead: UserDefaults.standard.bool(forKey: "onlyUpdateRead"))
}
await ruleSubscriptions.checkAutomaticUpdates(bookSources: sourceLibrary, rssSources: rssLibrary, replaceRules: replaceRuleLibrary)
}
.onOpenURL { url in
Task {
await sourceLibrary.load()
if url.scheme?.lowercased() == "legado", url.path.lowercased() == "/replacerule" {
await replaceRuleLibrary.load()
await replaceRuleLibrary.importText(url.absoluteString)
if let notice = replaceRuleLibrary.notice {
sourceLibrary.notice = .init(kind: notice.kind == .error ? .error : .success, title: notice.title, detail: notice.detail)
replaceRuleLibrary.notice = nil
}
} else { await sourceLibrary.handleDeepLink(url) }
}
}
}
}
}
final class LegadoAppDelegate: NSObject, UIApplicationDelegate {
func application(_ application: UIApplication, supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
ReaderScreenOrientation.supportedOrientations
}
}
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014 - 2015 Atsushi Kiwaki (@_tid_)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+674
View File
@@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
Legado Copyright (C) 2019-2023 gedoor
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+22
View File
@@ -0,0 +1,22 @@
The MIT License
Copyright (c) 2009-2025 Jonathan Hedley <https://jsoup.org/>
Copyright (c) 2016-2025 Nabil Chatbi (Swift port)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+44
View File
@@ -0,0 +1,44 @@
[
{
"id": -100,
"name": "1.百度",
"url": "http://tts.baidu.com/text2audio,{\n \"method\": \"POST\",\n \"body\": \"tex={{java.encodeURI(java.encodeURI(speakText))}}&spd={{(speakSpeed + 5) / 10 + 4}}&per=3&cuid=baidu_speech_demo&idx=1&cod=2&lan=zh&ctp=1&pdt=160&vol=5&aue=6&pit=5&_res_tag_=audio\"\n}",
"contentType": "audio/wav"
},
{
"id": -29,
"name": "2.阿里云语音",
"url": "https://nls-gateway.cn-shanghai.aliyuncs.com/stream/v1/tts,{\"method\": \"POST\",\"body\": {\"appkey\":\"{{source.getLoginInfoMap().get('AppKey')}}\",\"text\":\"{{speakText}}\",\"format\":\"mp3\",\"volume\":100,\"speech_rate\":{{String((speakSpeed) * 20 - 400)}} }}",
"contentType": "audio/mpeg",
"loginUrl": "function login(){var loginInfo = source.getLoginInfoMap();\nvar accessKeyId = loginInfo.get('AccessKeyId');\nvar accessKeySecret = loginInfo.get('AccessKeySecret');\nvar timestamp = java.timeFormatUTC(new Date().getTime(), \"yyyy-MM-dd'T'HH:mm:ss'Z'\", 0);\nvar aly = new JavaImporter(Packages.javax.crypto.Mac, Packages.javax.crypto.spec.SecretKeySpec, Packages.javax.xml.bind.DatatypeConverter, Packages.java.net.URLEncoder, Packages.java.lang.String, Packages.android.util.Base64);\nwith (aly) {\n function percentEncode(value) {\n return URLEncoder.encode(value, \"UTF-8\").replace(\"+\", \"%20\")\n .replace(\"*\", \"%2A\").replace(\"%7E\", \"~\")\n }\n\n function sign(stringToSign, accessKeySecret) {\n var mac = Mac.getInstance('HmacSHA1');\n mac.init(new SecretKeySpec(String(accessKeySecret + '&').getBytes(\"UTF-8\"), \"HmacSHA1\"));\n var signData = mac.doFinal(String(stringToSign).getBytes(\"UTF-8\"));\n var signBase64 = Base64.encodeToString(signData, Base64.NO_WRAP);\n var signUrlEncode = percentEncode(signBase64);\n return signUrlEncode;\n }\n}\nvar query = 'AccessKeyId=' + accessKeyId + '&Action=CreateToken&Format=JSON&RegionId=cn-shanghai&SignatureMethod=HMAC-SHA1&SignatureNonce=' + java.randomUUID() + '&SignatureVersion=1.0&Timestamp=' + percentEncode(timestamp) + '&Version=2019-02-28';\nvar signStr = sign('GET&' + percentEncode('/') + '&' + percentEncode(query), accessKeySecret);\nvar queryStringWithSign = \"Signature=\" + signStr + \"&\" + query;\nvar body = java.ajax('http://nls-meta.cn-shanghai.aliyuncs.com/?' + queryStringWithSign)\nvar res = JSON.parse(body)\nif (res.Message) {\n throw new Error(res.Message)\n}\nvar header = { \"X-NLS-Token\": res.Token.Id };\nsource.putLoginHeader(JSON.stringify(header))}",
"loginUi": [
{
"name": "AppKey",
"type": "text"
},
{
"name": "AccessKeyId",
"type": "text"
},
{
"name": "AccessKeySecret",
"type": "text"
}
],
"loginCheckJs": "var response = result;\nif (response.headers().get(\"Content-Type\") != \"audio/mpeg\") {\n var body = JSON.parse(response.body().string())\n if (body.status == 40000001) {\n source.login()\n java.getHeaderMap().putAll(source.getHeaderMap(true))\n response = java.getResponse()\n } else {\n throw body.message\n }\n}\nresponse"
},
{
"concurrentRate": "1500",
"contentType": "audio/mpeg",
"enabledCookieJar": false,
"header": "",
"id": 1768175007437,
"jsLib": "//明月照大江 · next引擎源\n//--洛娅橙\nvar banben = \"0.0.2\";\nvar styleM = {\n \"无风格\": \"\",\n \"助手\": \"assistant\",\n \"聊天\": \"chat\",\n \"客户服务\": \"customerservice\",\n \"新闻播报\": \"newscast\",\n \"深情\": \"affectionate\",\n \"愤怒\": \"angry\",\n \"平静\": \"calm\",\n \"欢快\": \"cheerful\",\n \"不满\": \"disgruntled\",\n \"恐惧\": \"fearful\",\n \"温柔\": \"gentle\",\n \"抒情\": \"lyrical\",\n \"悲伤\": \"sad\",\n \"严肃\": \"serious\",\n \"诗歌朗诵\": \"poetry-reading\",\n \"友好\": \"friendly\",\n \"随意聊天\": \"chat-casual\",\n \"耳语\": \"whispering\",\n \"歉意\": \"sorry\",\n \"兴奋\": \"excited\"\n};\n\nvar apiM = {\n \"http://171.113.113.119:8085/tts\": true,\n \"http://5.45.99.149:8075/tts\": true,\n \"http://104.214.168.83:8080/tts\": true,\n \"http://74.48.40.244:8010/tts\": true,\n \"http://47.119.125.172:8080/tts\": true,\n \"http://64.112.42.45:9080/tts\": true,\n \"http://146.56.188.115:8080/tts\": false,\n \"https://t.leftsite.cn/tts\": false,\n \"http://47.79.92.215:18080/tts\": false,\n \"http://36.248.181.23:22335/tts\": true,\n \"http://180.114.35.250:1080/tts\": true,\n \"http://124.71.164.73:8085/tts\": true,\n \"http://190.92.218.92:8080/tts\": true\n};\n\nvar fyrM = {\n \"晓晓\": \"zh-CN-XiaoxiaoNeural\",\n \"晓亦\": \"zh-CN-XiaoyiNeural\",\n \"晓辰\": \"zh-CN-XiaochenNeural\",\n \"晓涵\": \"zh-CN-XiaohanNeural\",\n \"晓梦\": \"zh-CN-XiaomengNeural\",\n \"晓墨\": \"zh-CN-XiaomoNeural\",\n \"晓秋\": \"zh-CN-XiaoqiuNeural\",\n \"晓柔\": \"zh-CN-XiaorouNeural\",\n \"晓睿\": \"zh-CN-XiaoruiNeural\",\n \"晓双\": \"zh-CN-XiaoshuangNeural\",\n \"晓颜\": \"zh-CN-XiaoyanNeural\",\n \"晓悠\": \"zh-CN-XiaoyouNeural\",\n \"晓甄\": \"zh-CN-XiaozhenNeural\",\n \"云溪\": \"zh-CN-YunxiNeural\",\n \"云间\": \"zh-CN-YunjianNeural\",\n \"云扬\": \"zh-CN-YunyangNeural\",\n \"云枫\": \"zh-CN-YunfengNeural\",\n \"云皓\": \"zh-CN-YunhaoNeural\",\n \"云杰\": \"zh-CN-YunjieNeural\",\n \"云夏\": \"zh-CN-YunxiaNeural\",\n \"云野\": \"zh-CN-YunyeNeural\",\n \"云泽\": \"zh-CN-YunzeNeural\",\n\n \"晓晓DIA\": \"zh-CN-XiaoxiaoDialectsNeural\",\n \"晓晓MT\": \"zh-CN-XiaoxiaoMultilingualNeural\",\n \"晓宇MT\": \"zh-CN-XiaoyuMultilingualNeural\",\n \"云晓MT\": \"zh-CN-YunxiaoMultilingualNeural\",\n \"云逸MT\": \"zh-CN-YunyiMultilingualNeural\",\n \"云帆MT\": \"zh-CN-YunfanMultilingualNeural\",\n\n \"晓辰HD\": \"zh-CN-Xiaochen:DragonHDLatestNeural\",\n \"云帆HD\": \"zh-CN-Yunfan:DragonHDLatestNeural\",\n\n \"晓辰FHD\": \"zh-CN-Xiaochen:DragonHDFlashLatestNeural\",\n \"晓晓FHD\": \"zh-CN-Xiaoxiao:DragonHDFlashLatestNeural\",\n \"潇潇FHD\": \"zh-CN-Xiaoxiao2:DragonHDFlashLatestNeural\",\n \"云萧FHD\": \"zh-CN-Yunxiao:DragonHDFlashLatestNeural\",\n \"云野FHD\": \"zh-CN-Yunye:DragonHDFlashLatestNeural\",\n \"云逸FHD\": \"zh-CN-Yunyi:DragonHDFlashLatestNeural\",\n\n \"晓宸_繁\": \"zh-TW-HsiaoChenNeural\",\n \"晓语_繁\": \"zh-TW-HsiaoYuNeural\",\n \"云喆_繁\": \"zh-TW-YunJheNeural\"\n};\n\nfunction apiurl(speakText, speakSpeed, apiurl) {\n let info = this.source.getLoginInfoMap();\n let api = apiurl || info[\"api\"];\n let apik = apiM[api];\n let fyr = info[\"发言人\"];\n let fyrk = fyrM[fyr];\n let fg = info[\"风格\"];\n let fgk = styleM[fg];\n let body = {};\n let params = `?t=${speakText}&v=${fyrk}&r=${(speakSpeed - 10) * 10}&p=0&s=${fgk}&api_key=`;\n return `${api}${params}`;\n}",
"lastUpdateTime": 1770778835144,
"loginCheckJs": "if (result.code() != 200) {\n java.toast(\"接口失效\\n正在切换中...\");\n java.log(\"失效接口:\" + java.ruleUrl)\n let ulm = Object.keys(apiM);\n let uls = ulm.map(x => apiurl(\"测试\", 10, x));\n let resl = java.ajaxTestAll(uls, 3000, true);\n let goodI = -1,\n i = 0;\n let goodt = 9999;\n for (let res of resl) {\n let time = res.callTime();\n if (time > 0 && time < goodt) {\n goodI = i;\n goodt = time;\n }\n i++;\n }\n if (goodI != -1) {\n let goodurl = ulm[goodI];\n java.toast(java.log(\"\\n已选择最优接口:\" + goodurl));\n let Info = source.getLoginInfoMap();\n Info['api'] = goodurl;\n source.putLoginInfo(JSON.stringify(Info));\n java.initUrl();\n result = java.getResponse();\n } else {\n java.toast(\"需在登录界面切换接口\\n引擎版本号:\" + banben);\n }\n}\nresult",
"loginUi": "@js:\nlet styleS = Object.keys(styleM).map(key => `\"${key}\"`).join(',');\nlet fyrS = Object.keys(fyrM).map(key => `\"${key}\"`).join(',');\nlet apiS = Object.keys(apiM).map(key => `\"${key}\"`).join(',');\n`[{\n \"name\": \"发言人\",\n \"viewName\": \"'发言人:'\",\n \"action\":\"clear()\",\n \"type\": \"select\",\n \"chars\": [${fyrS}],\n \"style\": {\n \"layout_flexGrow\": 1,\n \"layout_flexBasisPercent\": 0.4\n }\n },\n {\n \"name\": \"风格\",\n \"viewName\": \"'风格:'\",\n \"action\":\"clear()\",\n \"type\": \"select\",\n \"chars\": [${styleS}],\n \"style\": {\n \"layout_flexGrow\": 1,\n \"layout_flexBasisPercent\": 0.4\n }\n },\n {\n \"name\": \"api\",\n \"viewName\": \"'接口:'\",\n \"action\":\"checkjk()\",\n \"type\": \"select\",\n \"chars\": [${apiS}],\n \"style\": {\n \"layout_flexGrow\": 1,\n \"layout_flexBasisPercent\": 0.4,\n \"layout_wrapBefore\": true\n }\n },\n {\n \"name\": \"自动清除缓存\",\n \"viewName\": \"'切换发言人时清理缓存 '\",\n \"type\": \"toggle\",\n \"chars\": [\"✅\",\"🔳\"],\n \"style\": {\n \"layout_flexGrow\": 0,\n \"layout_flexBasisPercent\": -1,\n \"layout_justifySelf\":\"right\",\n \"layout_wrapBefore\": true\n }\n }\n]`;",
"loginUrl": "function login() {}\n\nfunction checkjk() {\n let jk = result[\"api\"];\n let fhd = apiM[jk];\n java.toast(`\\n当前接口${fhd?\"✓支持\":\"X不支持\"}FHD发言人\\n正在测试延迟...`);\n let url = apiurl(\"测试\", 10, jk);\n let res = java.ajaxTestAll([url], 3000, true)[0];\n let time = res.callTime();\n if (time < 0) {\n java.toast(java.log(`\\n接口:${jk}\\n无法访问 ${time}`));\n return\n }\n java.toast(`\\n接口:${jk}\\n延迟时间为${time}`);\n}\n\nfunction clear() {\n if (result[\"自动清除缓存\"] == \"🔳\") return;\n java.clearTtsCache();\n}",
"name": "🌈Next引擎-长按切换音色",
"url": "@js:apiurl(speakText,speakSpeed);"
}
]
+210
View File
@@ -0,0 +1,210 @@
[
{
"id": -1,
"enable": true,
"name": "目录(去空白)",
"rule": "(?<=[ \\s])(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|集(?![合和]))).{0,30}$",
"example": "第一章 假装第一章前面有空白但我不要",
"serialNumber": 0
},
{
"id": -2,
"enable": true,
"name": "目录",
"rule": "^[  \\t]{0,4}(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|集(?![合和])|部(?![分赛游])|篇(?!张))).{0,30}$",
"example": "第一章 标准的粤语就是这样",
"serialNumber": 1
},
{
"id": -3,
"enable": false,
"name": "目录(匹配简介)",
"rule": "(?<=[ \\s])(?:(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|集(?![合和])|部(?![分赛游])|回(?![合来事去])|场(?![和合比电是])|篇(?!张))).{0,30}$",
"example": "简介 老夫诸葛村夫",
"serialNumber": 2
},
{
"id": -4,
"enable": false,
"name": "目录(古典、轻小说备用)",
"rule": "^[  \\t]{0,4}(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|集(?![合和])|部(?![分赛游])|回(?![合来事去])|场(?![和合比电是])|话|篇(?!张))).{0,30}$",
"example": "第一章 比上面只多了回和话",
"serialNumber": 3
},
{
"id": -5,
"enable": false,
"name": "数字(纯数字标题)",
"rule": "(?<=[ \\s])\\d+\\.?[  \\t]{0,4}$",
"example": "12",
"serialNumber": 4
},
{
"id": -6,
"enable": false,
"name": "大写数字(纯数字标题)",
"rule": "(?<=[ \\s])[零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,12}[  \\t]{0,4}$",
"example": "一百七十",
"serialNumber": 5
},
{
"id": -7,
"enable": false,
"name": "数字混合(纯数字标题)",
"rule": "(?<=[ \\s])[零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟\\d]{1,12}[  \\t]{0,4}$",
"example": "12\n一百七十",
"serialNumber": 6
},
{
"id": -8,
"enable": true,
"name": "数字 分隔符 标题名称",
"rule": "^[  \\t]{0,4}\\d{1,5}[::,., 、_—\\-].{1,30}$",
"example": "1、这个就是标题",
"serialNumber": 7
},
{
"id": -9,
"enable": true,
"name": "大写数字 分隔符 标题名称",
"rule": "^[  \\t]{0,4}(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|[零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章?)[ 、_—\\-].{1,30}$",
"example": "一、只有前面的数字有差别\n二十四章 我瞎编的标题",
"serialNumber": 8
},
{
"id": -10,
"enable": false,
"name": "数字混合 分隔符 标题名称",
"rule": "^[  \\t]{0,4}(?:序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|[零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章?[ 、_—\\-]|\\d{1,5}章?[::,., 、_—\\-]).{0,30}$",
"example": "1、人参公鸡\n二百二十章 boy next door",
"serialNumber": 9
},
{
"id": -11,
"enable": true,
"name": "正文 标题/序号",
"rule": "^[  \\t]{0,4}正文[  ]{1,4}.{0,20}$",
"example": "正文 我奶常山赵子龙",
"serialNumber": 10
},
{
"id": -12,
"enable": true,
"name": "Chapter/Section/Part/Episode 序号 标题",
"rule": "^[  \\t]{0,4}(?:[Cc]hapter|[Ss]ection|[Pp]art|PART|[Nn][oO][.、]|[Ee]pisode|(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外)\\s{0,4}\\d{1,4}.{0,30}$",
"example": "Chapter 1 MyGrandmaIsNB",
"serialNumber": 11
},
{
"id": -13,
"enable": false,
"name": "Chapter(去简介)",
"rule": "^[  \\t]{0,4}(?:[Cc]hapter|[Ss]ection|[Pp]art|PART|[Nn][Oo]\\.|[Ee]pisode)\\s{0,4}\\d{1,4}.{0,30}$",
"example": "Chapter 1 MyGrandmaIsNB",
"serialNumber": 12
},
{
"id": -14,
"enable": true,
"name": "特殊符号 序号 标题",
"rule": "(?<=[\\s ])[【〔〖「『〈[\\[](?:第|[Cc]hapter)[\\d零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,10}[章节].{0,20}$",
"example": "【第一章 后面的符号可以没有",
"serialNumber": 13
},
{
"id": -15,
"enable": false,
"name": "特殊符号 标题(成对)",
"rule": "(?<=[\\s ]{0,4})(?:[\\[〈「『〖〔《(【\\(].{1,30}[\\)】)》〕〗』」〉\\]]?|(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外)[  ]{0,4}$",
"example": "『加个直角引号更专业』\n(11)我奶常山赵子聋",
"serialNumber": 14
},
{
"id": -16,
"enable": true,
"name": "特殊符号 标题(单个)",
"rule": "(?<=[\\s ]{0,4})(?:[☆★✦✧].{1,30}|(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外)[  ]{0,4}$",
"example": "☆、晋江作者最喜欢的格式",
"serialNumber": 15
},
{
"id": -17,
"enable": true,
"name": "章/卷 序号 标题",
"rule": "^[ \\t ]{0,4}(?:(?:内容|文章)?简介|文案|前言|序章|楔子|正文(?!完|结)|终章|后记|尾声|番外|[卷章][\\d零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8})[  ]{0,4}.{0,30}$",
"example": "卷五 开源盛世",
"serialNumber": 16
},
{
"id": -18,
"enable": false,
"name": "顶格标题",
"rule": "^\\S.{1,20}$",
"example": "20字以内顶格写的都是标题",
"serialNumber": 17
},
{
"id": -19,
"enable": false,
"name": "双标题(前向)",
"rule": "(?m)(?<=[ \\t ]{0,4})第[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章.{0,30}$(?=[\\s ]{0,8}第[\\d零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章)",
"example": "第一章 真正的标题\n第一章 这个不要",
"serialNumber": 18
},
{
"id": -20,
"enable": false,
"name": "双标题(后向)",
"rule": "(?m)(?<=[ \\t ]{0,4}第[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章.{0,30}$[\\s ]{0,8})第[\\d零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}章.{0,30}$",
"example": "第一章 这个标题不要\n第一章真正的标题",
"serialNumber": 19
},
{
"id": -21,
"enable": true,
"name": "书名 括号 序号",
"rule": "^[一-龥]{1,20}[  \\t]{0,4}[((][\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}[))][  \\t]{0,4}$",
"example": "标题后面数字有括号(12)",
"serialNumber": 20
},
{
"id": -22,
"enable": true,
"name": "书名 序号",
"rule": "^[一-龥]{1,20}[  \\t]{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]{1,8}[  \\t]{0,4}$",
"example": "标题后面数字没有括号124",
"serialNumber": 21
},
{
"id": -23,
"enable": false,
"name": "特定字符 标题 特定符号",
"rule": "(?<=\\={3,6}).{1,40}?(?=\\=)",
"example": "===起这种标题干什么===",
"serialNumber": 22
},
{
"id": -24,
"enable": true,
"name": "字数分割 分节阅读",
"rule": "(?<=[  \\t]{0,4})(?:.{0,15}分[页节章段]阅读[-_ ]|第\\s{0,4}[\\d零一二两三四五六七八九十百千万]{1,6}\\s{0,4}[页节]).{0,30}$",
"example": "分节|分页|分段阅读\n第一页",
"serialNumber": 23
},
{
"id": -25,
"enable": false,
"name": "通用规则",
"rule": "(?im)^.{0,6}(?:[引楔]子|正文(?!完|结)|[引序前]言|[序终]章|扉页|[上中下][部篇卷]|卷首语|后记|尾声|番外|={2,4}|第\\s{0,4}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟]+?\\s{0,4}(?:章|节(?!课)|卷|页[、  ]|集(?![合和])|部(?![分是门落])|篇(?!张))).{0,40}$|^.{0,6}[\\d〇零一二两三四五六七八九十百千万壹贰叁肆伍陆柒捌玖拾佰仟a-z]{1,8}[、.  ].{0,20}$",
"example": "激进规则,适配更多非常用格式",
"serialNumber": 24
},
{
"id": -100,
"enable": false,
"name": "默认分章规则",
"rule": "",
"example": "兜底规则,请勿改动此内容",
"serialNumber": 99
}
]
File diff suppressed because it is too large Load Diff