mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-20 19:21:22 +08:00
feat: open-source 小浣熊阅读 for iOS
This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
name: 问题反馈 / 书源兼容
|
||||||
|
description: 报告小浣熊阅读的可复现问题
|
||||||
|
title: "[Bug] "
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: 请先搜索已有问题。请勿上传账号、Cookie、Token、个人书架、付费内容或未经授权的完整正文。
|
||||||
|
- type: input
|
||||||
|
id: environment
|
||||||
|
attributes:
|
||||||
|
label: 应用版本、iOS 版本与设备
|
||||||
|
placeholder: 例如 0.1.8 / iOS 18.3.1 / iPhone 16 Pro
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: area
|
||||||
|
attributes:
|
||||||
|
label: 问题模块
|
||||||
|
options:
|
||||||
|
- 书源导入或规则兼容
|
||||||
|
- 搜索与发现
|
||||||
|
- 书架与换源
|
||||||
|
- 阅读器与朗读
|
||||||
|
- 本地文件
|
||||||
|
- RSS
|
||||||
|
- 其他
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: reproduction
|
||||||
|
attributes:
|
||||||
|
label: 复现步骤、预期结果与实际结果
|
||||||
|
description: 搜索问题请说明启用书源数、网络、是否缓存,以及是否含 WebView / JS 规则。
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: sample
|
||||||
|
attributes:
|
||||||
|
label: 最小规则、脱敏日志或截图
|
||||||
|
description: 优先使用虚构域名和自造响应;在线样本请确认有权公开。
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: 使用说明与当前支持范围
|
||||||
|
url: https://github.com/Aminark/legado-ios#readme
|
||||||
|
about: 提交前请查看 README 的使用步骤、兼容边界和已知问题。
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
name: 功能建议
|
||||||
|
description: 描述希望改善的阅读场景
|
||||||
|
title: "[Feature] "
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: problem
|
||||||
|
attributes:
|
||||||
|
label: 你遇到的使用问题
|
||||||
|
description: 说明具体阅读场景和现有操作的困难。
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: proposal
|
||||||
|
attributes:
|
||||||
|
label: 希望的行为与替代方案
|
||||||
|
description: 可附原型或示意图,说明希望如何操作。
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
## 问题与修改后的行为
|
||||||
|
|
||||||
|
说明具体问题、触发方式和最终行为;有关联 Issue 时附链接。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
说明运行的测试、构建、设备 / 系统,以及尚未验证的场景。影响界面时附截图。
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
.DS_Store
|
||||||
|
.build/
|
||||||
|
DerivedData/
|
||||||
|
*.xcuserstate
|
||||||
|
xcuserdata/
|
||||||
|
reference/
|
||||||
|
|
||||||
|
artifacts/
|
||||||
|
*.xcresult/
|
||||||
|
*.ipa
|
||||||
|
*.dSYM/
|
||||||
|
*.log
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.swiftpm/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
*.p12
|
||||||
|
*.p8
|
||||||
|
*.mobileprovision
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
*.keystore
|
||||||
|
*.jks
|
||||||
|
*.apk
|
||||||
|
*.aab
|
||||||
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 |
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"images": [
|
||||||
|
{ "filename": "BrandIcon.png", "idiom": "universal" }
|
||||||
|
],
|
||||||
|
"info": { "author": "xcode", "version": 1 }
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -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)秒"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct BookDetailView: View {
|
||||||
|
@EnvironmentObject private var library: BookLibraryModel
|
||||||
|
@EnvironmentObject private var sourceLibrary: SourceLibraryModel
|
||||||
|
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
|
||||||
|
@StateObject private var model: BookDetailViewModel
|
||||||
|
@State private var showsSourceSwitch = false
|
||||||
|
@State private var showsRemoveConfirmation = false
|
||||||
|
@State private var showsTableOfContents = false
|
||||||
|
|
||||||
|
init(searchBook: SearchBook, source: BookSource) {
|
||||||
|
_model = StateObject(wrappedValue: BookDetailViewModel(searchBook: searchBook, source: source))
|
||||||
|
}
|
||||||
|
|
||||||
|
init(book: Book, source: BookSource) {
|
||||||
|
_model = StateObject(wrappedValue: BookDetailViewModel(book: book, source: source))
|
||||||
|
}
|
||||||
|
|
||||||
|
init(demoBook: Book, source: BookSource, chapters: [BookChapter]) {
|
||||||
|
_model = StateObject(wrappedValue: BookDetailViewModel(demoBook: demoBook, source: source, chapters: chapters))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 24) {
|
||||||
|
hero
|
||||||
|
Divider()
|
||||||
|
if let intro = MetadataTextSanitizer().clean(model.book.intro), !intro.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("简介").font(.headline)
|
||||||
|
Text(intro).font(.subheadline).foregroundStyle(.secondary).lineSpacing(5)
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
chapterCard
|
||||||
|
if model.isCaching || model.cacheCompleted > 0 { cacheProgressCard }
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.bottom, 30)
|
||||||
|
}
|
||||||
|
.background(ReadingTheme.canvas)
|
||||||
|
.navigationTitle("书籍详情")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Menu {
|
||||||
|
Button("换源", systemImage: "arrow.triangle.2.circlepath") { showsSourceSwitch = true }
|
||||||
|
.disabled(sourceLibrary.sources.filter(\.enabled).count < 2)
|
||||||
|
Button("允许更新", systemImage: model.book.canUpdate ? "checkmark" : "circle") {
|
||||||
|
model.toggleCanUpdate()
|
||||||
|
Task { await saveCurrentBook() }
|
||||||
|
}
|
||||||
|
Button("刷新", systemImage: "arrow.clockwise") {
|
||||||
|
Task {
|
||||||
|
let oldURL = model.book.bookUrl
|
||||||
|
let wasInLibrary = library.contains(model.book)
|
||||||
|
await model.refresh(sources: sourceLibrary.sources)
|
||||||
|
if wasInLibrary, oldURL != model.book.bookUrl {
|
||||||
|
await library.replace(oldBookURL: oldURL, with: model.book)
|
||||||
|
} else if wasInLibrary {
|
||||||
|
await saveCurrentBook()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if model.source.loginUrl?.isEmpty == false {
|
||||||
|
NavigationLink {
|
||||||
|
SourceLoginView(source: model.source)
|
||||||
|
} label: {
|
||||||
|
Label("登录", systemImage: "person.badge.key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ReadingShareLink(item: model.book.bookUrl) {
|
||||||
|
Label("分享", systemImage: "square.and.arrow.up")
|
||||||
|
}
|
||||||
|
Divider()
|
||||||
|
Button("缓存接下来 10 章", systemImage: "arrow.down.circle") { model.cacheNextTen() }
|
||||||
|
.disabled(model.chapters.isEmpty || model.isCaching)
|
||||||
|
Button("缓存剩余章节", systemImage: "square.and.arrow.down.on.square") { model.cacheRemaining() }
|
||||||
|
.disabled(model.chapters.isEmpty || model.isCaching)
|
||||||
|
if model.isCaching {
|
||||||
|
Divider()
|
||||||
|
Button("停止缓存", systemImage: "stop.circle", role: .destructive) { model.cancelCaching() }
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "ellipsis.circle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
model.restoreSourceAfterReading(from: library.books, sources: sourceLibrary.sources)
|
||||||
|
let oldURL = model.book.bookUrl
|
||||||
|
let shouldPersistUpdate = library.contains(model.book)
|
||||||
|
if let stored = library.books.first(where: { $0.bookUrl == oldURL }) {
|
||||||
|
model.restoreLibraryBook(from: stored, sources: sourceLibrary.sources)
|
||||||
|
}
|
||||||
|
await model.load(sources: sourceLibrary.sources)
|
||||||
|
if shouldPersistUpdate {
|
||||||
|
if let stored = library.books.first(where: { $0.bookUrl == model.book.bookUrl }) {
|
||||||
|
model.restoreUserSettings(from: stored)
|
||||||
|
model.restoreReadingProgress(from: stored)
|
||||||
|
}
|
||||||
|
if oldURL != model.book.bookUrl {
|
||||||
|
await library.replace(oldBookURL: oldURL, with: model.book)
|
||||||
|
} else {
|
||||||
|
await saveCurrentBook()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showsSourceSwitch) {
|
||||||
|
ReadingNavigationStack {
|
||||||
|
SourceSwitchView(book: model.book, sources: sourceLibrary.sources) { report, source in
|
||||||
|
let oldURL = model.book.bookUrl
|
||||||
|
model.applySourceSwitch(report, source: source)
|
||||||
|
Task { await library.replace(oldBookURL: oldURL, with: report.book) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showsTableOfContents) {
|
||||||
|
BookTableOfContentsView(book: model.book, chapters: model.chapters, source: model.source)
|
||||||
|
}
|
||||||
|
.confirmationDialog("从书架移除《\(model.book.name)》?", isPresented: $showsRemoveConfirmation) {
|
||||||
|
Button("移出书架", role: .destructive) {
|
||||||
|
Task { await library.remove(bookURL: model.book.bookUrl) }
|
||||||
|
}
|
||||||
|
Button("取消", role: .cancel) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var cacheProgressCard: some View {
|
||||||
|
ReadingCard {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack {
|
||||||
|
Label(model.isCaching ? "正在缓存章节" : "章节缓存完成", systemImage: model.isCaching ? "arrow.down.circle.fill" : "checkmark.circle.fill")
|
||||||
|
.font(.headline)
|
||||||
|
Spacer()
|
||||||
|
Text("\(model.cacheCompleted)/\(model.cacheTotal)")
|
||||||
|
.font(.caption.monospacedDigit()).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
ProgressView(value: Double(model.cacheCompleted), total: Double(max(model.cacheTotal, 1)))
|
||||||
|
.tint(ReadingTheme.accent)
|
||||||
|
if model.isCaching {
|
||||||
|
Button("停止缓存", role: .destructive) { model.cancelCaching() }
|
||||||
|
.font(.subheadline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hero: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
HStack(alignment: .top, spacing: 18) {
|
||||||
|
BookCoverArtwork(
|
||||||
|
title: model.book.name,
|
||||||
|
author: model.book.author,
|
||||||
|
remoteURL: model.book.coverUrl,
|
||||||
|
width: 88,
|
||||||
|
height: 126
|
||||||
|
)
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text(model.book.name).font(.title2.weight(.semibold)).lineLimit(3)
|
||||||
|
Text(model.book.author.isEmpty ? "未知作者" : model.book.author)
|
||||||
|
.font(.subheadline).foregroundStyle(.secondary)
|
||||||
|
if let kind = model.book.kind, !kind.isEmpty {
|
||||||
|
Text(kind).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Text(model.source.bookSourceName)
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
if let latest = model.book.latestChapterTitle, !latest.isEmpty {
|
||||||
|
Text("更新至 \(latest)")
|
||||||
|
.font(.caption).foregroundStyle(.secondary).lineLimit(2)
|
||||||
|
}
|
||||||
|
Group {
|
||||||
|
if dynamicTypeSize.isAccessibilitySize {
|
||||||
|
VStack(spacing: 12) { readingActions }
|
||||||
|
} else {
|
||||||
|
HStack(spacing: 12) { readingActions }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.controlSize(.large)
|
||||||
|
}
|
||||||
|
.padding(.top, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var readingActions: some View {
|
||||||
|
NavigationLink {
|
||||||
|
readingDestination
|
||||||
|
} label: {
|
||||||
|
Text(model.book.durChapterIndex > 0 || model.book.durChapterPos > 0 ? "继续阅读" : "开始阅读")
|
||||||
|
.font(.body.weight(.medium))
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 28)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(model.chapters.isEmpty)
|
||||||
|
Button {
|
||||||
|
if library.contains(model.book) {
|
||||||
|
showsRemoveConfirmation = true
|
||||||
|
} else {
|
||||||
|
Task { await saveCurrentBook() }
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label(
|
||||||
|
library.contains(model.book) ? "已在书架" : "加入书架",
|
||||||
|
systemImage: library.contains(model.book) ? "checkmark" : "plus"
|
||||||
|
)
|
||||||
|
.font(.subheadline)
|
||||||
|
.frame(maxWidth: dynamicTypeSize.isAccessibilitySize ? .infinity : nil, minHeight: 28)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var readingDestination: some View {
|
||||||
|
let index = min(max(model.book.durChapterIndex, 0), max(model.chapters.count - 1, 0))
|
||||||
|
if ProcessInfo.processInfo.arguments.contains("-uiDemo") {
|
||||||
|
ReaderView(demoBook: model.book, chapters: model.chapters, initialIndex: index, content: DemoContent.readerText)
|
||||||
|
} else {
|
||||||
|
ReaderView(book: model.book, chapters: model.chapters, initialIndex: index, source: model.source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveCurrentBook() async {
|
||||||
|
await library.addOrUpdate(model.book)
|
||||||
|
if let stored = library.books.first(where: { $0.bookUrl == model.book.bookUrl }) {
|
||||||
|
model.restoreReadingProgress(from: stored)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var chapterCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
HStack {
|
||||||
|
Text("目录").font(.headline)
|
||||||
|
Spacer()
|
||||||
|
Text("\(model.chapters.count) 章").font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
if model.state == .loading || model.state == .idle {
|
||||||
|
HStack { Spacer(); ProgressView("正在解析目录…"); Spacer() }.padding(.vertical, 28)
|
||||||
|
} else if model.state == .findingSource {
|
||||||
|
HStack { Spacer(); ProgressView("当前源不可用,正在自动寻找可用源…"); Spacer() }
|
||||||
|
.padding(.vertical, 28)
|
||||||
|
} else if model.state == .failed {
|
||||||
|
ReadingUnavailableView(
|
||||||
|
"没有找到可用目录",
|
||||||
|
systemImage: "exclamationmark.arrow.triangle.2.circlepath",
|
||||||
|
description: Text("已自动尝试其他可搜索书源,可稍后刷新重试或手动换源。")
|
||||||
|
)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
} else {
|
||||||
|
ForEach(Array(model.chapters.filter { !$0.isVolume }.prefix(8).enumerated()), id: \.element.id) { offset, chapter in
|
||||||
|
if offset > 0 { Divider() }
|
||||||
|
if chapter.isVolume {
|
||||||
|
Text(chapter.title).font(.subheadline.bold()).foregroundStyle(ReadingTheme.accent)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
} else {
|
||||||
|
NavigationLink {
|
||||||
|
ReaderView(
|
||||||
|
book: model.book,
|
||||||
|
chapters: model.chapters,
|
||||||
|
initialIndex: chapter.index,
|
||||||
|
source: model.source
|
||||||
|
)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text(String(format: "%02d", chapter.index + 1))
|
||||||
|
.font(.caption.monospacedDigit()).foregroundStyle(.tertiary).frame(width: 28)
|
||||||
|
Text(chapter.title).font(.subheadline).foregroundStyle(.primary).lineLimit(2)
|
||||||
|
Spacer()
|
||||||
|
if chapter.isVip { Image(systemName: "lock.fill").font(.caption).foregroundStyle(.orange) }
|
||||||
|
Image(systemName: "chevron.right").font(.caption.bold()).foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if model.chapters.count > 8 {
|
||||||
|
Divider()
|
||||||
|
Button {
|
||||||
|
showsTableOfContents = true
|
||||||
|
} label: {
|
||||||
|
Label("查看全部目录", systemImage: "list.bullet")
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct BookTableOfContentsView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
let book: Book
|
||||||
|
let chapters: [BookChapter]
|
||||||
|
let source: BookSource
|
||||||
|
@State private var searchText = ""
|
||||||
|
@State private var descending = false
|
||||||
|
|
||||||
|
private var displayedChapters: [BookChapter] {
|
||||||
|
let filtered = searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
? chapters
|
||||||
|
: chapters.filter { $0.title.localizedCaseInsensitiveContains(searchText) }
|
||||||
|
return descending ? Array(filtered.reversed()) : filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ReadingNavigationStack {
|
||||||
|
List(displayedChapters) { chapter in
|
||||||
|
if chapter.isVolume {
|
||||||
|
Text(chapter.title).font(.headline).foregroundStyle(ReadingTheme.accent)
|
||||||
|
} else {
|
||||||
|
NavigationLink {
|
||||||
|
ReaderView(book: book, chapters: chapters, initialIndex: chapter.index, source: source)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text("\(chapter.index + 1)")
|
||||||
|
.font(.caption.monospacedDigit()).foregroundStyle(.secondary).frame(width: 42, alignment: .trailing)
|
||||||
|
Text(chapter.title).lineLimit(2)
|
||||||
|
Spacer()
|
||||||
|
if chapter.index == book.durChapterIndex {
|
||||||
|
Image(systemName: "location.fill").foregroundStyle(ReadingTheme.accent)
|
||||||
|
}
|
||||||
|
if chapter.isVip { Image(systemName: "lock.fill").foregroundStyle(.orange) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.searchable(text: $searchText, prompt: "搜索目录")
|
||||||
|
.navigationTitle("目录")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) { Button("关闭") { dismiss() } }
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button(descending ? "正序" : "倒序", systemImage: "arrow.up.arrow.down") {
|
||||||
|
descending.toggle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.safeAreaInset(edge: .bottom) {
|
||||||
|
HStack {
|
||||||
|
Text(book.durChapterTitle ?? "尚未阅读").lineLimit(1)
|
||||||
|
Spacer()
|
||||||
|
Text("\(min(book.durChapterIndex + 1, chapters.count))/\(chapters.count)")
|
||||||
|
.monospacedDigit().foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.font(.caption)
|
||||||
|
.padding(.horizontal, 16).padding(.vertical, 10)
|
||||||
|
.background(.bar)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import SwiftUI
|
||||||
|
#if SWIFT_PACKAGE
|
||||||
|
import LegadoCore
|
||||||
|
#endif
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class BookDetailViewModel: ObservableObject {
|
||||||
|
enum State: Equatable { case idle, loading, findingSource, ready, failed }
|
||||||
|
|
||||||
|
@Published private(set) var state: State = .idle
|
||||||
|
@Published private(set) var book: Book
|
||||||
|
@Published private(set) var chapters: [BookChapter] = []
|
||||||
|
@Published private(set) var diagnostics: [RuleDiagnostic] = []
|
||||||
|
@Published private(set) var isCaching = false
|
||||||
|
@Published private(set) var cacheCompleted = 0
|
||||||
|
@Published private(set) var cacheTotal = 0
|
||||||
|
|
||||||
|
@Published private(set) var source: BookSource
|
||||||
|
private var searchBook: SearchBook?
|
||||||
|
private let service: NetworkBookService
|
||||||
|
private let updateCoordinator: BookUpdateCoordinator
|
||||||
|
private let contentCoordinator: ChapterContentCoordinator
|
||||||
|
private let sourceSwitchService: BookSourceSwitchService
|
||||||
|
private var cacheTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
init(
|
||||||
|
searchBook: SearchBook,
|
||||||
|
source: BookSource,
|
||||||
|
service: NetworkBookService = .init(),
|
||||||
|
chapterRepository: any BookChapterRepository = AppBookChapterRepository.live()
|
||||||
|
) {
|
||||||
|
self.searchBook = searchBook
|
||||||
|
self.source = source
|
||||||
|
self.service = service
|
||||||
|
let chapters = chapterRepository
|
||||||
|
updateCoordinator = BookUpdateCoordinator(repository: chapters, loader: service)
|
||||||
|
contentCoordinator = ChapterContentCoordinator(loader: service)
|
||||||
|
sourceSwitchService = BookSourceSwitchService(
|
||||||
|
detailLoader: service,
|
||||||
|
chapterLoader: service,
|
||||||
|
contentLoader: service,
|
||||||
|
chapterRepository: chapters
|
||||||
|
)
|
||||||
|
var book = Book(
|
||||||
|
bookUrl: searchBook.bookURL,
|
||||||
|
name: searchBook.name,
|
||||||
|
author: searchBook.author,
|
||||||
|
origin: source.bookSourceUrl
|
||||||
|
)
|
||||||
|
book.originName = source.bookSourceName
|
||||||
|
book.coverUrl = searchBook.coverURL
|
||||||
|
book.intro = searchBook.intro
|
||||||
|
book.kind = searchBook.kind
|
||||||
|
self.book = book
|
||||||
|
}
|
||||||
|
|
||||||
|
init(book: Book, source: BookSource, service: NetworkBookService = .init()) {
|
||||||
|
searchBook = nil
|
||||||
|
self.book = book
|
||||||
|
self.source = source
|
||||||
|
self.service = service
|
||||||
|
let chapters = AppBookChapterRepository.live()
|
||||||
|
updateCoordinator = BookUpdateCoordinator(repository: chapters, loader: service)
|
||||||
|
contentCoordinator = ChapterContentCoordinator(loader: service)
|
||||||
|
sourceSwitchService = BookSourceSwitchService(
|
||||||
|
detailLoader: service,
|
||||||
|
chapterLoader: service,
|
||||||
|
contentLoader: service,
|
||||||
|
chapterRepository: chapters
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(demoBook: Book, source: BookSource, chapters: [BookChapter]) {
|
||||||
|
searchBook = nil
|
||||||
|
book = demoBook
|
||||||
|
self.source = source
|
||||||
|
service = .init()
|
||||||
|
updateCoordinator = .init()
|
||||||
|
contentCoordinator = .init()
|
||||||
|
sourceSwitchService = .init()
|
||||||
|
self.chapters = chapters
|
||||||
|
state = .ready
|
||||||
|
isCaching = true
|
||||||
|
cacheCompleted = 2
|
||||||
|
cacheTotal = chapters.filter { !$0.isVolume }.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func load(sources: [BookSource] = []) async {
|
||||||
|
guard state == .idle else { return }
|
||||||
|
state = .loading
|
||||||
|
if searchBook == nil {
|
||||||
|
let stored = await updateCoordinator.storedChapters(bookURL: book.bookUrl)
|
||||||
|
if !stored.isEmpty {
|
||||||
|
chapters = stored
|
||||||
|
state = .ready
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let searchBook {
|
||||||
|
let detail = await service.loadDetail(searchBook: searchBook, source: source)
|
||||||
|
let details = detail.book
|
||||||
|
book.name = details.name
|
||||||
|
book.author = details.author
|
||||||
|
book.originName = details.originName
|
||||||
|
if !details.tocUrl.isEmpty { book.tocUrl = details.tocUrl }
|
||||||
|
book.type = details.type
|
||||||
|
book.kind = details.kind ?? book.kind
|
||||||
|
book.coverUrl = details.coverUrl ?? book.coverUrl
|
||||||
|
book.intro = details.intro ?? book.intro
|
||||||
|
book.charset = details.charset ?? book.charset
|
||||||
|
book.wordCount = details.wordCount ?? book.wordCount
|
||||||
|
book.latestChapterTitle = details.latestChapterTitle ?? book.latestChapterTitle
|
||||||
|
book.originOrder = details.originOrder
|
||||||
|
book.variable = details.variable ?? book.variable
|
||||||
|
diagnostics += detail.diagnostics
|
||||||
|
}
|
||||||
|
let toc = await updateCoordinator.refresh(book: book, source: source)
|
||||||
|
if !toc.chapters.isEmpty { chapters = toc.chapters }
|
||||||
|
diagnostics += toc.diagnostics
|
||||||
|
book = toc.book
|
||||||
|
if !chapters.isEmpty {
|
||||||
|
state = .ready
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state = .findingSource
|
||||||
|
if let available = await sourceSwitchService.firstAvailableSource(for: book, sources: sources) {
|
||||||
|
applySourceSwitch(available.report, source: available.source)
|
||||||
|
} else {
|
||||||
|
state = .failed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cacheNextTen() { startCaching(limit: 10) }
|
||||||
|
|
||||||
|
func cacheRemaining() { startCaching(limit: nil) }
|
||||||
|
|
||||||
|
func cancelCaching() {
|
||||||
|
cacheTask?.cancel()
|
||||||
|
cacheTask = nil
|
||||||
|
isCaching = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func applySourceSwitch(_ report: BookSourceSwitchReport, source: BookSource) {
|
||||||
|
cancelCaching()
|
||||||
|
searchBook = nil
|
||||||
|
book = report.book
|
||||||
|
chapters = report.chapters
|
||||||
|
diagnostics = report.diagnostics
|
||||||
|
self.source = source
|
||||||
|
state = .ready
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleCanUpdate() { book.canUpdate.toggle() }
|
||||||
|
|
||||||
|
func restoreLibraryBook(from stored: Book, sources: [BookSource] = []) {
|
||||||
|
guard state == .idle, stored.bookUrl == book.bookUrl else { return }
|
||||||
|
if stored.origin != source.id,
|
||||||
|
let resolved = sources.first(where: { $0.id == stored.origin }) {
|
||||||
|
source = resolved
|
||||||
|
searchBook = nil
|
||||||
|
}
|
||||||
|
book = stored
|
||||||
|
book.origin = source.id
|
||||||
|
book.originName = source.bookSourceName
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreSourceAfterReading(from books: [Book], sources: [BookSource]) {
|
||||||
|
guard state == .ready,
|
||||||
|
let stored = books.first(where: { $0.bookUrl == book.bookUrl })
|
||||||
|
?? books.first(where: { $0.name == book.name && $0.author == book.author }),
|
||||||
|
stored.bookUrl != book.bookUrl || stored.origin != book.origin,
|
||||||
|
let source = sources.first(where: { $0.id == stored.origin }) else { return }
|
||||||
|
book = stored
|
||||||
|
self.source = source
|
||||||
|
searchBook = nil
|
||||||
|
chapters = []
|
||||||
|
diagnostics = []
|
||||||
|
state = .idle
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreUserSettings(from stored: Book) {
|
||||||
|
guard stored.bookUrl == book.bookUrl else { return }
|
||||||
|
book.group = stored.group
|
||||||
|
book.order = stored.order
|
||||||
|
book.customTag = stored.customTag
|
||||||
|
book.customCoverUrl = stored.customCoverUrl
|
||||||
|
book.customIntro = stored.customIntro
|
||||||
|
book.canUpdate = stored.canUpdate
|
||||||
|
book.readConfig = stored.readConfig
|
||||||
|
book.syncTime = stored.syncTime
|
||||||
|
}
|
||||||
|
|
||||||
|
func restoreReadingProgress(from stored: Book) {
|
||||||
|
guard stored.bookUrl == book.bookUrl, stored.durChapterTime > book.durChapterTime else { return }
|
||||||
|
let chapterIndex = stored.durChapterTitle.flatMap { title in
|
||||||
|
chapters.firstIndex { !$0.isVolume && $0.title == title }
|
||||||
|
} ?? stored.durChapterIndex
|
||||||
|
guard chapters.indices.contains(chapterIndex), !chapters[chapterIndex].isVolume else { return }
|
||||||
|
book.durChapterIndex = chapterIndex
|
||||||
|
book.durChapterTitle = chapters[chapterIndex].title
|
||||||
|
book.durChapterPos = stored.durChapterPos
|
||||||
|
book.durChapterTime = stored.durChapterTime
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh(sources: [BookSource] = []) async {
|
||||||
|
cancelCaching()
|
||||||
|
chapters = []
|
||||||
|
diagnostics = []
|
||||||
|
state = .idle
|
||||||
|
await load(sources: sources)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startCaching(limit: Int?) {
|
||||||
|
cancelCaching()
|
||||||
|
let start = min(max(book.durChapterIndex, 0), max(chapters.count - 1, 0))
|
||||||
|
let jobs = ChapterCachePlanner.plan(chapters: chapters, startIndex: start, limit: limit)
|
||||||
|
guard !jobs.isEmpty else { return }
|
||||||
|
|
||||||
|
isCaching = true
|
||||||
|
cacheCompleted = 0
|
||||||
|
cacheTotal = jobs.count
|
||||||
|
let coordinator = contentCoordinator
|
||||||
|
let currentBook = book
|
||||||
|
let currentSource = source
|
||||||
|
cacheTask = Task { [weak self] in
|
||||||
|
await withTaskGroup(of: Bool.self) { group in
|
||||||
|
var next = 0
|
||||||
|
func enqueue() {
|
||||||
|
guard next < jobs.count else { return }
|
||||||
|
let job = jobs[next]
|
||||||
|
next += 1
|
||||||
|
group.addTask {
|
||||||
|
guard !Task.isCancelled else { return false }
|
||||||
|
return await coordinator.prefetch(
|
||||||
|
book: currentBook,
|
||||||
|
chapter: job.chapter,
|
||||||
|
source: currentSource,
|
||||||
|
nextChapterURL: job.nextChapterURL
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _ in 0..<min(3, jobs.count) { enqueue() }
|
||||||
|
while await group.next() != nil {
|
||||||
|
guard !Task.isCancelled else {
|
||||||
|
group.cancelAll()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
self?.cacheCompleted += 1
|
||||||
|
enqueue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard !Task.isCancelled else { return }
|
||||||
|
self?.isCaching = false
|
||||||
|
self?.cacheTask = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,429 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
#if SWIFT_PACKAGE
|
||||||
|
import LegadoCore
|
||||||
|
#endif
|
||||||
|
|
||||||
|
enum AppBookChapterRepository {
|
||||||
|
static func live() -> any BookChapterRepository {
|
||||||
|
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||||
|
.appendingPathComponent("Legado", isDirectory: true)
|
||||||
|
guard let database = try? LegadoDatabase(url: base.appendingPathComponent("legado.sqlite3")) else {
|
||||||
|
return FileBookChapterRepository.live()
|
||||||
|
}
|
||||||
|
return SQLiteBookChapterRepository(
|
||||||
|
database: database,
|
||||||
|
legacyDirectory: base.appendingPathComponent("Chapters", isDirectory: true)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class BookLibraryModel: ObservableObject {
|
||||||
|
@Published private(set) var books: [Book] = []
|
||||||
|
@Published private(set) var groups: [BookGroup] = []
|
||||||
|
@Published private(set) var errorMessage: String?
|
||||||
|
@Published private(set) var updateMessage: String?
|
||||||
|
@Published private(set) var isCheckingUpdates = false
|
||||||
|
|
||||||
|
private let repository: any BookRepository
|
||||||
|
private let localDirectory: URL
|
||||||
|
let localFiles: LocalFileService
|
||||||
|
private let localService = LocalBookService.live()
|
||||||
|
private let updateCoordinator: BookUpdateCoordinator
|
||||||
|
private let chapterRepository: any BookChapterRepository
|
||||||
|
private let groupRepository: any BookGroupRepository
|
||||||
|
private let pendingProgressKey = "pendingBookReadingProgress"
|
||||||
|
private var pendingProgress: [String: BookProgress]
|
||||||
|
private var bookSaveTask: Task<Bool, Never>?
|
||||||
|
private var bookSaveGeneration = 0
|
||||||
|
private var persistedBooks: [Book] = []
|
||||||
|
|
||||||
|
init(
|
||||||
|
repository: any BookRepository,
|
||||||
|
localDirectory: URL? = nil,
|
||||||
|
updateCoordinator: BookUpdateCoordinator = .init(),
|
||||||
|
chapterRepository: any BookChapterRepository = FileBookChapterRepository.live(),
|
||||||
|
groupRepository: any BookGroupRepository = InMemoryBookGroupRepository()
|
||||||
|
) {
|
||||||
|
self.repository = repository
|
||||||
|
self.localDirectory = localDirectory ?? FileManager.default.temporaryDirectory.appendingPathComponent("LegadoLocalBooks")
|
||||||
|
localFiles = LocalFileService(directory: self.localDirectory)
|
||||||
|
self.updateCoordinator = updateCoordinator
|
||||||
|
self.chapterRepository = chapterRepository
|
||||||
|
self.groupRepository = groupRepository
|
||||||
|
pendingProgress = (UserDefaults.standard.data(forKey: pendingProgressKey)
|
||||||
|
.flatMap { try? JSONDecoder().decode([String: BookProgress].self, from: $0) }) ?? [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
static func live() -> BookLibraryModel {
|
||||||
|
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||||
|
.appendingPathComponent("Legado", isDirectory: true)
|
||||||
|
let legacyBooks = base.appendingPathComponent("books.json")
|
||||||
|
guard let database = try? LegadoDatabase(url: base.appendingPathComponent("legado.sqlite3")) else {
|
||||||
|
return BookLibraryModel(
|
||||||
|
repository: FileBookRepository(fileURL: legacyBooks),
|
||||||
|
localDirectory: base.appendingPathComponent("LocalBooks", isDirectory: true),
|
||||||
|
groupRepository: FileBookGroupRepository(fileURL: base.appendingPathComponent("book-groups.json"))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let chapters = SQLiteBookChapterRepository(
|
||||||
|
database: database,
|
||||||
|
legacyDirectory: base.appendingPathComponent("Chapters", isDirectory: true)
|
||||||
|
)
|
||||||
|
return BookLibraryModel(
|
||||||
|
repository: SQLiteBookRepository(database: database, legacyFileURL: legacyBooks),
|
||||||
|
localDirectory: base.appendingPathComponent("LocalBooks", isDirectory: true),
|
||||||
|
updateCoordinator: BookUpdateCoordinator(repository: chapters),
|
||||||
|
chapterRepository: chapters,
|
||||||
|
groupRepository: SQLiteBookGroupRepository(
|
||||||
|
database: database,
|
||||||
|
legacyFileURL: base.appendingPathComponent("book-groups.json")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func demo(books: [Book]) -> BookLibraryModel {
|
||||||
|
BookLibraryModel(repository: InMemoryBookRepository(books: books))
|
||||||
|
}
|
||||||
|
|
||||||
|
func load() async {
|
||||||
|
do {
|
||||||
|
async let storedBooks = repository.load()
|
||||||
|
async let storedGroups = groupRepository.load()
|
||||||
|
books = try await storedBooks
|
||||||
|
persistedBooks = books
|
||||||
|
groups = try await storedGroups
|
||||||
|
if groups.isEmpty {
|
||||||
|
groups = [.init(groupId: BookGroup.idAll, groupName: "全部", order: 0)]
|
||||||
|
try await persistGroups()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func contains(_ book: Book) -> Bool {
|
||||||
|
books.contains { $0.bookUrl == book.bookUrl }
|
||||||
|
}
|
||||||
|
|
||||||
|
func addOrUpdate(_ book: Book) async {
|
||||||
|
var book = book
|
||||||
|
if let existing = books.first(where: { $0.bookUrl == book.bookUrl }),
|
||||||
|
existing.durChapterTime > book.durChapterTime {
|
||||||
|
applyProgress(BookProgress(book: existing), to: &book)
|
||||||
|
}
|
||||||
|
if let pending = pendingProgress.removeValue(forKey: book.bookUrl) {
|
||||||
|
if pending.durChapterTime > book.durChapterTime {
|
||||||
|
applyProgress(pending, to: &book)
|
||||||
|
}
|
||||||
|
persistPendingProgress()
|
||||||
|
}
|
||||||
|
var updated = books
|
||||||
|
if let index = updated.firstIndex(where: { $0.bookUrl == book.bookUrl }) {
|
||||||
|
updated[index] = book
|
||||||
|
} else {
|
||||||
|
updated.append(book)
|
||||||
|
}
|
||||||
|
await persist(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func replace(oldBookURL: String, with book: Book) async {
|
||||||
|
guard books.contains(where: { $0.bookUrl == oldBookURL }) else {
|
||||||
|
await addOrUpdate(book)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var updated = books.filter { $0.bookUrl == oldBookURL || $0.bookUrl != book.bookUrl }
|
||||||
|
guard let index = updated.firstIndex(where: { $0.bookUrl == oldBookURL }) else { return }
|
||||||
|
updated[index] = book
|
||||||
|
await persist(updated)
|
||||||
|
if oldBookURL != book.bookUrl, books.contains(where: { $0.bookUrl == book.bookUrl }) {
|
||||||
|
try? await chapterRepository.remove(bookURL: oldBookURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateProgress(for book: Book, chapter: BookChapter, position: Int = 0) async {
|
||||||
|
guard let index = books.firstIndex(where: { $0.bookUrl == book.bookUrl }) else {
|
||||||
|
var reading = book
|
||||||
|
reading.durChapterIndex = chapter.index
|
||||||
|
reading.durChapterTitle = chapter.title
|
||||||
|
reading.durChapterPos = max(position, 0)
|
||||||
|
reading.durChapterTime = Int64(Date().timeIntervalSince1970 * 1_000)
|
||||||
|
pendingProgress[book.bookUrl] = BookProgress(book: reading)
|
||||||
|
persistPendingProgress()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var updated = books
|
||||||
|
updated[index].durChapterIndex = chapter.index
|
||||||
|
updated[index].durChapterTitle = chapter.title
|
||||||
|
updated[index].durChapterPos = max(position, 0)
|
||||||
|
updated[index].durChapterTime = Int64(Date().timeIntervalSince1970 * 1_000)
|
||||||
|
updated[index].lastCheckCount = 0
|
||||||
|
updated[index].totalChapterNum = max(updated[index].totalChapterNum, chapter.index + 1)
|
||||||
|
await persist(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyProgress(_ progress: BookProgress, to book: inout Book) {
|
||||||
|
book.durChapterIndex = progress.durChapterIndex
|
||||||
|
book.durChapterTitle = progress.durChapterTitle
|
||||||
|
book.durChapterPos = progress.durChapterPos
|
||||||
|
book.durChapterTime = progress.durChapterTime
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persistPendingProgress() {
|
||||||
|
if pendingProgress.isEmpty {
|
||||||
|
UserDefaults.standard.removeObject(forKey: pendingProgressKey)
|
||||||
|
} else if let data = try? JSONEncoder().encode(pendingProgress) {
|
||||||
|
UserDefaults.standard.set(data, forKey: pendingProgressKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkForUpdates(sources: [BookSource], onlyRead: Bool = false) async {
|
||||||
|
guard !isCheckingUpdates else { return }
|
||||||
|
isCheckingUpdates = true
|
||||||
|
updateMessage = nil
|
||||||
|
defer { isCheckingUpdates = false }
|
||||||
|
|
||||||
|
let sourceByURL = Dictionary(uniqueKeysWithValues: sources.map { ($0.bookSourceUrl, $0) })
|
||||||
|
let requestedBooks = books.filter { $0.canUpdate && $0.origin != "local" && (!onlyRead || $0.durChapterTime > 0) }
|
||||||
|
var checked = 0
|
||||||
|
var added = 0
|
||||||
|
var failed = 0
|
||||||
|
for requestedBook in requestedBooks {
|
||||||
|
guard let source = sourceByURL[requestedBook.origin], source.enabled else {
|
||||||
|
failed += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
let report = await updateCoordinator.refresh(book: requestedBook, source: source)
|
||||||
|
checked += 1
|
||||||
|
if report.refreshed {
|
||||||
|
// Network requests suspend this actor. Merge the new directory
|
||||||
|
// into the current shelf, including any progress or edits made
|
||||||
|
// while the request was running, and never resurrect removals.
|
||||||
|
guard let index = books.firstIndex(where: {
|
||||||
|
$0.bookUrl == requestedBook.bookUrl && $0.origin == requestedBook.origin
|
||||||
|
}) else { continue }
|
||||||
|
var updated = books
|
||||||
|
updated[index] = BookUpdateMerger().merge(
|
||||||
|
book: updated[index],
|
||||||
|
oldChapters: [],
|
||||||
|
newChapters: report.chapters,
|
||||||
|
now: report.book.lastCheckTime
|
||||||
|
).book
|
||||||
|
added += report.addedChapterCount
|
||||||
|
await persist(updated)
|
||||||
|
} else {
|
||||||
|
failed += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if checked == 0 && failed == 0 {
|
||||||
|
updateMessage = "书架里暂时没有可更新的网络书籍。"
|
||||||
|
} else if failed > 0 {
|
||||||
|
updateMessage = "已检查 \(checked) 本,发现 \(added) 个新章节;另有 \(failed) 本因书源不可用或解析失败未更新。"
|
||||||
|
} else {
|
||||||
|
updateMessage = added > 0 ? "更新完成,共发现 \(added) 个新章节。" : "更新完成,已是最新目录。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(at offsets: IndexSet) async {
|
||||||
|
var updated = books
|
||||||
|
updated.remove(atOffsets: offsets)
|
||||||
|
await persist(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
var visibleGroups: [BookGroup] {
|
||||||
|
groups.filter(\.show).sorted {
|
||||||
|
$0.order == $1.order ? $0.groupId < $1.groupId : $0.order < $1.order
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func books(in group: BookGroup) -> [Book] {
|
||||||
|
switch group.groupId {
|
||||||
|
case BookGroup.idAll: return books
|
||||||
|
case BookGroup.idLocal: return books.filter { $0.origin == "local" }
|
||||||
|
case BookGroup.idNetNone: return books.filter { $0.origin != "local" && $0.group == 0 }
|
||||||
|
case BookGroup.idLocalNone: return books.filter { $0.origin == "local" && $0.group == 0 }
|
||||||
|
default:
|
||||||
|
return group.groupId > 0
|
||||||
|
? books.filter { ($0.group & group.groupId) != 0 }
|
||||||
|
: books
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func createGroup(name: String) async {
|
||||||
|
let name = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !name.isEmpty, !groups.contains(where: { $0.groupName == name }) else { return }
|
||||||
|
let highest = groups.map(\.groupId).filter { $0 > 0 }.max() ?? 0
|
||||||
|
let nextID: Int64 = highest == 0 ? 1 : highest << 1
|
||||||
|
groups.append(.init(groupId: nextID, groupName: name, order: groups.count))
|
||||||
|
do { try await persistGroups() } catch { errorMessage = error.localizedDescription }
|
||||||
|
}
|
||||||
|
|
||||||
|
func renameGroup(_ group: BookGroup, name: String) async {
|
||||||
|
let name = name.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !name.isEmpty, let index = groups.firstIndex(where: { $0.id == group.id }) else { return }
|
||||||
|
groups[index].groupName = name
|
||||||
|
do { try await persistGroups() } catch { errorMessage = error.localizedDescription }
|
||||||
|
}
|
||||||
|
|
||||||
|
func setGroupVisible(_ group: BookGroup, visible: Bool) async {
|
||||||
|
guard let index = groups.firstIndex(where: { $0.id == group.id }) else { return }
|
||||||
|
groups[index].show = visible
|
||||||
|
do { try await persistGroups() } catch { errorMessage = error.localizedDescription }
|
||||||
|
}
|
||||||
|
|
||||||
|
func moveGroups(from offsets: IndexSet, to destination: Int) async {
|
||||||
|
var editable = groups.filter { $0.groupId > 0 }.sorted { $0.order < $1.order }
|
||||||
|
editable.move(fromOffsets: offsets, toOffset: destination)
|
||||||
|
for (order, group) in editable.enumerated() {
|
||||||
|
if let index = groups.firstIndex(where: { $0.id == group.id }) {
|
||||||
|
groups[index].order = order + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
do { try await persistGroups() } catch { errorMessage = error.localizedDescription }
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteGroup(_ group: BookGroup) async {
|
||||||
|
guard group.groupId > 0 else { return }
|
||||||
|
groups.removeAll { $0.id == group.id }
|
||||||
|
var updated = books
|
||||||
|
for index in updated.indices { updated[index].group &= ~group.groupId }
|
||||||
|
await persist(updated)
|
||||||
|
do { try await persistGroups() } catch { errorMessage = error.localizedDescription }
|
||||||
|
}
|
||||||
|
|
||||||
|
func setBook(_ book: Book, in group: BookGroup, included: Bool) async {
|
||||||
|
guard group.groupId > 0, let index = books.firstIndex(where: { $0.id == book.id }) else { return }
|
||||||
|
var updated = books
|
||||||
|
if included { updated[index].group |= group.groupId }
|
||||||
|
else { updated[index].group &= ~group.groupId }
|
||||||
|
await persist(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setBook(_ book: Book, groupIDs: Set<Int64>) async {
|
||||||
|
guard let index = books.firstIndex(where: { $0.id == book.id }) else { return }
|
||||||
|
let customMask = groups.reduce(Int64(0)) { value, group in
|
||||||
|
group.groupId > 0 ? value | group.groupId : value
|
||||||
|
}
|
||||||
|
let selectedMask = groupIDs.reduce(Int64(0), |)
|
||||||
|
var updated = books
|
||||||
|
updated[index].group = (updated[index].group & ~customMask) | selectedMask
|
||||||
|
await persist(updated)
|
||||||
|
}
|
||||||
|
|
||||||
|
func remove(bookURL: String) async {
|
||||||
|
await persist(books.filter { $0.bookUrl != bookURL })
|
||||||
|
try? await chapterRepository.remove(bookURL: bookURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
func importLocalFiles(_ urls: [URL]) async {
|
||||||
|
var failures: [String] = []
|
||||||
|
for url in urls {
|
||||||
|
let granted = url.startAccessingSecurityScopedResource()
|
||||||
|
defer { if granted { url.stopAccessingSecurityScopedResource() } }
|
||||||
|
do {
|
||||||
|
let stored = try await localFiles.importFile(from: url)
|
||||||
|
do {
|
||||||
|
try await addLocalFile(at: stored)
|
||||||
|
} catch {
|
||||||
|
if let staged = try? await localFiles.stageRemoval(stored) {
|
||||||
|
try? await localFiles.finishRemoval(staged)
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
failures.append("\(url.lastPathComponent):\(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
errorMessage = failures.isEmpty ? nil : failures.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func addLocalFile(at url: URL) async throws {
|
||||||
|
guard !books.contains(where: { $0.bookUrl == url.absoluteString }) else { return }
|
||||||
|
let report = try await Task.detached { [localService] in try localService.openTextFile(at: url) }.value
|
||||||
|
guard !books.contains(where: { $0.bookUrl == url.absoluteString }) else { return }
|
||||||
|
guard await persist(books + [report.book]) else {
|
||||||
|
throw LocalFileError.saveFailed(errorMessage ?? "无法写入数据库")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renameLocalFile(at url: URL, to name: String) async throws {
|
||||||
|
let target = try await localFiles.rename(url, to: name)
|
||||||
|
guard target != url else { return }
|
||||||
|
var updated = books
|
||||||
|
for index in updated.indices where updated[index].origin == "local" && updated[index].bookUrl == url.absoluteString {
|
||||||
|
updated[index].bookUrl = target.absoluteString
|
||||||
|
updated[index].originName = target.lastPathComponent
|
||||||
|
}
|
||||||
|
guard await persist(updated) else {
|
||||||
|
_ = try await localFiles.rename(target, to: url.lastPathComponent)
|
||||||
|
throw LocalFileError.saveFailed(errorMessage ?? "无法写入数据库")
|
||||||
|
}
|
||||||
|
if let progress = pendingProgress.removeValue(forKey: url.absoluteString) {
|
||||||
|
pendingProgress[target.absoluteString] = progress
|
||||||
|
persistPendingProgress()
|
||||||
|
}
|
||||||
|
try? await chapterRepository.remove(bookURL: url.absoluteString)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteLocalFile(at url: URL) async throws {
|
||||||
|
let staged: StagedLocalFile?
|
||||||
|
if url.isFileURL,
|
||||||
|
books.contains(where: { $0.origin == "local" && $0.bookUrl == url.absoluteString }),
|
||||||
|
!FileManager.default.fileExists(atPath: url.path) {
|
||||||
|
// Stale shelf paths can belong to a previous app container. Only
|
||||||
|
// remove their metadata; existing files still require containment.
|
||||||
|
staged = nil
|
||||||
|
} else {
|
||||||
|
staged = try await localFiles.stageRemoval(url)
|
||||||
|
}
|
||||||
|
guard await persist(books.filter { $0.bookUrl != url.absoluteString }) else {
|
||||||
|
if let staged { try await localFiles.restore(staged) }
|
||||||
|
throw LocalFileError.saveFailed(errorMessage ?? "无法写入数据库")
|
||||||
|
}
|
||||||
|
pendingProgress[url.absoluteString] = nil
|
||||||
|
persistPendingProgress()
|
||||||
|
try? await chapterRepository.remove(bookURL: url.absoluteString)
|
||||||
|
if let staged { try await localFiles.finishRemoval(staged) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearError() { errorMessage = nil }
|
||||||
|
|
||||||
|
func clearUpdateMessage() { updateMessage = nil }
|
||||||
|
|
||||||
|
func reportError(_ error: Error) { errorMessage = error.localizedDescription }
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func persist(_ updated: [Book]) async -> Bool {
|
||||||
|
// Publish before suspending so the next mutation sees the latest
|
||||||
|
// shelf. Serialize disk writes to keep their order identical to it.
|
||||||
|
books = updated
|
||||||
|
bookSaveGeneration &+= 1
|
||||||
|
let generation = bookSaveGeneration
|
||||||
|
let previousSave = bookSaveTask
|
||||||
|
let save = Task { @MainActor [weak self, repository] in
|
||||||
|
_ = await previousSave?.value
|
||||||
|
do {
|
||||||
|
try await repository.save(updated)
|
||||||
|
self?.persistedBooks = updated
|
||||||
|
if self?.bookSaveGeneration == generation { self?.errorMessage = nil }
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
if let self, bookSaveGeneration == generation {
|
||||||
|
books = persistedBooks
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bookSaveTask = save
|
||||||
|
let succeeded = await save.value
|
||||||
|
if bookSaveGeneration == generation { bookSaveTask = nil }
|
||||||
|
return succeeded
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persistGroups() async throws {
|
||||||
|
try await groupRepository.save(groups)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import SwiftUI
|
||||||
|
#if SWIFT_PACKAGE
|
||||||
|
import LegadoCore
|
||||||
|
#endif
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class BookshelfReadViewModel: ObservableObject {
|
||||||
|
@Published private(set) var chapters: [BookChapter] = []
|
||||||
|
@Published private(set) var resolvedBook: Book?
|
||||||
|
@Published private(set) var isLoading = false
|
||||||
|
@Published private(set) var errorMessage: String?
|
||||||
|
private let localService: LocalBookService
|
||||||
|
private let updateCoordinator: BookUpdateCoordinator
|
||||||
|
|
||||||
|
init(
|
||||||
|
localService: LocalBookService = .init(),
|
||||||
|
updateCoordinator: BookUpdateCoordinator = .init(repository: AppBookChapterRepository.live())
|
||||||
|
) {
|
||||||
|
self.localService = localService
|
||||||
|
self.updateCoordinator = updateCoordinator
|
||||||
|
}
|
||||||
|
|
||||||
|
func load(book: Book, source: BookSource?) async {
|
||||||
|
guard chapters.isEmpty, !isLoading else { return }
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
if book.origin == "local" {
|
||||||
|
do {
|
||||||
|
let report = try await Task.detached { [localService] in
|
||||||
|
try localService.loadChapters(book: book)
|
||||||
|
}.value
|
||||||
|
chapters = report.chapters
|
||||||
|
resolvedBook = book
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let stored = await updateCoordinator.storedChapters(bookURL: book.bookUrl)
|
||||||
|
if !stored.isEmpty, matchesReadingProgress(book, chapters: stored) {
|
||||||
|
chapters = stored
|
||||||
|
resolvedBook = book
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let source else {
|
||||||
|
errorMessage = "目录与阅读进度不一致,且当前书籍的书源不存在。请先恢复或更换书源。"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let report = await updateCoordinator.refresh(book: book, source: source)
|
||||||
|
if report.refreshed, matchesReadingProgress(report.book, chapters: report.chapters) {
|
||||||
|
chapters = report.chapters
|
||||||
|
resolvedBook = report.book
|
||||||
|
} else {
|
||||||
|
errorMessage = report.diagnostics.first?.message ?? "目录刷新后仍与阅读进度不一致,请稍后重试。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func matchesReadingProgress(_ book: Book, chapters: [BookChapter]) -> Bool {
|
||||||
|
guard book.totalChapterNum == 0 || book.totalChapterNum == chapters.count else { return false }
|
||||||
|
guard let title = book.durChapterTitle, !title.isEmpty else { return true }
|
||||||
|
return chapters.indices.contains(book.durChapterIndex)
|
||||||
|
&& chapters[book.durChapterIndex].title == title
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class LocalBookDetailViewModel: ObservableObject {
|
||||||
|
@Published private(set) var chapters: [BookChapter] = []
|
||||||
|
@Published private(set) var isLoading = false
|
||||||
|
@Published private(set) var errorMessage: String?
|
||||||
|
|
||||||
|
private let service = LocalBookService.live()
|
||||||
|
|
||||||
|
func load(book: Book) async {
|
||||||
|
guard chapters.isEmpty, !isLoading else { return }
|
||||||
|
isLoading = true
|
||||||
|
defer { isLoading = false }
|
||||||
|
do {
|
||||||
|
let report = try await Task.detached { [service] in try service.loadChapters(book: book) }.value
|
||||||
|
chapters = report.chapters
|
||||||
|
} catch {
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LocalBookDetailView: View {
|
||||||
|
@EnvironmentObject private var library: BookLibraryModel
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
let book: Book
|
||||||
|
@StateObject private var model = LocalBookDetailViewModel()
|
||||||
|
@State private var showsTableOfContents = false
|
||||||
|
@State private var showsRemoveConfirmation = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 16) {
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
BookCoverArtwork(title: book.name, author: book.author, width: 108, height: 154)
|
||||||
|
Text(book.name).font(.title2.bold())
|
||||||
|
HStack {
|
||||||
|
StatusPill(text: "本地 TXT", systemImage: "doc.text.fill")
|
||||||
|
if let charset = book.charset { StatusPill(text: charset, color: ReadingTheme.coral, systemImage: "character") }
|
||||||
|
}
|
||||||
|
Text(book.originName).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.top, 12)
|
||||||
|
|
||||||
|
ReadingCard {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
HStack {
|
||||||
|
Label("目录", systemImage: "list.bullet.rectangle.portrait.fill").font(.headline)
|
||||||
|
Spacer()
|
||||||
|
Text("\(model.chapters.count) 章").font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.bottom, 12)
|
||||||
|
if model.isLoading {
|
||||||
|
HStack { Spacer(); ProgressView("正在识别章节…"); Spacer() }.padding(.vertical, 28)
|
||||||
|
} else if let error = model.errorMessage {
|
||||||
|
ReadingUnavailableView("无法读取本地书", systemImage: "doc.badge.ellipsis", description: Text(error))
|
||||||
|
} else {
|
||||||
|
ForEach(model.chapters.prefix(8)) { chapter in
|
||||||
|
Divider()
|
||||||
|
NavigationLink {
|
||||||
|
ReaderView(book: book, chapters: model.chapters, initialIndex: chapter.index)
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text(String(format: "%02d", chapter.index + 1))
|
||||||
|
.font(.caption.monospacedDigit()).foregroundStyle(.tertiary).frame(width: 28)
|
||||||
|
Text(chapter.title).font(.subheadline).foregroundStyle(.primary).lineLimit(2)
|
||||||
|
Spacer()
|
||||||
|
Image(systemName: "chevron.right").font(.caption.bold()).foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if model.chapters.count > 8 {
|
||||||
|
Divider()
|
||||||
|
Button("查看全部目录", systemImage: "list.bullet") {
|
||||||
|
showsTableOfContents = true
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity).padding(.vertical, 10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16).padding(.bottom, 30)
|
||||||
|
}
|
||||||
|
.background(ReadingTheme.canvas)
|
||||||
|
.navigationTitle(book.name)
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Menu {
|
||||||
|
Button("全部目录", systemImage: "list.bullet") { showsTableOfContents = true }
|
||||||
|
Button("移出书架", systemImage: "trash", role: .destructive) { showsRemoveConfirmation = true }
|
||||||
|
} label: { Image(systemName: "ellipsis.circle") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task { await model.load(book: book) }
|
||||||
|
.sheet(isPresented: $showsTableOfContents) {
|
||||||
|
LocalBookTableOfContentsView(book: book, chapters: model.chapters)
|
||||||
|
}
|
||||||
|
.confirmationDialog("从书架移除《\(book.name)》?", isPresented: $showsRemoveConfirmation) {
|
||||||
|
Button("移出书架", role: .destructive) {
|
||||||
|
Task { await library.remove(bookURL: book.bookUrl); dismiss() }
|
||||||
|
}
|
||||||
|
Button("取消", role: .cancel) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct LocalBookTableOfContentsView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
let book: Book
|
||||||
|
let chapters: [BookChapter]
|
||||||
|
@State private var query = ""
|
||||||
|
@State private var descending = false
|
||||||
|
|
||||||
|
private var displayed: [BookChapter] {
|
||||||
|
let filtered = query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||||
|
? chapters
|
||||||
|
: chapters.filter { $0.title.localizedCaseInsensitiveContains(query) }
|
||||||
|
return descending ? Array(filtered.reversed()) : filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ReadingNavigationStack {
|
||||||
|
List(displayed) { chapter in
|
||||||
|
NavigationLink {
|
||||||
|
ReaderView(book: book, chapters: chapters, initialIndex: chapter.index)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text("\(chapter.index + 1)").font(.caption.monospacedDigit()).foregroundStyle(.secondary).frame(width: 42, alignment: .trailing)
|
||||||
|
Text(chapter.title).lineLimit(2)
|
||||||
|
Spacer()
|
||||||
|
if chapter.index == book.durChapterIndex {
|
||||||
|
Image(systemName: "location.fill").foregroundStyle(ReadingTheme.accent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.searchable(text: $query, prompt: "搜索目录")
|
||||||
|
.navigationTitle("目录")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) { Button("关闭") { dismiss() } }
|
||||||
|
ToolbarItem(placement: .topBarTrailing) {
|
||||||
|
Button(descending ? "正序" : "倒序", systemImage: "arrow.up.arrow.down") { descending.toggle() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class SourceSwitchViewModel: ObservableObject {
|
||||||
|
enum State: Equatable { case idle, searching, ready, empty }
|
||||||
|
|
||||||
|
@Published private(set) var state: State = .idle
|
||||||
|
@Published private(set) var candidates: [BookSourceCandidate] = []
|
||||||
|
@Published private(set) var switchingID: String?
|
||||||
|
@Published private(set) var errorMessage: String?
|
||||||
|
|
||||||
|
private let book: Book
|
||||||
|
var bookName: String { book.name }
|
||||||
|
private let service: BookSourceSwitchService
|
||||||
|
private var selectionGeneration = 0
|
||||||
|
|
||||||
|
init(book: Book, service: BookSourceSwitchService = .init(), candidates: [BookSourceCandidate] = []) {
|
||||||
|
self.book = book
|
||||||
|
self.service = service
|
||||||
|
self.candidates = candidates
|
||||||
|
state = candidates.isEmpty ? .idle : .ready
|
||||||
|
}
|
||||||
|
|
||||||
|
func load(sources: [BookSource]) async {
|
||||||
|
guard state == .idle else { return }
|
||||||
|
state = .searching
|
||||||
|
for await update in await service.candidateUpdates(for: book, sources: sources) {
|
||||||
|
guard !Task.isCancelled else { state = candidates.isEmpty ? .idle : .ready; return }
|
||||||
|
candidates = update
|
||||||
|
}
|
||||||
|
guard !Task.isCancelled else { state = candidates.isEmpty ? .idle : .ready; return }
|
||||||
|
state = candidates.isEmpty ? .empty : .ready
|
||||||
|
}
|
||||||
|
|
||||||
|
func select(_ candidate: BookSourceCandidate, sources: [BookSource]) async -> (BookSourceSwitchReport, BookSource)? {
|
||||||
|
guard switchingID == nil,
|
||||||
|
let source = sources.first(where: { $0.bookSourceUrl == candidate.book.origin }) else { return nil }
|
||||||
|
switchingID = candidate.id
|
||||||
|
let generation = selectionGeneration
|
||||||
|
errorMessage = nil
|
||||||
|
defer { switchingID = nil }
|
||||||
|
let report = await service.prepareSwitch(current: book, candidate: candidate.book, source: source)
|
||||||
|
guard generation == selectionGeneration, !Task.isCancelled else { return nil }
|
||||||
|
guard let report else {
|
||||||
|
errorMessage = "候选书源无法读取目录或当前章节正文,请选择其他书源。已保留当前书源和阅读位置。"
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return (report, source)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearError() { errorMessage = nil }
|
||||||
|
func cancelSelection() { selectionGeneration &+= 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SourceSwitchView: View {
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@StateObject private var model: SourceSwitchViewModel
|
||||||
|
let sources: [BookSource]
|
||||||
|
let onSwitched: @MainActor (BookSourceSwitchReport, BookSource) -> Void
|
||||||
|
|
||||||
|
init(
|
||||||
|
book: Book,
|
||||||
|
sources: [BookSource],
|
||||||
|
demoCandidates: [BookSourceCandidate] = [],
|
||||||
|
onSwitched: @escaping @MainActor (BookSourceSwitchReport, BookSource) -> Void
|
||||||
|
) {
|
||||||
|
self.sources = sources
|
||||||
|
self.onSwitched = onSwitched
|
||||||
|
_model = StateObject(wrappedValue: SourceSwitchViewModel(book: book, candidates: demoCandidates))
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
if model.candidates.isEmpty && (model.state == .searching || model.state == .idle) {
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
ProgressView()
|
||||||
|
Text("正在从其他书源查找同名作品…").foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} else if model.state == .empty {
|
||||||
|
ReadingUnavailableView(
|
||||||
|
"没有可用换源",
|
||||||
|
systemImage: "arrow.triangle.2.circlepath",
|
||||||
|
description: Text("仅显示书名一致的候选,并优先匹配作者。请确认其他书源已启用且支持搜索。")
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
List {
|
||||||
|
if model.state == .searching {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ProgressView()
|
||||||
|
Text("仍在查找其他书源,可直接选择已找到的结果")
|
||||||
|
.font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ForEach(model.candidates) { candidate in candidateRow(candidate) }
|
||||||
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
|
.readingScrollContentBackgroundHidden()
|
||||||
|
.background(ReadingTheme.canvas)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(ReadingTheme.canvas)
|
||||||
|
.navigationTitle("为《\(model.bookName)》换源")
|
||||||
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
|
.toolbar {
|
||||||
|
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
|
||||||
|
}
|
||||||
|
.task { await model.load(sources: sources) }
|
||||||
|
.onDisappear { model.cancelSelection() }
|
||||||
|
.alert("换源失败", isPresented: Binding(
|
||||||
|
get: { model.errorMessage != nil },
|
||||||
|
set: { if !$0 { model.clearError() } }
|
||||||
|
)) {
|
||||||
|
Button("好") { model.clearError() }
|
||||||
|
} message: {
|
||||||
|
Text(model.errorMessage ?? "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func candidateRow(_ candidate: BookSourceCandidate) -> some View {
|
||||||
|
Button {
|
||||||
|
Task {
|
||||||
|
if let (report, source) = await model.select(candidate, sources: sources) {
|
||||||
|
onSwitched(report, source)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 13) {
|
||||||
|
Image(systemName: "network")
|
||||||
|
.foregroundStyle(ReadingTheme.accent)
|
||||||
|
.frame(width: 42, height: 42)
|
||||||
|
.background(ReadingTheme.accentSoft.opacity(0.55), in: RoundedRectangle(cornerRadius: 13))
|
||||||
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
|
Text(candidate.book.originName).font(.headline).foregroundStyle(.primary)
|
||||||
|
Text(candidate.book.author.isEmpty ? "作者信息缺失" : candidate.book.author)
|
||||||
|
.font(.subheadline).foregroundStyle(.secondary)
|
||||||
|
if model.switchingID == candidate.id {
|
||||||
|
Text("正在验证目录和正文…").font(.caption).foregroundStyle(.secondary)
|
||||||
|
} else if let latest = candidate.book.latestChapterTitle, !latest.isEmpty {
|
||||||
|
Text(latest).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
if model.switchingID == candidate.id {
|
||||||
|
ProgressView()
|
||||||
|
} else {
|
||||||
|
Image(systemName: "arrow.right.circle.fill").foregroundStyle(ReadingTheme.coral)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
}
|
||||||
|
.disabled(model.switchingID != nil)
|
||||||
|
.accessibilityIdentifier("sourceSwitch.candidate.\(candidate.book.origin)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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: "&").replacingOccurrences(of: "\"", with: """).replacingOccurrences(of: "<", with: "<")
|
||||||
|
}
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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") }
|
||||||
|
}
|
||||||
@@ -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
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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>.
|
||||||
@@ -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.
|
||||||
@@ -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);"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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
@@ -0,0 +1,15 @@
|
|||||||
|
# 更新记录
|
||||||
|
|
||||||
|
## 0.1.9(本地安装包 · 2026-10-14)
|
||||||
|
|
||||||
|
- 个人中心采用阅读静物头图、暖白圆角卡片和黑金双色图标,适配深色模式。
|
||||||
|
- 应用版本更新为 `0.1.9`,构建号更新为 `12`。
|
||||||
|
|
||||||
|
## 未发布
|
||||||
|
|
||||||
|
- 最低支持版本降至 iOS 15.0,适配导航、分享、二维码导入、阅读器及系统 API。
|
||||||
|
- 应用展示名称统一为「小浣熊阅读」,更新「我的」与「关于」页面的品牌、开发者与项目入口。
|
||||||
|
- 添加 IPA 自签安装说明、应用截图、开源许可与贡献指南。
|
||||||
|
- 提供本地无签名模拟器构建与自动测试脚本。
|
||||||
|
|
||||||
|
当前代码中的应用版本为 `0.1.9`;该版本号不表示已发布 GitHub Release、TestFlight 或 App Store 版本。首个公开版本发布时补充日期、对应提交、安装方式、测试设备和已知问题。
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# 参与小浣熊阅读
|
||||||
|
|
||||||
|
欢迎提交可复现的 Bug、书源兼容用例、文档改进和代码贡献。
|
||||||
|
|
||||||
|
## 报告问题
|
||||||
|
|
||||||
|
请说明应用版本、iOS 版本、设备、操作步骤、预期结果和实际结果。搜索问题请同时说明启用书源数量、是否重复查询、网络环境,以及规则是否包含 WebView / JavaScript。
|
||||||
|
|
||||||
|
书源问题优先提供使用虚构域名和自造响应的最小规则。若必须提供公开网站链接,请先确认其可公开分享;不要提交账号、Cookie、Token、个人书架、完整书籍正文或付费内容。应用的「关于」页可以分享版本信息,诊断日志需手动导出并先脱敏。
|
||||||
|
|
||||||
|
## 修改代码
|
||||||
|
|
||||||
|
1. Fork 独立仓库并创建工作分支,一次 PR 解决一个清晰的问题。
|
||||||
|
2. 阅读 [开发指南](docs/DEVELOPMENT.md),在现有模块中修改。
|
||||||
|
3. 功能与兼容修复补充有意义的最小回归用例;纯文案和排版修改检查实际显示即可。
|
||||||
|
4. 运行适用测试;影响 App 时完成模拟器构建。说明没有覆盖的真机场景。
|
||||||
|
5. PR 写清问题、修改后的行为、验证方式及截图,不提交构建产物或签名文件。
|
||||||
|
|
||||||
|
提交代码须有权按本项目 GPL v3 许可提供,并保留第三方材料的版权与许可。相关声明统一放在 [第三方声明](THIRD_PARTY_NOTICES.md)。
|
||||||
|
|
||||||
|
## 协作约定
|
||||||
|
|
||||||
|
尊重不同设备、系统与网络条件下的反馈。讨论具体问题并提供复现依据,避免人身攻击、骚扰、刷屏或在无关项目中推广。本项目不接受绕过付费、验证码、安全控制或访问授权的贡献。
|
||||||
@@ -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>.
|
||||||
@@ -0,0 +1,943 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {};
|
||||||
|
objectVersion = 56;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
B10000000000000000000050 = {isa = PBXBuildFile; fileRef = B20000000000000000000050; };
|
||||||
|
B10000000000000000000030 /* ReaderScrollGeometryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000030; };
|
||||||
|
BC1000000000000000000001 /* WebReaderBridgeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC2000000000000000000001; };
|
||||||
|
B10000000000000000000029 /* ReaderScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000029 /* ReaderScrollView.swift */; };
|
||||||
|
F10000000000000000000001 /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = F20000000000000000000001 /* SwiftSoup */; };
|
||||||
|
F10000000000000000000002 /* Kanna in Frameworks */ = {isa = PBXBuildFile; productRef = F20000000000000000000002 /* Kanna */; };
|
||||||
|
A10000000000000000000001 /* LegadoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000001 /* LegadoApp.swift */; };
|
||||||
|
A10000000000000000000002 /* RootTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000002 /* RootTabView.swift */; };
|
||||||
|
A10000000000000000000003 /* SourceLibraryModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000003 /* SourceLibraryModel.swift */; };
|
||||||
|
A10000000000000000000004 /* SourceManagementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000004 /* SourceManagementView.swift */; };
|
||||||
|
A10000000000000000000005 /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000006 /* SearchViewModel.swift */; };
|
||||||
|
A10000000000000000000006 /* BookLibraryModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000007 /* BookLibraryModel.swift */; };
|
||||||
|
A10000000000000000000007 /* BookDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000008 /* BookDetailView.swift */; };
|
||||||
|
A10000000000000000000008 /* ReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000009 /* ReaderView.swift */; };
|
||||||
|
A10000000000000000000021 /* ReaderAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000028 /* ReaderAppearance.swift */; };
|
||||||
|
A10000000000000000000022 /* ReaderSpeechController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000029 /* ReaderSpeechController.swift */; };
|
||||||
|
A10000000000000000000023 /* ReaderTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000002A /* ReaderTextView.swift */; };
|
||||||
|
A10000000000000000000024 /* ReaderSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000002B /* ReaderSupport.swift */; };
|
||||||
|
A10000000000000000000025 /* ReaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000002C /* ReaderViewModel.swift */; };
|
||||||
|
A10000000000000000000026 /* ReadingStatisticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000002D /* ReadingStatisticsView.swift */; };
|
||||||
|
CC1500000000000000000001 /* Compatibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC1500000000000000000002 /* Compatibility.swift */; };
|
||||||
|
A10000000000000000000009 /* DesignSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000000A /* DesignSystem.swift */; };
|
||||||
|
A1000000000000000000000A /* DemoContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000000B /* DemoContent.swift */; };
|
||||||
|
A1000000000000000000000B /* ExploreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000000C /* ExploreView.swift */; };
|
||||||
|
A1000000000000000000000C /* SourceDebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000000D /* SourceDebugView.swift */; };
|
||||||
|
A1000000000000000000000D /* SourceLoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000000E /* SourceLoginView.swift */; };
|
||||||
|
A1000000000000000000000E /* LocalBookDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000000F /* LocalBookDetailView.swift */; };
|
||||||
|
A1000000000000000000000F /* ReplaceRuleLibraryModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000010 /* ReplaceRuleLibraryModel.swift */; };
|
||||||
|
A10000000000000000000010 /* ReplaceRuleManagementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000011 /* ReplaceRuleManagementView.swift */; };
|
||||||
|
A10000000000000000000011 /* ReadingActivityModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000012 /* ReadingActivityModel.swift */; };
|
||||||
|
A10000000000000000000012 /* ReadingHistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000013 /* ReadingHistoryView.swift */; };
|
||||||
|
A10000000000000000000013 /* CacheManagementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000014 /* CacheManagementView.swift */; };
|
||||||
|
A10000000000000000000014 /* SourceSwitchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000015 /* SourceSwitchView.swift */; };
|
||||||
|
A10000000000000000000015 /* RSSLibraryModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000016 /* RSSLibraryModel.swift */; };
|
||||||
|
A10000000000000000000016 /* RSSViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000017 /* RSSViews.swift */; };
|
||||||
|
A10000000000000000000017 /* RSSSourceEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000018 /* RSSSourceEditorView.swift */; };
|
||||||
|
A10000000000000000000018 /* RSSWebReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000019 /* RSSWebReaderView.swift */; };
|
||||||
|
A10000000000000000000019 /* RSSSourceDebugView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000020 /* RSSSourceDebugView.swift */; };
|
||||||
|
A1000000000000000000001A /* RSSAddBookView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000021 /* RSSAddBookView.swift */; };
|
||||||
|
A1000000000000000000001B /* RuleSubscriptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000022 /* RuleSubscriptionView.swift */; };
|
||||||
|
A1000000000000000000001C /* TxtTocRuleManagementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000023 /* TxtTocRuleManagementView.swift */; };
|
||||||
|
A1000000000000000000001D /* txtTocRule.json in Resources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000024 /* txtTocRule.json */; };
|
||||||
|
A1000000000000000000001E /* httpTTS.json in Resources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000025 /* httpTTS.json */; };
|
||||||
|
A1000000000000000000001F /* HttpTTSManagementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000026 /* HttpTTSManagementView.swift */; };
|
||||||
|
A10000000000000000000020 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000027 /* Assets.xcassets */; };
|
||||||
|
C10000000000000000000001 /* BookSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000001 /* BookSource.swift */; };
|
||||||
|
C10000000000000000000002 /* BookSourceRules.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000002 /* BookSourceRules.swift */; };
|
||||||
|
C10000000000000000000003 /* LibraryModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000003 /* LibraryModels.swift */; };
|
||||||
|
C10000000000000000000004 /* BookSourceValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000004 /* BookSourceValidator.swift */; };
|
||||||
|
C10000000000000000000005 /* BookSourceImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000005 /* BookSourceImporter.swift */; };
|
||||||
|
C10000000000000000000006 /* BookSourceRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000006 /* BookSourceRepository.swift */; };
|
||||||
|
C10000000000000000000007 /* SearchBook.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000007 /* SearchBook.swift */; };
|
||||||
|
C10000000000000000000008 /* RuleDiagnostic.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000008 /* RuleDiagnostic.swift */; };
|
||||||
|
C10000000000000000000009 /* JSONValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000009 /* JSONValue.swift */; };
|
||||||
|
C1000000000000000000000A /* RuleExpression.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2000000000000000000000A /* RuleExpression.swift */; };
|
||||||
|
C1000000000000000000000B /* JSONPathEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2000000000000000000000B /* JSONPathEvaluator.swift */; };
|
||||||
|
C1000000000000000000000C /* RegexEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2000000000000000000000C /* RegexEvaluator.swift */; };
|
||||||
|
C1000000000000000000000D /* RuleEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2000000000000000000000D /* RuleEngine.swift */; };
|
||||||
|
C1000000000000000000000E /* LegadoURLBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2000000000000000000000E /* LegadoURLBuilder.swift */; };
|
||||||
|
C1000000000000000000000F /* HTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2000000000000000000000F /* HTTPClient.swift */; };
|
||||||
|
C10000000000000000000010 /* BookSearchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000010 /* BookSearchService.swift */; };
|
||||||
|
C10000000000000000000011 /* HTMLRuleEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000011 /* HTMLRuleEvaluator.swift */; };
|
||||||
|
C10000000000000000000012 /* NetworkBookService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000012 /* NetworkBookService.swift */; };
|
||||||
|
C10000000000000000000013 /* BookRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000013 /* BookRepository.swift */; };
|
||||||
|
C10000000000000000000014 /* JavaScriptRuleEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000014 /* JavaScriptRuleEvaluator.swift */; };
|
||||||
|
C10000000000000000000015 /* ExploreKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000015 /* ExploreKind.swift */; };
|
||||||
|
C10000000000000000000016 /* BookExploreService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000016 /* BookExploreService.swift */; };
|
||||||
|
C10000000000000000000017 /* BookSourceDebugger.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000017 /* BookSourceDebugger.swift */; };
|
||||||
|
C10000000000000000000018 /* LocalBookService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000018 /* LocalBookService.swift */; };
|
||||||
|
C10000000000000000000019 /* ContentSanitizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000019 /* ContentSanitizer.swift */; };
|
||||||
|
C1000000000000000000001A /* ReplaceRuleRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2000000000000000000001A /* ReplaceRuleRepository.swift */; };
|
||||||
|
C1000000000000000000001B /* ReadingActivityRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2000000000000000000001B /* ReadingActivityRepository.swift */; };
|
||||||
|
C1000000000000000000001C /* ChapterContentCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2000000000000000000001C /* ChapterContentCache.swift */; };
|
||||||
|
C1000000000000000000001D /* BookChapterRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2000000000000000000001D /* BookChapterRepository.swift */; };
|
||||||
|
C1000000000000000000001E /* BookUpdateCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2000000000000000000001E /* BookUpdateCoordinator.swift */; };
|
||||||
|
C1000000000000000000001F /* BookSourceSwitchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2000000000000000000001F /* BookSourceSwitchService.swift */; };
|
||||||
|
C10000000000000000000020 /* LegadoDatabase.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000020 /* LegadoDatabase.swift */; };
|
||||||
|
C10000000000000000000021 /* BookSourceHeaderResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000021 /* BookSourceHeaderResolver.swift */; };
|
||||||
|
C10000000000000000000022 /* DifferentialSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000022 /* DifferentialSnapshot.swift */; };
|
||||||
|
C10000000000000000000023 /* ConfigurationRepositories.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000023 /* ConfigurationRepositories.swift */; };
|
||||||
|
C10000000000000000000024 /* RSSModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000024 /* RSSModels.swift */; };
|
||||||
|
C10000000000000000000025 /* RSSRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000025 /* RSSRepository.swift */; };
|
||||||
|
C10000000000000000000026 /* RSSDefaultParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000026 /* RSSDefaultParser.swift */; };
|
||||||
|
C10000000000000000000027 /* RSSRuleParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000027 /* RSSRuleParser.swift */; };
|
||||||
|
C10000000000000000000028 /* RSSService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000028 /* RSSService.swift */; };
|
||||||
|
C10000000000000000000029 /* RSSSourceImporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = C20000000000000000000029 /* RSSSourceImporter.swift */; };
|
||||||
|
C1000000000000000000002A /* RSSSortResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2000000000000000000002A /* RSSSortResolver.swift */; };
|
||||||
|
C1000000000000000000002B /* RSSReadingPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2000000000000000000002B /* RSSReadingPolicy.swift */; };
|
||||||
|
C1000000000000000000002C /* RSSSourceDebugger.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2000000000000000000002C /* RSSSourceDebugger.swift */; };
|
||||||
|
C1000000000000000000002D /* AddBookURLService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2000000000000000000002D /* AddBookURLService.swift */; };
|
||||||
|
C1000000000000000000002E /* HttpTTSService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2000000000000000000002E /* HttpTTSService.swift */; };
|
||||||
|
B10000000000000000000027 /* BookshelfReadViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000027 /* BookshelfReadViewModel.swift */; };
|
||||||
|
B10000000000000000000028 /* BookDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B20000000000000000000028 /* BookDetailViewModel.swift */; };
|
||||||
|
910000000000000000000001 /* ApplicationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20000000000000000000001 /* ApplicationSettings.swift */; };
|
||||||
|
910000000000000000000002 /* HostsHTTPTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20000000000000000000002 /* HostsHTTPTransport.swift */; };
|
||||||
|
910000000000000000000003 /* DiagnosticLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20000000000000000000003 /* DiagnosticLog.swift */; };
|
||||||
|
910000000000000000000004 /* OtherSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20000000000000000000004 /* OtherSettingsView.swift */; };
|
||||||
|
910000000000000000000005 /* DiagnosticLogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20000000000000000000005 /* DiagnosticLogView.swift */; };
|
||||||
|
910000000000000000000006 /* AboutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20000000000000000000006 /* AboutView.swift */; };
|
||||||
|
910000000000000000000007 /* AppDiagnostics.swift in Sources */ = {isa = PBXBuildFile; fileRef = E20000000000000000000007 /* AppDiagnostics.swift */; };
|
||||||
|
910000000000000000000008 /* Legado-LICENSE.txt in Resources */ = {isa = PBXBuildFile; fileRef = E20000000000000000000008 /* Legado-LICENSE.txt */; };
|
||||||
|
910000000000000000000009 /* SwiftSoup-LICENSE.txt in Resources */ = {isa = PBXBuildFile; fileRef = E20000000000000000000009 /* SwiftSoup-LICENSE.txt */; };
|
||||||
|
91000000000000000000000A /* Kanna-LICENSE.txt in Resources */ = {isa = PBXBuildFile; fileRef = E2000000000000000000000A /* Kanna-LICENSE.txt */; };
|
||||||
|
F11000000000000000000001 /* FileManagementView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F21000000000000000000001 /* FileManagementView.swift */; };
|
||||||
|
F11000000000000000000002 /* LocalFileService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F21000000000000000000002 /* LocalFileService.swift */; };
|
||||||
|
F11000000000000000000003 /* BookSourceEditorDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = F21000000000000000000003 /* BookSourceEditorDocument.swift */; };
|
||||||
|
F11000000000000000000004 /* RuleImportInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = F21000000000000000000004 /* RuleImportInput.swift */; };
|
||||||
|
AA1100000000000000000001 /* SourceEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2200000000000000000001; };
|
||||||
|
AA1100000000000000000002 /* ReplaceRuleEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2200000000000000000002; };
|
||||||
|
AA1100000000000000000003 /* SyntaxCodeEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2200000000000000000003; };
|
||||||
|
AA1100000000000000000004 /* QRCodeImportView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2200000000000000000004; };
|
||||||
|
AA1100000000000000000007 /* ReplaceRuleTester.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA2200000000000000000007; };
|
||||||
|
AA1100000000000000000008 = {isa = PBXBuildFile; fileRef = AA2200000000000000000008; };
|
||||||
|
AA1100000000000000000009 = {isa = PBXBuildFile; fileRef = AA2200000000000000000009; };
|
||||||
|
AA1100000000000000000010 = {isa = PBXBuildFile; fileRef = AA2200000000000000000010; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
B20000000000000000000050 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests/LegadoReaderUITests/ReaderScrollGestureTests.swift; sourceTree = SOURCE_ROOT; };
|
||||||
|
B20000000000000000000051 = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; path = LegadoReaderUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
B20000000000000000000030 /* ReaderScrollGeometryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests/LegadoReaderTests/ReaderScrollGeometryTests.swift; sourceTree = SOURCE_ROOT; };
|
||||||
|
BC2000000000000000000001 /* WebReaderBridgeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests/LegadoReaderTests/WebReaderBridgeTests.swift; sourceTree = SOURCE_ROOT; };
|
||||||
|
B20000000000000000000031 /* LegadoReaderTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; path = LegadoReaderTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
B20000000000000000000029 /* ReaderScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderScrollView.swift; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000001 /* LegadoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegadoApp.swift; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000002 /* RootTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootTabView.swift; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000003 /* SourceLibraryModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceLibraryModel.swift; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000004 /* SourceManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceManagementView.swift; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000005 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000006 /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000007 /* BookLibraryModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookLibraryModel.swift; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000008 /* BookDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailView.swift; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000009 /* ReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderView.swift; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000028 /* ReaderAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderAppearance.swift; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000029 /* ReaderSpeechController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderSpeechController.swift; sourceTree = "<group>"; };
|
||||||
|
A2000000000000000000002A /* ReaderTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderTextView.swift; sourceTree = "<group>"; };
|
||||||
|
A2000000000000000000002B /* ReaderSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderSupport.swift; sourceTree = "<group>"; };
|
||||||
|
A2000000000000000000002C /* ReaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
A2000000000000000000002D /* ReadingStatisticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingStatisticsView.swift; sourceTree = "<group>"; };
|
||||||
|
CC1500000000000000000002 /* Compatibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Compatibility.swift; sourceTree = "<group>"; };
|
||||||
|
A2000000000000000000000A /* DesignSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignSystem.swift; sourceTree = "<group>"; };
|
||||||
|
A2000000000000000000000B /* DemoContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoContent.swift; sourceTree = "<group>"; };
|
||||||
|
A2000000000000000000000C /* ExploreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreView.swift; sourceTree = "<group>"; };
|
||||||
|
A2000000000000000000000D /* SourceDebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceDebugView.swift; sourceTree = "<group>"; };
|
||||||
|
A2000000000000000000000E /* SourceLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceLoginView.swift; sourceTree = "<group>"; };
|
||||||
|
A2000000000000000000000F /* LocalBookDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalBookDetailView.swift; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000010 /* ReplaceRuleLibraryModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplaceRuleLibraryModel.swift; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000011 /* ReplaceRuleManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplaceRuleManagementView.swift; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000012 /* ReadingActivityModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingActivityModel.swift; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000013 /* ReadingHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingHistoryView.swift; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000014 /* CacheManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheManagementView.swift; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000015 /* SourceSwitchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSwitchView.swift; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000016 /* RSSLibraryModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSLibraryModel.swift; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000017 /* RSSViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSViews.swift; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000018 /* RSSSourceEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSSourceEditorView.swift; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000019 /* RSSWebReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSWebReaderView.swift; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000020 /* RSSSourceDebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSSourceDebugView.swift; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000021 /* RSSAddBookView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSAddBookView.swift; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000022 /* RuleSubscriptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuleSubscriptionView.swift; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000023 /* TxtTocRuleManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TxtTocRuleManagementView.swift; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000024 /* txtTocRule.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = txtTocRule.json; path = App/Resources/txtTocRule.json; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000025 /* httpTTS.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = httpTTS.json; path = App/Resources/httpTTS.json; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000026 /* HttpTTSManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HttpTTSManagementView.swift; sourceTree = "<group>"; };
|
||||||
|
A20000000000000000000027 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
|
C20000000000000000000001 /* BookSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSource.swift; sourceTree = "<group>"; };
|
||||||
|
C20000000000000000000002 /* BookSourceRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSourceRules.swift; sourceTree = "<group>"; };
|
||||||
|
C20000000000000000000003 /* LibraryModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryModels.swift; sourceTree = "<group>"; };
|
||||||
|
C20000000000000000000004 /* BookSourceValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSourceValidator.swift; sourceTree = "<group>"; };
|
||||||
|
C20000000000000000000005 /* BookSourceImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSourceImporter.swift; sourceTree = "<group>"; };
|
||||||
|
C20000000000000000000006 /* BookSourceRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSourceRepository.swift; sourceTree = "<group>"; };
|
||||||
|
C20000000000000000000007 /* SearchBook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBook.swift; sourceTree = "<group>"; };
|
||||||
|
C20000000000000000000008 /* RuleDiagnostic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuleDiagnostic.swift; sourceTree = "<group>"; };
|
||||||
|
C20000000000000000000009 /* JSONValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONValue.swift; sourceTree = "<group>"; };
|
||||||
|
C2000000000000000000000A /* RuleExpression.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuleExpression.swift; sourceTree = "<group>"; };
|
||||||
|
C2000000000000000000000B /* JSONPathEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONPathEvaluator.swift; sourceTree = "<group>"; };
|
||||||
|
C2000000000000000000000C /* RegexEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegexEvaluator.swift; sourceTree = "<group>"; };
|
||||||
|
C2000000000000000000000D /* RuleEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuleEngine.swift; sourceTree = "<group>"; };
|
||||||
|
C2000000000000000000000E /* LegadoURLBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegadoURLBuilder.swift; sourceTree = "<group>"; };
|
||||||
|
C2000000000000000000000F /* HTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPClient.swift; sourceTree = "<group>"; };
|
||||||
|
C20000000000000000000010 /* BookSearchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSearchService.swift; sourceTree = "<group>"; };
|
||||||
|
C20000000000000000000011 /* HTMLRuleEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLRuleEvaluator.swift; sourceTree = "<group>"; };
|
||||||
|
C20000000000000000000012 /* NetworkBookService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkBookService.swift; sourceTree = "<group>"; };
|
||||||
|
C20000000000000000000013 /* BookRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookRepository.swift; sourceTree = "<group>"; };
|
||||||
|
C20000000000000000000014 /* JavaScriptRuleEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JavaScriptRuleEvaluator.swift; sourceTree = "<group>"; };
|
||||||
|
C20000000000000000000015 /* ExploreKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreKind.swift; sourceTree = "<group>"; };
|
||||||
|
C20000000000000000000016 /* BookExploreService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookExploreService.swift; sourceTree = "<group>"; };
|
||||||
|
C20000000000000000000017 /* BookSourceDebugger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSourceDebugger.swift; sourceTree = "<group>"; };
|
||||||
|
C20000000000000000000018 /* LocalBookService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalBookService.swift; sourceTree = "<group>"; };
|
||||||
|
C20000000000000000000019 /* ContentSanitizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentSanitizer.swift; sourceTree = "<group>"; };
|
||||||
|
C2000000000000000000001A /* ReplaceRuleRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplaceRuleRepository.swift; sourceTree = "<group>"; };
|
||||||
|
C2000000000000000000001B /* ReadingActivityRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingActivityRepository.swift; sourceTree = "<group>"; };
|
||||||
|
C2000000000000000000001C /* ChapterContentCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterContentCache.swift; sourceTree = "<group>"; };
|
||||||
|
C2000000000000000000001D /* BookChapterRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookChapterRepository.swift; sourceTree = "<group>"; };
|
||||||
|
C2000000000000000000001E /* BookUpdateCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookUpdateCoordinator.swift; sourceTree = "<group>"; };
|
||||||
|
C2000000000000000000001F /* BookSourceSwitchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSourceSwitchService.swift; sourceTree = "<group>"; };
|
||||||
|
C20000000000000000000020 /* LegadoDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegadoDatabase.swift; sourceTree = "<group>"; };
|
||||||
|
C20000000000000000000021 /* BookSourceHeaderResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSourceHeaderResolver.swift; sourceTree = "<group>"; };
|
||||||
|
C20000000000000000000022 /* DifferentialSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DifferentialSnapshot.swift; sourceTree = "<group>"; };
|
||||||
|
C20000000000000000000023 /* ConfigurationRepositories.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationRepositories.swift; sourceTree = "<group>"; };
|
||||||
|
C20000000000000000000024 /* RSSModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSModels.swift; sourceTree = "<group>"; };
|
||||||
|
C20000000000000000000025 /* RSSRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSRepository.swift; sourceTree = "<group>"; };
|
||||||
|
C20000000000000000000026 /* RSSDefaultParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSDefaultParser.swift; sourceTree = "<group>"; };
|
||||||
|
C20000000000000000000027 /* RSSRuleParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSRuleParser.swift; sourceTree = "<group>"; };
|
||||||
|
C20000000000000000000028 /* RSSService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSService.swift; sourceTree = "<group>"; };
|
||||||
|
C20000000000000000000029 /* RSSSourceImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSSourceImporter.swift; sourceTree = "<group>"; };
|
||||||
|
C2000000000000000000002A /* RSSSortResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSSortResolver.swift; sourceTree = "<group>"; };
|
||||||
|
C2000000000000000000002B /* RSSReadingPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSReadingPolicy.swift; sourceTree = "<group>"; };
|
||||||
|
C2000000000000000000002C /* RSSSourceDebugger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSSourceDebugger.swift; sourceTree = "<group>"; };
|
||||||
|
C2000000000000000000002D /* AddBookURLService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddBookURLService.swift; sourceTree = "<group>"; };
|
||||||
|
C2000000000000000000002E /* HttpTTSService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HttpTTSService.swift; sourceTree = "<group>"; };
|
||||||
|
D20000000000000000000001 /* Legado.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Legado.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
B20000000000000000000027 /* BookshelfReadViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookshelfReadViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
B20000000000000000000028 /* BookDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
E20000000000000000000001 /* ApplicationSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationSettings.swift; sourceTree = "<group>"; };
|
||||||
|
E20000000000000000000002 /* HostsHTTPTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostsHTTPTransport.swift; sourceTree = "<group>"; };
|
||||||
|
E20000000000000000000003 /* DiagnosticLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticLog.swift; sourceTree = "<group>"; };
|
||||||
|
E20000000000000000000004 /* OtherSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OtherSettingsView.swift; sourceTree = "<group>"; };
|
||||||
|
E20000000000000000000005 /* DiagnosticLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticLogView.swift; sourceTree = "<group>"; };
|
||||||
|
E20000000000000000000006 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = "<group>"; };
|
||||||
|
E20000000000000000000007 /* AppDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDiagnostics.swift; sourceTree = "<group>"; };
|
||||||
|
E20000000000000000000008 /* Legado-LICENSE.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = Legado-LICENSE.txt; sourceTree = "<group>"; };
|
||||||
|
E20000000000000000000009 /* SwiftSoup-LICENSE.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = SwiftSoup-LICENSE.txt; sourceTree = "<group>"; };
|
||||||
|
E2000000000000000000000A /* Kanna-LICENSE.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = Kanna-LICENSE.txt; sourceTree = "<group>"; };
|
||||||
|
F21000000000000000000001 /* FileManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManagementView.swift; sourceTree = "<group>"; };
|
||||||
|
F21000000000000000000002 /* LocalFileService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFileService.swift; sourceTree = "<group>"; };
|
||||||
|
F21000000000000000000003 /* BookSourceEditorDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSourceEditorDocument.swift; sourceTree = "<group>"; };
|
||||||
|
F21000000000000000000004 /* RuleImportInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuleImportInput.swift; sourceTree = "<group>"; };
|
||||||
|
AA2200000000000000000001 /* SourceEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/Features/Sources/SourceEditorView.swift; sourceTree = SOURCE_ROOT; };
|
||||||
|
AA2200000000000000000002 /* ReplaceRuleEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/Features/Rules/ReplaceRuleEditorView.swift; sourceTree = SOURCE_ROOT; };
|
||||||
|
AA2200000000000000000003 /* SyntaxCodeEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/Features/Rules/Editor/SyntaxCodeEditor.swift; sourceTree = SOURCE_ROOT; };
|
||||||
|
AA2200000000000000000004 /* QRCodeImportView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/Features/Rules/Editor/QRCodeImportView.swift; sourceTree = SOURCE_ROOT; };
|
||||||
|
AA2200000000000000000007 /* ReplaceRuleTester.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sources/LegadoCore/Book/ReplaceRuleTester.swift; sourceTree = SOURCE_ROOT; };
|
||||||
|
AA2200000000000000000008 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sources/LegadoCore/Import/QRCodeDecoder.swift; sourceTree = SOURCE_ROOT; };
|
||||||
|
AA2200000000000000000009 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests/LegadoReaderUITests/RuleEditorUITests.swift; sourceTree = SOURCE_ROOT; };
|
||||||
|
AA2200000000000000000010 = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tests/LegadoReaderUITests/LiveReadingFlowUITests.swift; sourceTree = SOURCE_ROOT; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
B30000000000000000000051 = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; };
|
||||||
|
B30000000000000000000031 = {isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = (); runOnlyForDeploymentPostprocessing = 0; };
|
||||||
|
D30000000000000000000001 /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
F10000000000000000000001 /* SwiftSoup in Frameworks */,
|
||||||
|
F10000000000000000000002 /* Kanna in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
D40000000000000000000001 = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
AA2200000000000000000001 /* SourceEditorView.swift */,
|
||||||
|
AA2200000000000000000002 /* ReplaceRuleEditorView.swift */,
|
||||||
|
AA2200000000000000000003 /* SyntaxCodeEditor.swift */,
|
||||||
|
AA2200000000000000000004 /* QRCodeImportView.swift */,
|
||||||
|
AA2200000000000000000007 /* ReplaceRuleTester.swift */,
|
||||||
|
AA2200000000000000000008,
|
||||||
|
B20000000000000000000050 /* ReaderScrollGestureTests.swift */,
|
||||||
|
AA2200000000000000000009,
|
||||||
|
AA2200000000000000000010,
|
||||||
|
B20000000000000000000030 /* ReaderScrollGeometryTests.swift */,
|
||||||
|
BC2000000000000000000001 /* WebReaderBridgeTests.swift */,
|
||||||
|
A30000000000000000000001 /* App */,
|
||||||
|
C30000000000000000000001 /* LegadoCore */,
|
||||||
|
A20000000000000000000024 /* txtTocRule.json */,
|
||||||
|
A20000000000000000000025 /* httpTTS.json */,
|
||||||
|
D40000000000000000000002 /* Products */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
A30000000000000000000001 /* App */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
E30000000000000000000002 /* Licenses */,
|
||||||
|
E30000000000000000000001 /* Settings Feature */,
|
||||||
|
A20000000000000000000027 /* Assets.xcassets */,
|
||||||
|
A20000000000000000000001 /* LegadoApp.swift */,
|
||||||
|
A20000000000000000000002 /* RootTabView.swift */,
|
||||||
|
CC1500000000000000000002 /* Compatibility.swift */,
|
||||||
|
A2000000000000000000000A /* DesignSystem.swift */,
|
||||||
|
A2000000000000000000000B /* DemoContent.swift */,
|
||||||
|
A30000000000000000000002 /* Sources Feature */,
|
||||||
|
A30000000000000000000003 /* Search Feature */,
|
||||||
|
A30000000000000000000004 /* Books Feature */,
|
||||||
|
A30000000000000000000005 /* Reader Feature */,
|
||||||
|
A30000000000000000000006 /* Explore Feature */,
|
||||||
|
A30000000000000000000007 /* Rules Feature */,
|
||||||
|
A30000000000000000000008 /* Activity Feature */,
|
||||||
|
A30000000000000000000009 /* RSS Feature */,
|
||||||
|
A3000000000000000000000A /* TTS Feature */,
|
||||||
|
A20000000000000000000005 /* Info.plist */,
|
||||||
|
);
|
||||||
|
path = App;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
A30000000000000000000002 /* Sources Feature */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A20000000000000000000003 /* SourceLibraryModel.swift */,
|
||||||
|
A20000000000000000000004 /* SourceManagementView.swift */,
|
||||||
|
A2000000000000000000000D /* SourceDebugView.swift */,
|
||||||
|
A2000000000000000000000E /* SourceLoginView.swift */,
|
||||||
|
);
|
||||||
|
path = Features/Sources;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
A30000000000000000000003 /* Search Feature */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A20000000000000000000006 /* SearchViewModel.swift */,
|
||||||
|
);
|
||||||
|
path = Features/Search;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
A30000000000000000000004 /* Books Feature */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A20000000000000000000007 /* BookLibraryModel.swift */,
|
||||||
|
B20000000000000000000028 /* BookDetailViewModel.swift */,
|
||||||
|
B20000000000000000000027 /* BookshelfReadViewModel.swift */,
|
||||||
|
A20000000000000000000008 /* BookDetailView.swift */,
|
||||||
|
A20000000000000000000015 /* SourceSwitchView.swift */,
|
||||||
|
A2000000000000000000000F /* LocalBookDetailView.swift */,
|
||||||
|
);
|
||||||
|
path = Features/Books;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
A30000000000000000000005 /* Reader Feature */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A20000000000000000000009 /* ReaderView.swift */,
|
||||||
|
A2000000000000000000002C /* ReaderViewModel.swift */,
|
||||||
|
A2000000000000000000002A /* ReaderTextView.swift */,
|
||||||
|
B20000000000000000000029 /* ReaderScrollView.swift */,
|
||||||
|
A20000000000000000000029 /* ReaderSpeechController.swift */,
|
||||||
|
A20000000000000000000028 /* ReaderAppearance.swift */,
|
||||||
|
A2000000000000000000002B /* ReaderSupport.swift */,
|
||||||
|
);
|
||||||
|
path = Features/Reader;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
A30000000000000000000006 /* Explore Feature */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A2000000000000000000000C /* ExploreView.swift */,
|
||||||
|
);
|
||||||
|
path = Features/Explore;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
A30000000000000000000007 /* Rules Feature */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A20000000000000000000010 /* ReplaceRuleLibraryModel.swift */,
|
||||||
|
A20000000000000000000011 /* ReplaceRuleManagementView.swift */,
|
||||||
|
A20000000000000000000022 /* RuleSubscriptionView.swift */,
|
||||||
|
A20000000000000000000023 /* TxtTocRuleManagementView.swift */,
|
||||||
|
);
|
||||||
|
path = Features/Rules;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
A30000000000000000000008 /* Activity Feature */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
F21000000000000000000001 /* FileManagementView.swift */,
|
||||||
|
A20000000000000000000012 /* ReadingActivityModel.swift */,
|
||||||
|
A20000000000000000000013 /* ReadingHistoryView.swift */,
|
||||||
|
A20000000000000000000014 /* CacheManagementView.swift */,
|
||||||
|
A2000000000000000000002D /* ReadingStatisticsView.swift */,
|
||||||
|
);
|
||||||
|
path = Features/Activity;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
A3000000000000000000000A /* TTS Feature */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A20000000000000000000026 /* HttpTTSManagementView.swift */,
|
||||||
|
);
|
||||||
|
path = Features/TTS;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
C30000000000000000000001 /* LegadoCore */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
C30000000000000000000002 /* Domain */,
|
||||||
|
C30000000000000000000003 /* Import */,
|
||||||
|
C30000000000000000000004 /* Persistence */,
|
||||||
|
C30000000000000000000005 /* RuleEngine */,
|
||||||
|
C30000000000000000000006 /* Networking */,
|
||||||
|
C30000000000000000000007 /* Search */,
|
||||||
|
C30000000000000000000008 /* Book */,
|
||||||
|
C30000000000000000000009 /* Explore */,
|
||||||
|
C3000000000000000000000A /* Debugging */,
|
||||||
|
C3000000000000000000000B /* LocalBook */,
|
||||||
|
C3000000000000000000000C /* RSS */,
|
||||||
|
C3000000000000000000000D /* TTS */,
|
||||||
|
);
|
||||||
|
path = Sources/LegadoCore;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
C30000000000000000000002 /* Domain */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
F21000000000000000000003 /* BookSourceEditorDocument.swift */,
|
||||||
|
C20000000000000000000001 /* BookSource.swift */,
|
||||||
|
C20000000000000000000002 /* BookSourceRules.swift */,
|
||||||
|
C20000000000000000000003 /* LibraryModels.swift */,
|
||||||
|
C20000000000000000000007 /* SearchBook.swift */,
|
||||||
|
C20000000000000000000015 /* ExploreKind.swift */,
|
||||||
|
C20000000000000000000024 /* RSSModels.swift */,
|
||||||
|
);
|
||||||
|
path = Domain;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
C30000000000000000000003 /* Import */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
F21000000000000000000004 /* RuleImportInput.swift */,
|
||||||
|
C20000000000000000000004 /* BookSourceValidator.swift */,
|
||||||
|
C20000000000000000000005 /* BookSourceImporter.swift */,
|
||||||
|
C20000000000000000000029 /* RSSSourceImporter.swift */,
|
||||||
|
);
|
||||||
|
path = Import;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
C30000000000000000000004 /* Persistence */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
C20000000000000000000006 /* BookSourceRepository.swift */,
|
||||||
|
C20000000000000000000013 /* BookRepository.swift */,
|
||||||
|
C2000000000000000000001A /* ReplaceRuleRepository.swift */,
|
||||||
|
C2000000000000000000001B /* ReadingActivityRepository.swift */,
|
||||||
|
C2000000000000000000001D /* BookChapterRepository.swift */,
|
||||||
|
C20000000000000000000020 /* LegadoDatabase.swift */,
|
||||||
|
C20000000000000000000023 /* ConfigurationRepositories.swift */,
|
||||||
|
C20000000000000000000025 /* RSSRepository.swift */,
|
||||||
|
);
|
||||||
|
path = Persistence;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
C30000000000000000000005 /* RuleEngine */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
C20000000000000000000008 /* RuleDiagnostic.swift */,
|
||||||
|
C20000000000000000000009 /* JSONValue.swift */,
|
||||||
|
C2000000000000000000000A /* RuleExpression.swift */,
|
||||||
|
C2000000000000000000000B /* JSONPathEvaluator.swift */,
|
||||||
|
C2000000000000000000000C /* RegexEvaluator.swift */,
|
||||||
|
C2000000000000000000000D /* RuleEngine.swift */,
|
||||||
|
C20000000000000000000011 /* HTMLRuleEvaluator.swift */,
|
||||||
|
C20000000000000000000014 /* JavaScriptRuleEvaluator.swift */,
|
||||||
|
);
|
||||||
|
path = RuleEngine;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
C30000000000000000000006 /* Networking */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
E20000000000000000000001 /* ApplicationSettings.swift */,
|
||||||
|
E20000000000000000000002 /* HostsHTTPTransport.swift */,
|
||||||
|
C2000000000000000000000E /* LegadoURLBuilder.swift */,
|
||||||
|
C2000000000000000000000F /* HTTPClient.swift */,
|
||||||
|
C20000000000000000000021 /* BookSourceHeaderResolver.swift */,
|
||||||
|
);
|
||||||
|
path = Networking;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
C30000000000000000000007 /* Search */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
C20000000000000000000010 /* BookSearchService.swift */,
|
||||||
|
);
|
||||||
|
path = Search;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
C30000000000000000000008 /* Book */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
C20000000000000000000012 /* NetworkBookService.swift */,
|
||||||
|
C20000000000000000000019 /* ContentSanitizer.swift */,
|
||||||
|
C2000000000000000000001C /* ChapterContentCache.swift */,
|
||||||
|
C2000000000000000000001E /* BookUpdateCoordinator.swift */,
|
||||||
|
C2000000000000000000001F /* BookSourceSwitchService.swift */,
|
||||||
|
C2000000000000000000002D /* AddBookURLService.swift */,
|
||||||
|
);
|
||||||
|
path = Book;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
C30000000000000000000009 /* Explore */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
C20000000000000000000016 /* BookExploreService.swift */,
|
||||||
|
);
|
||||||
|
path = Explore;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
C3000000000000000000000A /* Debugging */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
E20000000000000000000003 /* DiagnosticLog.swift */,
|
||||||
|
C20000000000000000000017 /* BookSourceDebugger.swift */,
|
||||||
|
C2000000000000000000002C /* RSSSourceDebugger.swift */,
|
||||||
|
C20000000000000000000022 /* DifferentialSnapshot.swift */,
|
||||||
|
);
|
||||||
|
path = Debugging;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
C3000000000000000000000B /* LocalBook */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
F21000000000000000000002 /* LocalFileService.swift */,
|
||||||
|
C20000000000000000000018 /* LocalBookService.swift */,
|
||||||
|
);
|
||||||
|
path = LocalBook;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
C3000000000000000000000D /* TTS */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
C2000000000000000000002E /* HttpTTSService.swift */,
|
||||||
|
);
|
||||||
|
path = TTS;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
A30000000000000000000009 /* RSS Feature */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
A20000000000000000000016 /* RSSLibraryModel.swift */,
|
||||||
|
A20000000000000000000017 /* RSSViews.swift */,
|
||||||
|
A20000000000000000000018 /* RSSSourceEditorView.swift */,
|
||||||
|
A20000000000000000000019 /* RSSWebReaderView.swift */,
|
||||||
|
A20000000000000000000020 /* RSSSourceDebugView.swift */,
|
||||||
|
A20000000000000000000021 /* RSSAddBookView.swift */,
|
||||||
|
);
|
||||||
|
path = Features/RSS;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
C3000000000000000000000C /* RSS */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
C20000000000000000000026 /* RSSDefaultParser.swift */,
|
||||||
|
C20000000000000000000027 /* RSSRuleParser.swift */,
|
||||||
|
C20000000000000000000028 /* RSSService.swift */,
|
||||||
|
C2000000000000000000002A /* RSSSortResolver.swift */,
|
||||||
|
C2000000000000000000002B /* RSSReadingPolicy.swift */,
|
||||||
|
);
|
||||||
|
path = RSS;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
D40000000000000000000002 /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
D20000000000000000000001 /* Legado.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
E30000000000000000000001 /* Settings Feature */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
E20000000000000000000004 /* OtherSettingsView.swift */,
|
||||||
|
E20000000000000000000005 /* DiagnosticLogView.swift */,
|
||||||
|
E20000000000000000000006 /* AboutView.swift */,
|
||||||
|
E20000000000000000000007 /* AppDiagnostics.swift */,
|
||||||
|
);
|
||||||
|
path = Features/Settings;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
E30000000000000000000002 /* Licenses */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
E20000000000000000000008 /* Legado-LICENSE.txt */,
|
||||||
|
E20000000000000000000009 /* SwiftSoup-LICENSE.txt */,
|
||||||
|
E2000000000000000000000A /* Kanna-LICENSE.txt */,
|
||||||
|
);
|
||||||
|
path = Resources;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
B50000000000000000000050 = {isa = PBXNativeTarget; buildConfigurationList = B60000000000000000000050; buildPhases = (B30000000000000000000050, B30000000000000000000051); buildRules = (); dependencies = (B80000000000000000000050); name = LegadoReaderUITests; productName = LegadoReaderUITests; productReference = B20000000000000000000051; productType = "com.apple.product-type.bundle.ui-testing"; };
|
||||||
|
B50000000000000000000030 /* LegadoReaderTests */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = B60000000000000000000030;
|
||||||
|
buildPhases = (B30000000000000000000030, B30000000000000000000031);
|
||||||
|
buildRules = ();
|
||||||
|
dependencies = (B80000000000000000000030);
|
||||||
|
name = LegadoReaderTests;
|
||||||
|
productName = LegadoReaderTests;
|
||||||
|
productReference = B20000000000000000000031;
|
||||||
|
productType = "com.apple.product-type.bundle.unit-test";
|
||||||
|
};
|
||||||
|
D50000000000000000000001 /* Legado */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = D60000000000000000000002 /* Build configuration list for PBXNativeTarget "Legado" */;
|
||||||
|
buildPhases = (
|
||||||
|
D30000000000000000000002 /* Sources */,
|
||||||
|
D30000000000000000000001 /* Frameworks */,
|
||||||
|
D30000000000000000000003 /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = ();
|
||||||
|
dependencies = ();
|
||||||
|
name = Legado;
|
||||||
|
packageProductDependencies = (
|
||||||
|
F20000000000000000000001 /* SwiftSoup */,
|
||||||
|
F20000000000000000000002 /* Kanna */,
|
||||||
|
);
|
||||||
|
productName = Legado;
|
||||||
|
productReference = D20000000000000000000001 /* Legado.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
B90000000000000000000050 = {isa = PBXContainerItemProxy; containerPortal = D70000000000000000000001; proxyType = 1; remoteGlobalIDString = D50000000000000000000001; remoteInfo = Legado; };
|
||||||
|
B90000000000000000000030 = {isa = PBXContainerItemProxy; containerPortal = D70000000000000000000001; proxyType = 1; remoteGlobalIDString = D50000000000000000000001; remoteInfo = Legado; };
|
||||||
|
/* End PBXContainerItemProxy section */
|
||||||
|
/* Begin PBXTargetDependency section */
|
||||||
|
B80000000000000000000050 = {isa = PBXTargetDependency; target = D50000000000000000000001; targetProxy = B90000000000000000000050; };
|
||||||
|
B80000000000000000000030 = {isa = PBXTargetDependency; target = D50000000000000000000001; targetProxy = B90000000000000000000030; };
|
||||||
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
D70000000000000000000001 /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = 1;
|
||||||
|
LastSwiftUpdateCheck = 2610;
|
||||||
|
LastUpgradeCheck = 2610;
|
||||||
|
TargetAttributes = {
|
||||||
|
B50000000000000000000050 = {CreatedOnToolsVersion = 26.1; TestTargetID = D50000000000000000000001; };
|
||||||
|
D50000000000000000000001 = { CreatedOnToolsVersion = 26.1; };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
buildConfigurationList = D60000000000000000000001 /* Build configuration list for PBXProject "Legado" */;
|
||||||
|
compatibilityVersion = "Xcode 14.0";
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (en, Base);
|
||||||
|
mainGroup = D40000000000000000000001;
|
||||||
|
packageReferences = (
|
||||||
|
F30000000000000000000001 /* XCRemoteSwiftPackageReference "SwiftSoup" */,
|
||||||
|
F30000000000000000000002 /* XCRemoteSwiftPackageReference "Kanna" */,
|
||||||
|
);
|
||||||
|
productRefGroup = D40000000000000000000002 /* Products */;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (D50000000000000000000001 /* Legado */, B50000000000000000000030 /* LegadoReaderTests */, B50000000000000000000050 /* LegadoReaderUITests */);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
D30000000000000000000003 /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
91000000000000000000000A /* Kanna-LICENSE.txt in Resources */,
|
||||||
|
910000000000000000000009 /* SwiftSoup-LICENSE.txt in Resources */,
|
||||||
|
910000000000000000000008 /* Legado-LICENSE.txt in Resources */,
|
||||||
|
A10000000000000000000020 /* Assets.xcassets in Resources */,
|
||||||
|
A1000000000000000000001D /* txtTocRule.json in Resources */,
|
||||||
|
A1000000000000000000001E /* httpTTS.json in Resources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
B30000000000000000000050 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (B10000000000000000000050, AA1100000000000000000009, AA1100000000000000000010); runOnlyForDeploymentPostprocessing = 0; };
|
||||||
|
B30000000000000000000030 = {isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = (B10000000000000000000030, BC1000000000000000000001); runOnlyForDeploymentPostprocessing = 0; };
|
||||||
|
D30000000000000000000002 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
AA1100000000000000000001 /* SourceEditorView.swift in Sources */,
|
||||||
|
AA1100000000000000000002 /* ReplaceRuleEditorView.swift in Sources */,
|
||||||
|
AA1100000000000000000003 /* SyntaxCodeEditor.swift in Sources */,
|
||||||
|
AA1100000000000000000004 /* QRCodeImportView.swift in Sources */,
|
||||||
|
AA1100000000000000000007 /* ReplaceRuleTester.swift in Sources */,
|
||||||
|
AA1100000000000000000008,
|
||||||
|
910000000000000000000007 /* AppDiagnostics.swift in Sources */,
|
||||||
|
910000000000000000000006 /* AboutView.swift in Sources */,
|
||||||
|
910000000000000000000005 /* DiagnosticLogView.swift in Sources */,
|
||||||
|
910000000000000000000004 /* OtherSettingsView.swift in Sources */,
|
||||||
|
910000000000000000000003 /* DiagnosticLog.swift in Sources */,
|
||||||
|
910000000000000000000002 /* HostsHTTPTransport.swift in Sources */,
|
||||||
|
910000000000000000000001 /* ApplicationSettings.swift in Sources */,
|
||||||
|
A10000000000000000000001 /* LegadoApp.swift in Sources */,
|
||||||
|
A10000000000000000000002 /* RootTabView.swift in Sources */,
|
||||||
|
A10000000000000000000003 /* SourceLibraryModel.swift in Sources */,
|
||||||
|
A10000000000000000000004 /* SourceManagementView.swift in Sources */,
|
||||||
|
A10000000000000000000005 /* SearchViewModel.swift in Sources */,
|
||||||
|
A10000000000000000000006 /* BookLibraryModel.swift in Sources */,
|
||||||
|
B10000000000000000000028 /* BookDetailViewModel.swift in Sources */,
|
||||||
|
B10000000000000000000027 /* BookshelfReadViewModel.swift in Sources */,
|
||||||
|
A10000000000000000000007 /* BookDetailView.swift in Sources */,
|
||||||
|
A10000000000000000000008 /* ReaderView.swift in Sources */,
|
||||||
|
A10000000000000000000021 /* ReaderAppearance.swift in Sources */,
|
||||||
|
A10000000000000000000022 /* ReaderSpeechController.swift in Sources */,
|
||||||
|
A10000000000000000000023 /* ReaderTextView.swift in Sources */,
|
||||||
|
B10000000000000000000029 /* ReaderScrollView.swift in Sources */,
|
||||||
|
A10000000000000000000024 /* ReaderSupport.swift in Sources */,
|
||||||
|
A10000000000000000000025 /* ReaderViewModel.swift in Sources */,
|
||||||
|
A10000000000000000000026 /* ReadingStatisticsView.swift in Sources */,
|
||||||
|
CC1500000000000000000001 /* Compatibility.swift in Sources */,
|
||||||
|
A10000000000000000000009 /* DesignSystem.swift in Sources */,
|
||||||
|
A1000000000000000000000A /* DemoContent.swift in Sources */,
|
||||||
|
A1000000000000000000000B /* ExploreView.swift in Sources */,
|
||||||
|
A1000000000000000000000C /* SourceDebugView.swift in Sources */,
|
||||||
|
A1000000000000000000000D /* SourceLoginView.swift in Sources */,
|
||||||
|
A1000000000000000000000E /* LocalBookDetailView.swift in Sources */,
|
||||||
|
A1000000000000000000000F /* ReplaceRuleLibraryModel.swift in Sources */,
|
||||||
|
A10000000000000000000010 /* ReplaceRuleManagementView.swift in Sources */,
|
||||||
|
A10000000000000000000011 /* ReadingActivityModel.swift in Sources */,
|
||||||
|
A10000000000000000000012 /* ReadingHistoryView.swift in Sources */,
|
||||||
|
F11000000000000000000001 /* FileManagementView.swift in Sources */,
|
||||||
|
F11000000000000000000002 /* LocalFileService.swift in Sources */,
|
||||||
|
F11000000000000000000003 /* BookSourceEditorDocument.swift in Sources */,
|
||||||
|
F11000000000000000000004 /* RuleImportInput.swift in Sources */,
|
||||||
|
A10000000000000000000013 /* CacheManagementView.swift in Sources */,
|
||||||
|
A10000000000000000000014 /* SourceSwitchView.swift in Sources */,
|
||||||
|
A10000000000000000000015 /* RSSLibraryModel.swift in Sources */,
|
||||||
|
A10000000000000000000016 /* RSSViews.swift in Sources */,
|
||||||
|
A10000000000000000000017 /* RSSSourceEditorView.swift in Sources */,
|
||||||
|
A10000000000000000000018 /* RSSWebReaderView.swift in Sources */,
|
||||||
|
A10000000000000000000019 /* RSSSourceDebugView.swift in Sources */,
|
||||||
|
A1000000000000000000001A /* RSSAddBookView.swift in Sources */,
|
||||||
|
A1000000000000000000001B /* RuleSubscriptionView.swift in Sources */,
|
||||||
|
A1000000000000000000001C /* TxtTocRuleManagementView.swift in Sources */,
|
||||||
|
A1000000000000000000001F /* HttpTTSManagementView.swift in Sources */,
|
||||||
|
C10000000000000000000001 /* BookSource.swift in Sources */,
|
||||||
|
C10000000000000000000002 /* BookSourceRules.swift in Sources */,
|
||||||
|
C10000000000000000000003 /* LibraryModels.swift in Sources */,
|
||||||
|
C10000000000000000000004 /* BookSourceValidator.swift in Sources */,
|
||||||
|
C10000000000000000000005 /* BookSourceImporter.swift in Sources */,
|
||||||
|
C10000000000000000000006 /* BookSourceRepository.swift in Sources */,
|
||||||
|
C10000000000000000000007 /* SearchBook.swift in Sources */,
|
||||||
|
C10000000000000000000008 /* RuleDiagnostic.swift in Sources */,
|
||||||
|
C10000000000000000000009 /* JSONValue.swift in Sources */,
|
||||||
|
C1000000000000000000000A /* RuleExpression.swift in Sources */,
|
||||||
|
C1000000000000000000000B /* JSONPathEvaluator.swift in Sources */,
|
||||||
|
C1000000000000000000000C /* RegexEvaluator.swift in Sources */,
|
||||||
|
C1000000000000000000000D /* RuleEngine.swift in Sources */,
|
||||||
|
C1000000000000000000000E /* LegadoURLBuilder.swift in Sources */,
|
||||||
|
C1000000000000000000000F /* HTTPClient.swift in Sources */,
|
||||||
|
C10000000000000000000010 /* BookSearchService.swift in Sources */,
|
||||||
|
C10000000000000000000011 /* HTMLRuleEvaluator.swift in Sources */,
|
||||||
|
C10000000000000000000012 /* NetworkBookService.swift in Sources */,
|
||||||
|
C10000000000000000000013 /* BookRepository.swift in Sources */,
|
||||||
|
C10000000000000000000014 /* JavaScriptRuleEvaluator.swift in Sources */,
|
||||||
|
C10000000000000000000015 /* ExploreKind.swift in Sources */,
|
||||||
|
C10000000000000000000016 /* BookExploreService.swift in Sources */,
|
||||||
|
C10000000000000000000017 /* BookSourceDebugger.swift in Sources */,
|
||||||
|
C10000000000000000000018 /* LocalBookService.swift in Sources */,
|
||||||
|
C10000000000000000000019 /* ContentSanitizer.swift in Sources */,
|
||||||
|
C1000000000000000000001A /* ReplaceRuleRepository.swift in Sources */,
|
||||||
|
C1000000000000000000001B /* ReadingActivityRepository.swift in Sources */,
|
||||||
|
C1000000000000000000001C /* ChapterContentCache.swift in Sources */,
|
||||||
|
C1000000000000000000001D /* BookChapterRepository.swift in Sources */,
|
||||||
|
C1000000000000000000001E /* BookUpdateCoordinator.swift in Sources */,
|
||||||
|
C1000000000000000000001F /* BookSourceSwitchService.swift in Sources */,
|
||||||
|
C1000000000000000000002D /* AddBookURLService.swift in Sources */,
|
||||||
|
C10000000000000000000020 /* LegadoDatabase.swift in Sources */,
|
||||||
|
C10000000000000000000021 /* BookSourceHeaderResolver.swift in Sources */,
|
||||||
|
C10000000000000000000022 /* DifferentialSnapshot.swift in Sources */,
|
||||||
|
C10000000000000000000023 /* ConfigurationRepositories.swift in Sources */,
|
||||||
|
C10000000000000000000024 /* RSSModels.swift in Sources */,
|
||||||
|
C10000000000000000000025 /* RSSRepository.swift in Sources */,
|
||||||
|
C10000000000000000000026 /* RSSDefaultParser.swift in Sources */,
|
||||||
|
C10000000000000000000027 /* RSSRuleParser.swift in Sources */,
|
||||||
|
C10000000000000000000028 /* RSSService.swift in Sources */,
|
||||||
|
C10000000000000000000029 /* RSSSourceImporter.swift in Sources */,
|
||||||
|
C1000000000000000000002A /* RSSSortResolver.swift in Sources */,
|
||||||
|
C1000000000000000000002B /* RSSReadingPolicy.swift in Sources */,
|
||||||
|
C1000000000000000000002C /* RSSSourceDebugger.swift in Sources */,
|
||||||
|
C1000000000000000000002E /* HttpTTSService.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
B10000000000000000000052 = {isa = XCBuildConfiguration; buildSettings = {CODE_SIGN_STYLE = Automatic; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; PRODUCT_BUNDLE_IDENTIFIER = com.epiboly.legado.reader-ui-tests; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = Legado; }; name = Debug; };
|
||||||
|
B10000000000000000000053 = {isa = XCBuildConfiguration; buildSettings = {CODE_SIGN_STYLE = Automatic; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 15.0; PRODUCT_BUNDLE_IDENTIFIER = com.epiboly.legado.reader-ui-tests; PRODUCT_NAME = "$(TARGET_NAME)"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; TARGETED_DEVICE_FAMILY = "1,2"; TEST_TARGET_NAME = Legado; }; name = Release; };
|
||||||
|
B10000000000000000000032 = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.epiboly.legado.reader-tests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Legado.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Legado";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
B10000000000000000000033 = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.epiboly.legado.reader-tests;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Legado.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Legado";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
E10000000000000000000001 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
SWIFT_VERSION = 6.0;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
E10000000000000000000002 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
SWIFT_VERSION = 6.0;
|
||||||
|
VALIDATE_PRODUCT = YES;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
E10000000000000000000003 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 12;
|
||||||
|
DEVELOPMENT_ASSET_PATHS = "";
|
||||||
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
MARKETING_VERSION = 0.1.9;
|
||||||
|
OTHER_LDFLAGS = "$(inherited) -lsqlite3";
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.epiboly.legado;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
E10000000000000000000004 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 12;
|
||||||
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
|
||||||
|
MARKETING_VERSION = 0.1.9;
|
||||||
|
OTHER_LDFLAGS = "$(inherited) -lsqlite3";
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.epiboly.legado;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SUPPORTS_MACCATALYST = NO;
|
||||||
|
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||||
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
B60000000000000000000050 = {isa = XCConfigurationList; buildConfigurations = (B10000000000000000000052, B10000000000000000000053); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; };
|
||||||
|
B60000000000000000000030 = {isa = XCConfigurationList; buildConfigurations = (B10000000000000000000032, B10000000000000000000033); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; };
|
||||||
|
D60000000000000000000001 /* Build configuration list for PBXProject "Legado" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (E10000000000000000000001 /* Debug */, E10000000000000000000002 /* Release */);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
D60000000000000000000002 /* Build configuration list for PBXNativeTarget "Legado" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (E10000000000000000000003 /* Debug */, E10000000000000000000004 /* Release */);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
|
/* Begin XCRemoteSwiftPackageReference section */
|
||||||
|
F30000000000000000000001 /* XCRemoteSwiftPackageReference "SwiftSoup" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://github.com/scinfu/SwiftSoup.git";
|
||||||
|
requirement = { kind = exactVersion; version = 2.13.5; };
|
||||||
|
};
|
||||||
|
F30000000000000000000002 /* XCRemoteSwiftPackageReference "Kanna" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://github.com/tid-kijyun/Kanna.git";
|
||||||
|
requirement = { kind = exactVersion; version = 6.1.0; };
|
||||||
|
};
|
||||||
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|
||||||
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
|
F20000000000000000000001 /* SwiftSoup */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = F30000000000000000000001 /* XCRemoteSwiftPackageReference "SwiftSoup" */;
|
||||||
|
productName = SwiftSoup;
|
||||||
|
};
|
||||||
|
F20000000000000000000002 /* Kanna */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = F30000000000000000000002 /* XCRemoteSwiftPackageReference "Kanna" */;
|
||||||
|
productName = Kanna;
|
||||||
|
};
|
||||||
|
/* End XCSwiftPackageProductDependency section */
|
||||||
|
};
|
||||||
|
rootObject = D70000000000000000000001 /* Project object */;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"originHash" : "5cf2eb5dea5b10523b3afc86a164d9a3315a7269a1aefdfe8c91f0530c3f3522",
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "kanna",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/tid-kijyun/Kanna.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "3c73af6d3859d9240db60aef233941a715387744",
|
||||||
|
"version" : "6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swiftsoup",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/scinfu/SwiftSoup.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "49dcadd93161f4a44b4994d3a3e8de9f085aface",
|
||||||
|
"version" : "2.13.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 3
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme LastUpgradeVersion="2610" version="1.7">
|
||||||
|
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES">
|
||||||
|
<BuildActionEntries>
|
||||||
|
<BuildActionEntry buildForTesting="YES" buildForRunning="YES" buildForProfiling="YES" buildForArchiving="YES" buildForAnalyzing="YES">
|
||||||
|
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="D50000000000000000000001" BuildableName="Legado.app" BlueprintName="Legado" ReferencedContainer="container:Legado.xcodeproj"/>
|
||||||
|
</BuildActionEntry>
|
||||||
|
</BuildActionEntries>
|
||||||
|
</BuildAction>
|
||||||
|
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="YES"/>
|
||||||
|
<LaunchAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" launchStyle="0" useCustomWorkingDirectory="NO" ignoresPersistentStateOnLaunch="NO" debugDocumentVersioning="YES" debugServiceExtension="internal" allowLocationSimulation="YES">
|
||||||
|
<BuildableProductRunnable runnableDebuggingMode="0">
|
||||||
|
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="D50000000000000000000001" BuildableName="Legado.app" BlueprintName="Legado" ReferencedContainer="container:Legado.xcodeproj"/>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</LaunchAction>
|
||||||
|
<ProfileAction buildConfiguration="Release" shouldUseLaunchSchemeArgsEnv="YES" savedToolIdentifier="" useCustomWorkingDirectory="NO" debugDocumentVersioning="YES">
|
||||||
|
<BuildableProductRunnable runnableDebuggingMode="0">
|
||||||
|
<BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="D50000000000000000000001" BuildableName="Legado.app" BlueprintName="Legado" ReferencedContainer="container:Legado.xcodeproj"/>
|
||||||
|
</BuildableProductRunnable>
|
||||||
|
</ProfileAction>
|
||||||
|
<AnalyzeAction buildConfiguration="Debug"/>
|
||||||
|
<ArchiveAction buildConfiguration="Release" revealArchiveInOrganizer="YES"/>
|
||||||
|
</Scheme>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme LastUpgradeVersion="2610" version="1.7">
|
||||||
|
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES"><BuildActionEntries><BuildActionEntry buildForTesting="YES" buildForRunning="NO" buildForProfiling="NO" buildForArchiving="NO" buildForAnalyzing="YES"><BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="B50000000000000000000050" BuildableName="LegadoReaderUITests.xctest" BlueprintName="LegadoReaderUITests" ReferencedContainer="container:Legado.xcodeproj"/></BuildActionEntry></BuildActionEntries></BuildAction>
|
||||||
|
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="NO">
|
||||||
|
<Testables><TestableReference skipped="NO"><BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="B50000000000000000000050" BuildableName="LegadoReaderUITests.xctest" BlueprintName="LegadoReaderUITests" ReferencedContainer="container:Legado.xcodeproj"/><SelectedTests><Test Identifier="LiveReadingFlowUITests"/></SelectedTests></TestableReference></Testables>
|
||||||
|
<EnvironmentVariables><EnvironmentVariable key="LEGADO_LIVE_UI_TESTS" value="1" isEnabled="YES"/></EnvironmentVariables>
|
||||||
|
</TestAction>
|
||||||
|
</Scheme>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme LastUpgradeVersion="2610" version="1.7">
|
||||||
|
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES"><BuildActionEntries><BuildActionEntry buildForTesting="YES" buildForRunning="NO" buildForProfiling="NO" buildForArchiving="NO" buildForAnalyzing="YES"><BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="B50000000000000000000030" BuildableName="LegadoReaderTests.xctest" BlueprintName="LegadoReaderTests" ReferencedContainer="container:Legado.xcodeproj"/></BuildActionEntry></BuildActionEntries></BuildAction>
|
||||||
|
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="NO">
|
||||||
|
<Testables><TestableReference skipped="NO"><BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="B50000000000000000000030" BuildableName="LegadoReaderTests.xctest" BlueprintName="LegadoReaderTests" ReferencedContainer="container:Legado.xcodeproj"/></TestableReference></Testables>
|
||||||
|
<CommandLineArguments><CommandLineArgument argument="-uiDemo" isEnabled="YES"/></CommandLineArguments>
|
||||||
|
</TestAction>
|
||||||
|
</Scheme>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme LastUpgradeVersion="2610" version="1.7">
|
||||||
|
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES"><BuildActionEntries><BuildActionEntry buildForTesting="YES" buildForRunning="NO" buildForProfiling="NO" buildForArchiving="NO" buildForAnalyzing="YES"><BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="B50000000000000000000050" BuildableName="LegadoReaderUITests.xctest" BlueprintName="LegadoReaderUITests" ReferencedContainer="container:Legado.xcodeproj"/></BuildActionEntry></BuildActionEntries></BuildAction>
|
||||||
|
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="NO">
|
||||||
|
<Testables><TestableReference skipped="NO"><BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="B50000000000000000000050" BuildableName="LegadoReaderUITests.xctest" BlueprintName="LegadoReaderUITests" ReferencedContainer="container:Legado.xcodeproj"/></TestableReference></Testables>
|
||||||
|
<CommandLineArguments><CommandLineArgument argument="-uiDemo" isEnabled="YES"/></CommandLineArguments>
|
||||||
|
</TestAction>
|
||||||
|
</Scheme>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Scheme LastUpgradeVersion="2610" version="1.7">
|
||||||
|
<BuildAction parallelizeBuildables="YES" buildImplicitDependencies="YES"><BuildActionEntries><BuildActionEntry buildForTesting="YES" buildForRunning="NO" buildForProfiling="NO" buildForArchiving="NO" buildForAnalyzing="YES"><BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="B50000000000000000000050" BuildableName="LegadoReaderUITests.xctest" BlueprintName="LegadoReaderUITests" ReferencedContainer="container:Legado.xcodeproj"/></BuildActionEntry></BuildActionEntries></BuildAction>
|
||||||
|
<TestAction buildConfiguration="Debug" selectedDebuggerIdentifier="Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier="Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv="NO">
|
||||||
|
<Testables><TestableReference skipped="NO"><BuildableReference BuildableIdentifier="primary" BlueprintIdentifier="B50000000000000000000050" BuildableName="LegadoReaderUITests.xctest" BlueprintName="LegadoReaderUITests" ReferencedContainer="container:Legado.xcodeproj"/><SelectedTests><Test Identifier="LiveReadingFlowUITests/testSearchLatencyAndCachedQuery()"/></SelectedTests></TestableReference></Testables>
|
||||||
|
<EnvironmentVariables><EnvironmentVariable key="LEGADO_SEARCH_UI_BENCHMARK" value="1" isEnabled="YES"/></EnvironmentVariables>
|
||||||
|
</TestAction>
|
||||||
|
</Scheme>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"originHash" : "7f0bb8624f715c1c036934addf347a13f5c877af397c008dd678bb519c74d937",
|
||||||
|
"pins" : [
|
||||||
|
{
|
||||||
|
"identity" : "kanna",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/tid-kijyun/Kanna.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "3c73af6d3859d9240db60aef233941a715387744",
|
||||||
|
"version" : "6.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identity" : "swiftsoup",
|
||||||
|
"kind" : "remoteSourceControl",
|
||||||
|
"location" : "https://github.com/scinfu/SwiftSoup.git",
|
||||||
|
"state" : {
|
||||||
|
"revision" : "49dcadd93161f4a44b4994d3a3e8de9f085aface",
|
||||||
|
"version" : "2.13.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version" : 3
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// swift-tools-version: 6.0
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "LegadoIOS",
|
||||||
|
platforms: [
|
||||||
|
.iOS(.v15),
|
||||||
|
.macOS(.v14)
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
.library(name: "LegadoCore", targets: ["LegadoCore"])
|
||||||
|
],
|
||||||
|
dependencies: [
|
||||||
|
.package(url: "https://github.com/scinfu/SwiftSoup.git", exact: "2.13.5"),
|
||||||
|
.package(url: "https://github.com/tid-kijyun/Kanna.git", exact: "6.1.0")
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.target(
|
||||||
|
name: "LegadoCore",
|
||||||
|
dependencies: [
|
||||||
|
.product(name: "SwiftSoup", package: "SwiftSoup"),
|
||||||
|
.product(name: "Kanna", package: "Kanna")
|
||||||
|
],
|
||||||
|
linkerSettings: [
|
||||||
|
.linkedFramework("JavaScriptCore"),
|
||||||
|
.linkedFramework("CryptoKit"),
|
||||||
|
.linkedLibrary("sqlite3")
|
||||||
|
]
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "LegadoCoreTests",
|
||||||
|
dependencies: ["LegadoCore"],
|
||||||
|
resources: [.copy("Fixtures")]
|
||||||
|
),
|
||||||
|
.target(
|
||||||
|
name: "LegadoAppModels",
|
||||||
|
dependencies: ["LegadoCore"],
|
||||||
|
path: "App/Features",
|
||||||
|
exclude: [
|
||||||
|
"Activity", "Explore", "RSS", "TTS", "Settings",
|
||||||
|
"Sources/SourceManagementView.swift", "Sources/SourceDebugView.swift", "Sources/SourceLoginView.swift", "Sources/SourceEditorView.swift",
|
||||||
|
"Rules/ReplaceRuleManagementView.swift", "Rules/ReplaceRuleEditorView.swift", "Rules/RuleSubscriptionView.swift", "Rules/TxtTocRuleManagementView.swift", "Rules/Editor",
|
||||||
|
"Books/BookDetailView.swift", "Books/LocalBookDetailView.swift", "Books/SourceSwitchView.swift",
|
||||||
|
"Reader/ReaderAppearance.swift", "Reader/ReaderSpeechController.swift", "Reader/ReaderSupport.swift",
|
||||||
|
"Reader/ReaderTextView.swift", "Reader/ReaderScrollView.swift", "Reader/ReaderView.swift"
|
||||||
|
],
|
||||||
|
sources: [
|
||||||
|
"Sources/SourceLibraryModel.swift", "Rules/ReplaceRuleLibraryModel.swift", "Search/SearchViewModel.swift",
|
||||||
|
"Books/BookLibraryModel.swift", "Books/BookDetailViewModel.swift",
|
||||||
|
"Books/BookshelfReadViewModel.swift", "Reader/ReaderViewModel.swift"
|
||||||
|
]
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "LegadoAppModelsTests",
|
||||||
|
dependencies: ["LegadoAppModels", "LegadoCore"]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<img src="App/Assets.xcassets/BrandIcon.imageset/BrandIcon.png" width="128" height="128" alt="小浣熊阅读" />
|
||||||
|
|
||||||
|
# 小浣熊阅读
|
||||||
|
|
||||||
|
**搜书快一点,读书舒服一点。**
|
||||||
|
|
||||||
|
一款优先兼容阅读书源的 iOS 阅读器
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
|
[下载安装](#下载安装) · [主要功能](#主要功能) · [应用截图](#应用截图) · [书源说明](#书源说明) · [问题反馈](https://github.com/Aminark/legado-ios/issues)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
小浣熊阅读是一款 iOS 阅读器,由 [Aminark](https://github.com/Aminark) 维护,优先兼容阅读书源。希望从导入书源、搜索书籍到打开阅读,都能方便、顺手一些。
|
||||||
|
|
||||||
|
项目仍在边用边改,书源兼容和阅读体验会继续完善。
|
||||||
|
|
||||||
|
**小浣熊阅读不提供书源或在线书库。** 书源需由用户自行从网络公开分享获取,或自行编写后导入;也可以直接阅读本地 TXT。
|
||||||
|
|
||||||
|
## 下载安装
|
||||||
|
|
||||||
|
支持 **iOS / iPadOS 15.0 及以上**。
|
||||||
|
|
||||||
|
**IPA 将通过 [GitHub Releases](https://github.com/Aminark/legado-ios/releases) 提供,需要自行签名安装。** 如果 Releases 中还没有安装包,说明公开版本尚未发布。
|
||||||
|
|
||||||
|
目前没有付费 iOS 开发者账号,暂时无法提供 TestFlight,也暂不上架 App Store。这个项目主要是自己使用和分享,没有盈利计划。
|
||||||
|
|
||||||
|
- **自行签名安装**:使用自己的签名工具安装 IPA。免费 Apple 账号自签的描述文件有效期通常为 7 天,到期需要重新签名安装,见 [Apple 说明](https://developer.apple.com/help/account/basics/about-your-developer-account#enable-a-personal-team-in-xcode)。
|
||||||
|
- **巨魔安装**:已支持 [TrollStore](https://github.com/opa334/TrollStore) 的设备可自行尝试安装,具体系统兼容范围以 TrollStore 项目说明为准。
|
||||||
|
- **源码构建**:有 macOS 和 Xcode 的朋友也可以自行编译,见 [开发指南](docs/DEVELOPMENT.md)。
|
||||||
|
|
||||||
|
## 主要功能
|
||||||
|
|
||||||
|
| 功能 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| 阅读书源 | 支持 JSON、文件、URL 和二维码导入,以及书源分组、编辑和调试 |
|
||||||
|
| 快速搜书 | 多书源并发搜索,结果陆续展示;重复搜索可先显示有效缓存,同书多源合并 |
|
||||||
|
| 书架管理 | 列表、网格、分组、章节更新、换源和本机阅读进度 |
|
||||||
|
| 阅读排版 | 字号、间距、边距、日夜主题、滚动与分页阅读、目录和书签 |
|
||||||
|
| 本地 TXT | 导入本地文件、自定义目录规则和章节阅读 |
|
||||||
|
| 语音朗读 | 系统朗读与 HTTP TTS 配置 |
|
||||||
|
| 阅读工具 | 替换净化、阅读记录、时长统计和缓存管理 |
|
||||||
|
| RSS 订阅 | 订阅源管理与内容阅读 |
|
||||||
|
|
||||||
|
搜书速度是我比较在意的部分,目前做了并发调度、有效书源优先和结果缓存等优化。实际体验仍会受到网络和书源网站响应的影响。
|
||||||
|
|
||||||
|
兼容阅读书源不代表所有书源都能直接使用。复杂 JavaScript、登录、验证码和 WebView 规则仍可能存在平台差异,遇到问题欢迎反馈。
|
||||||
|
|
||||||
|
## 应用截图
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><strong>书架</strong><br /><a href="docs/images/bookshelf.png"><img src="docs/images/bookshelf.png" width="230" alt="小浣熊阅读书架" /></a></td>
|
||||||
|
<td align="center"><strong>搜书</strong><br /><a href="docs/images/search.png"><img src="docs/images/search.png" width="230" alt="小浣熊阅读搜索结果与缓存展示" /></a></td>
|
||||||
|
<td align="center"><strong>书源管理</strong><br /><a href="docs/images/sources.png"><img src="docs/images/sources.png" width="230" alt="小浣熊阅读书源管理" /></a></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><strong>日间阅读</strong><br /><a href="docs/images/reader.png"><img src="docs/images/reader.png" width="230" alt="小浣熊阅读纸色主题" /></a></td>
|
||||||
|
<td align="center"><strong>夜间阅读</strong><br /><a href="docs/images/reader-dark.png"><img src="docs/images/reader-dark.png" width="230" alt="小浣熊阅读夜间主题" /></a></td>
|
||||||
|
<td align="center"><strong>我的</strong><br /><a href="docs/images/profile.png"><img src="docs/images/profile.png" width="230" alt="小浣熊阅读个人中心" /></a></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>查看更多截图:发现、阅读统计、关于</summary>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td align="center"><strong>发现</strong><br /><a href="docs/images/explore.png"><img src="docs/images/explore.png" width="230" alt="小浣熊阅读发现页面" /></a></td>
|
||||||
|
<td align="center"><strong>阅读统计</strong><br /><a href="docs/images/statistics.png"><img src="docs/images/statistics.png" width="230" alt="小浣熊阅读统计页面" /></a></td>
|
||||||
|
<td align="center"><strong>关于</strong><br /><a href="docs/images/about.png"><img src="docs/images/about.png" width="230" alt="小浣熊阅读关于页面" /></a></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
截图使用演示书籍、书源和正文,点击可查看大图。
|
||||||
|
|
||||||
|
## 开始使用
|
||||||
|
|
||||||
|
1. 自行准备你有权使用的阅读书源,安装后进入「我的 → 书源管理」导入。
|
||||||
|
2. 在书架点击搜索,输入书名或作者,打开书籍详情后开始阅读。
|
||||||
|
3. 将书籍加入书架,方便下次继续阅读;也可以从书架直接导入本地 TXT。
|
||||||
|
4. 在阅读页调整排版、主题、书签和朗读设置。
|
||||||
|
|
||||||
|
应用支持 `legado://` 规则导入链接。来源网站改版或停止服务时,书源规则可能需要更新。
|
||||||
|
|
||||||
|
## 书源说明
|
||||||
|
|
||||||
|
小浣熊阅读仅提供书源解析、书籍管理和阅读功能,**不提供、内置或分发网络书源**。用户使用的书源来自网络公开分享,或由用户自行编写、导入。
|
||||||
|
|
||||||
|
在线书籍正文、封面、简介及订阅内容由书源对应的第三方网站提供,版权归原作者或合法权利人所有。网络公开分享、书源可导入或内容可访问,不代表已获得内容授权。
|
||||||
|
|
||||||
|
请自行核实书源和内容的来源,在获得授权或法律允许的范围内使用,遵守来源网站的使用规则,支持正版阅读。第三方网站或书源失效时,需要更新规则或更换来源。
|
||||||
|
|
||||||
|
## 为什么做这个
|
||||||
|
|
||||||
|
我之前一直用香色闺阁看书,也用得挺习惯。不过在自己的使用过程中,搜书等待时间一直让我比较难受。后来能用的书源越来越少,就想着干脆自己折腾一个。
|
||||||
|
|
||||||
|
最开始只是打算自己用:搜书快一点,导入书源方便一点,找到书以后能舒舒服服地看下去。
|
||||||
|
|
||||||
|
后来就这样边写边用、缺什么补什么,慢慢成了现在的小浣熊阅读。既然可能还有朋友有同样的需求,就把它开源出来,欢迎一起折腾。
|
||||||
|
|
||||||
|
## 反馈与贡献
|
||||||
|
|
||||||
|
遇到问题或有功能建议,欢迎提交 [Issue](https://github.com/Aminark/legado-ios/issues)。反馈时请附上应用版本、系统版本、设备和复现步骤;书源问题尽量提供最小规则。
|
||||||
|
|
||||||
|
请先去掉日志和规则中的账号、Cookie、Token 等个人信息。欢迎提交代码、兼容用例和文档改进,具体见 [贡献指南](CONTRIBUTING.md)。
|
||||||
|
|
||||||
|
## 开源许可
|
||||||
|
|
||||||
|
本项目采用 **GNU GPL v3**,详见 [LICENSE](LICENSE)。
|
||||||
|
|
||||||
|
第三方材料的版权与许可见 [第三方声明](THIRD_PARTY_NOTICES.md)。
|
||||||
|
|
||||||
|
请遵守书源网站的使用规则和内容授权,支持正版阅读。
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum AddBookURLError: LocalizedError, Equatable {
|
||||||
|
case invalidURL
|
||||||
|
case sourceNotFound
|
||||||
|
case detailFailed
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidURL: "书籍地址格式不对"
|
||||||
|
case .sourceNotFound: "未找到匹配书源"
|
||||||
|
case .detailFailed: "匹配书源未能解析书籍详情"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct AddBookURLResult: Sendable {
|
||||||
|
public var source: BookSource
|
||||||
|
public var book: Book
|
||||||
|
public var diagnostics: [RuleDiagnostic]
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct AddBookURLService: Sendable {
|
||||||
|
private let detailService: NetworkBookService
|
||||||
|
public init(detailService: NetworkBookService = .init()) { self.detailService = detailService }
|
||||||
|
|
||||||
|
public func resolve(bookURL: String, sources: [BookSource]) async throws -> AddBookURLResult {
|
||||||
|
let parsed = Self.parse(bookURL)
|
||||||
|
guard let url = URL(string: parsed.url), let scheme = url.scheme?.lowercased(), ["http", "https"].contains(scheme), let host = url.host else {
|
||||||
|
throw AddBookURLError.invalidURL
|
||||||
|
}
|
||||||
|
let baseURL = "\(scheme)://\(host)" + (url.port.map { ":\($0)" } ?? "")
|
||||||
|
let enabled = sources.filter(\.enabled).sorted { $0.customOrder < $1.customOrder }
|
||||||
|
var candidates: [BookSource] = []
|
||||||
|
if let origin = parsed.origin, let source = sources.first(where: { $0.bookSourceUrl == origin }) { candidates.append(source) }
|
||||||
|
if let source = enabled.first(where: { $0.bookSourceUrl == baseURL }) { candidates.append(source) }
|
||||||
|
for source in enabled where source.bookUrlPattern?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false && source.bookUrlPattern != "NONE" {
|
||||||
|
guard let rule = source.bookUrlPattern, let expression = try? NSRegularExpression(pattern: rule) else { continue }
|
||||||
|
let range = NSRange(parsed.url.startIndex..., in: parsed.url)
|
||||||
|
if let match = expression.firstMatch(in: parsed.url, range: range), match.range == range { candidates.append(source) }
|
||||||
|
}
|
||||||
|
var seen = Set<String>()
|
||||||
|
for source in candidates where seen.insert(source.bookSourceUrl).inserted {
|
||||||
|
let searchBook = SearchBook(bookURL: bookURL, origin: source.bookSourceUrl, originName: source.bookSourceName, type: source.bookSourceType, name: "")
|
||||||
|
let detail = await detailService.loadDetail(searchBook: searchBook, source: source)
|
||||||
|
if !detail.book.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
return .init(source: source, book: detail.book, diagnostics: detail.diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if candidates.isEmpty { throw AddBookURLError.sourceNotFound }
|
||||||
|
throw AddBookURLError.detailFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func matchingSource(bookURL: String, sources: [BookSource]) -> BookSource? {
|
||||||
|
let parsed = parse(bookURL)
|
||||||
|
if let origin = parsed.origin, let source = sources.first(where: { $0.bookSourceUrl == origin }) { return source }
|
||||||
|
guard let url = URL(string: parsed.url), let scheme = url.scheme, let host = url.host else { return nil }
|
||||||
|
let baseURL = "\(scheme)://\(host)" + (url.port.map { ":\($0)" } ?? "")
|
||||||
|
let enabled = sources.filter(\.enabled).sorted { $0.customOrder < $1.customOrder }
|
||||||
|
if let source = enabled.first(where: { $0.bookSourceUrl == baseURL }) { return source }
|
||||||
|
return enabled.first { source in
|
||||||
|
guard let rule = source.bookUrlPattern?.trimmingCharacters(in: .whitespacesAndNewlines), !rule.isEmpty, rule != "NONE",
|
||||||
|
let expression = try? NSRegularExpression(pattern: rule) else { return false }
|
||||||
|
let range = NSRange(parsed.url.startIndex..., in: parsed.url)
|
||||||
|
return expression.firstMatch(in: parsed.url, range: range)?.range == range
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parse(_ value: String) -> (url: String, origin: String?) {
|
||||||
|
guard let expression = try? NSRegularExpression(pattern: #"\s*,\s*(?=\{)"#),
|
||||||
|
let match = expression.firstMatch(in: value, range: NSRange(value.startIndex..., in: value)),
|
||||||
|
let range = Range(match.range, in: value) else { return (value, nil) }
|
||||||
|
let url = String(value[..<range.lowerBound])
|
||||||
|
let option = String(value[range.upperBound...])
|
||||||
|
guard let data = option.data(using: .utf8), let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return (url, nil) }
|
||||||
|
return (url, object["origin"] as? String ?? object["bookSourceUrl"] as? String)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public protocol BookSourceSearching: Sendable {
|
||||||
|
func search(keyword: String, page: Int, sources: [BookSource]) async -> BookSearchReport
|
||||||
|
func searchStream(keyword: String, sources: [BookSource]) -> AsyncStream<SourceSearchOutcome>
|
||||||
|
}
|
||||||
|
|
||||||
|
extension BookSourceSearching {
|
||||||
|
public func searchStream(keyword: String, sources: [BookSource]) -> AsyncStream<SourceSearchOutcome> {
|
||||||
|
AsyncStream { continuation in
|
||||||
|
let task = Task {
|
||||||
|
let report = await search(keyword: keyword, page: 1, sources: sources)
|
||||||
|
for outcome in report.outcomes {
|
||||||
|
guard !Task.isCancelled else { break }
|
||||||
|
continuation.yield(outcome)
|
||||||
|
}
|
||||||
|
continuation.finish()
|
||||||
|
}
|
||||||
|
continuation.onTermination = { _ in task.cancel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension BookSearchService: BookSourceSearching {
|
||||||
|
public func searchStream(keyword: String, sources: [BookSource]) -> AsyncStream<SourceSearchOutcome> {
|
||||||
|
searchOutcomes(keyword: keyword, sources: sources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol BookDetailLoading: Sendable {
|
||||||
|
func loadDetail(searchBook: SearchBook, source: BookSource) async -> BookDetailReport
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NetworkBookService: BookDetailLoading {}
|
||||||
|
|
||||||
|
public struct BookSourceCandidate: Identifiable, Sendable, Equatable {
|
||||||
|
public var book: SearchBook
|
||||||
|
public var score: Int
|
||||||
|
public var id: String { book.origin + "|" + book.bookURL }
|
||||||
|
|
||||||
|
public init(book: SearchBook, score: Int) {
|
||||||
|
self.book = book
|
||||||
|
self.score = score
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct BookSourceSwitchReport: Sendable {
|
||||||
|
public var book: Book
|
||||||
|
public var chapters: [BookChapter]
|
||||||
|
public var diagnostics: [RuleDiagnostic]
|
||||||
|
public var exactChapterMatch: Bool
|
||||||
|
public var verifiedContent: ChapterContentReport?
|
||||||
|
|
||||||
|
public init(book: Book, chapters: [BookChapter], diagnostics: [RuleDiagnostic] = [], exactChapterMatch: Bool, verifiedContent: ChapterContentReport? = nil) {
|
||||||
|
self.book = book
|
||||||
|
self.chapters = chapters
|
||||||
|
self.diagnostics = diagnostics
|
||||||
|
self.exactChapterMatch = exactChapterMatch
|
||||||
|
self.verifiedContent = verifiedContent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct AvailableBookSource: Sendable {
|
||||||
|
public var report: BookSourceSwitchReport
|
||||||
|
public var source: BookSource
|
||||||
|
|
||||||
|
public init(report: BookSourceSwitchReport, source: BookSource) {
|
||||||
|
self.report = report
|
||||||
|
self.source = source
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct BookSourceProgressMapper: Sendable {
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func migrate(current: Book, replacement: Book, chapters: [BookChapter], now: Int64) -> BookSourceSwitchReport {
|
||||||
|
var migrated = replacement
|
||||||
|
migrated.customTag = current.customTag
|
||||||
|
migrated.customCoverUrl = current.customCoverUrl
|
||||||
|
migrated.customIntro = current.customIntro
|
||||||
|
migrated.group = current.group
|
||||||
|
migrated.order = current.order
|
||||||
|
migrated.canUpdate = current.canUpdate
|
||||||
|
migrated.readConfig = current.readConfig
|
||||||
|
migrated.syncTime = current.syncTime
|
||||||
|
migrated.lastCheckCount = 0
|
||||||
|
migrated.lastCheckTime = now
|
||||||
|
migrated.latestChapterTime = now
|
||||||
|
migrated.totalChapterNum = chapters.count
|
||||||
|
migrated.latestChapterTitle = chapters.last(where: { !$0.isVolume })?.title ?? chapters.last?.title
|
||||||
|
|
||||||
|
let oldTitle = current.durChapterTitle ?? ""
|
||||||
|
let normalizedOldTitle = normalize(oldTitle)
|
||||||
|
let exactIndex = chapters.firstIndex { chapter in
|
||||||
|
chapter.title == oldTitle || (!normalizedOldTitle.isEmpty && normalize(chapter.title) == normalizedOldTitle)
|
||||||
|
}
|
||||||
|
let resolvedIndex: Int
|
||||||
|
if let exactIndex {
|
||||||
|
resolvedIndex = exactIndex
|
||||||
|
migrated.durChapterPos = current.durChapterPos
|
||||||
|
} else if !chapters.isEmpty {
|
||||||
|
let denominator = max(current.totalChapterNum - 1, 1)
|
||||||
|
let progress = min(max(Double(current.durChapterIndex) / Double(denominator), 0), 1)
|
||||||
|
resolvedIndex = min(Int((progress * Double(max(chapters.count - 1, 0))).rounded()), chapters.count - 1)
|
||||||
|
migrated.durChapterPos = 0
|
||||||
|
} else {
|
||||||
|
resolvedIndex = 0
|
||||||
|
migrated.durChapterPos = 0
|
||||||
|
}
|
||||||
|
migrated.durChapterIndex = resolvedIndex
|
||||||
|
migrated.durChapterTitle = chapters.indices.contains(resolvedIndex) ? chapters[resolvedIndex].title : nil
|
||||||
|
migrated.durChapterTime = current.durChapterTime
|
||||||
|
return .init(book: migrated, chapters: chapters, exactChapterMatch: exactIndex != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func normalize(_ value: String) -> String {
|
||||||
|
String(value.unicodeScalars.filter {
|
||||||
|
!CharacterSet.whitespacesAndNewlines.contains($0) && !CharacterSet.punctuationCharacters.contains($0)
|
||||||
|
}).lowercased()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public actor BookSourceSwitchService {
|
||||||
|
private let searcher: any BookSourceSearching
|
||||||
|
private let detailLoader: any BookDetailLoading
|
||||||
|
private let chapterLoader: any ChapterListLoading
|
||||||
|
private let contentLoader: any ChapterContentLoading
|
||||||
|
private let chapterRepository: any BookChapterRepository
|
||||||
|
private let mapper: BookSourceProgressMapper
|
||||||
|
|
||||||
|
public init(
|
||||||
|
searcher: any BookSourceSearching = BookSearchService(),
|
||||||
|
detailLoader: any BookDetailLoading = NetworkBookService(),
|
||||||
|
chapterLoader: any ChapterListLoading = NetworkBookService(),
|
||||||
|
contentLoader: any ChapterContentLoading = NetworkBookService(),
|
||||||
|
chapterRepository: any BookChapterRepository = FileBookChapterRepository.live(),
|
||||||
|
mapper: BookSourceProgressMapper = .init()
|
||||||
|
) {
|
||||||
|
self.searcher = searcher
|
||||||
|
self.detailLoader = detailLoader
|
||||||
|
self.chapterLoader = chapterLoader
|
||||||
|
self.contentLoader = contentLoader
|
||||||
|
self.chapterRepository = chapterRepository
|
||||||
|
self.mapper = mapper
|
||||||
|
}
|
||||||
|
|
||||||
|
public func candidates(for book: Book, sources: [BookSource]) async -> [BookSourceCandidate] {
|
||||||
|
var result: [BookSourceCandidate] = []
|
||||||
|
for await update in candidateUpdates(for: book, sources: sources) {
|
||||||
|
guard !Task.isCancelled else { return result }
|
||||||
|
result = update
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
public func candidateUpdates(for book: Book, sources: [BookSource]) -> AsyncStream<[BookSourceCandidate]> {
|
||||||
|
let eligible = sources.enumerated()
|
||||||
|
.filter { $0.element.enabled && $0.element.bookSourceUrl != book.origin && $0.element.searchUrl?.isEmpty == false }
|
||||||
|
.sorted {
|
||||||
|
if $0.element.customOrder != $1.element.customOrder { return $0.element.customOrder < $1.element.customOrder }
|
||||||
|
return $0.offset < $1.offset
|
||||||
|
}
|
||||||
|
.map(\.element)
|
||||||
|
guard !eligible.isEmpty else { return AsyncStream { $0.finish() } }
|
||||||
|
let eligibleIDs = Set(eligible.map(\.id))
|
||||||
|
let searcher = self.searcher
|
||||||
|
return AsyncStream { continuation in
|
||||||
|
let task = Task {
|
||||||
|
var found: [String: SearchBook] = [:]
|
||||||
|
for await outcome in searcher.searchStream(keyword: book.name, sources: eligible) {
|
||||||
|
guard !Task.isCancelled else { break }
|
||||||
|
for candidate in outcome.books where eligibleIDs.contains(candidate.origin) {
|
||||||
|
found[BookSourceCandidate(book: candidate, score: 0).id] = candidate
|
||||||
|
}
|
||||||
|
continuation.yield(Self.ranked(Array(found.values), for: book))
|
||||||
|
}
|
||||||
|
continuation.finish()
|
||||||
|
}
|
||||||
|
continuation.onTermination = { _ in task.cancel() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func ranked(_ books: [SearchBook], for book: Book) -> [BookSourceCandidate] {
|
||||||
|
let normalizedName = normalize(book.name)
|
||||||
|
let normalizedAuthor = normalizeAuthor(book.author)
|
||||||
|
return books.compactMap { candidate in
|
||||||
|
guard normalize(candidate.name) == normalizedName else { return nil }
|
||||||
|
let author = normalizeAuthor(candidate.author)
|
||||||
|
var score = 100
|
||||||
|
if !normalizedAuthor.isEmpty && author == normalizedAuthor { score += 80 }
|
||||||
|
if candidate.latestChapterTitle?.isEmpty == false { score += 10 }
|
||||||
|
score -= max(candidate.originOrder, 0)
|
||||||
|
return BookSourceCandidate(book: candidate, score: score)
|
||||||
|
}
|
||||||
|
.sorted {
|
||||||
|
if $0.score != $1.score { return $0.score > $1.score }
|
||||||
|
return $0.book.originName.localizedStandardCompare($1.book.originName) == .orderedAscending
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func prepareSwitch(
|
||||||
|
current: Book,
|
||||||
|
candidate: SearchBook,
|
||||||
|
source: BookSource,
|
||||||
|
now: Int64? = nil
|
||||||
|
) async -> BookSourceSwitchReport? {
|
||||||
|
guard !Task.isCancelled else { return nil }
|
||||||
|
let detail = await detailLoader.loadDetail(searchBook: candidate, source: source)
|
||||||
|
guard !Task.isCancelled else { return nil }
|
||||||
|
let toc = await chapterLoader.loadChapters(book: detail.book, source: source, maxPages: 20)
|
||||||
|
guard !Task.isCancelled, toc.chapters.contains(where: { !$0.isVolume }) else { return nil }
|
||||||
|
var result = mapper.migrate(
|
||||||
|
current: current,
|
||||||
|
replacement: detail.book,
|
||||||
|
chapters: toc.chapters,
|
||||||
|
now: now ?? Int64(Date().timeIntervalSince1970 * 1_000)
|
||||||
|
)
|
||||||
|
result.diagnostics = detail.diagnostics + toc.diagnostics
|
||||||
|
let readableIndex = result.chapters.indices.dropFirst(result.book.durChapterIndex)
|
||||||
|
.first(where: { !result.chapters[$0].isVolume })
|
||||||
|
?? result.chapters.indices.last(where: { !result.chapters[$0].isVolume })
|
||||||
|
guard let readableIndex else { return nil }
|
||||||
|
if readableIndex != result.book.durChapterIndex {
|
||||||
|
result.book.durChapterIndex = readableIndex
|
||||||
|
result.book.durChapterTitle = result.chapters[readableIndex].title
|
||||||
|
result.book.durChapterPos = 0
|
||||||
|
}
|
||||||
|
let chapter = result.chapters[readableIndex]
|
||||||
|
let nextURL = result.chapters.dropFirst(readableIndex + 1).first(where: { !$0.isVolume })?.url
|
||||||
|
let content = await contentLoader.loadContent(
|
||||||
|
book: result.book, chapter: chapter, source: source,
|
||||||
|
nextChapterURL: nextURL, maxPages: 20
|
||||||
|
)
|
||||||
|
guard !Task.isCancelled,
|
||||||
|
!content.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
|
||||||
|
result.verifiedContent = content
|
||||||
|
result.diagnostics += content.diagnostics
|
||||||
|
do {
|
||||||
|
try await chapterRepository.save(result.chapters, bookURL: result.book.bookUrl)
|
||||||
|
} catch {
|
||||||
|
result.diagnostics.append(.init(
|
||||||
|
severity: .error,
|
||||||
|
code: "source_switch.persistence",
|
||||||
|
message: "换源目录保存失败:\(error.localizedDescription)",
|
||||||
|
rule: result.book.bookUrl
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
public func firstAvailableSource(
|
||||||
|
for book: Book,
|
||||||
|
sources: [BookSource],
|
||||||
|
now: Int64? = nil
|
||||||
|
) async -> AvailableBookSource? {
|
||||||
|
var tried = Set<String>()
|
||||||
|
for await update in candidateUpdates(for: book, sources: sources) {
|
||||||
|
for candidate in update where tried.insert(candidate.id).inserted {
|
||||||
|
guard !Task.isCancelled else { return nil }
|
||||||
|
guard let source = sources.first(where: { $0.bookSourceUrl == candidate.book.origin }) else { continue }
|
||||||
|
if let report = await prepareSwitch(
|
||||||
|
current: book,
|
||||||
|
candidate: candidate.book,
|
||||||
|
source: source,
|
||||||
|
now: now
|
||||||
|
) {
|
||||||
|
return .init(report: report, source: source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func normalize(_ value: String) -> String {
|
||||||
|
String(value.unicodeScalars.filter {
|
||||||
|
!CharacterSet.whitespacesAndNewlines.contains($0) && !CharacterSet.punctuationCharacters.contains($0)
|
||||||
|
}).lowercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
private nonisolated static func normalizeAuthor(_ value: String) -> String {
|
||||||
|
normalize(value.replacingOccurrences(of: "作者", with: "").replacingOccurrences(of: "著", with: ""))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public protocol ChapterListLoading: Sendable {
|
||||||
|
func loadChapters(book: Book, source: BookSource, maxPages: Int) async -> ChapterListReport
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NetworkBookService: ChapterListLoading {}
|
||||||
|
|
||||||
|
public struct BookUpdateReport: Sendable {
|
||||||
|
public var book: Book
|
||||||
|
public var chapters: [BookChapter]
|
||||||
|
public var addedChapterCount: Int
|
||||||
|
public var diagnostics: [RuleDiagnostic]
|
||||||
|
public var refreshed: Bool
|
||||||
|
|
||||||
|
public init(
|
||||||
|
book: Book,
|
||||||
|
chapters: [BookChapter],
|
||||||
|
addedChapterCount: Int,
|
||||||
|
diagnostics: [RuleDiagnostic] = [],
|
||||||
|
refreshed: Bool = true
|
||||||
|
) {
|
||||||
|
self.book = book
|
||||||
|
self.chapters = chapters
|
||||||
|
self.addedChapterCount = addedChapterCount
|
||||||
|
self.diagnostics = diagnostics
|
||||||
|
self.refreshed = refreshed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct BookUpdateMerger: Sendable {
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func merge(book: Book, oldChapters: [BookChapter], newChapters: [BookChapter], now: Int64) -> BookUpdateReport {
|
||||||
|
guard !newChapters.isEmpty else {
|
||||||
|
return .init(book: book, chapters: oldChapters, addedChapterCount: 0, refreshed: false)
|
||||||
|
}
|
||||||
|
var normalized = newChapters
|
||||||
|
let metadata = Dictionary(uniqueKeysWithValues: oldChapters.map {
|
||||||
|
("\($0.index)\u{0}\($0.title)", ($0.wordCount, $0.variable, $0.imageURL))
|
||||||
|
})
|
||||||
|
for index in normalized.indices {
|
||||||
|
normalized[index].index = index
|
||||||
|
normalized[index].bookUrl = book.bookUrl
|
||||||
|
if let old = metadata["\(index)\u{0}\(normalized[index].title)"] {
|
||||||
|
if let wordCount = old.0 { normalized[index].wordCount = wordCount }
|
||||||
|
if let variable = old.1 { normalized[index].variable = variable }
|
||||||
|
if let imageURL = old.2 { normalized[index].imageURL = imageURL }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var updated = book
|
||||||
|
let previousTotal = max(book.totalChapterNum, oldChapters.count)
|
||||||
|
let added = max(normalized.count - previousTotal, 0)
|
||||||
|
if added > 0 {
|
||||||
|
updated.lastCheckCount = added
|
||||||
|
updated.latestChapterTime = now
|
||||||
|
}
|
||||||
|
updated.lastCheckTime = now
|
||||||
|
updated.totalChapterNum = normalized.count
|
||||||
|
updated.latestChapterTitle = normalized.last(where: { !$0.isVolume })?.title ?? normalized.last?.title
|
||||||
|
let newReadIndex = resolveReadIndex(book: book, chapters: normalized)
|
||||||
|
updated.durChapterIndex = newReadIndex
|
||||||
|
if let oldTitle = book.durChapterTitle, !oldTitle.isEmpty,
|
||||||
|
normalized.indices.contains(newReadIndex),
|
||||||
|
normalize(normalized[newReadIndex].title) != normalize(oldTitle) {
|
||||||
|
updated.durChapterPos = 0
|
||||||
|
}
|
||||||
|
updated.durChapterTitle = normalized.indices.contains(newReadIndex) ? normalized[newReadIndex].title : updated.durChapterTitle
|
||||||
|
return .init(book: updated, chapters: normalized, addedChapterCount: added)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveReadIndex(book: Book, chapters: [BookChapter]) -> Int {
|
||||||
|
if let title = book.durChapterTitle, !title.isEmpty {
|
||||||
|
if let exact = chapters.firstIndex(where: { $0.title == title }) { return exact }
|
||||||
|
let normalizedTitle = normalize(title)
|
||||||
|
if let close = chapters.firstIndex(where: { normalize($0.title) == normalizedTitle }) { return close }
|
||||||
|
}
|
||||||
|
return min(max(book.durChapterIndex, 0), max(chapters.count - 1, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func normalize(_ title: String) -> String {
|
||||||
|
String(title.unicodeScalars.filter {
|
||||||
|
!CharacterSet.whitespacesAndNewlines.contains($0) && !CharacterSet.punctuationCharacters.contains($0)
|
||||||
|
}).lowercased()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public actor BookUpdateCoordinator {
|
||||||
|
private let repository: any BookChapterRepository
|
||||||
|
private let loader: any ChapterListLoading
|
||||||
|
private let merger: BookUpdateMerger
|
||||||
|
|
||||||
|
public init(
|
||||||
|
repository: any BookChapterRepository = FileBookChapterRepository.live(),
|
||||||
|
loader: any ChapterListLoading = NetworkBookService(),
|
||||||
|
merger: BookUpdateMerger = .init()
|
||||||
|
) {
|
||||||
|
self.repository = repository
|
||||||
|
self.loader = loader
|
||||||
|
self.merger = merger
|
||||||
|
}
|
||||||
|
|
||||||
|
public func storedChapters(bookURL: String) async -> [BookChapter] {
|
||||||
|
(try? await repository.load(bookURL: bookURL)) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
public func refresh(book: Book, source: BookSource, now: Int64? = nil, maxPages: Int = 20) async -> BookUpdateReport {
|
||||||
|
let old = (try? await repository.load(bookURL: book.bookUrl)) ?? []
|
||||||
|
let loaded = await loader.loadChapters(book: book, source: source, maxPages: maxPages)
|
||||||
|
guard !loaded.chapters.isEmpty else {
|
||||||
|
return .init(
|
||||||
|
book: book,
|
||||||
|
chapters: old,
|
||||||
|
addedChapterCount: 0,
|
||||||
|
diagnostics: loaded.diagnostics,
|
||||||
|
refreshed: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var result = merger.merge(
|
||||||
|
book: book,
|
||||||
|
oldChapters: old,
|
||||||
|
newChapters: loaded.chapters,
|
||||||
|
now: now ?? Int64(Date().timeIntervalSince1970 * 1_000)
|
||||||
|
)
|
||||||
|
result.diagnostics = loaded.diagnostics
|
||||||
|
do {
|
||||||
|
try await repository.save(result.chapters, bookURL: result.book.bookUrl)
|
||||||
|
} catch {
|
||||||
|
result.diagnostics.append(.init(
|
||||||
|
severity: .error,
|
||||||
|
code: "toc.persistence",
|
||||||
|
message: "目录保存失败:\(error.localizedDescription)",
|
||||||
|
rule: book.bookUrl
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct CachedChapterContent: Codable, Equatable, Sendable {
|
||||||
|
public var bookURL: String
|
||||||
|
public var chapterURL: String
|
||||||
|
public var title: String?
|
||||||
|
public var content: String
|
||||||
|
public var supplementalContent: String?
|
||||||
|
public var savedAt: Int64
|
||||||
|
public var sourceID: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
bookURL: String,
|
||||||
|
chapterURL: String,
|
||||||
|
title: String? = nil,
|
||||||
|
content: String,
|
||||||
|
supplementalContent: String? = nil,
|
||||||
|
savedAt: Int64 = Int64(Date().timeIntervalSince1970 * 1_000),
|
||||||
|
sourceID: String? = nil
|
||||||
|
) {
|
||||||
|
self.bookURL = bookURL
|
||||||
|
self.chapterURL = chapterURL
|
||||||
|
self.title = title
|
||||||
|
self.content = content
|
||||||
|
self.supplementalContent = supplementalContent
|
||||||
|
self.savedAt = savedAt
|
||||||
|
self.sourceID = sourceID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ChapterContentCacheError: Error, LocalizedError, Equatable {
|
||||||
|
case entryTooLarge(Int)
|
||||||
|
case invalidEntry
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .entryTooLarge(let maximum): "章节正文超过缓存上限 \(maximum / 1_024 / 1_024) MB"
|
||||||
|
case .invalidEntry: "章节缓存内容无效"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public actor ChapterContentCache {
|
||||||
|
public static let maximumEntryBytes = 8 * 1_024 * 1_024
|
||||||
|
public static let defaultMaximumTotalBytes = 256 * 1_024 * 1_024
|
||||||
|
|
||||||
|
private let directory: URL
|
||||||
|
private let maximumTotalBytes: Int?
|
||||||
|
|
||||||
|
public init(directory: URL, maximumTotalBytes: Int? = nil) {
|
||||||
|
self.directory = directory
|
||||||
|
self.maximumTotalBytes = maximumTotalBytes.map { max($0, Self.maximumEntryBytes) }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func live() -> ChapterContentCache {
|
||||||
|
let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
||||||
|
.appendingPathComponent("Legado/Chapters", isDirectory: true)
|
||||||
|
return ChapterContentCache(directory: base)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func load(bookURL: String, chapterURL: String) throws -> CachedChapterContent? {
|
||||||
|
let url = fileURL(bookURL: bookURL, chapterURL: chapterURL)
|
||||||
|
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
|
||||||
|
do {
|
||||||
|
let value = try JSONDecoder().decode(CachedChapterContent.self, from: Data(contentsOf: url, options: .mappedIfSafe))
|
||||||
|
guard value.bookURL == bookURL, value.chapterURL == chapterURL, !value.content.isEmpty else {
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
throw ChapterContentCacheError.invalidEntry
|
||||||
|
}
|
||||||
|
try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: url.path)
|
||||||
|
return value
|
||||||
|
} catch let error as ChapterContentCacheError {
|
||||||
|
throw error
|
||||||
|
} catch {
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func save(_ entry: CachedChapterContent) throws {
|
||||||
|
guard !entry.bookURL.isEmpty, !entry.chapterURL.isEmpty, !entry.content.isEmpty else {
|
||||||
|
throw ChapterContentCacheError.invalidEntry
|
||||||
|
}
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
let data = try encoder.encode(entry)
|
||||||
|
guard data.count <= Self.maximumEntryBytes else {
|
||||||
|
throw ChapterContentCacheError.entryTooLarge(Self.maximumEntryBytes)
|
||||||
|
}
|
||||||
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||||
|
try data.write(to: fileURL(bookURL: entry.bookURL, chapterURL: entry.chapterURL), options: .atomic)
|
||||||
|
try pruneIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func remove(bookURL: String) throws {
|
||||||
|
guard FileManager.default.fileExists(atPath: directory.path) else { return }
|
||||||
|
let prefix = digest(bookURL) + "-"
|
||||||
|
for url in try FileManager.default.contentsOfDirectory(at: directory, includingPropertiesForKeys: nil)
|
||||||
|
where url.lastPathComponent.hasPrefix(prefix) {
|
||||||
|
try FileManager.default.removeItem(at: url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func remove(bookURL: String, chapterURL: String) throws {
|
||||||
|
let url = fileURL(bookURL: bookURL, chapterURL: chapterURL)
|
||||||
|
guard FileManager.default.fileExists(atPath: url.path) else { return }
|
||||||
|
try FileManager.default.removeItem(at: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func removeAll() throws {
|
||||||
|
guard FileManager.default.fileExists(atPath: directory.path) else { return }
|
||||||
|
try FileManager.default.removeItem(at: directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func statistics() throws -> (entries: Int, bytes: Int) {
|
||||||
|
let files = try cacheFiles()
|
||||||
|
return (files.count, files.reduce(0) { $0 + $1.size })
|
||||||
|
}
|
||||||
|
|
||||||
|
public func pruneIfNeeded() throws {
|
||||||
|
let maximumTotalBytes = maximumTotalBytes ?? ApplicationSettingsStore.shared.settings.chapterCacheMB * 1024 * 1024
|
||||||
|
var files = try cacheFiles().sorted { $0.modified < $1.modified }
|
||||||
|
var total = files.reduce(0) { $0 + $1.size }
|
||||||
|
while total > maximumTotalBytes, let oldest = files.first {
|
||||||
|
try FileManager.default.removeItem(at: oldest.url)
|
||||||
|
total -= oldest.size
|
||||||
|
files.removeFirst()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cacheFiles() throws -> [(url: URL, size: Int, modified: Date)] {
|
||||||
|
guard FileManager.default.fileExists(atPath: directory.path) else { return [] }
|
||||||
|
return try FileManager.default.contentsOfDirectory(
|
||||||
|
at: directory,
|
||||||
|
includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey],
|
||||||
|
options: [.skipsHiddenFiles]
|
||||||
|
).compactMap { url in
|
||||||
|
guard url.pathExtension == "json" else { return nil }
|
||||||
|
let values = try url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey])
|
||||||
|
return (url, values.fileSize ?? 0, values.contentModificationDate ?? .distantPast)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fileURL(bookURL: String, chapterURL: String) -> URL {
|
||||||
|
directory.appendingPathComponent("\(digest(bookURL))-\(digest(chapterURL)).json")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func digest(_ value: String) -> String {
|
||||||
|
SHA256.hash(data: Data(value.utf8)).map { String(format: "%02x", $0) }.joined()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol ChapterContentLoading: Sendable {
|
||||||
|
func loadContent(
|
||||||
|
book: Book,
|
||||||
|
chapter: BookChapter,
|
||||||
|
source: BookSource,
|
||||||
|
nextChapterURL: String?,
|
||||||
|
maxPages: Int
|
||||||
|
) async -> ChapterContentReport
|
||||||
|
}
|
||||||
|
|
||||||
|
extension NetworkBookService: ChapterContentLoading {}
|
||||||
|
|
||||||
|
public struct ChapterCacheJob: Sendable, Equatable {
|
||||||
|
public var chapter: BookChapter
|
||||||
|
public var nextChapterURL: String?
|
||||||
|
|
||||||
|
public init(chapter: BookChapter, nextChapterURL: String?) {
|
||||||
|
self.chapter = chapter
|
||||||
|
self.nextChapterURL = nextChapterURL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ChapterCachePlanner {
|
||||||
|
public static func plan(chapters: [BookChapter], startIndex: Int, limit: Int? = nil) -> [ChapterCacheJob] {
|
||||||
|
guard !chapters.isEmpty, limit.map({ $0 > 0 }) ?? true else { return [] }
|
||||||
|
let start = min(max(startIndex, 0), chapters.count - 1)
|
||||||
|
var jobs: [ChapterCacheJob] = []
|
||||||
|
for index in chapters.indices where index >= start && !chapters[index].isVolume {
|
||||||
|
let nextURL = chapters[(index + 1)...].first(where: { !$0.isVolume })?.url
|
||||||
|
jobs.append(.init(chapter: chapters[index], nextChapterURL: nextURL))
|
||||||
|
if let limit, jobs.count >= limit { break }
|
||||||
|
}
|
||||||
|
return jobs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct CoordinatedChapterContent: Sendable {
|
||||||
|
public var report: ChapterContentReport
|
||||||
|
public var loadedFromCache: Bool
|
||||||
|
|
||||||
|
public init(report: ChapterContentReport, loadedFromCache: Bool) {
|
||||||
|
self.report = report
|
||||||
|
self.loadedFromCache = loadedFromCache
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public actor ChapterContentCoordinator {
|
||||||
|
private let loader: any ChapterContentLoading
|
||||||
|
private let cache: ChapterContentCache
|
||||||
|
|
||||||
|
public init(loader: any ChapterContentLoading = NetworkBookService(), cache: ChapterContentCache = .live()) {
|
||||||
|
self.loader = loader
|
||||||
|
self.cache = cache
|
||||||
|
}
|
||||||
|
|
||||||
|
public func load(
|
||||||
|
book: Book,
|
||||||
|
chapter: BookChapter,
|
||||||
|
source: BookSource,
|
||||||
|
nextChapterURL: String? = nil,
|
||||||
|
maxPages: Int = 20,
|
||||||
|
forceRefresh: Bool = false
|
||||||
|
) async -> CoordinatedChapterContent {
|
||||||
|
if forceRefresh {
|
||||||
|
try? await cache.remove(bookURL: book.bookUrl, chapterURL: chapter.url)
|
||||||
|
} else if let cached = try? await cache.load(bookURL: book.bookUrl, chapterURL: chapter.url),
|
||||||
|
cached.sourceID == nil || cached.sourceID == source.id {
|
||||||
|
return .init(report: .init(
|
||||||
|
title: cached.title,
|
||||||
|
content: cached.content,
|
||||||
|
supplementalContent: cached.supplementalContent
|
||||||
|
), loadedFromCache: true)
|
||||||
|
}
|
||||||
|
let report = await loader.loadContent(
|
||||||
|
book: book,
|
||||||
|
chapter: chapter,
|
||||||
|
source: source,
|
||||||
|
nextChapterURL: nextChapterURL,
|
||||||
|
maxPages: maxPages
|
||||||
|
)
|
||||||
|
await saveContent(report, book: book, chapter: chapter, source: source)
|
||||||
|
return .init(report: report, loadedFromCache: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func saveContent(_ report: ChapterContentReport, book: Book, chapter: BookChapter, source: BookSource) async {
|
||||||
|
if !report.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
try? await cache.save(.init(
|
||||||
|
bookURL: book.bookUrl,
|
||||||
|
chapterURL: chapter.url,
|
||||||
|
title: report.title,
|
||||||
|
content: report.content,
|
||||||
|
supplementalContent: report.supplementalContent,
|
||||||
|
sourceID: source.id
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func saveEditedContent(book: Book, chapter: BookChapter, title: String?, content: String) async throws {
|
||||||
|
let value = content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !value.isEmpty else { throw ChapterContentCacheError.invalidEntry }
|
||||||
|
try await cache.save(.init(
|
||||||
|
bookURL: book.bookUrl,
|
||||||
|
chapterURL: chapter.url,
|
||||||
|
title: title,
|
||||||
|
content: content,
|
||||||
|
sourceID: book.origin
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
public func prefetch(
|
||||||
|
book: Book,
|
||||||
|
chapter: BookChapter,
|
||||||
|
source: BookSource,
|
||||||
|
nextChapterURL: String? = nil
|
||||||
|
) async -> Bool {
|
||||||
|
if let cached = try? await cache.load(bookURL: book.bookUrl, chapterURL: chapter.url),
|
||||||
|
cached.sourceID == nil || cached.sourceID == source.id { return false }
|
||||||
|
let result = await load(book: book, chapter: chapter, source: source, nextChapterURL: nextChapterURL)
|
||||||
|
return !result.report.content.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
public func invalidate(bookURL: String) async {
|
||||||
|
try? await cache.remove(bookURL: bookURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftSoup
|
||||||
|
|
||||||
|
public struct MetadataTextSanitizer: Sendable {
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
public func clean(_ value: String?) -> String? {
|
||||||
|
guard var value else { return nil }
|
||||||
|
value = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !value.isEmpty else { return nil }
|
||||||
|
|
||||||
|
do {
|
||||||
|
value = replacingBlockTagsWithNewlines(value)
|
||||||
|
let document = try SwiftSoup.parseBodyFragment(value)
|
||||||
|
try document.select("script,style,noscript").remove()
|
||||||
|
value = try document.body()?.text(trimAndNormaliseWhitespace: false) ?? document.text()
|
||||||
|
} catch {
|
||||||
|
value = value.replacingOccurrences(
|
||||||
|
of: #"<[^>]+>"#,
|
||||||
|
with: "",
|
||||||
|
options: [.regularExpression, .caseInsensitive]
|
||||||
|
)
|
||||||
|
value = (try? Parser.unescapeEntities(value, false)) ?? value
|
||||||
|
}
|
||||||
|
|
||||||
|
value = value
|
||||||
|
.replacingOccurrences(of: "\r\n", with: "\n")
|
||||||
|
.replacingOccurrences(of: "\r", with: "\n")
|
||||||
|
.replacingOccurrences(of: "\u{00A0}", with: " ")
|
||||||
|
.replacingOccurrences(of: "\u{200B}", with: "")
|
||||||
|
let output = value.components(separatedBy: .newlines)
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
let result = output.joined(separator: "\n")
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return result.isEmpty ? nil : result
|
||||||
|
}
|
||||||
|
|
||||||
|
private func replacingBlockTagsWithNewlines(_ value: String) -> String {
|
||||||
|
value.replacingOccurrences(
|
||||||
|
of: #"(?i)<\s*br\s*/?\s*>|<\s*/?\s*(?:p|div|li|ul|ol|section|article|blockquote|h[1-6])\b[^>]*>"#,
|
||||||
|
with: "\n",
|
||||||
|
options: .regularExpression
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChapterHTMLFormatter {
|
||||||
|
func format(_ html: String, baseURL: URL) -> String {
|
||||||
|
guard let expression = try? NSRegularExpression(pattern: #"(?i)<img\b[^>]*>"#) else {
|
||||||
|
return MetadataTextSanitizer().clean(html) ?? ""
|
||||||
|
}
|
||||||
|
var protected = html
|
||||||
|
var images: [String: String] = [:]
|
||||||
|
let token = UUID().uuidString
|
||||||
|
for (index, match) in expression.matches(in: html, range: NSRange(html.startIndex..., in: html)).enumerated().reversed() {
|
||||||
|
guard let range = Range(match.range, in: html),
|
||||||
|
let element = try? SwiftSoup.parseBodyFragment(String(html[range])).select("img").first() else { continue }
|
||||||
|
let src = ["data-src", "data-original", "data-srcset", "src"]
|
||||||
|
.compactMap { try? element.attr($0) }.first { !$0.isEmpty } ?? ""
|
||||||
|
let marker = "LEGADO_IMAGE_\(token)_\(index)"
|
||||||
|
let url = LegadoURLBuilder.resolve(src, against: baseURL)
|
||||||
|
.replacingOccurrences(of: "&", with: "&")
|
||||||
|
.replacingOccurrences(of: "\"", with: """)
|
||||||
|
images[marker] = src.isEmpty ? "" : "<img src=\"\(url)\">"
|
||||||
|
protected.replaceSubrange(range, with: "\n\(marker)\n")
|
||||||
|
}
|
||||||
|
var result = MetadataTextSanitizer().clean(protected) ?? ""
|
||||||
|
for (marker, image) in images { result = result.replacingOccurrences(of: marker, with: image) }
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ContentProcessingReport: Sendable {
|
||||||
|
public var title: String
|
||||||
|
public var content: String
|
||||||
|
public var effectiveRuleIDs: [Int64]
|
||||||
|
public var removedDuplicateTitle: Bool
|
||||||
|
public var diagnostics: [RuleDiagnostic]
|
||||||
|
|
||||||
|
public init(
|
||||||
|
title: String,
|
||||||
|
content: String,
|
||||||
|
effectiveRuleIDs: [Int64] = [],
|
||||||
|
removedDuplicateTitle: Bool = false,
|
||||||
|
diagnostics: [RuleDiagnostic] = []
|
||||||
|
) {
|
||||||
|
self.title = title
|
||||||
|
self.content = content
|
||||||
|
self.effectiveRuleIDs = effectiveRuleIDs
|
||||||
|
self.removedDuplicateTitle = removedDuplicateTitle
|
||||||
|
self.diagnostics = diagnostics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ContentSanitizer: Sendable {
|
||||||
|
private let regex: RegexEvaluator
|
||||||
|
|
||||||
|
public init(regex: RegexEvaluator = .init()) {
|
||||||
|
self.regex = regex
|
||||||
|
}
|
||||||
|
|
||||||
|
public func process(
|
||||||
|
book: Book,
|
||||||
|
chapter: BookChapter,
|
||||||
|
content: String,
|
||||||
|
rules: [ReplaceRule],
|
||||||
|
removeDuplicateTitle: Bool = true,
|
||||||
|
resegment: Bool = true
|
||||||
|
) -> ContentProcessingReport {
|
||||||
|
let ordered = rules.filter(\.isEnabled).sorted { $0.sortOrder == $1.sortOrder ? $0.id < $1.id : $0.sortOrder < $1.sortOrder }
|
||||||
|
var title = chapter.title
|
||||||
|
var body = content
|
||||||
|
var effective: [Int64] = []
|
||||||
|
var diagnostics: [RuleDiagnostic] = []
|
||||||
|
|
||||||
|
for rule in ordered where rule.scopeTitle && applies(rule, to: book) {
|
||||||
|
let result = apply(rule, to: title)
|
||||||
|
diagnostics += result.diagnostics
|
||||||
|
if result.value != title {
|
||||||
|
title = result.value
|
||||||
|
effective.append(rule.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var removed = false
|
||||||
|
if removeDuplicateTitle {
|
||||||
|
let lines = body.components(separatedBy: .newlines)
|
||||||
|
if let firstIndex = lines.firstIndex(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) {
|
||||||
|
let normalizedLine = normalize(lines[firstIndex])
|
||||||
|
let normalizedTitles = [chapter.title, title, book.name + chapter.title].map(normalize)
|
||||||
|
if normalizedTitles.contains(normalizedLine) {
|
||||||
|
var updated = lines
|
||||||
|
updated.remove(at: firstIndex)
|
||||||
|
body = updated.joined(separator: "\n")
|
||||||
|
removed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if resegment {
|
||||||
|
body = body.components(separatedBy: .newlines)
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines.union(CharacterSet(charactersIn: " "))) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
for rule in ordered where rule.scopeContent && applies(rule, to: book) {
|
||||||
|
let result = apply(rule, to: body)
|
||||||
|
diagnostics += result.diagnostics
|
||||||
|
if result.value != body {
|
||||||
|
body = result.value
|
||||||
|
effective.append(rule.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var seen = Set<Int64>()
|
||||||
|
effective = effective.filter { seen.insert($0).inserted }
|
||||||
|
return .init(
|
||||||
|
title: title,
|
||||||
|
content: body,
|
||||||
|
effectiveRuleIDs: effective,
|
||||||
|
removedDuplicateTitle: removed,
|
||||||
|
diagnostics: diagnostics
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applies(_ rule: ReplaceRule, to book: Book) -> Bool {
|
||||||
|
if let excluded = rule.excludeScope, !excluded.isEmpty,
|
||||||
|
(!book.name.isEmpty && excluded.contains(book.name)) || (!book.origin.isEmpty && excluded.contains(book.origin)) { return false }
|
||||||
|
guard let scope = rule.scope, !scope.isEmpty else { return true }
|
||||||
|
return (!book.name.isEmpty && scope.contains(book.name)) || (!book.origin.isEmpty && scope.contains(book.origin))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func apply(_ rule: ReplaceRule, to input: String) -> (value: String, diagnostics: [RuleDiagnostic]) {
|
||||||
|
guard !rule.pattern.isEmpty else { return (input, []) }
|
||||||
|
do {
|
||||||
|
if rule.isRegex {
|
||||||
|
return (try regex.replacingStrict(pattern: rule.pattern, in: input, with: rule.replacement, timeoutMilliseconds: rule.timeoutMillisecond), [])
|
||||||
|
}
|
||||||
|
return (input.replacingOccurrences(of: rule.pattern, with: rule.replacement), [])
|
||||||
|
} catch let error as RuleEngineError {
|
||||||
|
return (input, [error.diagnostic])
|
||||||
|
} catch {
|
||||||
|
return (input, [.init(
|
||||||
|
severity: .error,
|
||||||
|
code: "replace.failed",
|
||||||
|
message: "净化规则“\(rule.name)”执行失败:\(error.localizedDescription)",
|
||||||
|
rule: rule.pattern
|
||||||
|
)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func normalize(_ value: String) -> String {
|
||||||
|
value.unicodeScalars.filter {
|
||||||
|
!CharacterSet.whitespacesAndNewlines.contains($0) && !CharacterSet.punctuationCharacters.contains($0)
|
||||||
|
}.map(String.init).joined().lowercased()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,496 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct BookDetailReport: Sendable {
|
||||||
|
public var book: Book
|
||||||
|
public var diagnostics: [RuleDiagnostic]
|
||||||
|
|
||||||
|
public init(book: Book, diagnostics: [RuleDiagnostic] = []) {
|
||||||
|
self.book = book
|
||||||
|
self.diagnostics = diagnostics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ChapterListReport: Sendable {
|
||||||
|
public var chapters: [BookChapter]
|
||||||
|
public var diagnostics: [RuleDiagnostic]
|
||||||
|
public var pagesVisited: Int
|
||||||
|
|
||||||
|
public init(chapters: [BookChapter], diagnostics: [RuleDiagnostic] = [], pagesVisited: Int = 0) {
|
||||||
|
self.chapters = chapters
|
||||||
|
self.diagnostics = diagnostics
|
||||||
|
self.pagesVisited = pagesVisited
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ChapterContentReport: Sendable {
|
||||||
|
public var title: String?
|
||||||
|
public var content: String
|
||||||
|
public var supplementalContent: String?
|
||||||
|
public var diagnostics: [RuleDiagnostic]
|
||||||
|
public var pagesVisited: Int
|
||||||
|
|
||||||
|
public init(
|
||||||
|
title: String? = nil,
|
||||||
|
content: String = "",
|
||||||
|
supplementalContent: String? = nil,
|
||||||
|
diagnostics: [RuleDiagnostic] = [],
|
||||||
|
pagesVisited: Int = 0
|
||||||
|
) {
|
||||||
|
self.title = title
|
||||||
|
self.content = content
|
||||||
|
self.supplementalContent = supplementalContent
|
||||||
|
self.diagnostics = diagnostics
|
||||||
|
self.pagesVisited = pagesVisited
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct NetworkBookService: Sendable {
|
||||||
|
private let transport: any HTTPTransport
|
||||||
|
private let urlBuilder: LegadoURLBuilder
|
||||||
|
private let engine: RuleEngine
|
||||||
|
private let networkPolicy: SourceNetworkPolicy
|
||||||
|
private let webViewExecutor: (any SourceWebViewRequestExecuting)?
|
||||||
|
private let snapshotRecorder: (any DifferentialSnapshotRecording)?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
transport: any HTTPTransport = URLSessionHTTPTransport(),
|
||||||
|
urlBuilder: LegadoURLBuilder = .init(),
|
||||||
|
engine: RuleEngine = .init(),
|
||||||
|
networkPolicy: SourceNetworkPolicy = .shared,
|
||||||
|
webViewExecutor: (any SourceWebViewRequestExecuting)? = nil,
|
||||||
|
snapshotRecorder: (any DifferentialSnapshotRecording)? = nil
|
||||||
|
) {
|
||||||
|
self.transport = transport
|
||||||
|
self.urlBuilder = urlBuilder
|
||||||
|
self.engine = engine
|
||||||
|
self.networkPolicy = networkPolicy
|
||||||
|
self.webViewExecutor = webViewExecutor
|
||||||
|
self.snapshotRecorder = snapshotRecorder
|
||||||
|
}
|
||||||
|
|
||||||
|
public func loadDetail(searchBook: SearchBook, source: BookSource) async -> BookDetailReport {
|
||||||
|
var book = Book(
|
||||||
|
bookUrl: searchBook.bookURL,
|
||||||
|
name: searchBook.name,
|
||||||
|
author: searchBook.author,
|
||||||
|
origin: source.bookSourceUrl
|
||||||
|
)
|
||||||
|
book.originName = source.bookSourceName
|
||||||
|
book.type = searchBook.type
|
||||||
|
book.kind = searchBook.kind
|
||||||
|
book.coverUrl = searchBook.coverURL
|
||||||
|
book.intro = MetadataTextSanitizer().clean(searchBook.intro)
|
||||||
|
book.wordCount = searchBook.wordCount
|
||||||
|
book.latestChapterTitle = searchBook.latestChapterTitle
|
||||||
|
book.tocUrl = searchBook.tocURL
|
||||||
|
book.originOrder = searchBook.originOrder
|
||||||
|
book.variable = encodeVariables(searchBook.variables)
|
||||||
|
|
||||||
|
guard let rules = source.ruleBookInfo else {
|
||||||
|
return .init(book: book, diagnostics: [diagnostic(
|
||||||
|
code: "detail.configuration",
|
||||||
|
message: "书源缺少详情规则",
|
||||||
|
rule: searchBook.bookURL
|
||||||
|
)])
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let response = try await request(
|
||||||
|
searchBook.bookURL,
|
||||||
|
source: source,
|
||||||
|
stage: .detail,
|
||||||
|
context: .init(variables: searchBook.variables, source: source, book: book)
|
||||||
|
)
|
||||||
|
var document = PageDocument(response: response)
|
||||||
|
var diagnostics: [RuleDiagnostic] = []
|
||||||
|
var execution = RuleExecutionContext(
|
||||||
|
variables: searchBook.variables.merging(response.requestVariables) { _, requestValue in requestValue },
|
||||||
|
baseURL: response.finalURL.absoluteString,
|
||||||
|
source: source,
|
||||||
|
book: book
|
||||||
|
)
|
||||||
|
if let initialRule = rules.`init`, !initialRule.isEmpty {
|
||||||
|
let initial = await evaluate(initialRule, in: document, listMode: true, context: execution)
|
||||||
|
diagnostics += initial.diagnostics
|
||||||
|
execution.variables = initial.variables
|
||||||
|
if let first = initial.values.first {
|
||||||
|
document = .init(value: first, baseURL: document.baseURL, isFragment: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = await evaluate(rules.name, in: document, context: execution)
|
||||||
|
execution.variables = name.variables
|
||||||
|
let author = await evaluate(rules.author, in: document, context: execution)
|
||||||
|
execution.variables = author.variables
|
||||||
|
let kind = await evaluate(rules.kind, in: document, context: execution)
|
||||||
|
execution.variables = kind.variables
|
||||||
|
let intro = await evaluate(rules.intro, in: document, context: execution)
|
||||||
|
execution.variables = intro.variables
|
||||||
|
let lastChapter = await evaluate(rules.lastChapter, in: document, context: execution)
|
||||||
|
execution.variables = lastChapter.variables
|
||||||
|
let cover = await evaluate(rules.coverUrl, in: document, context: execution)
|
||||||
|
execution.variables = cover.variables
|
||||||
|
let toc = await evaluate(rules.tocUrl, in: document, context: execution)
|
||||||
|
execution.variables = toc.variables
|
||||||
|
let wordCount = await evaluate(rules.wordCount, in: document, context: execution)
|
||||||
|
execution.variables = wordCount.variables
|
||||||
|
let evaluations = [name, author, kind, intro, lastChapter, cover, toc, wordCount]
|
||||||
|
diagnostics += evaluations.flatMap(\.diagnostics)
|
||||||
|
|
||||||
|
book.name = nonempty(name.joinedString) ?? book.name
|
||||||
|
book.author = nonempty(author.joinedString) ?? book.author
|
||||||
|
book.kind = nonempty(kind.strings.filter { !$0.isEmpty }.joined(separator: ",")) ?? book.kind
|
||||||
|
book.intro = MetadataTextSanitizer().clean(intro.joinedString) ?? book.intro
|
||||||
|
book.latestChapterTitle = nonempty(lastChapter.joinedString) ?? book.latestChapterTitle
|
||||||
|
book.wordCount = nonempty(wordCount.joinedString) ?? book.wordCount
|
||||||
|
if let value = nonempty(cover.joinedString) {
|
||||||
|
book.coverUrl = resolve(value, against: response.finalURL)
|
||||||
|
}
|
||||||
|
let tocValue = document.isHTML ? toc.leadingString : toc.joinedString
|
||||||
|
book.tocUrl = nonempty(tocValue).map { resolve($0, against: response.finalURL) }
|
||||||
|
?? nonempty(book.tocUrl)
|
||||||
|
?? response.finalURL.absoluteString
|
||||||
|
book.variable = encodeVariables(execution.variables)
|
||||||
|
return .init(book: book, diagnostics: diagnostics)
|
||||||
|
} catch {
|
||||||
|
return .init(book: book, diagnostics: [diagnostic(error, stage: "detail", rule: searchBook.bookURL)])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func loadChapters(
|
||||||
|
book: Book,
|
||||||
|
source: BookSource,
|
||||||
|
maxPages: Int = 20
|
||||||
|
) async -> ChapterListReport {
|
||||||
|
guard let rules = source.ruleToc,
|
||||||
|
var listRule = nonempty(rules.chapterList) else {
|
||||||
|
return .init(chapters: [], diagnostics: [diagnostic(
|
||||||
|
code: "toc.configuration",
|
||||||
|
message: "书源缺少目录列表规则",
|
||||||
|
rule: book.tocUrl
|
||||||
|
)])
|
||||||
|
}
|
||||||
|
let reverse = listRule.hasPrefix("-")
|
||||||
|
if listRule.hasPrefix("-") || listRule.hasPrefix("+") { listRule.removeFirst() }
|
||||||
|
let firstURL = nonempty(book.tocUrl) ?? book.bookUrl
|
||||||
|
var queue = [firstURL]
|
||||||
|
var visited = Set<String>()
|
||||||
|
var requested = Set<String>()
|
||||||
|
var chapters: [BookChapter] = []
|
||||||
|
var diagnostics: [RuleDiagnostic] = []
|
||||||
|
var sharedContext = RuleExecutionContext(
|
||||||
|
variables: decodeVariables(book.variable),
|
||||||
|
source: source,
|
||||||
|
book: book
|
||||||
|
)
|
||||||
|
|
||||||
|
while let pageURL = queue.first, visited.count < max(maxPages, 1) {
|
||||||
|
queue.removeFirst()
|
||||||
|
guard requested.insert(pageURL).inserted else { continue }
|
||||||
|
do {
|
||||||
|
let response = try await request(
|
||||||
|
pageURL,
|
||||||
|
source: source,
|
||||||
|
stage: .toc,
|
||||||
|
baseURL: URL(string: book.bookUrl),
|
||||||
|
context: .init(variables: sharedContext.variables, source: source, book: book)
|
||||||
|
)
|
||||||
|
let canonicalURL = response.finalURL.absoluteString
|
||||||
|
guard visited.insert(pageIdentity(pageURL, finalURL: canonicalURL)).inserted else { continue }
|
||||||
|
let document = PageDocument(response: response)
|
||||||
|
var pageContext = sharedContext
|
||||||
|
pageContext.variables.merge(response.requestVariables) { _, requestValue in requestValue }
|
||||||
|
pageContext.baseURL = document.baseURL
|
||||||
|
let list = await evaluate(listRule, in: document, listMode: true, context: pageContext)
|
||||||
|
diagnostics += list.diagnostics
|
||||||
|
pageContext.variables = list.variables
|
||||||
|
for item in list.listItems(rule: listRule) {
|
||||||
|
try Task.checkCancellation()
|
||||||
|
let fragment = PageDocument(value: item, baseURL: document.baseURL, isFragment: true)
|
||||||
|
var itemContext = pageContext
|
||||||
|
let name = await evaluate(rules.chapterName, in: fragment, context: itemContext)
|
||||||
|
itemContext.variables = name.variables
|
||||||
|
let url = await evaluate(rules.chapterUrl, in: fragment, context: itemContext)
|
||||||
|
itemContext.variables = url.variables
|
||||||
|
let volume = await evaluate(rules.isVolume, in: fragment, context: itemContext)
|
||||||
|
itemContext.variables = volume.variables
|
||||||
|
let vip = await evaluate(rules.isVip, in: fragment, context: itemContext)
|
||||||
|
itemContext.variables = vip.variables
|
||||||
|
let pay = await evaluate(rules.isPay, in: fragment, context: itemContext)
|
||||||
|
itemContext.variables = pay.variables
|
||||||
|
let info = await evaluate(rules.updateTime, in: fragment, context: itemContext)
|
||||||
|
itemContext.variables = info.variables
|
||||||
|
diagnostics += [name, url, volume, vip, pay, info].flatMap(\.diagnostics)
|
||||||
|
guard let title = nonempty(name.joinedString) else { continue }
|
||||||
|
let isVolume = truthy(volume.joinedString)
|
||||||
|
let rawURL = nonempty(fragment.isHTML ? url.leadingString : url.joinedString)
|
||||||
|
?? (isVolume ? "#volume-\(chapters.count)" : canonicalURL)
|
||||||
|
var chapter = BookChapter(
|
||||||
|
url: resolve(rawURL, against: response.finalURL),
|
||||||
|
title: title,
|
||||||
|
bookUrl: book.bookUrl,
|
||||||
|
index: chapters.count
|
||||||
|
)
|
||||||
|
chapter.baseUrl = canonicalURL
|
||||||
|
chapter.isVolume = isVolume
|
||||||
|
chapter.isVip = truthy(vip.joinedString)
|
||||||
|
chapter.isPay = truthy(pay.joinedString)
|
||||||
|
chapter.tag = nonempty(info.joinedString)
|
||||||
|
chapter.variable = encodeVariables(itemContext.variables)
|
||||||
|
chapters.append(chapter)
|
||||||
|
}
|
||||||
|
let next = await evaluate(rules.nextTocUrl, in: document, context: pageContext)
|
||||||
|
diagnostics += next.diagnostics
|
||||||
|
sharedContext.variables = next.variables
|
||||||
|
for value in next.strings where !value.isEmpty {
|
||||||
|
let absolute = resolve(value, against: response.finalURL)
|
||||||
|
if !requested.contains(absolute), !queue.contains(absolute) { queue.append(absolute) }
|
||||||
|
}
|
||||||
|
} catch is CancellationError {
|
||||||
|
diagnostics.append(diagnostic(code: "toc.cancelled", message: "目录加载已取消", rule: pageURL))
|
||||||
|
break
|
||||||
|
} catch {
|
||||||
|
diagnostics.append(diagnostic(error, stage: "toc", rule: pageURL))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if reverse { chapters.reverse() }
|
||||||
|
var unique = Set<String>()
|
||||||
|
chapters = chapters.filter { unique.insert($0.url).inserted }
|
||||||
|
for index in chapters.indices { chapters[index].index = index }
|
||||||
|
if chapters.isEmpty {
|
||||||
|
diagnostics.append(diagnostic(code: "toc.empty", message: "目录规则未解析到章节", rule: listRule))
|
||||||
|
}
|
||||||
|
return .init(chapters: chapters, diagnostics: diagnostics, pagesVisited: visited.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func loadContent(
|
||||||
|
book: Book,
|
||||||
|
chapter: BookChapter,
|
||||||
|
source: BookSource,
|
||||||
|
nextChapterURL: String? = nil,
|
||||||
|
maxPages: Int = 20
|
||||||
|
) async -> ChapterContentReport {
|
||||||
|
guard let rules = source.ruleContent,
|
||||||
|
let contentRule = nonempty(rules.content) else {
|
||||||
|
return .init(diagnostics: [diagnostic(
|
||||||
|
code: "content.configuration",
|
||||||
|
message: "书源缺少正文规则",
|
||||||
|
rule: chapter.url
|
||||||
|
)])
|
||||||
|
}
|
||||||
|
var queue = [chapter.url]
|
||||||
|
var visited = Set<String>()
|
||||||
|
var requested = Set<String>()
|
||||||
|
var contents: [String] = []
|
||||||
|
var supplemental: String?
|
||||||
|
var title: String?
|
||||||
|
var diagnostics: [RuleDiagnostic] = []
|
||||||
|
var contentVariables = decodeVariables(book.variable)
|
||||||
|
contentVariables.merge(decodeVariables(chapter.variable)) { _, chapterValue in chapterValue }
|
||||||
|
let stopURL = nextChapterURL.map { value in
|
||||||
|
URL(string: chapter.baseUrl).map { resolve(value, against: $0) } ?? value
|
||||||
|
}
|
||||||
|
|
||||||
|
while let pageURL = queue.first, visited.count < max(maxPages, 1) {
|
||||||
|
queue.removeFirst()
|
||||||
|
guard pageURL != stopURL, requested.insert(pageURL).inserted else { continue }
|
||||||
|
do {
|
||||||
|
let response = try await request(
|
||||||
|
pageURL,
|
||||||
|
source: source,
|
||||||
|
stage: .content,
|
||||||
|
baseURL: URL(string: chapter.baseUrl),
|
||||||
|
context: .init(
|
||||||
|
variables: contentVariables,
|
||||||
|
source: source,
|
||||||
|
book: book,
|
||||||
|
chapter: chapter
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let canonicalURL = response.finalURL.absoluteString
|
||||||
|
guard canonicalURL != stopURL,
|
||||||
|
visited.insert(pageIdentity(pageURL, finalURL: canonicalURL)).inserted else { continue }
|
||||||
|
let document = PageDocument(response: response)
|
||||||
|
var execution = RuleExecutionContext(
|
||||||
|
variables: contentVariables.merging(response.requestVariables) { _, requestValue in requestValue },
|
||||||
|
baseURL: canonicalURL,
|
||||||
|
source: source,
|
||||||
|
book: book,
|
||||||
|
chapter: chapter
|
||||||
|
)
|
||||||
|
let content = await evaluate(contentRule, in: document, context: execution)
|
||||||
|
diagnostics += content.diagnostics
|
||||||
|
execution.variables = content.variables
|
||||||
|
contentVariables = execution.variables
|
||||||
|
var pageContent = content.strings.filter { !$0.isEmpty }.joined(separator: "\n")
|
||||||
|
if source.bookSourceType == 0 {
|
||||||
|
pageContent = ChapterHTMLFormatter().format(pageContent, baseURL: response.finalURL)
|
||||||
|
}
|
||||||
|
if !pageContent.isEmpty { contents.append(pageContent) }
|
||||||
|
if title == nil {
|
||||||
|
let value = await evaluate(rules.title, in: document, context: execution)
|
||||||
|
diagnostics += value.diagnostics
|
||||||
|
execution.variables = value.variables
|
||||||
|
contentVariables = execution.variables
|
||||||
|
title = nonempty(value.joinedString)
|
||||||
|
}
|
||||||
|
if supplemental == nil {
|
||||||
|
let value = await evaluate(rules.subContent, in: document, context: execution)
|
||||||
|
diagnostics += value.diagnostics
|
||||||
|
execution.variables = value.variables
|
||||||
|
contentVariables = execution.variables
|
||||||
|
supplemental = nonempty(value.strings.filter { !$0.isEmpty }.joined(separator: "\n"))
|
||||||
|
}
|
||||||
|
let next = await evaluate(rules.nextContentUrl, in: document, context: execution)
|
||||||
|
diagnostics += next.diagnostics
|
||||||
|
contentVariables = next.variables
|
||||||
|
for value in next.strings where !value.isEmpty {
|
||||||
|
let absolute = resolve(value, against: response.finalURL)
|
||||||
|
if absolute != stopURL, !requested.contains(absolute), !queue.contains(absolute) {
|
||||||
|
queue.append(absolute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch is CancellationError {
|
||||||
|
diagnostics.append(diagnostic(code: "content.cancelled", message: "正文加载已取消", rule: pageURL))
|
||||||
|
break
|
||||||
|
} catch {
|
||||||
|
diagnostics.append(diagnostic(error, stage: "content", rule: pageURL))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var content = contents.joined(separator: "\n")
|
||||||
|
if let replacement = nonempty(rules.replaceRegex) {
|
||||||
|
let replaced = await engine.evaluate(
|
||||||
|
replacement, inText: content,
|
||||||
|
context: .init(variables: contentVariables, baseURL: chapter.baseUrl, source: source, book: book, chapter: chapter)
|
||||||
|
)
|
||||||
|
diagnostics += replaced.diagnostics
|
||||||
|
if !replaced.diagnostics.contains(where: { $0.severity == .error }) {
|
||||||
|
content = replaced.strings.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !chapter.isVolume {
|
||||||
|
diagnostics.append(diagnostic(code: "content.empty", message: "正文规则未解析到内容", rule: contentRule))
|
||||||
|
}
|
||||||
|
return .init(
|
||||||
|
title: title,
|
||||||
|
content: content,
|
||||||
|
supplementalContent: supplemental,
|
||||||
|
diagnostics: diagnostics,
|
||||||
|
pagesVisited: visited.count
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func request(
|
||||||
|
_ value: String,
|
||||||
|
source: BookSource,
|
||||||
|
stage: DifferentialStage,
|
||||||
|
baseURL: URL? = nil,
|
||||||
|
context: URLTemplateContext? = nil
|
||||||
|
) async throws -> HTTPResponse {
|
||||||
|
let prepared = try urlBuilder.prepare(
|
||||||
|
value,
|
||||||
|
context: context ?? .init(source: source),
|
||||||
|
baseURL: baseURL ?? URL(string: source.bookSourceUrl),
|
||||||
|
defaultHeaders: try BookSourceHeaderResolver().resolve(source)
|
||||||
|
)
|
||||||
|
let timeout = min(max(Double(source.respondTime) / 1_000, 5), 180)
|
||||||
|
return try await networkPolicy.execute(
|
||||||
|
prepared,
|
||||||
|
source: source,
|
||||||
|
transport: transport,
|
||||||
|
timeout: timeout,
|
||||||
|
webViewExecutor: webViewExecutor,
|
||||||
|
snapshotRecorder: snapshotRecorder,
|
||||||
|
snapshotStage: stage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func evaluate(
|
||||||
|
_ rule: String?,
|
||||||
|
in document: PageDocument,
|
||||||
|
listMode: Bool = false,
|
||||||
|
context: RuleExecutionContext = .init()
|
||||||
|
) async -> RuleEvaluation {
|
||||||
|
guard let rule = nonempty(rule) else { return .init(variables: context.variables) }
|
||||||
|
var context = context
|
||||||
|
context.isHTMLFragment = document.isFragment
|
||||||
|
if document.isHTML {
|
||||||
|
return await engine.evaluate(
|
||||||
|
rule,
|
||||||
|
inText: document.value.stringValue,
|
||||||
|
baseURL: document.baseURL,
|
||||||
|
listMode: listMode,
|
||||||
|
context: context
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return await engine.evaluate(rule, in: document.value, listMode: listMode, context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolve(_ value: String, against baseURL: URL) -> String {
|
||||||
|
LegadoURLBuilder.resolve(value, against: baseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func pageIdentity(_ request: String, finalURL: String) -> String {
|
||||||
|
guard let range = request.range(of: #",\s*(?=\{)"#, options: .regularExpression) else { return finalURL }
|
||||||
|
return finalURL + request[range.lowerBound...]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func truthy(_ value: String?) -> Bool {
|
||||||
|
guard let value else { return false }
|
||||||
|
return ["true", "1", "yes", "是", "vip", "paid"].contains(value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func encodeVariables(_ variables: [String: String]) -> String? {
|
||||||
|
guard !variables.isEmpty,
|
||||||
|
let data = try? JSONEncoder().encode(variables) else { return nil }
|
||||||
|
return String(data: data, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decodeVariables(_ source: String?) -> [String: String] {
|
||||||
|
guard let source, let data = source.data(using: .utf8),
|
||||||
|
let variables = try? JSONDecoder().decode([String: String].self, from: data) else { return [:] }
|
||||||
|
return variables
|
||||||
|
}
|
||||||
|
|
||||||
|
private func nonempty(_ value: String?) -> String? {
|
||||||
|
guard let value, !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
private func diagnostic(_ error: Error, stage: String, rule: String) -> RuleDiagnostic {
|
||||||
|
if let error = error as? RuleEngineError { return error.diagnostic }
|
||||||
|
return diagnostic(code: "\(stage).request", message: error.localizedDescription, rule: rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func diagnostic(code: String, message: String, rule: String) -> RuleDiagnostic {
|
||||||
|
.init(severity: .error, code: code, message: message, rule: rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct PageDocument: Sendable {
|
||||||
|
var value: JSONValue
|
||||||
|
var isHTML: Bool
|
||||||
|
var baseURL: String
|
||||||
|
var isFragment = false
|
||||||
|
|
||||||
|
init(response: HTTPResponse) {
|
||||||
|
if let json = response.decodedJSON {
|
||||||
|
value = json
|
||||||
|
isHTML = false
|
||||||
|
} else {
|
||||||
|
value = .string(response.decodedText)
|
||||||
|
isHTML = true
|
||||||
|
}
|
||||||
|
baseURL = response.finalURL.absoluteString
|
||||||
|
}
|
||||||
|
|
||||||
|
init(value: JSONValue, baseURL: String, isFragment: Bool = false) {
|
||||||
|
self.value = value
|
||||||
|
if case .string = value { self.isHTML = true }
|
||||||
|
else { self.isHTML = false }
|
||||||
|
self.baseURL = baseURL
|
||||||
|
self.isFragment = isFragment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum ReplaceRuleValidator {
|
||||||
|
public static func validate(_ rule: ReplaceRule) throws {
|
||||||
|
guard !rule.pattern.isEmpty else { throw ReplaceRuleImportError.emptyPattern(rule.name) }
|
||||||
|
guard rule.scopeTitle || rule.scopeContent else { throw ReplaceRuleImportError.invalidDocument("至少选择标题或正文一个作用域") }
|
||||||
|
if rule.isRegex {
|
||||||
|
do { _ = try NSRegularExpression(pattern: rule.pattern) }
|
||||||
|
catch { throw ReplaceRuleImportError.invalidDocument("正则语法错误:\(error.localizedDescription)") }
|
||||||
|
if rule.pattern.hasSuffix("|"), !rule.pattern.hasSuffix("\\|") {
|
||||||
|
throw ReplaceRuleImportError.invalidDocument("正则不能以空分支 | 结尾")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ReplaceRuleTestResult: Sendable {
|
||||||
|
public let report: ContentProcessingReport
|
||||||
|
public let elapsedMilliseconds: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ReplaceRuleTester: Sendable {
|
||||||
|
public init() {}
|
||||||
|
public func test(rule: ReplaceRule, title: String, content: String, bookName: String, sourceURL: String) throws -> ReplaceRuleTestResult {
|
||||||
|
try ReplaceRuleValidator.validate(rule)
|
||||||
|
let book = Book(bookUrl: "test", name: bookName, origin: sourceURL)
|
||||||
|
let chapter = BookChapter(url: "chapter", title: title, bookUrl: book.bookUrl, index: 0)
|
||||||
|
let started = DispatchTime.now().uptimeNanoseconds
|
||||||
|
let report = ContentSanitizer().process(book: book, chapter: chapter, content: content,
|
||||||
|
rules: [rule], removeDuplicateTitle: false, resegment: false)
|
||||||
|
return .init(report: report, elapsedMilliseconds: Double(DispatchTime.now().uptimeNanoseconds - started) / 1_000_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum SourceDebugStage: String, Codable, CaseIterable, Sendable {
|
||||||
|
case configuration
|
||||||
|
case search
|
||||||
|
case detail
|
||||||
|
case toc
|
||||||
|
case content
|
||||||
|
|
||||||
|
public var title: String {
|
||||||
|
switch self {
|
||||||
|
case .configuration: "配置"
|
||||||
|
case .search: "搜索"
|
||||||
|
case .detail: "详情"
|
||||||
|
case .toc: "目录"
|
||||||
|
case .content: "正文"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum SourceDebugStatus: String, Codable, Sendable {
|
||||||
|
case passed
|
||||||
|
case warning
|
||||||
|
case failed
|
||||||
|
case skipped
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SourceDebugStep: Codable, Hashable, Identifiable, Sendable {
|
||||||
|
public var stage: SourceDebugStage
|
||||||
|
public var status: SourceDebugStatus
|
||||||
|
public var summary: String
|
||||||
|
public var elapsedMilliseconds: Int
|
||||||
|
public var diagnostics: [RuleDiagnostic]
|
||||||
|
|
||||||
|
public var id: SourceDebugStage { stage }
|
||||||
|
|
||||||
|
public init(
|
||||||
|
stage: SourceDebugStage,
|
||||||
|
status: SourceDebugStatus,
|
||||||
|
summary: String,
|
||||||
|
elapsedMilliseconds: Int = 0,
|
||||||
|
diagnostics: [RuleDiagnostic] = []
|
||||||
|
) {
|
||||||
|
self.stage = stage
|
||||||
|
self.status = status
|
||||||
|
self.summary = summary
|
||||||
|
self.elapsedMilliseconds = elapsedMilliseconds
|
||||||
|
self.diagnostics = diagnostics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct BookSourceDebugReport: Codable, Sendable {
|
||||||
|
public var sourceID: String
|
||||||
|
public var keyword: String
|
||||||
|
public var steps: [SourceDebugStep]
|
||||||
|
public var sampleBookName: String?
|
||||||
|
public var sampleChapterTitle: String?
|
||||||
|
public var contentPreview: String?
|
||||||
|
|
||||||
|
public var passed: Bool {
|
||||||
|
!steps.contains { $0.status == .failed }
|
||||||
|
&& steps.contains { $0.stage == .content && $0.status != .skipped }
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(sourceID: String, keyword: String, steps: [SourceDebugStep] = []) {
|
||||||
|
self.sourceID = sourceID
|
||||||
|
self.keyword = keyword
|
||||||
|
self.steps = steps
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct BookSourceDebugger: Sendable {
|
||||||
|
private let searchService: BookSearchService
|
||||||
|
private let bookService: NetworkBookService
|
||||||
|
|
||||||
|
public init(transport: any HTTPTransport = URLSessionHTTPTransport()) {
|
||||||
|
searchService = BookSearchService(transport: transport)
|
||||||
|
bookService = NetworkBookService(transport: transport)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func run(source: BookSource, keyword: String) async -> BookSourceDebugReport {
|
||||||
|
let keyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
var report = BookSourceDebugReport(sourceID: source.id, keyword: keyword)
|
||||||
|
let missing = missingConfiguration(source)
|
||||||
|
report.steps.append(.init(
|
||||||
|
stage: .configuration,
|
||||||
|
status: missing.isEmpty ? .passed : .failed,
|
||||||
|
summary: missing.isEmpty ? "搜索、详情、目录、正文规则均已配置" : "缺少:\(missing.joined(separator: "、"))",
|
||||||
|
diagnostics: missing.map {
|
||||||
|
.init(severity: .error, code: "debug.configuration", message: "缺少\($0)配置", rule: source.bookSourceName)
|
||||||
|
}
|
||||||
|
))
|
||||||
|
guard !keyword.isEmpty else {
|
||||||
|
appendSkipped(after: .configuration, to: &report, reason: "请输入调试关键词")
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
|
||||||
|
let searchStart = ProcessInfo.processInfo.systemUptime
|
||||||
|
let search = await searchService.search(keyword: keyword, page: 1, source: source)
|
||||||
|
let searchStep = step(
|
||||||
|
stage: .search,
|
||||||
|
hasOutput: !search.books.isEmpty,
|
||||||
|
summary: search.books.isEmpty ? "没有解析到搜索结果" : "解析到 \(search.books.count) 条结果",
|
||||||
|
diagnostics: search.diagnostics,
|
||||||
|
started: searchStart
|
||||||
|
)
|
||||||
|
report.steps.append(searchStep)
|
||||||
|
guard let searchBook = search.books.first else {
|
||||||
|
appendSkipped(after: .search, to: &report, reason: "搜索阶段没有样本书籍")
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
report.sampleBookName = searchBook.name
|
||||||
|
|
||||||
|
let detailStart = ProcessInfo.processInfo.systemUptime
|
||||||
|
let detail = await bookService.loadDetail(searchBook: searchBook, source: source)
|
||||||
|
report.steps.append(step(
|
||||||
|
stage: .detail,
|
||||||
|
hasOutput: !detail.book.name.isEmpty,
|
||||||
|
summary: "样本《\(detail.book.name)》;目录地址已\(!detail.book.tocUrl.isEmpty ? "解析" : "回退")",
|
||||||
|
diagnostics: detail.diagnostics,
|
||||||
|
started: detailStart
|
||||||
|
))
|
||||||
|
|
||||||
|
let tocStart = ProcessInfo.processInfo.systemUptime
|
||||||
|
let toc = await bookService.loadChapters(book: detail.book, source: source, maxPages: 5)
|
||||||
|
report.steps.append(step(
|
||||||
|
stage: .toc,
|
||||||
|
hasOutput: !toc.chapters.isEmpty,
|
||||||
|
summary: toc.chapters.isEmpty ? "没有解析到章节" : "解析到 \(toc.chapters.count) 章,访问 \(toc.pagesVisited) 页",
|
||||||
|
diagnostics: toc.diagnostics,
|
||||||
|
started: tocStart
|
||||||
|
))
|
||||||
|
guard let chapter = toc.chapters.first(where: { !$0.isVolume }) else {
|
||||||
|
appendSkipped(after: .toc, to: &report, reason: "目录阶段没有可读取章节")
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
report.sampleChapterTitle = chapter.title
|
||||||
|
|
||||||
|
let next = toc.chapters.drop(while: { $0.id != chapter.id }).dropFirst().first(where: { !$0.isVolume })?.url
|
||||||
|
let contentStart = ProcessInfo.processInfo.systemUptime
|
||||||
|
let content = await bookService.loadContent(
|
||||||
|
book: detail.book,
|
||||||
|
chapter: chapter,
|
||||||
|
source: source,
|
||||||
|
nextChapterURL: next,
|
||||||
|
maxPages: 5
|
||||||
|
)
|
||||||
|
let cleaned = content.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
report.contentPreview = cleaned.isEmpty ? nil : String(cleaned.prefix(240))
|
||||||
|
report.steps.append(step(
|
||||||
|
stage: .content,
|
||||||
|
hasOutput: !cleaned.isEmpty,
|
||||||
|
summary: cleaned.isEmpty ? "没有解析到正文" : "正文 \(cleaned.count) 字,访问 \(content.pagesVisited) 页",
|
||||||
|
diagnostics: content.diagnostics,
|
||||||
|
started: contentStart
|
||||||
|
))
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
|
||||||
|
private func missingConfiguration(_ source: BookSource) -> [String] {
|
||||||
|
var missing: [String] = []
|
||||||
|
if source.searchUrl?.isEmpty != false || source.ruleSearch == nil { missing.append("搜索") }
|
||||||
|
if source.ruleBookInfo == nil { missing.append("详情") }
|
||||||
|
if source.ruleToc?.chapterList?.isEmpty != false { missing.append("目录") }
|
||||||
|
if source.ruleContent?.content?.isEmpty != false { missing.append("正文") }
|
||||||
|
return missing
|
||||||
|
}
|
||||||
|
|
||||||
|
private func step(
|
||||||
|
stage: SourceDebugStage,
|
||||||
|
hasOutput: Bool,
|
||||||
|
summary: String,
|
||||||
|
diagnostics: [RuleDiagnostic],
|
||||||
|
started: TimeInterval
|
||||||
|
) -> SourceDebugStep {
|
||||||
|
let containsError = diagnostics.contains { $0.severity == .error }
|
||||||
|
let status: SourceDebugStatus = if !hasOutput {
|
||||||
|
.failed
|
||||||
|
} else if containsError || !diagnostics.isEmpty {
|
||||||
|
.warning
|
||||||
|
} else {
|
||||||
|
.passed
|
||||||
|
}
|
||||||
|
let milliseconds = Int((ProcessInfo.processInfo.systemUptime - started) * 1_000)
|
||||||
|
return .init(
|
||||||
|
stage: stage,
|
||||||
|
status: status,
|
||||||
|
summary: summary,
|
||||||
|
elapsedMilliseconds: max(milliseconds, 0),
|
||||||
|
diagnostics: diagnostics
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func appendSkipped(after stage: SourceDebugStage, to report: inout BookSourceDebugReport, reason: String) {
|
||||||
|
guard let index = SourceDebugStage.allCases.firstIndex(of: stage) else { return }
|
||||||
|
for remaining in SourceDebugStage.allCases.dropFirst(index + 1) {
|
||||||
|
report.steps.append(.init(stage: remaining, status: .skipped, summary: reason))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct DiagnosticLogEntry: Codable, Identifiable, Sendable {
|
||||||
|
public enum Level: String, Codable, CaseIterable, Sendable { case info, warning, error }
|
||||||
|
public var id: UUID
|
||||||
|
public var date: Date
|
||||||
|
public var level: Level
|
||||||
|
public var category: String
|
||||||
|
public var message: String
|
||||||
|
}
|
||||||
|
|
||||||
|
public final class DiagnosticLog: @unchecked Sendable {
|
||||||
|
public static let shared = DiagnosticLog()
|
||||||
|
private let lock = NSLock()
|
||||||
|
private let fileURL: URL
|
||||||
|
private let maximumEntries: Int
|
||||||
|
private let isEnabled: @Sendable () -> Bool
|
||||||
|
private var entries: [DiagnosticLogEntry]
|
||||||
|
|
||||||
|
public init(directory: URL? = nil, maximumEntries: Int = 500,
|
||||||
|
isEnabled: @escaping @Sendable () -> Bool = { ApplicationSettingsStore.shared.settings.recordLog }) {
|
||||||
|
fileURL = (directory ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||||
|
.appendingPathComponent("Legado/Logs", isDirectory: true)).appendingPathComponent("diagnostics.json")
|
||||||
|
self.maximumEntries = max(1, maximumEntries)
|
||||||
|
self.isEnabled = isEnabled
|
||||||
|
entries = (try? JSONDecoder().decode([DiagnosticLogEntry].self, from: Data(contentsOf: fileURL))) ?? []
|
||||||
|
entries = Array(entries.suffix(self.maximumEntries))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func record(_ level: DiagnosticLogEntry.Level = .info, category: String, message: String) {
|
||||||
|
guard isEnabled() else { return }
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
entries.append(.init(id: UUID(), date: Date(), level: level,
|
||||||
|
category: String(category.prefix(80)), message: Self.redact(String(message.prefix(2048)))))
|
||||||
|
entries = Array(entries.suffix(maximumEntries))
|
||||||
|
try? persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func snapshot() -> [DiagnosticLogEntry] {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
return entries.reversed()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func clear() throws {
|
||||||
|
lock.lock()
|
||||||
|
defer { lock.unlock() }
|
||||||
|
if FileManager.default.fileExists(atPath: fileURL.path) { try FileManager.default.removeItem(at: fileURL) }
|
||||||
|
entries.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
public func export() -> String {
|
||||||
|
let formatter = ISO8601DateFormatter()
|
||||||
|
return snapshot().reversed().map {
|
||||||
|
"\(formatter.string(from: $0.date)) [\($0.level.rawValue.uppercased())] [\($0.category)] \($0.message)"
|
||||||
|
}.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
public func network(_ request: URLRequest, status: Int? = nil, error: Error? = nil, startedAt: Date) {
|
||||||
|
// URL query strings, credentials and fragments can contain access tokens.
|
||||||
|
let url = request.url.map(Self.safeURL) ?? "未知地址"
|
||||||
|
let duration = Int(Date().timeIntervalSince(startedAt) * 1000)
|
||||||
|
let result: String
|
||||||
|
if let error, case HTTPClientError.statusResponse(let response) = error {
|
||||||
|
result = "HTTP \(response.statusCode)"
|
||||||
|
} else {
|
||||||
|
result = error.map { "失败 (\(($0 as NSError).domain):\(($0 as NSError).code))" } ?? "HTTP \(status ?? 0)"
|
||||||
|
}
|
||||||
|
record(error != nil || (status ?? 0) >= 400 ? .error : .info, category: "网络",
|
||||||
|
message: "\(request.httpMethod ?? "GET") \(url) · \(result) · \(duration) ms")
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func safeURL(_ url: URL) -> String {
|
||||||
|
guard var parts = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return "[URL]" }
|
||||||
|
parts.user = nil; parts.password = nil; parts.query = nil; parts.fragment = nil
|
||||||
|
return parts.string ?? "[URL]"
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func redact(_ text: String) -> String {
|
||||||
|
var value = text
|
||||||
|
if let regex = try? NSRegularExpression(pattern: #"https?://[^\s\"<>]+"#) {
|
||||||
|
for match in regex.matches(in: value, range: NSRange(value.startIndex..., in: value)).reversed() {
|
||||||
|
guard let range = Range(match.range, in: value), let url = URL(string: String(value[range])) else { continue }
|
||||||
|
value.replaceSubrange(range, with: safeURL(url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for pattern in [
|
||||||
|
#"(?im)(authorization|cookie)["']?(\s*[=:]\s*)[^\r\n]+"#,
|
||||||
|
#"(?i)["']?(password|passwd|token|secret)["']?(\s*[=:]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)"#
|
||||||
|
] {
|
||||||
|
if let regex = try? NSRegularExpression(pattern: pattern) {
|
||||||
|
value = regex.stringByReplacingMatches(in: value, range: NSRange(value.startIndex..., in: value), withTemplate: "$1$2[已隐藏]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persist() throws {
|
||||||
|
try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||||
|
try JSONEncoder().encode(entries).write(to: fileURL, options: .atomic)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import Foundation
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
|
public enum DifferentialStage: String, Codable, Sendable {
|
||||||
|
case search, detail, toc, content, login, debug
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct DifferentialRequestSnapshot: Codable, Sendable, Equatable {
|
||||||
|
public struct Request: Codable, Sendable, Equatable {
|
||||||
|
public var ruleURL: String
|
||||||
|
public var renderedURL: String
|
||||||
|
public var method: String
|
||||||
|
public var headers: [String: String]
|
||||||
|
public var bodyBase64: String?
|
||||||
|
public var charset: String?
|
||||||
|
public var cookies: [String: String]
|
||||||
|
public var timeoutMilliseconds: Int
|
||||||
|
public var retryCount: Int
|
||||||
|
public var proxy: String?
|
||||||
|
public var dnsIp: String?
|
||||||
|
public var serverID: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Response: Codable, Sendable, Equatable {
|
||||||
|
public struct ErrorInfo: Codable, Sendable, Equatable {
|
||||||
|
public var code: String
|
||||||
|
public var legadoCode: Int
|
||||||
|
public var message: String
|
||||||
|
}
|
||||||
|
|
||||||
|
public var statusCode: Int
|
||||||
|
public var finalURL: String
|
||||||
|
public var headers: [String: String]
|
||||||
|
public var charset: String?
|
||||||
|
public var bodySHA256: String
|
||||||
|
public var bodyJsSHA256: String?
|
||||||
|
public var setCookies: [String: String]
|
||||||
|
public var error: ErrorInfo?
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(caseID: String, stage: DifferentialStage, sourceID: String, prepared: PreparedRequest, failedResponse: HTTPResponse?, error: Error, timeout: TimeInterval) {
|
||||||
|
self.caseID = caseID
|
||||||
|
self.stage = stage
|
||||||
|
self.sourceID = sourceID
|
||||||
|
let response = failedResponse
|
||||||
|
request = .init(
|
||||||
|
ruleURL: prepared.ruleURL, renderedURL: prepared.url.absoluteString, method: prepared.method.rawValue,
|
||||||
|
headers: Self.normalizedHeaders(prepared.headers), bodyBase64: prepared.body?.base64EncodedString(),
|
||||||
|
charset: prepared.charset,
|
||||||
|
cookies: Self.cookies(from: prepared.headers.first { $0.key.caseInsensitiveCompare("Cookie") == .orderedSame }?.value),
|
||||||
|
timeoutMilliseconds: Int(timeout * 1_000), retryCount: prepared.retry,
|
||||||
|
proxy: prepared.proxy, dnsIp: prepared.dnsIP, serverID: prepared.serverID.map(String.init)
|
||||||
|
)
|
||||||
|
let classification = Self.classify(error)
|
||||||
|
self.response = .init(
|
||||||
|
statusCode: response?.statusCode ?? 0,
|
||||||
|
finalURL: response?.finalURL.absoluteString ?? prepared.url.absoluteString,
|
||||||
|
headers: Self.normalizedHeaders(response?.headers ?? [:]),
|
||||||
|
charset: response?.requestedCharset,
|
||||||
|
bodySHA256: Self.sha256(response?.data ?? Data()),
|
||||||
|
bodyJsSHA256: nil,
|
||||||
|
setCookies: Self.cookies(from: response?.headers.first { $0.key.caseInsensitiveCompare("Set-Cookie") == .orderedSame }?.value),
|
||||||
|
error: .init(code: classification.code, legadoCode: classification.legadoCode, message: error.localizedDescription)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public var schemaVersion = 1
|
||||||
|
public var caseID: String
|
||||||
|
public var platform = "ios"
|
||||||
|
public var stage: DifferentialStage
|
||||||
|
public var sourceID: String
|
||||||
|
public var request: Request
|
||||||
|
public var response: Response
|
||||||
|
|
||||||
|
public init(caseID: String, stage: DifferentialStage, sourceID: String, prepared: PreparedRequest, rawResponse: HTTPResponse, response: HTTPResponse, timeout: TimeInterval) {
|
||||||
|
self.caseID = caseID
|
||||||
|
self.stage = stage
|
||||||
|
self.sourceID = sourceID
|
||||||
|
let requestCookies = Self.cookies(from: prepared.headers.first { $0.key.caseInsensitiveCompare("Cookie") == .orderedSame }?.value)
|
||||||
|
let setCookies = Self.cookies(from: response.headers.first { $0.key.caseInsensitiveCompare("Set-Cookie") == .orderedSame }?.value)
|
||||||
|
request = .init(
|
||||||
|
ruleURL: prepared.ruleURL,
|
||||||
|
renderedURL: prepared.url.absoluteString,
|
||||||
|
method: prepared.method.rawValue,
|
||||||
|
headers: Self.normalizedHeaders(prepared.headers),
|
||||||
|
bodyBase64: prepared.body?.base64EncodedString(),
|
||||||
|
charset: prepared.charset,
|
||||||
|
cookies: requestCookies,
|
||||||
|
timeoutMilliseconds: Int(timeout * 1_000),
|
||||||
|
retryCount: prepared.retry,
|
||||||
|
proxy: prepared.proxy,
|
||||||
|
dnsIp: prepared.dnsIP,
|
||||||
|
serverID: prepared.serverID.map(String.init)
|
||||||
|
)
|
||||||
|
self.response = .init(
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
finalURL: response.finalURL.absoluteString,
|
||||||
|
headers: Self.normalizedHeaders(response.headers),
|
||||||
|
charset: response.requestedCharset,
|
||||||
|
bodySHA256: Self.sha256(rawResponse.data),
|
||||||
|
bodyJsSHA256: prepared.bodyJavaScript == nil ? nil : Self.sha256(response.data),
|
||||||
|
setCookies: setCookies,
|
||||||
|
error: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func normalizedHeaders(_ headers: [String: String]) -> [String: String] {
|
||||||
|
headers.reduce(into: [:]) { result, entry in
|
||||||
|
let (key, value) = entry
|
||||||
|
let normalized = key.lowercased()
|
||||||
|
result[normalized] = normalized == "cookie" || normalized == "set-cookie" || normalized == "authorization"
|
||||||
|
? "<redacted:\(sha256(Data(value.utf8)))>" : value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func cookies(from header: String?) -> [String: String] {
|
||||||
|
guard let header else { return [:] }
|
||||||
|
return header.split(separator: ";").reduce(into: [:]) { result, part in
|
||||||
|
let pair = part.split(separator: "=", maxSplits: 1).map(String.init)
|
||||||
|
guard pair.count == 2 else { return }
|
||||||
|
result[pair[0].trimmingCharacters(in: .whitespaces)] = "<redacted:\(sha256(Data(pair[1].utf8)))>"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func sha256(_ data: Data) -> String {
|
||||||
|
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func classify(_ error: Error) -> (code: String, legadoCode: Int) {
|
||||||
|
if case HTTPClientError.statusResponse(let response) = error { return ("http.\(response.statusCode)", -7) }
|
||||||
|
guard let urlError = error as? URLError else { return ("other", -7) }
|
||||||
|
switch urlError.code {
|
||||||
|
case .timedOut: return ("timeout", -2)
|
||||||
|
case .cannotFindHost, .dnsLookupFailed: return ("dns", -3)
|
||||||
|
case .cannotConnectToHost: return ("connection_refused", -4)
|
||||||
|
case .networkConnectionLost, .notConnectedToInternet: return ("socket", -5)
|
||||||
|
case .secureConnectionFailed, .serverCertificateHasBadDate, .serverCertificateUntrusted,
|
||||||
|
.serverCertificateHasUnknownRoot, .serverCertificateNotYetValid, .clientCertificateRejected:
|
||||||
|
return ("tls", -6)
|
||||||
|
default: return ("other", -7)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public protocol DifferentialSnapshotRecording: Sendable {
|
||||||
|
func record(stage: DifferentialStage, sourceID: String, prepared: PreparedRequest, rawResponse: HTTPResponse, response: HTTPResponse, timeout: TimeInterval) async
|
||||||
|
func recordFailure(stage: DifferentialStage, sourceID: String, prepared: PreparedRequest, response: HTTPResponse?, error: Error, timeout: TimeInterval) async
|
||||||
|
}
|
||||||
|
|
||||||
|
public actor MemoryDifferentialSnapshotRecorder: DifferentialSnapshotRecording {
|
||||||
|
public let caseID: String
|
||||||
|
public private(set) var snapshots: [DifferentialRequestSnapshot] = []
|
||||||
|
|
||||||
|
public init(caseID: String) { self.caseID = caseID }
|
||||||
|
|
||||||
|
public func record(stage: DifferentialStage, sourceID: String, prepared: PreparedRequest, rawResponse: HTTPResponse, response: HTTPResponse, timeout: TimeInterval) async {
|
||||||
|
snapshots.append(.init(caseID: caseID, stage: stage, sourceID: sourceID, prepared: prepared, rawResponse: rawResponse, response: response, timeout: timeout))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func recordFailure(stage: DifferentialStage, sourceID: String, prepared: PreparedRequest, response: HTTPResponse?, error: Error, timeout: TimeInterval) async {
|
||||||
|
snapshots.append(.init(caseID: caseID, stage: stage, sourceID: sourceID, prepared: prepared, failedResponse: response, error: error, timeout: timeout))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encodedSnapshots() throws -> Data {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
|
||||||
|
return try encoder.encode(snapshots)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct DifferentialBusinessSnapshot: Codable, Sendable {
|
||||||
|
public struct Content: Codable, Sendable {
|
||||||
|
public var title: String?
|
||||||
|
public var text: String
|
||||||
|
public var supplementalText: String?
|
||||||
|
public var pagesVisited: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Result: Codable, Sendable {
|
||||||
|
public var books: [SearchBook]?
|
||||||
|
public var book: Book?
|
||||||
|
public var chapters: [BookChapter]?
|
||||||
|
public var content: Content?
|
||||||
|
public var variables: [String: String]
|
||||||
|
public var cookies: [String: String]
|
||||||
|
public var diagnostics: [RuleDiagnostic]
|
||||||
|
}
|
||||||
|
|
||||||
|
public var schemaVersion = 1
|
||||||
|
public var caseID: String
|
||||||
|
public var platform = "ios"
|
||||||
|
public var flow: DifferentialStage
|
||||||
|
public var result: Result
|
||||||
|
|
||||||
|
public static func search(caseID: String, report: BookSearchReport) -> Self {
|
||||||
|
.init(caseID: caseID, flow: .search, result: .init(
|
||||||
|
books: report.books, book: nil, chapters: nil, content: nil,
|
||||||
|
variables: [:], cookies: [:], diagnostics: report.outcomes.flatMap(\.diagnostics)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func detail(caseID: String, report: BookDetailReport) -> Self {
|
||||||
|
.init(caseID: caseID, flow: .detail, result: .init(
|
||||||
|
books: nil, book: report.book, chapters: nil, content: nil,
|
||||||
|
variables: decodeVariables(report.book.variable), cookies: [:], diagnostics: report.diagnostics
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func toc(caseID: String, report: ChapterListReport) -> Self {
|
||||||
|
.init(caseID: caseID, flow: .toc, result: .init(
|
||||||
|
books: nil, book: nil, chapters: report.chapters, content: nil,
|
||||||
|
variables: [:], cookies: [:], diagnostics: report.diagnostics
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func content(caseID: String, report: ChapterContentReport) -> Self {
|
||||||
|
.init(caseID: caseID, flow: .content, result: .init(
|
||||||
|
books: nil, book: nil, chapters: nil,
|
||||||
|
content: .init(title: report.title, text: report.content, supplementalText: report.supplementalContent, pagesVisited: report.pagesVisited),
|
||||||
|
variables: [:], cookies: [:], diagnostics: report.diagnostics
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encoded() throws -> Data {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
|
||||||
|
return try encoder.encode(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func decodeVariables(_ value: String?) -> [String: String] {
|
||||||
|
guard let value, let data = value.data(using: .utf8) else { return [:] }
|
||||||
|
return (try? JSONDecoder().decode([String: String].self, from: data)) ?? [:]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum RSSDebugStage: String, Codable, CaseIterable, Sendable {
|
||||||
|
case configuration, listRequest, listParse, contentRequest, contentParse
|
||||||
|
|
||||||
|
public var title: String {
|
||||||
|
switch self {
|
||||||
|
case .configuration: "配置"
|
||||||
|
case .listRequest: "列表请求"
|
||||||
|
case .listParse: "列表解析"
|
||||||
|
case .contentRequest: "正文请求"
|
||||||
|
case .contentParse: "正文解析"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct RSSDebugStep: Codable, Hashable, Identifiable, Sendable {
|
||||||
|
public var stage: RSSDebugStage
|
||||||
|
public var status: SourceDebugStatus
|
||||||
|
public var summary: String
|
||||||
|
public var diagnostics: [RuleDiagnostic]
|
||||||
|
public var id: RSSDebugStage { stage }
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct RSSSourceDebugReport: Sendable {
|
||||||
|
public var sourceURL: String
|
||||||
|
public var input: String
|
||||||
|
public var steps: [RSSDebugStep] = []
|
||||||
|
public var listSource: String?
|
||||||
|
public var contentSource: String?
|
||||||
|
public var articles: [RssArticle] = []
|
||||||
|
public var contentPreview: String?
|
||||||
|
public var passed: Bool { !steps.contains { $0.status == .failed } && steps.contains { $0.stage == .listParse && $0.status != .skipped } }
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct RSSSourceDebugger: Sendable {
|
||||||
|
private let service: RSSService
|
||||||
|
public init(service: RSSService = .init()) { self.service = service }
|
||||||
|
|
||||||
|
public func run(source: RssSource, input: String) async -> RSSSourceDebugReport {
|
||||||
|
let input = input.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
var report = RSSSourceDebugReport(sourceURL: source.sourceUrl, input: input)
|
||||||
|
let listURL = resolvedListURL(source: source, input: input)
|
||||||
|
let missing = source.sourceUrl.isEmpty || listURL.isEmpty || source.sourceName.isEmpty
|
||||||
|
report.steps.append(.init(stage: .configuration, status: missing ? .failed : .passed, summary: missing ? "源名称、源URL或调试地址为空" : "使用地址:\(listURL)", diagnostics: []))
|
||||||
|
guard !missing else { appendSkipped(after: .configuration, to: &report, reason: "配置不完整"); return report }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let fetched = try await service.articlesDetailed(sortName: input, sortURL: listURL, source: source, page: 1, key: input.isEmpty ? nil : input)
|
||||||
|
report.listSource = fetched.body
|
||||||
|
report.steps.append(.init(stage: .listRequest, status: (200..<400).contains(fetched.statusCode) ? .passed : .warning, summary: "HTTP \(fetched.statusCode);\(fetched.finalURL)", diagnostics: []))
|
||||||
|
report.articles = fetched.report.result.articles
|
||||||
|
report.steps.append(.init(stage: .listParse, status: report.articles.isEmpty ? .failed : (fetched.report.diagnostics.isEmpty ? .passed : .warning), summary: report.articles.isEmpty ? "没有解析到文章" : "解析到 \(report.articles.count) 篇文章", diagnostics: fetched.report.diagnostics))
|
||||||
|
guard let article = report.articles.first, let rule = source.ruleContent, !rule.isEmpty else {
|
||||||
|
report.steps.append(.init(stage: .contentRequest, status: .skipped, summary: "没有样本文章或未配置正文规则", diagnostics: []))
|
||||||
|
report.steps.append(.init(stage: .contentParse, status: .skipped, summary: "正文将直接使用描述或网页", diagnostics: []))
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
let content = try await service.contentDetailed(article: article, rule: rule, source: source)
|
||||||
|
report.contentSource = content.body
|
||||||
|
report.steps.append(.init(stage: .contentRequest, status: (200..<400).contains(content.statusCode) ? .passed : .warning, summary: "HTTP \(content.statusCode);\(content.finalURL)", diagnostics: []))
|
||||||
|
let cleaned = content.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
report.contentPreview = cleaned.isEmpty ? nil : String(cleaned.prefix(400))
|
||||||
|
report.steps.append(.init(stage: .contentParse, status: cleaned.isEmpty ? .failed : (content.diagnostics.isEmpty ? .passed : .warning), summary: cleaned.isEmpty ? "正文结果为空" : "正文结果 \(cleaned.count) 字", diagnostics: content.diagnostics))
|
||||||
|
} catch {
|
||||||
|
let reachedList = report.steps.contains { $0.stage == .listRequest }
|
||||||
|
let stage: RSSDebugStage = reachedList ? .contentRequest : .listRequest
|
||||||
|
report.steps.append(.init(stage: stage, status: .failed, summary: error.localizedDescription, diagnostics: []))
|
||||||
|
appendSkipped(after: stage, to: &report, reason: "前一阶段失败")
|
||||||
|
}
|
||||||
|
return report
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvedListURL(source: RssSource, input: String) -> String {
|
||||||
|
if let separator = input.range(of: "::") { return String(input[separator.upperBound...]) }
|
||||||
|
if !input.isEmpty, let search = source.searchUrl, !search.isEmpty { return search }
|
||||||
|
return source.sourceUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
private func appendSkipped(after stage: RSSDebugStage, to report: inout RSSSourceDebugReport, reason: String) {
|
||||||
|
guard let index = RSSDebugStage.allCases.firstIndex(of: stage) else { return }
|
||||||
|
let existing = Set(report.steps.map(\.stage))
|
||||||
|
for value in RSSDebugStage.allCases.dropFirst(index + 1) where !existing.contains(value) {
|
||||||
|
report.steps.append(.init(stage: value, status: .skipped, summary: reason, diagnostics: []))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct BookSource: Codable, Hashable, Identifiable, Sendable {
|
||||||
|
public var bookSourceUrl: String
|
||||||
|
public var bookSourceName: String
|
||||||
|
public var bookSourceGroup: String?
|
||||||
|
public var bookSourceType: Int
|
||||||
|
public var bookUrlPattern: String?
|
||||||
|
public var customOrder: Int
|
||||||
|
public var enabled: Bool
|
||||||
|
public var enabledExplore: Bool
|
||||||
|
public var jsLib: String?
|
||||||
|
public var enabledCookieJar: Bool?
|
||||||
|
public var concurrentRate: String?
|
||||||
|
public var header: String?
|
||||||
|
public var loginUrl: String?
|
||||||
|
public var loginUi: String?
|
||||||
|
public var loginCheckJs: String?
|
||||||
|
public var coverDecodeJs: String?
|
||||||
|
public var bookSourceComment: String?
|
||||||
|
public var variableComment: String?
|
||||||
|
public var lastUpdateTime: Int64
|
||||||
|
public var respondTime: Int64
|
||||||
|
public var weight: Int
|
||||||
|
public var exploreUrl: String?
|
||||||
|
public var exploreScreen: String?
|
||||||
|
public var ruleExplore: ExploreRule?
|
||||||
|
public var searchUrl: String?
|
||||||
|
public var ruleSearch: SearchRule?
|
||||||
|
public var ruleBookInfo: BookInfoRule?
|
||||||
|
public var ruleToc: TocRule?
|
||||||
|
public var ruleContent: ContentRule?
|
||||||
|
public var ruleReview: ReviewRule?
|
||||||
|
public var eventListener: Bool
|
||||||
|
public var customButton: Bool
|
||||||
|
|
||||||
|
public var additionalFields: [String: JSONValue] = [:]
|
||||||
|
|
||||||
|
public var id: String { bookSourceUrl }
|
||||||
|
|
||||||
|
public init(bookSourceUrl: String, bookSourceName: String) {
|
||||||
|
self.bookSourceUrl = bookSourceUrl
|
||||||
|
self.bookSourceName = bookSourceName
|
||||||
|
bookSourceGroup = nil
|
||||||
|
bookSourceType = 0
|
||||||
|
bookUrlPattern = nil
|
||||||
|
customOrder = 0
|
||||||
|
enabled = true
|
||||||
|
enabledExplore = true
|
||||||
|
jsLib = nil
|
||||||
|
enabledCookieJar = true
|
||||||
|
concurrentRate = nil
|
||||||
|
header = nil
|
||||||
|
loginUrl = nil
|
||||||
|
loginUi = nil
|
||||||
|
loginCheckJs = nil
|
||||||
|
coverDecodeJs = nil
|
||||||
|
bookSourceComment = nil
|
||||||
|
variableComment = nil
|
||||||
|
lastUpdateTime = 0
|
||||||
|
respondTime = 180_000
|
||||||
|
weight = 0
|
||||||
|
exploreUrl = nil
|
||||||
|
exploreScreen = nil
|
||||||
|
ruleExplore = nil
|
||||||
|
searchUrl = nil
|
||||||
|
ruleSearch = nil
|
||||||
|
ruleBookInfo = nil
|
||||||
|
ruleToc = nil
|
||||||
|
ruleContent = nil
|
||||||
|
ruleReview = nil
|
||||||
|
eventListener = false
|
||||||
|
customButton = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey, CaseIterable {
|
||||||
|
case bookSourceUrl, bookSourceName, bookSourceGroup, bookSourceType, bookUrlPattern, customOrder, enabled, enabledExplore, jsLib, enabledCookieJar, concurrentRate, header, loginUrl, loginUi, loginCheckJs, coverDecodeJs, bookSourceComment, variableComment, lastUpdateTime, respondTime, weight, exploreUrl, exploreScreen, ruleExplore, searchUrl, ruleSearch, ruleBookInfo, ruleToc, ruleContent, ruleReview, eventListener, customButton
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
bookSourceUrl = try values.decodeIfPresent(String.self, forKey: .bookSourceUrl) ?? ""
|
||||||
|
bookSourceName = try values.decodeIfPresent(String.self, forKey: .bookSourceName) ?? ""
|
||||||
|
bookSourceGroup = try values.decodeIfPresent(String.self, forKey: .bookSourceGroup)
|
||||||
|
bookSourceType = try values.decodeLossyInt(forKey: .bookSourceType) ?? 0
|
||||||
|
bookUrlPattern = try values.decodeIfPresent(String.self, forKey: .bookUrlPattern)
|
||||||
|
customOrder = try values.decodeLossyInt(forKey: .customOrder) ?? 0
|
||||||
|
enabled = try values.decodeLossyBool(forKey: .enabled) ?? true
|
||||||
|
enabledExplore = try values.decodeLossyBool(forKey: .enabledExplore) ?? true
|
||||||
|
jsLib = try values.decodeIfPresent(String.self, forKey: .jsLib)
|
||||||
|
enabledCookieJar = values.contains(.enabledCookieJar)
|
||||||
|
? try values.decodeLossyBool(forKey: .enabledCookieJar)
|
||||||
|
: true
|
||||||
|
concurrentRate = try values.decodeIfPresent(String.self, forKey: .concurrentRate)
|
||||||
|
header = try values.decodeIfPresent(String.self, forKey: .header)
|
||||||
|
loginUrl = try values.decodeIfPresent(String.self, forKey: .loginUrl)
|
||||||
|
loginUi = try values.decodeIfPresent(String.self, forKey: .loginUi)
|
||||||
|
loginCheckJs = try values.decodeIfPresent(String.self, forKey: .loginCheckJs)
|
||||||
|
coverDecodeJs = try values.decodeIfPresent(String.self, forKey: .coverDecodeJs)
|
||||||
|
bookSourceComment = try values.decodeIfPresent(String.self, forKey: .bookSourceComment)
|
||||||
|
variableComment = try values.decodeIfPresent(String.self, forKey: .variableComment)
|
||||||
|
lastUpdateTime = try values.decodeLossyInt64(forKey: .lastUpdateTime) ?? 0
|
||||||
|
respondTime = try values.decodeLossyInt64(forKey: .respondTime) ?? 180_000
|
||||||
|
weight = try values.decodeLossyInt(forKey: .weight) ?? 0
|
||||||
|
exploreUrl = try values.decodeIfPresent(String.self, forKey: .exploreUrl)
|
||||||
|
exploreScreen = try values.decodeIfPresent(String.self, forKey: .exploreScreen)
|
||||||
|
ruleExplore = try values.decodeFlexible(ExploreRule.self, forKey: .ruleExplore)
|
||||||
|
searchUrl = try values.decodeIfPresent(String.self, forKey: .searchUrl)
|
||||||
|
ruleSearch = try values.decodeFlexible(SearchRule.self, forKey: .ruleSearch)
|
||||||
|
ruleBookInfo = try values.decodeFlexible(BookInfoRule.self, forKey: .ruleBookInfo)
|
||||||
|
ruleToc = try values.decodeFlexible(TocRule.self, forKey: .ruleToc)
|
||||||
|
ruleContent = try values.decodeFlexible(ContentRule.self, forKey: .ruleContent)
|
||||||
|
ruleReview = try values.decodeFlexible(ReviewRule.self, forKey: .ruleReview)
|
||||||
|
eventListener = try values.decodeLossyBool(forKey: .eventListener) ?? false
|
||||||
|
customButton = try values.decodeLossyBool(forKey: .customButton) ?? false
|
||||||
|
if case .object(var raw) = try JSONValue(from: decoder) {
|
||||||
|
for key in CodingKeys.allCases {
|
||||||
|
if BookSourceEditorDocument.ruleKeys.contains(key.rawValue) {
|
||||||
|
var nested: [String: JSONValue] = [:]
|
||||||
|
var value = raw[key.rawValue]
|
||||||
|
if case .string(let text) = value { value = try? JSONValue.decode(data: Data(text.utf8)) }
|
||||||
|
if case .object(let object) = value { nested = object }
|
||||||
|
for field in BookSourceEditorSchema.sections.flatMap(\.fields) where field.path.hasPrefix(key.rawValue + ".") {
|
||||||
|
nested.removeValue(forKey: String(field.path.split(separator: ".")[1]))
|
||||||
|
}
|
||||||
|
raw[key.rawValue] = nested.isEmpty ? nil : .object(nested)
|
||||||
|
} else { raw.removeValue(forKey: key.rawValue) }
|
||||||
|
}
|
||||||
|
additionalFields = raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var values = encoder.container(keyedBy: SourceCodingKey.self)
|
||||||
|
for (key, value) in additionalFields { try values.encode(value, forKey: SourceCodingKey(key)) }
|
||||||
|
try values.encode(bookSourceUrl, forKey: SourceCodingKey("bookSourceUrl"))
|
||||||
|
try values.encode(bookSourceName, forKey: SourceCodingKey("bookSourceName"))
|
||||||
|
try values.encodeIfPresent(bookSourceGroup, forKey: SourceCodingKey("bookSourceGroup"))
|
||||||
|
try values.encode(bookSourceType, forKey: SourceCodingKey("bookSourceType"))
|
||||||
|
try values.encodeIfPresent(bookUrlPattern, forKey: SourceCodingKey("bookUrlPattern"))
|
||||||
|
try values.encode(customOrder, forKey: SourceCodingKey("customOrder"))
|
||||||
|
try values.encode(enabled, forKey: SourceCodingKey("enabled"))
|
||||||
|
try values.encode(enabledExplore, forKey: SourceCodingKey("enabledExplore"))
|
||||||
|
try values.encodeIfPresent(jsLib, forKey: SourceCodingKey("jsLib"))
|
||||||
|
try values.encode(enabledCookieJar, forKey: SourceCodingKey("enabledCookieJar"))
|
||||||
|
try values.encodeIfPresent(concurrentRate, forKey: SourceCodingKey("concurrentRate"))
|
||||||
|
try values.encodeIfPresent(header, forKey: SourceCodingKey("header"))
|
||||||
|
try values.encodeIfPresent(loginUrl, forKey: SourceCodingKey("loginUrl"))
|
||||||
|
try values.encodeIfPresent(loginUi, forKey: SourceCodingKey("loginUi"))
|
||||||
|
try values.encodeIfPresent(loginCheckJs, forKey: SourceCodingKey("loginCheckJs"))
|
||||||
|
try values.encodeIfPresent(coverDecodeJs, forKey: SourceCodingKey("coverDecodeJs"))
|
||||||
|
try values.encodeIfPresent(bookSourceComment, forKey: SourceCodingKey("bookSourceComment"))
|
||||||
|
try values.encodeIfPresent(variableComment, forKey: SourceCodingKey("variableComment"))
|
||||||
|
try values.encode(lastUpdateTime, forKey: SourceCodingKey("lastUpdateTime"))
|
||||||
|
try values.encode(respondTime, forKey: SourceCodingKey("respondTime"))
|
||||||
|
try values.encode(weight, forKey: SourceCodingKey("weight"))
|
||||||
|
try values.encodeIfPresent(exploreUrl, forKey: SourceCodingKey("exploreUrl"))
|
||||||
|
try values.encodeIfPresent(exploreScreen, forKey: SourceCodingKey("exploreScreen"))
|
||||||
|
try encodeRule(ruleExplore, key: "ruleExplore", into: &values)
|
||||||
|
try values.encodeIfPresent(searchUrl, forKey: SourceCodingKey("searchUrl"))
|
||||||
|
try encodeRule(ruleSearch, key: "ruleSearch", into: &values)
|
||||||
|
try encodeRule(ruleBookInfo, key: "ruleBookInfo", into: &values)
|
||||||
|
try encodeRule(ruleToc, key: "ruleToc", into: &values)
|
||||||
|
try encodeRule(ruleContent, key: "ruleContent", into: &values)
|
||||||
|
try encodeRule(ruleReview, key: "ruleReview", into: &values)
|
||||||
|
try values.encode(eventListener, forKey: SourceCodingKey("eventListener"))
|
||||||
|
try values.encode(customButton, forKey: SourceCodingKey("customButton"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func encodeRule<T: Encodable>(_ rule: T?, key: String, into values: inout KeyedEncodingContainer<SourceCodingKey>) throws {
|
||||||
|
var object: [String: JSONValue] = [:]
|
||||||
|
if case .object(let extra) = additionalFields[key] { object = extra }
|
||||||
|
if let rule, case .object(let known) = try JSONValue.decode(data: JSONEncoder().encode(rule)) {
|
||||||
|
object.merge(known) { _, new in new }
|
||||||
|
}
|
||||||
|
if rule != nil || !object.isEmpty { try values.encode(object, forKey: SourceCodingKey(key)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public extension BookSource {
|
||||||
|
var groups: [String] {
|
||||||
|
Self.parseGroups(bookSourceGroup)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasGroup(_ group: String) -> Bool {
|
||||||
|
groups.contains(group.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func addGroups(_ value: String) {
|
||||||
|
bookSourceGroup = Self.joinGroups(groups + Self.parseGroups(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeGroups(_ value: String) {
|
||||||
|
let removed = Set(Self.parseGroups(value))
|
||||||
|
bookSourceGroup = Self.joinGroups(groups.filter { !removed.contains($0) })
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func replaceGroup(_ oldValue: String, with newValue: String?) {
|
||||||
|
let oldValue = oldValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
var values = groups.filter { $0 != oldValue }
|
||||||
|
if let newValue { values += Self.parseGroups(newValue) }
|
||||||
|
bookSourceGroup = Self.joinGroups(values)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func parseGroups(_ value: String?) -> [String] {
|
||||||
|
guard let value else { return [] }
|
||||||
|
var seen = Set<String>()
|
||||||
|
return value
|
||||||
|
.components(separatedBy: CharacterSet(charactersIn: ",;,;"))
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty && seen.insert($0).inserted }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func joinGroups(_ values: [String]) -> String? {
|
||||||
|
var seen = Set<String>()
|
||||||
|
let normalized = values
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
.filter { !$0.isEmpty && seen.insert($0).inserted }
|
||||||
|
return normalized.isEmpty ? nil : normalized.joined(separator: ",")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension KeyedDecodingContainer {
|
||||||
|
func decodeFlexible<T: Decodable>(_ type: T.Type, forKey key: Key) throws -> T? {
|
||||||
|
guard contains(key), try !decodeNil(forKey: key) else { return nil }
|
||||||
|
if let value = try? decode(T.self, forKey: key) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
let json = try decode(String.self, forKey: key)
|
||||||
|
guard !json.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
||||||
|
let data = json.data(using: .utf8) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return try JSONDecoder().decode(T.self, from: data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeLossyBool(forKey key: Key) throws -> Bool? {
|
||||||
|
guard contains(key), try !decodeNil(forKey: key) else { return nil }
|
||||||
|
if let value = try? decode(Bool.self, forKey: key) { return value }
|
||||||
|
if let value = try? decode(Int.self, forKey: key) { return value != 0 }
|
||||||
|
let value = try decode(String.self, forKey: key).lowercased()
|
||||||
|
if ["true", "1", "yes"].contains(value) { return true }
|
||||||
|
if ["false", "0", "no"].contains(value) { return false }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeLossyInt(forKey key: Key) throws -> Int? {
|
||||||
|
guard contains(key), try !decodeNil(forKey: key) else { return nil }
|
||||||
|
if let value = try? decode(Int.self, forKey: key) { return value }
|
||||||
|
return Int(try decode(String.self, forKey: key))
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeLossyInt64(forKey key: Key) throws -> Int64? {
|
||||||
|
guard contains(key), try !decodeNil(forKey: key) else { return nil }
|
||||||
|
if let value = try? decode(Int64.self, forKey: key) { return value }
|
||||||
|
return Int64(try decode(String.self, forKey: key))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct BookSourceEditorField: Identifiable, Sendable {
|
||||||
|
public let path: String
|
||||||
|
public let title: String
|
||||||
|
public var id: String { path }
|
||||||
|
public var help: String {
|
||||||
|
switch path {
|
||||||
|
case "bookSourceUrl": return "填写书源站点地址,例如 https://example.com;它也是书源的唯一标识。"
|
||||||
|
case "bookSourceName": return "显示在书源列表中的名称,不能为空。"
|
||||||
|
case "bookSourceGroup": return "多个分组可用逗号或分号分隔。"
|
||||||
|
case "bookUrlPattern": return "匹配该书源的书籍详情 URL 的正则表达式,用于从网址添加书籍。"
|
||||||
|
case "concurrentRate": return "1000 表示请求间隔 1000 ms;1/1000 表示每 1000 ms 最多 1 次请求。"
|
||||||
|
case "header": return "填写请求头 JSON,或用 @js: / <js> 动态生成。User-Agent 为 null 可禁用默认 UA。"
|
||||||
|
case "jsLib": return "填写共享 JavaScript,或以 JSON 对象配置在线脚本库。"
|
||||||
|
case "searchUrl": return "用 {{key}} 表示关键词、{{page}} 表示页码;URL 后可用逗号附加请求选项 JSON。"
|
||||||
|
case "exploreUrl": return "填写 名称::地址,多项用换行或 && 分隔;也支持 JSON 分类数组和 JavaScript。"
|
||||||
|
case "loginUi": return "填写登录界面 JSON。"
|
||||||
|
case "ruleToc.formatJs": return "目录去重后处理标题,可使用 title、index(从 1 开始)和 gInt。"
|
||||||
|
case "ruleContent.nextContentUrl": return "提取当前章的下一分页地址,而非下一章。"
|
||||||
|
case "ruleContent.replaceRegex": return "在多页正文合并后执行的替换规则,使用 ## 分隔正则和替换内容。"
|
||||||
|
case "ruleContent.imageStyle": return "FULL 表示图片铺满;留空使用默认样式。"
|
||||||
|
default:
|
||||||
|
if isJavaScript { return "使用 JavaScript 编写,可通过更多菜单调试当前书源草稿。" }
|
||||||
|
if path.contains(".") { return "支持 CSS / JSoup、XPath、JSONPath、正则和 JavaScript;留空表示不配置此项。" }
|
||||||
|
return "留空表示不配置此项。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
public var isJavaScript: Bool {
|
||||||
|
path.lowercased().contains("js") || path == "jsLib" || path.hasSuffix("imageDecode") || path.hasSuffix("payAction")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct BookSourceEditorSection: Identifiable, Sendable {
|
||||||
|
public let id: String
|
||||||
|
public let title: String
|
||||||
|
public let fields: [BookSourceEditorField]
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum BookSourceEditorSchema {
|
||||||
|
public static let sections: [BookSourceEditorSection] = [
|
||||||
|
.init(id: "basic", title: "基础", fields: [
|
||||||
|
.init(path: "bookSourceName", title: "书源名称"),
|
||||||
|
.init(path: "bookSourceUrl", title: "书源地址"),
|
||||||
|
.init(path: "bookSourceGroup", title: "分组"),
|
||||||
|
.init(path: "bookSourceComment", title: "备注"),
|
||||||
|
.init(path: "bookUrlPattern", title: "书籍 URL 正则"),
|
||||||
|
.init(path: "concurrentRate", title: "并发率"),
|
||||||
|
.init(path: "header", title: "请求头"),
|
||||||
|
.init(path: "jsLib", title: "JavaScript 库"),
|
||||||
|
.init(path: "variableComment", title: "变量说明"),
|
||||||
|
]),
|
||||||
|
.init(id: "login", title: "登录", fields: [
|
||||||
|
.init(path: "loginUrl", title: "登录地址 / 脚本"),
|
||||||
|
.init(path: "loginUi", title: "自定义登录界面"),
|
||||||
|
.init(path: "loginCheckJs", title: "登录检测脚本"),
|
||||||
|
.init(path: "coverDecodeJs", title: "封面解密脚本"),
|
||||||
|
]),
|
||||||
|
.init(id: "search", title: "搜索", fields: [
|
||||||
|
.init(path: "searchUrl", title: "搜索地址"),
|
||||||
|
.init(path: "ruleSearch.checkKeyWord", title: "测试关键词"),
|
||||||
|
.init(path: "ruleSearch.bookList", title: "书籍列表"),
|
||||||
|
.init(path: "ruleSearch.name", title: "书名"),
|
||||||
|
.init(path: "ruleSearch.author", title: "作者"),
|
||||||
|
.init(path: "ruleSearch.intro", title: "简介"),
|
||||||
|
.init(path: "ruleSearch.kind", title: "分类"),
|
||||||
|
.init(path: "ruleSearch.lastChapter", title: "最新章节"),
|
||||||
|
.init(path: "ruleSearch.updateTime", title: "更新时间"),
|
||||||
|
.init(path: "ruleSearch.bookUrl", title: "详情地址"),
|
||||||
|
.init(path: "ruleSearch.coverUrl", title: "封面地址"),
|
||||||
|
.init(path: "ruleSearch.wordCount", title: "字数"),
|
||||||
|
]),
|
||||||
|
.init(id: "explore", title: "发现", fields: [
|
||||||
|
.init(path: "exploreUrl", title: "发现分类地址"),
|
||||||
|
.init(path: "exploreScreen", title: "发现筛选配置"),
|
||||||
|
.init(path: "ruleExplore.bookList", title: "书籍列表"),
|
||||||
|
.init(path: "ruleExplore.name", title: "书名"),
|
||||||
|
.init(path: "ruleExplore.author", title: "作者"),
|
||||||
|
.init(path: "ruleExplore.intro", title: "简介"),
|
||||||
|
.init(path: "ruleExplore.kind", title: "分类"),
|
||||||
|
.init(path: "ruleExplore.lastChapter", title: "最新章节"),
|
||||||
|
.init(path: "ruleExplore.updateTime", title: "更新时间"),
|
||||||
|
.init(path: "ruleExplore.bookUrl", title: "详情地址"),
|
||||||
|
.init(path: "ruleExplore.coverUrl", title: "封面地址"),
|
||||||
|
.init(path: "ruleExplore.wordCount", title: "字数"),
|
||||||
|
]),
|
||||||
|
.init(id: "info", title: "详情", fields: [
|
||||||
|
.init(path: "ruleBookInfo.init", title: "预处理"),
|
||||||
|
.init(path: "ruleBookInfo.name", title: "书名"),
|
||||||
|
.init(path: "ruleBookInfo.author", title: "作者"),
|
||||||
|
.init(path: "ruleBookInfo.intro", title: "简介"),
|
||||||
|
.init(path: "ruleBookInfo.kind", title: "分类"),
|
||||||
|
.init(path: "ruleBookInfo.lastChapter", title: "最新章节"),
|
||||||
|
.init(path: "ruleBookInfo.updateTime", title: "更新时间"),
|
||||||
|
.init(path: "ruleBookInfo.coverUrl", title: "封面地址"),
|
||||||
|
.init(path: "ruleBookInfo.tocUrl", title: "目录地址"),
|
||||||
|
.init(path: "ruleBookInfo.wordCount", title: "字数"),
|
||||||
|
.init(path: "ruleBookInfo.canReName", title: "允许修改书名作者"),
|
||||||
|
.init(path: "ruleBookInfo.downloadUrls", title: "文件下载地址"),
|
||||||
|
]),
|
||||||
|
.init(id: "toc", title: "目录", fields: [
|
||||||
|
.init(path: "ruleToc.preUpdateJs", title: "更新前脚本"),
|
||||||
|
.init(path: "ruleToc.chapterList", title: "章节列表"),
|
||||||
|
.init(path: "ruleToc.chapterName", title: "章节名称"),
|
||||||
|
.init(path: "ruleToc.chapterUrl", title: "章节地址"),
|
||||||
|
.init(path: "ruleToc.formatJs", title: "标题处理脚本"),
|
||||||
|
.init(path: "ruleToc.isVolume", title: "卷名标识"),
|
||||||
|
.init(path: "ruleToc.isVip", title: "VIP 标识"),
|
||||||
|
.init(path: "ruleToc.isPay", title: "已购买标识"),
|
||||||
|
.init(path: "ruleToc.updateTime", title: "章节信息"),
|
||||||
|
.init(path: "ruleToc.nextTocUrl", title: "目录下一页"),
|
||||||
|
]),
|
||||||
|
.init(id: "content", title: "正文", fields: [
|
||||||
|
.init(path: "ruleContent.content", title: "正文"),
|
||||||
|
.init(path: "ruleContent.subContent", title: "副正文"),
|
||||||
|
.init(path: "ruleContent.title", title: "正文标题"),
|
||||||
|
.init(path: "ruleContent.nextContentUrl", title: "正文下一页"),
|
||||||
|
.init(path: "ruleContent.webJs", title: "WebView 注入脚本"),
|
||||||
|
.init(path: "ruleContent.sourceRegex", title: "资源正则"),
|
||||||
|
.init(path: "ruleContent.replaceRegex", title: "合并后替换"),
|
||||||
|
.init(path: "ruleContent.imageStyle", title: "图片样式"),
|
||||||
|
.init(path: "ruleContent.imageDecode", title: "图片解密脚本"),
|
||||||
|
.init(path: "ruleContent.payAction", title: "购买脚本"),
|
||||||
|
.init(path: "ruleContent.callBackJs", title: "事件回调脚本"),
|
||||||
|
]),
|
||||||
|
.init(id: "review", title: "段评", fields: [
|
||||||
|
.init(path: "ruleReview.reviewUrl", title: "段评地址"),
|
||||||
|
.init(path: "ruleReview.avatarRule", title: "头像"),
|
||||||
|
.init(path: "ruleReview.contentRule", title: "内容"),
|
||||||
|
.init(path: "ruleReview.postTimeRule", title: "发布时间"),
|
||||||
|
.init(path: "ruleReview.reviewQuoteUrl", title: "回复地址"),
|
||||||
|
.init(path: "ruleReview.voteUpUrl", title: "点赞地址"),
|
||||||
|
.init(path: "ruleReview.voteDownUrl", title: "点踩地址"),
|
||||||
|
.init(path: "ruleReview.postReviewUrl", title: "发布段评"),
|
||||||
|
.init(path: "ruleReview.postQuoteUrl", title: "发布回复"),
|
||||||
|
.init(path: "ruleReview.deleteUrl", title: "删除地址"),
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct BookSourceEditorDocument: Sendable, Equatable {
|
||||||
|
public private(set) var fields: [String: JSONValue]
|
||||||
|
|
||||||
|
public init(source: BookSource) throws {
|
||||||
|
self = try Self(json: String(decoding: JSONEncoder().encode(source), as: UTF8.self))
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(json: String) throws {
|
||||||
|
guard case .object(let object) = try JSONValue.decode(data: Data(json.utf8)) else {
|
||||||
|
throw BookSourceImportError.invalidDocument("编辑器需要单个书源 JSON 对象")
|
||||||
|
}
|
||||||
|
fields = object
|
||||||
|
for key in Self.ruleKeys {
|
||||||
|
if case .string(let text) = fields[key], !text.isEmpty,
|
||||||
|
case .object(let object) = try JSONValue.decode(data: Data(text.utf8)) {
|
||||||
|
fields[key] = .object(object)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func value(at path: String) -> JSONValue? {
|
||||||
|
let keys = path.split(separator: ".").map(String.init)
|
||||||
|
guard let key = keys.first else { return nil }
|
||||||
|
return keys.count == 1 ? fields[key] : fields[key]?[keys[1]]
|
||||||
|
}
|
||||||
|
|
||||||
|
public mutating func set(_ value: JSONValue?, at path: String) {
|
||||||
|
let keys = path.split(separator: ".").map(String.init)
|
||||||
|
guard let key = keys.first else { return }
|
||||||
|
if keys.count == 1 { fields[key] = value; return }
|
||||||
|
var object: [String: JSONValue] = [:]
|
||||||
|
if case .object(let existing) = fields[key] { object = existing }
|
||||||
|
object[keys[1]] = value
|
||||||
|
fields[key] = object.isEmpty ? nil : .object(object)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func json() throws -> String {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
|
||||||
|
return String(decoding: try encoder.encode(fields), as: UTF8.self)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func source() throws -> BookSource {
|
||||||
|
let source = try JSONDecoder().decode(BookSource.self, from: JSONEncoder().encode(fields))
|
||||||
|
let errors = BookSourceValidator.validate(source).filter { $0.severity == .error }
|
||||||
|
guard errors.isEmpty else { throw BookSourceImportError.invalidSources(errors) }
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
|
||||||
|
static let ruleKeys = ["ruleSearch", "ruleExplore", "ruleBookInfo", "ruleToc", "ruleContent", "ruleReview"]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SourceCodingKey: CodingKey {
|
||||||
|
let stringValue: String
|
||||||
|
let intValue: Int? = nil
|
||||||
|
init(_ value: String) { stringValue = value }
|
||||||
|
init?(stringValue: String) { self.init(stringValue) }
|
||||||
|
init?(intValue: Int) { return nil }
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct BookListRule: Codable, Hashable, Sendable {
|
||||||
|
public var bookList: String?
|
||||||
|
public var name: String?
|
||||||
|
public var author: String?
|
||||||
|
public var intro: String?
|
||||||
|
public var kind: String?
|
||||||
|
public var lastChapter: String?
|
||||||
|
public var updateTime: String?
|
||||||
|
public var bookUrl: String?
|
||||||
|
public var coverUrl: String?
|
||||||
|
public var wordCount: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
bookList: String? = nil,
|
||||||
|
name: String? = nil,
|
||||||
|
author: String? = nil,
|
||||||
|
intro: String? = nil,
|
||||||
|
kind: String? = nil,
|
||||||
|
lastChapter: String? = nil,
|
||||||
|
updateTime: String? = nil,
|
||||||
|
bookUrl: String? = nil,
|
||||||
|
coverUrl: String? = nil,
|
||||||
|
wordCount: String? = nil
|
||||||
|
) {
|
||||||
|
self.bookList = bookList
|
||||||
|
self.name = name
|
||||||
|
self.author = author
|
||||||
|
self.intro = intro
|
||||||
|
self.kind = kind
|
||||||
|
self.lastChapter = lastChapter
|
||||||
|
self.updateTime = updateTime
|
||||||
|
self.bookUrl = bookUrl
|
||||||
|
self.coverUrl = coverUrl
|
||||||
|
self.wordCount = wordCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SearchRule: Codable, Hashable, Sendable {
|
||||||
|
public var checkKeyWord: String?
|
||||||
|
public var bookList: String?
|
||||||
|
public var name: String?
|
||||||
|
public var author: String?
|
||||||
|
public var intro: String?
|
||||||
|
public var kind: String?
|
||||||
|
public var lastChapter: String?
|
||||||
|
public var updateTime: String?
|
||||||
|
public var bookUrl: String?
|
||||||
|
public var coverUrl: String?
|
||||||
|
public var wordCount: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
checkKeyWord: String? = nil,
|
||||||
|
bookList: String? = nil,
|
||||||
|
name: String? = nil,
|
||||||
|
author: String? = nil,
|
||||||
|
intro: String? = nil,
|
||||||
|
kind: String? = nil,
|
||||||
|
lastChapter: String? = nil,
|
||||||
|
updateTime: String? = nil,
|
||||||
|
bookUrl: String? = nil,
|
||||||
|
coverUrl: String? = nil,
|
||||||
|
wordCount: String? = nil
|
||||||
|
) {
|
||||||
|
self.checkKeyWord = checkKeyWord
|
||||||
|
self.bookList = bookList
|
||||||
|
self.name = name
|
||||||
|
self.author = author
|
||||||
|
self.intro = intro
|
||||||
|
self.kind = kind
|
||||||
|
self.lastChapter = lastChapter
|
||||||
|
self.updateTime = updateTime
|
||||||
|
self.bookUrl = bookUrl
|
||||||
|
self.coverUrl = coverUrl
|
||||||
|
self.wordCount = wordCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public typealias ExploreRule = BookListRule
|
||||||
|
|
||||||
|
public struct BookInfoRule: Codable, Hashable, Sendable {
|
||||||
|
public var `init`: String?
|
||||||
|
public var name: String?
|
||||||
|
public var author: String?
|
||||||
|
public var intro: String?
|
||||||
|
public var kind: String?
|
||||||
|
public var lastChapter: String?
|
||||||
|
public var updateTime: String?
|
||||||
|
public var coverUrl: String?
|
||||||
|
public var tocUrl: String?
|
||||||
|
public var wordCount: String?
|
||||||
|
public var canReName: String?
|
||||||
|
public var downloadUrls: String?
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct TocRule: Codable, Hashable, Sendable {
|
||||||
|
public var preUpdateJs: String?
|
||||||
|
public var chapterList: String?
|
||||||
|
public var chapterName: String?
|
||||||
|
public var chapterUrl: String?
|
||||||
|
public var formatJs: String?
|
||||||
|
public var isVolume: String?
|
||||||
|
public var isVip: String?
|
||||||
|
public var isPay: String?
|
||||||
|
public var updateTime: String?
|
||||||
|
public var nextTocUrl: String?
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ContentRule: Codable, Hashable, Sendable {
|
||||||
|
public var content: String?
|
||||||
|
public var subContent: String?
|
||||||
|
public var title: String?
|
||||||
|
public var nextContentUrl: String?
|
||||||
|
public var webJs: String?
|
||||||
|
public var sourceRegex: String?
|
||||||
|
public var replaceRegex: String?
|
||||||
|
public var imageStyle: String?
|
||||||
|
public var imageDecode: String?
|
||||||
|
public var payAction: String?
|
||||||
|
public var callBackJs: String?
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ReviewRule: Codable, Hashable, Sendable {
|
||||||
|
public var reviewUrl: String?
|
||||||
|
public var avatarRule: String?
|
||||||
|
public var contentRule: String?
|
||||||
|
public var postTimeRule: String?
|
||||||
|
public var reviewQuoteUrl: String?
|
||||||
|
public var voteUpUrl: String?
|
||||||
|
public var voteDownUrl: String?
|
||||||
|
public var postReviewUrl: String?
|
||||||
|
public var postQuoteUrl: String?
|
||||||
|
public var deleteUrl: String?
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct ExploreKind: Codable, Hashable, Identifiable, Sendable {
|
||||||
|
public var title: String
|
||||||
|
public var url: String?
|
||||||
|
public var type: String
|
||||||
|
public var action: String?
|
||||||
|
public var chars: [String?]?
|
||||||
|
public var defaultValue: String?
|
||||||
|
|
||||||
|
public var id: String { "\(title)\u{0}\(url ?? "")" }
|
||||||
|
|
||||||
|
public var navigableURL: String? {
|
||||||
|
guard type.caseInsensitiveCompare("url") == .orderedSame,
|
||||||
|
let value = url?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!value.isEmpty else { return nil }
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case title, url, type, action, chars
|
||||||
|
case defaultValue = "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(
|
||||||
|
title: String,
|
||||||
|
url: String? = nil,
|
||||||
|
type: String = "url",
|
||||||
|
action: String? = nil,
|
||||||
|
chars: [String?]? = nil,
|
||||||
|
defaultValue: String? = nil
|
||||||
|
) {
|
||||||
|
self.title = title
|
||||||
|
self.url = url
|
||||||
|
self.type = type
|
||||||
|
self.action = action
|
||||||
|
self.chars = chars
|
||||||
|
self.defaultValue = defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
title = values.lossyString(forKey: .title) ?? ""
|
||||||
|
url = values.lossyString(forKey: .url)
|
||||||
|
type = values.lossyString(forKey: .type) ?? "url"
|
||||||
|
action = values.lossyString(forKey: .action)
|
||||||
|
defaultValue = values.lossyString(forKey: .defaultValue)
|
||||||
|
if let decoded = try? values.decodeIfPresent([JSONValue].self, forKey: .chars) {
|
||||||
|
chars = decoded.map { value in
|
||||||
|
if case .null = value { return nil }
|
||||||
|
return value.stringValue
|
||||||
|
}
|
||||||
|
} else if let scalar = values.lossyString(forKey: .chars) {
|
||||||
|
chars = [scalar]
|
||||||
|
} else {
|
||||||
|
chars = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension KeyedDecodingContainer where Key == ExploreKind.CodingKeys {
|
||||||
|
func lossyString(forKey key: Key) -> String? {
|
||||||
|
guard contains(key), (try? decodeNil(forKey: key)) != true else { return nil }
|
||||||
|
if let value = try? decode(String.self, forKey: key) { return value }
|
||||||
|
if let value = try? decode(Bool.self, forKey: key) { return String(value) }
|
||||||
|
if let value = try? decode(Int64.self, forKey: key) { return String(value) }
|
||||||
|
if let value = try? decode(Double.self, forKey: key) {
|
||||||
|
return value.rounded() == value ? String(format: "%.0f", value) : String(value)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ExploreKindsReport: Sendable {
|
||||||
|
public var kinds: [ExploreKind]
|
||||||
|
public var diagnostics: [RuleDiagnostic]
|
||||||
|
|
||||||
|
public init(kinds: [ExploreKind] = [], diagnostics: [RuleDiagnostic] = []) {
|
||||||
|
self.kinds = kinds
|
||||||
|
self.diagnostics = diagnostics
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,474 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct Book: Codable, Hashable, Identifiable, Sendable {
|
||||||
|
public var bookUrl: String
|
||||||
|
public var tocUrl: String
|
||||||
|
public var origin: String
|
||||||
|
public var originName: String
|
||||||
|
public var name: String
|
||||||
|
public var author: String
|
||||||
|
public var kind: String?
|
||||||
|
public var customTag: String?
|
||||||
|
public var coverUrl: String?
|
||||||
|
public var customCoverUrl: String?
|
||||||
|
public var intro: String?
|
||||||
|
public var customIntro: String?
|
||||||
|
public var charset: String?
|
||||||
|
public var type: Int
|
||||||
|
public var group: Int64
|
||||||
|
public var latestChapterTitle: String?
|
||||||
|
public var latestChapterTime: Int64
|
||||||
|
public var lastCheckTime: Int64
|
||||||
|
public var lastCheckCount: Int
|
||||||
|
public var totalChapterNum: Int
|
||||||
|
public var durChapterTitle: String?
|
||||||
|
public var durChapterIndex: Int
|
||||||
|
public var durVolumeIndex: Int
|
||||||
|
public var chapterInVolumeIndex: Int
|
||||||
|
public var durChapterPos: Int
|
||||||
|
public var durChapterTime: Int64
|
||||||
|
public var wordCount: String?
|
||||||
|
public var canUpdate: Bool
|
||||||
|
public var order: Int
|
||||||
|
public var originOrder: Int
|
||||||
|
public var variable: String?
|
||||||
|
public var readConfig: BookReadConfig?
|
||||||
|
public var syncTime: Int64
|
||||||
|
|
||||||
|
public var id: String { bookUrl }
|
||||||
|
|
||||||
|
public init(bookUrl: String, name: String, author: String = "", origin: String = "local") {
|
||||||
|
self.bookUrl = bookUrl
|
||||||
|
tocUrl = ""
|
||||||
|
self.origin = origin
|
||||||
|
originName = ""
|
||||||
|
self.name = name
|
||||||
|
self.author = author
|
||||||
|
kind = nil
|
||||||
|
customTag = nil
|
||||||
|
coverUrl = nil
|
||||||
|
customCoverUrl = nil
|
||||||
|
intro = nil
|
||||||
|
customIntro = nil
|
||||||
|
charset = nil
|
||||||
|
type = 0
|
||||||
|
group = 0
|
||||||
|
latestChapterTitle = nil
|
||||||
|
latestChapterTime = 0
|
||||||
|
lastCheckTime = 0
|
||||||
|
lastCheckCount = 0
|
||||||
|
totalChapterNum = 0
|
||||||
|
durChapterTitle = nil
|
||||||
|
durChapterIndex = 0
|
||||||
|
durVolumeIndex = 0
|
||||||
|
chapterInVolumeIndex = 0
|
||||||
|
durChapterPos = 0
|
||||||
|
durChapterTime = 0
|
||||||
|
wordCount = nil
|
||||||
|
canUpdate = true
|
||||||
|
order = 0
|
||||||
|
originOrder = 0
|
||||||
|
variable = nil
|
||||||
|
readConfig = nil
|
||||||
|
syncTime = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case bookUrl, tocUrl, origin, originName, name, author, kind, customTag, coverUrl, customCoverUrl
|
||||||
|
case intro, customIntro, charset, type, group, latestChapterTitle, latestChapterTime, lastCheckTime
|
||||||
|
case lastCheckCount, totalChapterNum, durChapterTitle, durChapterIndex, durVolumeIndex
|
||||||
|
case chapterInVolumeIndex, durChapterPos, durChapterTime, wordCount, canUpdate, order, originOrder
|
||||||
|
case variable, readConfig, syncTime
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let value = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
bookUrl = try value.decodeIfPresent(String.self, forKey: .bookUrl) ?? ""
|
||||||
|
tocUrl = try value.decodeIfPresent(String.self, forKey: .tocUrl) ?? ""
|
||||||
|
origin = try value.decodeIfPresent(String.self, forKey: .origin) ?? "local"
|
||||||
|
originName = try value.decodeIfPresent(String.self, forKey: .originName) ?? ""
|
||||||
|
name = try value.decodeIfPresent(String.self, forKey: .name) ?? ""
|
||||||
|
author = try value.decodeIfPresent(String.self, forKey: .author) ?? ""
|
||||||
|
kind = try value.decodeIfPresent(String.self, forKey: .kind)
|
||||||
|
customTag = try value.decodeIfPresent(String.self, forKey: .customTag)
|
||||||
|
coverUrl = try value.decodeIfPresent(String.self, forKey: .coverUrl)
|
||||||
|
customCoverUrl = try value.decodeIfPresent(String.self, forKey: .customCoverUrl)
|
||||||
|
intro = try value.decodeIfPresent(String.self, forKey: .intro)
|
||||||
|
customIntro = try value.decodeIfPresent(String.self, forKey: .customIntro)
|
||||||
|
charset = try value.decodeIfPresent(String.self, forKey: .charset)
|
||||||
|
type = try value.decodeIfPresent(Int.self, forKey: .type) ?? 0
|
||||||
|
group = try value.decodeIfPresent(Int64.self, forKey: .group) ?? 0
|
||||||
|
latestChapterTitle = try value.decodeIfPresent(String.self, forKey: .latestChapterTitle)
|
||||||
|
latestChapterTime = try value.decodeIfPresent(Int64.self, forKey: .latestChapterTime) ?? 0
|
||||||
|
lastCheckTime = try value.decodeIfPresent(Int64.self, forKey: .lastCheckTime) ?? 0
|
||||||
|
lastCheckCount = try value.decodeIfPresent(Int.self, forKey: .lastCheckCount) ?? 0
|
||||||
|
totalChapterNum = try value.decodeIfPresent(Int.self, forKey: .totalChapterNum) ?? 0
|
||||||
|
durChapterTitle = try value.decodeIfPresent(String.self, forKey: .durChapterTitle)
|
||||||
|
durChapterIndex = try value.decodeIfPresent(Int.self, forKey: .durChapterIndex) ?? 0
|
||||||
|
durVolumeIndex = try value.decodeIfPresent(Int.self, forKey: .durVolumeIndex) ?? 0
|
||||||
|
chapterInVolumeIndex = try value.decodeIfPresent(Int.self, forKey: .chapterInVolumeIndex) ?? 0
|
||||||
|
durChapterPos = try value.decodeIfPresent(Int.self, forKey: .durChapterPos) ?? 0
|
||||||
|
durChapterTime = try value.decodeIfPresent(Int64.self, forKey: .durChapterTime) ?? 0
|
||||||
|
wordCount = try value.decodeIfPresent(String.self, forKey: .wordCount)
|
||||||
|
canUpdate = try value.decodeIfPresent(Bool.self, forKey: .canUpdate) ?? true
|
||||||
|
order = try value.decodeIfPresent(Int.self, forKey: .order) ?? 0
|
||||||
|
originOrder = try value.decodeIfPresent(Int.self, forKey: .originOrder) ?? 0
|
||||||
|
variable = try value.decodeIfPresent(String.self, forKey: .variable)
|
||||||
|
readConfig = try value.decodeIfPresent(BookReadConfig.self, forKey: .readConfig)
|
||||||
|
syncTime = try value.decodeIfPresent(Int64.self, forKey: .syncTime) ?? 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct BookReadConfig: Codable, Hashable, Sendable {
|
||||||
|
public var reverseToc = false
|
||||||
|
public var pageAnim: Int?
|
||||||
|
public var reSegment = false
|
||||||
|
public var imageStyle: String?
|
||||||
|
public var useReplaceRule: Bool?
|
||||||
|
public var delTag: Int64 = 0
|
||||||
|
public var ttsEngine: String?
|
||||||
|
public var splitLongChapter = true
|
||||||
|
public var readSimulating = false
|
||||||
|
public var startDate: String?
|
||||||
|
public var startChapter: Int?
|
||||||
|
public var dailyChapters = 3
|
||||||
|
public var openCredits = 0
|
||||||
|
public var closeCredits = 0
|
||||||
|
public var playMode = 0
|
||||||
|
public var playSpeed: Float = 1
|
||||||
|
|
||||||
|
public init() {}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case reverseToc, pageAnim, reSegment, imageStyle, useReplaceRule, delTag, ttsEngine
|
||||||
|
case splitLongChapter, readSimulating, startDate, startChapter, dailyChapters
|
||||||
|
case openCredits, closeCredits, playMode, playSpeed
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let value = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
reverseToc = try value.decodeIfPresent(Bool.self, forKey: .reverseToc) ?? false
|
||||||
|
pageAnim = try value.decodeIfPresent(Int.self, forKey: .pageAnim)
|
||||||
|
reSegment = try value.decodeIfPresent(Bool.self, forKey: .reSegment) ?? false
|
||||||
|
imageStyle = try value.decodeIfPresent(String.self, forKey: .imageStyle)
|
||||||
|
useReplaceRule = try value.decodeIfPresent(Bool.self, forKey: .useReplaceRule)
|
||||||
|
delTag = try value.decodeIfPresent(Int64.self, forKey: .delTag) ?? 0
|
||||||
|
ttsEngine = try value.decodeIfPresent(String.self, forKey: .ttsEngine)
|
||||||
|
splitLongChapter = try value.decodeIfPresent(Bool.self, forKey: .splitLongChapter) ?? true
|
||||||
|
readSimulating = try value.decodeIfPresent(Bool.self, forKey: .readSimulating) ?? false
|
||||||
|
startDate = try value.decodeIfPresent(String.self, forKey: .startDate)
|
||||||
|
startChapter = try value.decodeIfPresent(Int.self, forKey: .startChapter)
|
||||||
|
dailyChapters = try value.decodeIfPresent(Int.self, forKey: .dailyChapters) ?? 3
|
||||||
|
openCredits = try value.decodeIfPresent(Int.self, forKey: .openCredits) ?? 0
|
||||||
|
closeCredits = try value.decodeIfPresent(Int.self, forKey: .closeCredits) ?? 0
|
||||||
|
playMode = try value.decodeIfPresent(Int.self, forKey: .playMode) ?? 0
|
||||||
|
playSpeed = try value.decodeIfPresent(Float.self, forKey: .playSpeed) ?? 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct BookGroup: Codable, Hashable, Identifiable, Sendable {
|
||||||
|
public static let idRoot: Int64 = -100
|
||||||
|
public static let idAll: Int64 = -1
|
||||||
|
public static let idLocal: Int64 = -2
|
||||||
|
public static let idAudio: Int64 = -3
|
||||||
|
public static let idNetNone: Int64 = -4
|
||||||
|
public static let idLocalNone: Int64 = -5
|
||||||
|
public static let idVideo: Int64 = -6
|
||||||
|
public static let idError: Int64 = -11
|
||||||
|
|
||||||
|
public var groupId: Int64
|
||||||
|
public var groupName: String
|
||||||
|
public var cover: String?
|
||||||
|
public var order: Int
|
||||||
|
public var enableRefresh: Bool
|
||||||
|
public var show: Bool
|
||||||
|
public var bookSort: Int
|
||||||
|
public var onlyUpdateRead: Bool
|
||||||
|
|
||||||
|
public var id: Int64 { groupId }
|
||||||
|
|
||||||
|
public init(
|
||||||
|
groupId: Int64,
|
||||||
|
groupName: String,
|
||||||
|
cover: String? = nil,
|
||||||
|
order: Int = 0,
|
||||||
|
enableRefresh: Bool = true,
|
||||||
|
show: Bool = true,
|
||||||
|
bookSort: Int = -1,
|
||||||
|
onlyUpdateRead: Bool = false
|
||||||
|
) {
|
||||||
|
self.groupId = groupId
|
||||||
|
self.groupName = groupName
|
||||||
|
self.cover = cover
|
||||||
|
self.order = order
|
||||||
|
self.enableRefresh = enableRefresh
|
||||||
|
self.show = show
|
||||||
|
self.bookSort = bookSort
|
||||||
|
self.onlyUpdateRead = onlyUpdateRead
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct BookChapter: Codable, Hashable, Identifiable, Sendable {
|
||||||
|
public var url: String
|
||||||
|
public var title: String
|
||||||
|
public var isVolume: Bool
|
||||||
|
public var baseUrl: String
|
||||||
|
public var bookUrl: String
|
||||||
|
public var index: Int
|
||||||
|
public var isVip: Bool
|
||||||
|
public var isPay: Bool
|
||||||
|
public var resourceUrl: String?
|
||||||
|
public var tag: String?
|
||||||
|
public var wordCount: String?
|
||||||
|
public var start: Int64?
|
||||||
|
public var end: Int64?
|
||||||
|
public var startFragmentId: String?
|
||||||
|
public var endFragmentId: String?
|
||||||
|
public var variable: String?
|
||||||
|
public var imageURL: String?
|
||||||
|
|
||||||
|
public var id: String { "\(bookUrl)\u{0}\(url)" }
|
||||||
|
|
||||||
|
public init(url: String, title: String, bookUrl: String, index: Int) {
|
||||||
|
self.url = url
|
||||||
|
self.title = title
|
||||||
|
isVolume = false
|
||||||
|
baseUrl = ""
|
||||||
|
self.bookUrl = bookUrl
|
||||||
|
self.index = index
|
||||||
|
isVip = false
|
||||||
|
isPay = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case url, title, isVolume, baseUrl, bookUrl, index, isVip, isPay, resourceUrl, tag, wordCount
|
||||||
|
case start, end, startFragmentId, endFragmentId, variable, imgUrl, imageURL
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let value = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
url = try value.decodeIfPresent(String.self, forKey: .url) ?? ""
|
||||||
|
title = try value.decodeIfPresent(String.self, forKey: .title) ?? ""
|
||||||
|
isVolume = try value.decodeIfPresent(Bool.self, forKey: .isVolume) ?? false
|
||||||
|
baseUrl = try value.decodeIfPresent(String.self, forKey: .baseUrl) ?? ""
|
||||||
|
bookUrl = try value.decodeIfPresent(String.self, forKey: .bookUrl) ?? ""
|
||||||
|
index = try value.decodeIfPresent(Int.self, forKey: .index) ?? 0
|
||||||
|
isVip = try value.decodeIfPresent(Bool.self, forKey: .isVip) ?? false
|
||||||
|
isPay = try value.decodeIfPresent(Bool.self, forKey: .isPay) ?? false
|
||||||
|
resourceUrl = try value.decodeIfPresent(String.self, forKey: .resourceUrl)
|
||||||
|
tag = try value.decodeIfPresent(String.self, forKey: .tag)
|
||||||
|
wordCount = try value.decodeIfPresent(String.self, forKey: .wordCount)
|
||||||
|
start = try value.decodeIfPresent(Int64.self, forKey: .start)
|
||||||
|
end = try value.decodeIfPresent(Int64.self, forKey: .end)
|
||||||
|
startFragmentId = try value.decodeIfPresent(String.self, forKey: .startFragmentId)
|
||||||
|
endFragmentId = try value.decodeIfPresent(String.self, forKey: .endFragmentId)
|
||||||
|
variable = try value.decodeIfPresent(String.self, forKey: .variable)
|
||||||
|
imageURL = try value.decodeIfPresent(String.self, forKey: .imgUrl)
|
||||||
|
?? value.decodeIfPresent(String.self, forKey: .imageURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var value = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try value.encode(url, forKey: .url)
|
||||||
|
try value.encode(title, forKey: .title)
|
||||||
|
try value.encode(isVolume, forKey: .isVolume)
|
||||||
|
try value.encode(baseUrl, forKey: .baseUrl)
|
||||||
|
try value.encode(bookUrl, forKey: .bookUrl)
|
||||||
|
try value.encode(index, forKey: .index)
|
||||||
|
try value.encode(isVip, forKey: .isVip)
|
||||||
|
try value.encode(isPay, forKey: .isPay)
|
||||||
|
try value.encodeIfPresent(resourceUrl, forKey: .resourceUrl)
|
||||||
|
try value.encodeIfPresent(tag, forKey: .tag)
|
||||||
|
try value.encodeIfPresent(wordCount, forKey: .wordCount)
|
||||||
|
try value.encodeIfPresent(start, forKey: .start)
|
||||||
|
try value.encodeIfPresent(end, forKey: .end)
|
||||||
|
try value.encodeIfPresent(startFragmentId, forKey: .startFragmentId)
|
||||||
|
try value.encodeIfPresent(endFragmentId, forKey: .endFragmentId)
|
||||||
|
try value.encodeIfPresent(variable, forKey: .variable)
|
||||||
|
try value.encodeIfPresent(imageURL, forKey: .imgUrl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Bookmark: Codable, Hashable, Identifiable, Sendable {
|
||||||
|
public var time: Int64
|
||||||
|
public var bookName: String
|
||||||
|
public var bookAuthor: String
|
||||||
|
public var chapterIndex: Int
|
||||||
|
public var chapterPosition: Int
|
||||||
|
public var chapterName: String
|
||||||
|
public var bookText: String
|
||||||
|
public var content: String
|
||||||
|
|
||||||
|
public var id: Int64 { time }
|
||||||
|
|
||||||
|
public init(time: Int64, bookName: String, bookAuthor: String = "") {
|
||||||
|
self.time = time
|
||||||
|
self.bookName = bookName
|
||||||
|
self.bookAuthor = bookAuthor
|
||||||
|
chapterIndex = 0
|
||||||
|
chapterPosition = 0
|
||||||
|
chapterName = ""
|
||||||
|
bookText = ""
|
||||||
|
content = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case time, bookName, bookAuthor, chapterIndex, chapterPos, chapterPosition
|
||||||
|
case chapterName, bookText, content
|
||||||
|
}
|
||||||
|
|
||||||
|
public func resolvedChapterIndex(in chapters: [BookChapter]) -> Int? {
|
||||||
|
if !chapterName.isEmpty,
|
||||||
|
let index = chapters.firstIndex(where: { !$0.isVolume && $0.title == chapterName }) {
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
return chapters.firstIndex(where: { !$0.isVolume && $0.index == chapterIndex })
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
time = try values.decodeIfPresent(Int64.self, forKey: .time) ?? Int64(Date().timeIntervalSince1970 * 1_000)
|
||||||
|
bookName = try values.decodeIfPresent(String.self, forKey: .bookName) ?? ""
|
||||||
|
bookAuthor = try values.decodeIfPresent(String.self, forKey: .bookAuthor) ?? ""
|
||||||
|
chapterIndex = try values.decodeIfPresent(Int.self, forKey: .chapterIndex) ?? 0
|
||||||
|
chapterPosition = try values.decodeIfPresent(Int.self, forKey: .chapterPos)
|
||||||
|
?? values.decodeIfPresent(Int.self, forKey: .chapterPosition)
|
||||||
|
?? 0
|
||||||
|
chapterName = try values.decodeIfPresent(String.self, forKey: .chapterName) ?? ""
|
||||||
|
bookText = try values.decodeIfPresent(String.self, forKey: .bookText) ?? ""
|
||||||
|
content = try values.decodeIfPresent(String.self, forKey: .content) ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var values = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try values.encode(time, forKey: .time)
|
||||||
|
try values.encode(bookName, forKey: .bookName)
|
||||||
|
try values.encode(bookAuthor, forKey: .bookAuthor)
|
||||||
|
try values.encode(chapterIndex, forKey: .chapterIndex)
|
||||||
|
try values.encode(chapterPosition, forKey: .chapterPos)
|
||||||
|
try values.encode(chapterName, forKey: .chapterName)
|
||||||
|
try values.encode(bookText, forKey: .bookText)
|
||||||
|
try values.encode(content, forKey: .content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ReadRecord: Codable, Hashable, Identifiable, Sendable {
|
||||||
|
public var deviceId: String
|
||||||
|
public var bookName: String
|
||||||
|
public var readTime: Int64
|
||||||
|
public var lastRead: Int64
|
||||||
|
public var dailyReadTimes: [String: Int64]
|
||||||
|
|
||||||
|
public var id: String { "\(deviceId)\u{0}\(bookName)" }
|
||||||
|
|
||||||
|
public init(
|
||||||
|
deviceId: String,
|
||||||
|
bookName: String,
|
||||||
|
readTime: Int64 = 0,
|
||||||
|
lastRead: Int64? = nil,
|
||||||
|
dailyReadTimes: [String: Int64] = [:]
|
||||||
|
) {
|
||||||
|
self.deviceId = deviceId
|
||||||
|
self.bookName = bookName
|
||||||
|
self.readTime = readTime
|
||||||
|
self.lastRead = lastRead ?? Int64(Date().timeIntervalSince1970 * 1_000)
|
||||||
|
self.dailyReadTimes = dailyReadTimes
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case deviceId, bookName, readTime, lastRead, dailyReadTimes
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
deviceId = try values.decodeIfPresent(String.self, forKey: .deviceId) ?? ""
|
||||||
|
bookName = try values.decodeIfPresent(String.self, forKey: .bookName) ?? ""
|
||||||
|
readTime = try values.decodeIfPresent(Int64.self, forKey: .readTime) ?? 0
|
||||||
|
lastRead = try values.decodeIfPresent(Int64.self, forKey: .lastRead) ?? 0
|
||||||
|
dailyReadTimes = try values.decodeIfPresent([String: Int64].self, forKey: .dailyReadTimes) ?? [:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ReadRecordSummary: Hashable, Identifiable, Sendable {
|
||||||
|
public var bookName: String
|
||||||
|
public var readTime: Int64
|
||||||
|
public var lastRead: Int64
|
||||||
|
|
||||||
|
public var id: String { bookName }
|
||||||
|
|
||||||
|
public init(bookName: String, readTime: Int64, lastRead: Int64) {
|
||||||
|
self.bookName = bookName
|
||||||
|
self.readTime = readTime
|
||||||
|
self.lastRead = lastRead
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct ReplaceRule: Codable, Hashable, Identifiable, Sendable {
|
||||||
|
public var id: Int64
|
||||||
|
public var name: String
|
||||||
|
public var group: String?
|
||||||
|
public var pattern: String
|
||||||
|
public var replacement: String
|
||||||
|
public var scope: String?
|
||||||
|
public var scopeTitle: Bool
|
||||||
|
public var scopeContent: Bool
|
||||||
|
public var excludeScope: String?
|
||||||
|
public var isEnabled: Bool
|
||||||
|
public var isRegex: Bool
|
||||||
|
public var timeoutMillisecond: Int64
|
||||||
|
public var sortOrder: Int
|
||||||
|
|
||||||
|
public init(id: Int64, name: String, pattern: String, replacement: String = "") {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.pattern = pattern
|
||||||
|
self.replacement = replacement
|
||||||
|
scopeTitle = false
|
||||||
|
scopeContent = true
|
||||||
|
isEnabled = true
|
||||||
|
isRegex = true
|
||||||
|
timeoutMillisecond = 3_000
|
||||||
|
sortOrder = Int.min
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case id, name, group, pattern, replacement, scope, scopeTitle, scopeContent
|
||||||
|
case excludeScope, isEnabled, isRegex, timeoutMillisecond, sortOrder, order
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
id = try values.decodeIfPresent(Int64.self, forKey: .id) ?? Int64(Date().timeIntervalSince1970 * 1_000)
|
||||||
|
name = try values.decodeIfPresent(String.self, forKey: .name) ?? ""
|
||||||
|
group = try values.decodeIfPresent(String.self, forKey: .group)
|
||||||
|
pattern = try values.decodeIfPresent(String.self, forKey: .pattern) ?? ""
|
||||||
|
replacement = try values.decodeIfPresent(String.self, forKey: .replacement) ?? ""
|
||||||
|
scope = try values.decodeIfPresent(String.self, forKey: .scope)
|
||||||
|
scopeTitle = try values.decodeIfPresent(Bool.self, forKey: .scopeTitle) ?? false
|
||||||
|
scopeContent = try values.decodeIfPresent(Bool.self, forKey: .scopeContent) ?? true
|
||||||
|
excludeScope = try values.decodeIfPresent(String.self, forKey: .excludeScope)
|
||||||
|
isEnabled = try values.decodeIfPresent(Bool.self, forKey: .isEnabled) ?? true
|
||||||
|
isRegex = try values.decodeIfPresent(Bool.self, forKey: .isRegex) ?? true
|
||||||
|
timeoutMillisecond = try values.decodeIfPresent(Int64.self, forKey: .timeoutMillisecond) ?? 3_000
|
||||||
|
sortOrder = try values.decodeIfPresent(Int.self, forKey: .sortOrder)
|
||||||
|
?? values.decodeIfPresent(Int.self, forKey: .order)
|
||||||
|
?? Int.min
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var values = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try values.encode(id, forKey: .id)
|
||||||
|
try values.encode(name, forKey: .name)
|
||||||
|
try values.encodeIfPresent(group, forKey: .group)
|
||||||
|
try values.encode(pattern, forKey: .pattern)
|
||||||
|
try values.encode(replacement, forKey: .replacement)
|
||||||
|
try values.encodeIfPresent(scope, forKey: .scope)
|
||||||
|
try values.encode(scopeTitle, forKey: .scopeTitle)
|
||||||
|
try values.encode(scopeContent, forKey: .scopeContent)
|
||||||
|
try values.encodeIfPresent(excludeScope, forKey: .excludeScope)
|
||||||
|
try values.encode(isEnabled, forKey: .isEnabled)
|
||||||
|
try values.encode(isRegex, forKey: .isRegex)
|
||||||
|
try values.encode(timeoutMillisecond, forKey: .timeoutMillisecond)
|
||||||
|
try values.encode(sortOrder, forKey: .order)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct RssSource: Codable, Hashable, Identifiable, Sendable {
|
||||||
|
public var sourceUrl = ""
|
||||||
|
public var sourceName = ""
|
||||||
|
public var sourceIcon = ""
|
||||||
|
public var sourceGroup: String?
|
||||||
|
public var sourceComment: String?
|
||||||
|
public var enabled = true
|
||||||
|
public var variableComment: String?
|
||||||
|
public var jsLib: String?
|
||||||
|
public var enabledCookieJar: Bool? = true
|
||||||
|
public var concurrentRate: String?
|
||||||
|
public var header: String?
|
||||||
|
public var loginUrl: String?
|
||||||
|
public var loginUi: String?
|
||||||
|
public var loginCheckJs: String?
|
||||||
|
public var coverDecodeJs: String?
|
||||||
|
public var sortUrl: String?
|
||||||
|
public var singleUrl = false
|
||||||
|
public var articleStyle = 0
|
||||||
|
public var ruleArticles: String?
|
||||||
|
public var ruleNextPage: String?
|
||||||
|
public var ruleTitle: String?
|
||||||
|
public var rulePubDate: String?
|
||||||
|
public var ruleDescription: String?
|
||||||
|
public var ruleImage: String?
|
||||||
|
public var ruleLink: String?
|
||||||
|
public var ruleContent: String?
|
||||||
|
public var contentWhitelist: String?
|
||||||
|
public var contentBlacklist: String?
|
||||||
|
public var shouldOverrideUrlLoading: String?
|
||||||
|
public var style: String?
|
||||||
|
public var enableJs = true
|
||||||
|
public var loadWithBaseUrl = true
|
||||||
|
public var injectJs: String?
|
||||||
|
public var preloadJs: String?
|
||||||
|
public var startHtml: String?
|
||||||
|
public var startStyle: String?
|
||||||
|
public var startJs: String?
|
||||||
|
public var showWebLog = false
|
||||||
|
public var lastUpdateTime: Int64 = 0
|
||||||
|
public var customOrder = 0
|
||||||
|
public var type = 0
|
||||||
|
public var preload = false
|
||||||
|
public var cacheFirst = false
|
||||||
|
public var searchUrl: String?
|
||||||
|
public var id: String { sourceUrl }
|
||||||
|
|
||||||
|
public init(sourceUrl: String = "", sourceName: String = "") {
|
||||||
|
self.sourceUrl = sourceUrl
|
||||||
|
self.sourceName = sourceName
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let values = try decoder.container(keyedBy: RSSCodingKey.self)
|
||||||
|
sourceUrl = values.string("sourceUrl") ?? ""
|
||||||
|
sourceName = values.string("sourceName") ?? ""
|
||||||
|
sourceIcon = values.string("sourceIcon") ?? ""
|
||||||
|
sourceGroup = values.string("sourceGroup")
|
||||||
|
sourceComment = values.string("sourceComment")
|
||||||
|
enabled = values.bool("enabled") ?? true
|
||||||
|
variableComment = values.string("variableComment")
|
||||||
|
jsLib = values.string("jsLib")
|
||||||
|
enabledCookieJar = values.contains("enabledCookieJar") ? values.bool("enabledCookieJar") : true
|
||||||
|
concurrentRate = values.string("concurrentRate")
|
||||||
|
header = values.string("header")
|
||||||
|
loginUrl = values.string("loginUrl")
|
||||||
|
loginUi = values.jsonString("loginUi")
|
||||||
|
loginCheckJs = values.string("loginCheckJs")
|
||||||
|
coverDecodeJs = values.string("coverDecodeJs")
|
||||||
|
sortUrl = values.string("sortUrl")
|
||||||
|
singleUrl = values.bool("singleUrl") ?? false
|
||||||
|
articleStyle = values.int("articleStyle") ?? 0
|
||||||
|
ruleArticles = values.string("ruleArticles")
|
||||||
|
ruleNextPage = values.string("ruleNextPage")
|
||||||
|
ruleTitle = values.string("ruleTitle")
|
||||||
|
rulePubDate = values.string("rulePubDate")
|
||||||
|
ruleDescription = values.string("ruleDescription")
|
||||||
|
ruleImage = values.string("ruleImage")
|
||||||
|
ruleLink = values.string("ruleLink")
|
||||||
|
ruleContent = values.string("ruleContent")
|
||||||
|
contentWhitelist = values.string("contentWhitelist")
|
||||||
|
contentBlacklist = values.string("contentBlacklist")
|
||||||
|
shouldOverrideUrlLoading = values.string("shouldOverrideUrlLoading")
|
||||||
|
style = values.string("style")
|
||||||
|
enableJs = values.bool("enableJs") ?? true
|
||||||
|
loadWithBaseUrl = values.bool("loadWithBaseUrl") ?? true
|
||||||
|
injectJs = values.string("injectJs")
|
||||||
|
preloadJs = values.string("preloadJs")
|
||||||
|
startHtml = values.string("startHtml")
|
||||||
|
startStyle = values.string("startStyle")
|
||||||
|
startJs = values.string("startJs")
|
||||||
|
showWebLog = values.bool("showWebLog") ?? false
|
||||||
|
lastUpdateTime = values.int64("lastUpdateTime") ?? 0
|
||||||
|
customOrder = values.int("customOrder") ?? 0
|
||||||
|
type = values.int("type") ?? 0
|
||||||
|
preload = values.bool("preload") ?? false
|
||||||
|
cacheFirst = values.bool("cacheFirst") ?? false
|
||||||
|
searchUrl = values.string("searchUrl")
|
||||||
|
}
|
||||||
|
|
||||||
|
public var groups: [String] { BookSource.parseGroups(sourceGroup) }
|
||||||
|
public var displayNameGroup: String {
|
||||||
|
groups.isEmpty ? sourceName : "\(sourceName) (\(sourceGroup ?? ""))"
|
||||||
|
}
|
||||||
|
public static func == (lhs: Self, rhs: Self) -> Bool { lhs.sourceUrl == rhs.sourceUrl }
|
||||||
|
public func hash(into hasher: inout Hasher) { hasher.combine(sourceUrl) }
|
||||||
|
|
||||||
|
public mutating func addGroups(_ value: String) {
|
||||||
|
var merged = Set(groups)
|
||||||
|
merged.formUnion(BookSource.parseGroups(value))
|
||||||
|
sourceGroup = merged.joined(separator: ",")
|
||||||
|
}
|
||||||
|
public mutating func removeGroups(_ value: String) {
|
||||||
|
let removed = Set(BookSource.parseGroups(value))
|
||||||
|
sourceGroup = groups.filter { !removed.contains($0) }.joined(separator: ",")
|
||||||
|
}
|
||||||
|
public func displayVariableComment(_ other: String) -> String {
|
||||||
|
guard let variableComment, !variableComment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return other }
|
||||||
|
return "\(variableComment)\n\(other)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct RssArticle: Codable, Hashable, Identifiable, Sendable {
|
||||||
|
public var origin = ""
|
||||||
|
public var sort = ""
|
||||||
|
public var title = ""
|
||||||
|
public var order: Int64 = 0
|
||||||
|
public var link = ""
|
||||||
|
public var pubDate: String?
|
||||||
|
public var description: String?
|
||||||
|
public var content: String?
|
||||||
|
public var image: String?
|
||||||
|
public var group = "默认分组"
|
||||||
|
public var read = false
|
||||||
|
public var variable: String?
|
||||||
|
public var type = 0
|
||||||
|
public var durPos = 0
|
||||||
|
public var id: String { "\(origin)\u{0}\(link)\u{0}\(sort)" }
|
||||||
|
|
||||||
|
public init(origin: String = "", sort: String = "", title: String = "", link: String = "") {
|
||||||
|
self.origin = origin; self.sort = sort; self.title = title; self.link = link
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let values = try decoder.container(keyedBy: RSSCodingKey.self)
|
||||||
|
origin = values.string("origin") ?? ""; sort = values.string("sort") ?? ""
|
||||||
|
title = values.string("title") ?? ""; order = values.int64("order") ?? 0
|
||||||
|
link = values.string("link") ?? ""; pubDate = values.string("pubDate")
|
||||||
|
description = values.string("description"); content = values.string("content")
|
||||||
|
image = values.string("image"); group = values.string("group") ?? "默认分组"
|
||||||
|
read = values.bool("read") ?? false; variable = values.string("variable")
|
||||||
|
type = values.int("type") ?? 0; durPos = values.int("durPos") ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
|
public func star(at time: Int64 = Int64(Date().timeIntervalSince1970 * 1_000)) -> RssStar {
|
||||||
|
.init(article: self, starTime: time)
|
||||||
|
}
|
||||||
|
public func record(at time: Int64 = Int64(Date().timeIntervalSince1970 * 1_000)) -> RssReadRecord {
|
||||||
|
.init(record: link, title: title, readTime: time, origin: origin, sort: sort, image: image, type: type, durPos: durPos, pubDate: pubDate)
|
||||||
|
}
|
||||||
|
public var variableMap: [String: String] { Self.decodeVariables(variable) }
|
||||||
|
public static func == (lhs: Self, rhs: Self) -> Bool {
|
||||||
|
lhs.origin == rhs.origin && lhs.link == rhs.link && lhs.sort == rhs.sort
|
||||||
|
}
|
||||||
|
public func hash(into hasher: inout Hasher) { hasher.combine(link) }
|
||||||
|
|
||||||
|
fileprivate static func decodeVariables(_ value: String?) -> [String: String] {
|
||||||
|
guard let value, let data = value.data(using: .utf8),
|
||||||
|
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [:] }
|
||||||
|
return object.reduce(into: [:]) { result, pair in
|
||||||
|
if let string = pair.value as? String { result[pair.key] = string }
|
||||||
|
else if !(pair.value is NSNull) { result[pair.key] = String(describing: pair.value) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct RssReadRecord: Codable, Hashable, Identifiable, Sendable {
|
||||||
|
public var record: String
|
||||||
|
public var title: String?
|
||||||
|
public var readTime: Int64?
|
||||||
|
public var read: Bool
|
||||||
|
public var origin: String
|
||||||
|
public var sort: String
|
||||||
|
public var image: String?
|
||||||
|
public var type: Int
|
||||||
|
public var durPos: Int
|
||||||
|
public var pubDate: String?
|
||||||
|
public var id: String { record }
|
||||||
|
public init(record: String, title: String? = nil, readTime: Int64? = nil, read: Bool = true, origin: String = "", sort: String = "", image: String? = nil, type: Int = 0, durPos: Int = 0, pubDate: String? = nil) {
|
||||||
|
self.record = record; self.title = title; self.readTime = readTime; self.read = read
|
||||||
|
self.origin = origin; self.sort = sort; self.image = image; self.type = type
|
||||||
|
self.durPos = durPos; self.pubDate = pubDate
|
||||||
|
}
|
||||||
|
public var article: RssArticle {
|
||||||
|
var value = RssArticle(origin: origin, sort: sort, title: title ?? "", link: record)
|
||||||
|
value.image = image; value.type = type; value.durPos = durPos; value.pubDate = pubDate
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct RssStar: Codable, Hashable, Identifiable, Sendable {
|
||||||
|
public var origin = ""
|
||||||
|
public var sort = ""
|
||||||
|
public var title = ""
|
||||||
|
public var starTime: Int64 = 0
|
||||||
|
public var link = ""
|
||||||
|
public var pubDate: String?
|
||||||
|
public var description: String?
|
||||||
|
public var content: String?
|
||||||
|
public var image: String?
|
||||||
|
public var group = "默认分组"
|
||||||
|
public var variable: String?
|
||||||
|
public var type = 0
|
||||||
|
public var durPos = 0
|
||||||
|
public var id: String { "\(origin)\u{0}\(link)" }
|
||||||
|
public init(article: RssArticle = .init(), starTime: Int64 = 0) {
|
||||||
|
origin = article.origin; sort = article.sort; title = article.title; self.starTime = starTime
|
||||||
|
link = article.link; pubDate = article.pubDate; description = article.description
|
||||||
|
content = article.content; image = article.image; group = article.group
|
||||||
|
variable = article.variable; type = article.type; durPos = article.durPos
|
||||||
|
}
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let article = try RssArticle(from: decoder)
|
||||||
|
self.init(article: article, starTime: try decoder.container(keyedBy: RSSCodingKey.self).int64("starTime") ?? 0)
|
||||||
|
}
|
||||||
|
public var article: RssArticle {
|
||||||
|
var value = RssArticle(origin: origin, sort: sort, title: title, link: link)
|
||||||
|
value.pubDate = pubDate; value.description = description; value.content = content
|
||||||
|
value.image = image; value.group = group; value.variable = variable; value.type = type; value.durPos = durPos
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
public var variableMap: [String: String] { RssArticle.decodeVariables(variable) }
|
||||||
|
public func record(at time: Int64 = Int64(Date().timeIntervalSince1970 * 1_000)) -> RssReadRecord {
|
||||||
|
.init(record: link, title: title, readTime: time, origin: origin, sort: sort, image: image, type: type, durPos: durPos, pubDate: pubDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct RSSCodingKey: CodingKey {
|
||||||
|
let stringValue: String
|
||||||
|
var intValue: Int? { nil }
|
||||||
|
init?(stringValue: String) { self.stringValue = stringValue }
|
||||||
|
init?(intValue: Int) { return nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension KeyedDecodingContainer where Key == RSSCodingKey {
|
||||||
|
func key(_ value: String) -> Key { Key(stringValue: value)! }
|
||||||
|
func contains(_ value: String) -> Bool { contains(key(value)) }
|
||||||
|
func string(_ name: String) -> String? {
|
||||||
|
let key = key(name)
|
||||||
|
if let value = try? decode(String.self, forKey: key) { return value }
|
||||||
|
if let value = try? decode(Int64.self, forKey: key) { return String(value) }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func bool(_ name: String) -> Bool? {
|
||||||
|
let key = key(name)
|
||||||
|
if let value = try? decode(Bool.self, forKey: key) { return value }
|
||||||
|
if let value = try? decode(Int.self, forKey: key) { return value != 0 }
|
||||||
|
if let value = string(name)?.lowercased() { return ["true", "1", "yes"].contains(value) ? true : (["false", "0", "no"].contains(value) ? false : nil) }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func int(_ name: String) -> Int? {
|
||||||
|
let key = key(name)
|
||||||
|
if let value = try? decode(Int.self, forKey: key) { return value }
|
||||||
|
return string(name).flatMap(Int.init)
|
||||||
|
}
|
||||||
|
func int64(_ name: String) -> Int64? {
|
||||||
|
let key = key(name)
|
||||||
|
if let value = try? decode(Int64.self, forKey: key) { return value }
|
||||||
|
return string(name).flatMap(Int64.init)
|
||||||
|
}
|
||||||
|
func jsonString(_ name: String) -> String? {
|
||||||
|
if let value = string(name) { return value }
|
||||||
|
let key = key(name)
|
||||||
|
guard let value = try? decode(JSONValue.self, forKey: key) else { return nil }
|
||||||
|
return value.stringValue
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct SearchBook: Codable, Hashable, Identifiable, Sendable {
|
||||||
|
public var bookURL: String
|
||||||
|
public var origin: String
|
||||||
|
public var originName: String
|
||||||
|
public var type: Int
|
||||||
|
public var name: String
|
||||||
|
public var author: String
|
||||||
|
public var kind: String?
|
||||||
|
public var coverURL: String?
|
||||||
|
public var intro: String?
|
||||||
|
public var wordCount: String?
|
||||||
|
public var latestChapterTitle: String?
|
||||||
|
public var tocURL: String
|
||||||
|
public var time: Int64
|
||||||
|
public var originOrder: Int
|
||||||
|
public var chapterWordCountText: String?
|
||||||
|
public var chapterWordCount: Int
|
||||||
|
public var respondTime: Int
|
||||||
|
public var origins: Set<String>
|
||||||
|
public var variables: [String: String]
|
||||||
|
|
||||||
|
public var id: String { bookURL }
|
||||||
|
public var variable: String? {
|
||||||
|
get {
|
||||||
|
guard !variables.isEmpty, let data = try? JSONEncoder().encode(variables) else { return nil }
|
||||||
|
return String(decoding: data, as: UTF8.self)
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
guard let newValue, let data = newValue.data(using: .utf8) else { variables = [:]; return }
|
||||||
|
variables = (try? JSONDecoder().decode([String: String].self, from: data)) ?? [:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(
|
||||||
|
bookURL: String,
|
||||||
|
origin: String,
|
||||||
|
originName: String,
|
||||||
|
type: Int,
|
||||||
|
name: String,
|
||||||
|
author: String = ""
|
||||||
|
) {
|
||||||
|
self.bookURL = bookURL
|
||||||
|
self.origin = origin
|
||||||
|
self.originName = originName
|
||||||
|
self.type = type
|
||||||
|
self.name = name
|
||||||
|
self.author = author
|
||||||
|
tocURL = ""
|
||||||
|
time = Int64(Date().timeIntervalSince1970 * 1_000)
|
||||||
|
originOrder = 0
|
||||||
|
chapterWordCountText = nil
|
||||||
|
chapterWordCount = -1
|
||||||
|
respondTime = -1
|
||||||
|
origins = [origin]
|
||||||
|
variables = [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum CodingKeys: String, CodingKey {
|
||||||
|
case bookUrl, origin, originName, type, name, author, kind, coverUrl, intro, wordCount
|
||||||
|
case latestChapterTitle, tocUrl, time, variable, originOrder, chapterWordCountText
|
||||||
|
case chapterWordCount, respondTime
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(from decoder: Decoder) throws {
|
||||||
|
let value = try decoder.container(keyedBy: CodingKeys.self)
|
||||||
|
bookURL = try value.decodeIfPresent(String.self, forKey: .bookUrl) ?? ""
|
||||||
|
origin = try value.decodeIfPresent(String.self, forKey: .origin) ?? ""
|
||||||
|
originName = try value.decodeIfPresent(String.self, forKey: .originName) ?? ""
|
||||||
|
type = try value.decodeIfPresent(Int.self, forKey: .type) ?? 0
|
||||||
|
name = try value.decodeIfPresent(String.self, forKey: .name) ?? ""
|
||||||
|
author = try value.decodeIfPresent(String.self, forKey: .author) ?? ""
|
||||||
|
kind = try value.decodeIfPresent(String.self, forKey: .kind)
|
||||||
|
coverURL = try value.decodeIfPresent(String.self, forKey: .coverUrl)
|
||||||
|
intro = try value.decodeIfPresent(String.self, forKey: .intro)
|
||||||
|
wordCount = try value.decodeIfPresent(String.self, forKey: .wordCount)
|
||||||
|
latestChapterTitle = try value.decodeIfPresent(String.self, forKey: .latestChapterTitle)
|
||||||
|
tocURL = try value.decodeIfPresent(String.self, forKey: .tocUrl) ?? ""
|
||||||
|
time = try value.decodeIfPresent(Int64.self, forKey: .time) ?? 0
|
||||||
|
originOrder = try value.decodeIfPresent(Int.self, forKey: .originOrder) ?? 0
|
||||||
|
chapterWordCountText = try value.decodeIfPresent(String.self, forKey: .chapterWordCountText)
|
||||||
|
chapterWordCount = try value.decodeIfPresent(Int.self, forKey: .chapterWordCount) ?? -1
|
||||||
|
respondTime = try value.decodeIfPresent(Int.self, forKey: .respondTime) ?? -1
|
||||||
|
origins = [origin]
|
||||||
|
let rawVariable = try value.decodeIfPresent(String.self, forKey: .variable)
|
||||||
|
variables = rawVariable.flatMap { raw in
|
||||||
|
try? JSONDecoder().decode([String: String].self, from: Data(raw.utf8))
|
||||||
|
} ?? [:]
|
||||||
|
}
|
||||||
|
|
||||||
|
public func encode(to encoder: Encoder) throws {
|
||||||
|
var value = encoder.container(keyedBy: CodingKeys.self)
|
||||||
|
try value.encode(bookURL, forKey: .bookUrl)
|
||||||
|
try value.encode(origin, forKey: .origin)
|
||||||
|
try value.encode(originName, forKey: .originName)
|
||||||
|
try value.encode(type, forKey: .type)
|
||||||
|
try value.encode(name, forKey: .name)
|
||||||
|
try value.encode(author, forKey: .author)
|
||||||
|
try value.encodeIfPresent(kind, forKey: .kind)
|
||||||
|
try value.encodeIfPresent(coverURL, forKey: .coverUrl)
|
||||||
|
try value.encodeIfPresent(intro, forKey: .intro)
|
||||||
|
try value.encodeIfPresent(wordCount, forKey: .wordCount)
|
||||||
|
try value.encodeIfPresent(latestChapterTitle, forKey: .latestChapterTitle)
|
||||||
|
try value.encode(tocURL, forKey: .tocUrl)
|
||||||
|
try value.encode(time, forKey: .time)
|
||||||
|
try value.encodeIfPresent(variable, forKey: .variable)
|
||||||
|
try value.encode(originOrder, forKey: .originOrder)
|
||||||
|
try value.encodeIfPresent(chapterWordCountText, forKey: .chapterWordCountText)
|
||||||
|
try value.encode(chapterWordCount, forKey: .chapterWordCount)
|
||||||
|
try value.encode(respondTime, forKey: .respondTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct BookExploreService: Sendable {
|
||||||
|
private let transport: any HTTPTransport
|
||||||
|
private let urlBuilder: LegadoURLBuilder
|
||||||
|
private let engine: RuleEngine
|
||||||
|
private let networkPolicy: SourceNetworkPolicy
|
||||||
|
private let webViewExecutor: (any SourceWebViewRequestExecuting)?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
transport: any HTTPTransport = URLSessionHTTPTransport(),
|
||||||
|
urlBuilder: LegadoURLBuilder = .init(),
|
||||||
|
engine: RuleEngine = .init(),
|
||||||
|
networkPolicy: SourceNetworkPolicy = .shared,
|
||||||
|
webViewExecutor: (any SourceWebViewRequestExecuting)? = nil
|
||||||
|
) {
|
||||||
|
self.transport = transport
|
||||||
|
self.urlBuilder = urlBuilder
|
||||||
|
self.engine = engine
|
||||||
|
self.networkPolicy = networkPolicy
|
||||||
|
self.webViewExecutor = webViewExecutor
|
||||||
|
}
|
||||||
|
|
||||||
|
public func kinds(for source: BookSource) async -> ExploreKindsReport {
|
||||||
|
guard let rule = source.exploreUrl?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!rule.isEmpty else { return .init() }
|
||||||
|
var diagnostics: [RuleDiagnostic] = []
|
||||||
|
let rendered: String
|
||||||
|
if rule.lowercased().hasPrefix("@js:") || rule.lowercased().hasPrefix("<js>") {
|
||||||
|
let result = await engine.evaluate(
|
||||||
|
rule,
|
||||||
|
in: .null,
|
||||||
|
context: .init(source: source)
|
||||||
|
)
|
||||||
|
diagnostics += result.diagnostics
|
||||||
|
rendered = result.firstString ?? ""
|
||||||
|
} else {
|
||||||
|
rendered = rule
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
return .init(kinds: try parseKinds(rendered), diagnostics: diagnostics)
|
||||||
|
} catch let error as RuleEngineError {
|
||||||
|
diagnostics.append(error.diagnostic)
|
||||||
|
return .init(diagnostics: diagnostics)
|
||||||
|
} catch {
|
||||||
|
diagnostics.append(.init(
|
||||||
|
severity: .error,
|
||||||
|
code: "explore.kinds",
|
||||||
|
message: error.localizedDescription,
|
||||||
|
rule: rule
|
||||||
|
))
|
||||||
|
return .init(diagnostics: diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public func explore(
|
||||||
|
source: BookSource,
|
||||||
|
kind: ExploreKind,
|
||||||
|
page: Int = 1
|
||||||
|
) async -> SourceSearchOutcome {
|
||||||
|
guard let template = kind.navigableURL else {
|
||||||
|
return failure(source, code: "explore.url", message: "发现分类缺少 URL", rule: kind.title)
|
||||||
|
}
|
||||||
|
let rules = resolvedRules(source)
|
||||||
|
guard let rules, let listRule = rules.bookList, !listRule.isEmpty else {
|
||||||
|
return failure(source, code: "explore.configuration", message: "书源缺少发现列表规则", rule: template)
|
||||||
|
}
|
||||||
|
do {
|
||||||
|
let prepared = try urlBuilder.prepare(
|
||||||
|
template,
|
||||||
|
context: .init(page: page, source: source),
|
||||||
|
baseURL: URL(string: source.bookSourceUrl),
|
||||||
|
defaultHeaders: parseHeaders(source.header)
|
||||||
|
)
|
||||||
|
let timeout = min(max(Double(source.respondTime) / 1_000, 5), 180)
|
||||||
|
let response = try await networkPolicy.execute(
|
||||||
|
prepared,
|
||||||
|
source: source,
|
||||||
|
transport: transport,
|
||||||
|
timeout: timeout,
|
||||||
|
webViewExecutor: webViewExecutor
|
||||||
|
)
|
||||||
|
let body = response.decodedText
|
||||||
|
let root = response.decodedJSON
|
||||||
|
var context = RuleExecutionContext(
|
||||||
|
variables: response.requestVariables,
|
||||||
|
baseURL: response.finalURL.absoluteString,
|
||||||
|
source: source
|
||||||
|
)
|
||||||
|
let list = if let root {
|
||||||
|
await engine.evaluate(listRule.trimmingListPrefix, in: root, listMode: true, context: context)
|
||||||
|
} else {
|
||||||
|
await engine.evaluate(
|
||||||
|
listRule.trimmingListPrefix,
|
||||||
|
inText: body,
|
||||||
|
baseURL: response.finalURL.absoluteString,
|
||||||
|
listMode: true,
|
||||||
|
context: context
|
||||||
|
)
|
||||||
|
}
|
||||||
|
context.variables = list.variables
|
||||||
|
var diagnostics = list.diagnostics
|
||||||
|
var books: [SearchBook] = []
|
||||||
|
for item in list.listItems(rule: listRule.trimmingListPrefix) {
|
||||||
|
try Task.checkCancellation()
|
||||||
|
let fields = await evaluateFields(
|
||||||
|
rules,
|
||||||
|
item: item,
|
||||||
|
isHTML: root == nil,
|
||||||
|
baseURL: response.finalURL.absoluteString,
|
||||||
|
source: source,
|
||||||
|
variables: context.variables
|
||||||
|
)
|
||||||
|
diagnostics += fields.diagnostics
|
||||||
|
guard let name = fields.name, !name.isEmpty else { continue }
|
||||||
|
let bookURL = resolve(fields.bookURL ?? response.finalURL.absoluteString, against: response.finalURL)
|
||||||
|
var book = SearchBook(
|
||||||
|
bookURL: bookURL,
|
||||||
|
origin: source.bookSourceUrl,
|
||||||
|
originName: source.bookSourceName,
|
||||||
|
type: source.bookSourceType,
|
||||||
|
name: name,
|
||||||
|
author: fields.author ?? ""
|
||||||
|
)
|
||||||
|
book.kind = fields.kind
|
||||||
|
book.coverURL = fields.coverURL.map { resolve($0, against: response.finalURL) }
|
||||||
|
book.intro = MetadataTextSanitizer().clean(fields.intro)
|
||||||
|
book.wordCount = fields.wordCount
|
||||||
|
book.latestChapterTitle = fields.lastChapter
|
||||||
|
book.originOrder = source.customOrder
|
||||||
|
book.variables = fields.variables
|
||||||
|
books.append(book)
|
||||||
|
}
|
||||||
|
if listRule.hasPrefix("-") { books.reverse() }
|
||||||
|
var unique = Set<String>()
|
||||||
|
books = books.filter { unique.insert($0.bookURL).inserted }
|
||||||
|
return .init(sourceID: source.id, books: books, diagnostics: diagnostics)
|
||||||
|
} catch is CancellationError {
|
||||||
|
return failure(source, code: "explore.cancelled", message: "发现加载已取消", rule: template)
|
||||||
|
} catch let error as RuleEngineError {
|
||||||
|
return .init(sourceID: source.id, books: [], diagnostics: [error.diagnostic])
|
||||||
|
} catch {
|
||||||
|
return failure(source, code: "explore.request", message: error.localizedDescription, rule: template)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseKinds(_ value: String) throws -> [ExploreKind] {
|
||||||
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !trimmed.isEmpty else { return [] }
|
||||||
|
if trimmed.hasPrefix("[") {
|
||||||
|
do {
|
||||||
|
return try JSONDecoder().decode([ExploreKind].self, from: Data(trimmed.utf8))
|
||||||
|
} catch let strictError {
|
||||||
|
do {
|
||||||
|
let object = try JSONSerialization.jsonObject(
|
||||||
|
with: Data(trimmed.utf8),
|
||||||
|
options: [.fragmentsAllowed, .json5Allowed]
|
||||||
|
)
|
||||||
|
let normalized = try JSONEncoder().encode(JSONValue.bridge(object))
|
||||||
|
return try JSONDecoder().decode([ExploreKind].self, from: normalized)
|
||||||
|
} catch {
|
||||||
|
throw RuleEngineError.malformedRule(
|
||||||
|
rule: value,
|
||||||
|
detail: "发现分类 JSON 无效:\(strictError.localizedDescription)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
.replacingOccurrences(of: "&&", with: "\n")
|
||||||
|
.split(whereSeparator: { $0.isNewline })
|
||||||
|
.compactMap { line in
|
||||||
|
let line = String(line)
|
||||||
|
let range = line.range(of: "::")
|
||||||
|
let title = String(range.map { line[..<$0.lowerBound] } ?? line[...])
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !title.isEmpty else { return nil }
|
||||||
|
let url = range.map { String(line[$0.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||||
|
return ExploreKind(title: title, url: url?.isEmpty == true ? nil : url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolvedRules(_ source: BookSource) -> BookListRule? {
|
||||||
|
if let rules = source.ruleExplore, rules.bookList?.isEmpty == false { return rules }
|
||||||
|
guard let search = source.ruleSearch else { return source.ruleExplore }
|
||||||
|
return .init(
|
||||||
|
bookList: search.bookList,
|
||||||
|
name: search.name,
|
||||||
|
author: search.author,
|
||||||
|
intro: search.intro,
|
||||||
|
kind: search.kind,
|
||||||
|
lastChapter: search.lastChapter,
|
||||||
|
updateTime: search.updateTime,
|
||||||
|
bookUrl: search.bookUrl,
|
||||||
|
coverUrl: search.coverUrl,
|
||||||
|
wordCount: search.wordCount
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func evaluateFields(
|
||||||
|
_ rules: BookListRule,
|
||||||
|
item: JSONValue,
|
||||||
|
isHTML: Bool,
|
||||||
|
baseURL: String,
|
||||||
|
source: BookSource,
|
||||||
|
variables: [String: String]
|
||||||
|
) async -> Fields {
|
||||||
|
var context = RuleExecutionContext(variables: variables, baseURL: baseURL, source: source, isHTMLFragment: true)
|
||||||
|
var result = Fields()
|
||||||
|
let name = await evaluate(rules.name, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
|
||||||
|
context.variables = name.variables
|
||||||
|
let author = await evaluate(rules.author, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
|
||||||
|
context.variables = author.variables
|
||||||
|
let kind = await evaluate(rules.kind, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
|
||||||
|
context.variables = kind.variables
|
||||||
|
let bookURL = await evaluate(rules.bookUrl, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
|
||||||
|
context.variables = bookURL.variables
|
||||||
|
let cover = await evaluate(rules.coverUrl, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
|
||||||
|
context.variables = cover.variables
|
||||||
|
let intro = await evaluate(rules.intro, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
|
||||||
|
context.variables = intro.variables
|
||||||
|
let wordCount = await evaluate(rules.wordCount, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
|
||||||
|
context.variables = wordCount.variables
|
||||||
|
let lastChapter = await evaluate(rules.lastChapter, item: item, isHTML: isHTML, baseURL: baseURL, context: context)
|
||||||
|
context.variables = lastChapter.variables
|
||||||
|
result.name = name.joinedString
|
||||||
|
result.author = author.joinedString
|
||||||
|
result.kind = kind.strings.filter { !$0.isEmpty }.joined(separator: ",").nilIfEmpty
|
||||||
|
result.bookURL = isHTML ? bookURL.leadingString : bookURL.joinedString
|
||||||
|
result.coverURL = cover.joinedString
|
||||||
|
result.intro = MetadataTextSanitizer().clean(intro.joinedString)
|
||||||
|
result.wordCount = wordCount.joinedString
|
||||||
|
result.lastChapter = lastChapter.joinedString
|
||||||
|
result.variables = context.variables
|
||||||
|
result.diagnostics = [name, author, kind, bookURL, cover, intro, wordCount, lastChapter].flatMap(\.diagnostics)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private func evaluate(
|
||||||
|
_ rule: String?,
|
||||||
|
item: JSONValue,
|
||||||
|
isHTML: Bool,
|
||||||
|
baseURL: String,
|
||||||
|
context: RuleExecutionContext
|
||||||
|
) async -> RuleEvaluation {
|
||||||
|
guard let rule, !rule.isEmpty else { return .init(variables: context.variables) }
|
||||||
|
if case .string = item {
|
||||||
|
return await engine.evaluate(rule, inText: item.stringValue, baseURL: baseURL, context: context)
|
||||||
|
}
|
||||||
|
return await engine.evaluate(rule, in: item, context: context)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolve(_ value: String, against base: URL) -> String {
|
||||||
|
LegadoURLBuilder.resolve(value, against: base)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseHeaders(_ source: String?) -> [String: String] {
|
||||||
|
guard let source, let value = try? JSONValue.decode(data: Data(source.utf8)),
|
||||||
|
case .object(let object) = value else { return [:] }
|
||||||
|
return object.mapValues(\.stringValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func failure(_ source: BookSource, code: String, message: String, rule: String) -> SourceSearchOutcome {
|
||||||
|
.init(sourceID: source.id, books: [], diagnostics: [
|
||||||
|
.init(severity: .error, code: code, message: message, rule: rule)
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Fields {
|
||||||
|
var name: String?
|
||||||
|
var author: String?
|
||||||
|
var kind: String?
|
||||||
|
var bookURL: String?
|
||||||
|
var coverURL: String?
|
||||||
|
var intro: String?
|
||||||
|
var wordCount: String?
|
||||||
|
var lastChapter: String?
|
||||||
|
var variables: [String: String] = [:]
|
||||||
|
var diagnostics: [RuleDiagnostic] = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
var trimmingListPrefix: String {
|
||||||
|
guard hasPrefix("-") || hasPrefix("+") else { return self }
|
||||||
|
return String(dropFirst())
|
||||||
|
}
|
||||||
|
|
||||||
|
fileprivate var nilIfEmpty: String? { isEmpty ? nil : self }
|
||||||
|
}
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
import Compression
|
||||||
|
import CoreFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum DuplicatePolicy: Sendable {
|
||||||
|
case replace
|
||||||
|
case skip
|
||||||
|
case fail
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum BookSourceImportError: Error, Equatable, LocalizedError {
|
||||||
|
case emptyDocument
|
||||||
|
case invalidDocument(String)
|
||||||
|
case invalidSources([BookSourceIssue])
|
||||||
|
case duplicate(String)
|
||||||
|
|
||||||
|
public var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .emptyDocument:
|
||||||
|
return "导入内容为空"
|
||||||
|
case .invalidDocument(let detail):
|
||||||
|
return "无法解析书源 JSON:\(detail)"
|
||||||
|
case .invalidSources(let issues):
|
||||||
|
return issues.map(\.message).joined(separator: ";")
|
||||||
|
case .duplicate(let url):
|
||||||
|
return "书源已存在:\(url)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct BookSourceImportResult: Sendable {
|
||||||
|
public var sources: [BookSource]
|
||||||
|
public var inserted: Int
|
||||||
|
public var replaced: Int
|
||||||
|
public var skipped: Int
|
||||||
|
public var warnings: [BookSourceIssue]
|
||||||
|
|
||||||
|
public init(
|
||||||
|
sources: [BookSource],
|
||||||
|
inserted: Int,
|
||||||
|
replaced: Int,
|
||||||
|
skipped: Int,
|
||||||
|
warnings: [BookSourceIssue]
|
||||||
|
) {
|
||||||
|
self.sources = sources
|
||||||
|
self.inserted = inserted
|
||||||
|
self.replaced = replaced
|
||||||
|
self.skipped = skipped
|
||||||
|
self.warnings = warnings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct BookSourceImporter: Sendable {
|
||||||
|
public static let maximumDocumentBytes = 50 * 1_024 * 1_024
|
||||||
|
private let decoder: JSONDecoder
|
||||||
|
|
||||||
|
public init() {
|
||||||
|
decoder = JSONDecoder()
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct RejectedSource: Sendable {
|
||||||
|
public var source: BookSource
|
||||||
|
public var issues: [BookSourceIssue]
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Inspection: Sendable {
|
||||||
|
public var sources: [BookSource]
|
||||||
|
public var rejected: [RejectedSource]
|
||||||
|
}
|
||||||
|
|
||||||
|
public func inspect(_ data: Data) throws -> Inspection {
|
||||||
|
let cleaned = try normalizedData(data)
|
||||||
|
let root: JSONValue
|
||||||
|
do { root = try JSONValue.decode(data: cleaned) }
|
||||||
|
catch { throw BookSourceImportError.invalidDocument(error.localizedDescription) }
|
||||||
|
let entries: [JSONValue]
|
||||||
|
switch root {
|
||||||
|
case .array(let array): entries = array
|
||||||
|
case .object: entries = [root]
|
||||||
|
default: throw BookSourceImportError.invalidDocument("需要书源对象或数组")
|
||||||
|
}
|
||||||
|
guard !entries.isEmpty else { throw BookSourceImportError.emptyDocument }
|
||||||
|
var sources: [BookSource] = []
|
||||||
|
var rejected: [RejectedSource] = []
|
||||||
|
for (index, entry) in entries.enumerated() {
|
||||||
|
do {
|
||||||
|
guard case .object = entry else { throw BookSourceImportError.invalidDocument("第 \(index + 1) 项不是对象") }
|
||||||
|
let source = try decoder.decode(BookSource.self, from: JSONEncoder().encode(entry))
|
||||||
|
let errors = BookSourceValidator.validate(source).filter { $0.severity == .error }
|
||||||
|
if errors.isEmpty { sources.append(source) }
|
||||||
|
else { rejected.append(.init(source: source, issues: errors)) }
|
||||||
|
} catch {
|
||||||
|
let placeholder = BookSource(bookSourceUrl: entry["bookSourceUrl"]?.stringValue ?? "",
|
||||||
|
bookSourceName: entry["bookSourceName"]?.stringValue ?? "第 \(index + 1) 项")
|
||||||
|
rejected.append(.init(source: placeholder, issues: [.init(severity: .error, code: "source.decode",
|
||||||
|
field: "[\(index)]", message: "第 \(index + 1) 项:\(error.localizedDescription)")]))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return .init(sources: sources, rejected: rejected)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func decode(_ data: Data) throws -> [BookSource] {
|
||||||
|
let result = try inspect(data)
|
||||||
|
guard result.rejected.isEmpty else { throw BookSourceImportError.invalidSources(result.rejected.flatMap(\.issues)) }
|
||||||
|
return result.sources
|
||||||
|
}
|
||||||
|
|
||||||
|
public func normalizedData(_ data: Data) throws -> Data {
|
||||||
|
guard data.count <= Self.maximumDocumentBytes else { throw BookSourceImportError.invalidDocument("书源文件超过 50 MB") }
|
||||||
|
let cleaned = decodeText(stripUTF8BOM(decompressIfGzip(data)))
|
||||||
|
guard cleaned.count <= Self.maximumDocumentBytes else { throw BookSourceImportError.invalidDocument("解压后书源超过 50 MB") }
|
||||||
|
guard !String(decoding: cleaned, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||||
|
throw BookSourceImportError.emptyDocument
|
||||||
|
}
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
|
||||||
|
public func prepare(
|
||||||
|
data: Data,
|
||||||
|
existing: [BookSource],
|
||||||
|
duplicatePolicy: DuplicatePolicy = .replace
|
||||||
|
) throws -> BookSourceImportResult {
|
||||||
|
let incoming = try decode(data)
|
||||||
|
var fatalIssues: [BookSourceIssue] = []
|
||||||
|
var warnings: [BookSourceIssue] = []
|
||||||
|
for source in incoming {
|
||||||
|
for issue in BookSourceValidator.validate(source) {
|
||||||
|
if issue.severity == .error {
|
||||||
|
fatalIssues.append(issue)
|
||||||
|
} else {
|
||||||
|
warnings.append(issue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard fatalIssues.isEmpty else {
|
||||||
|
throw BookSourceImportError.invalidSources(fatalIssues)
|
||||||
|
}
|
||||||
|
|
||||||
|
var merged = existing
|
||||||
|
var index = Dictionary(uniqueKeysWithValues: merged.enumerated().map { ($1.id, $0) })
|
||||||
|
var inserted = 0
|
||||||
|
var replaced = 0
|
||||||
|
var skipped = 0
|
||||||
|
|
||||||
|
for source in incoming {
|
||||||
|
if let existingIndex = index[source.id] {
|
||||||
|
switch duplicatePolicy {
|
||||||
|
case .replace:
|
||||||
|
merged[existingIndex] = source
|
||||||
|
replaced += 1
|
||||||
|
case .skip:
|
||||||
|
skipped += 1
|
||||||
|
case .fail:
|
||||||
|
throw BookSourceImportError.duplicate(source.id)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
index[source.id] = merged.count
|
||||||
|
merged.append(source)
|
||||||
|
inserted += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return .init(
|
||||||
|
sources: merged,
|
||||||
|
inserted: inserted,
|
||||||
|
replaced: replaced,
|
||||||
|
skipped: skipped,
|
||||||
|
warnings: warnings
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stripUTF8BOM(_ data: Data) -> Data {
|
||||||
|
let bom = Data([0xEF, 0xBB, 0xBF])
|
||||||
|
return data.starts(with: bom) ? Data(data.dropFirst(3)) : data
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decodeText(_ data: Data) -> Data {
|
||||||
|
if let value = String(data: data, encoding: .utf8) { return Data(value.utf8) }
|
||||||
|
if data.starts(with: [0xFF, 0xFE]), let value = String(data: data, encoding: .utf16LittleEndian) {
|
||||||
|
return Data(value.utf8)
|
||||||
|
}
|
||||||
|
if data.starts(with: [0xFE, 0xFF]), let value = String(data: data, encoding: .utf16BigEndian) {
|
||||||
|
return Data(value.utf8)
|
||||||
|
}
|
||||||
|
if let value = String(data: data, encoding: Self.gb18030Encoding) {
|
||||||
|
return Data(value.utf8)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
private static var gb18030Encoding: String.Encoding {
|
||||||
|
String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding(CFStringEncoding(CFStringEncodings.GB_18030_2000.rawValue)))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decompressIfGzip(_ data: Data) -> Data {
|
||||||
|
guard data.count > 10, data[0] == 0x1F, data[1] == 0x8B, data[2] == 0x08 else { return data }
|
||||||
|
var offset = 10
|
||||||
|
let flags = Int(data[3])
|
||||||
|
if flags & 0x04 != 0 {
|
||||||
|
guard offset + 2 <= data.count - 8 else { return data }
|
||||||
|
let extraLength = Int(data[offset]) | (Int(data[offset + 1]) << 8)
|
||||||
|
offset += 2 + extraLength
|
||||||
|
}
|
||||||
|
if flags & 0x08 != 0 {
|
||||||
|
while offset < data.count, data[offset] != 0 { offset += 1 }
|
||||||
|
offset += 1
|
||||||
|
}
|
||||||
|
if flags & 0x10 != 0 {
|
||||||
|
while offset < data.count, data[offset] != 0 { offset += 1 }
|
||||||
|
offset += 1
|
||||||
|
}
|
||||||
|
if flags & 0x02 != 0 { offset += 2 }
|
||||||
|
guard offset < data.count - 8 else { return data }
|
||||||
|
let payload = Data(data[offset..<data.count - min(8, data.count - offset)])
|
||||||
|
return inflate(payload) ?? data
|
||||||
|
}
|
||||||
|
|
||||||
|
private func inflate(_ payload: Data) -> Data? {
|
||||||
|
var capacity = max(payload.count * 4, 64 * 1_024)
|
||||||
|
while capacity > 0, capacity <= Self.maximumDocumentBytes + 1 {
|
||||||
|
let destination = UnsafeMutablePointer<UInt8>.allocate(capacity: capacity)
|
||||||
|
let produced = payload.withUnsafeBytes { (source: UnsafeRawBufferPointer) -> Int in
|
||||||
|
guard let base = source.bindMemory(to: UInt8.self).baseAddress else { return 0 }
|
||||||
|
return compression_decode_buffer(
|
||||||
|
destination, capacity,
|
||||||
|
base, payload.count,
|
||||||
|
nil,
|
||||||
|
COMPRESSION_ZLIB
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if produced > 0, produced < capacity {
|
||||||
|
let result = Data(bytes: destination, count: produced)
|
||||||
|
destination.deallocate()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
destination.deallocate()
|
||||||
|
guard produced == capacity, capacity <= Int.max / 2 else { return nil }
|
||||||
|
capacity = min(capacity * 2, Self.maximumDocumentBytes + 1)
|
||||||
|
if capacity == produced { return nil }
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct BookSourceIssue: Codable, Hashable, Identifiable, Sendable {
|
||||||
|
public enum Severity: String, Codable, Sendable {
|
||||||
|
case warning
|
||||||
|
case error
|
||||||
|
}
|
||||||
|
|
||||||
|
public var severity: Severity
|
||||||
|
public var code: String
|
||||||
|
public var field: String
|
||||||
|
public var message: String
|
||||||
|
|
||||||
|
public var id: String { "\(code):\(field):\(message)" }
|
||||||
|
|
||||||
|
public init(severity: Severity, code: String, field: String, message: String) {
|
||||||
|
self.severity = severity
|
||||||
|
self.code = code
|
||||||
|
self.field = field
|
||||||
|
self.message = message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum BookSourceValidator {
|
||||||
|
public static func validate(_ source: BookSource) -> [BookSourceIssue] {
|
||||||
|
var issues: [BookSourceIssue] = []
|
||||||
|
if source.bookSourceUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
issues.append(.init(
|
||||||
|
severity: .error,
|
||||||
|
code: "source_url.empty",
|
||||||
|
field: "bookSourceUrl",
|
||||||
|
message: "书源地址不能为空"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
if source.bookSourceName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
issues.append(.init(
|
||||||
|
severity: .error,
|
||||||
|
code: "source_name.empty",
|
||||||
|
field: "bookSourceName",
|
||||||
|
message: "书源名称不能为空"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
if !source.bookSourceUrl.isEmpty,
|
||||||
|
let components = URLComponents(string: source.bookSourceUrl),
|
||||||
|
components.scheme == nil {
|
||||||
|
issues.append(.init(
|
||||||
|
severity: .warning,
|
||||||
|
code: "source_url.no_scheme",
|
||||||
|
field: "bookSourceUrl",
|
||||||
|
message: "书源地址没有 URL scheme,运行时可能无法解析相对地址"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
if source.searchUrl?.isEmpty == false, source.ruleSearch == nil {
|
||||||
|
issues.append(.init(
|
||||||
|
severity: .warning,
|
||||||
|
code: "search.rule_missing",
|
||||||
|
field: "ruleSearch",
|
||||||
|
message: "配置了搜索地址但没有搜索解析规则"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
if source.exploreUrl?.isEmpty == false, source.ruleExplore == nil {
|
||||||
|
issues.append(.init(
|
||||||
|
severity: .warning,
|
||||||
|
code: "explore.rule_missing",
|
||||||
|
field: "ruleExplore",
|
||||||
|
message: "配置了发现地址但没有发现解析规则"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return issues
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user