commit ffc9e9d91d78567312d0d11981339aea19f966e8 Author: Aminark Date: Wed Oct 14 11:26:13 2026 +0800 feat: open-source 小浣熊阅读 for iOS diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..2dee7c1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -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: 优先使用虚构域名和自造响应;在线样本请确认有权公开。 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..a1856df --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: 使用说明与当前支持范围 + url: https://github.com/Aminark/legado-ios#readme + about: 提交前请查看 README 的使用步骤、兼容边界和已知问题。 diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..00d70e5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -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 diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..322f22f --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,7 @@ +## 问题与修改后的行为 + +说明具体问题、触发方式和最终行为;有关联 Issue 时附链接。 + +## 验证 + +说明运行的测试、构建、设备 / 系统,以及尚未验证的场景。影响界面时附截图。 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..da126dc --- /dev/null +++ b/.gitignore @@ -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 diff --git a/App/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/App/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png new file mode 100644 index 0000000..54540e1 Binary files /dev/null and b/App/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ diff --git a/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..f22e10c --- /dev/null +++ b/App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "AppIcon-1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/App/Assets.xcassets/BrandIcon.imageset/BrandIcon.png b/App/Assets.xcassets/BrandIcon.imageset/BrandIcon.png new file mode 100644 index 0000000..54540e1 Binary files /dev/null and b/App/Assets.xcassets/BrandIcon.imageset/BrandIcon.png differ diff --git a/App/Assets.xcassets/BrandIcon.imageset/Contents.json b/App/Assets.xcassets/BrandIcon.imageset/Contents.json new file mode 100644 index 0000000..8002ae4 --- /dev/null +++ b/App/Assets.xcassets/BrandIcon.imageset/Contents.json @@ -0,0 +1,6 @@ +{ + "images": [ + { "filename": "BrandIcon.png", "idiom": "universal" } + ], + "info": { "author": "xcode", "version": 1 } +} diff --git a/App/Assets.xcassets/Contents.json b/App/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/App/Assets.xcassets/ProfileHeader.imageset/Contents.json b/App/Assets.xcassets/ProfileHeader.imageset/Contents.json new file mode 100644 index 0000000..cd2e9e4 --- /dev/null +++ b/App/Assets.xcassets/ProfileHeader.imageset/Contents.json @@ -0,0 +1,6 @@ +{ + "images": [ + { "filename": "profile-reading-header.png", "idiom": "universal" } + ], + "info": { "author": "xcode", "version": 1 } +} diff --git a/App/Assets.xcassets/ProfileHeader.imageset/profile-reading-header.png b/App/Assets.xcassets/ProfileHeader.imageset/profile-reading-header.png new file mode 100644 index 0000000..dadf2dd Binary files /dev/null and b/App/Assets.xcassets/ProfileHeader.imageset/profile-reading-header.png differ diff --git a/App/Compatibility.swift b/App/Compatibility.swift new file mode 100644 index 0000000..accce9e --- /dev/null +++ b/App/Compatibility.swift @@ -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: 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: 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, 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: 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 { + init(item: URL) { + self.init(item: item) { SwiftUI.Label("分享", systemImage: "square.and.arrow.up") } + } +} + +struct ReadingPhotoPicker: 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?) -> 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?) -> Void + + init(onResult: @escaping @MainActor (Result?) -> 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( + isPresented: Binding, @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 + } + } +} diff --git a/App/DemoContent.swift b/App/DemoContent.swift new file mode 100644 index 0000000..a7fd3fc --- /dev/null +++ b/App/DemoContent.swift @@ -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] + ) + ] + }() +} diff --git a/App/DesignSystem.swift b/App/DesignSystem.swift new file mode 100644 index 0000000..b259ee6 --- /dev/null +++ b/App/DesignSystem.swift @@ -0,0 +1,380 @@ +import SwiftUI + +struct ReadingUnavailableView: 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, 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, isPresented: Binding, 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: 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: 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) + } +} diff --git a/App/Features/Activity/CacheManagementView.swift b/App/Features/Activity/CacheManagementView.swift new file mode 100644 index 0000000..d684a50 --- /dev/null +++ b/App/Features/Activity/CacheManagementView.swift @@ -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 ?? "未知错误") + } + } +} diff --git a/App/Features/Activity/FileManagementView.swift b/App/Features/Activity/FileManagementView.swift new file mode 100644 index 0000000..01bffa1 --- /dev/null +++ b/App/Features/Activity/FileManagementView.swift @@ -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() + @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() } + } +} diff --git a/App/Features/Activity/ReadingActivityModel.swift b/App/Features/Activity/ReadingActivityModel.swift new file mode 100644 index 0000000..650b823 --- /dev/null +++ b/App/Features/Activity/ReadingActivityModel.swift @@ -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.. 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)) + } +} diff --git a/App/Features/Activity/ReadingStatisticsView.swift b/App/Features/Activity/ReadingStatisticsView.swift new file mode 100644 index 0000000..2b15c66 --- /dev/null +++ b/App/Features/Activity/ReadingStatisticsView.swift @@ -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)秒" + } +} diff --git a/App/Features/Books/BookDetailView.swift b/App/Features/Books/BookDetailView.swift new file mode 100644 index 0000000..79d2d7d --- /dev/null +++ b/App/Features/Books/BookDetailView.swift @@ -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) + } + } + } +} diff --git a/App/Features/Books/BookDetailViewModel.swift b/App/Features/Books/BookDetailViewModel.swift new file mode 100644 index 0000000..8ea97a3 --- /dev/null +++ b/App/Features/Books/BookDetailViewModel.swift @@ -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? + + 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.. 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? + 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) 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) + } +} diff --git a/App/Features/Books/BookshelfReadViewModel.swift b/App/Features/Books/BookshelfReadViewModel.swift new file mode 100644 index 0000000..e8478da --- /dev/null +++ b/App/Features/Books/BookshelfReadViewModel.swift @@ -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 + } +} diff --git a/App/Features/Books/LocalBookDetailView.swift b/App/Features/Books/LocalBookDetailView.swift new file mode 100644 index 0000000..d555ab5 --- /dev/null +++ b/App/Features/Books/LocalBookDetailView.swift @@ -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() } + } + } + } + } +} diff --git a/App/Features/Books/SourceSwitchView.swift b/App/Features/Books/SourceSwitchView.swift new file mode 100644 index 0000000..b54770b --- /dev/null +++ b/App/Features/Books/SourceSwitchView.swift @@ -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)") + } +} diff --git a/App/Features/Explore/ExploreView.swift b/App/Features/Explore/ExploreView.swift new file mode 100644 index 0000000..645d79d --- /dev/null +++ b/App/Features/Explore/ExploreView.swift @@ -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? + + 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) + } + } +} diff --git a/App/Features/RSS/RSSAddBookView.swift b/App/Features/RSS/RSSAddBookView.swift new file mode 100644 index 0000000..825d7ee --- /dev/null +++ b/App/Features/RSS/RSSAddBookView.swift @@ -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 + } + } +} diff --git a/App/Features/RSS/RSSLibraryModel.swift b/App/Features/RSS/RSSLibraryModel.swift new file mode 100644 index 0000000..60e569a --- /dev/null +++ b/App/Features/RSS/RSSLibraryModel.swift @@ -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 } +} diff --git a/App/Features/RSS/RSSSourceDebugView.swift b/App/Features/RSS/RSSSourceDebugView.swift new file mode 100644 index 0000000..b4364db --- /dev/null +++ b/App/Features/RSS/RSSSourceDebugView.swift @@ -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? + + 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 } } +} diff --git a/App/Features/RSS/RSSSourceEditorView.swift b/App/Features/RSS/RSSSourceEditorView.swift new file mode 100644 index 0000000..132ca50 --- /dev/null +++ b/App/Features/RSS/RSSSourceEditorView.swift @@ -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, axis: Axis = .horizontal) -> some View { + ReadingTextField(title, text: optionalText(keyPath), axis: axis).textInputAutocapitalization(.never) + } + @ViewBuilder private func requiredField(_ title: String, _ keyPath: WritableKeyPath, 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) -> Binding { + Binding(get: { source[keyPath: keyPath] ?? "" }, set: { source[keyPath: keyPath] = $0.isEmpty ? nil : $0 }) + } + private func optionalBool(_ keyPath: WritableKeyPath, default defaultValue: Bool) -> Binding { + Binding(get: { source[keyPath: keyPath] ?? defaultValue }, set: { source[keyPath: keyPath] = $0 }) + } +} diff --git a/App/Features/RSS/RSSViews.swift b/App/Features/RSS/RSSViews.swift new file mode 100644 index 0000000..4a86e31 --- /dev/null +++ b/App/Features/RSS/RSSViews.swift @@ -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[.. 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 } +} diff --git a/App/Features/RSS/RSSWebReaderView.swift b/App/Features/RSS/RSSWebReaderView.swift new file mode 100644 index 0000000..418bbf3 --- /dev/null +++ b/App/Features/RSS/RSSWebReaderView.swift @@ -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("", style: source.style), baseURL: URL(string: value)) + case .video(let value): + let escaped = Self.htmlEscape(value) + webView.loadHTMLString(htmlProcessor.contentHTML("", 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 = "\(value)".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 } +} diff --git a/App/Features/Reader/ReaderAppearance.swift b/App/Features/Reader/ReaderAppearance.swift new file mode 100644 index 0000000..4df091b --- /dev/null +++ b/App/Features/Reader/ReaderAppearance.swift @@ -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 + } +} diff --git a/App/Features/Reader/ReaderScrollView.swift b/App/Features/Reader/ReaderScrollView.swift new file mode 100644 index 0000000..2eda514 --- /dev/null +++ b/App/Features/Reader/ReaderScrollView.swift @@ -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) -> 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.. 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)" + } +} diff --git a/App/Features/Reader/ReaderSpeechController.swift b/App/Features/Reader/ReaderSpeechController.swift new file mode 100644 index 0000000..7747dc4 --- /dev/null +++ b/App/Features/Reader/ReaderSpeechController.swift @@ -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? + + 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..= 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? + private var httpPrefetchTasks: [Task] = [] + 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() + } +} diff --git a/App/Features/Reader/ReaderSupport.swift b/App/Features/Reader/ReaderSupport.swift new file mode 100644 index 0000000..97ff4ea --- /dev/null +++ b/App/Features/Reader/ReaderSupport.swift @@ -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") } +} diff --git a/App/Features/Reader/ReaderTextView.swift b/App/Features/Reader/ReaderTextView.swift new file mode 100644 index 0000000..6f792a0 --- /dev/null +++ b/App/Features/Reader/ReaderTextView.swift @@ -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.. 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) + } + } + } +} diff --git a/App/Features/Reader/ReaderView.swift b/App/Features/Reader/ReaderView.swift new file mode 100644 index 0000000..6d72a38 --- /dev/null +++ b/App/Features/Reader/ReaderView.swift @@ -0,0 +1,2140 @@ +@preconcurrency import AVFoundation +import SwiftUI +import UIKit +import UniformTypeIdentifiers + +struct ReaderView: View { + + @Environment(\.dismiss) private var dismiss + @Environment(\.scenePhase) private var scenePhase + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @EnvironmentObject private var library: BookLibraryModel + @EnvironmentObject private var sourceLibrary: SourceLibraryModel + @EnvironmentObject private var replaceRules: ReplaceRuleLibraryModel + @EnvironmentObject private var activity: ReadingActivityModel + @StateObject private var model: ReaderViewModel + private var book: Book { model.book } + @State private var sourceSwitchBook: Book? + @StateObject private var speech = ReaderSpeechController() + @State private var showsTableOfContents = false + @State private var showsBookmarks = false + @State private var showsAutoReadControls = false + @State private var showsStyleControls = false + @State private var isAutoReading = false + @State private var showsReaderMenu = false + @State private var followsSystemBrightness = true + @State private var readerBrightness = Double(UIScreen.main.brightness) + @State private var brightnessBeforeReading = Double(UIScreen.main.brightness) + @State private var tocSearch = "" + @State private var tocDescending = false + @State private var showsContentSearch = false + @State private var showsReplaceRules = false + @State private var showsReaderSettings = false + @State private var showsClickActionSettings = false + @State private var showsContentEditor = false + @State private var editedContent = "" + @State private var contentEditError: String? + @State private var backgroundImage: UIImage? + @State private var backgroundError: String? + @State private var showsFontImporter = false + @State private var fontError: String? + @State private var readerJumpPosition: Int? + @State private var contentSearchQuery = "" + @State private var readerPosition = 0 + @State private var readerPageRanges: [NSRange] = [] + @State private var speechPageIndex = 0 + @State private var currentPage = 1 + @State private var totalPages = 1 + @State private var readerRenderRevision = 0 + @State private var isWaitingForNextChapter = false + @State private var isWaitingForPreviousChapter = false + @State private var httpTTSEngines: [HttpTTS] = [] + @State private var resumesSpeechAfterChapterLoad = false + @State private var speechTimerTask: Task? + @State private var progressSaveTask: Task? + @State private var progressSaveGeneration = 0 + @State private var speechStartGeneration = 0 + @State private var speechTimerRemaining = 0 + @State private var bookmarkSaved = false + @ScaledMetric(relativeTo: .body) private var toolbarIconSize = 21 + @AppStorage("textSize") private var fontSize = 20.0 + @AppStorage("letterSpacing") private var letterSpacing = 0.1 + @AppStorage("lineSpacingExtra") private var lineSpacing = 12.0 + @AppStorage("paragraphSpacing") private var paragraphSpacing = 2.0 + @AppStorage("paragraphIndent") private var paragraphIndent = "  " + @AppStorage("paddingLeft") private var paddingLeft = 16.0 + @AppStorage("paddingRight") private var paddingRight = 16.0 + @AppStorage("paddingTop") private var paddingTop = 6.0 + @AppStorage("paddingBottom") private var paddingBottom = 6.0 + @AppStorage("textBold") private var fontWeight = 0 + @AppStorage("textFont") private var fontPath = "" + @AppStorage("titleMode") private var titleMode = 0 + @AppStorage("titleSize") private var titleSize = 0.0 + @AppStorage("titleTopSpacing") private var titleTopSpacing = 0.0 + @AppStorage("titleBottomSpacing") private var titleBottomSpacing = 0.0 + @AppStorage("underlineMode") private var underlineMode = 0 + @AppStorage("headerPaddingBottom") private var headerPaddingBottom = 0.0 + @AppStorage("headerPaddingLeft") private var headerPaddingLeft = 16.0 + @AppStorage("headerPaddingRight") private var headerPaddingRight = 16.0 + @AppStorage("headerPaddingTop") private var headerPaddingTop = 0.0 + @AppStorage("footerPaddingBottom") private var footerPaddingBottom = 6.0 + @AppStorage("footerPaddingLeft") private var footerPaddingLeft = 16.0 + @AppStorage("footerPaddingRight") private var footerPaddingRight = 16.0 + @AppStorage("footerPaddingTop") private var footerPaddingTop = 6.0 + @AppStorage("showHeaderLine") private var showHeaderLine = false + @AppStorage("showFooterLine") private var showFooterLine = true + @AppStorage("tipHeaderLeft") private var tipHeaderLeft = 2 + @AppStorage("tipHeaderMiddle") private var tipHeaderMiddle = 0 + @AppStorage("tipHeaderRight") private var tipHeaderRight = 3 + @AppStorage("tipFooterLeft") private var tipFooterLeft = 1 + @AppStorage("tipFooterMiddle") private var tipFooterMiddle = 0 + @AppStorage("tipFooterRight") private var tipFooterRight = 6 + @AppStorage("headerMode") private var headerMode = 0 + @AppStorage("footerMode") private var footerMode = 0 + @AppStorage("readerTheme") private var theme = "wechat" + @AppStorage("readerPreviousDayTheme") private var previousDayTheme = "wechat" + @AppStorage("autoReadSpeed") private var autoReadSpeed = 10 + @AppStorage("ttsFollowSys") private var ttsFollowSystem = true + @AppStorage("ttsSpeechRate") private var ttsSpeechRate = 5 + @AppStorage("appTtsEngine") private var appTTSEngine = "" + @AppStorage("systemTtsVoice") private var systemTTSVoice = "" + @AppStorage("readAloudByPage") private var readAloudByPage = false + @AppStorage("streamReadAloudAudio") private var streamReadAloudAudio = true + @AppStorage("readAloudWakeLock") private var readAloudWakeLock = false + @AppStorage("ignoreAudioFocus") private var ignoreAudioFocus = false + @AppStorage("pauseReadAloudWhilePhoneCalls") private var pauseReadAloudWhilePhoneCalls = false + @AppStorage("mediaButtonPerNext") private var mediaButtonPerNext = false + @AppStorage("ttsTimer") private var ttsTimer = 0 + @AppStorage("readerFullJustify") private var fullJustify = true + @AppStorage("readerKeepScreenOn") private var keepScreenOn = false + @AppStorage("readerHideHomeIndicator") private var hideHomeIndicator = false + @AppStorage("readerHideStatusBar") private var hideStatusBar = false + @AppStorage("readerShowBrightness") private var showBrightness = true + @AppStorage("readerBrightness") private var storedReaderBrightness = 0.0 + @AppStorage("readerUseBackgroundImage") private var useBackgroundImage = false + @AppStorage("themeMode") private var themeMode = "0" + @AppStorage("enableReadRecord") private var enableReadRecord = true + @State private var pageAnimation: Int + @AppStorage("screenOrientation") private var screenOrientation = "0" + @AppStorage("clickActionTopLeft") private var clickActionTL = 2 + @AppStorage("clickActionTopCenter") private var clickActionTC = 2 + @AppStorage("clickActionTopRight") private var clickActionTR = 1 + @AppStorage("clickActionMiddleLeft") private var clickActionML = 2 + @AppStorage("clickActionMiddleRight") private var clickActionMR = 1 + @AppStorage("clickActionBottomLeft") private var clickActionBL = 2 + @AppStorage("clickActionBottomCenter") private var clickActionBC = 1 + @AppStorage("clickActionBottomRight") private var clickActionBR = 1 + + + init(book: Book, chapters: [BookChapter], initialIndex: Int, source: BookSource) { + _pageAnimation = State(initialValue: book.readConfig?.pageAnim.flatMap { $0 >= 0 ? $0 : nil } + ?? Self.globalPageAnimation()) + _model = StateObject(wrappedValue: ReaderViewModel( + book: book, + chapters: chapters, + initialIndex: initialIndex, + source: source + )) + } + + init(book: Book, chapters: [BookChapter], initialIndex: Int) { + _pageAnimation = State(initialValue: book.readConfig?.pageAnim.flatMap { $0 >= 0 ? $0 : nil } + ?? Self.globalPageAnimation()) + _model = StateObject(wrappedValue: ReaderViewModel( + book: book, + chapters: chapters, + initialIndex: initialIndex + )) + } + + init(demoBook book: Book, chapters: [BookChapter], initialIndex: Int, content: String) { + var readerChapters = chapters + var readerInitialIndex = initialIndex + var readerContent = content + #if DEBUG + let arguments = ProcessInfo.processInfo.arguments + if arguments.contains("-uiScrollRegression") { + readerChapters = (0..<80).map { + BookChapter(url: "demo://scroll/\($0)", title: "第\($0 + 1)章 · 连续阅读", bookUrl: book.bookUrl, index: $0) + } + readerInitialIndex = 20 + if arguments.contains("-uiShortChapters") { readerContent = "短章正文,继续向下可以自然读到下一章。" } + } + #endif + _pageAnimation = State(initialValue: book.readConfig?.pageAnim.flatMap { $0 >= 0 ? $0 : nil } + ?? Self.globalPageAnimation()) + #if DEBUG + if arguments.contains("-uiReaderFailure") { + _model = StateObject(wrappedValue: ReaderViewModel( + book: book, chapters: readerChapters, initialIndex: readerInitialIndex, + source: BookSource(bookSourceUrl: "https://reader-failure.invalid", bookSourceName: "正文失败测试源"), + service: ReaderFailureTestLoader() + )) + return + } + #endif + _model = StateObject(wrappedValue: ReaderViewModel( + demoBook: book, + chapters: readerChapters, + initialIndex: readerInitialIndex, + content: readerContent + )) + } + + + var body: some View { + readerCanvas + .preferredColorScheme(resolvedStyle.id == "night" ? .dark : .light) + .readingTabBarHidden() + .navigationBarBackButtonHidden(true) + .background { + ReaderInteractivePopEnabler() + .frame(width: 0, height: 0) + } + .statusBarHidden(hideStatusBar) + .readingHomeIndicatorHidden(hideHomeIndicator) + .overlay(alignment: .top) { + if showsReaderMenu { + readerTopChrome + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + .animation(.easeOut(duration: 0.22), value: showsReaderMenu) + .onAppear { + activity.beginReading(book) + speech.onNextChapter = { moveSpeechToChapter(direction: 1) } + speech.onPreviousChapter = { moveSpeechToChapter(direction: -1) } + speech.onRemoteStop = { + speechTimerTask?.cancel() + speechTimerRemaining = 0 + resumesSpeechAfterChapterLoad = false + } + UIDevice.current.isBatteryMonitoringEnabled = true + brightnessBeforeReading = Double(UIScreen.main.brightness) + if storedReaderBrightness > 0.05 { + followsSystemBrightness = false + readerBrightness = storedReaderBrightness + UIScreen.main.brightness = CGFloat(storedReaderBrightness) + } else { + readerBrightness = brightnessBeforeReading + } + backgroundImage = ReaderBackgroundStore.load() + ReaderScreenOrientation.apply(screenOrientation) + Task { await loadHTTPEngines() } + if ProcessInfo.processInfo.arguments.contains("-uiDemoBookmarks") { + showsBookmarks = true + } + if ProcessInfo.processInfo.arguments.contains("-uiDemoReaderMenu") { + showsReaderMenu = true + } + if ProcessInfo.processInfo.arguments.contains("-uiDemoReaderStyle") { + DispatchQueue.main.async { showsStyleControls = true } + } + } + .onDisappear { + isAutoReading = false + UIApplication.shared.isIdleTimerDisabled = false + if speech.isSpeaking || speech.isPaused { speech.toggle(text: "") } + saveProgress(immediate: true) + ReaderScreenOrientation.restoreApplicationDefault() + activity.cancelReading(book) + UIDevice.current.isBatteryMonitoringEnabled = false + if !followsSystemBrightness { + UIScreen.main.brightness = CGFloat(brightnessBeforeReading) + } + speechTimerTask?.cancel() + speechTimerRemaining = 0 + speech.onNextChapter = nil + speech.onPreviousChapter = nil + speech.onRemoteStop = nil + } + .overlay(alignment: .bottom) { + if showsReaderMenu { + readerBottomChrome + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .sheet(isPresented: $showsTableOfContents) { tableOfContents } + .sheet(isPresented: $showsBookmarks) { bookmarksSheet } + .sheet(isPresented: $showsAutoReadControls) { autoReadControls } + .sheet(isPresented: $showsStyleControls) { styleControls } + .sheet(isPresented: $showsContentSearch) { contentSearchSheet } + .sheet(item: $sourceSwitchBook) { currentBook in + ReadingNavigationStack { + SourceSwitchView(book: currentBook, sources: sourceLibrary.sources) { report, source in + Task { + await progressSaveTask?.value + await activity.endReading(book) + await library.replace(oldBookURL: book.bookUrl, with: report.book) + model.applySourceSwitch(report, source: source) + readerPosition = report.book.durChapterPos + readerJumpPosition = nil + readerPageRanges = [] + readerRenderRevision &+= 1 + activity.beginReading(report.book) + } + } + } + } + .sheet(isPresented: $showsReplaceRules) { + ReadingNavigationStack { ReplaceRuleManagementView() } + } + .sheet(isPresented: $showsReaderSettings) { readerSettings } + .sheet(isPresented: $showsClickActionSettings) { clickActionSettings } + .sheet(isPresented: $showsContentEditor) { contentEditor } + .fileImporter( + isPresented: $showsFontImporter, + allowedContentTypes: [.font, .data], + allowsMultipleSelection: false + ) { result in + fontError = nil + do { + guard let source = try result.get().first else { return } + fontPath = try ReaderFontStore.install(from: source) + } catch { + fontError = error.localizedDescription + } + } + .task(id: model.chapterLoadGeneration) { + isWaitingForNextChapter = false + isWaitingForPreviousChapter = false + let needsInitialPosition = !model.hasLoadedCurrentChapter + if let pending = progressSaveTask { + let pendingGeneration = progressSaveGeneration + await pending.value + // A newer page callback may have scheduled another save while + // this chapter task was waiting. Never erase that newer task's + // handle, or a subsequent chapter transition could skip it. + if progressSaveGeneration == pendingGeneration { + progressSaveTask = nil + } + } + readerJumpPosition = nil + readerPageRanges = [] + if needsInitialPosition { readerPosition = model.requestedPosition } + await model.load(rules: model.usesReplaceRules ? replaceRules.rules : [], prefetchNextChapter: pageAnimation != 3) + guard !Task.isCancelled else { return } + if needsInitialPosition { + readerPosition = pageAnimation == 3 + ? continuousPosition(model.requestedPosition, content: model.content) + : min(model.requestedPosition, max(model.content.utf16.count - 1, 0)) + } + if let chapter = model.currentChapter { + await library.updateProgress(for: book, chapter: chapter, position: readerPosition) + } + if resumesSpeechAfterChapterLoad, !model.content.isEmpty { + resumesSpeechAfterChapterLoad = false + startConfiguredSpeech() + } + } + .task(id: "\(model.chapterLoadGeneration):\(pageAnimation == 3):\(model.hasLoadedCurrentChapter):\(model.scrollRevision)") { + guard pageAnimation == 3 else { return } + await model.prepareContinuousReading(rules: model.usesReplaceRules ? replaceRules.rules : []) + } + .onAppear { updateIdleTimer() } + .onChange(of: keepScreenOn) { _ in + updateIdleTimer() + } + .onChange(of: adjacentReadiness) { _ in + if model.isContinuationWindowReady, isWaitingForNextChapter { + isWaitingForNextChapter = false + readerRenderRevision &+= 1 + } + if model.isPreviousWindowReady, isWaitingForPreviousChapter { + advanceToPreparedPreviousChapter() + } + } + .onChange(of: isAutoReading) { _ in + updateIdleTimer() + } + .onChange(of: speech.isSpeaking) { _ in + updateIdleTimer() + } + .onChange(of: speech.completionCount) { _ in + if readAloudByPage { + continueSpeechToNextPage() + } else { + continueSpeechToNextChapter() + } + } + .onChange(of: speech.currentSegmentIndex) { index in + guard model.paragraphs.indices.contains(index), !readAloudByPage else { return } + readerJumpPosition = nil + DispatchQueue.main.async { readerJumpPosition = model.paragraphs[index].id } + } + .onChange(of: readAloudByPage) { _ in + restartSpeechAfterReaderLayoutChange() + } + .onChange(of: readAloudWakeLock) { _ in + updateIdleTimer() + } + .onChange(of: scenePhase) { phase in + if phase == .active { + if !followsSystemBrightness { + UIScreen.main.brightness = CGFloat(readerBrightness) + } + activity.beginReading(book) + } else { + isAutoReading = false + if !followsSystemBrightness { + UIScreen.main.brightness = CGFloat(brightnessBeforeReading) + } + saveProgress(immediate: true) + activity.cancelReading(book) + } + } + .onChange(of: screenOrientation) { value in + ReaderScreenOrientation.apply(value) + } + .onChange(of: themeMode) { _ in + isAutoReading = false + readerJumpPosition = nil + currentPage = 1 + totalPages = 1 + } + .onChange(of: pageAnimation) { value in + isAutoReading = false + saveProgress(immediate: true) + readerJumpPosition = readerPosition + currentPage = 1 + totalPages = 1 + UserDefaults.standard.set(value, forKey: "pageAnim") + let updatedBook = model.useGlobalPageAnimation() + Task { await library.addOrUpdate(updatedBook) } + } + } + + + private var readerCanvas: some View { + ZStack { + readerContent + readerMenuDismissOverlay + } + .background { + ZStack { + backgroundColor + if themeMode != "3", useBackgroundImage, let backgroundImage { + Image(uiImage: backgroundImage) + .resizable() + .scaledToFill() + } + } + .clipped() + .ignoresSafeArea() + } + } + + @ViewBuilder private var readerContent: some View { + if pageAnimation != 3, + (model.isLoading && model.content.isEmpty) || (model.content.isEmpty && !model.hasLoadedCurrentChapter) { + VStack(spacing: 14) { + ProgressView() + .controlSize(.regular) + Text("正在打开本章") + .font(.subheadline.weight(.medium)) + Text(model.title) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .foregroundStyle(foregroundColor) + .background(backgroundColor.ignoresSafeArea()) + .contentShape(Rectangle()) + .onTapGesture { showsReaderMenu.toggle() } + .transition(.opacity) + } else if pageAnimation != 3, model.content.isEmpty && model.hasLoadedCurrentChapter { + VStack(spacing: 18) { + Image(systemName: "text.page.slash").font(.largeTitle) + Text(model.currentChapterError == nil ? "正文为空" : "本章加载失败").font(.title3.weight(.semibold)) + Text(model.currentChapterError ?? "本章暂无正文。") + .font(.subheadline).multilineTextAlignment(.center) + .foregroundStyle(.secondary) + readerRecoveryActions + Button("打开阅读菜单") { showsReaderMenu = true } + .accessibilityIdentifier("reader.failureMenu") + } + .padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .foregroundStyle(foregroundColor) + .background(backgroundColor.ignoresSafeArea()) + } else { + VStack(spacing: 0) { + if headerVisible { readerTipBar(isHeader: true) } + readerSurface + if footerVisible { readerTipBar(isHeader: false) } + } + .frame(maxWidth: readingColumnMaxWidth) + .frame(maxWidth: .infinity) + .overlay(alignment: .bottom) { + if pageAnimation == 3, let error = model.currentChapterError, !showsReaderMenu { + VStack(spacing: 10) { + Text("本章加载失败").font(.subheadline.weight(.semibold)) + Text(error).font(.caption).lineLimit(2).multilineTextAlignment(.center) + readerRecoveryActions + } + .padding(16) + .frame(maxWidth: .infinity) + .foregroundStyle(foregroundColor) + .background(backgroundColor) + } + } + } + } + + private var readerRecoveryActions: some View { + HStack(spacing: 16) { + Button { + Task { + if pageAnimation == 3, model.scrollChapters.contains(where: { $0.id == model.currentIndex }) { + await model.retryContinuousChapter(index: model.currentIndex, rules: model.usesReplaceRules ? replaceRules.rules : []) + } else { + await model.load(rules: model.usesReplaceRules ? replaceRules.rules : [], forceRefresh: true) + } + } + } label: { + Label(model.scrollRetries.contains(model.currentIndex) ? "重试中…" : "重试本章", systemImage: "arrow.clockwise") + } + .disabled(model.isLoading || model.scrollRetries.contains(model.currentIndex)) + .accessibilityIdentifier("reader.retryChapter") + if model.canEditContent { + Button(action: openSourceSwitch) { + Label("换源", systemImage: "arrow.triangle.2.circlepath") + } + .accessibilityIdentifier("reader.failureChangeSource") + } + } + .buttonStyle(.bordered) + } + + private func openSourceSwitch() { + stopSpeechIfNeeded() + isAutoReading = false + saveProgress(immediate: true) + showsReaderMenu = false + sourceSwitchBook = model.bookForSourceSwitch(position: readerPosition) + } + + @ViewBuilder private var readerSurface: some View { + if pageAnimation == 3 { + ReaderScrollView( + configuration: readerTextConfiguration, + chapters: model.scrollChapters, + resetID: model.scrollRevision, + activeIndex: model.currentIndex, + loadingAhead: model.isLoadingScrollAhead, + loadingBehind: model.isLoadingScrollBehind, + onNeedChapters: { direction in + Task { await model.extendContinuousReading(direction: direction, rules: model.usesReplaceRules ? replaceRules.rules : []) } + }, + onPositionChanged: { index, position in + if index != model.currentIndex { + saveProgress(immediate: true) + model.updateContinuousPosition(chapter: index) + readerJumpPosition = nil + } + let resolved = min(max(position, 0), model.content.utf16.count) + if readerPosition != resolved { readerPosition = resolved } + }, + onRetainChapters: { model.retainContinuousChapters($0) } + ) + } else { + readerTextConfiguration + } + } + + private var readerTextConfiguration: ReaderTextView { + ReaderTextView( + text: readerDisplayText, + title: model.title, + previousTitle: pageAnimation == 3 ? model.previousTitle : nil, + previousText: pageAnimation == 3 ? model.previousContent : "", + isPreviousReady: pageAnimation == 3 && model.isPreviousWindowReady, + continuationTitle: pageAnimation == 3 ? model.continuationTitle : nil, + continuationText: pageAnimation == 3 ? model.continuationContent : "", + isContinuationReady: pageAnimation == 3 && model.isContinuationWindowReady, + isVolume: model.currentChapter?.isVolume == true, + fontSize: fontSize, + fontPath: fontPath, + letterSpacing: letterSpacing, + lineSpacing: lineSpacing, + paragraphSpacing: paragraphSpacing, + paragraphIndent: paragraphIndent, + paddingLeft: paddingLeft, + paddingRight: paddingRight, + paddingTop: paddingTop, + paddingBottom: paddingBottom, + headerInset: 0, + footerInset: 0, + fontWeight: fontWeight, + titleMode: titleMode, + titleSize: titleSize, + titleTopSpacing: titleTopSpacing, + titleBottomSpacing: titleBottomSpacing, + underlineMode: underlineMode, + isSelectable: false, + fullJustify: fullJustify, + pageAnimation: pageAnimation, + isEInkMode: themeMode == "3", + clickActions: clickActions, + backgroundColor: themeMode != "3" && useBackgroundImage && backgroundImage != nil ? .clear : resolvedStyle.background, + foregroundColor: resolvedStyle.foreground, + requestedPosition: pageAnimation == 3 ? readerPosition : model.requestedPosition, + jumpPosition: readerJumpPosition, + renderRevision: readerRenderRevision, + readerPosition: $readerPosition, + pageRanges: $readerPageRanges, + currentPage: $currentPage, + totalPages: $totalPages, + isAutoReading: isAutoReading, + isAutoReadPaused: pausesAutoReading, + isLoadingNextChapter: isWaitingForNextChapter, + secondsPerScreen: Double(autoReadSpeed), + onReachedEnd: { boundaryPosition in + if pageAnimation == 3 { + requestNextChapter() + return + } + if model.canGoNext { + showsReaderMenu = false + readerPosition = boundaryPosition + saveProgress(immediate: true) + readerJumpPosition = nil + // Keep every chapter in its own TextKit document. Appending + // text here makes the absolute UTF-16 offset belong to the + // previous chapter while progress is saved under the next. + model.goNext() + readerPosition = model.requestedPosition + } else { + isAutoReading = false + } + }, + onReachedStart: { boundaryPosition in + if pageAnimation == 3 { + requestPreviousChapter() + return + } + guard model.canGoPrevious else { return } + showsReaderMenu = false + readerPosition = boundaryPosition + saveProgress(immediate: true) + readerJumpPosition = nil + model.goPrevious(position: Int.max) + }, + onEnteredPrevious: { position in + guard pageAnimation == 3, model.canGoPrevious, model.hasPreviousWindow else { return } + saveProgress(immediate: true) + readerPosition = continuousPosition(position, content: model.previousContent) + readerJumpPosition = nil + model.promotePrevious(position: readerPosition) + }, + onEnteredContinuation: { position in + guard pageAnimation == 3, model.canGoNext, model.hasContinuationWindow else { return } + saveProgress(immediate: true) + readerPosition = continuousPosition(position, content: model.continuationContent) + readerJumpPosition = nil + model.promoteContinuation(position: readerPosition) + }, + onDeferredRender: { readerRenderRevision &+= 1 }, + onCancelNextLoading: { isWaitingForNextChapter = false }, + onPageChanged: pageDidChange, + onTapAction: handleTapAction + ) + } + + @ViewBuilder private var readerMenuDismissOverlay: some View { + if showsReaderMenu { + Color.black + .opacity(resolvedStyle.id == "night" ? 0.08 : 0.03) + .ignoresSafeArea() + .contentShape(ReaderMenuDismissShape()) + .onTapGesture { showsReaderMenu = false } + .accessibilityHidden(true) + } + } + + private var readingColumnMaxWidth: CGFloat { + UIDevice.current.userInterfaceIdiom == .pad ? 760 : .greatestFiniteMagnitude + } + + private var adjacentReadiness: Int { + (model.isPreviousWindowReady ? 1 : 0) | (model.isContinuationWindowReady ? 2 : 0) + } + + private var readerDisplayText: String { + if !model.content.isEmpty { return model.content } + if pageAnimation == 3 { + return String(repeating: "\n", count: 80) + } + if model.isLoading || !model.hasLoadedCurrentChapter { + return "正文正在后台加载…\n\n你可以继续向下滚动,下一章已经提前生成。" + } + let reason = model.diagnostics.first?.message ?? "当前书源没有返回正文。" + return "本章加载失败\n\n\(reason)\n\n本章位置会保留,请继续向下进入下一章。" + } + + private func continuousPosition(_ position: Int, content: String) -> Int { + let length = content.isEmpty ? 80 : content.utf16.count + return min(max(position, 0), max(length - 1, 0)) + } + + private var backgroundColor: Color { + Color(uiColor: resolvedStyle.background) + } + + private var foregroundColor: Color { + Color(uiColor: resolvedStyle.foreground) + } + + private var resolvedStyle: ReaderStylePreset { + themeMode == "3" ? .eInk : ReaderStylePreset.resolve(theme) + } + + private var headerVisible: Bool { + switch headerMode { + case 1: true + case 2: false + default: hideStatusBar + } + } + + private var footerVisible: Bool { footerMode == 0 } + + private var pausesAutoReading: Bool { + isWaitingForNextChapter + || isWaitingForPreviousChapter + || showsReaderMenu + || showsTableOfContents + || showsBookmarks + || showsAutoReadControls + || showsStyleControls + || showsContentSearch + || showsReplaceRules + || showsReaderSettings + || showsClickActionSettings + || showsContentEditor + } + + + private func readerTipBar(isHeader: Bool) -> some View { + TimelineView(.periodic(from: .now, by: 30)) { context in + readerTipRow( + isHeader ? [tipHeaderLeft, tipHeaderMiddle, tipHeaderRight] : [tipFooterLeft, tipFooterMiddle, tipFooterRight], + date: context.date, + showDivider: isHeader ? showHeaderLine : showFooterLine, + isHeader: isHeader + ) + .padding(.top, isHeader ? headerPaddingTop : footerPaddingTop) + .padding(.bottom, isHeader ? headerPaddingBottom : footerPaddingBottom) + } + .allowsHitTesting(false) + } + + private func readerTipRow(_ values: [Int], date: Date, showDivider: Bool, isHeader: Bool) -> some View { + HStack(spacing: 8) { + Text(readerTipText(values[0], date: date)) + .frame(maxWidth: .infinity, alignment: .leading) + Text(readerTipText(values[1], date: date)) + .frame(maxWidth: .infinity, alignment: .center) + Text(readerTipText(values[2], date: date)) + .frame(maxWidth: .infinity, alignment: .trailing) + } + .font(.system(size: 12)) + .foregroundStyle(foregroundColor) + .lineLimit(1) + .padding(.leading, isHeader ? headerPaddingLeft : footerPaddingLeft) + .padding(.trailing, isHeader ? headerPaddingRight : footerPaddingRight) + .overlay(alignment: .bottom) { + if showDivider { Divider().overlay(foregroundColor.opacity(0.35)) } + } + } + + private func readerTipText(_ value: Int, date: Date) -> String { + let time = date.formatted(.dateTime.hour(.twoDigits(amPM: .omitted)).minute(.twoDigits)) + let batteryValue = UIDevice.current.batteryLevel + let battery = batteryValue >= 0 ? Int((batteryValue * 100).rounded()) : 100 + let chapterCount = max(model.chapters.filter { !$0.isVolume }.count, 1) + let readableIndex = model.chapters.prefix(model.currentIndex + 1).filter { !$0.isVolume }.count + let chapterFraction = chapterPageFraction + let progress = min(max((Double(max(readableIndex - 1, 0)) + chapterFraction) / Double(chapterCount), 0), 1) + let progressText = String(format: "%.1f%%", progress * 100) + switch value { + case 1: return model.title + case 2: return time + case 3: return "▰" + case 4: return "\(currentPage)/\(totalPages)" + case 5: return progressText + case 6: return "\(currentPage)/\(totalPages) \(progressText)" + case 7: return book.name + case 8: return "\(time) ▰" + case 9: return "\(time) \(battery)%" + case 10: return "\(battery)%" + case 11: return "\(readableIndex)/\(chapterCount)" + default: return "" + } + } + + private var readerChromeTint: Color { + .primary + } + + @ViewBuilder private var readerChromeBackground: some View { + if resolvedStyle.id == "night" { + Color(uiColor: .secondarySystemBackground) + } else { + Color(uiColor: .systemBackground) + } + } + + private var chapterProgress: Double { + let chapter = max(model.chapters.filter { !$0.isVolume }.count, 1) + let readable = model.chapters.prefix(model.currentIndex + 1).filter { !$0.isVolume }.count + let pagePart = chapterPageFraction + return min(max((Double(max(readable - 1, 0)) + pagePart) / Double(chapter), 0), 1) + } + + private var chapterPageFraction: Double { + guard totalPages > 1 else { return 1 } + return min(max(Double(max(currentPage - 1, 0)) / Double(totalPages - 1), 0), 1) + } + + + private var readerTopChrome: some View { + HStack(spacing: 4) { + readerChromeIconButton("chevron.backward", label: "返回") { dismiss() } + VStack(alignment: .leading, spacing: 3) { + Text(book.name) + .font(.subheadline.weight(.medium)) + .lineLimit(1) + HStack(spacing: 4) { + Text(model.title).lineLimit(1) + if model.loadedFromCache { + Image(systemName: "arrow.down.circle") + .accessibilityLabel("已缓存") + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityElement(children: .combine) + readerChromeIconButton(bookmarkSaved ? "bookmark.fill" : "bookmark", label: "书签") { + addBookmark() + } + .accessibilityValue(bookmarkSaved ? "已添加" : "添加当前位置") + readerMoreMenu + } + .foregroundStyle(readerChromeTint) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .frame(maxWidth: .infinity) + .dynamicTypeSize(...DynamicTypeSize.accessibility1) + .background { readerChromeBackground.ignoresSafeArea(edges: .top) } + .overlay(alignment: .bottom) { + Rectangle().fill(Color(uiColor: .separator).opacity(0.35)).frame(height: 0.5) + } + } + + private var readerMoreMenu: some View { + Menu { + Button("搜索正文", systemImage: "magnifyingglass") { + showsReaderMenu = false + showsContentSearch = true + } + Button("书签列表", systemImage: "bookmark") { + showsReaderMenu = false + showsBookmarks = true + } + Button(isAutoReading ? "停止自动阅读" : "自动阅读", systemImage: isAutoReading ? "stop" : "play") { + if isAutoReading { + isAutoReading = false + } else { + showsReaderMenu = false + showsAutoReadControls = true + } + } + Divider() + Button(resolvedStyle.id == "night" ? "切换为日间" : "切换为夜间", systemImage: resolvedStyle.id == "night" ? "sun.max" : "moon") { + toggleNightTheme() + } + .disabled(themeMode == "3") + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 20, weight: .regular)) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("更多") + } + + private var readerBottomChrome: some View { + VStack(spacing: 12) { + readerChapterControls + Rectangle().fill(Color(uiColor: .separator).opacity(0.35)).frame(height: 0.5) + LazyVGrid(columns: readerToolbarColumns, spacing: 12) { + readerChromeAction("list.bullet", "目录") { + showsReaderMenu = false + showsTableOfContents = true + } + if model.canEditContent { + readerChromeAction("arrow.triangle.2.circlepath", "换源", action: openSourceSwitch) + .accessibilityIdentifier("reader.changeSource") + .accessibilityValue(book.originName) + } + readerChromeAction(speech.isSpeaking ? "stop" : "headphones", speech.isSpeaking ? "停止" : "朗读", isActive: speech.isSpeaking) { toggleSpeech() } + readerChromeAction("textformat.size", "外观") { + showsReaderMenu = false + showsStyleControls = true + } + readerChromeAction("gearshape", "设置") { + showsReaderMenu = false + showsReaderSettings = true + } + } + } + .foregroundStyle(readerChromeTint) + .padding(.horizontal, 20) + .padding(.top, 12) + .padding(.bottom, 10) + .frame(maxWidth: .infinity) + .dynamicTypeSize(...DynamicTypeSize.accessibility1) + .background { readerChromeBackground.ignoresSafeArea(edges: .bottom) } + .overlay(alignment: .top) { + Rectangle().fill(Color(uiColor: .separator).opacity(0.35)).frame(height: 0.5) + } + } + + private var readerToolbarColumns: [GridItem] { + let count = dynamicTypeSize.isAccessibilitySize ? (model.canEditContent ? 3 : 2) : (model.canEditContent ? 5 : 4) + return Array(repeating: GridItem(.flexible(), spacing: 0), count: count) + } + + private var readerChapterControls: some View { + HStack(spacing: 16) { + readerChromeIconButton("chevron.left", label: "上一章") { moveChapter(-1) } + .disabled(!model.canGoPrevious) + .opacity(model.canGoPrevious ? 1 : 0.25) + VStack(spacing: 8) { + HStack { + let chapterCount = model.chapters.filter { !$0.isVolume }.count + let chapterNumber = model.chapters.prefix(model.currentIndex + 1).filter { !$0.isVolume }.count + if #available(iOS 16.0, *) { + ViewThatFits(in: .horizontal) { + Text("第 \(chapterNumber) 章 / 共 \(chapterCount) 章").fixedSize() + Text("\(chapterNumber) / \(chapterCount) 章").fixedSize() + } + } else { + Text("\(chapterNumber) / \(chapterCount) 章").fixedSize() + } + Spacer(minLength: 8) + Text(chapterProgress, format: .percent.precision(.fractionLength(0))) + } + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .lineLimit(1) + .minimumScaleFactor(0.8) + ProgressView(value: chapterProgress) + .tint(readerChromeTint) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("阅读进度") + .accessibilityValue(chapterProgress.formatted(.percent.precision(.fractionLength(0)))) + readerChromeIconButton("chevron.right", label: "下一章") { moveChapter(1) } + .disabled(!model.canGoNext) + .opacity(model.canGoNext ? 1 : 0.25) + } + } + + private func readerChromeIconButton(_ icon: String, label: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + Image(systemName: icon) + .font(.system(size: 19, weight: .regular)) + .frame(width: 44, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(label) + } + + private func readerChromeAction(_ icon: String, _ title: String, isActive: Bool = false, action: @escaping () -> Void) -> some View { + Button(action: action) { + VStack(spacing: 8) { + Image(systemName: icon) + .font(.system(size: min(toolbarIconSize, 28), weight: .regular)) + .frame(height: 28) + Text(title) + .font(.caption) + .foregroundStyle(isActive ? Color.primary : .secondary) + } + .frame(maxWidth: .infinity, minHeight: 58) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(title) + } + + private var readerBrightnessBinding: Binding { + Binding( + get: { readerBrightness }, + set: { value in + followsSystemBrightness = false + readerBrightness = value + storedReaderBrightness = value + UIScreen.main.brightness = CGFloat(value) + } + ) + } + + private func toggleNightTheme() { + if resolvedStyle.id == "night" { + theme = previousDayTheme == "night" ? "wechat" : previousDayTheme + } else { + previousDayTheme = resolvedStyle.id + theme = "night" + } + } + + private func moveChapter(_ direction: Int) { + stopSpeechIfNeeded() + showsReaderMenu = false + saveProgress(immediate: true) + readerJumpPosition = nil + if direction < 0 { model.goPrevious() } else { model.goNext() } + readerPosition = model.requestedPosition + } + + private func requestNextChapter() { + if pageAnimation == 3 { + guard model.canGoNext else { isAutoReading = false; return } + Task { await model.extendContinuousReading(direction: 1, rules: model.usesReplaceRules ? replaceRules.rules : []) } + return + } + guard model.canGoNext, model.hasContinuationWindow else { + isAutoReading = false + return + } + if model.isContinuationWindowReady { + isWaitingForNextChapter = false + readerRenderRevision &+= 1 + } else { + isWaitingForNextChapter = true + } + } + + private func requestPreviousChapter() { + guard model.canGoPrevious, model.hasPreviousWindow else { return } + if model.isPreviousWindowReady { + advanceToPreparedPreviousChapter() + } else { + isWaitingForPreviousChapter = true + } + } + + private func advanceToPreparedPreviousChapter() { + guard model.canGoPrevious, model.hasPreviousWindow, model.isPreviousWindowReady else { return } + isWaitingForPreviousChapter = false + saveProgress(immediate: true) + readerJumpPosition = nil + let position = max(model.previousContent.utf16.count - 1, 0) + readerPosition = position + model.promotePrevious(position: position) + } + + private func startAutoReading() { + stopSpeechIfNeeded() + showsReaderMenu = false + isAutoReading = true + } + + private func updateIdleTimer() { + UIApplication.shared.isIdleTimerDisabled = keepScreenOn || isAutoReading + || (readAloudWakeLock && speech.isSpeaking) + } + + private func pageDidChange() { + saveProgress() + guard enableReadRecord else { return } + Task { await activity.checkpoint(book) } + } + + + private var autoReadControls: some View { + ReadingNavigationStack { + Form { + Section { + HStack { + Text("自动翻页速度") + Spacer() + Text("\(autoReadSpeed)s").monospacedDigit().foregroundStyle(.secondary) + } + Slider( + value: Binding( + get: { Double(autoReadSpeed) }, + set: { autoReadSpeed = max(1, min(Int($0.rounded()), 120)) } + ), + in: 1...120, + step: 1 + ) + } footer: { + Text("数值表示自动阅读一屏所用的秒数,范围 1–120 秒。") + } + Section { + Button("目录", systemImage: "list.bullet") { + showsAutoReadControls = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { showsTableOfContents = true } + } + Button("开始自动阅读", systemImage: "play.fill") { + showsAutoReadControls = false + startAutoReading() + } + Button("主菜单", systemImage: "menubar.rectangle") { + showsAutoReadControls = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { showsReaderMenu = true } + } + Button("停止自动阅读", systemImage: "stop.fill", role: .destructive) { + isAutoReading = false + showsAutoReadControls = false + } + Button("设置", systemImage: "gearshape") { + showsAutoReadControls = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { showsStyleControls = true } + } + } + } + .navigationTitle("自动阅读") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("继续阅读") { showsAutoReadControls = false } + } + } + } + .readingPresentationDetents([.medium]) + } + + + private var styleControls: some View { + ReadingNavigationStack { + Form { + Section("快速排版") { + HStack(spacing: 12) { + Button { + fontSize = max(5, fontSize - 1) + } label: { + Image(systemName: "textformat.size.smaller") + .frame(width: 38, height: 32) + } + .buttonStyle(.bordered) + + VStack(spacing: 2) { + Text("字号") + .font(.caption) + .foregroundStyle(.secondary) + Text("\(Int(fontSize))") + .font(.headline.monospacedDigit()) + } + .frame(maxWidth: .infinity) + + Button { + fontSize = min(50, fontSize + 1) + } label: { + Image(systemName: "textformat.size.larger") + .frame(width: 38, height: 32) + } + .buttonStyle(.bordered) + } + + HStack(spacing: 8) { + Text("行距") + .font(.subheadline) + Spacer(minLength: 8) + readerChoiceChip("紧凑", selected: lineSpacing <= 7) { lineSpacing = 6 } + readerChoiceChip("标准", selected: lineSpacing > 7 && lineSpacing < 18) { lineSpacing = 12 } + readerChoiceChip("宽松", selected: lineSpacing >= 18) { lineSpacing = 22 } + } + Toggle("两端对齐", isOn: $fullJustify) + } + + Section("阅读背景") { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(ReaderStylePreset.legadoDefaults) { preset in + Button { + theme = preset.id + } label: { + VStack(spacing: 6) { + RoundedRectangle(cornerRadius: 7) + .fill(Color(uiColor: preset.background)) + .frame(width: 54, height: 72) + .overlay { + Text("文字") + .font(.caption) + .foregroundStyle(Color(uiColor: preset.foreground)) + } + .overlay { + RoundedRectangle(cornerRadius: 7) + .stroke(resolvedStyle.id == preset.id ? ReadingTheme.accent : Color.secondary, lineWidth: resolvedStyle.id == preset.id ? 3 : 1) + } + Text(preset.name).font(.caption2).foregroundStyle(.primary) + } + } + .buttonStyle(.plain) + } + } + } + ReadingPhotoPicker { data in + backgroundError = nil + do { + backgroundImage = try ReaderBackgroundStore.save(data) + useBackgroundImage = true + } catch { + backgroundError = error.localizedDescription + } + } onError: { error in + backgroundError = error.localizedDescription + } label: { + Label("选择背景图片", systemImage: "photo") + } + if backgroundImage != nil { + Toggle("使用背景图片", isOn: $useBackgroundImage) + Button("删除背景图片", systemImage: "trash", role: .destructive) { + ReaderBackgroundStore.remove() + backgroundImage = nil + useBackgroundImage = false + } + } + if let backgroundError { + Text(backgroundError).font(.footnote).foregroundStyle(.red) + } + } + + Section("文字") { + valueSlider("字号", value: $fontSize, range: 5...50, format: { "\(Int($0))" }) + valueSlider("字间距", value: $letterSpacing, range: -0.5...0.5, step: 0.01, format: { String(format: "%.2f", $0) }) + valueSlider("行间距", value: $lineSpacing, range: 0...50, format: { "\(Int($0))" }) + valueSlider("段距", value: $paragraphSpacing, range: 0...50, format: { String(format: "%.1f", $0) }) + Picker("字重", selection: $fontWeight) { + Text("正常").tag(0) + Text("粗体").tag(1) + Text("细体").tag(2) + } + TextField("段落缩进字符", text: $paragraphIndent) + .textInputAutocapitalization(.never) + Button(fontPath.isEmpty ? "选择字体文件" : "更换字体文件", systemImage: "textformat") { + showsFontImporter = true + } + if !fontPath.isEmpty { + HStack { + Text("当前字体") + Spacer() + Text(URL(fileURLWithPath: fontPath).lastPathComponent) + .foregroundStyle(.secondary) + } + Button("恢复默认字体", role: .destructive) { fontPath = "" } + } + if let fontError { + Text(fontError).font(.footnote).foregroundStyle(.red) + } + Picker("下划线", selection: $underlineMode) { + Text("关闭").tag(0) + Text("实线").tag(1) + Text("虚线").tag(2) + } + } + + Section("章节标题") { + Picker("标题模式", selection: $titleMode) { + Text("左对齐").tag(0) + Text("居中").tag(1) + Text("隐藏").tag(2) + } + .pickerStyle(.segmented) + valueSlider("标题字号增量", value: $titleSize, range: -10...30, format: { String(format: "%+.0f", $0) }) + valueSlider("标题上间距", value: $titleTopSpacing, range: 0...100, format: { "\(Int($0))" }) + valueSlider("标题下间距", value: $titleBottomSpacing, range: 0...100, format: { "\(Int($0))" }) + } + + Section("翻页动画") { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 10) { + ForEach(Self.pageAnimationChoices, id: \.0) { choice in + Button { + pageAnimation = choice.value + } label: { + VStack(spacing: 6) { + Image(systemName: choice.icon) + .font(.headline) + Text(choice.title) + .font(.caption2) + } + .foregroundStyle(pageAnimation == choice.value ? Color.white : Color.primary) + .frame(width: 58, height: 54) + .background( + pageAnimation == choice.value ? ReadingTheme.accent : Color.secondary.opacity(0.1), + in: RoundedRectangle(cornerRadius: 12, style: .continuous) + ) + } + .buttonStyle(.plain) + } + } + } + Text(pageAnimation == 3 + ? "滚动模式连续滚动;其余模式按整屏翻页。左右滑动或点击左右三分之一区域翻页,点击中间区域打开菜单。" + : "当前模式按整屏翻页;左右滑动或点击左右三分之一区域翻页,点击中间区域打开菜单。") + .font(.caption) + .foregroundStyle(.secondary) + } + + Section("正文边距") { + valueSlider("左边距", value: $paddingLeft, range: 0...100, format: { "\(Int($0))" }) + valueSlider("右边距", value: $paddingRight, range: 0...100, format: { "\(Int($0))" }) + valueSlider("上边距", value: $paddingTop, range: 0...100, format: { "\(Int($0))" }) + valueSlider("下边距", value: $paddingBottom, range: 0...100, format: { "\(Int($0))" }) + } + + Section("页眉页脚") { + Picker("页眉显示", selection: $headerMode) { + Text("状态栏显示时隐藏").tag(0) + Text("显示").tag(1) + Text("隐藏").tag(2) + } + Picker("页脚显示", selection: $footerMode) { + Text("显示").tag(0) + Text("隐藏").tag(1) + } + tipPicker("页眉左", selection: $tipHeaderLeft) + tipPicker("页眉中", selection: $tipHeaderMiddle) + tipPicker("页眉右", selection: $tipHeaderRight) + tipPicker("页脚左", selection: $tipFooterLeft) + tipPicker("页脚中", selection: $tipFooterMiddle) + tipPicker("页脚右", selection: $tipFooterRight) + Toggle("页眉分隔线", isOn: $showHeaderLine) + Toggle("页脚分隔线", isOn: $showFooterLine) + } + + Section("页眉边距") { + valueSlider("左边距", value: $headerPaddingLeft, range: 0...100, format: { "\(Int($0))" }) + valueSlider("右边距", value: $headerPaddingRight, range: 0...100, format: { "\(Int($0))" }) + valueSlider("上边距", value: $headerPaddingTop, range: 0...100, format: { "\(Int($0))" }) + valueSlider("下边距", value: $headerPaddingBottom, range: 0...100, format: { "\(Int($0))" }) + } + + Section("页脚边距") { + valueSlider("左边距", value: $footerPaddingLeft, range: 0...100, format: { "\(Int($0))" }) + valueSlider("右边距", value: $footerPaddingRight, range: 0...100, format: { "\(Int($0))" }) + valueSlider("上边距", value: $footerPaddingTop, range: 0...100, format: { "\(Int($0))" }) + valueSlider("下边距", value: $footerPaddingBottom, range: 0...100, format: { "\(Int($0))" }) + } + } + .navigationTitle("阅读样式") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("完成") { showsStyleControls = false } + } + } + } + .readingPresentationDetents([.medium]) + .readingPresentationDragIndicator() + } + + private static let pageAnimationChoices: [(value: Int, title: String, icon: String)] = [ + (0, "覆盖", "rectangle.on.rectangle"), + (1, "滑动", "rectangle.portrait.and.arrow.right"), + (2, "仿真", "book.pages"), + (3, "滚动", "arrow.up.and.down"), + (4, "无动画", "rectangle") + ] + + private func readerChoiceChip(_ title: String, selected: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + Text(title) + .font(.caption.weight(.medium)) + .foregroundStyle(selected ? Color.white : Color.primary) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(selected ? ReadingTheme.accent : Color.secondary.opacity(0.1), in: Capsule()) + } + .buttonStyle(.plain) + } + + private func valueSlider( + _ title: String, + value: Binding, + range: ClosedRange, + step: Double = 1, + format: @escaping (Double) -> String + ) -> some View { + VStack(spacing: 6) { + HStack { + Text(title) + Spacer() + Text(format(value.wrappedValue)).monospacedDigit().foregroundStyle(.secondary) + } + Slider(value: value, in: range, step: step) + } + } + + private static let readerTipChoices: [(Int, String)] = [ + (0, "无"), (7, "书名"), (1, "标题"), (2, "时间"), (3, "电量"), + (10, "电量%"), (4, "页数"), (5, "进度(%)"), (11, "进度(xx/yyy)"), + (6, "页数及进度"), (8, "时间及电量"), (9, "时间及电量%") + ] + + private func tipPicker(_ title: String, selection: Binding) -> some View { + Picker(title, selection: selection) { + ForEach(Self.readerTipChoices, id: \.0) { value, name in + Text(name).tag(value) + } + } + } + + + private var clickActions: [Int] { + // Keep persisted action IDs stable; the removed sync action becomes a no-op. + [clickActionTL, clickActionTC, clickActionTR, clickActionML, 0, clickActionMR, clickActionBL, clickActionBC, clickActionBR] + .map { $0 == 12 ? -1 : $0 } + } + + private static let tapActionChoices: [(Int, String)] = [ + (-1, "无动作"), (0, "菜单"), (1, "下一页"), (2, "上一页"), + (3, "下一章"), (4, "上一章"), (5, "朗读上一段"), (6, "朗读下一段"), + (7, "添加书签"), (8, "编辑正文"), (9, "替换(启用/禁用)"), (10, "目录"), + (11, "搜索正文"), (13, "暂停/继续朗读") + ] + + private var clickActionSettings: some View { + ReadingNavigationStack { + VStack(spacing: 8) { + ForEach(0..<3, id: \.self) { row in + HStack(spacing: 8) { + ForEach(0..<3, id: \.self) { column in + let index = row * 3 + column + VStack(spacing: 8) { + Text(["左上", "上中", "右上", "左中", "中央", "右中", "左下", "下中", "右下"][index]) + .font(.caption).foregroundStyle(.secondary) + if index == 4 { + Text("固定:菜单") + .font(.caption.weight(.medium)) + .foregroundStyle(ReadingTheme.accent) + .frame(maxWidth: .infinity, minHeight: 32) + } else { + Picker("动作", selection: clickActionBinding(at: index)) { + ForEach(Self.tapActionChoices, id: \.0) { action in + Text(action.1).tag(action.0) + } + } + .labelsHidden() + .frame(maxWidth: .infinity) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(6) + .background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10)) + } + } + } + } + .padding(10) + .navigationTitle("点击区域设置") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("完成") { showsClickActionSettings = false } + } + } + } + } + + private func clickActionBinding(at index: Int) -> Binding { + let binding: Binding = switch index { + case 0: $clickActionTL + case 1: $clickActionTC + case 2: $clickActionTR + case 3: $clickActionML + case 5: $clickActionMR + case 6: $clickActionBL + case 7: $clickActionBC + default: $clickActionBR + } + return Binding( + get: { binding.wrappedValue == 12 ? -1 : binding.wrappedValue }, + set: { binding.wrappedValue = $0 } + ) + } + + private func handleTapAction(_ action: Int) { + switch action { + case 0: showsReaderMenu.toggle() + case 3: if model.canGoNext { moveChapter(1) } + case 4: if model.canGoPrevious { moveChapter(-1) } + case 5: speech.previousSegment() + case 6: speech.nextSegment() + case 7: addBookmark() + case 8: + guard model.canEditContent else { return } + showsReaderMenu = false + editedContent = model.content + contentEditError = nil + showsContentEditor = true + case 9: + showsReaderMenu = false + let updatedBook = model.toggleReplaceRules() + Task { + await library.addOrUpdate(updatedBook) + await model.load(rules: model.usesReplaceRules ? replaceRules.rules : []) + } + case 10: + showsReaderMenu = false + showsTableOfContents = true + case 11: + showsReaderMenu = false + showsContentSearch = true + case 13: + isAutoReading = false + configureSpeech() + if let engine = selectedHTTPEngine { + speech.pauseOrResumeHTTP(engine: engine, text: model.content, continuesAcrossChapters: true) + } else { + speech.pauseOrResume(text: model.content, continuesAcrossChapters: true) + } + default: break + } + } + + + private var readerSettings: some View { + ReadingNavigationStack { + Form { + Section { + Toggle("两端对齐", isOn: $fullJustify) + Button("编辑本章正文", systemImage: "square.and.pencil") { + editedContent = model.content + contentEditError = nil + showsReaderSettings = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { showsContentEditor = true } + } + .disabled(!model.canEditContent || model.content.isEmpty) + Button("重新获取本章", systemImage: "arrow.clockwise") { + showsReaderSettings = false + refreshCurrentChapter() + } + .disabled(model.isLoading || !model.canEditContent) + } header: { + Text("正文") + } footer: { + if !model.canEditContent { + Text("本地 TXT 直接来自原文件;为避免破坏章节偏移,本章编辑与网络刷新仅对网络书籍开放。") + } + } + + Section("屏幕") { + Picker("屏幕方向", selection: $screenOrientation) { + Text("跟随系统").tag("0") + Text("竖向").tag("1") + Text("横向").tag("2") + Text("跟随传感器").tag("3") + Text("反向竖屏").tag("4") + Text("反向横屏").tag("5") + } + Toggle("阅读时不自动锁屏", isOn: $keepScreenOn) + if #available(iOS 16.0, *) { + Toggle("阅读时隐藏小横条", isOn: $hideHomeIndicator) + } + Toggle("隐藏状态栏", isOn: $hideStatusBar) + Toggle("显示亮度调节", isOn: $showBrightness) + Button("跟随系统亮度", systemImage: "sun.max") { + followsSystemBrightness = true + storedReaderBrightness = 0 + UIScreen.main.brightness = CGFloat(brightnessBeforeReading) + readerBrightness = brightnessBeforeReading + } + } + + Section("朗读") { + Picker("全局朗读引擎", selection: $appTTSEngine) { + Text("系统 TTS").tag("") + ForEach(httpTTSEngines) { engine in + Text(engine.name).tag(String(engine.id)) + } + } + Picker("本书朗读引擎", selection: bookTTSEngineBinding) { + Text("跟随全局").tag("__global__") + Text("系统 TTS").tag("") + ForEach(httpTTSEngines) { engine in + Text(engine.name).tag(String(engine.id)) + } + } + Picker("系统声音", selection: $systemTTSVoice) { + Text("系统默认中文声音").tag("") + ForEach(systemTTSVoices, id: \.identifier) { voice in + Text("\(voice.name)(\(voice.language))").tag(voice.identifier) + } + } + Toggle("跟随系统语速", isOn: $ttsFollowSystem) + Stepper("朗读语速:\(ttsSpeechRate)", value: $ttsSpeechRate, in: 0...45) + .disabled(ttsFollowSystem) + Toggle("按页朗读", isOn: $readAloudByPage) + Toggle("流式播放在线音频", isOn: $streamReadAloudAudio) + Toggle("朗读时保持亮屏", isOn: $readAloudWakeLock) + Toggle("忽略音频焦点", isOn: $ignoreAudioFocus) + Toggle("忽略焦点时仍在来电时暂停", isOn: $pauseReadAloudWhilePhoneCalls) + .disabled(!ignoreAudioFocus) + Toggle("媒体上一首/下一首切换章节", isOn: $mediaButtonPerNext) + Stepper("默认定时:\(ttsTimer) 分钟", value: $ttsTimer, in: 0...180) + if speechTimerRemaining > 0 { + Text("本次朗读还剩 \(speechTimerRemaining) 分钟") + .font(.footnote.monospacedDigit()) + .foregroundStyle(.secondary) + } + if let error = speech.errorMessage { + Text(error).font(.footnote).foregroundStyle(.red) + } + } + + Section("操作") { + Button("自动阅读设置", systemImage: "play.circle") { + showsReaderSettings = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { showsAutoReadControls = true } + } + Button("点击区域设置", systemImage: "square.grid.3x3") { + showsReaderSettings = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { showsClickActionSettings = true } + } + } + } + .navigationTitle("阅读设置") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("完成") { showsReaderSettings = false } + } + } + } + .readingPresentationDetents([.medium, .large]) + } + + + private var contentEditor: some View { + ReadingNavigationStack { + VStack(spacing: 0) { + TextEditor(text: $editedContent) + .font(.body.monospaced()) + .padding(8) + if let contentEditError { + Text(contentEditError) + .font(.footnote) + .foregroundStyle(.red) + .padding(.horizontal) + .padding(.bottom, 8) + } + } + .navigationTitle(model.title) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("取消") { showsContentEditor = false } + } + ToolbarItemGroup(placement: .topBarTrailing) { + Button("重置") { refreshEditorFromSource() } + Button("保存") { saveEditedContent() } + .disabled(editedContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + } + } + + private func refreshCurrentChapter() { + stopSpeechIfNeeded() + Task { + await model.load(rules: replaceRules.rules, forceRefresh: true, prefetchNextChapter: pageAnimation != 3) + readerPosition = min(readerPosition, model.content.utf16.count) + } + } + + private func refreshEditorFromSource() { + contentEditError = nil + Task { + await model.load(rules: replaceRules.rules, forceRefresh: true, prefetchNextChapter: pageAnimation != 3) + editedContent = model.content + if let error = model.diagnostics.first(where: { $0.severity == .error }) { + contentEditError = error.message + } + } + } + + private func saveEditedContent() { + contentEditError = nil + Task { + do { + try await model.saveEditedContent(editedContent) + readerPosition = min(readerPosition, editedContent.utf16.count) + showsContentEditor = false + } catch { + contentEditError = error.localizedDescription + } + } + } + + + private var tableOfContents: some View { + ReadingNavigationStack { + List { + Section { + ForEach(tocChapters) { chapter in + if chapter.isVolume { + HStack(spacing: 10) { + Image(systemName: "books.vertical.fill") + Text(chapter.title).font(.headline) + Spacer() + } + .foregroundStyle(ReadingTheme.accent) + .padding(.vertical, 4) + .listRowBackground(ReadingTheme.accent.opacity(0.07)) + } else { + let isCurrent = chapter.id == model.currentChapter?.id + Button { + guard let index = model.chapters.firstIndex(where: { $0.id == chapter.id }) else { return } + stopSpeechIfNeeded() + saveProgress(immediate: true) + readerJumpPosition = nil + model.select(index: index) + readerPosition = 0 + showsTableOfContents = false + } label: { + HStack(spacing: 12) { + Text(tocChapterNumber(chapter)) + .font(.caption.monospacedDigit()) + .foregroundStyle(isCurrent ? ReadingTheme.accent : .secondary) + .frame(width: 30, alignment: .trailing) + VStack(alignment: .leading, spacing: 4) { + Text(chapter.title) + .foregroundStyle(.primary) + .lineLimit(2) + if isCurrent { + HStack(spacing: 6) { + Text("正在阅读") + ProgressView(value: chapterProgress) + .tint(ReadingTheme.accent) + } + .font(.caption2) + .foregroundStyle(ReadingTheme.accent) + } + } + Spacer(minLength: 8) + if isCurrent { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(ReadingTheme.accent) + } + } + .padding(.vertical, 4) + } + .buttonStyle(.plain) + .listRowBackground(isCurrent ? ReadingTheme.accent.opacity(0.08) : Color.clear) + } + } + } header: { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(book.name).font(.headline).lineLimit(1) + Spacer() + Text("\(tocReadableChapterCount) 章") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + ProgressView(value: chapterProgress) + .tint(ReadingTheme.accent) + Text("第 \(max(model.currentIndex + 1, 1)) 章 · \(Int(chapterProgress * 100))%") + .font(.caption) + .foregroundStyle(.secondary) + } + .textCase(nil) + .padding(.vertical, 4) + } + } + .listStyle(.insetGrouped) + .navigationTitle("目录") + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $tocSearch, prompt: "搜索章节") + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("关闭") { showsTableOfContents = false } } + ToolbarItem(placement: .topBarTrailing) { + Button(tocDescending ? "正序" : "倒序", systemImage: "arrow.up.arrow.down") { + tocDescending.toggle() + } + } + } + } + .readingPresentationDetents([.medium, .large]) + .readingPresentationDragIndicator() + } + + private var tocReadableChapterCount: Int { + model.chapters.filter { !$0.isVolume }.count + } + + private func tocChapterNumber(_ chapter: BookChapter) -> String { + guard let index = model.chapters.firstIndex(where: { $0.id == chapter.id }) else { return "—" } + let number = model.chapters.prefix(index + 1).filter { !$0.isVolume }.count + return number > 0 ? "\(number)" : "—" + } + + + private var contentSearchSheet: some View { + ReadingNavigationStack { + Group { + if contentSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + ReadingUnavailableView("搜索正文", systemImage: "magnifyingglass", description: Text("输入本章正文关键字")) + } else if contentSearchResults.isEmpty { + ReadingUnavailableView.search(text: contentSearchQuery) + } else { + List(contentSearchResults) { paragraph in + Button { + readerPosition = paragraph.id + readerJumpPosition = nil + DispatchQueue.main.async { readerJumpPosition = paragraph.id } + showsContentSearch = false + } label: { + Text(paragraph.text) + .foregroundStyle(.primary) + .lineLimit(3) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } + .navigationTitle("正文搜索") + .navigationBarTitleDisplayMode(.inline) + .searchable(text: $contentSearchQuery, prompt: "搜索本章") + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("关闭") { showsContentSearch = false } + } + } + } + } + + private var contentSearchResults: [ReaderParagraph] { + let query = contentSearchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + guard !query.isEmpty else { return [] } + return model.paragraphs.filter { $0.text.localizedCaseInsensitiveContains(query) } + } + + private var tocChapters: [BookChapter] { + let query = tocSearch.trimmingCharacters(in: .whitespacesAndNewlines) + let filtered = query.isEmpty ? model.chapters : model.chapters.filter { + $0.title.localizedCaseInsensitiveContains(query) + } + return tocDescending ? Array(filtered.reversed()) : filtered + } + + + private var bookmarksSheet: some View { + ReadingNavigationStack { + Group { + let bookmarks = activity.bookmarks(for: book) + if bookmarks.isEmpty { + ReadingUnavailableView( + "本书还没有书签", + systemImage: "bookmark", + description: Text("阅读时点击顶部书签按钮即可记录当前位置。") + ) + } else { + List(bookmarks) { bookmark in + Button { + jump(to: bookmark) + } label: { + VStack(alignment: .leading, spacing: 6) { + Text(bookmark.chapterName).font(.headline).foregroundStyle(.primary) + if !bookmark.bookText.isEmpty { + Text(bookmark.bookText).font(.subheadline).foregroundStyle(.secondary).lineLimit(3) + } + Text("位置 \(bookmark.chapterPosition)").font(.caption.monospacedDigit()).foregroundStyle(.tertiary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .swipeActions { + Button("删除", role: .destructive) { Task { await activity.removeBookmark(bookmark) } } + } + } + } + } + .navigationTitle("书签") + .navigationBarTitleDisplayMode(.inline) + .toolbar { ToolbarItem(placement: .cancellationAction) { Button("关闭") { showsBookmarks = false } } } + } + } + + private func addBookmark() { + guard let chapter = model.currentChapter else { return } + bookmarkSaved = true + Task { + await activity.addBookmark( + book: book, + chapter: chapter, + position: readerPosition, + content: model.content + ) + try? await Task.sleep(nanoseconds: 1_200_000_000) + bookmarkSaved = false + } + } + + private func jump(to bookmark: Bookmark) { + guard let index = bookmark.resolvedChapterIndex(in: model.chapters) else { return } + stopSpeechIfNeeded() + saveProgress(immediate: true) + model.select(index: index, position: bookmark.chapterPosition) + readerPosition = bookmark.chapterPosition + readerJumpPosition = nil + showsBookmarks = false + } + + + private func saveProgress(immediate: Bool = false) { + guard let chapter = model.currentChapter else { return } + let position = readerPosition + let currentBook = book + progressSaveGeneration &+= 1 + progressSaveTask?.cancel() + progressSaveTask = Task { @MainActor [weak library] in + if !immediate { try? await Task.sleep(nanoseconds: 100_000_000) } + guard !Task.isCancelled, let library else { return } + await library.updateProgress(for: currentBook, chapter: chapter, position: position) + } + } + + + private func stopSpeechIfNeeded() { + speechStartGeneration &+= 1 + if speech.isSpeaking || speech.isPaused { speech.toggle(text: "") } + } + + private func restartSpeechAfterReaderLayoutChange() { + readerPageRanges = [] + guard speech.isSpeaking || speech.isPaused else { return } + speechStartGeneration &+= 1 + speech.stop() + configureSpeech() + startConfiguredSpeech() + } + + private func toggleSpeech() { + isAutoReading = false + configureSpeech() + if speech.isSpeaking || speech.isPaused { + speechStartGeneration &+= 1 + speech.stop() + speechTimerTask?.cancel() + speechTimerRemaining = 0 + return + } + scheduleSpeechTimer() + startConfiguredSpeech() + } + + private func startConfiguredSpeech() { + if readAloudByPage { + guard !readerPageRanges.isEmpty else { + guard !model.content.isEmpty else { return } + let generation = speechStartGeneration + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + guard generation == speechStartGeneration, + readAloudByPage, + !speech.isSpeaking, + !speech.isPaused else { return } + startConfiguredSpeech() + } + return + } + let pageIndex = readerPageRanges.firstIndex { readerPosition < NSMaxRange($0) } + ?? max(readerPageRanges.count - 1, 0) + speechPageIndex = pageIndex + speakPage(at: pageIndex, from: readerPosition) + return + } + let start = model.paragraphs.lastIndex(where: { $0.id <= readerPosition }) ?? 0 + let text = model.paragraphs.indices.contains(start) + ? model.paragraphs[start...].map(\.text).joined(separator: "\n") + : model.content + if let engine = selectedHTTPEngine { + speech.speakHTTP( + engine: engine, + text: text, + continuesAcrossChapters: true, + segmentIndexOffset: start + ) + prefetchNextChapterSpeech(engine: engine) + } else { + speech.speak(text, continuesAcrossChapters: true, segmentIndexOffset: start) + } + } + + private func speakPage(at index: Int, from position: Int? = nil) { + guard readerPageRanges.indices.contains(index) else { + continueSpeechToNextChapter() + return + } + let range = readerPageRanges[index] + let start = min(max(position ?? range.location, range.location), NSMaxRange(range)) + let pageRange = NSRange(location: start, length: max(NSMaxRange(range) - start, 0)) + guard pageRange.length > 0 else { + continueSpeechToNextPage() + return + } + let pageText = (model.content as NSString).substring(with: pageRange) + if let engine = selectedHTTPEngine { + speech.speakHTTP( + engine: engine, + text: pageText, + continuesAcrossChapters: true, + segmentIndexOffset: 0 + ) + } else { + speech.speak(pageText, continuesAcrossChapters: true, segmentIndexOffset: 0) + } + } + + private func continueSpeechToNextPage() { + guard readAloudByPage, readerPageRanges.indices.contains(speechPageIndex) else { + continueSpeechToNextChapter() + return + } + readerPosition = NSMaxRange(readerPageRanges[speechPageIndex]) + guard readerPageRanges.indices.contains(speechPageIndex + 1) else { + continueSpeechToNextChapter() + return + } + speechPageIndex += 1 + let nextPosition = readerPageRanges[speechPageIndex].location + readerPosition = nextPosition + readerJumpPosition = nextPosition + speakPage(at: speechPageIndex) + } + + private func prefetchNextChapterSpeech(engine: HttpTTS) { + Task { + guard let content = await model.nextSpeechContent( + rules: model.usesReplaceRules ? replaceRules.rules : [] + ), selectedHTTPEngine?.id == engine.id else { return } + speech.prefetchHTTP(engine: engine, text: content, limit: 10) + } + } + + private func continueSpeechToNextChapter() { + guard model.canGoNext else { + speechTimerTask?.cancel() + speechTimerRemaining = 0 + return + } + saveProgress(immediate: true) + resumesSpeechAfterChapterLoad = true + readerJumpPosition = nil + model.goNext() + readerPosition = model.requestedPosition + } + + private func moveSpeechToChapter(direction: Int) { + let canMove = direction < 0 ? model.canGoPrevious : model.canGoNext + guard canMove else { return } + speech.stop() + saveProgress(immediate: true) + resumesSpeechAfterChapterLoad = true + readerJumpPosition = nil + if direction < 0 { model.goPrevious() } else { model.goNext() } + readerPosition = model.requestedPosition + } + + private func scheduleSpeechTimer() { + speechTimerTask?.cancel() + speechTimerRemaining = ttsTimer + guard speechTimerRemaining > 0 else { return } + speechTimerTask = Task { @MainActor in + do { + while speechTimerRemaining > 0 { + try await Task.sleep(nanoseconds: 60 * 1_000_000_000) + if !speech.isSpeaking && !speech.isPaused && !resumesSpeechAfterChapterLoad { + speechTimerRemaining = 0 + break + } + if !speech.isPaused { speechTimerRemaining -= 1 } + if speechTimerRemaining == 0 { + speech.stop() + resumesSpeechAfterChapterLoad = false + } + } + } catch { } + } + } + + private func configureSpeech() { + speech.configure( + followsSystemRate: ttsFollowSystem, + speechRate: ttsSpeechRate, + readByPage: readAloudByPage, + streamsHTTPAudio: streamReadAloudAudio, + voiceIdentifier: systemTTSVoice, + ignoresAudioFocus: ignoreAudioFocus, + pausesForPhoneCalls: pauseReadAloudWhilePhoneCalls, + mediaButtonPerNext: mediaButtonPerNext, + bookName: model.book.name, + chapterTitle: model.title, + author: model.book.author + ) + } + + private static func globalPageAnimation() -> Int { + let defaults = UserDefaults.standard + if defaults.object(forKey: "pageAnim") == nil, + let legacy = defaults.object(forKey: "readerPageAnimation") as? NSNumber { + defaults.set(legacy.intValue, forKey: "pageAnim") + } + return defaults.integer(forKey: "pageAnim") + } + + private var selectedHTTPEngine: HttpTTS? { + let value = model.book.readConfig?.ttsEngine ?? appTTSEngine + guard let id = Int64(value) else { return nil } + return httpTTSEngines.first { $0.id == id } + } + + private var systemTTSVoices: [AVSpeechSynthesisVoice] { + AVSpeechSynthesisVoice.speechVoices() + .filter { $0.language.hasPrefix("zh") } + .sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + } + + private var bookTTSEngineBinding: Binding { + Binding( + get: { model.book.readConfig?.ttsEngine ?? "__global__" }, + set: { value in + let updated = model.setTTSEngine(value == "__global__" ? nil : value) + Task { await library.addOrUpdate(updated) } + } + ) + } + + private func loadHTTPEngines() async { + guard let database = try? LegadoDatabase(url: LegadoDatabase.defaultURL()) else { return } + let repository = SQLiteConfigurationRepository(database: database) + var values = (try? await repository.loadHttpTTS()) ?? [] + if values.isEmpty, + let url = Bundle.main.url(forResource: "httpTTS", withExtension: "json"), + let data = try? Data(contentsOf: url), + let defaults = try? JSONDecoder().decode([HttpTTS].self, from: data) { + values = defaults + try? await repository.saveHttpTTS(values) + } + httpTTSEngines = values.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + if !appTTSEngine.isEmpty, selectedHTTPEngine == nil { appTTSEngine = "" } + } +} + +#if DEBUG +private struct ReaderFailureTestLoader: ChapterContentLoading { + func loadContent(book: Book, chapter: BookChapter, source: BookSource, nextChapterURL: String?, maxPages: Int) async -> ChapterContentReport { + .init(diagnostics: [.init(severity: .error, code: "content.request", message: "测试书源正文请求失败", rule: chapter.url)]) + } +} +#endif diff --git a/App/Features/Reader/ReaderViewModel.swift b/App/Features/Reader/ReaderViewModel.swift new file mode 100644 index 0000000..6405c4d --- /dev/null +++ b/App/Features/Reader/ReaderViewModel.swift @@ -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 = [] + 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) { + 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 + } +} diff --git a/App/Features/Rules/Editor/QRCodeImportView.swift b/App/Features/Rules/Editor/QRCodeImportView.swift new file mode 100644 index 0000000..35e8d9e --- /dev/null +++ b/App/Features/Rules/Editor/QRCodeImportView.swift @@ -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() } } + } +} diff --git a/App/Features/Rules/Editor/SyntaxCodeEditor.swift b/App/Features/Rules/Editor/SyntaxCodeEditor.swift new file mode 100644 index 0000000..913a07b --- /dev/null +++ b/App/Features/Rules/Editor/SyntaxCodeEditor.swift @@ -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) + } +} diff --git a/App/Features/Rules/ReplaceRuleEditorView.swift b/App/Features/Rules/ReplaceRuleEditorView.swift new file mode 100644 index 0000000..3cc7355 --- /dev/null +++ b/App/Features/Rules/ReplaceRuleEditorView.swift @@ -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) -> Binding { + 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 + } + } +} diff --git a/App/Features/Rules/ReplaceRuleLibraryModel.swift b/App/Features/Rules/ReplaceRuleLibraryModel.swift new file mode 100644 index 0000000..ab19a63 --- /dev/null +++ b/App/Features/Rules/ReplaceRuleLibraryModel.swift @@ -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 + } + } +} diff --git a/App/Features/Rules/ReplaceRuleManagementView.swift b/App/Features/Rules/ReplaceRuleManagementView.swift new file mode 100644 index 0000000..bd9c169 --- /dev/null +++ b/App/Features/Rules/ReplaceRuleManagementView.swift @@ -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) + } + } + } + } +} diff --git a/App/Features/Rules/RuleSubscriptionView.swift b/App/Features/Rules/RuleSubscriptionView.swift new file mode 100644 index 0000000..343cb67 --- /dev/null +++ b/App/Features/Rules/RuleSubscriptionView.swift @@ -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() + 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) } + } + } + } +} diff --git a/App/Features/Rules/TxtTocRuleManagementView.swift b/App/Features/Rules/TxtTocRuleManagementView.swift new file mode 100644 index 0000000..98f3ece --- /dev/null +++ b/App/Features/Rules/TxtTocRuleManagementView.swift @@ -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)) + } +} diff --git a/App/Features/Search/SearchViewModel.swift b/App/Features/Search/SearchViewModel.swift new file mode 100644 index 0000000..aebe40e --- /dev/null +++ b/App/Features/Search/SearchViewModel.swift @@ -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? + 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() + private var sourceNames: [String: String] = [:] + private var accumulator = BookSearchAccumulator() + private var cachedIdentities = Set() + private var liveIdentities = Set() + 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() + 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() + 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")) } + } +} diff --git a/App/Features/Settings/AboutView.swift b/App/Features/Settings/AboutView.swift new file mode 100644 index 0000000..868cd46 --- /dev/null +++ b/App/Features/Settings/AboutView.swift @@ -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) } + } +} diff --git a/App/Features/Settings/AppDiagnostics.swift b/App/Features/Settings/AppDiagnostics.swift new file mode 100644 index 0000000..de3ac48 --- /dev/null +++ b/App/Features/Settings/AppDiagnostics.swift @@ -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))") + } + } + } +} diff --git a/App/Features/Settings/DiagnosticLogView.swift b/App/Features/Settings/DiagnosticLogView.swift new file mode 100644 index 0000000..be73d7b --- /dev/null +++ b/App/Features/Settings/DiagnosticLogView.swift @@ -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)) } +} diff --git a/App/Features/Settings/OtherSettingsView.swift b/App/Features/Settings/OtherSettingsView.swift new file mode 100644 index 0000000..3fd7a99 --- /dev/null +++ b/App/Features/Settings/OtherSettingsView.swift @@ -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 } + } +} diff --git a/App/Features/Sources/SourceDebugView.swift b/App/Features/Sources/SourceDebugView.swift new file mode 100644 index 0000000..689120e --- /dev/null +++ b/App/Features/Sources/SourceDebugView.swift @@ -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? + + 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() + + 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? + + 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" + } + } +} diff --git a/App/Features/Sources/SourceEditorView.swift b/App/Features/Sources/SourceEditorView.swift new file mode 100644 index 0000000..1a28f30 --- /dev/null +++ b/App/Features/Sources/SourceEditorView.swift @@ -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 { + 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 { + 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 { + 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 + } + } + } + } +} diff --git a/App/Features/Sources/SourceLibraryModel.swift b/App/Features/Sources/SourceLibraryModel.swift new file mode 100644 index 0000000..4214c96 --- /dev/null +++ b/App/Features/Sources/SourceLibraryModel.swift @@ -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) 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, 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, + 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) 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) 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) 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) + } +} diff --git a/App/Features/Sources/SourceLoginView.swift b/App/Features/Sources/SourceLoginView.swift new file mode 100644 index 0000000..97ce444 --- /dev/null +++ b/App/Features/Sources/SourceLoginView.swift @@ -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? + private var responseStatus = 200 + private var responseHeaders: [String: String] = [:] + private var completed = false + private var timeoutTask: Task? + + 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) { + 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) } + } + } +} diff --git a/App/Features/Sources/SourceManagementView.swift b/App/Features/Sources/SourceManagementView.swift new file mode 100644 index 0000000..6aca0bf --- /dev/null +++ b/App/Features/Sources/SourceManagementView.swift @@ -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() + @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 + @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 + @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 ? "已配置" : "未配置") + } + } +} diff --git a/App/Features/TTS/HttpTTSManagementView.swift b/App/Features/TTS/HttpTTSManagementView.swift new file mode 100644 index 0000000..b8ecab7 --- /dev/null +++ b/App/Features/TTS/HttpTTSManagementView.swift @@ -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) -> Binding { + Binding(get: { engine[keyPath: keyPath] ?? "" }, set: { engine[keyPath: keyPath] = $0.isEmpty ? nil : $0 }) + } + + private func optionalBool(_ keyPath: WritableKeyPath) -> Binding { + 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? + + 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(""), raw.hasSuffix("") { + 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 { + 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) + } +} diff --git a/App/Info.plist b/App/Info.plist new file mode 100644 index 0000000..a22ace5 --- /dev/null +++ b/App/Info.plist @@ -0,0 +1,83 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + 小浣熊阅读 + CFBundleDocumentTypes + + + CFBundleTypeName + Legado 书源 + CFBundleTypeRole + Editor + LSHandlerRank + Alternate + LSItemContentTypes + + public.json + public.plain-text + public.data + + + + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + 小浣熊阅读 + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 0.1.9 + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + legado + + + + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSRequiresIPhoneOS + + LSSupportsOpeningDocumentsInPlace + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSCameraUsageDescription + 用于扫描书源和净化规则的导入二维码。 + NSPhotoLibraryAddUsageDescription + 用于保存阅读页面中的图片到照片图库。 + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UILaunchScreen + + UIBackgroundModes + + audio + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/App/LegadoApp.swift b/App/LegadoApp.swift new file mode 100644 index 0000000..97dcf64 --- /dev/null +++ b/App/LegadoApp.swift @@ -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 + } +} diff --git a/App/Resources/Kanna-LICENSE.txt b/App/Resources/Kanna-LICENSE.txt new file mode 100644 index 0000000..30070be --- /dev/null +++ b/App/Resources/Kanna-LICENSE.txt @@ -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. \ No newline at end of file diff --git a/App/Resources/Legado-LICENSE.txt b/App/Resources/Legado-LICENSE.txt new file mode 100644 index 0000000..ef08d94 --- /dev/null +++ b/App/Resources/Legado-LICENSE.txt @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + 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. + + + Copyright (C) + + 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 . + +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 +. + + 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 +. diff --git a/App/Resources/SwiftSoup-LICENSE.txt b/App/Resources/SwiftSoup-LICENSE.txt new file mode 100644 index 0000000..a338f11 --- /dev/null +++ b/App/Resources/SwiftSoup-LICENSE.txt @@ -0,0 +1,22 @@ +The MIT License + +Copyright (c) 2009-2025 Jonathan Hedley +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. diff --git a/App/Resources/httpTTS.json b/App/Resources/httpTTS.json new file mode 100644 index 0000000..81cb488 --- /dev/null +++ b/App/Resources/httpTTS.json @@ -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);" + } +] \ No newline at end of file diff --git a/App/Resources/txtTocRule.json b/App/Resources/txtTocRule.json new file mode 100644 index 0000000..15fd300 --- /dev/null +++ b/App/Resources/txtTocRule.json @@ -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 + } +] diff --git a/App/RootTabView.swift b/App/RootTabView.swift new file mode 100644 index 0000000..0470493 --- /dev/null +++ b/App/RootTabView.swift @@ -0,0 +1,2103 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct RootTabView: View { + @EnvironmentObject private var sourceLibrary: SourceLibraryModel + private enum Tab: Hashable { case bookshelf, explore, statistics, my } + @State private var selection: Tab + @State private var showsDemoReader = false + @State private var didOpenDemoReader = false + @State private var sourceImportIsPresented = false + @State private var displayedSourceNotice: SourceLibraryModel.Notice? + @AppStorage("themeMode") private var themeMode = "0" + + init() { + let arguments = ProcessInfo.processInfo.arguments + let requested = arguments.firstIndex(of: "-uiDemoTab") + .flatMap { arguments.indices.contains($0 + 1) ? arguments[$0 + 1] : nil } + let initial: Tab = switch requested { + case "explore": .explore + case "statistics": .statistics + case "settings": .my + default: + switch UserDefaults.standard.string(forKey: "defaultHomePage") { + case "explore": .explore + case "statistics": .statistics + case "settings": .my + default: .bookshelf + } + } + _selection = State(initialValue: initial) + } + + var body: some View { + TabView(selection: $selection) { + ReadingNavigationStack { + Group { + if ProcessInfo.processInfo.arguments.contains("-uiDemoSourceSwitch") { + SourceSwitchView( + book: DemoContent.books[0], + sources: DemoContent.sources, + demoCandidates: DemoContent.sourceSwitchCandidates + ) { _, _ in } + } else if ProcessInfo.processInfo.arguments.contains("-uiDemoBookDetail") { + BookDetailView( + demoBook: DemoContent.books[0], + source: DemoContent.sources[0], + chapters: DemoContent.readerChapters + ) + } else if requestedDemoPage == "search" { + SearchView(initialKeyword: automationSearchKeyword) + } else if requestedDemoPage == "sources" { + SourceManagementView() + } else { + BookshelfView() + } + } + .readingNavigationDestination(isPresented: $showsDemoReader) { + ReaderView( + demoBook: DemoContent.books[0], + chapters: DemoContent.readerChapters, + initialIndex: 0, + content: DemoContent.readerText + ) + } + .task { + guard !didOpenDemoReader else { return } + didOpenDemoReader = true + showsDemoReader = ProcessInfo.processInfo.arguments.contains("-uiDemoReader") + } + } + .tabItem { Label("书架", systemImage: "book.fill") } + .tag(Tab.bookshelf) + + ReadingNavigationStack { ExploreView() } + .tabItem { Label("发现", systemImage: "safari") } + .tag(Tab.explore) + + ReadingNavigationStack { ReadingStatisticsView() } + .tabItem { Label("统计", systemImage: "chart.bar") } + .tag(Tab.statistics) + + ReadingNavigationStack { + if ProcessInfo.processInfo.arguments.contains("-uiDemoRules") { + ReplaceRuleManagementView() + } else if ProcessInfo.processInfo.arguments.contains("-uiDemoHistory") { + ReadingHistoryView() + } else if ProcessInfo.processInfo.arguments.contains("-uiDemoFiles") { + FileManagementView() + } else if ProcessInfo.processInfo.arguments.contains("-uiDemoOtherSettings") { + OtherSettingsView() + } else if ProcessInfo.processInfo.arguments.contains("-uiDemoAbout") { + AboutView() + } else if ProcessInfo.processInfo.arguments.contains("-uiDemoLogs") { + DiagnosticLogView() + } else { + SettingsView() + } + } + .tabItem { Label("我的", systemImage: "person.crop.circle") } + .tag(Tab.my) + } + .tint(ReadingTheme.accent) + .readingTabBarBackground() + .preferredColorScheme(themeMode == "2" ? .dark : (themeMode == "1" || themeMode == "3" ? .light : nil)) + .transaction { transaction in + if themeMode == "3" { transaction.animation = nil } + } + .sheet(item: $sourceLibrary.pendingImport, onDismiss: { + sourceImportIsPresented = false + displayedSourceNotice = sourceLibrary.notice + }) { pending in + SourceImportReviewView(pending: pending) + .environmentObject(sourceLibrary) + .onAppear { sourceImportIsPresented = true } + } + .onChange(of: sourceLibrary.notice?.id) { _ in + if !sourceImportIsPresented { displayedSourceNotice = sourceLibrary.notice } + } + .alert(item: $displayedSourceNotice) { notice in + Alert( + title: Text(notice.title), + message: Text(notice.detail), + dismissButton: .default(Text("好")) { sourceLibrary.notice = nil } + ) + } + } + + private var requestedDemoPage: String? { + let arguments = ProcessInfo.processInfo.arguments + guard let index = arguments.firstIndex(of: "-uiDemoTab"), arguments.indices.contains(index + 1) else { + return nil + } + return arguments[index + 1] + } + + private var automationSearchKeyword: String { + let arguments = ProcessInfo.processInfo.arguments + guard let index = arguments.firstIndex(of: "-uiAutomationSearch"), + arguments.indices.contains(index + 1) else { return "" } + return arguments[index + 1] + } +} + +private struct BookshelfView: View { + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @EnvironmentObject private var library: BookLibraryModel + @EnvironmentObject private var sources: SourceLibraryModel + @State private var showsLocalImporter = false + @State private var pendingLocalURLs: [URL] = [] + @State private var selectedGroupID = BookGroup.idAll + @State private var showsGroupManager = false + @AppStorage("bookshelfLayout") private var bookshelfLayout = "grid3" + + private var selectedGroup: BookGroup { + library.visibleGroups.first(where: { $0.id == selectedGroupID }) + ?? library.visibleGroups.first + ?? BookGroup(groupId: BookGroup.idAll, groupName: "全部") + } + + private var displayedBooks: [Book] { library.books(in: selectedGroup) } + + private var recentBook: Book? { + displayedBooks.filter(BookReadingPresentation.hasStarted) + .sorted { $0.durChapterTime > $1.durChapterTime } + .first + } + + var body: some View { + ScrollView { + VStack(spacing: 0) { + ReadingPageHeader(title: "书架", subtitle: "阅读,让时间有了形状") { + NavigationLink { SearchView() } label: { + ReadingToolbarIcon(systemName: "magnifyingglass") + } + .accessibilityLabel("搜索") + bookshelfMenu + } + if library.visibleGroups.count > 1 { + bookshelfGroups.padding(.bottom, 24) + } + LazyVStack(alignment: .leading, spacing: 0) { + if displayedBooks.isEmpty { + EmptyBookshelfHero { showsLocalImporter = true } + } else { + if let book = recentBook { + VStack(alignment: .leading, spacing: 12) { + Text("继续阅读") + .font(.subheadline.weight(.medium)) + .foregroundStyle(.secondary) + NavigationLink { + BookshelfBookDestination(book: book, source: sources.sources.first { $0.bookSourceUrl == book.origin }) + } label: { + ContinueReadingCard(book: book) + } + .buttonStyle(.plain) + .accessibilityLabel("继续阅读,\(book.name),\(BookReadingPresentation.position(book))") + .accessibilityIdentifier("bookshelf.continue") + } + .padding(.bottom, 30) + } + bookshelfSummary + if bookshelfLayout.hasPrefix("grid") { + LazyVGrid(columns: gridColumns, alignment: .leading, spacing: 24) { + ForEach(displayedBooks) { book in + BookshelfBookLink(book: book, compact: false, grid: true) + } + } + } else { + ForEach(displayedBooks) { book in + BookshelfBookLink(book: book, compact: bookshelfLayout == "compact", grid: false) + if book.id != displayedBooks.last?.id { + Rectangle().fill(ReadingTheme.separator).frame(height: 0.5) + .padding(.leading, bookshelfLayout == "compact" ? 54 : 66) + } + } + } + } + } + .padding(.horizontal, 24) + .padding(.bottom, 32) + } + } + .background(ReadingTheme.canvas) + .navigationBarHidden(true) + .fileImporter( + isPresented: $showsLocalImporter, + allowedContentTypes: [.text, .plainText, .data], + allowsMultipleSelection: true + ) { result in + switch result { + case .success(let urls): + pendingLocalURLs = urls.filter { $0.pathExtension.lowercased() == "txt" } + if pendingLocalURLs.isEmpty, !urls.isEmpty { + library.reportError(LocalBookError.unsupportedFormat) + } + case .failure(let error): library.reportError(error) + } + } + .sheet(isPresented: Binding( + get: { !pendingLocalURLs.isEmpty }, + set: { if !$0 { pendingLocalURLs = [] } } + )) { + LocalBookImportReviewView(urls: pendingLocalURLs) { + pendingLocalURLs = [] + } + .environmentObject(library) + } + .sheet(isPresented: $showsGroupManager) { + BookGroupManagerView() + .environmentObject(library) + } + .alert("本地书导入失败", isPresented: Binding( + get: { library.errorMessage != nil }, + set: { if !$0 { library.clearError() } } + )) { + Button("好") { library.clearError() } + } message: { + Text(library.errorMessage ?? "未知错误") + } + .alert("书架更新", isPresented: Binding( + get: { library.updateMessage != nil }, + set: { if !$0 { library.clearUpdateMessage() } } + )) { + Button("好") { library.clearUpdateMessage() } + } message: { + Text(library.updateMessage ?? "") + } + } + + private var bookshelfSummary: some View { + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 6) { + Text(selectedGroupID == BookGroup.idAll ? "全部书籍" : selectedGroup.groupName) + .font(.title3.bold()) + .accessibilityAddTraits(.isHeader) + Text("\(displayedBooks.count)本") + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + HStack(spacing: 0) { + Button { bookshelfLayout = "grid3" } label: { + Image(systemName: "square.grid.2x2.fill") + .foregroundStyle(bookshelfLayout.hasPrefix("grid") ? ReadingTheme.accent : .secondary) + .frame(width: 44, height: 44) + } + .accessibilityLabel("网格布局") + .accessibilityAddTraits(bookshelfLayout.hasPrefix("grid") ? .isSelected : []) + Button { bookshelfLayout = "list" } label: { + Image(systemName: "list.bullet") + .foregroundStyle(bookshelfLayout.hasPrefix("grid") ? .secondary : ReadingTheme.accent) + .frame(width: 44, height: 44) + } + .accessibilityLabel("列表布局") + .accessibilityAddTraits(bookshelfLayout.hasPrefix("grid") ? [] : .isSelected) + } + .font(.system(size: 18, weight: .medium)) + .buttonStyle(.plain) + } + .padding(.bottom, 20) + } + + @ViewBuilder + private var bookshelfMenu: some View { + if library.isCheckingUpdates { + ProgressView().controlSize(.small).frame(width: 44, height: 44) + } else { + Menu { + Button("更新目录", systemImage: "arrow.clockwise") { + Task { await library.checkForUpdates(sources: sources.sources) } + } + Button("本地书", systemImage: "plus") { showsLocalImporter = true } + Button("书架分组", systemImage: "rectangle.3.group") { showsGroupManager = true } + Menu("书架布局", systemImage: "square.grid.2x2") { + layoutButton("列表", value: "list") + layoutButton("紧凑列表", value: "compact") + ForEach(2...6, id: \.self) { count in + layoutButton("\(count) 列宫格", value: "grid\(count)") + } + } + NavigationLink { + FileManagementView() + } label: { + Label("文件管理", systemImage: "folder") + } + } label: { + ReadingToolbarIcon(systemName: "ellipsis") + } + .accessibilityLabel("更多") + } + } + + private var bookshelfGroups: some View { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 24) { + ForEach(library.visibleGroups) { group in + Button(group.groupName) { selectedGroupID = group.id } + .font(.subheadline.weight(selectedGroupID == group.id ? .semibold : .regular)) + .foregroundStyle(selectedGroupID == group.id ? Color.primary : .secondary) + .frame(minHeight: 44) + .overlay(alignment: .bottom) { + if selectedGroupID == group.id { + Rectangle().fill(ReadingTheme.accent).frame(height: 2) + } + } + .accessibilityAddTraits(selectedGroupID == group.id ? .isSelected : []) + } + } + .padding(.horizontal, 24) + } + .overlay(alignment: .bottom) { + Rectangle().fill(ReadingTheme.separator).frame(height: 0.5) + } + } + + private var gridColumns: [GridItem] { + let count = Int(bookshelfLayout.dropFirst(4)) ?? 3 + let maximum = dynamicTypeSize.isAccessibilitySize ? 2 : 6 + return Array(repeating: GridItem(.flexible(), spacing: 16, alignment: .top), count: max(2, min(count, maximum))) + } + + @ViewBuilder + private func layoutButton(_ title: String, value: String) -> some View { + Button { + bookshelfLayout = value + } label: { + if bookshelfLayout == value { Label(title, systemImage: "checkmark") } + else { Text(title) } + } + } +} + +private struct BookshelfBookLink: View { + @EnvironmentObject private var library: BookLibraryModel + @EnvironmentObject private var sources: SourceLibraryModel + let book: Book + let compact: Bool + let grid: Bool + @State private var showsGroupSelector = false + + private var source: BookSource? { sources.sources.first { $0.bookSourceUrl == book.origin } } + + var body: some View { + NavigationLink { + BookshelfBookDestination(book: book, source: source) + } label: { + if grid { BookshelfGridItem(book: book) } + else { BookshelfRow(book: book, compact: compact) } + } + .buttonStyle(.plain) + .contextMenu { + NavigationLink { + if let source { BookDetailView(book: book, source: source) } + else { LocalBookDetailView(book: book) } + } label: { + Label("书籍信息", systemImage: "info.circle") + } + Button("移至分组", systemImage: "rectangle.3.group") { showsGroupSelector = true } + Button("移出书架", systemImage: "trash", role: .destructive) { + Task { await library.remove(bookURL: book.bookUrl) } + } + } + .sheet(isPresented: $showsGroupSelector) { + BookGroupSelectView(book: book).environmentObject(library) + } + } +} + +private struct BookshelfGridItem: View { + let book: Book + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + GeometryReader { geometry in + BookCoverArtwork( + title: book.name, + author: book.author, + remoteURL: book.coverUrl, + width: geometry.size.width, + height: geometry.size.height + ) + } + .aspectRatio(0.70, contentMode: .fit) + .frame(maxWidth: 180) + .frame(maxWidth: .infinity) + .overlay(alignment: .topTrailing) { + if book.lastCheckCount > 0 { + Circle() + .fill(ReadingTheme.coral) + .frame(width: 7, height: 7) + .overlay { Circle().stroke(ReadingTheme.canvas, lineWidth: 2) } + .offset(x: 3, y: -3) + .accessibilityLabel("更新 \(book.lastCheckCount) 章") + } + } + Text(book.name) + .font(.caption.weight(.semibold)) + .lineLimit(2) + .padding(.top, 10) + Text(book.author.isEmpty ? "未知作者" : book.author) + .font(.caption2).foregroundStyle(.secondary).lineLimit(1) + .padding(.top, 3) + ReadingProgressLine(value: BookReadingPresentation.progress(book)) + .padding(.top, 12) + Text(BookReadingPresentation.progressLabel(book)) + .font(.caption2).foregroundStyle(.secondary).lineLimit(1) + .padding(.top, 7) + } + } +} + +private struct BookshelfBookDestination: View { + let book: Book + let source: BookSource? + + var body: some View { + if ProcessInfo.processInfo.arguments.contains("-uiDemo") { + ReaderView(demoBook: book, chapters: DemoContent.readerChapters, initialIndex: 0, content: DemoContent.readerText) + } else { + BookshelfReadView(book: book, source: source) + } + } +} + +private struct ContinueReadingCard: View { + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + let book: Book + + var body: some View { + HStack(alignment: .center, spacing: 18) { + VStack(alignment: .leading, spacing: 0) { + Text(book.name) + .font(.title3.bold()) + .foregroundStyle(ReadingTheme.ink) + .lineLimit(2) + Text(book.author.isEmpty ? "未知作者" : book.author) + .font(.subheadline).foregroundStyle(.secondary) + .padding(.top, 7) + Text(BookReadingPresentation.position(book)) + .font(.caption).foregroundStyle(.secondary) + .lineLimit(2) + .padding(.top, 20) + if book.totalChapterNum > 0 { + HStack(spacing: 10) { + ReadingProgressLine(value: BookReadingPresentation.progress(book)) + Text("\(Int((BookReadingPresentation.progress(book) * 100).rounded()))%") + .font(.caption2.monospacedDigit()) + .foregroundStyle(.secondary) + } + .padding(.top, 12) + } + HStack(spacing: 12) { + Text("继续阅读") + Image(systemName: "arrow.right") + } + .font(.caption.weight(.semibold)) + .foregroundStyle(ReadingTheme.onAccent) + .padding(.horizontal, 18) + .frame(minHeight: 40) + .background(ReadingTheme.accent, in: Capsule()) + .padding(.top, 20) + } + .frame(maxWidth: .infinity, alignment: .leading) + if !dynamicTypeSize.isAccessibilitySize { + BookCoverArtwork(title: book.name, author: book.author, remoteURL: book.coverUrl, width: 94, height: 140) + } + } + .padding(20) + .background(ReadingTheme.groupedCanvas, in: RoundedRectangle(cornerRadius: 20)) + } +} + +private struct BookGroupManagerView: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var library: BookLibraryModel + @State private var newName = "" + @State private var showsCreate = false + @State private var editingGroup: BookGroup? + @State private var editingName = "" + + private var customGroups: [BookGroup] { + library.groups.filter { $0.groupId > 0 }.sorted { $0.order < $1.order } + } + + var body: some View { + ReadingNavigationStack { + List { + Section { + HStack { + Text("全部") + Spacer() + Text("系统分组").font(.caption).foregroundStyle(.secondary) + } + } + Section("自定义分组") { + ForEach(customGroups) { group in + HStack { + Toggle(isOn: Binding( + get: { group.show }, + set: { visible in Task { await library.setGroupVisible(group, visible: visible) } } + )) { + Text(group.groupName) + } + Button { + editingGroup = group + editingName = group.groupName + } label: { + Image(systemName: "pencil") + } + .buttonStyle(.borderless) + } + .swipeActions { + Button("删除", role: .destructive) { + Task { await library.deleteGroup(group) } + } + } + } + .onMove { offsets, destination in + Task { await library.moveGroups(from: offsets, to: destination) } + } + } + } + .overlay { + if customGroups.isEmpty { + ReadingUnavailableView("没有自定义分组", systemImage: "rectangle.3.group", description: Text("点右上角加号新建分组。")) + } + } + .navigationTitle("书架分组") + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("关闭") { dismiss() } } + ToolbarItemGroup(placement: .topBarTrailing) { + EditButton() + Button("新建", systemImage: "plus") { newName = ""; showsCreate = true } + } + } + .alert("新建分组", isPresented: $showsCreate) { + TextField("分组名称", text: $newName) + Button("取消", role: .cancel) {} + Button("确定") { Task { await library.createGroup(name: newName) } } + } + .alert("编辑分组", isPresented: Binding( + get: { editingGroup != nil }, + set: { if !$0 { editingGroup = nil } } + )) { + TextField("分组名称", text: $editingName) + Button("取消", role: .cancel) { editingGroup = nil } + Button("确定") { + guard let group = editingGroup else { return } + Task { await library.renameGroup(group, name: editingName) } + editingGroup = nil + } + } + } + } +} + +private struct BookGroupSelectView: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var library: BookLibraryModel + let book: Book + @State private var selected: Set + + init(book: Book) { + self.book = book + _selected = State(initialValue: []) + } + + private var customGroups: [BookGroup] { + library.groups.filter { $0.groupId > 0 }.sorted { $0.order < $1.order } + } + + var body: some View { + ReadingNavigationStack { + List(customGroups) { group in + Button { + if !selected.insert(group.id).inserted { selected.remove(group.id) } + } label: { + HStack { + Text(group.groupName).foregroundStyle(.primary) + Spacer() + if selected.contains(group.id) { Image(systemName: "checkmark").foregroundStyle(ReadingTheme.accent) } + } + } + } + .overlay { + if customGroups.isEmpty { + ReadingUnavailableView("没有自定义分组", systemImage: "rectangle.3.group") + } + } + .navigationTitle("移至分组") + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { + Button("确定") { + Task { await library.setBook(book, groupIDs: selected); dismiss() } + } + } + } + .onAppear { + selected = Set(customGroups.filter { (book.group & $0.groupId) != 0 }.map(\.groupId)) + } + } + .interactiveDismissDisabled() + } +} + +private struct LocalBookImportReviewView: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var library: BookLibraryModel + let urls: [URL] + let onFinish: () -> Void + @State private var selectedPaths: Set + @State private var isImporting = false + + init(urls: [URL], onFinish: @escaping () -> Void) { + self.urls = urls + self.onFinish = onFinish + _selectedPaths = State(initialValue: Set(urls.map(\.path))) + } + + var body: some View { + ReadingNavigationStack { + List(urls, id: \.path) { url in + Button { + if !selectedPaths.insert(url.path).inserted { selectedPaths.remove(url.path) } + } label: { + HStack { + Image(systemName: selectedPaths.contains(url.path) ? "checkmark.square.fill" : "square") + .foregroundStyle(selectedPaths.contains(url.path) ? ReadingTheme.accent : .secondary) + VStack(alignment: .leading) { + Text(url.deletingPathExtension().lastPathComponent).foregroundStyle(.primary) + Text(url.lastPathComponent).font(.caption).foregroundStyle(.secondary) + } + } + } + } + .overlay { if isImporting { ProgressView("正在导入…") } } + .navigationTitle("导入本地书") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("取消") { finish() }.disabled(isImporting) + } + ToolbarItem(placement: .topBarTrailing) { + Button(selectedPaths.count == urls.count ? "取消全选" : "全选") { + if selectedPaths.count == urls.count { selectedPaths.removeAll() } + else { selectedPaths = Set(urls.map(\.path)) } + } + .disabled(isImporting) + } + ToolbarItem(placement: .confirmationAction) { + Button("导入") { + isImporting = true + let selected = urls.filter { selectedPaths.contains($0.path) } + Task { + await library.importLocalFiles(selected) + finish() + } + } + .disabled(selectedPaths.isEmpty || isImporting) + } + } + } + .interactiveDismissDisabled(isImporting) + } + + private func finish() { + onFinish() + dismiss() + } +} + +private struct EmptyBookshelfHero: View { + let onImport: () -> Void + + var body: some View { + VStack(spacing: 24) { + Image(systemName: "books.vertical") + .font(.system(size: 40, weight: .ultraLight)) + .foregroundStyle(ReadingTheme.accent) + .accessibilityHidden(true) + VStack(spacing: 10) { + Text("书架还是空的").font(.title3.weight(.semibold)) + Text("导入本地书,或添加书源后搜索书籍。") + .font(.subheadline).foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + VStack(spacing: 12) { + Button("导入本地书", action: onImport) + .buttonStyle(.borderedProminent) + .controlSize(.large) + NavigationLink("添加书源") { SourceManagementView() } + .font(.subheadline) + .frame(minHeight: 44) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 80) + } +} + +struct BookshelfReadView: View { + let book: Book + let source: BookSource? + @StateObject private var model = BookshelfReadViewModel() + + var body: some View { + Group { + if !model.chapters.isEmpty, let resolvedBook = model.resolvedBook { + if let source { + ReaderView( + book: resolvedBook, + chapters: model.chapters, + initialIndex: resolvedBook.durChapterIndex, + source: source + ) + } else { + ReaderView(book: resolvedBook, chapters: model.chapters, initialIndex: resolvedBook.durChapterIndex) + } + } else if model.isLoading { + ProgressView("正在打开…") + } else if let errorMessage = model.errorMessage { + ReadingUnavailableView("无法打开书籍", systemImage: "book.closed", description: Text(errorMessage)) + } else { + ProgressView("正在打开…") + } + } + .task { await model.load(book: book, source: source) } + } +} + +private struct BookshelfRow: View { + let book: Book + var compact = false + @ScaledMetric(relativeTo: .body) private var coverWidth = 52 + + private var readingPosition: String { + if let title = book.durChapterTitle, !title.isEmpty { return title } + if book.durChapterIndex > 0 || book.durChapterPos > 0 || book.durChapterTime > 0 { + return book.totalChapterNum > 0 + ? "第 \(book.durChapterIndex + 1) 章 / 共 \(book.totalChapterNum) 章" + : "已读至第 \(book.durChapterIndex + 1) 章" + } + return "尚未阅读" + } + + var body: some View { + HStack(alignment: .center, spacing: 14) { + BookCoverArtwork( + title: book.name, + author: book.author, + remoteURL: book.coverUrl, + width: compact ? 40 : coverWidth, + height: compact ? 56 : coverWidth * 1.42 + ) + VStack(alignment: .leading, spacing: 5) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(book.name) + .font(.body.weight(.medium)) + .foregroundStyle(.primary) + .lineLimit(2) + Spacer(minLength: 0) + if book.lastCheckCount > 0 { + Text("+\(book.lastCheckCount)") + .font(.caption.monospacedDigit()) + .foregroundStyle(ReadingTheme.accent) + .fixedSize() + .accessibilityLabel("更新 \(book.lastCheckCount) 章") + } + } + Text(book.author.isEmpty ? "未知作者" : book.author) + .font(.caption).foregroundStyle(.secondary).lineLimit(1) + if !compact { + Text(readingPosition) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.vertical, compact ? 10 : 12) + .contentShape(Rectangle()) + .accessibilityElement(children: .combine) + .accessibilityValue(book.latestChapterTitle.map { "最新章节:\($0)" } ?? "") + } +} + +struct SearchView: View { + @EnvironmentObject private var library: SourceLibraryModel + let fixedSource: BookSource? + private let initialKeyword: String + @State private var keyword: String + @StateObject private var model = SearchViewModel() + @State private var showsScope = false + @State private var selectedGroups = Set() + @State private var selectedSourceID: String? + @State private var didStartInitialSearch = false + @State private var searchIsPresented = false + @AppStorage("precisionSearch") private var precisionSearch = false + + init(fixedSource: BookSource? = nil, initialKeyword: String = "") { + self.fixedSource = fixedSource + self.initialKeyword = initialKeyword + _keyword = State(initialValue: initialKeyword) + } + + var body: some View { + ScrollView { + LazyVStack(spacing: 12) { + switch model.state { + case .idle: + idleContent + case .searching where model.books.isEmpty: + initialLoadingView + case .unavailable: + unavailableView + case .empty: + emptyResultsView + case .searching, .finished: + resultsContent + } + if model.state != .searching, !model.diagnostics.isEmpty { diagnosticsCard } + } + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 28) + } + .background(ReadingTheme.canvas) + .navigationTitle("搜索") + .readingSearchable(text: $keyword, isPresented: $searchIsPresented, prompt: "书名或作者") + .readingScrollDismissesKeyboard() + .onSubmit(of: [.text, .search]) { startSearch() } + .onDisappear { + if model.state == .searching || model.isLoadingMore { model.cancel() } + } + .onChange(of: keyword) { value in + if value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + model.cancel(reset: true) + } + } + .onChange(of: library.sources.count) { _ in + guard !didStartInitialSearch, + !initialKeyword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + !library.sources.isEmpty else { return } + didStartInitialSearch = true + startSearch() + } + .task { + await model.loadSearchKeywords() + if initialKeyword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + searchIsPresented = true + } else if !library.sources.isEmpty, !didStartInitialSearch { + didStartInitialSearch = true + startSearch() + } + } + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Toggle("精准搜索", isOn: $precisionSearch) + Button("分组或书源", systemImage: "scope") { showsScope = true } + NavigationLink { + SourceManagementView() + } label: { + Label("书源管理", systemImage: "network") + } + if model.state == .searching { + Button("停止搜索", systemImage: "stop.fill", role: .destructive) { + model.cancel() + } + } else if model.isPaused { + Button("继续搜索", systemImage: "play.fill") { model.resume() } + } + } label: { + Label("搜索选项", systemImage: "slider.horizontal.3") + } + } + } + .sheet(isPresented: $showsScope) { + SearchScopeSheet( + sources: library.sources.filter(\.enabled), + groups: library.groups, + selectedGroups: $selectedGroups, + selectedSourceID: $selectedSourceID + ) + } + } + + @ViewBuilder + private var idleContent: some View { + discoveryPrompt + if !model.searchKeywords.isEmpty { + searchHistoryView + } + } + + private var discoveryPrompt: some View { + Button { searchIsPresented = true } label: { + HStack(alignment: .top, spacing: 12) { + Image(systemName: "magnifyingglass") + .font(.body) + .foregroundStyle(ReadingTheme.accent) + VStack(alignment: .leading, spacing: 6) { + Text("搜索书名或作者") + .font(.headline.weight(.medium)) + .foregroundStyle(.primary) + Text("\(searchableSourceCount) 个书源 · \(scopeDisplay)") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + .padding(.vertical, 20) + } + .buttonStyle(.plain) + } + + private var initialLoadingView: some View { + VStack(spacing: 12) { + ReadingCard { + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 12) { + ProgressView().controlSize(.regular) + VStack(alignment: .leading, spacing: 3) { + Text("正在搜索“\(model.submittedKeyword)”").font(.headline) + Text(searchProgressText).font(.caption).foregroundStyle(.secondary) + } + Spacer() + Button("停止", role: .destructive) { model.cancel() } + .font(.subheadline.weight(.medium)) + } + ProgressView(value: searchProgress) + .tint(ReadingTheme.accent) + Text("结果会在书源返回后立即显示,无需等待全部完成。") + .font(.caption).foregroundStyle(.secondary) + } + } + ForEach(0..<2, id: \.self) { _ in SearchLoadingRow() } + } + } + + private var resultsContent: some View { + Group { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + Text("“\(model.submittedKeyword)”").font(.headline).lineLimit(1) + Text(model.isShowingCachedResults + ? (model.state == .searching ? "已显示缓存结果,正在更新书源" : "已显示缓存结果") + : "找到 \(model.books.count) 本,已自动合并重复来源") + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + if model.isPaused { + Button("继续", systemImage: "play.fill") { model.resume() } + .buttonStyle(.bordered) + .controlSize(.small) + } else if model.state == .searching { + StatusPill(text: "\(model.completedSourceCount)/\(model.totalSourceCount)", systemImage: "magnifyingglass") + } else { + StatusPill(text: scopeDisplay, systemImage: "scope") + } + } + if model.state == .searching { + ProgressView(value: searchProgress).tint(ReadingTheme.accent) + } + } + .padding(.vertical, 4) + + ForEach(model.books) { book in + if let source = library.sources.first(where: { $0.bookSourceUrl == book.origin }) { + NavigationLink { + BookDetailView(searchBook: book, source: source) + } label: { + SearchBookRow(book: book) + } + .buttonStyle(.plain) + .onAppear { loadMoreIfNeeded(book) } + } else { + SearchBookRow(book: book) + .onAppear { loadMoreIfNeeded(book) } + } + } + if model.isLoadingMore { + HStack(spacing: 10) { + ProgressView().controlSize(.small) + Text("正在查找更多结果…").font(.subheadline).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + } else if !model.hasMore, model.state == .finished { + Text("已经到底了") + .font(.caption).foregroundStyle(.tertiary) + .frame(maxWidth: .infinity).padding(.vertical, 8) + } + } + } + + private var emptyResultsView: some View { + SearchStateCard( + title: model.isPaused ? "搜索已停止" : "没有找到“\(model.submittedKeyword)”", + detail: emptyResultsDetail, + systemImage: model.isPaused ? "pause.circle" : "book.closed" + ) { + if model.isPaused { + Button("继续搜索", systemImage: "play.fill") { model.resume() } + .buttonStyle(.borderedProminent).tint(ReadingTheme.accent) + } + Button("修改关键词", systemImage: "text.cursor") { searchIsPresented = true } + .buttonStyle(.bordered) + } + } + + private var unavailableView: some View { + SearchStateCard( + title: "当前范围没有可搜索书源", + detail: "请启用带搜索规则的书源,或调整当前搜索范围。", + systemImage: "network.slash" + ) { + Button("调整范围", systemImage: "scope") { showsScope = true } + .buttonStyle(.borderedProminent).tint(ReadingTheme.accent) + NavigationLink { + SourceManagementView() + } label: { + Label("管理书源", systemImage: "network") + } + .buttonStyle(.bordered) + } + } + + private var searchProgress: Double { + guard model.totalSourceCount > 0 else { return 0 } + return Double(model.completedSourceCount) / Double(model.totalSourceCount) + } + + private var emptyResultsDetail: String { + if model.isPaused { return "你可以继续搜索剩余书源,或修改关键词重新搜索。" } + if precisionSearch { return "试试更短的书名、作者名,或关闭精准搜索。" } + return "试试更短的书名、作者名,或调整搜索范围。" + } + + private var searchProgressText: String { + model.completedSourceCount == 0 + ? "正在连接 \(model.totalSourceCount) 个书源" + : "已完成 \(model.completedSourceCount) / \(model.totalSourceCount) 个书源" + } + + private var searchableSourceCount: Int { + scopedSources.filter { $0.enabled && $0.searchUrl?.isEmpty == false }.count + } + + private func loadMoreIfNeeded(_ book: SearchBook) { + if model.state != .searching, book.id == model.books.last?.id { model.loadMore() } + } + + private var diagnosticsCard: some View { + DisclosureGroup("部分书源未返回结果(\(model.diagnostics.count))") { + VStack(alignment: .leading, spacing: 10) { + ForEach(model.diagnostics.prefix(20)) { diagnostic in + HStack(alignment: .top) { + Image(systemName: "exclamationmark.triangle.fill").foregroundStyle(.orange) + VStack(alignment: .leading, spacing: 2) { + Text(diagnostic.message).font(.subheadline) + Text(diagnostic.code).font(.caption.monospaced()).foregroundStyle(.secondary) + } + } + } + } + .padding(.top, 10) + } + .padding(16) + .background(.background, in: RoundedRectangle(cornerRadius: 18)) + } + + private var searchHistoryView: some View { + ReadingCard { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text("搜索历史").font(.headline) + Spacer() + Button("清空") { Task { await model.clearSearchKeywords() } }.font(.subheadline) + } + .padding(.bottom, 8) + ForEach(model.searchKeywords) { item in + Divider().padding(.leading, 28) + HStack(spacing: 10) { + Button { keyword = item.word; startSearch() } label: { + HStack(spacing: 10) { + Image(systemName: "clock.arrow.circlepath").foregroundStyle(.secondary) + Text(item.word).foregroundStyle(.primary).lineLimit(1) + Spacer() + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + Button { + Task { await model.deleteSearchKeyword(item.word) } + } label: { + Image(systemName: "xmark").font(.caption.bold()).foregroundStyle(.tertiary) + .frame(width: 28, height: 28) + } + .buttonStyle(.plain) + .accessibilityLabel("删除 \(item.word)") + } + .padding(.vertical, 7) + } + } + } + } + + private var scopedSources: [BookSource] { + if var fixedSource { fixedSource.enabled = true; return [fixedSource] } + let enabled = library.sources.filter(\.enabled) + if let selectedSourceID { return enabled.filter { $0.id == selectedSourceID } } + guard !selectedGroups.isEmpty else { return enabled } + return enabled.filter { source in selectedGroups.contains(where: source.hasGroup) } + } + + private var scopeDisplay: String { + if let fixedSource { return fixedSource.bookSourceName } + if let selectedSourceID, let source = library.sources.first(where: { $0.id == selectedSourceID }) { return source.bookSourceName } + return selectedGroups.isEmpty ? "全部书源" : selectedGroups.sorted().joined(separator: ",") + } + + private func startSearch() { + let value = keyword.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { return } + searchIsPresented = false + model.search(keyword: value, sources: scopedSources, precision: precisionSearch) + } +} + +private struct SearchLoadingRow: View { + var body: some View { + ReadingCard { + HStack(spacing: 15) { + RoundedRectangle(cornerRadius: 8).fill(.secondary.opacity(0.12)).frame(width: 58, height: 82) + VStack(alignment: .leading, spacing: 10) { + RoundedRectangle(cornerRadius: 4).fill(.secondary.opacity(0.14)).frame(width: 150, height: 14) + RoundedRectangle(cornerRadius: 4).fill(.secondary.opacity(0.1)).frame(width: 90, height: 11) + RoundedRectangle(cornerRadius: 4).fill(.secondary.opacity(0.08)).frame(maxWidth: .infinity).frame(height: 11) + } + } + } + .accessibilityHidden(true) + } +} + +private struct SearchStateCard: View { + let title: String + let detail: String + let systemImage: String + @ViewBuilder let actions: Actions + + var body: some View { + ReadingCard { + VStack(spacing: 14) { + Image(systemName: systemImage) + .font(.system(size: 32)) + .foregroundStyle(ReadingTheme.accent) + .frame(width: 64, height: 64) + .background(ReadingTheme.accentSoft.opacity(0.5), in: Circle()) + VStack(spacing: 5) { + Text(title).font(.headline).multilineTextAlignment(.center) + Text(detail).font(.subheadline).foregroundStyle(.secondary).multilineTextAlignment(.center) + } + HStack(spacing: 10) { actions } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + } + } +} + +private struct SearchBookRow: View { + let book: SearchBook + + var body: some View { + HStack(alignment: .top, spacing: 14) { + 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(.primary).lineLimit(2) + Text(book.author.isEmpty ? "未知作者" : book.author) + .font(.caption).foregroundStyle(.secondary) + if let latest = book.latestChapterTitle, !latest.isEmpty { + Text(latest) + .font(.caption).foregroundStyle(.secondary).lineLimit(1) + } + HStack(spacing: 6) { + Text(book.origins.count > 1 ? "\(book.origins.count) 个来源" : book.originName) + .lineLimit(1) + if let wordCount = book.wordCount, !wordCount.isEmpty { + Text("·") + Text(wordCount).lineLimit(1) + } + } + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + Image(systemName: "chevron.right").font(.caption.bold()).foregroundStyle(.tertiary) + } + .padding(.vertical, 12) + .overlay(alignment: .bottom) { + Rectangle().fill(ReadingTheme.separator).frame(height: 0.5) + .padding(.leading, 66) + } + .contentShape(Rectangle()) + .accessibilityElement(children: .combine) + } +} + +private struct SettingsView: View { + @Environment(\.colorScheme) private var colorScheme + @AppStorage("themeMode") private var themeMode = "0" + @ScaledMetric(relativeTo: .body) private var rowHeight = 52 + + var body: some View { + GeometryReader { geometry in + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + profileHeader(topInset: geometry.safeAreaInsets.top) + VStack(alignment: .leading, spacing: 24) { + appCard + readingSection + rulesSection + settingSection("文件与存储") { + settingLink("文件管理", icon: "folder", showsSeparator: false) { + FileManagementView() + } + } + supportSection + } + .padding(.horizontal, 28) + .padding(.bottom, 32) + } + .background(alignment: .top) { + Image("ProfileHeader") + .resizable() + .scaledToFill() + .frame(width: geometry.size.width, height: geometry.safeAreaInsets.top + 156) + .clipped() + .overlay { + if colorScheme == .dark { + Color.black.opacity(0.62) + } + } + .overlay(alignment: .bottom) { + LinearGradient( + colors: [ProfileTheme.canvas.opacity(0), ProfileTheme.canvas], + startPoint: .top, endPoint: .bottom + ) + .frame(height: 44) + } + .accessibilityHidden(true) + } + } + .background(ProfileTheme.canvas) + .ignoresSafeArea(.container, edges: .top) + .accessibilityIdentifier("profile.scroll") + } + .background(ProfileTheme.canvas) + .navigationBarHidden(true) + } + + private func profileHeader(topInset: CGFloat) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text("我的") + .font(.largeTitle.bold()) + .foregroundStyle(ProfileTheme.ink) + .accessibilityAddTraits(.isHeader) + Text("按照你的方式阅读") + .font(.subheadline) + .foregroundStyle(ProfileTheme.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 28) + .padding(.top, topInset + 42) + .padding(.bottom, 34) + } + + private var appCard: some View { + NavigationLink { + AboutView() + } label: { + HStack(spacing: 16) { + Image("BrandIcon") + .resizable() + .scaledToFit() + .frame(width: 58, height: 58) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 6) { + Text(AppIdentity.name) + .font(.headline) + .foregroundStyle(ProfileTheme.ink) + .fixedSize(horizontal: false, vertical: true) + Text("快速搜书,自由阅读") + .font(.footnote) + .foregroundStyle(ProfileTheme.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + Image(systemName: "chevron.right") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(ProfileTheme.ink.opacity(0.75)) + .frame(width: 26, height: 26) + .background(ProfileTheme.detail.opacity(0.10), in: Circle()) + .accessibilityHidden(true) + } + .padding(14) + .frame(maxWidth: .infinity, minHeight: 86, alignment: .leading) + .background { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(LinearGradient( + colors: [ProfileTheme.surface, ProfileTheme.appCardTint], + startPoint: .topLeading, endPoint: .bottomTrailing + )) + } + .overlay { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .strokeBorder(ProfileTheme.surface.opacity(0.9), lineWidth: 0.75) + } + .shadow(color: .black.opacity(0.035), radius: 18, x: 0, y: 8) + } + .buttonStyle(.plain) + .accessibilityElement(children: .combine) + .accessibilityHint("查看关于与版本信息") + .accessibilityIdentifier("profile.about") + } + + private var readingSection: some View { + settingSection("阅读") { + settingLink("阅读设置", icon: "textformat.size") { ReadingPreferencesView() } + Menu { + Picker("外观", selection: $themeMode) { + Text("跟随系统").tag("0") + Text("浅色").tag("1") + Text("深色").tag("2") + Text("E-Ink 模式").tag("3") + } + } label: { + settingRow("外观", icon: "circle.lefthalf.filled", value: appearanceTitle, showsValuePill: true) + } + .buttonStyle(.plain) + .accessibilityLabel("外观") + .accessibilityValue(appearanceTitle) + .accessibilityIdentifier("profile.appearance") + settingLink("在线朗读", icon: "waveform") { HttpTTSManagementView() } + settingLink("书签", icon: "bookmark") { AllBookmarksView() } + settingLink("阅读记录", icon: "clock", showsSeparator: false) { + ReadingHistoryView() + } + } + } + + private var rulesSection: some View { + settingSection("书源与规则") { + settingLink("书源管理", icon: "globe") { SourceManagementView() } + settingLink("规则订阅", icon: "link") { RuleSubscriptionView() } + settingLink("替换净化", icon: "text.badge.checkmark") { ReplaceRuleManagementView() } + settingLink("TXT 目录规则", icon: "list.bullet.rectangle", showsSeparator: false) { + TxtTocRuleManagementView() + } + } + } + + private var supportSection: some View { + settingSection("项目与支持") { + Link(destination: AppIdentity.authorURL) { + settingRow("开发者 Aminark", icon: "person.crop.circle", isExternal: true) + } + .buttonStyle(.plain) + Link(destination: AppIdentity.projectURL) { + settingRow("GitHub 项目", icon: "chevron.left.forwardslash.chevron.right", isExternal: true) + } + .buttonStyle(.plain) + settingLink("其他设置", icon: "gearshape") { OtherSettingsView() } + settingLink("关于", icon: "info.circle", showsSeparator: false) { AboutView() } + } + } + + private var appearanceTitle: String { + switch themeMode { + case "1": "浅色" + case "2": "深色" + case "3": "E-Ink 模式" + default: "跟随系统" + } + } + + private func settingSection(_ title: String, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 12) { + Text(title) + .font(.body) + .foregroundStyle(ProfileTheme.secondary) + .accessibilityAddTraits(.isHeader) + VStack(spacing: 0, content: content) + .background(ProfileTheme.surface, in: RoundedRectangle(cornerRadius: 18, style: .continuous)) + .shadow(color: .black.opacity(0.025), radius: 20, x: 0, y: 8) + } + } + + private func settingLink( + _ title: String, + icon: String, + showsSeparator: Bool = true, + @ViewBuilder destination: () -> Destination + ) -> some View { + NavigationLink(destination: destination) { + settingRow(title, icon: icon, showsSeparator: showsSeparator) + } + .buttonStyle(.plain) + .accessibilityIdentifier("profile.\(icon)") + } + + private func settingRow( + _ title: String, + icon: String, + value: String? = nil, + showsValuePill: Bool = false, + isExternal: Bool = false, + showsSeparator: Bool = true + ) -> some View { + HStack(spacing: 18) { + ProfileSettingIcon(systemName: icon) + .frame(width: 26, height: 24) + .accessibilityHidden(true) + Text(title) + .font(.body) + .foregroundStyle(ProfileTheme.ink) + .fixedSize(horizontal: false, vertical: true) + Spacer(minLength: 0) + if let value { + Text(value) + .font(.footnote) + .foregroundStyle(ProfileTheme.secondary) + .multilineTextAlignment(.trailing) + .fixedSize(horizontal: false, vertical: true) + .padding(.horizontal, showsValuePill ? 10 : 0) + .padding(.vertical, showsValuePill ? 4 : 0) + .overlay { + if showsValuePill { + Capsule().strokeBorder(ProfileTheme.separator, lineWidth: 0.5) + } + } + } + Image(systemName: isExternal ? "arrow.up.right" : "chevron.right") + .font(.caption.weight(.medium)) + .foregroundStyle(ProfileTheme.secondary.opacity(0.65)) + .accessibilityHidden(true) + } + .padding(.horizontal, 18) + .padding(.vertical, 12) + .frame(minHeight: rowHeight) + .contentShape(Rectangle()) + .overlay(alignment: .bottom) { + if showsSeparator { + Rectangle() + .fill(ProfileTheme.separator) + .frame(height: 0.5) + .padding(.leading, 62) + .padding(.trailing, 18) + .accessibilityHidden(true) + } + } + .accessibilityElement(children: .combine) + } +} + +private enum ProfileTheme { + static let canvas = adaptive(0xF8F8F7, dark: 0x141416) + static let surface = adaptive(0xFFFFFF, dark: 0x232326) + static let appCardTint = adaptive(0xFBF3EC, dark: 0x302923) + static let ink = adaptive(0x171817, dark: 0xF3F0EC) + static let secondary = adaptive(0x898A8C, dark: 0xA5A2A0) + static let detail = adaptive(0xBA916B, dark: 0xD1AB84) + static let separator = adaptive(0xEAEAEA, dark: 0x373638) + + 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 + ) + }) + } +} + +private struct ProfileSettingIcon: View { + let systemName: String + + var body: some View { + Group { + if systemName == "textformat.size" { + HStack(alignment: .firstTextBaseline, spacing: 0) { + Text("A").font(.system(size: 24, weight: .light)).foregroundStyle(ProfileTheme.ink) + Text("A").font(.system(size: 13, weight: .medium)).foregroundStyle(ProfileTheme.detail) + } + } else if ["circle.lefthalf.filled", "waveform", "bookmark", "clock", "globe"].contains(systemName) { + Canvas { context, size in + context.scaleBy(x: size.width / 24, y: size.height / 24) + draw(in: &context) + } + .frame(width: 22, height: 22) + } else { + Image(systemName: systemName) + .font(.system(size: 20, weight: .regular)) + .symbolRenderingMode(.palette) + .foregroundStyle(ProfileTheme.ink, ProfileTheme.detail) + } + } + } + + private func draw(in context: inout GraphicsContext) { + let stroke = StrokeStyle(lineWidth: 1.65, lineCap: .round, lineJoin: .round) + let circle = Path(ellipseIn: CGRect(x: 2.5, y: 2.5, width: 19, height: 19)) + switch systemName { + case "circle.lefthalf.filled": + var half = Path() + half.addArc(center: CGPoint(x: 12, y: 12), radius: 9.5, startAngle: .degrees(90), endAngle: .degrees(270), clockwise: false) + half.closeSubpath() + context.fill(half, with: .color(ProfileTheme.ink)) + context.stroke(circle, with: .color(ProfileTheme.ink), style: stroke) + var edge = Path() + edge.addArc(center: CGPoint(x: 12, y: 12), radius: 10.2, startAngle: .degrees(105), endAngle: .degrees(255), clockwise: false) + context.stroke(edge, with: .color(ProfileTheme.detail), style: StrokeStyle(lineWidth: 1, lineCap: .round)) + case "waveform": + for (index, height) in [4.0, 12.0, 21.0, 14.0, 5.0].enumerated() { + let bar = CGRect(x: 2 + Double(index) * 4.4, y: (24 - height) / 2, width: 2.4, height: height) + context.fill(Path(roundedRect: bar, cornerRadius: 1.2), with: .color(index > 2 ? ProfileTheme.detail : ProfileTheme.ink)) + } + case "bookmark": + var bookmark = Path() + bookmark.move(to: CGPoint(x: 5, y: 21)) + bookmark.addLine(to: CGPoint(x: 5, y: 4)) + bookmark.addQuadCurve(to: CGPoint(x: 7, y: 2), control: CGPoint(x: 5, y: 2)) + bookmark.addLine(to: CGPoint(x: 17, y: 2)) + bookmark.addQuadCurve(to: CGPoint(x: 19, y: 4), control: CGPoint(x: 19, y: 2)) + bookmark.addLine(to: CGPoint(x: 19, y: 21)) + bookmark.addLine(to: CGPoint(x: 12, y: 16)) + bookmark.closeSubpath() + context.stroke(bookmark, with: .color(ProfileTheme.detail), style: stroke) + case "clock": + context.stroke(circle, with: .color(ProfileTheme.ink), style: stroke) + var hands = Path() + hands.move(to: CGPoint(x: 12, y: 6)) + hands.addLine(to: CGPoint(x: 12, y: 12)) + hands.addLine(to: CGPoint(x: 16, y: 15)) + context.stroke(hands, with: .color(ProfileTheme.detail), style: stroke) + case "globe": + let meridian = Path(ellipseIn: CGRect(x: 7.5, y: 2.5, width: 9, height: 19)) + context.stroke(meridian, with: .color(ProfileTheme.detail), style: stroke) + var parallels = Path() + for y in [7.0, 17.0] { + parallels.move(to: CGPoint(x: 4, y: y)) + parallels.addQuadCurve(to: CGPoint(x: 20, y: y), control: CGPoint(x: 12, y: 12)) + } + context.stroke(parallels, with: .color(ProfileTheme.detail), style: stroke) + context.stroke(circle, with: .color(ProfileTheme.ink), style: stroke) + default: break + } + } +} + +private struct AllBookmarksView: View { + @EnvironmentObject private var activity: ReadingActivityModel + @EnvironmentObject private var library: BookLibraryModel + @EnvironmentObject private var sources: SourceLibraryModel + @State private var editingBookmark: Bookmark? + @State private var exportsJSON = false + @State private var exportsMarkdown = false + + private var sections: [BookmarkSection] { + var output: [BookmarkSection] = [] + for bookmark in activity.bookmarks { + let id = bookmark.bookName + "\u{0}" + bookmark.bookAuthor + if output.last?.id == id { + output[output.count - 1].bookmarks.append(bookmark) + } else { + output.append(.init(id: id, bookName: bookmark.bookName, author: bookmark.bookAuthor, bookmarks: [bookmark])) + } + } + return output + } + + var body: some View { + List { + if activity.bookmarks.isEmpty { + ReadingUnavailableView( + "暂无书签", + systemImage: "bookmark.slash", + description: Text("阅读时可从菜单或点击区域添加书签。") + ) + } else { + ForEach(sections) { section in + Section("\(section.bookName)(\(section.author))") { + ForEach(section.bookmarks) { bookmark in + bookmarkLink(bookmark) + .contextMenu { + Button("编辑书签", systemImage: "square.and.pencil") { editingBookmark = bookmark } + } + } + } + } + } + } + .navigationTitle("书签") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + if !activity.bookmarks.isEmpty { + Menu("导出", systemImage: "square.and.arrow.up") { + Button("导出 JSON") { exportsJSON = true } + Button("导出 Markdown") { exportsMarkdown = true } + } + } + } + } + .sheet(item: $editingBookmark) { bookmark in + BookmarkEditorView(bookmark: bookmark) + } + .fileExporter( + isPresented: $exportsJSON, + document: BookmarkExportDocument(data: jsonExportData), + contentType: .json, + defaultFilename: exportFilename(extension: "json") + ) { _ in } + .fileExporter( + isPresented: $exportsMarkdown, + document: BookmarkExportDocument(data: Data(markdownExport.utf8)), + contentType: .plainText, + defaultFilename: exportFilename(extension: "md") + ) { _ in } + } + + private func matchingBook(_ bookmark: Bookmark) -> Book? { + library.books.first { $0.name == bookmark.bookName && $0.author == bookmark.bookAuthor } + } + + @ViewBuilder private func bookmarkLink(_ bookmark: Bookmark) -> some View { + if let book = matchingBook(bookmark) { + NavigationLink { + BookmarkReaderDestination( + book: book, + bookmark: bookmark, + source: sources.sources.first { $0.bookSourceUrl == book.origin } + ) + } label: { bookmarkRow(bookmark) } + } else { + Button { editingBookmark = bookmark } label: { bookmarkRow(bookmark) } + } + } + + private func bookmarkRow(_ bookmark: Bookmark) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(bookmark.chapterName.isEmpty ? "第 \(bookmark.chapterIndex + 1) 章" : bookmark.chapterName) + .font(.headline).foregroundStyle(ReadingTheme.coral) + if !bookmark.bookText.isEmpty { + Text(bookmark.bookText).font(.footnote).foregroundStyle(.secondary).lineLimit(3) + } + if !bookmark.content.isEmpty { + Label(bookmark.content, systemImage: "note.text").font(.caption).lineLimit(2) + } + } + .padding(.vertical, 5) + } + + private var jsonExportData: Data { + (try? JSONEncoder().encode(activity.bookmarks)) ?? Data("[]".utf8) + } + + private var markdownExport: String { + var output = "" + var name = "" + var author = "" + for bookmark in activity.bookmarks { + if bookmark.bookName != name && bookmark.bookAuthor != author { + name = bookmark.bookName + author = bookmark.bookAuthor + output += "## \(bookmark.bookName) \(bookmark.bookAuthor)\n\n" + } + output += "#### \(bookmark.chapterName)\n\n" + output += "###### 原文\n \(bookmark.bookText)\n\n" + output += "###### 摘要\n \(bookmark.content)\n\n" + } + return output + } + + private func exportFilename(extension value: String) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyMMddHHmmss" + return "bookmark-\(formatter.string(from: Date())).\(value)" + } +} + +private struct BookmarkSection: Identifiable { + let id: String + let bookName: String + let author: String + var bookmarks: [Bookmark] +} + +private struct BookmarkExportDocument: FileDocument { + static var readableContentTypes: [UTType] { [.json, .plainText] } + 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 BookmarkEditorView: View { + @EnvironmentObject private var activity: ReadingActivityModel + @Environment(\.dismiss) private var dismiss + let bookmark: Bookmark + @State private var bookText: String + @State private var content: String + + init(bookmark: Bookmark) { + self.bookmark = bookmark + _bookText = State(initialValue: bookmark.bookText) + _content = State(initialValue: bookmark.content) + } + + var body: some View { + ReadingNavigationStack { + Form { + Section("原文") { TextEditor(text: $bookText).frame(minHeight: 150) } + Section("摘要") { TextEditor(text: $content).frame(minHeight: 120) } + Section { + Button("删除书签", role: .destructive) { + Task { await activity.removeBookmark(bookmark); dismiss() } + } + } + } + .navigationTitle(bookmark.chapterName.isEmpty ? "编辑书签" : bookmark.chapterName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { + Button("保存") { + var updated = bookmark + updated.bookText = bookText + updated.content = content + Task { await activity.updateBookmark(updated); dismiss() } + } + } + } + } + } +} + +private struct BookmarkReaderDestination: View { + let book: Book + let bookmark: Bookmark + let source: BookSource? + @State private var chapters: [BookChapter] = [] + @State private var preparedBook: Book? + @State private var isLoading = true + @State private var errorMessage: String? + + var body: some View { + Group { + if let preparedBook, !chapters.isEmpty { + if book.origin == "local" { + ReaderView(book: preparedBook, chapters: chapters, initialIndex: preparedBook.durChapterIndex) + } else if let source { + ReaderView(book: preparedBook, chapters: chapters, initialIndex: preparedBook.durChapterIndex, source: source) + } else { + ReadingUnavailableView("书源不可用", systemImage: "network.slash", description: Text("请先恢复或启用这本书对应的书源。")) + } + } else if isLoading { + ProgressView("正在恢复书签位置…") + } else { + ReadingUnavailableView( + "无法打开书签", + systemImage: "bookmark.slash", + description: Text(errorMessage ?? "当前目录中找不到书签对应章节。") + ) + } + } + .navigationTitle(bookmark.chapterName.isEmpty ? book.name : bookmark.chapterName) + .navigationBarTitleDisplayMode(.inline) + .task { await load() } + } + + private func load() async { + guard isLoading, chapters.isEmpty else { return } + let loaded: [BookChapter] + if book.origin == "local" { + let targetBook = book + do { + loaded = try await Task.detached { + try LocalBookService.live().loadChapters(book: targetBook).chapters + }.value + } catch { + errorMessage = error.localizedDescription + isLoading = false + return + } + } else { + guard let source else { + errorMessage = "书源已删除或未启用。" + isLoading = false + return + } + let coordinator = Self.chapterCoordinator() + let cached = await coordinator.storedChapters(bookURL: book.bookUrl) + if cached.isEmpty { + let report = await coordinator.refresh(book: book, source: source) + loaded = report.chapters + if loaded.isEmpty { errorMessage = report.diagnostics.first?.message ?? "目录加载失败。" } + } else { + loaded = cached + } + } + guard let target = targetIndex(in: loaded) else { + isLoading = false + return + } + var restored = book + restored.durChapterIndex = target + restored.durChapterPos = max(bookmark.chapterPosition, 0) + restored.durChapterTitle = loaded[target].title + chapters = loaded + preparedBook = restored + isLoading = false + } + + private func targetIndex(in chapters: [BookChapter]) -> Int? { + bookmark.resolvedChapterIndex(in: chapters) ?? chapters.firstIndex(where: { !$0.isVolume }) + } + + private static func chapterCoordinator() -> BookUpdateCoordinator { + BookUpdateCoordinator(repository: AppBookChapterRepository.live()) + } +} + +private struct SearchScopeSheet: View { + @Environment(\.dismiss) private var dismiss + let sources: [BookSource] + let groups: [String] + @Binding var selectedGroups: Set + @Binding var selectedSourceID: String? + @State private var mode = 0 + @State private var sourceFilter = "" + @State private var filterIsPresented = false + @State private var workingGroups: Set + @State private var workingSourceID: String? + + init( + sources: [BookSource], + groups: [String], + selectedGroups: Binding>, + selectedSourceID: Binding + ) { + self.sources = sources + self.groups = groups + _selectedGroups = selectedGroups + _selectedSourceID = selectedSourceID + _workingGroups = State(initialValue: selectedGroups.wrappedValue) + _workingSourceID = State(initialValue: selectedSourceID.wrappedValue) + _mode = State(initialValue: selectedSourceID.wrappedValue == nil ? 0 : 1) + } + + var body: some View { + ReadingNavigationStack { + List { + Picker("范围类型", selection: $mode) { + Text("分组").tag(0) + Text("书源").tag(1) + } + .pickerStyle(.segmented) + + Button("全部书源") { + selectedGroups.removeAll() + selectedSourceID = nil + dismiss() + } + + if mode == 0 { + ForEach(groups, id: \.self) { group in + Button { + workingSourceID = nil + if !workingGroups.insert(group).inserted { workingGroups.remove(group) } + } label: { + HStack { + Text(group).foregroundStyle(.primary) + Spacer() + if workingGroups.contains(group) { Image(systemName: "checkmark") } + } + } + } + } else { + ForEach(filteredSources) { source in + Button { + workingGroups.removeAll() + workingSourceID = source.id + filterIsPresented = false + } label: { + HStack { + VStack(alignment: .leading) { + Text(source.bookSourceName).foregroundStyle(.primary) + Text(source.bookSourceUrl).font(.caption).foregroundStyle(.secondary) + } + Spacer() + if workingSourceID == source.id { Image(systemName: "checkmark") } + } + } + } + } + } + .navigationTitle("分组或书源") + .navigationBarTitleDisplayMode(.inline) + .readingSearchable(text: $sourceFilter, isPresented: $filterIsPresented, prompt: "筛选书源") + .toolbar { + ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } } + ToolbarItem(placement: .confirmationAction) { + Button("确定") { + selectedGroups = workingGroups + selectedSourceID = workingSourceID + dismiss() + } + } + } + } + } + + private var filteredSources: [BookSource] { + guard !sourceFilter.isEmpty else { return sources } + return sources.filter { + $0.bookSourceName.localizedCaseInsensitiveContains(sourceFilter) + || $0.bookSourceUrl.localizedCaseInsensitiveContains(sourceFilter) + } + } +} + +private struct ReadingPreferencesView: View { + @AppStorage("textSize") private var fontSize = 20.0 + @AppStorage("letterSpacing") private var letterSpacing = 0.1 + @AppStorage("lineSpacingExtra") private var lineSpacing = 12.0 + @AppStorage("paragraphSpacing") private var paragraphSpacing = 2.0 + @AppStorage("paragraphIndent") private var paragraphIndent = "  " + @AppStorage("paddingLeft") private var paddingLeft = 16.0 + @AppStorage("paddingRight") private var paddingRight = 16.0 + @AppStorage("paddingTop") private var paddingTop = 6.0 + @AppStorage("paddingBottom") private var paddingBottom = 6.0 + @AppStorage("textBold") private var fontWeight = 0 + @AppStorage("titleMode") private var titleMode = 0 + @AppStorage("titleSize") private var titleSize = 0.0 + @AppStorage("titleTopSpacing") private var titleTopSpacing = 0.0 + @AppStorage("titleBottomSpacing") private var titleBottomSpacing = 0.0 + @AppStorage("underlineMode") private var underlineMode = 0 + @AppStorage("readerTheme") private var theme = "wechat" + @AppStorage("pageAnim") private var pageAnimation = 0 + @AppStorage("readerKeepScreenOn") private var keepScreenOn = false + @AppStorage("readerHideHomeIndicator") private var hideHomeIndicator = false + + var body: some View { + Form { + Section("排版") { + ReadingLabeledContent("字号", value: String(format: "%.0f", fontSize)) + Slider(value: $fontSize, in: 5...50, step: 1) + ReadingLabeledContent("字间距", value: String(format: "%.2f", letterSpacing)) + Slider(value: $letterSpacing, in: -0.5...0.5, step: 0.01) + ReadingLabeledContent("行间距", value: String(format: "%.0f", lineSpacing)) + Slider(value: $lineSpacing, in: 0...50, step: 1) + ReadingLabeledContent("段距", value: String(format: "%.1f", paragraphSpacing)) + Slider(value: $paragraphSpacing, in: 0...50, step: 1) + Picker("字重", selection: $fontWeight) { + Text("正常").tag(0) + Text("粗体").tag(1) + Text("细体").tag(2) + } + TextField("段落缩进字符", text: $paragraphIndent) + Picker("下划线", selection: $underlineMode) { + Text("关闭").tag(0) + Text("实线").tag(1) + Text("虚线").tag(2) + } + } + Section("正文边距") { + paddingSlider("左边距", value: $paddingLeft) + paddingSlider("右边距", value: $paddingRight) + paddingSlider("上边距", value: $paddingTop) + paddingSlider("下边距", value: $paddingBottom) + } + Section("章节标题") { + Picker("标题模式", selection: $titleMode) { + Text("左对齐").tag(0) + Text("居中").tag(1) + Text("隐藏").tag(2) + } + ReadingLabeledContent("字号增量", value: String(format: "%+.0f", titleSize)) + Slider(value: $titleSize, in: -10...30, step: 1) + ReadingLabeledContent("上间距", value: String(Int(titleTopSpacing))) + Slider(value: $titleTopSpacing, in: 0...100, step: 1) + ReadingLabeledContent("下间距", value: String(Int(titleBottomSpacing))) + Slider(value: $titleBottomSpacing, in: 0...100, step: 1) + } + Section("翻页") { + Picker("翻页模式", selection: $pageAnimation) { + Text("覆盖").tag(0) + Text("滑动").tag(1) + Text("仿真").tag(2) + Text("滚动").tag(3) + Text("无动画").tag(4) + } + } + Section("屏幕") { + Toggle("阅读时不自动锁屏", isOn: $keepScreenOn) + Toggle("阅读时隐藏小横条", isOn: $hideHomeIndicator) + } + Section("阅读背景") { + ForEach(ReaderStylePreset.legadoDefaults) { preset in + Button { + theme = preset.id + } label: { + HStack { + RoundedRectangle(cornerRadius: 5) + .fill(Color(uiColor: preset.background)) + .frame(width: 38, height: 28) + .overlay { + RoundedRectangle(cornerRadius: 5).stroke(Color.secondary.opacity(0.5)) + } + Text(preset.name).foregroundStyle(.primary) + Spacer() + if ReaderStylePreset.resolve(theme).id == preset.id { + Image(systemName: "checkmark").foregroundStyle(ReadingTheme.accent) + } + } + } + } + } + Section("预览") { + Text("雨落在旧城的瓦檐上,故事从一束灯光开始。") + .tracking(letterSpacing * fontSize) + .font(.system(size: fontSize, design: .serif)) + .lineSpacing(lineSpacing) + .foregroundStyle(Color(uiColor: ReaderStylePreset.resolve(theme).foreground)) + .padding(.vertical, 12) + .listRowBackground(Color(uiColor: ReaderStylePreset.resolve(theme).background)) + } + } + .navigationTitle("阅读设置") + } + + private func paddingSlider(_ title: String, value: Binding) -> some View { + VStack { + ReadingLabeledContent(title, value: String(Int(value.wrappedValue))) + Slider(value: value, in: 0...100, step: 1) + } + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..09aa555 --- /dev/null +++ b/CHANGELOG.md @@ -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 版本。首个公开版本发布时补充日期、对应提交、安装方式、测试设备和已知问题。 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..16dc03d --- /dev/null +++ b/CONTRIBUTING.md @@ -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)。 + +## 协作约定 + +尊重不同设备、系统与网络条件下的反馈。讨论具体问题并提供复现依据,避免人身攻击、骚扰、刷屏或在无关项目中推广。本项目不接受绕过付费、验证码、安全控制或访问授权的贡献。 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ef08d94 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + 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. + + + Copyright (C) + + 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 . + +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 +. + + 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 +. diff --git a/Legado.xcodeproj/project.pbxproj b/Legado.xcodeproj/project.pbxproj new file mode 100644 index 0000000..852a82c --- /dev/null +++ b/Legado.xcodeproj/project.pbxproj @@ -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 = ""; }; + A20000000000000000000001 /* LegadoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegadoApp.swift; sourceTree = ""; }; + A20000000000000000000002 /* RootTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootTabView.swift; sourceTree = ""; }; + A20000000000000000000003 /* SourceLibraryModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceLibraryModel.swift; sourceTree = ""; }; + A20000000000000000000004 /* SourceManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceManagementView.swift; sourceTree = ""; }; + A20000000000000000000005 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A20000000000000000000006 /* SearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModel.swift; sourceTree = ""; }; + A20000000000000000000007 /* BookLibraryModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookLibraryModel.swift; sourceTree = ""; }; + A20000000000000000000008 /* BookDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailView.swift; sourceTree = ""; }; + A20000000000000000000009 /* ReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderView.swift; sourceTree = ""; }; + A20000000000000000000028 /* ReaderAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderAppearance.swift; sourceTree = ""; }; + A20000000000000000000029 /* ReaderSpeechController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderSpeechController.swift; sourceTree = ""; }; + A2000000000000000000002A /* ReaderTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderTextView.swift; sourceTree = ""; }; + A2000000000000000000002B /* ReaderSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderSupport.swift; sourceTree = ""; }; + A2000000000000000000002C /* ReaderViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReaderViewModel.swift; sourceTree = ""; }; + A2000000000000000000002D /* ReadingStatisticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingStatisticsView.swift; sourceTree = ""; }; + CC1500000000000000000002 /* Compatibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Compatibility.swift; sourceTree = ""; }; + A2000000000000000000000A /* DesignSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DesignSystem.swift; sourceTree = ""; }; + A2000000000000000000000B /* DemoContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DemoContent.swift; sourceTree = ""; }; + A2000000000000000000000C /* ExploreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreView.swift; sourceTree = ""; }; + A2000000000000000000000D /* SourceDebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceDebugView.swift; sourceTree = ""; }; + A2000000000000000000000E /* SourceLoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceLoginView.swift; sourceTree = ""; }; + A2000000000000000000000F /* LocalBookDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalBookDetailView.swift; sourceTree = ""; }; + A20000000000000000000010 /* ReplaceRuleLibraryModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplaceRuleLibraryModel.swift; sourceTree = ""; }; + A20000000000000000000011 /* ReplaceRuleManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplaceRuleManagementView.swift; sourceTree = ""; }; + A20000000000000000000012 /* ReadingActivityModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingActivityModel.swift; sourceTree = ""; }; + A20000000000000000000013 /* ReadingHistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingHistoryView.swift; sourceTree = ""; }; + A20000000000000000000014 /* CacheManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CacheManagementView.swift; sourceTree = ""; }; + A20000000000000000000015 /* SourceSwitchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SourceSwitchView.swift; sourceTree = ""; }; + A20000000000000000000016 /* RSSLibraryModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSLibraryModel.swift; sourceTree = ""; }; + A20000000000000000000017 /* RSSViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSViews.swift; sourceTree = ""; }; + A20000000000000000000018 /* RSSSourceEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSSourceEditorView.swift; sourceTree = ""; }; + A20000000000000000000019 /* RSSWebReaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSWebReaderView.swift; sourceTree = ""; }; + A20000000000000000000020 /* RSSSourceDebugView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSSourceDebugView.swift; sourceTree = ""; }; + A20000000000000000000021 /* RSSAddBookView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSAddBookView.swift; sourceTree = ""; }; + A20000000000000000000022 /* RuleSubscriptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuleSubscriptionView.swift; sourceTree = ""; }; + A20000000000000000000023 /* TxtTocRuleManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TxtTocRuleManagementView.swift; sourceTree = ""; }; + A20000000000000000000024 /* txtTocRule.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = txtTocRule.json; path = App/Resources/txtTocRule.json; sourceTree = ""; }; + A20000000000000000000025 /* httpTTS.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = httpTTS.json; path = App/Resources/httpTTS.json; sourceTree = ""; }; + A20000000000000000000026 /* HttpTTSManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HttpTTSManagementView.swift; sourceTree = ""; }; + A20000000000000000000027 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + C20000000000000000000001 /* BookSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSource.swift; sourceTree = ""; }; + C20000000000000000000002 /* BookSourceRules.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSourceRules.swift; sourceTree = ""; }; + C20000000000000000000003 /* LibraryModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryModels.swift; sourceTree = ""; }; + C20000000000000000000004 /* BookSourceValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSourceValidator.swift; sourceTree = ""; }; + C20000000000000000000005 /* BookSourceImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSourceImporter.swift; sourceTree = ""; }; + C20000000000000000000006 /* BookSourceRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSourceRepository.swift; sourceTree = ""; }; + C20000000000000000000007 /* SearchBook.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchBook.swift; sourceTree = ""; }; + C20000000000000000000008 /* RuleDiagnostic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuleDiagnostic.swift; sourceTree = ""; }; + C20000000000000000000009 /* JSONValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONValue.swift; sourceTree = ""; }; + C2000000000000000000000A /* RuleExpression.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuleExpression.swift; sourceTree = ""; }; + C2000000000000000000000B /* JSONPathEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONPathEvaluator.swift; sourceTree = ""; }; + C2000000000000000000000C /* RegexEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegexEvaluator.swift; sourceTree = ""; }; + C2000000000000000000000D /* RuleEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuleEngine.swift; sourceTree = ""; }; + C2000000000000000000000E /* LegadoURLBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegadoURLBuilder.swift; sourceTree = ""; }; + C2000000000000000000000F /* HTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPClient.swift; sourceTree = ""; }; + C20000000000000000000010 /* BookSearchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSearchService.swift; sourceTree = ""; }; + C20000000000000000000011 /* HTMLRuleEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLRuleEvaluator.swift; sourceTree = ""; }; + C20000000000000000000012 /* NetworkBookService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkBookService.swift; sourceTree = ""; }; + C20000000000000000000013 /* BookRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookRepository.swift; sourceTree = ""; }; + C20000000000000000000014 /* JavaScriptRuleEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JavaScriptRuleEvaluator.swift; sourceTree = ""; }; + C20000000000000000000015 /* ExploreKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExploreKind.swift; sourceTree = ""; }; + C20000000000000000000016 /* BookExploreService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookExploreService.swift; sourceTree = ""; }; + C20000000000000000000017 /* BookSourceDebugger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSourceDebugger.swift; sourceTree = ""; }; + C20000000000000000000018 /* LocalBookService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalBookService.swift; sourceTree = ""; }; + C20000000000000000000019 /* ContentSanitizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentSanitizer.swift; sourceTree = ""; }; + C2000000000000000000001A /* ReplaceRuleRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplaceRuleRepository.swift; sourceTree = ""; }; + C2000000000000000000001B /* ReadingActivityRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingActivityRepository.swift; sourceTree = ""; }; + C2000000000000000000001C /* ChapterContentCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChapterContentCache.swift; sourceTree = ""; }; + C2000000000000000000001D /* BookChapterRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookChapterRepository.swift; sourceTree = ""; }; + C2000000000000000000001E /* BookUpdateCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookUpdateCoordinator.swift; sourceTree = ""; }; + C2000000000000000000001F /* BookSourceSwitchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSourceSwitchService.swift; sourceTree = ""; }; + C20000000000000000000020 /* LegadoDatabase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegadoDatabase.swift; sourceTree = ""; }; + C20000000000000000000021 /* BookSourceHeaderResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSourceHeaderResolver.swift; sourceTree = ""; }; + C20000000000000000000022 /* DifferentialSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DifferentialSnapshot.swift; sourceTree = ""; }; + C20000000000000000000023 /* ConfigurationRepositories.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationRepositories.swift; sourceTree = ""; }; + C20000000000000000000024 /* RSSModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSModels.swift; sourceTree = ""; }; + C20000000000000000000025 /* RSSRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSRepository.swift; sourceTree = ""; }; + C20000000000000000000026 /* RSSDefaultParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSDefaultParser.swift; sourceTree = ""; }; + C20000000000000000000027 /* RSSRuleParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSRuleParser.swift; sourceTree = ""; }; + C20000000000000000000028 /* RSSService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSService.swift; sourceTree = ""; }; + C20000000000000000000029 /* RSSSourceImporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSSourceImporter.swift; sourceTree = ""; }; + C2000000000000000000002A /* RSSSortResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSSortResolver.swift; sourceTree = ""; }; + C2000000000000000000002B /* RSSReadingPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSReadingPolicy.swift; sourceTree = ""; }; + C2000000000000000000002C /* RSSSourceDebugger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RSSSourceDebugger.swift; sourceTree = ""; }; + C2000000000000000000002D /* AddBookURLService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddBookURLService.swift; sourceTree = ""; }; + C2000000000000000000002E /* HttpTTSService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HttpTTSService.swift; sourceTree = ""; }; + 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 = ""; }; + B20000000000000000000028 /* BookDetailViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookDetailViewModel.swift; sourceTree = ""; }; + E20000000000000000000001 /* ApplicationSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationSettings.swift; sourceTree = ""; }; + E20000000000000000000002 /* HostsHTTPTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostsHTTPTransport.swift; sourceTree = ""; }; + E20000000000000000000003 /* DiagnosticLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticLog.swift; sourceTree = ""; }; + E20000000000000000000004 /* OtherSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OtherSettingsView.swift; sourceTree = ""; }; + E20000000000000000000005 /* DiagnosticLogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticLogView.swift; sourceTree = ""; }; + E20000000000000000000006 /* AboutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutView.swift; sourceTree = ""; }; + E20000000000000000000007 /* AppDiagnostics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDiagnostics.swift; sourceTree = ""; }; + E20000000000000000000008 /* Legado-LICENSE.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = Legado-LICENSE.txt; sourceTree = ""; }; + E20000000000000000000009 /* SwiftSoup-LICENSE.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = SwiftSoup-LICENSE.txt; sourceTree = ""; }; + E2000000000000000000000A /* Kanna-LICENSE.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = Kanna-LICENSE.txt; sourceTree = ""; }; + F21000000000000000000001 /* FileManagementView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileManagementView.swift; sourceTree = ""; }; + F21000000000000000000002 /* LocalFileService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalFileService.swift; sourceTree = ""; }; + F21000000000000000000003 /* BookSourceEditorDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookSourceEditorDocument.swift; sourceTree = ""; }; + F21000000000000000000004 /* RuleImportInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RuleImportInput.swift; sourceTree = ""; }; + 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 = ""; + }; + 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 = ""; + }; + A30000000000000000000002 /* Sources Feature */ = { + isa = PBXGroup; + children = ( + A20000000000000000000003 /* SourceLibraryModel.swift */, + A20000000000000000000004 /* SourceManagementView.swift */, + A2000000000000000000000D /* SourceDebugView.swift */, + A2000000000000000000000E /* SourceLoginView.swift */, + ); + path = Features/Sources; + sourceTree = ""; + }; + A30000000000000000000003 /* Search Feature */ = { + isa = PBXGroup; + children = ( + A20000000000000000000006 /* SearchViewModel.swift */, + ); + path = Features/Search; + sourceTree = ""; + }; + 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 = ""; + }; + 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 = ""; + }; + A30000000000000000000006 /* Explore Feature */ = { + isa = PBXGroup; + children = ( + A2000000000000000000000C /* ExploreView.swift */, + ); + path = Features/Explore; + sourceTree = ""; + }; + A30000000000000000000007 /* Rules Feature */ = { + isa = PBXGroup; + children = ( + A20000000000000000000010 /* ReplaceRuleLibraryModel.swift */, + A20000000000000000000011 /* ReplaceRuleManagementView.swift */, + A20000000000000000000022 /* RuleSubscriptionView.swift */, + A20000000000000000000023 /* TxtTocRuleManagementView.swift */, + ); + path = Features/Rules; + sourceTree = ""; + }; + 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 = ""; + }; + A3000000000000000000000A /* TTS Feature */ = { + isa = PBXGroup; + children = ( + A20000000000000000000026 /* HttpTTSManagementView.swift */, + ); + path = Features/TTS; + sourceTree = ""; + }; + 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 = ""; + }; + 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 = ""; + }; + C30000000000000000000003 /* Import */ = { + isa = PBXGroup; + children = ( + F21000000000000000000004 /* RuleImportInput.swift */, + C20000000000000000000004 /* BookSourceValidator.swift */, + C20000000000000000000005 /* BookSourceImporter.swift */, + C20000000000000000000029 /* RSSSourceImporter.swift */, + ); + path = Import; + sourceTree = ""; + }; + 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 = ""; + }; + 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 = ""; + }; + C30000000000000000000006 /* Networking */ = { + isa = PBXGroup; + children = ( + E20000000000000000000001 /* ApplicationSettings.swift */, + E20000000000000000000002 /* HostsHTTPTransport.swift */, + C2000000000000000000000E /* LegadoURLBuilder.swift */, + C2000000000000000000000F /* HTTPClient.swift */, + C20000000000000000000021 /* BookSourceHeaderResolver.swift */, + ); + path = Networking; + sourceTree = ""; + }; + C30000000000000000000007 /* Search */ = { + isa = PBXGroup; + children = ( + C20000000000000000000010 /* BookSearchService.swift */, + ); + path = Search; + sourceTree = ""; + }; + 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 = ""; + }; + C30000000000000000000009 /* Explore */ = { + isa = PBXGroup; + children = ( + C20000000000000000000016 /* BookExploreService.swift */, + ); + path = Explore; + sourceTree = ""; + }; + C3000000000000000000000A /* Debugging */ = { + isa = PBXGroup; + children = ( + E20000000000000000000003 /* DiagnosticLog.swift */, + C20000000000000000000017 /* BookSourceDebugger.swift */, + C2000000000000000000002C /* RSSSourceDebugger.swift */, + C20000000000000000000022 /* DifferentialSnapshot.swift */, + ); + path = Debugging; + sourceTree = ""; + }; + C3000000000000000000000B /* LocalBook */ = { + isa = PBXGroup; + children = ( + F21000000000000000000002 /* LocalFileService.swift */, + C20000000000000000000018 /* LocalBookService.swift */, + ); + path = LocalBook; + sourceTree = ""; + }; + C3000000000000000000000D /* TTS */ = { + isa = PBXGroup; + children = ( + C2000000000000000000002E /* HttpTTSService.swift */, + ); + path = TTS; + sourceTree = ""; + }; + 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 = ""; + }; + C3000000000000000000000C /* RSS */ = { + isa = PBXGroup; + children = ( + C20000000000000000000026 /* RSSDefaultParser.swift */, + C20000000000000000000027 /* RSSRuleParser.swift */, + C20000000000000000000028 /* RSSService.swift */, + C2000000000000000000002A /* RSSSortResolver.swift */, + C2000000000000000000002B /* RSSReadingPolicy.swift */, + ); + path = RSS; + sourceTree = ""; + }; + D40000000000000000000002 /* Products */ = { + isa = PBXGroup; + children = ( + D20000000000000000000001 /* Legado.app */, + ); + name = Products; + sourceTree = ""; + }; + E30000000000000000000001 /* Settings Feature */ = { + isa = PBXGroup; + children = ( + E20000000000000000000004 /* OtherSettingsView.swift */, + E20000000000000000000005 /* DiagnosticLogView.swift */, + E20000000000000000000006 /* AboutView.swift */, + E20000000000000000000007 /* AppDiagnostics.swift */, + ); + path = Features/Settings; + sourceTree = ""; + }; + E30000000000000000000002 /* Licenses */ = { + isa = PBXGroup; + children = ( + E20000000000000000000008 /* Legado-LICENSE.txt */, + E20000000000000000000009 /* SwiftSoup-LICENSE.txt */, + E2000000000000000000000A /* Kanna-LICENSE.txt */, + ); + path = Resources; + sourceTree = ""; + }; +/* 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 */; +} diff --git a/Legado.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Legado.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..fad215a --- /dev/null +++ b/Legado.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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 +} diff --git a/Legado.xcodeproj/xcshareddata/xcschemes/Legado.xcscheme b/Legado.xcodeproj/xcshareddata/xcschemes/Legado.xcscheme new file mode 100644 index 0000000..c8a24b8 --- /dev/null +++ b/Legado.xcodeproj/xcshareddata/xcschemes/Legado.xcscheme @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Legado.xcodeproj/xcshareddata/xcschemes/LegadoLiveReadingUITests.xcscheme b/Legado.xcodeproj/xcshareddata/xcschemes/LegadoLiveReadingUITests.xcscheme new file mode 100644 index 0000000..c7fe2b0 --- /dev/null +++ b/Legado.xcodeproj/xcshareddata/xcschemes/LegadoLiveReadingUITests.xcscheme @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Legado.xcodeproj/xcshareddata/xcschemes/LegadoReaderTests.xcscheme b/Legado.xcodeproj/xcshareddata/xcschemes/LegadoReaderTests.xcscheme new file mode 100644 index 0000000..708480d --- /dev/null +++ b/Legado.xcodeproj/xcshareddata/xcschemes/LegadoReaderTests.xcscheme @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Legado.xcodeproj/xcshareddata/xcschemes/LegadoReaderUITests.xcscheme b/Legado.xcodeproj/xcshareddata/xcschemes/LegadoReaderUITests.xcscheme new file mode 100644 index 0000000..4be03f5 --- /dev/null +++ b/Legado.xcodeproj/xcshareddata/xcschemes/LegadoReaderUITests.xcscheme @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Legado.xcodeproj/xcshareddata/xcschemes/LegadoSearchPerformanceUITests.xcscheme b/Legado.xcodeproj/xcshareddata/xcschemes/LegadoSearchPerformanceUITests.xcscheme new file mode 100644 index 0000000..fcf9ec1 --- /dev/null +++ b/Legado.xcodeproj/xcshareddata/xcschemes/LegadoSearchPerformanceUITests.xcscheme @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..81fcec7 --- /dev/null +++ b/Package.resolved @@ -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 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..c80a10c --- /dev/null +++ b/Package.swift @@ -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"] + ) + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..790017c --- /dev/null +++ b/README.md @@ -0,0 +1,121 @@ +
+ +小浣熊阅读 + +# 小浣熊阅读 + +**搜书快一点,读书舒服一点。** + +一款优先兼容阅读书源的 iOS 阅读器 + +![iOS 15+](https://img.shields.io/badge/iOS-15%2B-315C50) +![Swift](https://img.shields.io/badge/Swift-F05138?logo=swift&logoColor=white) +[![GPL v3](https://img.shields.io/badge/License-GPL%20v3-blue)](LICENSE) + +[下载安装](#下载安装) · [主要功能](#主要功能) · [应用截图](#应用截图) · [书源说明](#书源说明) · [问题反馈](https://github.com/Aminark/legado-ios/issues) + +
+ +小浣熊阅读是一款 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 规则仍可能存在平台差异,遇到问题欢迎反馈。 + +## 应用截图 + + + + + + + + + + + + +
书架
小浣熊阅读书架
搜书
小浣熊阅读搜索结果与缓存展示
书源管理
小浣熊阅读书源管理
日间阅读
小浣熊阅读纸色主题
夜间阅读
小浣熊阅读夜间主题
我的
小浣熊阅读个人中心
+ +
+查看更多截图:发现、阅读统计、关于 + + + + + + + +
发现
小浣熊阅读发现页面
阅读统计
小浣熊阅读统计页面
关于
小浣熊阅读关于页面
+ +
+ +截图使用演示书籍、书源和正文,点击可查看大图。 + +## 开始使用 + +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)。 + +请遵守书源网站的使用规则和内容授权,支持正版阅读。 diff --git a/Sources/LegadoCore/Book/AddBookURLService.swift b/Sources/LegadoCore/Book/AddBookURLService.swift new file mode 100644 index 0000000..091dd2e --- /dev/null +++ b/Sources/LegadoCore/Book/AddBookURLService.swift @@ -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() + 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[.. BookSearchReport + func searchStream(keyword: String, sources: [BookSource]) -> AsyncStream +} + +extension BookSourceSearching { + public func searchStream(keyword: String, sources: [BookSource]) -> AsyncStream { + 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 { + 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() + 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: "")) + } +} diff --git a/Sources/LegadoCore/Book/BookUpdateCoordinator.swift b/Sources/LegadoCore/Book/BookUpdateCoordinator.swift new file mode 100644 index 0000000..d0fbff4 --- /dev/null +++ b/Sources/LegadoCore/Book/BookUpdateCoordinator.swift @@ -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 + } +} diff --git a/Sources/LegadoCore/Book/ChapterContentCache.swift b/Sources/LegadoCore/Book/ChapterContentCache.swift new file mode 100644 index 0000000..57fc193 --- /dev/null +++ b/Sources/LegadoCore/Book/ChapterContentCache.swift @@ -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) + } +} diff --git a/Sources/LegadoCore/Book/ContentSanitizer.swift b/Sources/LegadoCore/Book/ContentSanitizer.swift new file mode 100644 index 0000000..fcef25b --- /dev/null +++ b/Sources/LegadoCore/Book/ContentSanitizer.swift @@ -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)]*>"#) 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 ? "" : "" + 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() + 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() + } +} diff --git a/Sources/LegadoCore/Book/NetworkBookService.swift b/Sources/LegadoCore/Book/NetworkBookService.swift new file mode 100644 index 0000000..67e6c20 --- /dev/null +++ b/Sources/LegadoCore/Book/NetworkBookService.swift @@ -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() + var requested = Set() + 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() + 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() + var requested = Set() + 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 + } + } +} diff --git a/Sources/LegadoCore/Book/ReplaceRuleTester.swift b/Sources/LegadoCore/Book/ReplaceRuleTester.swift new file mode 100644 index 0000000..dbb21df --- /dev/null +++ b/Sources/LegadoCore/Book/ReplaceRuleTester.swift @@ -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) + } +} diff --git a/Sources/LegadoCore/Debugging/BookSourceDebugger.swift b/Sources/LegadoCore/Debugging/BookSourceDebugger.swift new file mode 100644 index 0000000..c68fa70 --- /dev/null +++ b/Sources/LegadoCore/Debugging/BookSourceDebugger.swift @@ -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)) + } + } +} diff --git a/Sources/LegadoCore/Debugging/DiagnosticLog.swift b/Sources/LegadoCore/Debugging/DiagnosticLog.swift new file mode 100644 index 0000000..a246c75 --- /dev/null +++ b/Sources/LegadoCore/Debugging/DiagnosticLog.swift @@ -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) + } +} diff --git a/Sources/LegadoCore/Debugging/DifferentialSnapshot.swift b/Sources/LegadoCore/Debugging/DifferentialSnapshot.swift new file mode 100644 index 0000000..78b069b --- /dev/null +++ b/Sources/LegadoCore/Debugging/DifferentialSnapshot.swift @@ -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" + ? "" : 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)] = "" + } + } + + 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)) ?? [:] + } +} diff --git a/Sources/LegadoCore/Debugging/RSSSourceDebugger.swift b/Sources/LegadoCore/Debugging/RSSSourceDebugger.swift new file mode 100644 index 0000000..f91ea7e --- /dev/null +++ b/Sources/LegadoCore/Debugging/RSSSourceDebugger.swift @@ -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: [])) + } + } +} diff --git a/Sources/LegadoCore/Domain/BookSource.swift b/Sources/LegadoCore/Domain/BookSource.swift new file mode 100644 index 0000000..d1d81d0 --- /dev/null +++ b/Sources/LegadoCore/Domain/BookSource.swift @@ -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(_ rule: T?, key: String, into values: inout KeyedEncodingContainer) 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() + 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() + 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(_ 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)) + } +} diff --git a/Sources/LegadoCore/Domain/BookSourceEditorDocument.swift b/Sources/LegadoCore/Domain/BookSourceEditorDocument.swift new file mode 100644 index 0000000..399fc02 --- /dev/null +++ b/Sources/LegadoCore/Domain/BookSourceEditorDocument.swift @@ -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: / 动态生成。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 } +} diff --git a/Sources/LegadoCore/Domain/BookSourceRules.swift b/Sources/LegadoCore/Domain/BookSourceRules.swift new file mode 100644 index 0000000..8005dee --- /dev/null +++ b/Sources/LegadoCore/Domain/BookSourceRules.swift @@ -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() {} +} diff --git a/Sources/LegadoCore/Domain/ExploreKind.swift b/Sources/LegadoCore/Domain/ExploreKind.swift new file mode 100644 index 0000000..cb0ba2b --- /dev/null +++ b/Sources/LegadoCore/Domain/ExploreKind.swift @@ -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 + } +} diff --git a/Sources/LegadoCore/Domain/LibraryModels.swift b/Sources/LegadoCore/Domain/LibraryModels.swift new file mode 100644 index 0000000..331abd9 --- /dev/null +++ b/Sources/LegadoCore/Domain/LibraryModels.swift @@ -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) + } +} diff --git a/Sources/LegadoCore/Domain/RSSModels.swift b/Sources/LegadoCore/Domain/RSSModels.swift new file mode 100644 index 0000000..2f6904d --- /dev/null +++ b/Sources/LegadoCore/Domain/RSSModels.swift @@ -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 + } +} diff --git a/Sources/LegadoCore/Domain/SearchBook.swift b/Sources/LegadoCore/Domain/SearchBook.swift new file mode 100644 index 0000000..418472f --- /dev/null +++ b/Sources/LegadoCore/Domain/SearchBook.swift @@ -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 + 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) + } +} diff --git a/Sources/LegadoCore/Explore/BookExploreService.swift b/Sources/LegadoCore/Explore/BookExploreService.swift new file mode 100644 index 0000000..e84a66a --- /dev/null +++ b/Sources/LegadoCore/Explore/BookExploreService.swift @@ -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("") { + 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() + 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 } +} diff --git a/Sources/LegadoCore/Import/BookSourceImporter.swift b/Sources/LegadoCore/Import/BookSourceImporter.swift new file mode 100644 index 0000000..e1624dc --- /dev/null +++ b/Sources/LegadoCore/Import/BookSourceImporter.swift @@ -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? { + var capacity = max(payload.count * 4, 64 * 1_024) + while capacity > 0, capacity <= Self.maximumDocumentBytes + 1 { + let destination = UnsafeMutablePointer.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 + } +} diff --git a/Sources/LegadoCore/Import/BookSourceValidator.swift b/Sources/LegadoCore/Import/BookSourceValidator.swift new file mode 100644 index 0000000..45b4597 --- /dev/null +++ b/Sources/LegadoCore/Import/BookSourceValidator.swift @@ -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 + } +} + diff --git a/Sources/LegadoCore/Import/QRCodeDecoder.swift b/Sources/LegadoCore/Import/QRCodeDecoder.swift new file mode 100644 index 0000000..e30ca23 --- /dev/null +++ b/Sources/LegadoCore/Import/QRCodeDecoder.swift @@ -0,0 +1,13 @@ +import Foundation +import Vision + +public struct QRCodeDecoder: Sendable { + public init() {} + public func detect(in data: Data) throws -> [String] { + let request = VNDetectBarcodesRequest() + request.symbologies = [.qr] + try VNImageRequestHandler(data: data).perform([request]) + var seen = Set() + return (request.results ?? []).compactMap(\.payloadStringValue).filter { seen.insert($0).inserted } + } +} diff --git a/Sources/LegadoCore/Import/RSSSourceImporter.swift b/Sources/LegadoCore/Import/RSSSourceImporter.swift new file mode 100644 index 0000000..6bbd802 --- /dev/null +++ b/Sources/LegadoCore/Import/RSSSourceImporter.swift @@ -0,0 +1,88 @@ +import Foundation + +public struct RSSSourceImportPayload: Sendable { + public var sources: [RssSource] + public var subscriptionURLs: [String] + public init(sources: [RssSource] = [], subscriptionURLs: [String] = []) { + self.sources = sources + self.subscriptionURLs = subscriptionURLs + } +} + +public enum RSSSourceImportError: Error, LocalizedError, Sendable { + case emptyDocument + case wrongFormat + case invalidSource + case duplicate(String) + public var errorDescription: String? { + switch self { + case .emptyDocument: "导入内容为空" + case .wrongFormat: "RSS源格式错误" + case .invalidSource: "不是订阅源" + case .duplicate(let url): "RSS源已存在:\(url)" + } + } +} + +public struct RSSSourceImporter: Sendable { + public init() {} + + public func decode(_ data: Data) throws -> RSSSourceImportPayload { + var data = data + if data.starts(with: [0xEF, 0xBB, 0xBF]) { data = Data(data.dropFirst(3)) } + guard !data.isEmpty else { throw RSSSourceImportError.emptyDocument } + let decoder = JSONDecoder() + if let manifest = try? decoder.decode(SubscriptionManifest.self, from: data), !manifest.sourceUrls.isEmpty { + return .init(subscriptionURLs: manifest.sourceUrls) + } + let sources: [RssSource] + if let values = try? decoder.decode([RssSource].self, from: data) { sources = values } + else if let value = try? decoder.decode(RssSource.self, from: data) { sources = [value] } + else { throw RSSSourceImportError.wrongFormat } + guard sources.isEmpty || sources.first?.sourceUrl.isEmpty == false else { throw RSSSourceImportError.invalidSource } + return .init(sources: sources) + } + + public func merge( + _ incoming: [RssSource], + into existing: [RssSource], + duplicatePolicy: DuplicatePolicy = .replace, + addGroup: String? = nil + ) throws -> [RssSource] { + var result = existing + var indexes = Dictionary(uniqueKeysWithValues: result.enumerated().map { ($0.element.sourceUrl, $0.offset) }) + for original in incoming where !original.sourceUrl.isEmpty { + var source = original + if let addGroup, !addGroup.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { source.addGroups(addGroup) } + if let index = indexes[source.sourceUrl] { + switch duplicatePolicy { + case .replace: result[index] = source + case .skip: continue + case .fail: throw RSSSourceImportError.duplicate(source.sourceUrl) + } + } else { + indexes[source.sourceUrl] = result.count + result.append(source) + } + } + return result + } + + public func defaultSelection(incoming: [RssSource], existing: [RssSource]) -> [Bool] { + let current = Dictionary(uniqueKeysWithValues: existing.map { ($0.sourceUrl, $0.lastUpdateTime) }) + return incoming.map { source in + guard let oldTime = current[source.sourceUrl] else { return true } + return oldTime < source.lastUpdateTime + } + } + + public func encode(_ sources: [RssSource], prettyPrinted: Bool = true) throws -> Data { + let encoder = JSONEncoder() + if prettyPrinted { encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] } + return try encoder.encode(sources) + } + + private struct SubscriptionManifest: Decodable { + var sourceUrls: [String] + } +} diff --git a/Sources/LegadoCore/Import/RuleImportInput.swift b/Sources/LegadoCore/Import/RuleImportInput.swift new file mode 100644 index 0000000..a8d537f --- /dev/null +++ b/Sources/LegadoCore/Import/RuleImportInput.swift @@ -0,0 +1,42 @@ +import Foundation + +public enum RuleImportInput: Sendable, Equatable { + case json(String) + case remote(URL, withoutUserAgent: Bool) + + public static func parse(_ input: String, kind: String = "bookSource") throws -> Self { + var value = input.trimmingCharacters(in: .whitespacesAndNewlines) + if value.hasPrefix("{"), value.hasSuffix("}") { return .json(value) } + if value.hasPrefix("["), value.hasSuffix("]"), (try? JSONValue.decode(data: Data(value.utf8))) != nil { + return .json(value) + } + if let start = value.range(of: "]("), value.hasSuffix(")") { + value = String(value[start.upperBound.. \n\r\t")) + if let url = URL(string: value), url.scheme?.lowercased() == "legado" { + guard url.host?.lowercased() == "import", url.path.lowercased() == "/" + kind.lowercased(), + let src = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.first(where: { $0.name == "src" })?.value, + !src.lowercased().hasPrefix("legado:") else { + throw BookSourceImportError.invalidDocument("二维码或深链类型不匹配,需要 \(kind)") + } + return try parse(src, kind: kind) + } + let marker = "#requestWithoutUA" + let withoutUA = value.hasSuffix(marker) + if withoutUA { value = String(value.dropLast(marker.count)) } + guard let url = URL(string: value), let scheme = url.scheme?.lowercased(), + ["http", "https"].contains(scheme), url.host?.isEmpty == false else { + throw BookSourceImportError.invalidDocument("请输入 JSON、HTTP(S) 地址或 Legado 导入二维码") + } + return .remote(url, withoutUserAgent: withoutUA) + } + + public func request() -> URLRequest? { + guard case .remote(let url, let withoutUA) = self else { return nil } + var request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 45) + request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept") + if withoutUA { request.setValue("null", forHTTPHeaderField: "User-Agent") } + return request + } +} diff --git a/Sources/LegadoCore/LocalBook/LocalBookService.swift b/Sources/LegadoCore/LocalBook/LocalBookService.swift new file mode 100644 index 0000000..3827e4c --- /dev/null +++ b/Sources/LegadoCore/LocalBook/LocalBookService.swift @@ -0,0 +1,767 @@ +import CoreFoundation +import CryptoKit +import Foundation + +public enum LocalBookError: Error, LocalizedError, Sendable { + case unsupportedFormat + case oversized + case unreadableEncoding + case missingFile + + public var errorDescription: String? { + switch self { + case .unsupportedFormat: "当前仅支持 TXT 本地书" + case .oversized: "本地书超过当前配置允许的大小" + case .unreadableEncoding: "无法识别 TXT 编码" + case .missingFile: "本地书文件已丢失" + } + } +} + +public struct LocalBookImportReport: Sendable { + public var book: Book + public var chapters: [BookChapter] + + public init(book: Book, chapters: [BookChapter]) { + self.book = book + self.chapters = chapters + } +} + +public enum TxtTocRuleSelector { + public static let separator = "🫅🈳🏻" + + public static func select( + content: String, + rules: [TxtTocRule], + transformTitle: (TxtTocRule, String, Int, String?, Int) -> String = { _, value, _, _, _ in value } + ) -> TxtTocRule? { + let source = content as NSString + var maximum = -1 + var selected: TxtTocRule? + for rule in rules.filter(\.enable).sorted(by: { $0.serialNumber < $1.serialNumber }) { + guard !rule.rule.isEmpty else { continue } + guard let expression = try? NSRegularExpression(pattern: rule.rule, options: .anchorsMatchLines) else { continue } + var start = 0 + var validCount = 0 + var shortGapCount = 0 + var lastTitle: String? + for match in expression.matches(in: content, range: NSRange(location: 0, length: source.length)) { + let length = match.range.location - start + if start == 0 || length > 1_000 { + let rawTitle = source.substring(with: match.range) + let title = transformTitle(rule, rawTitle, validCount, lastTitle, length) + if !title.isEmpty { + lastTitle = title + validCount += 1 + } + start = NSMaxRange(match.range) + } else if length < 100 { + shortGapCount += 1 + } + } + if validCount >= shortGapCount * 3, validCount > maximum + 2 { + maximum = validCount + selected = rule + if maximum > 70 { break } + } + } + return selected + } +} + +public enum TxtTocRuleDefaults { + public static func load(bundle: Bundle = .main) -> [TxtTocRule] { + guard let url = bundle.url(forResource: "txtTocRule", withExtension: "json"), + let data = try? Data(contentsOf: url), + let values = try? JSONDecoder().decode([TxtTocRule].self, from: data) else { return [] } + return values.sorted { $0.serialNumber < $1.serialNumber } + } +} + +private final class TxtVolumeState { + var lastTitle = "" +} + +private struct TxtHeadingMatch { + var title: String + var start: Int64 + var end: Int64 + var previousLength: Int +} + +public struct LocalBookService: Sendable { + public var maximumFileSize: Int64 + public var tocRules: [TxtTocRule] + private var loadsLiveTocRules = false + + public init(maximumFileSize: Int64 = .max, tocRules: [TxtTocRule] = []) { + self.maximumFileSize = maximumFileSize + self.tocRules = tocRules.filter(\.enable).sorted { $0.serialNumber < $1.serialNumber } + } + + public static func live() -> LocalBookService { + LocalBookService(tocRules: TxtTocRuleDefaults.load()) + } + + public func importTextFile(at sourceURL: URL, into directory: URL) throws -> LocalBookImportReport { + guard sourceURL.pathExtension.lowercased() == "txt" else { throw LocalBookError.unsupportedFormat } + let values = try sourceURL.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]) + guard values.isRegularFile == true else { throw LocalBookError.missingFile } + if Int64(values.fileSize ?? 0) > maximumFileSize { throw LocalBookError.oversized } + let data = try Data(contentsOf: sourceURL, options: .mappedIfSafe) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let destination = directory.appendingPathComponent("\(UUID().uuidString).txt") + try data.write(to: destination, options: .atomic) + do { + return try textFileReport(data: data, sourceURL: sourceURL, destination: destination) + } catch { + try? FileManager.default.removeItem(at: destination) + throw error + } + } + + public func openTextFile(at url: URL) throws -> LocalBookImportReport { + guard url.pathExtension.lowercased() == "txt" else { throw LocalBookError.unsupportedFormat } + let values = try url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey]) + guard values.isRegularFile == true else { throw LocalBookError.missingFile } + guard Int64(values.fileSize ?? 0) <= maximumFileSize else { throw LocalBookError.oversized } + return try textFileReport(data: Data(contentsOf: url, options: .mappedIfSafe), sourceURL: url, destination: url) + } + + private func textFileReport(data: Data, sourceURL: URL, destination: URL) throws -> LocalBookImportReport { + let decoded = try decode(Data(data.prefix(512_000))) + + let name = sourceURL.deletingPathExtension().lastPathComponent + var book = Book(bookUrl: destination.absoluteString, name: name, origin: "local") + book.originName = sourceURL.lastPathComponent + book.charset = decoded.charset + book.canUpdate = false + let selectionVolumeState = TxtVolumeState() + let probeText = textProbe(data, encoding: decoded.encoding) + let selectedRule = TxtTocRuleSelector.select(content: probeText, rules: effectiveTocRules()) { + rule, title, index, previous, length in + transformedTitle(title, script: rule.replacement, index: index, previousTitle: previous, previousLength: length, book: book, volumeState: selectionVolumeState) + } + if let selectedRule { + book.tocUrl = selectedRule.rule + TxtTocRuleSelector.separator + selectedRule.replacement + } + let fullBOMLength = bomLength(in: data, encoding: decoded.encoding) + let hasTOC = !headingMatches( + data: data, + encoding: decoded.encoding, + bomLength: fullBOMLength, + pattern: selectedRule?.rule ?? fallbackTocPattern, + usesFirstCapture: selectedRule == nil + ).isEmpty + var chapters = chapters( + data: data, + encoding: decoded.encoding, + bomLength: fullBOMLength, + book: book, + fallbackTitle: name, + selectedRule: selectedRule + ) + let totalWordCount = applyWordCounts(&chapters, data: data, encoding: decoded.encoding) + book.wordCount = wordCountFormat(totalWordCount) + if hasTOC, + let first = chapters.first(where: { !$0.isVolume && $0.start == Int64(fullBOMLength) }), + first.start == Int64(fullBOMLength), + let endValue = first.end, + endValue > Int64(fullBOMLength), + let preface = String( + data: data.subdata(in: fullBOMLength.. 600 ? String(preface.prefix(600)) : preface + } + book.totalChapterNum = chapters.count + book.latestChapterTitle = chapters.last?.title + return .init(book: book, chapters: chapters) + } + + public func loadChapters(book: Book) throws -> ChapterListReport { + let decoded = try read(book) + let selectionVolumeState = TxtVolumeState() + let probeText = textProbe(decoded.data, encoding: decoded.encoding) + let selectedRule = rule(storedIn: book.tocUrl) ?? TxtTocRuleSelector.select(content: probeText, rules: effectiveTocRules()) { + rule, title, index, previous, length in + transformedTitle(title, script: rule.replacement, index: index, previousTitle: previous, previousLength: length, book: book, volumeState: selectionVolumeState) + } + var chapters = chapters( + data: decoded.data, + encoding: decoded.encoding, + bomLength: decoded.bomLength, + book: book, + fallbackTitle: book.name, + selectedRule: selectedRule + ) + _ = applyWordCounts(&chapters, data: decoded.data, encoding: decoded.encoding) + return .init(chapters: chapters) + } + + public func loadContent(book: Book, chapter: BookChapter) throws -> ChapterContentReport { + guard let url = URL(string: book.bookUrl), url.isFileURL, + FileManager.default.fileExists(atPath: url.path) else { throw LocalBookError.missingFile } + let fileSize = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0 + let start = max(Int(chapter.start ?? 0), 0) + let end = min(Int(chapter.end ?? Int64(fileSize)), fileSize) + guard start <= end else { + return .init(title: chapter.title, diagnostics: [ + .init(severity: .error, code: "local.range", message: "本地章节范围无效", rule: chapter.url) + ]) + } + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + try handle.seek(toOffset: UInt64(start)) + let contentData = try handle.read(upToCount: end - start) ?? Data() + let encoding: String.Encoding + if let charset = book.charset, let stored = self.encoding(named: charset) { + encoding = stored + } else { + try handle.seek(toOffset: 0) + let probe = try handle.read(upToCount: 512_000) ?? Data() + encoding = try decode(probe).encoding + } + let content = (String(data: contentData, encoding: encoding) ?? "") + .replacingOccurrences(of: #"^[\n\s]+"#, with: "  ", options: .regularExpression) + return .init(title: chapter.title, content: content, pagesVisited: 1) + } + + private func read(_ book: Book) throws -> (charset: String, encoding: String.Encoding, bomLength: Int, data: Data) { + guard let url = URL(string: book.bookUrl), url.isFileURL, + FileManager.default.fileExists(atPath: url.path) else { throw LocalBookError.missingFile } + let data = try Data(contentsOf: url, options: .mappedIfSafe) + if let charset = book.charset, let encoding = encoding(named: charset) { + return (charset, encoding, bomLength(in: data, encoding: encoding), data) + } + let decoded = try decode(Data(data.prefix(512_000))) + return (decoded.charset, decoded.encoding, bomLength(in: data, encoding: decoded.encoding), data) + } + + private func decode(_ data: Data, preferredCharset: String? = nil) throws -> (text: String, charset: String, encoding: String.Encoding, bomLength: Int) { + if let preferredCharset, let encoding = encoding(named: preferredCharset), + let value = String(data: data, encoding: encoding) { + return (stripBOM(value), preferredCharset, encoding, bomLength(in: data, encoding: encoding)) + } + if data.starts(with: [0xFF, 0xFE]), + let value = String(data: data, encoding: .utf16LittleEndian) { + return (stripBOM(value), "UTF-16LE", .utf16LittleEndian, 2) + } + if data.starts(with: [0xFE, 0xFF]), + let value = String(data: data, encoding: .utf16BigEndian) { + return (stripBOM(value), "UTF-16BE", .utf16BigEndian, 2) + } + let candidates: [(String, String.Encoding)] = [ + ("UTF-8", .utf8), + ("UTF-16", .utf16), + ("GB18030", .gb18030), + ("Big5", .big5) + ] + for (name, encoding) in candidates { + if let value = String(data: data, encoding: encoding), isPlausible(value) { + return (stripBOM(value), name, encoding, bomLength(in: data, encoding: encoding)) + } + // A bounded probe can end halfway through a UTF-8 scalar. Keep + // UTF-8 as the selected charset instead of misclassifying the + // truncated probe as UTF-16 and producing unreadable chapters. + if encoding == .utf8 { + let value = String(decoding: data, as: UTF8.self) + let replacementCount = value.reduce(into: 0) { count, character in + if character == "\u{FFFD}" { count += 1 } + } + if replacementCount <= 2, isPlausible(value) { + return (stripBOM(value), name, encoding, bomLength(in: data, encoding: encoding)) + } + } + } + throw LocalBookError.unreadableEncoding + } + + private func chapters( + data: Data, + encoding: String.Encoding, + bomLength: Int, + book: Book, + fallbackTitle: String, + selectedRule: TxtTocRule? + ) -> [BookChapter] { + let pattern = selectedRule?.rule ?? fallbackTocPattern + let matches = headingMatches( + data: data, + encoding: encoding, + bomLength: bomLength, + pattern: pattern, + usesFirstCapture: selectedRule == nil + ) + guard !matches.isEmpty else { + if data.count - bomLength > 10 * 1_024 { + return chaptersWithoutTOC(data, encoding: encoding, bomLength: bomLength, book: book) + } + var chapter = BookChapter(url: "local://chapter/0", title: fallbackTitle, bookUrl: book.bookUrl, index: 0) + chapter.start = Int64(bomLength) + chapter.end = Int64(data.count) + chapter.baseUrl = book.bookUrl + return finalized([chapter], book: book) + } + var previousTitle: String? + var chapters: [BookChapter] = [] + let volumeState = TxtVolumeState() + if let first = matches.first, + first.start > Int64(bomLength), + let preface = String( + data: data.subdata(in: bomLength.. 102_400 else { return } + let start = max(Int(startValue), 0) + let end = min(max(Int(endValue), start), data.count) + chapters.removeLast() + chapter.end = chapter.start + chapter.isVolume = true + chapter.tag = nil + chapters.append(chapter) + volumeState.lastTitle = chapter.title + chapters.append(contentsOf: splitByteRange( + data, + start: start, + end: end, + encoding: encoding, + book: book, + title: { "\(chapter.title)(\($0 + 1))" } + )) + } + + private func splitByteRange( + _ data: Data, + start initialStart: Int, + end rangeEnd: Int, + encoding: String.Encoding, + book: Book, + title: (Int) -> String + ) -> [BookChapter] { + splitHeadinglessRange(data, start: initialStart, end: rangeEnd, encoding: encoding, book: book) { _, _, index in title(index) } + } + + private func headingMatches( + data: Data, + encoding: String.Encoding, + bomLength: Int, + pattern: String, + usesFirstCapture: Bool + ) -> [TxtHeadingMatch] { + guard let expression = try? NSRegularExpression(pattern: pattern, options: .anchorsMatchLines) else { return [] } + if data.count <= 2 * 1_024 * 1_024, + bomLength <= data.count, + let text = String(data: data.subdata(in: bomLength.. 1 ? match.range(at: 1) : match.range + let title = titleRange.location == NSNotFound ? "" : source.substring(with: titleRange) + let start = min(match.range.location, offsets.count - 1) + let end = min(NSMaxRange(match.range), offsets.count - 1) + return .init(title: title, start: offsets[start], end: offsets[end], previousLength: match.range.location) + } + } + let blockSize = 512_000 + var position = bomLength + var output: [TxtHeadingMatch] = [] + while position < data.count { + let limit = min(position + blockSize, data.count) + var end = limit + if limit < data.count, + let newline = newlineIndex(in: data, range: position.. position { + end = newline + } + if end <= position { end = limit } + let blockData = data.subdata(in: position.. 1 ? match.range(at: 1) : match.range + let title = titleRange.location == NSNotFound ? "" : source.substring(with: titleRange) + let startIndex = min(match.range.location, offsets.count - 1) + let endIndex = min(NSMaxRange(match.range), offsets.count - 1) + output.append(.init( + title: title, + start: offsets[startIndex], + end: offsets[endIndex], + previousLength: max(match.range.location - previousEnd, 0) + )) + previousEnd = NSMaxRange(match.range) + } + position = end + } + return output + } + + private func chaptersWithoutTOC(_ data: Data, encoding: String.Encoding, bomLength: Int, book: Book) -> [BookChapter] { + let chapters = splitHeadinglessRange(data, start: bomLength, end: data.count, encoding: encoding, book: book) { + block, chapter, _ in "第\(block)章(\(chapter))" + } + return finalized(chapters, book: book) + } + + private func splitHeadinglessRange( + _ data: Data, + start rangeStart: Int, + end rangeEnd: Int, + encoding: String.Encoding, + book: Book, + title: (Int, Int, Int) -> String + ) -> [BookChapter] { + let bufferSize = 512_000 + let maximumLength = 10 * 1_024 + var cursor = rangeStart + var block = 0 + var output: [BookChapter] = [] + var finalChapterNumber = 0 + while cursor < rangeEnd { + block += 1 + let blockEnd = min(cursor + bufferSize, rangeEnd) + var chapterInBlock = 0 + while blockEnd - cursor > maximumLength { + chapterInBlock += 1 + let searchStart = cursor + maximumLength + let newline = newlineIndex( + in: data, + range: searchStart.. Int { + var total = 0 + for index in chapters.indices where !chapters[index].isVolume { + let start = max(Int(chapters[index].start ?? 0), 0) + let end = min(max(Int(chapters[index].end ?? Int64(start)), start), data.count) + guard start < end else { + chapters[index].wordCount = "" + continue + } + let text = String(data: data.subdata(in: start.. String { + guard words > 0 else { return "" } + guard words > 10_000 else { return "\(words)字" } + let value = Double(words) / 10_000 + let rounded = (value * 10).rounded() / 10 + if rounded.rounded() == rounded { return "\(Int(rounded))万字" } + return String(format: "%.1f万字", rounded) + } + + private func finalized(_ chapters: [BookChapter], book: Book) -> [BookChapter] { + chapters.enumerated().map { index, value in + var chapter = value + chapter.index = index + chapter.bookUrl = book.bookUrl + let seed = book.originName + String(index) + chapter.title + let digest = Insecure.MD5.hash(data: Data(seed.utf8)).map { String(format: "%02x", $0) }.joined() + chapter.url = String(digest.dropFirst(8).prefix(16)) + return chapter + } + } + + private func rule(storedIn value: String) -> TxtTocRule? { + guard !value.isEmpty else { return nil } + let parts = value.components(separatedBy: TxtTocRuleSelector.separator) + return .init(name: "本书目录规则", rule: parts[0], replacement: parts.dropFirst().first ?? "") + } + + private func effectiveTocRules() -> [TxtTocRule] { + tocRules + } + + private func transformedTitle( + _ value: String, + script: String?, + index: Int, + previousTitle: String? = nil, + previousLength: Int = -1, + book: Book, + volumeState: TxtVolumeState = .init(), + onVolume: ((String) -> Void)? = nil + ) -> String { + guard let script = script?.trimmingCharacters(in: .whitespacesAndNewlines), !script.isEmpty else { return value } + let previous: String + if let previousTitle, + let data = try? JSONSerialization.data(withJSONObject: [previousTitle]), + let encoded = String(data: data, encoding: .utf8) { + previous = String(encoded.dropFirst().dropLast()) + } else { + previous = "null" + } + let volumeLiteral = (try? JSONSerialization.data(withJSONObject: [volumeState.lastTitle])) + .flatMap { String(data: $0, encoding: .utf8) } + .map { String($0.dropFirst().dropLast()) } ?? "\"\"" + let wrapped = """ + var index = \(index + 1); var prevTitle = \(previous); var prevLength = \(previousLength); + var lastVolumeTitle = \(volumeLiteral); + \(script) + """ + guard let evaluated = try? JavaScriptRuleEvaluator().evaluate( + script: wrapped, + result: .string(value), + executionContext: .init(page: index + 1, book: book) + ) else { return value } + let title = evaluated.value.stringValue + let volumes = evaluated.variables["__legadoVolumes"] + .flatMap { try? JSONDecoder().decode([String].self, from: Data($0.utf8)) } ?? [] + for volume in volumes { + volumeState.lastTitle = volume + onVolume?(volume) + } + return title + } + + private func encoding(named name: String) -> String.Encoding? { + switch name.uppercased() { + case "UTF-8", "UTF8": .utf8 + case "UTF-16", "UTF16": .utf16 + case "UTF-16LE", "UTF16LE": .utf16LittleEndian + case "UTF-16BE", "UTF16BE": .utf16BigEndian + case "GB18030", "GBK", "GB2312": .gb18030 + case "BIG5": .big5 + default: nil + } + } + + private func bomLength(in data: Data, encoding: String.Encoding) -> Int { + if data.starts(with: [0xEF, 0xBB, 0xBF]) { return 3 } + if isUTF16(encoding), + data.starts(with: [0xFF, 0xFE]) || data.starts(with: [0xFE, 0xFF]) { return 2 } + return 0 + } + + private func isUTF16(_ encoding: String.Encoding) -> Bool { + encoding == .utf16 || encoding == .utf16LittleEndian || encoding == .utf16BigEndian + } + + private func newlineIndex( + in data: Data, + range: Range, + encoding: String.Encoding, + fromEnd: Bool + ) -> Int? { + guard !range.isEmpty else { return nil } + let bytePair: (UInt8, UInt8)? + if encoding == .utf16LittleEndian || (encoding == .utf16 && data.starts(with: [0xFF, 0xFE])) { + bytePair = (0x0A, 0x00) + } else if encoding == .utf16BigEndian || (encoding == .utf16 && data.starts(with: [0xFE, 0xFF])) { + bytePair = (0x00, 0x0A) + } else { + bytePair = nil + } + guard let bytePair else { + return fromEnd ? data[range].lastIndex(of: 0x0A) : data[range].firstIndex(of: 0x0A) + } + var index = range.lowerBound + (range.lowerBound.isMultiple(of: 2) ? 0 : 1) + guard index + 1 < range.upperBound else { return nil } + if fromEnd { + index = range.upperBound - 2 + if !index.isMultiple(of: 2) { index -= 1 } + while index >= range.lowerBound { + if data[index] == bytePair.0, data[index + 1] == bytePair.1 { return index } + index -= 2 + } + } else { + while index + 1 < range.upperBound { + if data[index] == bytePair.0, data[index + 1] == bytePair.1 { return index } + index += 2 + } + } + return nil + } + + private func byteOffsetMap(_ content: String, encoding: String.Encoding, initialOffset: Int) -> [Int64] { + var offsets = [Int64](repeating: Int64(initialOffset), count: content.utf16.count + 1) + var utf16Index = 0 + var byteOffset = initialOffset + for scalar in content.unicodeScalars { + let scalarText = String(scalar) + let unitCount = scalarText.utf16.count + let byteCount = isUTF16(encoding) + ? unitCount * 2 + : (scalarText.data(using: encoding, allowLossyConversion: true)?.count ?? 0) + if unitCount > 1 { + for step in 1.. String { + let prefix = Data(data.prefix(512_000)) + for removed in 0...8 where prefix.count >= removed { + let candidate = prefix.dropLast(removed) + if let value = String(data: Data(candidate), encoding: encoding) { return stripBOM(value) } + } + return "" + } + + private func stripBOM(_ value: String) -> String { + value.hasPrefix("\u{feff}") ? String(value.dropFirst()) : value + } + + private func isPlausible(_ value: String) -> Bool { + guard !value.isEmpty else { return true } + let controls = value.unicodeScalars.filter { $0.value < 32 && !CharacterSet.whitespacesAndNewlines.contains($0) }.count + return controls * 100 < max(value.unicodeScalars.count, 1) + } +} + +private extension String.Encoding { + static let gb18030 = String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding( + CFStringEncoding(CFStringEncodings.GB_18030_2000.rawValue) + )) + static let big5 = String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding( + CFStringEncoding(CFStringEncodings.big5.rawValue) + )) +} diff --git a/Sources/LegadoCore/LocalBook/LocalFileService.swift b/Sources/LegadoCore/LocalBook/LocalFileService.swift new file mode 100644 index 0000000..d560d54 --- /dev/null +++ b/Sources/LegadoCore/LocalBook/LocalFileService.swift @@ -0,0 +1,188 @@ +import Foundation + +public struct LocalFileEntry: Identifiable, Sendable, Equatable { + public let url: URL + public let bytes: Int64 + public let modified: Date? + public var id: String { url.absoluteString } +} + +public enum LocalFileError: Error, LocalizedError, Equatable { + case outsideLibrary, invalidName, alreadyExists, missingFile, emptyCache, missingDirectory + case saveFailed(String) + + public var errorDescription: String? { + switch self { + case .outsideLibrary: "只能管理应用内的本地书文件。" + case .invalidName: "文件名不能为空,不能包含路径分隔符或控制字符。" + case .alreadyExists: "同名文件已存在,请换一个名称。" + case .missingFile: "文件已不存在,请重新导入。" + case .emptyCache: "这本书还没有可导出的缓存正文,请先缓存章节。" + case .missingDirectory: "还没有这本书的目录,请先打开详情加载目录。" + case .saveFailed(let message): "书架保存失败:\(message)" + } + } +} + +public struct StagedLocalFile: Sendable { + public let original: URL + public let staged: URL +} + +public actor LocalFileService { + public let directory: URL + + public init(directory: URL) { self.directory = directory.standardizedFileURL } + + public func list() throws -> [LocalFileEntry] { + guard FileManager.default.fileExists(atPath: directory.path) else { return [] } + return try FileManager.default.contentsOfDirectory( + at: directory, includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey, .contentModificationDateKey], + options: [.skipsHiddenFiles] + ).compactMap { url in + let values = try url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey, .contentModificationDateKey]) + guard values.isRegularFile == true, values.isSymbolicLink != true, url.pathExtension.lowercased() == "txt" else { return nil } + return .init(url: url, bytes: Int64(values.fileSize ?? 0), modified: values.contentModificationDate) + }.sorted { $0.url.lastPathComponent.localizedStandardCompare($1.url.lastPathComponent) == .orderedAscending } + } + + public func importFile(from source: URL) throws -> URL { + guard source.pathExtension.lowercased() == "txt" else { throw LocalBookError.unsupportedFormat } + let values = try source.resourceValues(forKeys: [.isRegularFileKey]) + guard values.isRegularFile == true else { throw LocalFileError.missingFile } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let stem = source.deletingPathExtension().lastPathComponent + var target = directory.appendingPathComponent(source.lastPathComponent) + var suffix = 2 + while FileManager.default.fileExists(atPath: target.path) { + target = directory.appendingPathComponent("\(stem) (\(suffix)).txt") + suffix += 1 + } + try FileManager.default.copyItem(at: source, to: target) + return target + } + + public func rename(_ source: URL, to name: String) throws -> URL { + try validate(source) + let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, trimmed != ".", trimmed != "..", !trimmed.hasPrefix("."), + !trimmed.contains(where: { $0 == "/" || $0 == "\\" || $0.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) }) else { + throw LocalFileError.invalidName + } + let filename = trimmed.lowercased().hasSuffix(".txt") ? trimmed : trimmed + ".txt" + guard filename.utf8.count <= 255 else { throw LocalFileError.invalidName } + let target = directory.appendingPathComponent(filename) + if source.standardizedFileURL == target.standardizedFileURL { return source } + guard !FileManager.default.fileExists(atPath: target.path) else { throw LocalFileError.alreadyExists } + try FileManager.default.moveItem(at: source, to: target) + return target + } + + public func stageRemoval(_ source: URL) throws -> StagedLocalFile? { + try validate(source, allowMissing: true) + guard FileManager.default.fileExists(atPath: source.path) else { return nil } + let trash = directory.appendingPathComponent(".Trash", isDirectory: true) + try FileManager.default.createDirectory(at: trash, withIntermediateDirectories: true) + let staged = trash.appendingPathComponent(UUID().uuidString + ".txt") + try FileManager.default.moveItem(at: source, to: staged) + return .init(original: source, staged: staged) + } + + public func restore(_ file: StagedLocalFile) throws { + try validate(file.original, allowMissing: true) + guard file.staged.deletingLastPathComponent().standardizedFileURL == directory.appendingPathComponent(".Trash").standardizedFileURL else { + throw LocalFileError.outsideLibrary + } + try FileManager.default.moveItem(at: file.staged, to: file.original) + } + + public func finishRemoval(_ file: StagedLocalFile) throws { + guard file.staged.deletingLastPathComponent().standardizedFileURL == directory.appendingPathComponent(".Trash").standardizedFileURL else { + throw LocalFileError.outsideLibrary + } + try FileManager.default.removeItem(at: file.staged) + } + + private func validate(_ url: URL, allowMissing: Bool = false) throws { + guard url.isFileURL, url.pathExtension.lowercased() == "txt", + url.standardizedFileURL.deletingLastPathComponent() == directory, + url.resolvingSymlinksInPath().deletingLastPathComponent() == directory.resolvingSymlinksInPath() else { + throw LocalFileError.outsideLibrary + } + if !allowMissing, !FileManager.default.fileExists(atPath: url.path) { throw LocalFileError.missingFile } + } +} + +public struct BookFileExport: Identifiable, Sendable { + public var id: String { url.absoluteString } + public let url: URL + public let cachedChapters: Int + public let totalChapters: Int + public var isPartial: Bool { cachedChapters < totalChapters } +} + +public actor BookFileExporter { + private let directory: URL + private let cache: ChapterContentCache + + public init(directory: URL = FileManager.default.temporaryDirectory.appendingPathComponent("LegadoExports"), cache: ChapterContentCache = .live()) { + self.directory = directory + self.cache = cache + } + + public func originalFile(_ source: URL, filename: String) throws -> BookFileExport { + let target = try destination(filename: filename) + do { + try FileManager.default.copyItem(at: source, to: target) + return .init(url: target, cachedChapters: 0, totalChapters: 0) + } catch { + try? FileManager.default.removeItem(at: target.deletingLastPathComponent()) + throw error + } + } + + public func cachedBook(_ book: Book, chapters: [BookChapter]) async throws -> BookFileExport { + guard chapters.contains(where: { !$0.isVolume }) else { throw LocalFileError.missingDirectory } + let target = try destination(filename: book.name + ".txt") + do { + _ = FileManager.default.createFile(atPath: target.path, contents: nil) + let output = try FileHandle(forWritingTo: target) + defer { try? output.close() } + try output.write(contentsOf: Data((book.name + (book.author.isEmpty ? "" : "\n作者:" + book.author) + "\n\n").utf8)) + var cached = 0 + var total = 0 + for chapter in chapters.sorted(by: { $0.index < $1.index }) { + try Task.checkCancellation() + try output.write(contentsOf: Data((chapter.title + "\n\n").utf8)) + guard !chapter.isVolume else { continue } + total += 1 + if let entry = try? await cache.load(bookURL: book.bookUrl, chapterURL: chapter.url) { + cached += 1 + try output.write(contentsOf: Data((entry.content + "\n" + (entry.supplementalContent ?? "") + "\n\n").utf8)) + } else { + try output.write(contentsOf: Data("【本章尚未缓存】\n\n".utf8)) + } + } + guard cached > 0 else { throw LocalFileError.emptyCache } + return .init(url: target, cachedChapters: cached, totalChapters: total) + } catch { + try? FileManager.default.removeItem(at: target.deletingLastPathComponent()) + throw error + } + } + + public func discard(_ export: BookFileExport) throws { + guard export.url.deletingLastPathComponent().deletingLastPathComponent().standardizedFileURL == directory.standardizedFileURL else { + throw LocalFileError.outsideLibrary + } + try FileManager.default.removeItem(at: export.url.deletingLastPathComponent()) + } + + private func destination(filename: String) throws -> URL { + let folder = directory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + let safe = filename.components(separatedBy: CharacterSet(charactersIn: "/\\:").union(.controlCharacters)).joined(separator: "_") + let stem = String(safe.replacingOccurrences(of: ".txt", with: "", options: [.caseInsensitive, .anchored, .backwards]).prefix(70)) + return folder.appendingPathComponent((stem.isEmpty ? "书籍" : stem) + ".txt") + } +} diff --git a/Sources/LegadoCore/Networking/ApplicationSettings.swift b/Sources/LegadoCore/Networking/ApplicationSettings.swift new file mode 100644 index 0000000..455c4ab --- /dev/null +++ b/Sources/LegadoCore/Networking/ApplicationSettings.swift @@ -0,0 +1,118 @@ +import Foundation +import Network + +public struct ApplicationSettings: Codable, Equatable, Sendable { + public var userAgent = "" + public var proxy = "" + public var hosts = "" + public var timeoutSeconds = 0 + public var concurrentSources = 32 + public var chapterCacheMB = 256 + public var prefetchChapters = 1 + public var recordLog = false + public var ignoreCertificateErrors = true + + public init() {} + + public var effectiveUserAgent: String { + userAgent.isEmpty ? LegadoURLBuilder.defaultUserAgent : userAgent + } + + public func validated() throws -> Self { + var value = self + value.userAgent = userAgent.trimmingCharacters(in: .whitespacesAndNewlines) + value.proxy = proxy.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.userAgent.contains(where: { $0.isNewline || $0 == "\0" }) else { + throw SettingsError.invalid("User-Agent 不能包含换行。") + } + if !value.proxy.isEmpty { _ = try Self.proxyDictionary(value.proxy) } + _ = try Self.parseHosts(hosts) + guard timeoutSeconds == 0 || (5...300).contains(timeoutSeconds), + (1...64).contains(concurrentSources), (16...4096).contains(chapterCacheMB), + (0...20).contains(prefetchChapters) else { + throw SettingsError.invalid("网络或缓存设置超出允许范围。") + } + return value + } + + public func configure(_ configuration: URLSessionConfiguration, proxyOverride: String? = nil) throws { + configuration.httpMaximumConnectionsPerHost = concurrentSources + let address = proxyOverride ?? proxy + configuration.connectionProxyDictionary = address.isEmpty ? nil : try Self.proxyDictionary(address) + if timeoutSeconds > 0 { + configuration.timeoutIntervalForRequest = Double(timeoutSeconds) + configuration.timeoutIntervalForResource = Double(timeoutSeconds) + } + } + + public func configure(_ request: URLRequest) -> URLRequest { + var request = request + if timeoutSeconds > 0 { request.timeoutInterval = Double(timeoutSeconds) } + if request.value(forHTTPHeaderField: "User-Agent") == nil, + URLProtocol.property(forKey: "LegadoSuppressUserAgent", in: request) as? Bool != true { + request.setValue(effectiveUserAgent, forHTTPHeaderField: "User-Agent") + } + return request + } + + public static func proxyDictionary(_ address: String) throws -> [AnyHashable: Any] { + guard let url = URL(string: address), let host = url.host, !host.isEmpty, + let scheme = url.scheme?.lowercased(), ["http", "https", "socks", "socks4", "socks5"].contains(scheme), + url.user == nil, url.password == nil, url.query == nil, url.fragment == nil, + url.path.isEmpty || url.path == "/", (1...65535).contains(url.port ?? 8080) else { + throw SettingsError.invalid("代理格式应为 http://主机:端口、https://主机:端口或 socks5://主机:端口,不含账号和密码。") + } + let port = url.port ?? (scheme.hasPrefix("socks") ? 1080 : scheme == "https" ? 443 : 8080) + if scheme.hasPrefix("socks") { + return ["SOCKSEnable": 1, "SOCKSProxy": host, "SOCKSPort": port] + } + return ["HTTPEnable": 1, "HTTPProxy": host, "HTTPPort": port, + "HTTPSEnable": 1, "HTTPSProxy": host, "HTTPSPort": port] + } + + public static func parseHosts(_ text: String) throws -> [String: String] { + var values: [String: String] = [:] + for (index, line) in text.components(separatedBy: .newlines).enumerated() { + let fields = line.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)[0] + .split(whereSeparator: { $0.isWhitespace }).map(String.init) + guard !fields.isEmpty else { continue } + guard fields.count >= 2, IPv4Address(fields[0]) != nil || IPv6Address(fields[0]) != nil else { + throw SettingsError.invalid("Hosts 第 \(index + 1) 行应为 IP地址 域名,可添加 # 注释。") + } + for domain in fields.dropFirst() { + let domain = domain.lowercased() + guard domain.utf8.count <= 253, domain.split(separator: ".", omittingEmptySubsequences: false).allSatisfy({ label in + !label.isEmpty && label.count <= 63 && label.first != "-" && label.last != "-" + && label.utf8.allSatisfy { (97...122).contains($0) || (48...57).contains($0) || $0 == 45 } + }) else { throw SettingsError.invalid("Hosts 第 \(index + 1) 行的域名无效。") } + values[domain] = fields[0] + } + } + return values + } +} + +public enum SettingsError: Error, LocalizedError { + case invalid(String) + public var errorDescription: String? { if case .invalid(let message) = self { message } else { nil } } +} + +public final class ApplicationSettingsStore: @unchecked Sendable { + public static let shared = ApplicationSettingsStore() + public static let key = "legado.applicationSettings.v1" + private let defaults: UserDefaults + + public init(defaults: UserDefaults = .standard) { self.defaults = defaults } + + public var settings: ApplicationSettings { + guard let data = defaults.data(forKey: Self.key), + let value = try? JSONDecoder().decode(ApplicationSettings.self, from: data), + let validated = try? value.validated() else { return .init() } + return validated + } + + public func save(_ settings: ApplicationSettings) throws { + let validated = try settings.validated() + defaults.set(try JSONEncoder().encode(validated), forKey: Self.key) + } +} diff --git a/Sources/LegadoCore/Networking/BookSourceHeaderResolver.swift b/Sources/LegadoCore/Networking/BookSourceHeaderResolver.swift new file mode 100644 index 0000000..dd69a2f --- /dev/null +++ b/Sources/LegadoCore/Networking/BookSourceHeaderResolver.swift @@ -0,0 +1,34 @@ +import Foundation + +public struct BookSourceHeaderResolver: Sendable { + public init() {} + + public func resolve(_ source: BookSource, includeLoginHeader: Bool = true) throws -> [String: String] { + var headers: [String: String] = [:] + if let raw = source.header?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty { + let json: String + if raw.lowercased().hasPrefix("@js:") { + json = try JavaScriptRuleEvaluator().evaluate( + script: String(raw.dropFirst(4)), + result: .null, + executionContext: .init(source: source) + ).value.stringValue + } else if raw.lowercased().hasPrefix(""), raw.lowercased().hasSuffix("") { + json = try JavaScriptRuleEvaluator().evaluate( + script: String(raw.dropFirst(4).dropLast(5)), + result: .null, + executionContext: .init(source: source) + ).value.stringValue + } else { + json = raw + } + if case .object(let object) = try LegadoURLBuilder.decodeLenientJSON(json) { + headers = object.mapValues(\.stringValue) + } + } + if includeLoginHeader { + headers.merge(BookSourceRuntimeStore.shared.loginHeaders(sourceID: source.id)) { _, login in login } + } + return headers + } +} diff --git a/Sources/LegadoCore/Networking/HTTPClient.swift b/Sources/LegadoCore/Networking/HTTPClient.swift new file mode 100644 index 0000000..38b8024 --- /dev/null +++ b/Sources/LegadoCore/Networking/HTTPClient.swift @@ -0,0 +1,939 @@ +import Foundation +import CoreFoundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif + +public struct HTTPResponse: Sendable { + public var data: Data + public var statusCode: Int + public var headers: [String: String] + public var finalURL: URL + public var requestedCharset: String? + public var requestVariables: [String: String] + + public init(data: Data, statusCode: Int, headers: [String: String], finalURL: URL, requestedCharset: String? = nil, requestVariables: [String: String] = [:]) { + self.data = data + self.statusCode = statusCode + self.headers = headers + self.finalURL = finalURL + self.requestedCharset = requestedCharset + self.requestVariables = requestVariables + } + + public var decodedText: String { + let encoding = requestedCharset.flatMap(HTTPTextDecoder.encoding(named:)) + ?? contentTypeCharset.flatMap(HTTPTextDecoder.encoding(named:)) + ?? HTTPTextDecoder.bomEncoding(data) + ?? HTTPTextDecoder.htmlEncoding(data) + ?? .utf8 + if let value = String(data: HTTPTextDecoder.removingBOM(data), encoding: encoding) { return value } + if encoding != .utf8, let value = String(data: HTTPTextDecoder.removingBOM(data), encoding: .utf8) { return value } + return String(decoding: data, as: UTF8.self) + } + + public var decodedJSON: JSONValue? { + try? JSONValue.decode(data: Data(decodedText.utf8)) + } + + private var contentTypeCharset: String? { + guard let value = headers.first(where: { $0.key.caseInsensitiveCompare("Content-Type") == .orderedSame })?.value, + let expression = try? NSRegularExpression(pattern: #"charset\s*=\s*[\"']?([^;\"'\s]+)"#, options: .caseInsensitive), + let match = expression.firstMatch(in: value, range: NSRange(value.startIndex..., in: value)), + let range = Range(match.range(at: 1), in: value) else { return nil } + return String(value[range]) + } +} + +public enum HTTPTextDecoder { + public static func htmlEncoding(_ data: Data) -> String.Encoding? { + let head = String(decoding: data.prefix(16_384), as: UTF8.self) + guard let meta = try? NSRegularExpression(pattern: #"]*>"#, options: .caseInsensitive), + let charset = try? NSRegularExpression(pattern: #"charset\s*=\s*["']?\s*([a-zA-Z0-9_-]+)"#, options: .caseInsensitive) else { return nil } + for tag in meta.matches(in: head, range: NSRange(head.startIndex..., in: head)) { + guard let match = charset.firstMatch(in: head, range: tag.range), + let range = Range(match.range(at: 1), in: head), + let encoding = encoding(named: String(head[range])) else { continue } + return encoding + } + return nil + } + + public static func encoding(named rawValue: String) -> String.Encoding? { + let value = rawValue.lowercased().replacingOccurrences(of: "_", with: "-").trimmingCharacters(in: .whitespacesAndNewlines) + switch value { + case "utf-8", "utf8": return .utf8 + case "utf-16", "utf16": return .utf16 + case "utf-16le", "utf16le": return .utf16LittleEndian + case "utf-16be", "utf16be": return .utf16BigEndian + case "utf-32", "utf32": return .utf32 + case "utf-32le", "utf32le": return .utf32LittleEndian + case "utf-32be", "utf32be": return .utf32BigEndian + case "gbk", "gb2312", "gb-2312", "gb18030", "gb-18030": + return foundationEncoding(.GB_18030_2000) + case "big5", "big-5": return foundationEncoding(.big5) + case "shift-jis", "shiftjis", "sjis": return .shiftJIS + case "iso-8859-1", "latin1": return .isoLatin1 + case "windows-1252", "cp1252": return .windowsCP1252 + default: return nil + } + } + + private static func foundationEncoding(_ value: CFStringEncodings) -> String.Encoding { + .init(rawValue: CFStringConvertEncodingToNSStringEncoding(CFStringEncoding(value.rawValue))) + } + + public static func bomEncoding(_ data: Data) -> String.Encoding? { + let prefix = Array(data.prefix(4)) + if prefix.starts(with: [0xEF, 0xBB, 0xBF]) { return .utf8 } + if prefix.starts(with: [0xFF, 0xFE, 0x00, 0x00]) { return .utf32LittleEndian } + if prefix.starts(with: [0x00, 0x00, 0xFE, 0xFF]) { return .utf32BigEndian } + if prefix.starts(with: [0xFF, 0xFE]) { return .utf16LittleEndian } + if prefix.starts(with: [0xFE, 0xFF]) { return .utf16BigEndian } + return nil + } + + public static func removingBOM(_ data: Data) -> Data { + let bytes = Array(data.prefix(4)) + if bytes.starts(with: [0xEF, 0xBB, 0xBF]) { return data.dropFirst(3) } + if bytes.starts(with: [0xFF, 0xFE, 0x00, 0x00]) || bytes.starts(with: [0x00, 0x00, 0xFE, 0xFF]) { return data.dropFirst(4) } + if bytes.starts(with: [0xFF, 0xFE]) || bytes.starts(with: [0xFE, 0xFF]) { return data.dropFirst(2) } + return data + } +} + +public protocol HTTPTransport: Sendable { + func data(for request: URLRequest) async throws -> HTTPResponse +} + +public final class LegadoUnsafeTrustDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + public static let shared = LegadoUnsafeTrustDelegate() + private let settingsStore: ApplicationSettingsStore + private let interceptHostsRedirects: Bool + + public init(settingsStore: ApplicationSettingsStore = .shared, interceptHostsRedirects: Bool = true) { + self.settingsStore = settingsStore + self.interceptHostsRedirects = interceptHostsRedirects + super.init() + } + + public func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge + ) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + Self.resolution(for: challenge, ignoreCertificateErrors: settingsStore.settings.ignoreCertificateErrors) + } + + public func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping @Sendable (URLRequest?) -> Void + ) { + let settings = settingsStore.settings + let address = try? ApplicationSettings.parseHosts(settings.hosts)[request.url?.host?.lowercased() ?? ""] + completionHandler(interceptHostsRedirects && settings.proxy.isEmpty && address != nil ? nil : request) + } + + public static func resolve( + _ challenge: URLAuthenticationChallenge, + completionHandler: @escaping @Sendable (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + let result = resolution(for: challenge) + completionHandler(result.0, result.1) + } + + static func resolution( + for challenge: URLAuthenticationChallenge, + ignoreCertificateErrors: Bool = ApplicationSettingsStore.shared.settings.ignoreCertificateErrors + ) -> (URLSession.AuthChallengeDisposition, URLCredential?) { + let disposition: URLSession.AuthChallengeDisposition + let credential: URLCredential? + if ignoreCertificateErrors, + challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust, + let trust = challenge.protectionSpace.serverTrust { + disposition = .useCredential + credential = URLCredential(trust: trust) + } else { + disposition = .performDefaultHandling + credential = nil + } + return (disposition, credential) + } +} + +public struct HTTPStreamResponse: Sendable { + public var statusCode: Int + public var headers: [String: String] + public var finalURL: URL + public var expectedContentLength: Int64? + public var chunks: AsyncThrowingStream + + public init( + statusCode: Int, + headers: [String: String], + finalURL: URL, + expectedContentLength: Int64? = nil, + chunks: AsyncThrowingStream + ) { + self.statusCode = statusCode + self.headers = headers + self.finalURL = finalURL + self.expectedContentLength = expectedContentLength + self.chunks = chunks + } +} + +public protocol StreamingHTTPTransport: HTTPTransport { + func stream(for request: URLRequest) async throws -> HTTPStreamResponse +} + +public protocol ProxyHTTPTransport: HTTPTransport { + func data(for request: URLRequest, proxy: String) async throws -> HTTPResponse +} + +public enum HTTPClientError: Error, LocalizedError, Sendable { + case nonHTTPResponse + case statusCode(Int) + case statusResponse(HTTPResponse) + + public var errorDescription: String? { + switch self { + case .nonHTTPResponse: "服务器没有返回 HTTP 响应" + case .statusCode(let code): "服务器返回 HTTP \(code)" + case .statusResponse(let response): "服务器返回 HTTP \(response.statusCode)" + } + } +} + +public final class URLSessionHTTPTransport: ProxyHTTPTransport, StreamingHTTPTransport, @unchecked Sendable { + public static let shared = URLSessionHTTPTransport() + private let configuration: URLSessionConfiguration + private let settingsStore: ApplicationSettingsStore + private let lock = NSLock() + private var cachedSettings: ApplicationSettings? + private var cachedSession: URLSession? + + public init(configuration: URLSessionConfiguration = .ephemeral, settingsStore: ApplicationSettingsStore = .shared) { + self.configuration = configuration.copy() as! URLSessionConfiguration + self.settingsStore = settingsStore + self.configuration.httpCookieAcceptPolicy = .always + self.configuration.httpShouldSetCookies = true + self.configuration.httpCookieStorage = HTTPCookieStorage.shared + self.configuration.requestCachePolicy = .reloadRevalidatingCacheData + } + + deinit { cachedSession?.finishTasksAndInvalidate() } + + private func configuredSession(settings: ApplicationSettings) throws -> URLSession { + lock.lock() + defer { lock.unlock() } + if settings == cachedSettings, let cachedSession { return cachedSession } + let config = configuration.copy() as! URLSessionConfiguration + try settings.configure(config) + let session = URLSession(configuration: config, delegate: LegadoUnsafeTrustDelegate(settingsStore: settingsStore), delegateQueue: nil) + cachedSession?.finishTasksAndInvalidate() + cachedSession = session + cachedSettings = settings + return session + } + + public func data(for request: URLRequest) async throws -> HTTPResponse { + try await load(request, redirectsRemaining: 10) + } + + private func load(_ original: URLRequest, redirectsRemaining: Int) async throws -> HTTPResponse { + try Task.checkCancellation() + let settings = settingsStore.settings + let request = settings.configure(original) + let started = Date() + do { + let response: HTTPResponse + if settings.proxy.isEmpty, + let address = try ApplicationSettings.parseHosts(settings.hosts)[request.url?.host?.lowercased() ?? ""] { + response = try await HostsHTTPTransport.data(for: request, address: address, ignoreCertificateErrors: settings.ignoreCertificateErrors) + } else { + let session = try configuredSession(settings: settings) + response = try await rawData(for: request, session: session) + } + if [301, 302, 303, 307, 308].contains(response.statusCode), + let location = response.headers.first(where: { $0.key.lowercased() == "location" })?.value, + let target = URL(string: location, relativeTo: response.finalURL)?.absoluteURL { + guard redirectsRemaining > 0 else { throw URLError(.httpTooManyRedirects) } + var next = request + next.url = target + if response.statusCode == 303 || ([301, 302].contains(response.statusCode) && next.httpMethod == "POST") { + next.httpMethod = "GET"; next.httpBody = nil + next.setValue(nil, forHTTPHeaderField: "Content-Length") + next.setValue(nil, forHTTPHeaderField: "Content-Type") + } + if target.host?.lowercased() != request.url?.host?.lowercased() + || target.scheme?.lowercased() != request.url?.scheme?.lowercased() + || target.port != request.url?.port { + next.setValue(nil, forHTTPHeaderField: "Authorization") + next.setValue(nil, forHTTPHeaderField: "Cookie") + } + next.setValue(nil, forHTTPHeaderField: "Host") + DiagnosticLog.shared.network(request, status: response.statusCode, startedAt: started) + return try await load(next, redirectsRemaining: redirectsRemaining - 1) + } + guard (200...299).contains(response.statusCode) else { throw HTTPClientError.statusResponse(response) } + DiagnosticLog.shared.network(request, status: response.statusCode, startedAt: started) + return response + } catch { + DiagnosticLog.shared.network(request, error: error, startedAt: started) + throw error + } + } + + public func download(for request: URLRequest) async throws -> (Data, URLResponse) { + let value = try await data(for: request) + guard let response = HTTPURLResponse(url: value.finalURL, statusCode: value.statusCode, httpVersion: nil, headerFields: value.headers) else { + throw HTTPClientError.nonHTTPResponse + } + return (value.data, response) + } + + public func stream(for request: URLRequest) async throws -> HTTPStreamResponse { + let settings = settingsStore.settings + let request = settings.configure(request) + if !settings.hosts.isEmpty, settings.proxy.isEmpty { + let response = try await data(for: request) + return .init(statusCode: response.statusCode, headers: response.headers, finalURL: response.finalURL, + expectedContentLength: Int64(response.data.count), chunks: AsyncThrowingStream { continuation in + continuation.yield(response.data) + continuation.finish() + }) + } + let session = try configuredSession(settings: settings) + let started = Date() + let bytes: URLSession.AsyncBytes + let response: URLResponse + do { + (bytes, response) = try await session.bytes(for: request) + } catch { + DiagnosticLog.shared.network(request, error: error, startedAt: started) + throw error + } + guard let http = response as? HTTPURLResponse, let finalURL = http.url else { + throw HTTPClientError.nonHTTPResponse + } + let headers = Self.headers(from: http) + DiagnosticLog.shared.network(request, status: http.statusCode, startedAt: started) + guard (200 ... 299).contains(http.statusCode) else { + var body = Data() + for try await byte in bytes { body.append(byte) } + throw HTTPClientError.statusResponse(.init( + data: body, + statusCode: http.statusCode, + headers: headers, + finalURL: finalURL + )) + } + let chunks = AsyncThrowingStream { continuation in + let task = Task { + do { + var chunk = Data() + chunk.reserveCapacity(32 * 1_024) + for try await byte in bytes { + try Task.checkCancellation() + chunk.append(byte) + if chunk.count >= 32 * 1_024 { + continuation.yield(chunk) + chunk.removeAll(keepingCapacity: true) + } + } + if !chunk.isEmpty { continuation.yield(chunk) } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + return .init( + statusCode: http.statusCode, + headers: headers, + finalURL: finalURL, + expectedContentLength: http.expectedContentLength >= 0 ? http.expectedContentLength : nil, + chunks: chunks + ) + } + + public func data(for original: URLRequest, proxy: String) async throws -> HTTPResponse { + let settings = settingsStore.settings + let config = configuration.copy() as! URLSessionConfiguration + try settings.configure(config, proxyOverride: proxy) + let session = URLSession(configuration: config, delegate: LegadoUnsafeTrustDelegate(settingsStore: settingsStore, interceptHostsRedirects: false), delegateQueue: nil) + defer { session.finishTasksAndInvalidate() } + let request = settings.configure(original) + let started = Date() + do { + let response = try await rawData(for: request, session: session) + guard (200...299).contains(response.statusCode) else { throw HTTPClientError.statusResponse(response) } + DiagnosticLog.shared.network(request, status: response.statusCode, startedAt: started) + return response + } catch { + DiagnosticLog.shared.network(request, error: error, startedAt: started) + throw error + } + } + + private func rawData(for request: URLRequest, session: URLSession) async throws -> HTTPResponse { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, let url = http.url else { throw HTTPClientError.nonHTTPResponse } + return .init(data: data, statusCode: http.statusCode, headers: Self.headers(from: http), finalURL: url) + } + + private static func headers(from response: HTTPURLResponse) -> [String: String] { + response.allHeaderFields.reduce(into: [:]) { result, entry in + result[String(describing: entry.key)] = String(describing: entry.value) + } + } +} + +public final class LegadoCookieStore: @unchecked Sendable { + public static let shared = LegadoCookieStore() + + private let storage: HTTPCookieStorage + private let lock = NSLock() + private let persistent = PersistentRuntimeCache.shared + + public init(storage: HTTPCookieStorage = .shared) { + self.storage = storage + } + + public func cookieHeader(for value: String) -> String { + guard let url = normalizedURL(value) else { return "" } + lock.lock() + let cookies = storage.cookies(for: url) ?? [] + lock.unlock() + let session = HTTPCookie.requestHeaderFields(with: cookies)["Cookie"] ?? "" + let saved = persistent.cookie(for: persistenceKey(url)) ?? "" + return mergeCookieHeaders(saved, session) + } + + public func cookie(for value: String, key: String) -> String { + guard let url = normalizedURL(value) else { return "" } + lock.lock() + let result = storage.cookies(for: url)?.first(where: { $0.name == key })?.value + lock.unlock() + if let result { return result } + return cookieMap(persistent.cookie(for: persistenceKey(url)) ?? "")[key] ?? "" + } + + public func setCookie(_ cookie: String, for value: String) { + guard let url = normalizedURL(value), !cookie.isEmpty else { return } + var cookies = HTTPCookie.cookies(withResponseHeaderFields: ["Set-Cookie": cookie], for: url) + if cookies.isEmpty, + let host = url.host, + let first = cookie.split(separator: ";").first { + let pair = first.split(separator: "=", maxSplits: 1).map(String.init) + if pair.count == 2, let fallback = HTTPCookie(properties: [ + .name: pair[0].trimmingCharacters(in: .whitespaces), + .value: pair[1], + .domain: host, + .path: "/", + .secure: url.scheme == "https" ? "TRUE" : "FALSE" + ]) { + cookies = [fallback] + } + } + lock.lock() + cookies.forEach(storage.setCookie) + lock.unlock() + let old = persistent.cookie(for: persistenceKey(url)) ?? "" + persistent.putCookie( + key: persistenceKey(url), + value: mergeCookieHeaders(old, persistentCookieHeader(cookie)) + ) + } + + public func removeCookies(for value: String) { + guard let url = normalizedURL(value) else { return } + lock.lock() + storage.cookies(for: url)?.forEach(storage.deleteCookie) + lock.unlock() + persistent.deleteCookie(key: persistenceKey(url)) + } + + private func normalizedURL(_ value: String) -> URL? { + if let url = URL(string: value), url.scheme != nil { return url } + return URL(string: "https://\(value)") + } + + private func persistenceKey(_ url: URL) -> String { + guard let host = url.host?.lowercased() else { return url.absoluteString } + return Self.persistenceDomain(for: host) + } + + static func persistenceDomain(for host: String) -> String { + let host = host.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".")) + guard !host.isEmpty, !isIPAddress(host) else { return host } + let labels = host.split(separator: ".").map(String.init) + guard labels.count > 2 else { return host } + let lastTwo = labels.suffix(2).joined(separator: ".") + let publicSecondLevels: Set = [ + "com.cn", "net.cn", "org.cn", "gov.cn", "edu.cn", "ac.cn", + "com.hk", "net.hk", "org.hk", "com.tw", "net.tw", "org.tw", + "co.uk", "org.uk", "me.uk", "ac.uk", "gov.uk", + "co.jp", "ne.jp", "or.jp", "ac.jp", "go.jp", + "com.au", "net.au", "org.au", "edu.au", "gov.au", + "co.nz", "net.nz", "org.nz", "co.kr", "or.kr", "ne.kr", + "co.in", "firm.in", "net.in", "org.in", "gen.in", "ind.in", + "com.sg", "net.sg", "org.sg", "com.br", "net.br", "org.br" + ] + return publicSecondLevels.contains(lastTwo) + ? labels.suffix(3).joined(separator: ".") + : lastTwo + } + + private static func isIPAddress(_ host: String) -> Bool { + if host.contains(":") { return host.allSatisfy { $0.isHexDigit || $0 == ":" || $0 == "." } } + let parts = host.split(separator: ".") + return parts.count == 4 && parts.allSatisfy { part in + guard let value = Int(part) else { return false } + return (0...255).contains(value) + } + } + + private func cookieMap(_ header: String) -> [String: String] { + var result: [String: String] = [:] + for part in header.split(separator: ";") { + let pair = part.split(separator: "=", maxSplits: 1).map(String.init) + guard pair.count == 2 else { continue } + result[pair[0].trimmingCharacters(in: .whitespaces)] = pair[1].trimmingCharacters(in: .whitespaces) + } + return result + } + + private func mergeCookieHeaders(_ headers: String...) -> String { + var values: [String: String] = [:] + var order: [String] = [] + for header in headers { + for part in header.split(separator: ";") { + let pair = part.split(separator: "=", maxSplits: 1).map(String.init) + guard pair.count == 2 else { continue } + let name = pair[0].trimmingCharacters(in: .whitespaces) + if values[name] == nil { order.append(name) } + values[name] = pair[1].trimmingCharacters(in: .whitespaces) + } + } + return order.compactMap { name in values[name].map { "\(name)=\($0)" } }.joined(separator: "; ") + } + + private func persistentCookieHeader(_ header: String) -> String { + let attributes = Set(["path", "domain", "expires", "max-age", "samesite"]) + return header.split(separator: ";").compactMap { part -> String? in + let pair = part.split(separator: "=", maxSplits: 1).map(String.init) + guard pair.count == 2 else { return nil } + let name = pair[0].trimmingCharacters(in: .whitespaces) + guard !attributes.contains(name.lowercased()) else { return nil } + return "\(name)=\(pair[1].trimmingCharacters(in: .whitespaces))" + }.joined(separator: "; ") + } +} + +public protocol SourceWebViewRequestExecuting: Sendable { + @MainActor + func execute(_ prepared: PreparedRequest, source: BookSource, timeout: TimeInterval) async throws -> HTTPResponse +} + +public final class SourceWebViewExecutorRegistry: @unchecked Sendable { + public static let shared = SourceWebViewExecutorRegistry() + + private let lock = NSLock() + private var storedExecutor: (any SourceWebViewRequestExecuting)? + + public init() {} + + public func register(_ executor: any SourceWebViewRequestExecuting) { + lock.lock() + storedExecutor = executor + lock.unlock() + } + + public func executor() -> (any SourceWebViewRequestExecuting)? { + lock.lock() + let value = storedExecutor + lock.unlock() + return value + } +} + +public struct SourceRateLimit: Sendable, Equatable { + public var accessLimit: Int + public var intervalMilliseconds: Int64 + + public init?(rawValue: String?) { + guard let rawValue else { return nil } + let value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty, value != "0" else { return nil } + if let slash = value.firstIndex(of: "/"), + let count = Int(value[.. 0, interval > 0 { + accessLimit = count + intervalMilliseconds = interval + } else if let interval = Int64(value), interval > 0 { + accessLimit = 1 + intervalMilliseconds = interval + } else { + return nil + } + } +} + +public struct SourceRateWindow: Sendable, Equatable { + public var startedAt: Int64 + public var frequency: Int + + public init(startedAt: Int64 = 0, frequency: Int = 0) { + self.startedAt = startedAt + self.frequency = frequency + } + + public mutating func reserve(now: Int64, limit: SourceRateLimit) -> Int64 { + if frequency == 0 || now >= startedAt + limit.intervalMilliseconds { + startedAt = now + frequency = 1 + return 0 + } + if frequency < limit.accessLimit { + frequency += 1 + return 0 + } + return max(startedAt + limit.intervalMilliseconds - now, 0) + } +} + +public actor SourceNetworkPolicy { + public static let shared = SourceNetworkPolicy() + + private struct StoredCookie: Sendable { + var name: String + var value: String + var domain: String + var path: String + var expiresAt: Date? + var secure: Bool + } + + private var windows: [String: SourceRateWindow] = [:] + private var cookies: [String: [StoredCookie]] = [:] + private let javaScript: JavaScriptRuleEvaluator + + public init(javaScript: JavaScriptRuleEvaluator = .init()) { self.javaScript = javaScript } + + public func execute( + _ prepared: PreparedRequest, + source: BookSource, + transport: any HTTPTransport, + timeout: TimeInterval, + webViewExecutor: (any SourceWebViewRequestExecuting)? = nil, + snapshotRecorder: (any DifferentialSnapshotRecording)? = nil, + snapshotStage: DifferentialStage = .debug + ) async throws -> HTTPResponse { + try await acquire(source: source) + if prepared.url.scheme?.lowercased() == "data" { + do { + let response = try dataURIResponse(for: prepared) + let transformed = try await transformBodyIfNeeded(response, prepared: prepared, source: source) + await snapshotRecorder?.record( + stage: snapshotStage, + sourceID: source.id, + prepared: prepared, + rawResponse: response, + response: transformed, + timeout: timeout + ) + return transformed + } catch { + await snapshotRecorder?.recordFailure( + stage: snapshotStage, + sourceID: source.id, + prepared: prepared, + response: nil, + error: error, + timeout: timeout + ) + throw error + } + } + if prepared.useWebView || prepared.webJavaScript != nil { + guard let executor = webViewExecutor ?? SourceWebViewExecutorRegistry.shared.executor() else { + throw RuleEngineError.unsupportedRule(rule: prepared.ruleURL, feature: "后台 WKWebView 请求") + } + return try await executeWebView( + prepared, + source: source, + executor: executor, + timeout: timeout, + snapshotRecorder: snapshotRecorder, + snapshotStage: snapshotStage + ) + } + var request = prepared.urlRequest(timeout: timeout, handleCookies: false) + if source.enabledCookieJar == true { + let stored = cookieHeader(sourceID: source.id, url: prepared.url) + let explicit = request.value(forHTTPHeaderField: "Cookie") ?? "" + let merged = mergeCookieHeaders(stored, explicit) + if !merged.isEmpty { request.setValue(merged, forHTTPHeaderField: "Cookie") } + } + + let retryCount = max(prepared.retry, 0) + let attempts = retryCount == Int.max ? Int.max : retryCount + 1 + var lastError: Error = HTTPClientError.nonHTTPResponse + for attempt in 0.. HTTPStreamResponse { + guard prepared.url.scheme?.lowercased() != "data", + !prepared.useWebView, + prepared.webJavaScript == nil, + prepared.bodyJavaScript == nil, + prepared.responseType == nil else { + throw RuleEngineError.unsupportedRule(rule: prepared.ruleURL, feature: "流式响应转换") + } + guard prepared.proxy == nil else { + throw RuleEngineError.unsupportedRule(rule: prepared.ruleURL, feature: "流式代理请求") + } + try await acquire(source: source) + var request = prepared.urlRequest(timeout: timeout, handleCookies: false) + if source.enabledCookieJar == true { + let stored = cookieHeader(sourceID: source.id, url: prepared.url) + let explicit = request.value(forHTTPHeaderField: "Cookie") ?? "" + let merged = mergeCookieHeaders(stored, explicit) + if !merged.isEmpty { request.setValue(merged, forHTTPHeaderField: "Cookie") } + } + let response = try await transport.stream(for: request) + if source.enabledCookieJar == true { + captureCookies( + response: .init( + data: Data(), + statusCode: response.statusCode, + headers: response.headers, + finalURL: response.finalURL + ), + sourceID: source.id + ) + } + return response + } + + private func executeWebView( + _ prepared: PreparedRequest, + source: BookSource, + executor: any SourceWebViewRequestExecuting, + timeout: TimeInterval, + snapshotRecorder: (any DifferentialSnapshotRecording)?, + snapshotStage: DifferentialStage + ) async throws -> HTTPResponse { + let started = Date() + var webPrepared = prepared + var request = prepared.urlRequest(timeout: timeout, handleCookies: false) + if source.enabledCookieJar == true { + let stored = cookieHeader(sourceID: source.id, url: prepared.url) + let explicit = request.value(forHTTPHeaderField: "Cookie") ?? "" + let merged = mergeCookieHeaders(stored, explicit) + if !merged.isEmpty { + request.setValue(merged, forHTTPHeaderField: "Cookie") + webPrepared.headers["Cookie"] = merged + } + } + let retryCount = max(prepared.retry, 0) + let attempts = retryCount == Int.max ? Int.max : retryCount + 1 + var lastError: Error = HTTPClientError.nonHTTPResponse + for attempt in 0.. HTTPResponse { + var response = original + response.requestVariables = prepared.variables + if prepared.responseType != nil { + response.data = Data(original.data.map { String(format: "%02x", $0) }.joined().utf8) + response.requestedCharset = "utf-8" + return response + } + guard let script = prepared.bodyJavaScript else { return response } + let transformed = try await javaScript.evaluateAsync( + script: script, + result: .string(response.decodedText), + executionContext: .init( + variables: response.requestVariables, + baseURL: response.finalURL.absoluteString, + key: prepared.key, + page: prepared.page, + source: source, + book: prepared.book, + chapter: prepared.chapter + ) + ) + response.data = Data(transformed.value.stringValue.utf8) + response.requestedCharset = "utf-8" + response.requestVariables = transformed.variables + return response + } + + public func cookieHeader(sourceID: String, url: URL) -> String { + let now = Date() + let host = url.host?.lowercased() ?? "" + let path = url.path.isEmpty ? "/" : url.path + let values = (cookies[sourceID] ?? []).filter { cookie in + (cookie.expiresAt == nil || cookie.expiresAt! > now) + && domainMatches(host: host, domain: cookie.domain) + && path.hasPrefix(cookie.path) + && (!cookie.secure || url.scheme?.lowercased() == "https") + } + return values.map { "\($0.name)=\($0.value)" }.joined(separator: "; ") + } + + public func setCookieHeader(_ header: String, sourceID: String, url: URL) { + for part in header.split(separator: ";") { + let pair = part.split(separator: "=", maxSplits: 1).map(String.init) + guard pair.count == 2 else { continue } + upsert(.init(name: pair[0].trimmingCharacters(in: .whitespaces), value: pair[1], domain: url.host ?? "", path: "/", expiresAt: nil, secure: url.scheme == "https"), sourceID: sourceID) + } + } + + public func removeCookies(sourceID: String) { cookies[sourceID] = nil } + + private func acquire(source: BookSource) async throws { + guard let limit = SourceRateLimit(rawValue: source.concurrentRate) else { return } + while true { + let now = Int64(Date().timeIntervalSince1970 * 1_000) + var window = windows[source.id] ?? .init() + let wait = window.reserve(now: now, limit: limit) + windows[source.id] = window + guard wait > 0 else { return } + try await Task.sleep(nanoseconds: UInt64(wait) * 1_000_000) + } + } + + private func captureCookies(response: HTTPResponse, sourceID: String) { + guard let header = response.headers.first(where: { $0.key.caseInsensitiveCompare("Set-Cookie") == .orderedSame })?.value else { return } + LegadoCookieStore.shared.setCookie(header, for: response.finalURL.absoluteString) + for cookie in HTTPCookie.cookies(withResponseHeaderFields: ["Set-Cookie": header], for: response.finalURL) { + upsert(.init(name: cookie.name, value: cookie.value, domain: cookie.domain, path: cookie.path, expiresAt: cookie.expiresDate, secure: cookie.isSecure), sourceID: sourceID) + } + } + + private func dataURIResponse(for prepared: PreparedRequest) throws -> HTTPResponse { + let value = prepared.url.absoluteString + guard value.lowercased().hasPrefix("data:"), + let comma = value.firstIndex(of: ",") else { + throw RuleEngineError.malformedRule(rule: prepared.ruleURL, detail: "data URI 无效") + } + let metadata = String(value[value.index(value.startIndex, offsetBy: 5).. Bool { + let normalized = domain.lowercased().trimmingCharacters(in: CharacterSet(charactersIn: ".")) + return host == normalized || host.hasSuffix(".\(normalized)") + } + + private func mergeCookieHeaders(_ headers: String...) -> String { + var values: [String: String] = [:] + var order: [String] = [] + for header in headers { + for part in header.split(separator: ";") { + let pair = part.split(separator: "=", maxSplits: 1).map(String.init) + guard pair.count == 2 else { continue } + let name = pair[0].trimmingCharacters(in: .whitespaces) + if values[name] == nil { order.append(name) } + values[name] = pair[1].trimmingCharacters(in: .whitespaces) + } + } + return order.compactMap { name in values[name].map { "\(name)=\($0)" } }.joined(separator: "; ") + } +} diff --git a/Sources/LegadoCore/Networking/HostsHTTPTransport.swift b/Sources/LegadoCore/Networking/HostsHTTPTransport.swift new file mode 100644 index 0000000..fbe916f --- /dev/null +++ b/Sources/LegadoCore/Networking/HostsHTTPTransport.swift @@ -0,0 +1,198 @@ +import Foundation +import Network +import Security + +public enum HostsHTTPTransport { + public static func data(for request: URLRequest, address: String, ignoreCertificateErrors: Bool) async throws -> HTTPResponse { + let operation = try HostsRequestOperation(request: request, address: address, ignoreCertificateErrors: ignoreCertificateErrors) + return try await withTaskCancellationHandler { + try await operation.run() + } onCancel: { operation.cancel() } + } +} + +private final class HostsRequestOperation: @unchecked Sendable { + private let queue = DispatchQueue(label: "legado.hosts.\(UUID().uuidString)") + private let connection: NWConnection + private let request: URLRequest + private var continuation: CheckedContinuation? + private var buffer = Data() + private var finished = false + private var cancelled = false + + init(request: URLRequest, address: String, ignoreCertificateErrors: Bool) throws { + guard let url = request.url, let host = url.host, + ["http", "https"].contains(url.scheme?.lowercased() ?? ""), + let port = NWEndpoint.Port(rawValue: UInt16(exactly: url.port ?? (url.scheme == "https" ? 443 : 80)) ?? 0), + port.rawValue > 0 else { throw URLError(.badURL) } + self.request = request + let parameters: NWParameters + if url.scheme?.lowercased() == "https" { + let tls = NWProtocolTLS.Options() + sec_protocol_options_set_tls_server_name(tls.securityProtocolOptions, host) + sec_protocol_options_add_tls_application_protocol(tls.securityProtocolOptions, "http/1.1") + if ignoreCertificateErrors { + sec_protocol_options_set_verify_block(tls.securityProtocolOptions, { _, _, complete in complete(true) }, queue) + } + parameters = NWParameters(tls: tls, tcp: NWProtocolTCP.Options()) + } else { parameters = .tcp } + connection = NWConnection(host: NWEndpoint.Host(address), port: port, using: parameters) + } + + func run() async throws -> HTTPResponse { + try await withCheckedThrowingContinuation { continuation in + queue.async { + self.continuation = continuation + if self.cancelled { self.finish(.failure(CancellationError())); return } + self.connection.stateUpdateHandler = { [weak self] state in + guard let self, !self.finished else { return } + switch state { + case .ready: self.send() + case .failed(let error): self.finish(.failure(error)) + case .cancelled: self.finish(.failure(CancellationError())) + default: break + } + } + self.connection.start(queue: self.queue) + self.queue.asyncAfter(deadline: .now() + max(1, self.request.timeoutInterval)) { [weak self] in + self?.finish(.failure(URLError(.timedOut))) + } + } + } + } + + func cancel() { + queue.async { + self.cancelled = true + if self.continuation != nil { self.finish(.failure(CancellationError())) } + } + } + + private func send() { + guard let url = request.url, let host = url.host, + let parts = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + finish(.failure(URLError(.badURL))); return + } + let path = (parts.percentEncodedPath.isEmpty ? "/" : parts.percentEncodedPath) + + (parts.percentEncodedQuery.map { "?\($0)" } ?? "") + var headers = request.allHTTPHeaderFields ?? [:] + for name in headers.keys.filter({ ["host", "connection", "accept-encoding", "content-length", "transfer-encoding"].contains($0.lowercased()) }) { + headers.removeValue(forKey: name) + } + let hostField = host.contains(":") ? "[\(host)]" : host + headers["Host"] = hostField + (url.port.map { ":\($0)" } ?? "") + headers["Connection"] = "close" + headers["Accept-Encoding"] = "identity" + let body = request.httpBody ?? Data() + if !body.isEmpty || request.httpMethod == "POST" || request.httpMethod == "PUT" { + headers["Content-Length"] = String(body.count) + } + if request.httpShouldHandleCookies, headers.keys.contains(where: { $0.lowercased() == "cookie" }) == false { + headers.merge(HTTPCookie.requestHeaderFields(with: HTTPCookieStorage.shared.cookies(for: url) ?? [])) { explicit, _ in explicit } + } + let method = request.httpMethod ?? "GET" + guard !method.contains(where: { $0.isWhitespace }), !path.contains(where: { $0.isNewline }), + headers.allSatisfy({ !$0.key.contains(where: { $0.isNewline }) && !$0.value.contains(where: { $0.isNewline }) }) else { + finish(.failure(URLError(.badURL))); return + } + let head = "\(method) \(path) HTTP/1.1\r\n" + + headers.map { "\($0.key): \($0.value)\r\n" }.joined() + "\r\n" + var data = Data(head.utf8) + data.append(body) + connection.send(content: data, completion: .contentProcessed { [weak self] error in + if let error { self?.finish(.failure(error)) } else { self?.receive() } + }) + } + + private func receive() { + connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { [weak self] data, _, complete, error in + guard let self, !self.finished else { return } + if let error { self.finish(.failure(error)); return } + if let data { self.buffer.append(data) } + do { + if let response = try HostsHTTPResponseDecoder.decode(self.buffer, url: self.request.url!, method: self.request.httpMethod ?? "GET", complete: complete) { + if self.request.httpShouldHandleCookies { + let cookies = HTTPCookie.cookies(withResponseHeaderFields: response.headers, for: response.finalURL) + cookies.forEach { HTTPCookieStorage.shared.setCookie($0) } + } + self.finish(.success(response)) + } else if complete { self.finish(.failure(URLError(.badServerResponse))) } + else { self.receive() } + } catch { self.finish(.failure(error)) } + } + } + + private func finish(_ result: Result) { + guard !finished else { return } + finished = true + connection.stateUpdateHandler = nil + connection.cancel() + continuation?.resume(with: result) + continuation = nil + } +} + +public enum HostsHTTPResponseDecoder { + public static func decode(_ data: Data, url: URL, method: String = "GET", complete: Bool = false) throws -> HTTPResponse? { + guard data.count <= 64 * 1024 * 1024 else { throw URLError(.dataLengthExceedsMaximum) } + guard let boundary = data.range(of: Data("\r\n\r\n".utf8)) else { + if data.count > 64 * 1024 { throw URLError(.badServerResponse) } + return nil + } + guard boundary.lowerBound <= 64 * 1024 else { throw URLError(.badServerResponse) } + let lines = String(decoding: data[..= 0, length <= 64 * 1024 * 1024 else { throw URLError(.badServerResponse) } + guard remaining.count >= length else { return nil } + body = Data(remaining.prefix(length)) + } else { + guard complete else { return nil } + body = remaining + } + guard headers["content-encoding"] == nil || headers["content-encoding"] == "identity" || body.isEmpty else { + throw SettingsError.invalid("Hosts 请求收到不支持的压缩响应,请移除此域名的 Hosts 覆盖后重试。") + } + return .init(data: body, statusCode: status, headers: headers, finalURL: url) + } + + private static func decodeChunks(_ data: Data) throws -> Data? { + var offset = data.startIndex + var body = Data() + let newline = Data("\r\n".utf8) + while offset < data.endIndex { + guard let line = data.range(of: newline, in: offset..= 0, + count <= 64 * 1024 * 1024 else { throw URLError(.badServerResponse) } + offset = line.upperBound + if count == 0 { + if data[offset...].starts(with: newline) || data.range(of: Data("\r\n\r\n".utf8), in: offset..= count + 2 else { return nil } + body.append(data[offset..<(offset + count)]) + offset += count + guard data[offset..<(offset + 2)] == newline else { throw URLError(.badServerResponse) } + offset += 2 + } + return nil + } +} diff --git a/Sources/LegadoCore/Networking/LegadoURLBuilder.swift b/Sources/LegadoCore/Networking/LegadoURLBuilder.swift new file mode 100644 index 0000000..1e4dc49 --- /dev/null +++ b/Sources/LegadoCore/Networking/LegadoURLBuilder.swift @@ -0,0 +1,646 @@ +import Foundation + +public enum HTTPMethod: String, Codable, Sendable { + case get = "GET" + case post = "POST" + case head = "HEAD" +} + +public struct URLTemplateContext: Sendable { + public var key: String? + public var page: Int? + public var variables: [String: String] + public var source: BookSource? + public var book: Book? + public var chapter: BookChapter? + + public init( + key: String? = nil, + page: Int? = nil, + variables: [String: String] = [:], + source: BookSource? = nil, + book: Book? = nil, + chapter: BookChapter? = nil + ) { + self.key = key + self.page = page + self.variables = variables + self.source = source + self.book = book + self.chapter = chapter + } + + fileprivate func value(for expression: String) -> String? { + switch expression.trimmingCharacters(in: .whitespacesAndNewlines) { + case "key", "searchKey": key + case "page", "searchPage": page.map(String.init) + default: variables[expression.trimmingCharacters(in: .whitespacesAndNewlines)] + } + } +} + +public struct PreparedRequest: Sendable { + public var suppressesUserAgent: Bool + public var ruleURL: String + public var url: URL + public var method: HTTPMethod + public var headers: [String: String] + public var body: Data? + public var charset: String? + public var retry: Int + public var useWebView: Bool + public var webJavaScript: String? + public var bodyJavaScript: String? + public var webViewDelayMilliseconds: Int + public var responseType: String? + public var proxy: String? + public var dnsIP: String? + public var serverID: Int64? + public var variables: [String: String] + public var key: String? + public var page: Int? + public var book: Book? + public var chapter: BookChapter? + + public init( + ruleURL: String, + url: URL, + method: HTTPMethod, + headers: [String: String], + body: Data?, + charset: String?, + retry: Int, + useWebView: Bool, + webJavaScript: String?, + bodyJavaScript: String?, + webViewDelayMilliseconds: Int = 0, + responseType: String? = nil, + proxy: String? = nil, + dnsIP: String? = nil, + serverID: Int64? = nil, + variables: [String: String] = [:], + key: String? = nil, + page: Int? = nil, + book: Book? = nil, + chapter: BookChapter? = nil, + suppressesUserAgent: Bool = false + ) { + self.suppressesUserAgent = suppressesUserAgent + self.ruleURL = ruleURL + self.url = url + self.method = method + self.headers = headers + self.body = body + self.charset = charset + self.retry = retry + self.useWebView = useWebView + self.webJavaScript = webJavaScript + self.bodyJavaScript = bodyJavaScript + self.webViewDelayMilliseconds = webViewDelayMilliseconds + self.responseType = responseType + self.proxy = proxy + self.dnsIP = dnsIP + self.serverID = serverID + self.variables = variables + self.key = key + self.page = page + self.book = book + self.chapter = chapter + } + + public func urlRequest(timeout: TimeInterval = 30, handleCookies: Bool = true) -> URLRequest { + let override = ApplicationSettingsStore.shared.settings.timeoutSeconds + var request = URLRequest(url: url, timeoutInterval: override > 0 ? Double(override) : timeout) + request.httpMethod = method.rawValue + request.httpBody = method == .post ? body : nil + request.httpShouldHandleCookies = handleCookies + for (key, value) in headers { request.setValue(value, forHTTPHeaderField: key) } + if suppressesUserAgent { + let mutable = (request as NSURLRequest).mutableCopy() as! NSMutableURLRequest + URLProtocol.setProperty(true, forKey: "LegadoSuppressUserAgent", in: mutable) + return mutable as URLRequest + } + return request + } +} + +public struct LegadoURLBuilder: Sendable { + public static let defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36" + + public init() {} + + public static func resolve(_ value: String, against baseURL: URL) -> String { + let value = value.trimmingCharacters(in: .whitespacesAndNewlines) + if value.lowercased().hasPrefix("javascript:") { return "" } + guard let pattern = try? NSRegularExpression(pattern: #",\s*(?=\{)"#), + let match = pattern.firstMatch(in: value, range: NSRange(value.startIndex..., in: value)), + let range = Range(match.range, in: value) else { + return URL(string: value, relativeTo: baseURL)?.absoluteURL.absoluteString ?? value + } + let address = String(value[.. JSONValue { + try JSONValue.decode(data: Data(lenientJSON(source, quoteUnquotedValues: quoteUnquotedValues).utf8)) + } + + public func prepare( + _ template: String, + context: URLTemplateContext = .init(), + baseURL: URL? = nil, + defaultHeaders: [String: String] = [:] + ) throws -> PreparedRequest { + let renderedResult = try renderResult(template, context: context, baseURL: baseURL) + let rendered = renderedResult.text + let parsed = try parseOptions(rendered, rule: template) + let options = parsed.options + var urlText = parsed.url + var variables = renderedResult.variables + if let script = options.javaScript { + let evaluated = try evaluateJavaScript( + script, + result: .string(urlText), + context: context, + variables: variables, + baseURL: baseURL + ) + urlText = evaluated.value.stringValue + variables = evaluated.variables + } + let charsetEncoding = options.charset.flatMap(HTTPTextDecoder.encoding(named:)) + let requestURLText = encodeQueryIfNeeded(urlText, encoding: charsetEncoding) + var urlAllowed = CharacterSet.urlFragmentAllowed + urlAllowed.insert(charactersIn: "%") + let encoded = requestURLText.addingPercentEncoding(withAllowedCharacters: urlAllowed) ?? requestURLText + guard let url = URL(string: encoded, relativeTo: baseURL)?.absoluteURL else { + throw RuleEngineError.malformedRule(rule: template, detail: "URL 无法解析:\(urlText)") + } + var headers = defaultHeaders + let proxyKey = headers.keys.first { $0.caseInsensitiveCompare("proxy") == .orderedSame } + let proxy = proxyKey.flatMap { headers.removeValue(forKey: $0) } + for (key, value) in options.headers { + for existing in Array(headers.keys) where existing.caseInsensitiveCompare(key) == .orderedSame { + headers.removeValue(forKey: existing) + } + headers[key] = value + } + let userAgentKey = headers.keys.first { $0.caseInsensitiveCompare("User-Agent") == .orderedSame } + let suppressesUserAgent = userAgentKey.flatMap { headers[$0] } == "null" + if suppressesUserAgent, let userAgentKey { + headers.removeValue(forKey: userAgentKey) + } + if !suppressesUserAgent, + headers.first(where: { $0.key.caseInsensitiveCompare("User-Agent") == .orderedSame }) == nil { + headers["User-Agent"] = ApplicationSettingsStore.shared.settings.effectiveUserAgent + } + let bodyText = options.body?.stringValue + let trimmedBody = bodyText?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let isStructuredBody = trimmedBody.hasPrefix("{") || trimmedBody.hasPrefix("[") || trimmedBody.hasPrefix("<") + let hasExplicitContentType = headers.keys.contains { $0.caseInsensitiveCompare("Content-Type") == .orderedSame } + let encodedBodyText = if let bodyText, !isStructuredBody, !hasExplicitContentType { + percentEncode(bodyText, encoding: charsetEncoding ?? .utf8, preserving: Self.formDelimiters, spaceAsPlus: true) + } else { + bodyText + } + let body = options.method == .post ? (encodedBodyText?.data(using: charsetEncoding ?? .utf8) ?? Data()) : nil + if options.method == .post, !hasExplicitContentType { + let charsetName = options.charset ?? "utf-8" + if trimmedBody.hasPrefix("<") { + headers["Content-Type"] = "application/xml; charset=\(charsetName)" + } else if isStructuredBody { + headers["Content-Type"] = "application/json; charset=\(charsetName)" + } else { + headers["Content-Type"] = "application/x-www-form-urlencoded; charset=\(charsetName)" + } + } + return .init( + ruleURL: template, + url: url, + method: options.method, + headers: headers, + body: body, + charset: options.charset, + retry: options.retry, + useWebView: options.useWebView, + webJavaScript: options.webJavaScript, + bodyJavaScript: options.bodyJavaScript, + webViewDelayMilliseconds: options.webViewDelayMilliseconds, + responseType: options.responseType, + proxy: proxy, + dnsIP: options.dnsIP, + serverID: options.serverID, + variables: variables, + key: context.key, + page: context.page, + book: context.book, + chapter: context.chapter, + suppressesUserAgent: suppressesUserAgent + ) + } + + private static let queryDelimiters: CharacterSet = { + var value = CharacterSet.alphanumerics + value.insert(charactersIn: "-._~!$&'()*+,;=:@/?%") + return value + }() + private static let formDelimiters: CharacterSet = { + var value = CharacterSet.alphanumerics + value.insert(charactersIn: "-._~&=;%+") + return value + }() + + private func encodeQueryIfNeeded(_ value: String, encoding: String.Encoding?) -> String { + guard let encoding, encoding != .utf8, let separator = value.firstIndex(of: "?") else { return value } + let queryStart = value.index(after: separator) + return String(value[...separator]) + + percentEncode(String(value[queryStart...]), encoding: encoding, preserving: Self.queryDelimiters) + } + + private func percentEncode( + _ value: String, + encoding: String.Encoding, + preserving allowed: CharacterSet, + spaceAsPlus: Bool = false + ) -> String { + var output = "" + for character in value { + let text = String(character) + if spaceAsPlus, text == " " { + output += "+" + continue + } + if text.unicodeScalars.allSatisfy({ $0.isASCII && allowed.contains($0) }) { + output += text + continue + } + let bytes = text.data(using: encoding) ?? Data(text.utf8) + output += bytes.map { String(format: "%%%02X", $0) }.joined() + } + return output + } + + public func render(_ template: String, context: URLTemplateContext) throws -> String { + try renderResult(template, context: context, baseURL: nil).text + } + + private func renderResult( + _ template: String, + context: URLTemplateContext, + baseURL: URL? + ) throws -> (text: String, variables: [String: String]) { + var variables = context.variables + let segments = try evaluateJavaScriptSegments( + template, + context: context, + variables: variables, + baseURL: baseURL + ) + var result = segments.text + variables = segments.variables + let inline = try NSRegularExpression(pattern: #"\{\{([\s\S]*?)\}\}"#) + let matches = inline.matches(in: result, range: NSRange(result.startIndex..., in: result)).reversed() + for match in matches { + guard let fullRange = Range(match.range(at: 0), in: result), + let expressionRange = Range(match.range(at: 1), in: result) else { continue } + let expression = String(result[expressionRange]) + let value: String + if let literal = context.value(for: expression) { + value = literal + } else { + let evaluated = try evaluateJavaScript( + expression, + result: .string(result), + context: context, + variables: variables, + baseURL: baseURL + ) + value = evaluated.value.stringValue + variables = evaluated.variables + } + result.replaceSubrange(fullRange, with: value) + } + if let key = context.key { + result = result.replacingOccurrences(of: "searchKey", with: key) + } + if let page = context.page { + result = result.replacingOccurrences(of: "searchPage", with: String(page)) + } + if let page = context.page { + let pageExpression = try NSRegularExpression(pattern: #"<([^<>]*?)>"#) + let pageMatches = pageExpression.matches( + in: result, + range: NSRange(result.startIndex..., in: result) + ).reversed() + for match in pageMatches { + guard let fullRange = Range(match.range(at: 0), in: result), + let listRange = Range(match.range(at: 1), in: result) else { continue } + let choices = result[listRange].split(separator: ",", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + guard !choices.isEmpty else { continue } + let selected = choices[min(max(page - 1, 0), choices.count - 1)] + result.replaceSubrange(fullRange, with: selected) + } + } + return (result, variables) + } + + private func evaluateJavaScriptSegments( + _ template: String, + context: URLTemplateContext, + variables: [String: String], + baseURL: URL? + ) throws -> (text: String, variables: [String: String]) { + let expression = try NSRegularExpression( + pattern: #"([\s\S]*?)|@js:([\s\S]*)"#, + options: .caseInsensitive + ) + let matches = expression.matches(in: template, range: NSRange(template.startIndex..., in: template)) + guard !matches.isEmpty else { return (template, variables) } + var output = template + var currentVariables = variables + var cursor = template.startIndex + for match in matches { + guard let matchRange = Range(match.range(at: 0), in: template) else { continue } + let prefix = String(template[cursor.. JavaScriptRuleResult { + try JavaScriptRuleEvaluator().evaluate( + script: script, + result: result, + executionContext: .init( + variables: variables, + baseURL: baseURL?.absoluteString ?? context.source?.bookSourceUrl ?? "", + key: context.key, + page: context.page, + source: context.source, + book: context.book, + chapter: context.chapter + ) + ) + } + + private func splitOptions(_ value: String) -> [(url: String, options: String)] { + guard let expression = try? NSRegularExpression(pattern: #"\s*,\s*(?=\{)"#) else { + return [(value.trimmingCharacters(in: .whitespacesAndNewlines), "")] + } + let matches = expression.matches(in: value, range: NSRange(value.startIndex..., in: value)) + guard !matches.isEmpty else { + return [(value.trimmingCharacters(in: .whitespacesAndNewlines), "")] + } + return matches.reversed().compactMap { match in + guard let range = Range(match.range, in: value) else { return nil } + return ( + String(value[.. (url: String, options: Options) { + let candidates = splitOptions(source) + var firstError: Error? + for (index, candidate) in candidates.enumerated() { + guard !candidate.options.isEmpty else { + return (candidate.url, .init()) + } + do { + let value = try decodeOptions(candidate.options) + guard case .object(let object) = value else { + throw RuleEngineError.malformedRule(rule: rule, detail: "URL 参数必须是 JSON 对象") + } + return (candidate.url, try makeOptions(object, rule: rule)) + } catch { + if index == 0 { firstError = error } + continue + } + } + throw firstError ?? RuleEngineError.malformedRule(rule: rule, detail: "URL 参数 JSON 无效") + } + + private func decodeOptions(_ source: String) throws -> JSONValue { + do { + return try JSONValue.decode(data: Data(source.utf8)) + } catch { + do { + return try JSONValue.decode(data: Data(Self.lenientJSON(source).utf8)) + } catch { + throw RuleEngineError.malformedRule(rule: source, detail: "URL 参数 JSON 无效:\(error.localizedDescription)") + } + } + } + + private func makeOptions(_ object: [String: JSONValue], rule: String) throws -> Options { + var options = Options() + if let method = object["method"]?.stringValue.uppercased() { + options.method = HTTPMethod(rawValue: method) ?? .get + } + options.charset = object["charset"]?.stringValue.nilIfEmpty + options.retry = Int(object["retry"]?.stringValue ?? "") ?? 0 + options.useWebView = object["webView"].map { value in + switch value { + case .bool(let enabled): enabled + case .number(let number): number != 0 + case .string(let string): !string.isEmpty && string.lowercased() != "false" + default: false + } + } ?? false + options.webJavaScript = object["webJs"]?.stringValue.nilIfEmpty + options.bodyJavaScript = object["bodyJs"]?.stringValue.nilIfEmpty + options.javaScript = object["js"]?.stringValue.nilIfEmpty + options.webViewDelayMilliseconds = min(max(Int(object["webViewDelayTime"]?.stringValue ?? "") ?? 0, 0), 30_000) + options.responseType = object["type"]?.stringValue.nilIfEmpty + options.dnsIP = object["dnsIp"]?.stringValue.nilIfEmpty + options.serverID = Int64(object["serverID"]?.stringValue ?? "") + options.body = object["body"] + if let headerValue = object["headers"] { + options.headers = try parseHeaders(headerValue, rule: rule) + } + return options + } + + private static func lenientJSON(_ source: String, quoteUnquotedValues: Bool = false) -> String { + let characters = Array(source) + var output = "" + var index = 0 + var expectingKey = true + + func peek(_ offset: Int = 0) -> Character? { + let position = index + offset + return position < characters.count ? characters[position] : nil + } + + while index < characters.count { + let character = characters[index] + index += 1 + switch character { + case "\"": + output.append("\"") + while index < characters.count { + let inner = characters[index] + index += 1 + output.append(inner) + if inner == "\\", index < characters.count { + output.append(characters[index]) + index += 1 + continue + } + if inner == "\"" { break } + } + expectingKey = false + case "'": + var text = "" + while index < characters.count { + let inner = characters[index] + index += 1 + if inner == "\\", index < characters.count { + let escaped = characters[index] + index += 1 + text.append(escaped == "'" ? "'" : "\\\(escaped)") + continue + } + if inner == "'" { break } + text.append(inner) + } + output.append(escapedJSONString(text)) + expectingKey = false + case "/": + if peek() == "/" { + index += 1 + while let value = peek(), value != "\n" { index += 1 } + } else if peek() == "*" { + index += 1 + while let value = peek() { + if value == "*", peek(1) == "/" { + index += 2 + break + } + index += 1 + } + } else { + output.append(character) + } + case "{", ",": + output.append(character) + expectingKey = true + case ":": + output.append(character) + expectingKey = false + if quoteUnquotedValues { + var start = index + while start < characters.count, characters[start].isWhitespace { start += 1 } + if start < characters.count, !["\"", "'", "{", "[", "}", ","].contains(characters[start]) { + var end = start + while end < characters.count, characters[end] != ",", characters[end] != "}" { end += 1 } + output.append(escapedJSONString(String(characters[start.. String { + var text = "" + for character in value { + switch character { + case "\"": text.append("\\\"") + case "\\": text.append("\\\\") + case "\n": text.append("\\n") + case "\r": text.append("\\r") + case "\t": text.append("\\t") + default: text.append(character) + } + } + return "\"\(text)\"" + } + + private func parseHeaders(_ value: JSONValue, rule: String) throws -> [String: String] { + let parsed: JSONValue + if case .string(let source) = value { + do { + parsed = try JSONValue.decode(data: Data(source.utf8)) + } catch { + throw RuleEngineError.malformedRule(rule: rule, detail: "headers JSON 无效") + } + } else { + parsed = value + } + guard case .object(let object) = parsed else { + throw RuleEngineError.malformedRule(rule: rule, detail: "headers 必须是对象") + } + return object.mapValues(\.stringValue) + } + + private struct Options { + var method: HTTPMethod = .get + var charset: String? + var headers: [String: String] = [:] + var body: JSONValue? + var retry = 0 + var useWebView = false + var webJavaScript: String? + var bodyJavaScript: String? + var javaScript: String? + var webViewDelayMilliseconds = 0 + var responseType: String? + var dnsIP: String? + var serverID: Int64? + } +} + +private extension String { + var nilIfEmpty: String? { isEmpty ? nil : self } +} diff --git a/Sources/LegadoCore/Persistence/BookChapterRepository.swift b/Sources/LegadoCore/Persistence/BookChapterRepository.swift new file mode 100644 index 0000000..ee73653 --- /dev/null +++ b/Sources/LegadoCore/Persistence/BookChapterRepository.swift @@ -0,0 +1,66 @@ +import CryptoKit +import Foundation + +public protocol BookChapterRepository: Sendable { + func load(bookURL: String) async throws -> [BookChapter] + func save(_ chapters: [BookChapter], bookURL: String) async throws + func remove(bookURL: String) async throws +} + +public actor FileBookChapterRepository: BookChapterRepository { + public static let maximumDocumentBytes = 24 * 1_024 * 1_024 + private let directory: URL + + public init(directory: URL) { self.directory = directory } + + public static func live() -> FileBookChapterRepository { + let directory = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Legado/Chapters", isDirectory: true) + return FileBookChapterRepository(directory: directory) + } + + public func load(bookURL: String) throws -> [BookChapter] { + let url = fileURL(bookURL) + guard FileManager.default.fileExists(atPath: url.path) else { return [] } + let data = try Data(contentsOf: url, options: .mappedIfSafe) + guard data.count <= Self.maximumDocumentBytes else { + try? FileManager.default.removeItem(at: url) + return [] + } + return try JSONDecoder().decode([BookChapter].self, from: data).sorted { $0.index < $1.index } + } + + public func save(_ chapters: [BookChapter], bookURL: String) throws { + var normalized = chapters + for index in normalized.indices { + normalized[index].bookUrl = bookURL + normalized[index].index = index + } + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(normalized) + guard data.count <= Self.maximumDocumentBytes else { + throw CocoaError(.fileWriteOutOfSpace) + } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try data.write(to: fileURL(bookURL), options: .atomic) + } + + public func remove(bookURL: String) throws { + let url = fileURL(bookURL) + if FileManager.default.fileExists(atPath: url.path) { try FileManager.default.removeItem(at: url) } + } + + private func fileURL(_ bookURL: String) -> URL { + let name = SHA256.hash(data: Data(bookURL.utf8)).map { String(format: "%02x", $0) }.joined() + return directory.appendingPathComponent("\(name).json") + } +} + +public actor InMemoryBookChapterRepository: BookChapterRepository { + private var values: [String: [BookChapter]] + public init(values: [String: [BookChapter]] = [:]) { self.values = values } + public func load(bookURL: String) -> [BookChapter] { values[bookURL] ?? [] } + public func save(_ chapters: [BookChapter], bookURL: String) { values[bookURL] = chapters } + public func remove(bookURL: String) { values[bookURL] = nil } +} diff --git a/Sources/LegadoCore/Persistence/BookRepository.swift b/Sources/LegadoCore/Persistence/BookRepository.swift new file mode 100644 index 0000000..a4ff8f0 --- /dev/null +++ b/Sources/LegadoCore/Persistence/BookRepository.swift @@ -0,0 +1,71 @@ +import Foundation + +public protocol BookRepository: Sendable { + func load() async throws -> [Book] + func save(_ books: [Book]) async throws +} + +public actor FileBookRepository: BookRepository { + private let fileURL: URL + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + public init(fileURL: URL) { + self.fileURL = fileURL + encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + decoder = JSONDecoder() + } + + public func load() throws -> [Book] { + guard FileManager.default.fileExists(atPath: fileURL.path) else { return [] } + return try decoder.decode([Book].self, from: Data(contentsOf: fileURL)) + } + + public func save(_ books: [Book]) throws { + let directory = fileURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try encoder.encode(books).write(to: fileURL, options: .atomic) + } +} + +public actor InMemoryBookRepository: BookRepository { + private var books: [Book] + + public init(books: [Book] = []) { + self.books = books + } + + public func load() -> [Book] { books } + + public func save(_ books: [Book]) { + self.books = books + } +} + +public protocol BookGroupRepository: Sendable { + func load() async throws -> [BookGroup] + func save(_ groups: [BookGroup]) async throws +} + +public actor FileBookGroupRepository: BookGroupRepository { + private let fileURL: URL + public init(fileURL: URL) { self.fileURL = fileURL } + public func load() throws -> [BookGroup] { + guard FileManager.default.fileExists(atPath: fileURL.path) else { return [] } + return try JSONDecoder().decode([BookGroup].self, from: Data(contentsOf: fileURL)) + } + public func save(_ groups: [BookGroup]) throws { + try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try encoder.encode(groups).write(to: fileURL, options: .atomic) + } +} + +public actor InMemoryBookGroupRepository: BookGroupRepository { + private var groups: [BookGroup] + public init(groups: [BookGroup] = []) { self.groups = groups } + public func load() -> [BookGroup] { groups } + public func save(_ groups: [BookGroup]) { self.groups = groups } +} diff --git a/Sources/LegadoCore/Persistence/BookSourceRepository.swift b/Sources/LegadoCore/Persistence/BookSourceRepository.swift new file mode 100644 index 0000000..b2b0931 --- /dev/null +++ b/Sources/LegadoCore/Persistence/BookSourceRepository.swift @@ -0,0 +1,46 @@ +import Foundation + +public protocol BookSourceRepository: Sendable { + func load() async throws -> [BookSource] + func save(_ sources: [BookSource]) async throws +} + +public actor FileBookSourceRepository: BookSourceRepository { + private let fileURL: URL + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + public init(fileURL: URL) { + self.fileURL = fileURL + encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + decoder = JSONDecoder() + } + + public func load() throws -> [BookSource] { + guard FileManager.default.fileExists(atPath: fileURL.path) else { return [] } + return try decoder.decode([BookSource].self, from: Data(contentsOf: fileURL)) + } + + public func save(_ sources: [BookSource]) throws { + let directory = fileURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let data = try encoder.encode(sources) + try data.write(to: fileURL, options: .atomic) + } +} + +public actor InMemoryBookSourceRepository: BookSourceRepository { + private var sources: [BookSource] + + public init(sources: [BookSource] = []) { + self.sources = sources + } + + public func load() -> [BookSource] { sources } + + public func save(_ sources: [BookSource]) { + self.sources = sources + } +} + diff --git a/Sources/LegadoCore/Persistence/ConfigurationRepositories.swift b/Sources/LegadoCore/Persistence/ConfigurationRepositories.swift new file mode 100644 index 0000000..595ccec --- /dev/null +++ b/Sources/LegadoCore/Persistence/ConfigurationRepositories.swift @@ -0,0 +1,566 @@ +import Foundation +import SQLite3 + +public struct SearchKeyword: Codable, Hashable, Identifiable, Sendable { + public var word: String + public var usage: Int + public var lastUseTime: Int64 + public var id: String { word } + + public init(word: String, usage: Int = 1, lastUseTime: Int64? = nil) { + self.word = word + self.usage = usage + self.lastUseTime = lastUseTime ?? Int64(Date().timeIntervalSince1970 * 1_000) + } +} + +public struct LegadoCache: Codable, Hashable, Identifiable, Sendable { + public var key: String + public var value: String? + public var deadline: Int64 + public var id: String { key } + + public init(key: String, value: String? = nil, deadline: Int64 = 0) { + self.key = key + self.value = value + self.deadline = deadline + } +} + +public struct LegadoCookie: Codable, Hashable, Identifiable, Sendable { + public var url: String + public var cookie: String + public var id: String { url } + + public init(url: String, cookie: String = "") { + self.url = url + self.cookie = cookie + } +} + +public struct TxtTocRule: Codable, Hashable, Identifiable, Sendable { + public var id: Int64 + public var name: String + public var rule: String + public var replacement: String + public var example: String? + public var serialNumber: Int + public var enable: Bool + public init( + id: Int64 = Int64(Date().timeIntervalSince1970 * 1_000), + name: String = "", + rule: String = "", + replacement: String = "", + example: String? = nil, + serialNumber: Int = -1, + enable: Bool = true + ) { + self.id = id; self.name = name; self.rule = rule; self.replacement = replacement + self.example = example; self.serialNumber = serialNumber; self.enable = enable + } + + private enum CodingKeys: String, CodingKey { + case id, name, rule, replacement, example, serialNumber, enable + } + + public init(from decoder: Decoder) throws { + let value = try decoder.container(keyedBy: CodingKeys.self) + id = try value.decodeIfPresent(Int64.self, forKey: .id) ?? Int64(Date().timeIntervalSince1970 * 1_000) + name = try value.decodeIfPresent(String.self, forKey: .name) ?? "" + rule = try value.decodeIfPresent(String.self, forKey: .rule) ?? "" + replacement = try value.decodeIfPresent(String.self, forKey: .replacement) ?? "" + example = try value.decodeIfPresent(String.self, forKey: .example) + serialNumber = try value.decodeIfPresent(Int.self, forKey: .serialNumber) ?? -1 + enable = try value.decodeIfPresent(Bool.self, forKey: .enable) ?? true + } +} + +public struct HttpTTS: Codable, Hashable, Identifiable, Sendable { + public var id: Int64 + public var name: String + public var url: String + public var contentType: String? + public var concurrentRate: String? + public var loginUrl: String? + public var loginUi: String? + public var header: String? + public var jsLib: String? + public var enabledCookieJar: Bool? + public var loginCheckJs: String? + public var lastUpdateTime: Int64 + public init(id: Int64 = Int64(Date().timeIntervalSince1970 * 1_000), name: String = "", url: String = "") { + self.id = id; self.name = name; self.url = url; contentType = nil; concurrentRate = "0" + loginUrl = nil; loginUi = nil; header = nil; jsLib = nil; enabledCookieJar = false + loginCheckJs = nil; lastUpdateTime = id + } + + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + let now = Int64(Date().timeIntervalSince1970 * 1_000) + id = (try? values.decode(Int64.self, forKey: .id)) + ?? Int64((try? values.decode(String.self, forKey: .id)) ?? "") + ?? now + name = try values.decodeIfPresent(String.self, forKey: .name) ?? "" + url = try values.decodeIfPresent(String.self, forKey: .url) ?? "" + contentType = try values.decodeIfPresent(String.self, forKey: .contentType) + concurrentRate = try values.decodeIfPresent(String.self, forKey: .concurrentRate) + loginUrl = try values.decodeIfPresent(String.self, forKey: .loginUrl) + if let text = try? values.decodeIfPresent(String.self, forKey: .loginUi) { + loginUi = text + } else if let json = try? values.decodeIfPresent(JSONValue.self, forKey: .loginUi) { + loginUi = (try? JSONEncoder().encode(json)).flatMap { String(data: $0, encoding: .utf8) } + } else { + loginUi = nil + } + header = try values.decodeIfPresent(String.self, forKey: .header) + jsLib = try values.decodeIfPresent(String.self, forKey: .jsLib) + enabledCookieJar = try values.decodeIfPresent(Bool.self, forKey: .enabledCookieJar) ?? false + loginCheckJs = try values.decodeIfPresent(String.self, forKey: .loginCheckJs) + lastUpdateTime = (try? values.decode(Int64.self, forKey: .lastUpdateTime)) + ?? Int64((try? values.decode(String.self, forKey: .lastUpdateTime)) ?? "") + ?? now + } +} + +public struct RuleSub: Codable, Hashable, Identifiable, Sendable { + public var id: Int64 + public var name: String + public var url: String + public var type: Int + public var customOrder: Int + public var autoUpdate: Bool + public var update: Int64 + public var updateInterval: Int + public var silentUpdate: Bool + public var js: String? + public var showRule: String? + public var sourceUrl: String? + public init(id: Int64 = Int64(Date().timeIntervalSince1970 * 1_000), name: String = "", url: String = "") { + self.id = id; self.name = name; self.url = url; type = 0; customOrder = 0 + autoUpdate = false; update = id; updateInterval = 0; silentUpdate = false + js = nil; showRule = nil; sourceUrl = nil + } +} + +public struct KeyboardAssist: Codable, Hashable, Identifiable, Sendable { + public var type: Int + public var key: String + public var value: String + public var serialNo: Int + public var id: String { "\(type)\u{0}\(key)" } + public init(type: Int = 0, key: String, value: String, serialNo: Int = 0) { + self.type = type; self.key = key; self.value = value; self.serialNo = serialNo + } +} + +public struct Server: Codable, Hashable, Identifiable, Sendable { + public enum ServerType: String, Codable, Sendable { case webDAV = "WEBDAV" } + public struct WebDAVConfig: Codable, Hashable, Sendable { + public var url: String + public var username: String + public var password: String + public init(url: String, username: String, password: String) { + self.url = url; self.username = username; self.password = password + } + } + public var id: Int64 + public var name: String + public var type: ServerType + public var config: String? + public var sortNumber: Int + public init(id: Int64 = Int64(Date().timeIntervalSince1970 * 1_000), name: String = "", type: ServerType = .webDAV) { + self.id = id; self.name = name; self.type = type; config = nil; sortNumber = 0 + } + public var webDAVConfig: WebDAVConfig? { + guard type == .webDAV, let config else { return nil } + return try? JSONDecoder().decode(WebDAVConfig.self, from: Data(config.utf8)) + } +} + +public enum SearchKeywordOrder: Sendable { case usage, lastUseTime } + +public protocol SearchKeywordRepository: Sendable { + func load(order: SearchKeywordOrder) async throws -> [SearchKeyword] + @discardableResult func record(_ word: String, at time: Int64) async throws -> SearchKeyword + func delete(_ word: String) async throws + func deleteAll() async throws +} + +public actor SQLiteSearchKeywordRepository: SearchKeywordRepository { + private let database: LegadoDatabase + private let legacyWords: [String] + private var migratedLegacy = false + + public init(database: LegadoDatabase, legacyWords: [String] = []) { + self.database = database + self.legacyWords = legacyWords + } + + public func load(order: SearchKeywordOrder) async throws -> [SearchKeyword] { + try await migrateLegacyIfNeeded() + return try await database.loadSearchKeywords(order: order) + } + + public func record(_ word: String, at time: Int64) async throws -> SearchKeyword { + try await migrateLegacyIfNeeded() + return try await database.recordSearchKeyword(word, at: time) + } + + public func delete(_ word: String) async throws { try await database.deleteSearchKeyword(word) } + public func deleteAll() async throws { try await database.deleteAllSearchKeywords() } + + private func migrateLegacyIfNeeded() async throws { + guard !migratedLegacy else { return } + migratedLegacy = true + try await database.importLegacySearchKeywords(legacyWords) + } +} + +public actor InMemorySearchKeywordRepository: SearchKeywordRepository { + private var values: [String: SearchKeyword] + public init(values: [SearchKeyword] = []) { + self.values = Dictionary(uniqueKeysWithValues: values.map { ($0.word, $0) }) + } + public func load(order: SearchKeywordOrder) -> [SearchKeyword] { + values.values.sorted { + switch order { + case .usage: + $0.usage == $1.usage ? $0.lastUseTime > $1.lastUseTime : $0.usage > $1.usage + case .lastUseTime: + $0.lastUseTime == $1.lastUseTime ? $0.usage > $1.usage : $0.lastUseTime > $1.lastUseTime + } + } + } + public func record(_ word: String, at time: Int64) -> SearchKeyword { + var value = values[word] ?? .init(word: word, usage: 0, lastUseTime: time) + value.usage += 1 + value.lastUseTime = time + values[word] = value + return value + } + public func delete(_ word: String) { values[word] = nil } + public func deleteAll() { values.removeAll() } +} + +public protocol LegadoCacheRepository: Sendable { + func value(for key: String, now: Int64) async throws -> String? + func put(_ cache: LegadoCache) async throws + func delete(_ key: String) async throws + func deleteSourceVariables(_ sourceKey: String) async throws + func clearExpired(now: Int64) async throws +} + +public actor SQLiteLegadoCacheRepository: LegadoCacheRepository { + private let database: LegadoDatabase + public init(database: LegadoDatabase) { self.database = database } + public func value(for key: String, now: Int64) async throws -> String? { + try await database.cacheValue(for: key, now: now) + } + public func put(_ cache: LegadoCache) async throws { try await database.putCache(cache) } + public func delete(_ key: String) async throws { try await database.deleteCache(key) } + public func deleteSourceVariables(_ sourceKey: String) async throws { + try await database.deleteSourceVariables(sourceKey) + } + public func clearExpired(now: Int64) async throws { try await database.clearExpiredCaches(now: now) } +} + +public protocol LegadoCookieRepository: Sendable { + func get(_ url: String) async throws -> LegadoCookie? + func getOkHTTP() async throws -> [LegadoCookie] + func put(_ cookie: LegadoCookie) async throws + func delete(_ url: String) async throws + func deleteOkHTTP() async throws +} + +public protocol SearchBookRepository: Sendable { + func cachedMatches(keyword: String) async throws -> [SearchBook] + func get(bookURL: String) async throws -> SearchBook? + func first(name: String, author: String) async throws -> SearchBook? + func changeSources(name: String, author: String, sourceGroup: String, key: String?) async throws -> [SearchBook] + func enabledWithCover(name: String, author: String) async throws -> [SearchBook] + func put(_ books: [SearchBook]) async throws + func clear(name: String, author: String) async throws + func clearExpired(before time: Int64) async throws +} + +extension SearchBookRepository { + public func cachedMatches(keyword: String) async throws -> [SearchBook] { + try await changeSources(name: keyword, author: "", sourceGroup: "", key: nil) + } +} + +public protocol ConfigurationRepository: Sendable { + func loadTxtTocRules() async throws -> [TxtTocRule] + func saveTxtTocRules(_ values: [TxtTocRule]) async throws + func loadHttpTTS() async throws -> [HttpTTS] + func saveHttpTTS(_ values: [HttpTTS]) async throws + func loadRuleSubs() async throws -> [RuleSub] + func saveRuleSubs(_ values: [RuleSub]) async throws + func loadKeyboardAssists() async throws -> [KeyboardAssist] + func saveKeyboardAssists(_ values: [KeyboardAssist]) async throws + func loadServers() async throws -> [Server] + func saveServers(_ values: [Server]) async throws +} + +public actor SQLiteConfigurationRepository: ConfigurationRepository { + private let database: LegadoDatabase + public init(database: LegadoDatabase) { self.database = database } + public func loadTxtTocRules() async throws -> [TxtTocRule] { try await database.loadTxtTocRules() } + public func saveTxtTocRules(_ values: [TxtTocRule]) async throws { try await database.replaceTxtTocRules(values) } + public func loadHttpTTS() async throws -> [HttpTTS] { try await database.loadHttpTTS() } + public func saveHttpTTS(_ values: [HttpTTS]) async throws { try await database.replaceHttpTTS(values) } + public func loadRuleSubs() async throws -> [RuleSub] { try await database.loadRuleSubs() } + public func saveRuleSubs(_ values: [RuleSub]) async throws { try await database.replaceRuleSubs(values) } + public func loadKeyboardAssists() async throws -> [KeyboardAssist] { try await database.loadKeyboardAssists() } + public func saveKeyboardAssists(_ values: [KeyboardAssist]) async throws { try await database.replaceKeyboardAssists(values) } + public func loadServers() async throws -> [Server] { try await database.loadServers() } + public func saveServers(_ values: [Server]) async throws { try await database.replaceServers(values) } +} + +public actor SQLiteSearchBookRepository: SearchBookRepository { + private let database: LegadoDatabase + public init(database: LegadoDatabase) { self.database = database } + + public func cachedMatches(keyword: String) async throws -> [SearchBook] { + try await database.cachedSearchMatches(keyword: keyword) + } + + public func get(bookURL: String) async throws -> SearchBook? { + let books = try await database.loadSearchBooks() + return books.first { $0.bookURL == bookURL } + } + + public func first(name: String, author: String) async throws -> SearchBook? { + let books = try await database.loadSearchBooks() + let sources = try await database.loadBookSources() + let sourceIDs = Set(sources.map(\.bookSourceUrl)) + return books + .filter { $0.name == name && $0.author == author } + .filter { sourceIDs.contains($0.origin) } + .sorted { $0.originOrder < $1.originOrder } + .first + } + + public func changeSources( + name: String, + author: String, + sourceGroup: String, + key: String? + ) async throws -> [SearchBook] { + let sources = try await database.loadBookSources() + let sourceMap = Dictionary(uniqueKeysWithValues: sources.map { ($0.bookSourceUrl, $0) }) + let books = try await database.loadSearchBooks() + return books.compactMap { book in + guard book.name == name, + book.author.localizedCaseInsensitiveContains(author), + let source = sourceMap[book.origin], source.enabled, + sourceGroup.isEmpty || (source.bookSourceGroup ?? "").contains(sourceGroup) else { return nil } + if let key, !key.isEmpty, + !book.originName.localizedCaseInsensitiveContains(key), + !(book.latestChapterTitle ?? "").localizedCaseInsensitiveContains(key) { return nil } + var value = book + value.originOrder = source.customOrder + return value + }.sorted { $0.originOrder < $1.originOrder } + } + + public func enabledWithCover(name: String, author: String) async throws -> [SearchBook] { + let sources = try await database.loadBookSources() + let sourceMap = Dictionary(uniqueKeysWithValues: sources.map { ($0.bookSourceUrl, $0) }) + let books = try await database.loadSearchBooks() + return books.compactMap { book in + guard book.name == name, book.author == author, + !(book.coverURL ?? "").isEmpty, + let source = sourceMap[book.origin], source.enabled else { return nil } + var value = book + value.originOrder = source.customOrder + return value + }.sorted { $0.originOrder < $1.originOrder } + } + + public func put(_ books: [SearchBook]) async throws { try await database.putSearchBooks(books) } + public func clear(name: String, author: String) async throws { + try await database.deleteSearchBooks(name: name, author: author) + } + public func clearExpired(before time: Int64) async throws { + try await database.clearExpiredSearchBooks(before: time) + } +} + +public actor SQLiteLegadoCookieRepository: LegadoCookieRepository { + private let database: LegadoDatabase + public init(database: LegadoDatabase) { self.database = database } + public func get(_ url: String) async throws -> LegadoCookie? { try await database.cookie(for: url) } + public func getOkHTTP() async throws -> [LegadoCookie] { try await database.loadOkHTTPCookies() } + public func put(_ cookie: LegadoCookie) async throws { try await database.putCookie(cookie) } + public func delete(_ url: String) async throws { try await database.deleteCookie(url) } + public func deleteOkHTTP() async throws { try await database.deleteOkHTTPCookies() } +} + +public enum SearchKeywordRepositoryFactory { + public static func live(defaults: UserDefaults = .standard) -> any SearchKeywordRepository { + guard let database = try? LegadoDatabase(url: LegadoDatabase.defaultURL()) else { + return InMemorySearchKeywordRepository() + } + return SQLiteSearchKeywordRepository( + database: database, + legacyWords: defaults.stringArray(forKey: "bookSearchHistory") ?? [] + ) + } +} + +public enum SearchBookRepositoryFactory { + public static func live() -> any SearchBookRepository { + guard let database = try? LegadoDatabase(url: LegadoDatabase.defaultURL()) else { + return InMemorySearchBookRepository() + } + return SQLiteSearchBookRepository(database: database) + } +} + +public final class PersistentRuntimeCache: @unchecked Sendable { + public static let shared = PersistentRuntimeCache() + private let lock = NSLock() + private var handle: OpaquePointer? + + public init(url: URL = LegadoDatabase.defaultURL()) { + let migrator = try? LegadoDatabase(url: url) + _ = migrator + sqlite3_open_v2( + url.path, + &handle, + SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, + nil + ) + if let handle { sqlite3_busy_timeout(handle, 5_000) } + } + + deinit { if let handle { sqlite3_close(handle) } } + + public func get(_ key: String, now: Int64 = Int64(Date().timeIntervalSince1970 * 1_000)) -> String? { + lock.lock() + defer { lock.unlock() } + guard let handle else { return nil } + var statement: OpaquePointer? + guard sqlite3_prepare_v2( + handle, + "SELECT value FROM caches WHERE `key` = ? AND (deadline = 0 OR deadline > ?)", + -1, + &statement, + nil + ) == SQLITE_OK, let statement else { return nil } + defer { sqlite3_finalize(statement) } + sqlite3_bind_text(statement, 1, key, -1, Self.transient) + sqlite3_bind_int64(statement, 2, now) + guard sqlite3_step(statement) == SQLITE_ROW, + let value = sqlite3_column_text(statement, 0) else { return nil } + return String(cString: value) + } + + public func put(_ key: String, value: String, deadline: Int64 = 0) { + lock.lock() + defer { lock.unlock() } + guard let handle else { return } + var statement: OpaquePointer? + guard sqlite3_prepare_v2( + handle, + """ + INSERT INTO caches(`key`, value, deadline) VALUES(?, ?, ?) + ON CONFLICT(`key`) DO UPDATE SET value = excluded.value, deadline = excluded.deadline + """, + -1, + &statement, + nil + ) == SQLITE_OK, let statement else { return } + defer { sqlite3_finalize(statement) } + sqlite3_bind_text(statement, 1, key, -1, Self.transient) + sqlite3_bind_text(statement, 2, value, -1, Self.transient) + sqlite3_bind_int64(statement, 3, deadline) + sqlite3_step(statement) + } + + public func cookie(for key: String) -> String? { + lock.lock() + defer { lock.unlock() } + guard let handle else { return nil } + var statement: OpaquePointer? + guard sqlite3_prepare_v2(handle, "SELECT cookie FROM cookies WHERE url = ?", -1, &statement, nil) == SQLITE_OK, + let statement else { return nil } + defer { sqlite3_finalize(statement) } + sqlite3_bind_text(statement, 1, key, -1, Self.transient) + guard sqlite3_step(statement) == SQLITE_ROW, let value = sqlite3_column_text(statement, 0) else { return nil } + return String(cString: value) + } + + public func putCookie(key: String, value: String) { + lock.lock() + defer { lock.unlock() } + guard let handle else { return } + var statement: OpaquePointer? + guard sqlite3_prepare_v2( + handle, + "INSERT INTO cookies(url, cookie) VALUES(?, ?) ON CONFLICT(url) DO UPDATE SET cookie = excluded.cookie", + -1, + &statement, + nil + ) == SQLITE_OK, let statement else { return } + defer { sqlite3_finalize(statement) } + sqlite3_bind_text(statement, 1, key, -1, Self.transient) + sqlite3_bind_text(statement, 2, value, -1, Self.transient) + sqlite3_step(statement) + } + + public func deleteCookie(key: String) { + lock.lock() + defer { lock.unlock() } + guard let handle else { return } + var statement: OpaquePointer? + guard sqlite3_prepare_v2(handle, "DELETE FROM cookies WHERE url = ?", -1, &statement, nil) == SQLITE_OK, + let statement else { return } + defer { sqlite3_finalize(statement) } + sqlite3_bind_text(statement, 1, key, -1, Self.transient) + sqlite3_step(statement) + } + + private static let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) +} + +public actor InMemorySearchBookRepository: SearchBookRepository { + private var values: [String: SearchBook] = [:] + public init(values: [SearchBook] = []) { + self.values = Dictionary(uniqueKeysWithValues: values.map { ($0.bookURL, $0) }) + } + public func get(bookURL: String) -> SearchBook? { values[bookURL] } + public func cachedMatches(keyword: String) async throws -> [SearchBook] { + guard !keyword.isEmpty else { return [] } + return Array(values.values.filter { + $0.name.localizedCaseInsensitiveContains(keyword) || $0.author.localizedCaseInsensitiveContains(keyword) + }.sorted { + let lhsExact = $0.name == keyword || $0.author == keyword + let rhsExact = $1.name == keyword || $1.author == keyword + if lhsExact != rhsExact { return lhsExact } + return $0.time > $1.time + }.prefix(200)) + } + public func first(name: String, author: String) -> SearchBook? { + values.values.filter { $0.name == name && $0.author == author } + .sorted { $0.originOrder < $1.originOrder }.first + } + public func changeSources(name: String, author: String, sourceGroup: String, key: String?) -> [SearchBook] { + values.values.filter { + $0.name == name && $0.author.localizedCaseInsensitiveContains(author) + && (key?.isEmpty != false + || $0.originName.localizedCaseInsensitiveContains(key ?? "") + || ($0.latestChapterTitle ?? "").localizedCaseInsensitiveContains(key ?? "")) + }.sorted { $0.originOrder < $1.originOrder } + } + public func enabledWithCover(name: String, author: String) -> [SearchBook] { + values.values.filter { $0.name == name && $0.author == author && !($0.coverURL ?? "").isEmpty } + .sorted { $0.originOrder < $1.originOrder } + } + public func put(_ books: [SearchBook]) { for book in books { values[book.bookURL] = book } } + public func clear(name: String, author: String) { + values = values.filter { $0.value.name != name || $0.value.author != author } + } + public func clearExpired(before time: Int64) { values = values.filter { $0.value.time >= time } } +} diff --git a/Sources/LegadoCore/Persistence/LegadoDatabase.swift b/Sources/LegadoCore/Persistence/LegadoDatabase.swift new file mode 100644 index 0000000..9eeb763 --- /dev/null +++ b/Sources/LegadoCore/Persistence/LegadoDatabase.swift @@ -0,0 +1,1306 @@ +import Foundation +import SQLite3 + +public enum LegadoDatabaseError: Error, LocalizedError, Sendable { + case open(String) + case execute(String) + case prepare(String) + case bind(String) + case decode(String) + + public var errorDescription: String? { + switch self { + case .open(let message), .execute(let message), .prepare(let message), + .bind(let message), .decode(let message): message + } + } +} + +public actor LegadoDatabase { + public static let schemaVersion: Int32 = 7 + + // SQLite is opened with FULLMUTEX and every operational access remains + // actor-isolated. `nonisolated(unsafe)` only permits closing the handle + // from Swift 6's nonisolated actor deinitializer. + nonisolated(unsafe) private let handle: OpaquePointer + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() + + public init(url: URL) throws { + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + var database: OpaquePointer? + let flags = SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX + guard sqlite3_open_v2(url.path, &database, flags, nil) == SQLITE_OK, let database else { + let message = database.map { String(cString: sqlite3_errmsg($0)) } ?? "无法打开数据库" + if let database { sqlite3_close(database) } + throw LegadoDatabaseError.open(message) + } + handle = database + sqlite3_busy_timeout(database, 5_000) + do { + try Self.execute(database, "PRAGMA foreign_keys = ON") + try Self.execute(database, "PRAGMA journal_mode = WAL") + try Self.migrate(database) + } catch { + sqlite3_close(database) + throw error + } + } + + deinit { sqlite3_close(handle) } + + public static func defaultURL() -> URL { + FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Legado/legado.sqlite3") + } + + public func compact() throws { + try Self.execute(handle, "PRAGMA wal_checkpoint(TRUNCATE)") + try Self.execute(handle, "VACUUM") + } + + public func loadBookSources() throws -> [BookSource] { + try load("SELECT payload FROM book_sources ORDER BY position, book_source_url", as: BookSource.self) + } + + public func replaceBookSources(_ values: [BookSource]) throws { + try transaction { + if values.isEmpty { + try Self.execute(handle, "DELETE FROM book_sources") + } else { + let placeholders = Array(repeating: "?", count: values.count).joined(separator: ",") + let prune = try prepare("DELETE FROM book_sources WHERE book_source_url NOT IN (\(placeholders))") + defer { sqlite3_finalize(prune) } + for (offset, value) in values.enumerated() { + try bind(value.bookSourceUrl, to: prune, at: Int32(offset + 1)) + } + try stepDone(prune) + } + let statement = try prepare(""" + INSERT INTO book_sources( + book_source_url, book_source_group, custom_order, enabled, position, payload + ) VALUES(?, ?, ?, ?, ?, ?) + ON CONFLICT(book_source_url) DO UPDATE SET + book_source_group = excluded.book_source_group, + custom_order = excluded.custom_order, + enabled = excluded.enabled, + position = excluded.position, + payload = excluded.payload + """) + defer { sqlite3_finalize(statement) } + for (position, value) in values.enumerated() { + sqlite3_reset(statement) + sqlite3_clear_bindings(statement) + try bind(value.bookSourceUrl, to: statement, at: 1) + if let group = value.bookSourceGroup { try bind(group, to: statement, at: 2) } + else { sqlite3_bind_null(statement, 2) } + sqlite3_bind_int64(statement, 3, Int64(value.customOrder)) + sqlite3_bind_int(statement, 4, value.enabled ? 1 : 0) + sqlite3_bind_int64(statement, 5, Int64(position)) + try bind(try encoder.encode(value), to: statement, at: 6) + try stepDone(statement) + } + } + } + + public func loadBooks() throws -> [Book] { + try load("SELECT payload FROM books ORDER BY position, book_url", as: Book.self) + } + + public func replaceBooks(_ values: [Book]) throws { + try transaction { + if values.isEmpty { + try Self.execute(handle, "DELETE FROM books") + } else { + let placeholders = Array(repeating: "?", count: values.count).joined(separator: ",") + let prune = try prepare("DELETE FROM books WHERE book_url NOT IN (\(placeholders))") + defer { sqlite3_finalize(prune) } + for (offset, value) in values.enumerated() { + try bind(value.bookUrl, to: prune, at: Int32(offset + 1)) + } + try stepDone(prune) + } + let insert = try prepare(""" + INSERT INTO books(book_url, name, author, position, payload) VALUES(?, ?, ?, ?, ?) + ON CONFLICT(book_url) DO UPDATE SET + name = excluded.name, + author = excluded.author, + position = excluded.position, + payload = excluded.payload + """) + defer { sqlite3_finalize(insert) } + for (position, value) in values.enumerated() { + sqlite3_reset(insert) + sqlite3_clear_bindings(insert) + try bind(value.bookUrl, to: insert, at: 1) + try bind(value.name, to: insert, at: 2) + try bind(value.author, to: insert, at: 3) + sqlite3_bind_int64(insert, 4, Int64(position)) + try bind(try encoder.encode(value), to: insert, at: 5) + try stepDone(insert) + } + } + } + + public func loadChapters(bookURL: String) throws -> [BookChapter] { + let statement = try prepare("SELECT payload FROM chapters WHERE book_url = ? ORDER BY chapter_index") + defer { sqlite3_finalize(statement) } + try bind(bookURL, to: statement, at: 1) + return try decodeRows(statement, as: BookChapter.self) + } + + public func replaceChapters(_ chapters: [BookChapter], bookURL: String) throws { + try transaction { + let delete = try prepare("DELETE FROM chapters WHERE book_url = ?") + defer { sqlite3_finalize(delete) } + try bind(bookURL, to: delete, at: 1) + try stepDone(delete) + + let insert = try prepare( + "INSERT INTO chapters(url, book_url, chapter_index, payload) VALUES(?, ?, ?, ?)" + ) + defer { sqlite3_finalize(insert) } + for (position, original) in chapters.enumerated() { + var chapter = original + chapter.bookUrl = bookURL + chapter.index = position + sqlite3_reset(insert) + sqlite3_clear_bindings(insert) + try bind(chapter.url, to: insert, at: 1) + try bind(bookURL, to: insert, at: 2) + sqlite3_bind_int64(insert, 3, Int64(position)) + try bind(try encoder.encode(chapter), to: insert, at: 4) + try stepDone(insert) + } + } + } + + public func removeChapters(bookURL: String) throws { + let statement = try prepare("DELETE FROM chapters WHERE book_url = ?") + defer { sqlite3_finalize(statement) } + try bind(bookURL, to: statement, at: 1) + try stepDone(statement) + } + + public func loadBookmarks() throws -> [Bookmark] { + try load("SELECT payload FROM bookmarks ORDER BY time", as: Bookmark.self) + } + + public func loadBookGroups() throws -> [BookGroup] { + try load("SELECT payload FROM book_groups ORDER BY position, group_id", as: BookGroup.self) + } + + public func replaceBookGroups(_ values: [BookGroup]) throws { + try replaceAll( + deleteSQL: "DELETE FROM book_groups", + insertSQL: "INSERT INTO book_groups(group_id, position, payload) VALUES(?, ?, ?)", + values: values + ) { statement, value, position, data in + sqlite3_bind_int64(statement, 1, value.groupId) + sqlite3_bind_int64(statement, 2, Int64(position)) + try self.bind(data, to: statement, at: 3) + } + } + + public func replaceBookmarks(_ values: [Bookmark]) throws { + try replaceAll( + deleteSQL: "DELETE FROM bookmarks", + insertSQL: "INSERT INTO bookmarks(time, payload) VALUES(?, ?)", + values: values + ) { statement, value, _, data in + sqlite3_bind_int64(statement, 1, value.time) + try self.bind(data, to: statement, at: 2) + } + } + + public func loadReadRecords() throws -> [ReadRecord] { + try load("SELECT payload FROM read_records ORDER BY last_read DESC", as: ReadRecord.self) + } + + public func replaceReadRecords(_ values: [ReadRecord]) throws { + try replaceAll( + deleteSQL: "DELETE FROM read_records", + insertSQL: "INSERT INTO read_records(device_id, book_name, last_read, payload) VALUES(?, ?, ?, ?)", + values: values + ) { statement, value, _, data in + try self.bind(value.deviceId, to: statement, at: 1) + try self.bind(value.bookName, to: statement, at: 2) + sqlite3_bind_int64(statement, 3, value.lastRead) + try self.bind(data, to: statement, at: 4) + } + } + + public func loadSearchKeywords(order: SearchKeywordOrder) throws -> [SearchKeyword] { + let orderSQL = switch order { + case .usage: "usage DESC, last_use_time DESC" + case .lastUseTime: "last_use_time DESC, usage DESC" + } + let statement = try prepare("SELECT word, usage, last_use_time FROM search_keywords ORDER BY \(orderSQL)") + defer { sqlite3_finalize(statement) } + var result: [SearchKeyword] = [] + while true { + let status = sqlite3_step(statement) + if status == SQLITE_DONE { return result } + guard status == SQLITE_ROW else { throw databaseError(.execute) } + result.append(.init( + word: columnString(statement, at: 0), + usage: Int(sqlite3_column_int64(statement, 1)), + lastUseTime: sqlite3_column_int64(statement, 2) + )) + } + } + + public func recordSearchKeyword(_ rawWord: String, at time: Int64) throws -> SearchKeyword { + let word = rawWord.trimmingCharacters(in: .whitespacesAndNewlines) + guard !word.isEmpty else { throw LegadoDatabaseError.bind("搜索词不能为空") } + try transaction { + let statement = try prepare(""" + INSERT INTO search_keywords(word, usage, last_use_time) VALUES(?, 1, ?) + ON CONFLICT(word) DO UPDATE SET + usage = search_keywords.usage + 1, + last_use_time = excluded.last_use_time + """) + defer { sqlite3_finalize(statement) } + try bind(word, to: statement, at: 1) + sqlite3_bind_int64(statement, 2, time) + try stepDone(statement) + } + let statement = try prepare("SELECT usage, last_use_time FROM search_keywords WHERE word = ?") + defer { sqlite3_finalize(statement) } + try bind(word, to: statement, at: 1) + guard sqlite3_step(statement) == SQLITE_ROW else { throw databaseError(.execute) } + return .init( + word: word, + usage: Int(sqlite3_column_int64(statement, 0)), + lastUseTime: sqlite3_column_int64(statement, 1) + ) + } + + public func deleteSearchKeyword(_ word: String) throws { + try executeBound("DELETE FROM search_keywords WHERE word = ?", text: word) + } + + public func deleteAllSearchKeywords() throws { try Self.execute(handle, "DELETE FROM search_keywords") } + + public func importLegacySearchKeywords(_ words: [String]) throws { + guard try count("search_keywords") == 0, !words.isEmpty else { return } + let now: Int64 = 0 + try transaction { + let statement = try prepare( + "INSERT OR IGNORE INTO search_keywords(word, usage, last_use_time) VALUES(?, 1, ?)" + ) + defer { sqlite3_finalize(statement) } + for (offset, rawWord) in words.enumerated() { + let word = rawWord.trimmingCharacters(in: .whitespacesAndNewlines) + guard !word.isEmpty else { continue } + sqlite3_reset(statement) + sqlite3_clear_bindings(statement) + try bind(word, to: statement, at: 1) + sqlite3_bind_int64(statement, 2, now - Int64(offset)) + try stepDone(statement) + } + } + } + + public func cacheValue(for key: String, now: Int64) throws -> String? { + let statement = try prepare( + "SELECT value FROM caches WHERE `key` = ? AND (deadline = 0 OR deadline > ?)" + ) + defer { sqlite3_finalize(statement) } + try bind(key, to: statement, at: 1) + sqlite3_bind_int64(statement, 2, now) + guard sqlite3_step(statement) == SQLITE_ROW else { return nil } + return sqlite3_column_type(statement, 0) == SQLITE_NULL ? nil : columnString(statement, at: 0) + } + + public func putCache(_ cache: LegadoCache) throws { + let statement = try prepare(""" + INSERT INTO caches(`key`, value, deadline) VALUES(?, ?, ?) + ON CONFLICT(`key`) DO UPDATE SET value = excluded.value, deadline = excluded.deadline + """) + defer { sqlite3_finalize(statement) } + try bind(cache.key, to: statement, at: 1) + if let value = cache.value { try bind(value, to: statement, at: 2) } + else { sqlite3_bind_null(statement, 2) } + sqlite3_bind_int64(statement, 3, cache.deadline) + try stepDone(statement) + } + + public func deleteCache(_ key: String) throws { + try executeBound("DELETE FROM caches WHERE `key` = ?", text: key) + } + + public func deleteSourceVariables(_ sourceKey: String) throws { + let statement = try prepare(""" + DELETE FROM caches WHERE `key` LIKE 'v_' || ? || '_%' + OR `key` = 'userInfo_' || ? + OR `key` = 'loginHeader_' || ? + OR `key` = 'sourceVariable_' || ? + OR `key` = 'infoMap_' || ? + """) + defer { sqlite3_finalize(statement) } + for index in 1...5 { try bind(sourceKey, to: statement, at: Int32(index)) } + try stepDone(statement) + } + + public func clearExpiredCaches(now: Int64) throws { + let statement = try prepare("DELETE FROM caches WHERE deadline > 0 AND deadline < ?") + defer { sqlite3_finalize(statement) } + sqlite3_bind_int64(statement, 1, now) + try stepDone(statement) + } + + public func cookie(for url: String) throws -> LegadoCookie? { + let statement = try prepare("SELECT cookie FROM cookies WHERE url = ?") + defer { sqlite3_finalize(statement) } + try bind(url, to: statement, at: 1) + guard sqlite3_step(statement) == SQLITE_ROW else { return nil } + return .init(url: url, cookie: columnString(statement, at: 0)) + } + + public func loadOkHTTPCookies() throws -> [LegadoCookie] { + let statement = try prepare("SELECT url, cookie FROM cookies WHERE url LIKE '%|%'") + defer { sqlite3_finalize(statement) } + var result: [LegadoCookie] = [] + while true { + let status = sqlite3_step(statement) + if status == SQLITE_DONE { return result } + guard status == SQLITE_ROW else { throw databaseError(.execute) } + result.append(.init(url: columnString(statement, at: 0), cookie: columnString(statement, at: 1))) + } + } + + public func putCookie(_ cookie: LegadoCookie) throws { + let statement = try prepare(""" + INSERT INTO cookies(url, cookie) VALUES(?, ?) + ON CONFLICT(url) DO UPDATE SET cookie = excluded.cookie + """) + defer { sqlite3_finalize(statement) } + try bind(cookie.url, to: statement, at: 1) + try bind(cookie.cookie, to: statement, at: 2) + try stepDone(statement) + } + + public func deleteCookie(_ url: String) throws { + try executeBound("DELETE FROM cookies WHERE url = ?", text: url) + } + + public func deleteOkHTTPCookies() throws { try Self.execute(handle, "DELETE FROM cookies WHERE url LIKE '%|%'") } + + public func loadSearchBooks() throws -> [SearchBook] { + try load("SELECT payload FROM searchBooks ORDER BY time DESC", as: SearchBook.self) + } + + public func cachedSearchMatches(keyword: String) throws -> [SearchBook] { + guard !keyword.isEmpty else { return [] } + return try load(""" + SELECT payload FROM searchBooks + WHERE instr(lower(name), lower(?1)) > 0 OR instr(lower(author), lower(?1)) > 0 + ORDER BY CASE WHEN name = ?1 OR author = ?1 THEN 0 ELSE 1 END, time DESC + LIMIT 200 + """, as: SearchBook.self, bindings: [keyword]) + } + + public func putSearchBooks(_ values: [SearchBook]) throws { + try transaction { + let statement = try prepare(""" + INSERT INTO searchBooks(book_url, origin, name, author, time, payload) + VALUES(?, ?, ?, ?, ?, ?) + ON CONFLICT(book_url) DO UPDATE SET + origin = excluded.origin, + name = excluded.name, + author = excluded.author, + time = excluded.time, + payload = excluded.payload + """) + defer { sqlite3_finalize(statement) } + for value in values { + sqlite3_reset(statement) + sqlite3_clear_bindings(statement) + try bind(value.bookURL, to: statement, at: 1) + try bind(value.origin, to: statement, at: 2) + try bind(value.name, to: statement, at: 3) + try bind(value.author, to: statement, at: 4) + sqlite3_bind_int64(statement, 5, value.time) + try bind(try encoder.encode(value), to: statement, at: 6) + try stepDone(statement) + } + } + } + + public func deleteSearchBooks(name: String, author: String) throws { + let statement = try prepare("DELETE FROM searchBooks WHERE name = ? AND author = ?") + defer { sqlite3_finalize(statement) } + try bind(name, to: statement, at: 1) + try bind(author, to: statement, at: 2) + try stepDone(statement) + } + + public func clearExpiredSearchBooks(before time: Int64) throws { + let statement = try prepare("DELETE FROM searchBooks WHERE time < ?") + defer { sqlite3_finalize(statement) } + sqlite3_bind_int64(statement, 1, time) + try stepDone(statement) + } + + public func loadTxtTocRules() throws -> [TxtTocRule] { + try load("SELECT payload FROM txtTocRules ORDER BY serial_number, id", as: TxtTocRule.self) + } + public func replaceTxtTocRules(_ values: [TxtTocRule]) throws { + try replaceAll( + deleteSQL: "DELETE FROM txtTocRules", + insertSQL: "INSERT INTO txtTocRules(id, serial_number, enabled, payload) VALUES(?, ?, ?, ?)", + values: values + ) { statement, value, _, data in + sqlite3_bind_int64(statement, 1, value.id) + sqlite3_bind_int64(statement, 2, Int64(value.serialNumber)) + sqlite3_bind_int(statement, 3, value.enable ? 1 : 0) + try self.bind(data, to: statement, at: 4) + } + } + + public func loadHttpTTS() throws -> [HttpTTS] { try load("SELECT payload FROM httpTTS ORDER BY name", as: HttpTTS.self) } + public func replaceHttpTTS(_ values: [HttpTTS]) throws { + try replaceAll( + deleteSQL: "DELETE FROM httpTTS", + insertSQL: "INSERT INTO httpTTS(id, name, payload) VALUES(?, ?, ?)", + values: values + ) { statement, value, _, data in + sqlite3_bind_int64(statement, 1, value.id) + try self.bind(value.name, to: statement, at: 2) + try self.bind(data, to: statement, at: 3) + } + } + + public func loadRuleSubs() throws -> [RuleSub] { + try load("SELECT payload FROM ruleSubs ORDER BY custom_order, id", as: RuleSub.self) + } + public func replaceRuleSubs(_ values: [RuleSub]) throws { + try replaceAll( + deleteSQL: "DELETE FROM ruleSubs", + insertSQL: "INSERT INTO ruleSubs(id, type, custom_order, payload) VALUES(?, ?, ?, ?)", + values: values + ) { statement, value, _, data in + sqlite3_bind_int64(statement, 1, value.id) + sqlite3_bind_int64(statement, 2, Int64(value.type)) + sqlite3_bind_int64(statement, 3, Int64(value.customOrder)) + try self.bind(data, to: statement, at: 4) + } + } + + public func loadKeyboardAssists() throws -> [KeyboardAssist] { + try load("SELECT payload FROM keyboardAssists ORDER BY serial_no", as: KeyboardAssist.self) + } + public func replaceKeyboardAssists(_ values: [KeyboardAssist]) throws { + try replaceAll( + deleteSQL: "DELETE FROM keyboardAssists", + insertSQL: "INSERT INTO keyboardAssists(type, key, serial_no, payload) VALUES(?, ?, ?, ?)", + values: values + ) { statement, value, _, data in + sqlite3_bind_int64(statement, 1, Int64(value.type)) + try self.bind(value.key, to: statement, at: 2) + sqlite3_bind_int64(statement, 3, Int64(value.serialNo)) + try self.bind(data, to: statement, at: 4) + } + } + + public func loadServers() throws -> [Server] { + try load("SELECT payload FROM servers ORDER BY sort_number, id", as: Server.self) + } + public func replaceServers(_ values: [Server]) throws { + try replaceAll( + deleteSQL: "DELETE FROM servers", + insertSQL: "INSERT INTO servers(id, sort_number, payload) VALUES(?, ?, ?)", + values: values + ) { statement, value, _, data in + sqlite3_bind_int64(statement, 1, value.id) + sqlite3_bind_int64(statement, 2, Int64(value.sortNumber)) + try self.bind(data, to: statement, at: 3) + } + } + + public func loadRssSources() throws -> [RssSource] { + try load("SELECT payload FROM rssSources ORDER BY custom_order", as: RssSource.self) + } + + public func replaceRssSources(_ values: [RssSource]) throws { + try replaceAll( + deleteSQL: "DELETE FROM rssSources", + insertSQL: "INSERT INTO rssSources(source_url, source_name, source_group, source_comment, enabled, custom_order, login_url, payload) VALUES(?, ?, ?, ?, ?, ?, ?, ?)", + values: values + ) { statement, value, _, data in + try self.bind(value.sourceUrl, to: statement, at: 1) + try self.bind(value.sourceName, to: statement, at: 2) + if let group = value.sourceGroup { try self.bind(group, to: statement, at: 3) } else { sqlite3_bind_null(statement, 3) } + if let comment = value.sourceComment { try self.bind(comment, to: statement, at: 4) } else { sqlite3_bind_null(statement, 4) } + sqlite3_bind_int(statement, 5, value.enabled ? 1 : 0) + sqlite3_bind_int64(statement, 6, Int64(value.customOrder)) + if let loginURL = value.loginUrl { try self.bind(loginURL, to: statement, at: 7) } else { sqlite3_bind_null(statement, 7) } + try self.bind(data, to: statement, at: 8) + } + } + + public func loadRssArticles(origin: String, sort: String) throws -> [RssArticle] { + let statement = try prepare("SELECT a.payload, EXISTS(SELECT 1 FROM rssReadRecords r WHERE r.record = a.link) FROM rssArticles a WHERE a.origin = ? AND a.sort = ? ORDER BY a.article_order DESC") + defer { sqlite3_finalize(statement) } + try bind(origin, to: statement, at: 1) + try bind(sort, to: statement, at: 2) + var result: [RssArticle] = [] + while true { + let status = sqlite3_step(statement) + if status == SQLITE_DONE { return result } + guard status == SQLITE_ROW else { throw databaseError(.execute) } + guard let bytes = sqlite3_column_blob(statement, 0) else { throw LegadoDatabaseError.decode("RSS文章记录缺少payload") } + var article = try decoder.decode(RssArticle.self, from: Data(bytes: bytes, count: Int(sqlite3_column_bytes(statement, 0)))) + article.read = sqlite3_column_int(statement, 1) != 0 + result.append(article) + } + } + + public func findRssArticle(origin: String, link: String, sort: String? = nil) throws -> RssArticle? { + let sql = sort == nil + ? "SELECT payload FROM rssArticles WHERE origin = ? AND link = ? LIMIT 1" + : "SELECT payload FROM rssArticles WHERE origin = ? AND link = ? AND sort = ? LIMIT 1" + let statement = try prepare(sql) + defer { sqlite3_finalize(statement) } + try bind(origin, to: statement, at: 1); try bind(link, to: statement, at: 2) + if let sort { try bind(sort, to: statement, at: 3) } + return try decodeFirst(statement, as: RssArticle.self) + } + + public func replaceRssArticles(_ values: [RssArticle], origin: String, sort: String) throws { + try transaction { + let delete = try prepare("DELETE FROM rssArticles WHERE origin = ? AND sort = ?") + defer { sqlite3_finalize(delete) } + try bind(origin, to: delete, at: 1); try bind(sort, to: delete, at: 2); try stepDone(delete) + try insertRssArticles(values, conflict: "REPLACE") + } + } + + public func appendRssArticles(_ values: [RssArticle]) throws { + try transaction { try insertRssArticles(values, conflict: "IGNORE") } + } + + public func putRssArticles(_ values: [RssArticle]) throws { + try transaction { try insertRssArticles(values, conflict: "REPLACE") } + } + + public func updateRssArticles(_ values: [RssArticle]) throws { + try transaction { + let statement = try prepare("UPDATE rssArticles SET article_order = ?, payload = ? WHERE origin = ? AND link = ? AND sort = ?") + defer { sqlite3_finalize(statement) } + for value in values { + sqlite3_reset(statement); sqlite3_clear_bindings(statement) + sqlite3_bind_int64(statement, 1, value.order); try bind(try encoder.encode(value), to: statement, at: 2) + try bind(value.origin, to: statement, at: 3); try bind(value.link, to: statement, at: 4) + try bind(value.sort, to: statement, at: 5); try stepDone(statement) + } + } + } + + public func updateRssArticleOrigin(from oldOrigin: String, to origin: String) throws { + let statement = try prepare("SELECT payload FROM rssArticles WHERE origin = ?") + defer { sqlite3_finalize(statement) } + try bind(oldOrigin, to: statement, at: 1) + var values = try decodeRows(statement, as: RssArticle.self) + values.indices.forEach { values[$0].origin = origin } + try transaction { + let update = try prepare("UPDATE rssArticles SET origin = ?, payload = ? WHERE origin = ? AND link = ? AND sort = ?") + defer { sqlite3_finalize(update) } + for value in values { + sqlite3_reset(update); sqlite3_clear_bindings(update) + try bind(origin, to: update, at: 1); try bind(try encoder.encode(value), to: update, at: 2) + try bind(oldOrigin, to: update, at: 3); try bind(value.link, to: update, at: 4) + try bind(value.sort, to: update, at: 5); try stepDone(update) + } + } + } + + public func clearOldRssArticles(origin: String, sort: String, before order: Int64) throws { + let statement = try prepare("DELETE FROM rssArticles WHERE origin = ? AND sort = ? AND article_order < ?") + defer { sqlite3_finalize(statement) } + try bind(origin, to: statement, at: 1); try bind(sort, to: statement, at: 2) + sqlite3_bind_int64(statement, 3, order); try stepDone(statement) + } + + public func removeRssArticles(origin: String) throws { + try executeBound("DELETE FROM rssArticles WHERE origin = ?", text: origin) + } + + public func loadRssReadRecords() throws -> [RssReadRecord] { + try load("SELECT payload FROM rssReadRecords ORDER BY read_time DESC", as: RssReadRecord.self) + } + + public func appendRssReadRecords(_ values: [RssReadRecord]) throws { + try transaction { + let statement = try prepare("INSERT OR IGNORE INTO rssReadRecords(record, origin, read_time, payload) VALUES(?, ?, ?, ?)") + defer { sqlite3_finalize(statement) } + for value in values { + sqlite3_reset(statement); sqlite3_clear_bindings(statement) + try bind(value.record, to: statement, at: 1); try bind(value.origin, to: statement, at: 2) + if let time = value.readTime { sqlite3_bind_int64(statement, 3, time) } else { sqlite3_bind_null(statement, 3) } + try bind(try encoder.encode(value), to: statement, at: 4); try stepDone(statement) + } + } + } + + public func updateRssReadRecords(_ values: [RssReadRecord]) throws { + try transaction { + let statement = try prepare("UPDATE rssReadRecords SET origin = ?, read_time = ?, payload = ? WHERE record = ?") + defer { sqlite3_finalize(statement) } + for value in values { + sqlite3_reset(statement); sqlite3_clear_bindings(statement) + try bind(value.origin, to: statement, at: 1) + if let time = value.readTime { sqlite3_bind_int64(statement, 2, time) } else { sqlite3_bind_null(statement, 2) } + try bind(try encoder.encode(value), to: statement, at: 3); try bind(value.record, to: statement, at: 4) + try stepDone(statement) + } + } + } + + public func findRssReadRecord(record: String, origin: String) throws -> RssReadRecord? { + let statement = try prepare("SELECT payload FROM rssReadRecords WHERE record = ? AND origin = ? LIMIT 1") + defer { sqlite3_finalize(statement) } + try bind(record, to: statement, at: 1); try bind(origin, to: statement, at: 2) + return try decodeFirst(statement, as: RssReadRecord.self) + } + + public func clearRssReadRecords() throws { try Self.execute(handle, "DELETE FROM rssReadRecords") } + + public func removeRssReadRecords(origin: String) throws { + try executeBound("DELETE FROM rssReadRecords WHERE origin = ?", text: origin) + } + + public func loadRssStars() throws -> [RssStar] { + try load("SELECT payload FROM rssStars ORDER BY star_time DESC", as: RssStar.self) + } + + public func replaceRssStars(_ values: [RssStar]) throws { + try replaceAll( + deleteSQL: "DELETE FROM rssStars", + insertSQL: "INSERT INTO rssStars(origin, link, star_time, group_name, payload) VALUES(?, ?, ?, ?, ?)", + values: values + ) { statement, value, _, data in + try self.bind(value.origin, to: statement, at: 1); try self.bind(value.link, to: statement, at: 2) + sqlite3_bind_int64(statement, 3, value.starTime); try self.bind(value.group, to: statement, at: 4) + try self.bind(data, to: statement, at: 5) + } + } + + public func updateRssStars(_ values: [RssStar]) throws { + try transaction { + let statement = try prepare("INSERT OR REPLACE INTO rssStars(origin, link, star_time, group_name, payload) VALUES(?, ?, ?, ?, ?)") + defer { sqlite3_finalize(statement) } + for value in values { + sqlite3_reset(statement); sqlite3_clear_bindings(statement) + try bind(value.origin, to: statement, at: 1); try bind(value.link, to: statement, at: 2) + sqlite3_bind_int64(statement, 3, value.starTime); try bind(value.group, to: statement, at: 4) + try bind(try encoder.encode(value), to: statement, at: 5); try stepDone(statement) + } + } + } + + public func findRssStar(origin: String, link: String) throws -> RssStar? { + let statement = try prepare("SELECT payload FROM rssStars WHERE origin = ? AND link = ? LIMIT 1") + defer { sqlite3_finalize(statement) } + try bind(origin, to: statement, at: 1); try bind(link, to: statement, at: 2) + return try decodeFirst(statement, as: RssStar.self) + } + + public func removeRssStars(origin: String, link: String? = nil) throws { + let sql = link == nil ? "DELETE FROM rssStars WHERE origin = ?" : "DELETE FROM rssStars WHERE origin = ? AND link = ?" + let statement = try prepare(sql) + defer { sqlite3_finalize(statement) } + try bind(origin, to: statement, at: 1); if let link { try bind(link, to: statement, at: 2) } + try stepDone(statement) + } + + public func removeRssStars(group: String) throws { try executeBound("DELETE FROM rssStars WHERE group_name = ?", text: group) } + public func clearRssStars() throws { try Self.execute(handle, "DELETE FROM rssStars") } + + public func updateRssStarOrigin(from oldOrigin: String, to origin: String) throws { + let statement = try prepare("SELECT payload FROM rssStars WHERE origin = ?") + defer { sqlite3_finalize(statement) } + try bind(oldOrigin, to: statement, at: 1) + var values = try decodeRows(statement, as: RssStar.self) + values.indices.forEach { values[$0].origin = origin } + try transaction { + let update = try prepare("UPDATE rssStars SET origin = ?, payload = ? WHERE origin = ? AND link = ?") + defer { sqlite3_finalize(update) } + for value in values { + sqlite3_reset(update); sqlite3_clear_bindings(update) + try bind(origin, to: update, at: 1); try bind(try encoder.encode(value), to: update, at: 2) + try bind(oldOrigin, to: update, at: 3); try bind(value.link, to: update, at: 4) + try stepDone(update) + } + } + } + + private func insertRssArticles(_ values: [RssArticle], conflict: String) throws { + let statement = try prepare("INSERT OR \(conflict) INTO rssArticles(origin, link, sort, article_order, payload) VALUES(?, ?, ?, ?, ?)") + defer { sqlite3_finalize(statement) } + for value in values { + sqlite3_reset(statement); sqlite3_clear_bindings(statement) + try bind(value.origin, to: statement, at: 1); try bind(value.link, to: statement, at: 2) + try bind(value.sort, to: statement, at: 3); sqlite3_bind_int64(statement, 4, value.order) + try bind(try encoder.encode(value), to: statement, at: 5); try stepDone(statement) + } + } + + public func importLegacyJSON( + bookSourcesURL: URL?, + booksURL: URL?, + chaptersDirectory: URL? = nil, + bookGroupsURL: URL? = nil, + bookmarksURL: URL? = nil, + readRecordsURL: URL? = nil + ) throws { + if try count("book_sources") == 0, + let bookSourcesURL, + FileManager.default.fileExists(atPath: bookSourcesURL.path) { + try replaceBookSources(decoder.decode([BookSource].self, from: Data(contentsOf: bookSourcesURL))) + } + if try count("books") == 0, + let booksURL, + FileManager.default.fileExists(atPath: booksURL.path) { + try replaceBooks(decoder.decode([Book].self, from: Data(contentsOf: booksURL))) + } + if let chaptersDirectory, !legacyChaptersImported() { + if try count("chapters") == 0, + FileManager.default.fileExists(atPath: chaptersDirectory.path) { + for url in try FileManager.default.contentsOfDirectory(at: chaptersDirectory, includingPropertiesForKeys: nil) + where url.pathExtension.lowercased() == "json" { + let chapters = try decoder.decode([BookChapter].self, from: Data(contentsOf: url)) + if let bookURL = chapters.first?.bookUrl, !bookURL.isEmpty { + try replaceChapters(chapters, bookURL: bookURL) + } + } + } + try Self.execute(handle, "INSERT OR IGNORE INTO legacy_imports(key) VALUES('chapters')") + } + if try count("bookmarks") == 0, + let bookmarksURL, + FileManager.default.fileExists(atPath: bookmarksURL.path) { + try replaceBookmarks(decoder.decode([Bookmark].self, from: Data(contentsOf: bookmarksURL))) + } + if try count("book_groups") == 0, + let bookGroupsURL, + FileManager.default.fileExists(atPath: bookGroupsURL.path) { + try replaceBookGroups(decoder.decode([BookGroup].self, from: Data(contentsOf: bookGroupsURL))) + } + if try count("read_records") == 0, + let readRecordsURL, + FileManager.default.fileExists(atPath: readRecordsURL.path) { + try replaceReadRecords(decoder.decode([ReadRecord].self, from: Data(contentsOf: readRecordsURL))) + } + } + + private static func migrate(_ database: OpaquePointer) throws { + let version = try scalarInt32(database, "PRAGMA user_version") + guard version <= schemaVersion else { + throw LegadoDatabaseError.open("数据库版本 \(version) 高于当前支持版本 \(schemaVersion)") + } + if version < 1 { + try execute(database, "BEGIN IMMEDIATE") + do { + try execute(database, """ + CREATE TABLE book_sources( + book_source_url TEXT PRIMARY KEY NOT NULL, + position INTEGER NOT NULL, + payload BLOB NOT NULL + ); + CREATE INDEX book_sources_position ON book_sources(position); + CREATE TABLE books( + book_url TEXT PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + author TEXT NOT NULL, + position INTEGER NOT NULL, + payload BLOB NOT NULL + ); + CREATE UNIQUE INDEX books_name_author ON books(name, author); + CREATE INDEX books_position ON books(position); + CREATE TABLE chapters( + url TEXT NOT NULL, + book_url TEXT NOT NULL, + chapter_index INTEGER NOT NULL, + payload BLOB NOT NULL, + PRIMARY KEY(url, book_url), + UNIQUE(book_url, chapter_index), + FOREIGN KEY(book_url) REFERENCES books(book_url) + ON UPDATE CASCADE ON DELETE CASCADE + ); + CREATE INDEX chapters_book_url ON chapters(book_url); + PRAGMA user_version = 1; + """) + try execute(database, "COMMIT") + } catch { + try? execute(database, "ROLLBACK") + throw error + } + } + if version < 2 { + try execute(database, "BEGIN IMMEDIATE") + do { + try execute(database, """ + CREATE TABLE book_groups( + group_id INTEGER PRIMARY KEY NOT NULL, + position INTEGER NOT NULL, + payload BLOB NOT NULL + ); + CREATE TABLE bookmarks( + time INTEGER PRIMARY KEY NOT NULL, + payload BLOB NOT NULL + ); + CREATE TABLE read_records( + device_id TEXT NOT NULL, + book_name TEXT NOT NULL, + last_read INTEGER NOT NULL, + payload BLOB NOT NULL, + PRIMARY KEY(device_id, book_name) + ); + CREATE INDEX read_records_last_read ON read_records(last_read DESC); + PRAGMA user_version = 2; + """) + try execute(database, "COMMIT") + } catch { + try? execute(database, "ROLLBACK") + throw error + } + } + if version < 3 { + try execute(database, "BEGIN IMMEDIATE") + do { + try execute(database, """ + CREATE TABLE search_keywords( + word TEXT PRIMARY KEY NOT NULL, + usage INTEGER NOT NULL DEFAULT 1, + last_use_time INTEGER NOT NULL + ); + CREATE TABLE caches( + `key` TEXT PRIMARY KEY NOT NULL, + value TEXT, + deadline INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE cookies( + url TEXT PRIMARY KEY NOT NULL, + cookie TEXT NOT NULL DEFAULT '' + ); + CREATE TABLE searchBooks( + book_url TEXT PRIMARY KEY NOT NULL, + origin TEXT NOT NULL, + payload BLOB NOT NULL, + FOREIGN KEY(origin) REFERENCES book_sources(book_source_url) ON DELETE CASCADE + ); + CREATE INDEX searchBooks_origin ON searchBooks(origin); + PRAGMA user_version = 3; + """) + try execute(database, "COMMIT") + } catch { + try? execute(database, "ROLLBACK") + throw error + } + } + if version < 4 { + try execute(database, "BEGIN IMMEDIATE") + do { + try execute(database, """ + ALTER TABLE book_sources ADD COLUMN book_source_group TEXT; + ALTER TABLE book_sources ADD COLUMN custom_order INTEGER NOT NULL DEFAULT 0; + ALTER TABLE book_sources ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1; + CREATE INDEX book_sources_group ON book_sources(book_source_group); + CREATE INDEX book_sources_enabled_order ON book_sources(enabled, custom_order); + ALTER TABLE searchBooks ADD COLUMN name TEXT NOT NULL DEFAULT ''; + ALTER TABLE searchBooks ADD COLUMN author TEXT NOT NULL DEFAULT ''; + ALTER TABLE searchBooks ADD COLUMN time INTEGER NOT NULL DEFAULT 0; + CREATE INDEX searchBooks_name_author ON searchBooks(name, author); + CREATE INDEX searchBooks_time ON searchBooks(time); + PRAGMA user_version = 4; + """) + try execute(database, "COMMIT") + } catch { + try? execute(database, "ROLLBACK") + throw error + } + } + if version < 5 { + try execute(database, "BEGIN IMMEDIATE") + do { + try execute(database, """ + CREATE TABLE txtTocRules( + id INTEGER PRIMARY KEY NOT NULL, + serial_number INTEGER NOT NULL DEFAULT -1, + enabled INTEGER NOT NULL DEFAULT 1, + payload BLOB NOT NULL + ); + -- Retain the historical v5 schema for existing databases; no dictionary APIs remain. + CREATE TABLE dictRules( + name TEXT PRIMARY KEY NOT NULL, + sort_number INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + payload BLOB NOT NULL + ); + CREATE TABLE httpTTS( + id INTEGER PRIMARY KEY NOT NULL, + name TEXT NOT NULL, + payload BLOB NOT NULL + ); + CREATE TABLE ruleSubs( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + type INTEGER NOT NULL DEFAULT 0, + custom_order INTEGER NOT NULL DEFAULT 0, + payload BLOB NOT NULL + ); + CREATE TABLE keyboardAssists( + type INTEGER NOT NULL DEFAULT 0, + key TEXT NOT NULL, + serial_no INTEGER NOT NULL DEFAULT 0, + payload BLOB NOT NULL, + PRIMARY KEY(type, key) + ); + CREATE TABLE servers( + id INTEGER PRIMARY KEY NOT NULL, + sort_number INTEGER NOT NULL DEFAULT 0, + payload BLOB NOT NULL + ); + PRAGMA user_version = 5; + """) + try execute(database, "COMMIT") + } catch { + try? execute(database, "ROLLBACK") + throw error + } + } + if version < 6 { + try execute(database, "BEGIN IMMEDIATE") + do { + try execute(database, """ + CREATE TABLE rssSources( + source_url TEXT PRIMARY KEY NOT NULL, + source_name TEXT NOT NULL, + source_group TEXT, + source_comment TEXT, + enabled INTEGER NOT NULL DEFAULT 1, + custom_order INTEGER NOT NULL DEFAULT 0, + login_url TEXT, + payload BLOB NOT NULL + ); + CREATE INDEX rssSources_order ON rssSources(custom_order, source_url); + CREATE TABLE rssArticles( + origin TEXT NOT NULL, + link TEXT NOT NULL, + sort TEXT NOT NULL, + article_order INTEGER NOT NULL DEFAULT 0, + payload BLOB NOT NULL, + PRIMARY KEY(origin, link, sort) + ); + CREATE INDEX rssArticles_origin_sort_order ON rssArticles(origin, sort, article_order DESC); + CREATE TABLE rssReadRecords( + record TEXT PRIMARY KEY NOT NULL, + origin TEXT NOT NULL, + read_time INTEGER, + payload BLOB NOT NULL + ); + CREATE INDEX rssReadRecords_time ON rssReadRecords(read_time DESC); + CREATE INDEX rssReadRecords_origin ON rssReadRecords(origin); + CREATE TABLE rssStars( + origin TEXT NOT NULL, + link TEXT NOT NULL, + star_time INTEGER NOT NULL DEFAULT 0, + group_name TEXT NOT NULL DEFAULT '默认分组', + payload BLOB NOT NULL, + PRIMARY KEY(origin, link) + ); + CREATE INDEX rssStars_time ON rssStars(star_time DESC); + PRAGMA user_version = 6; + """) + try execute(database, "COMMIT") + } catch { + try? execute(database, "ROLLBACK") + throw error + } + } + if version < 7 { + try execute(database, "BEGIN IMMEDIATE") + do { + try execute(database, """ + CREATE TABLE legacy_imports(key TEXT PRIMARY KEY NOT NULL); + PRAGMA user_version = 7; + """) + if version > 0, try scalarInt32(database, "SELECT COUNT(*) FROM chapters") > 0 { + try execute(database, "INSERT INTO legacy_imports(key) VALUES('chapters')") + } + try execute(database, "COMMIT") + } catch { + try? execute(database, "ROLLBACK") + throw error + } + } + } + + private func legacyChaptersImported() -> Bool { + (try? Self.scalarInt32(handle, "SELECT COUNT(*) FROM legacy_imports WHERE key = 'chapters'")) == 1 + } + + private func load(_ sql: String, as type: T.Type, bindings: [String] = []) throws -> [T] { + let statement = try prepare(sql) + defer { sqlite3_finalize(statement) } + for (index, value) in bindings.enumerated() { try bind(value, to: statement, at: Int32(index + 1)) } + return try decodeRows(statement, as: type) + } + + private func decodeRows(_ statement: OpaquePointer, as type: T.Type) throws -> [T] { + var result: [T] = [] + while true { + let status = sqlite3_step(statement) + if status == SQLITE_DONE { return result } + guard status == SQLITE_ROW else { throw databaseError(.execute) } + guard let bytes = sqlite3_column_blob(statement, 0) else { + throw LegadoDatabaseError.decode("数据库记录缺少payload") + } + let count = Int(sqlite3_column_bytes(statement, 0)) + do { result.append(try decoder.decode(T.self, from: Data(bytes: bytes, count: count))) } + catch { throw LegadoDatabaseError.decode(error.localizedDescription) } + } + } + + private func decodeFirst(_ statement: OpaquePointer, as type: T.Type) throws -> T? { + let status = sqlite3_step(statement) + if status == SQLITE_DONE { return nil } + guard status == SQLITE_ROW, let bytes = sqlite3_column_blob(statement, 0) else { + throw databaseError(.execute) + } + do { + return try decoder.decode(T.self, from: Data(bytes: bytes, count: Int(sqlite3_column_bytes(statement, 0)))) + } catch { + throw LegadoDatabaseError.decode(error.localizedDescription) + } + } + + private func replaceAll( + deleteSQL: String, + insertSQL: String, + values: [T], + binder: (OpaquePointer, T, Int, Data) throws -> Void + ) throws { + try transaction { + try Self.execute(handle, deleteSQL) + let statement = try prepare(insertSQL) + defer { sqlite3_finalize(statement) } + for (position, value) in values.enumerated() { + sqlite3_reset(statement) + sqlite3_clear_bindings(statement) + try binder(statement, value, position, try encoder.encode(value)) + try stepDone(statement) + } + } + } + + private func transaction(_ operation: () throws -> Void) throws { + try Self.execute(handle, "BEGIN IMMEDIATE") + do { + try operation() + try Self.execute(handle, "COMMIT") + } catch { + try? Self.execute(handle, "ROLLBACK") + throw error + } + } + + private func count(_ table: String) throws -> Int64 { + let allowed = [ + "book_sources", "books", "chapters", "book_groups", "bookmarks", "read_records", + "search_keywords", "caches", "cookies", "searchBooks" + , "txtTocRules", "httpTTS", "ruleSubs", "keyboardAssists", "servers", + "rssSources", "rssArticles", "rssReadRecords", "rssStars" + ] + guard allowed.contains(table) else { throw LegadoDatabaseError.execute("非法表名") } + let statement = try prepare("SELECT COUNT(*) FROM \(table)") + defer { sqlite3_finalize(statement) } + guard sqlite3_step(statement) == SQLITE_ROW else { throw databaseError(.execute) } + return sqlite3_column_int64(statement, 0) + } + + private func prepare(_ sql: String) throws -> OpaquePointer { + var statement: OpaquePointer? + guard sqlite3_prepare_v2(handle, sql, -1, &statement, nil) == SQLITE_OK, let statement else { + throw databaseError(.prepare) + } + return statement + } + + private func bind(_ value: String, to statement: OpaquePointer, at index: Int32) throws { + guard sqlite3_bind_text(statement, index, value, -1, Self.transient) == SQLITE_OK else { + throw databaseError(.bind) + } + } + + private func bind(_ value: Data, to statement: OpaquePointer, at index: Int32) throws { + let status = value.withUnsafeBytes { + sqlite3_bind_blob(statement, index, $0.baseAddress, Int32($0.count), Self.transient) + } + guard status == SQLITE_OK else { throw databaseError(.bind) } + } + + private func stepDone(_ statement: OpaquePointer) throws { + guard sqlite3_step(statement) == SQLITE_DONE else { throw databaseError(.execute) } + } + + private func executeBound(_ sql: String, text: String) throws { + let statement = try prepare(sql) + defer { sqlite3_finalize(statement) } + try bind(text, to: statement, at: 1) + try stepDone(statement) + } + + private func columnString(_ statement: OpaquePointer, at index: Int32) -> String { + guard let value = sqlite3_column_text(statement, index) else { return "" } + return String(cString: value) + } + + private enum ErrorKind { case execute, prepare, bind } + private func databaseError(_ kind: ErrorKind) -> LegadoDatabaseError { + let message = String(cString: sqlite3_errmsg(handle)) + switch kind { + case .execute: return LegadoDatabaseError.execute(message) + case .prepare: return LegadoDatabaseError.prepare(message) + case .bind: return LegadoDatabaseError.bind(message) + } + } + + private static func execute(_ database: OpaquePointer, _ sql: String) throws { + var error: UnsafeMutablePointer? + guard sqlite3_exec(database, sql, nil, nil, &error) == SQLITE_OK else { + let message = error.map { String(cString: $0) } ?? String(cString: sqlite3_errmsg(database)) + sqlite3_free(error) + throw LegadoDatabaseError.execute(message) + } + } + + private static func scalarInt32(_ database: OpaquePointer, _ sql: String) throws -> Int32 { + var statement: OpaquePointer? + guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, + let statement else { + throw LegadoDatabaseError.prepare(String(cString: sqlite3_errmsg(database))) + } + defer { sqlite3_finalize(statement) } + guard sqlite3_step(statement) == SQLITE_ROW else { + throw LegadoDatabaseError.execute(String(cString: sqlite3_errmsg(database))) + } + return sqlite3_column_int(statement, 0) + } + + private static let transient = unsafeBitCast(-1, to: sqlite3_destructor_type.self) +} + +public actor SQLiteBookSourceRepository: BookSourceRepository { + private let database: LegadoDatabase + private let legacyFileURL: URL? + public init(database: LegadoDatabase, legacyFileURL: URL? = nil) { + self.database = database + self.legacyFileURL = legacyFileURL + } + public func load() async throws -> [BookSource] { + try await database.importLegacyJSON(bookSourcesURL: legacyFileURL, booksURL: nil) + return try await database.loadBookSources() + } + public func save(_ sources: [BookSource]) async throws { try await database.replaceBookSources(sources) } +} + +public actor SQLiteBookRepository: BookRepository { + private let database: LegadoDatabase + private let legacyFileURL: URL? + public init(database: LegadoDatabase, legacyFileURL: URL? = nil) { + self.database = database + self.legacyFileURL = legacyFileURL + } + public func load() async throws -> [Book] { + try await database.importLegacyJSON(bookSourcesURL: nil, booksURL: legacyFileURL) + return try await database.loadBooks() + } + public func save(_ books: [Book]) async throws { try await database.replaceBooks(books) } +} + +public actor SQLiteBookChapterRepository: BookChapterRepository { + private let database: LegadoDatabase + private let legacyDirectory: URL? + public init(database: LegadoDatabase, legacyDirectory: URL? = nil) { + self.database = database + self.legacyDirectory = legacyDirectory + } + public func load(bookURL: String) async throws -> [BookChapter] { + try await database.importLegacyJSON( + bookSourcesURL: nil, + booksURL: nil, + chaptersDirectory: legacyDirectory + ) + return try await database.loadChapters(bookURL: bookURL) + } + public func save(_ chapters: [BookChapter], bookURL: String) async throws { + try await database.replaceChapters(chapters, bookURL: bookURL) + } + public func remove(bookURL: String) async throws { try await database.removeChapters(bookURL: bookURL) } +} + +public actor SQLiteReadingActivityRepository: ReadingActivityRepository { + private let database: LegadoDatabase + private let legacyBookmarksURL: URL? + private let legacyRecordsURL: URL? + + public init( + database: LegadoDatabase, + legacyBookmarksURL: URL? = nil, + legacyRecordsURL: URL? = nil + ) { + self.database = database + self.legacyBookmarksURL = legacyBookmarksURL + self.legacyRecordsURL = legacyRecordsURL + } + + public func loadBookmarks() async throws -> [Bookmark] { + try await migrate() + return try await database.loadBookmarks() + } + + public func saveBookmarks(_ bookmarks: [Bookmark]) async throws { + try await database.replaceBookmarks(bookmarks) + } + + public func loadReadRecords() async throws -> [ReadRecord] { + try await migrate() + return try await database.loadReadRecords() + } + + public func saveReadRecords(_ records: [ReadRecord]) async throws { + try await database.replaceReadRecords(records) + } + + private func migrate() async throws { + try await database.importLegacyJSON( + bookSourcesURL: nil, + booksURL: nil, + bookmarksURL: legacyBookmarksURL, + readRecordsURL: legacyRecordsURL + ) + } +} + +public actor SQLiteBookGroupRepository: BookGroupRepository { + private let database: LegadoDatabase + private let legacyFileURL: URL? + public init(database: LegadoDatabase, legacyFileURL: URL? = nil) { + self.database = database + self.legacyFileURL = legacyFileURL + } + public func load() async throws -> [BookGroup] { + try await database.importLegacyJSON( + bookSourcesURL: nil, + booksURL: nil, + bookGroupsURL: legacyFileURL + ) + return try await database.loadBookGroups() + } + public func save(_ groups: [BookGroup]) async throws { try await database.replaceBookGroups(groups) } +} diff --git a/Sources/LegadoCore/Persistence/RSSRepository.swift b/Sources/LegadoCore/Persistence/RSSRepository.swift new file mode 100644 index 0000000..e36a8b9 --- /dev/null +++ b/Sources/LegadoCore/Persistence/RSSRepository.swift @@ -0,0 +1,158 @@ +import Foundation + +public protocol RssSourceRepository: Sendable { + func all() async throws -> [RssSource] + func enabled() async throws -> [RssSource] + func disabled() async throws -> [RssSource] + func groups() async throws -> [String] + func inGroup(_ group: String) async throws -> [RssSource] + func search(_ key: String) async throws -> [RssSource] + func save(_ values: [RssSource]) async throws + func setEnabled(_ value: Bool, sourceURL: String) async throws + func remove(sourceURL: String) async throws +} + +public actor SQLiteRssSourceRepository: RssSourceRepository { + private let database: LegadoDatabase + public init(database: LegadoDatabase) { self.database = database } + + public func all() async throws -> [RssSource] { try await database.loadRssSources() } + public func enabled() async throws -> [RssSource] { try await all().filter(\.enabled) } + public func disabled() async throws -> [RssSource] { try await all().filter { !$0.enabled } } + public func groups() async throws -> [String] { + var seen = Set() + return try await all().flatMap(\.groups).filter { seen.insert($0).inserted } + .sorted { $0.localizedStandardCompare($1) == .orderedAscending } + } + public func inGroup(_ group: String) async throws -> [RssSource] { + try await all().filter { $0.groups.contains(group) } + } + public func search(_ key: String) async throws -> [RssSource] { + guard !key.isEmpty else { return try await all() } + return try await all().filter { + [$0.sourceName, $0.sourceUrl, $0.sourceGroup ?? "", $0.sourceComment ?? ""] + .contains { $0.localizedCaseInsensitiveContains(key) } + } + } + public func enabled(search key: String) async throws -> [RssSource] { + try await search(key).filter(\.enabled) + } + public func enabled(inGroup group: String) async throws -> [RssSource] { + try await inGroup(group).filter(\.enabled) + } + public func loginSources() async throws -> [RssSource] { + try await all().filter { !($0.loginUrl ?? "").isEmpty } + } + public func noGroup() async throws -> [RssSource] { + try await all().filter { $0.groups.isEmpty || $0.groups.contains("未分组") } + } + public func contains(sourceURL: String) async throws -> Bool { + try await all().contains { $0.sourceUrl == sourceURL } + } + public func source(for sourceURL: String) async throws -> RssSource? { + try await all().first { $0.sourceUrl == sourceURL } + } + public func save(_ values: [RssSource]) async throws { try await database.replaceRssSources(values) } + public func setEnabled(_ value: Bool, sourceURL: String) async throws { + var values = try await all() + guard let index = values.firstIndex(where: { $0.sourceUrl == sourceURL }) else { return } + values[index].enabled = value + try await save(values) + } + public func remove(sourceURL: String) async throws { + let values = try await all().filter { $0.sourceUrl != sourceURL } + try await save(values) + try await database.removeRssArticles(origin: sourceURL) + try await database.deleteSourceVariables(sourceURL) + } +} + +public protocol RssArticleRepository: Sendable { + func articles(origin: String, sort: String) async throws -> [RssArticle] + func replace(_ values: [RssArticle], origin: String, sort: String) async throws + func append(_ values: [RssArticle]) async throws + func put(_ values: [RssArticle]) async throws + func clearOld(origin: String, sort: String, before order: Int64) async throws + func remove(origin: String) async throws + func article(origin: String, link: String, sort: String) async throws -> RssArticle? + func article(origin: String, link: String) async throws -> RssArticle? + func update(_ values: [RssArticle]) async throws + func updateOrigin(from oldOrigin: String, to origin: String) async throws +} + +public actor SQLiteRssArticleRepository: RssArticleRepository { + private let database: LegadoDatabase + public init(database: LegadoDatabase) { self.database = database } + public func articles(origin: String, sort: String) async throws -> [RssArticle] { + try await database.loadRssArticles(origin: origin, sort: sort) + } + public func replace(_ values: [RssArticle], origin: String, sort: String) async throws { + try await database.replaceRssArticles(values, origin: origin, sort: sort) + } + public func append(_ values: [RssArticle]) async throws { try await database.appendRssArticles(values) } + public func put(_ values: [RssArticle]) async throws { try await database.putRssArticles(values) } + public func clearOld(origin: String, sort: String, before order: Int64) async throws { + try await database.clearOldRssArticles(origin: origin, sort: sort, before: order) + } + public func remove(origin: String) async throws { try await database.removeRssArticles(origin: origin) } + public func article(origin: String, link: String, sort: String) async throws -> RssArticle? { + try await database.findRssArticle(origin: origin, link: link, sort: sort) + } + public func article(origin: String, link: String) async throws -> RssArticle? { + try await database.findRssArticle(origin: origin, link: link) + } + public func update(_ values: [RssArticle]) async throws { try await database.updateRssArticles(values) } + public func updateOrigin(from oldOrigin: String, to origin: String) async throws { + try await database.updateRssArticleOrigin(from: oldOrigin, to: origin) + } +} + +public actor SQLiteRssReadRecordRepository { + private let database: LegadoDatabase + public init(database: LegadoDatabase) { self.database = database } + public func all() async throws -> [RssReadRecord] { try await database.loadRssReadRecords() } + public func forOrigin(_ origin: String) async throws -> [RssReadRecord] { + try await all().filter { $0.origin == origin } + } + public func insertIgnoringExisting(_ value: RssReadRecord) async throws { + try await database.appendRssReadRecords([value]) + } + public func record(_ record: String, origin: String) async throws -> RssReadRecord? { + try await database.findRssReadRecord(record: record, origin: origin) + } + public func update(_ values: [RssReadRecord]) async throws { try await database.updateRssReadRecords(values) } + public func clear() async throws { try await database.clearRssReadRecords() } + public func remove(origin: String) async throws { try await database.removeRssReadRecords(origin: origin) } +} + +public actor SQLiteRssStarRepository { + private let database: LegadoDatabase + public init(database: LegadoDatabase) { self.database = database } + public func all() async throws -> [RssStar] { try await database.loadRssStars() } + public func groups() async throws -> [String] { + var seen = Set() + return try await all().map(\.group).filter { seen.insert($0).inserted }.sorted() + } + public func inGroup(_ group: String) async throws -> [RssStar] { + try await all().filter { $0.group == group } + } + public func save(_ value: RssStar) async throws { + try await database.updateRssStars([value]) + } + public func remove(origin: String, link: String) async throws { + try await database.removeRssStars(origin: origin, link: link) + } + public func star(origin: String, link: String) async throws -> RssStar? { try await database.findRssStar(origin: origin, link: link) } + public func remove(origin: String) async throws { try await database.removeRssStars(origin: origin) } + public func remove(group: String) async throws { try await database.removeRssStars(group: group) } + public func clear() async throws { try await database.clearRssStars() } + public func updateOrigin(from oldOrigin: String, to origin: String) async throws { + try await database.updateRssStarOrigin(from: oldOrigin, to: origin) + } +} + +public enum RSSRepositoryFactory { + public static func database(url: URL = LegadoDatabase.defaultURL()) throws -> LegadoDatabase { + try LegadoDatabase(url: url) + } +} diff --git a/Sources/LegadoCore/Persistence/ReadingActivityRepository.swift b/Sources/LegadoCore/Persistence/ReadingActivityRepository.swift new file mode 100644 index 0000000..e2315d1 --- /dev/null +++ b/Sources/LegadoCore/Persistence/ReadingActivityRepository.swift @@ -0,0 +1,227 @@ +import Foundation + +public protocol ReadingActivityRepository: Sendable { + func loadBookmarks() async throws -> [Bookmark] + func saveBookmarks(_ bookmarks: [Bookmark]) async throws + func loadReadRecords() async throws -> [ReadRecord] + func saveReadRecords(_ records: [ReadRecord]) async throws +} + +public actor FileReadingActivityRepository: ReadingActivityRepository { + private let bookmarksURL: URL + private let recordsURL: URL + + public init(bookmarksURL: URL, recordsURL: URL) { + self.bookmarksURL = bookmarksURL + self.recordsURL = recordsURL + } + + public func loadBookmarks() throws -> [Bookmark] { try load([Bookmark].self, from: bookmarksURL) } + public func saveBookmarks(_ bookmarks: [Bookmark]) throws { try save(bookmarks, to: bookmarksURL) } + public func loadReadRecords() throws -> [ReadRecord] { try load([ReadRecord].self, from: recordsURL) } + public func saveReadRecords(_ records: [ReadRecord]) throws { try save(records, to: recordsURL) } + + private func load(_ type: T.Type, from url: URL) throws -> T where T: ExpressibleByArrayLiteral { + guard FileManager.default.fileExists(atPath: url.path) else { return [] } + return try JSONDecoder().decode(type, from: Data(contentsOf: url)) + } + + private func save(_ value: T, to url: URL) throws { + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try encoder.encode(value).write(to: url, options: .atomic) + } +} + +public actor InMemoryReadingActivityRepository: ReadingActivityRepository { + private var bookmarks: [Bookmark] + private var records: [ReadRecord] + + public init(bookmarks: [Bookmark] = [], records: [ReadRecord] = []) { + self.bookmarks = bookmarks + self.records = records + } + + public func loadBookmarks() -> [Bookmark] { bookmarks } + public func saveBookmarks(_ bookmarks: [Bookmark]) { self.bookmarks = bookmarks } + public func loadReadRecords() -> [ReadRecord] { records } + public func saveReadRecords(_ records: [ReadRecord]) { self.records = records } +} + +public struct ReadingActivityService: Sendable { + public init() {} + + public func sortedBookmarks(_ bookmarks: [Bookmark]) -> [Bookmark] { + bookmarks.sorted { + let name = $0.bookName.localizedStandardCompare($1.bookName) + if name != .orderedSame { return name == .orderedAscending } + let author = $0.bookAuthor.localizedStandardCompare($1.bookAuthor) + if author != .orderedSame { return author == .orderedAscending } + if $0.chapterIndex != $1.chapterIndex { return $0.chapterIndex < $1.chapterIndex } + return $0.chapterPosition < $1.chapterPosition + } + } + + public func summaries(_ records: [ReadRecord]) -> [ReadRecordSummary] { + Dictionary(grouping: records, by: \.bookName) + .map { name, values in + ReadRecordSummary( + bookName: name, + readTime: values.reduce(0) { $0 + max($1.readTime, 0) }, + lastRead: values.map(\.lastRead).max() ?? 0 + ) + } + .sorted { $0.lastRead > $1.lastRead } + } + + public func displayedSummaries( + _ records: [ReadRecord], + searchKey: String, + sortMode: Int + ) -> [ReadRecordSummary] { + let values = summaries(records).filter { + searchKey.isEmpty || $0.bookName.localizedCaseInsensitiveContains(searchKey) + } + return values.sorted { + switch sortMode { + case 1: + if $0.readTime != $1.readTime { return $0.readTime > $1.readTime } + case 2: + if $0.lastRead != $1.lastRead { return $0.lastRead > $1.lastRead } + default: + let order = $0.bookName.localizedStandardCompare($1.bookName) + if order != .orderedSame { return order == .orderedAscending } + } + return $0.bookName.localizedStandardCompare($1.bookName) == .orderedAscending + } + } + + public func addingReadTime( + _ elapsedMilliseconds: Int64, + deviceID: String, + bookName: String, + now: Int64, + to records: [ReadRecord], + calendar: Calendar = .current + ) -> [ReadRecord] { + guard elapsedMilliseconds > 0, !bookName.isEmpty else { return records } + var updated = records + let day = Self.dayKey( + for: Date(timeIntervalSince1970: Double(now) / 1_000), + calendar: calendar + ) + if let index = updated.firstIndex(where: { $0.deviceId == deviceID && $0.bookName == bookName }) { + updated[index].readTime += elapsedMilliseconds + updated[index].lastRead = now + updated[index].dailyReadTimes[day, default: 0] += elapsedMilliseconds + } else { + updated.append(.init( + deviceId: deviceID, + bookName: bookName, + readTime: elapsedMilliseconds, + lastRead: now, + dailyReadTimes: [day: elapsedMilliseconds] + )) + } + return updated + } + + public func statistics( + _ records: [ReadRecord], + now: Date = Date(), + calendar: Calendar = .current + ) -> ReadingStatistics { + let dailyTotals = records.reduce(into: [String: Int64]()) { result, record in + for (day, milliseconds) in record.dailyReadTimes where milliseconds > 0 { + result[day, default: 0] += milliseconds + } + } + let today = calendar.startOfDay(for: now) + let todayKey = Self.dayKey(for: today, calendar: calendar) + let yesterday = calendar.date(byAdding: .day, value: -1, to: today) ?? today + var cursor: Date? + if dailyTotals[todayKey, default: 0] > 0 { + cursor = today + } else if dailyTotals[Self.dayKey(for: yesterday, calendar: calendar), default: 0] > 0 { + cursor = yesterday + } else { + cursor = nil + } + var streak = 0 + while let day = cursor, + dailyTotals[Self.dayKey(for: day, calendar: calendar), default: 0] > 0 { + streak += 1 + cursor = calendar.date(byAdding: .day, value: -1, to: day) + } + let recentDays = (-6...0).compactMap { offset -> DailyReadingTotal? in + guard let date = calendar.date(byAdding: .day, value: offset, to: today) else { return nil } + return DailyReadingTotal( + date: date, + readTime: dailyTotals[Self.dayKey(for: date, calendar: calendar), default: 0] + ) + } + return ReadingStatistics( + todayReadTime: dailyTotals[todayKey, default: 0], + currentStreak: streak, + totalReadTime: records.reduce(0) { $0 + max($1.readTime, 0) }, + bookCount: Set(records.lazy.map(\.bookName).filter { !$0.isEmpty }).count, + recentDays: recentDays + ) + } + + private static func dayKey(for date: Date, calendar: Calendar) -> String { + let components = calendar.dateComponents([.year, .month, .day], from: date) + return String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0) + } +} + +public struct DailyReadingTotal: Hashable, Sendable { + public let date: Date + public let readTime: Int64 + + public init(date: Date, readTime: Int64) { + self.date = date + self.readTime = readTime + } +} + +public struct ReadingStatistics: Hashable, Sendable { + public let todayReadTime: Int64 + public let currentStreak: Int + public let totalReadTime: Int64 + public let bookCount: Int + public let recentDays: [DailyReadingTotal] + + public init( + todayReadTime: Int64, + currentStreak: Int, + totalReadTime: Int64, + bookCount: Int, + recentDays: [DailyReadingTotal] + ) { + self.todayReadTime = todayReadTime + self.currentStreak = currentStreak + self.totalReadTime = totalReadTime + self.bookCount = bookCount + self.recentDays = recentDays + } +} + +public struct BookProgress: Codable, Hashable, Sendable { + public var name: String + public var author: String + public var durChapterIndex: Int + public var durChapterPos: Int + public var durChapterTime: Int64 + public var durChapterTitle: String? + + public init(book: Book) { + name = book.name + author = book.author + durChapterIndex = book.durChapterIndex + durChapterPos = book.durChapterPos + durChapterTime = book.durChapterTime + durChapterTitle = book.durChapterTitle + } +} diff --git a/Sources/LegadoCore/Persistence/ReplaceRuleRepository.swift b/Sources/LegadoCore/Persistence/ReplaceRuleRepository.swift new file mode 100644 index 0000000..d8a64d0 --- /dev/null +++ b/Sources/LegadoCore/Persistence/ReplaceRuleRepository.swift @@ -0,0 +1,91 @@ +import Foundation + +public enum ReplaceRuleImportError: Error, LocalizedError, Equatable { + case emptyDocument + case documentTooLarge(Int) + case invalidDocument(String) + case emptyPattern(String) + + public var errorDescription: String? { + switch self { + case .emptyDocument: "净化规则内容为空" + case .documentTooLarge(let maximum): "净化规则文件超过 \(maximum / 1_024 / 1_024) MB 上限" + case .invalidDocument(let detail): "无法解析净化规则 JSON:\(detail)" + case .emptyPattern(let name): "净化规则“\(name)”没有匹配内容" + } + } +} + +public struct ReplaceRuleImporter: Sendable { + public static let maximumDocumentBytes = 10 * 1_024 * 1_024 + + public init() {} + + public func decode(_ data: Data) throws -> [ReplaceRule] { + guard data.count <= Self.maximumDocumentBytes else { + throw ReplaceRuleImportError.documentTooLarge(Self.maximumDocumentBytes) + } + let bom = Data([0xEF, 0xBB, 0xBF]) + let cleaned = data.starts(with: bom) ? Data(data.dropFirst(3)) : data + guard !cleaned.isEmpty else { throw ReplaceRuleImportError.emptyDocument } + do { + let decoder = JSONDecoder() + let rules = if let array = try? decoder.decode([ReplaceRule].self, from: cleaned) { + array + } else { + [try decoder.decode(ReplaceRule.self, from: cleaned)] + } + for rule in rules { try ReplaceRuleValidator.validate(rule) } + return rules + } catch let error as ReplaceRuleImportError { + throw error + } catch { + throw ReplaceRuleImportError.invalidDocument(error.localizedDescription) + } + } + + public func merge(_ incoming: [ReplaceRule], into existing: [ReplaceRule]) -> [ReplaceRule] { + var result = existing + var indexes = Dictionary(uniqueKeysWithValues: result.enumerated().map { ($1.id, $0) }) + for rule in incoming { + if let index = indexes[rule.id] { + result[index] = rule + } else { + indexes[rule.id] = result.count + result.append(rule) + } + } + return result.sorted { $0.sortOrder < $1.sortOrder } + } +} + +public protocol ReplaceRuleRepository: Sendable { + func load() async throws -> [ReplaceRule] + func save(_ rules: [ReplaceRule]) async throws +} + +public actor FileReplaceRuleRepository: ReplaceRuleRepository { + private let fileURL: URL + + public init(fileURL: URL) { self.fileURL = fileURL } + + public func load() throws -> [ReplaceRule] { + guard FileManager.default.fileExists(atPath: fileURL.path) else { return [] } + return try JSONDecoder().decode([ReplaceRule].self, from: Data(contentsOf: fileURL)) + } + + public func save(_ rules: [ReplaceRule]) throws { + let directory = fileURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + try encoder.encode(rules).write(to: fileURL, options: .atomic) + } +} + +public actor InMemoryReplaceRuleRepository: ReplaceRuleRepository { + private var rules: [ReplaceRule] + public init(rules: [ReplaceRule] = []) { self.rules = rules } + public func load() -> [ReplaceRule] { rules } + public func save(_ rules: [ReplaceRule]) { self.rules = rules } +} diff --git a/Sources/LegadoCore/RSS/RSSDefaultParser.swift b/Sources/LegadoCore/RSS/RSSDefaultParser.swift new file mode 100644 index 0000000..8981d96 --- /dev/null +++ b/Sources/LegadoCore/RSS/RSSDefaultParser.swift @@ -0,0 +1,105 @@ +import Foundation +#if canImport(FoundationXML) +import FoundationXML +#endif + +public struct RSSParseResult: Sendable, Equatable { + public var articles: [RssArticle] + public var nextURL: String? + public init(articles: [RssArticle], nextURL: String? = nil) { + self.articles = articles + self.nextURL = nextURL + } +} + +public enum RSSDefaultParser { + public static func parse(sortName: String, xml: String, sourceURL: String) throws -> RSSParseResult { + let delegate = Delegate(sortName: sortName, sourceURL: sourceURL) + let parser = XMLParser(data: Data(xml.utf8)) + parser.shouldProcessNamespaces = false + parser.delegate = delegate + guard parser.parse() else { throw parser.parserError ?? RSSParserError.invalidXML } + return .init(articles: delegate.articles) + } + + private final class Delegate: NSObject, XMLParserDelegate { + let sortName: String + let sourceURL: String + var articles: [RssArticle] = [] + var article = RssArticle() + var insideItem = false + var activeField: String? + var text = "" + + init(sortName: String, sourceURL: String) { + self.sortName = sortName + self.sourceURL = sourceURL + } + + func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String] = [:]) { + let name = elementName.lowercased() + if name == "item" { insideItem = true; article = RssArticle(); return } + guard insideItem else { return } + if name == "media:thumbnail" { + article.image = attribute(attributeDict, named: "url") + } else if name == "enclosure", attribute(attributeDict, named: "type")?.localizedCaseInsensitiveContains("image/") == true { + article.image = attribute(attributeDict, named: "url") + } + if ["title", "link", "description", "content:encoded", "pubdate", "time"].contains(name) { + activeField = name + text = "" + } + } + + func parser(_ parser: XMLParser, foundCharacters string: String) { + if insideItem, activeField != nil { text += string } + } + func parser(_ parser: XMLParser, foundCDATA CDATABlock: Data) { + if insideItem, activeField != nil { text += String(decoding: CDATABlock, as: UTF8.self) } + } + func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) { + let name = elementName.lowercased() + if name == "item" { + article.origin = sourceURL; article.sort = sortName; articles.append(article) + insideItem = false; activeField = nil + return + } + guard insideItem, name == activeField else { return } + let value = text.trimmingCharacters(in: .whitespacesAndNewlines) + switch name { + case "title": article.title = value + case "link": article.link = value + case "description": article.description = value; if article.image == nil { article.image = firstImageURL(in: text) } + case "content:encoded": article.content = value; if article.image == nil { article.image = firstImageURL(in: text) } + case "pubdate", "time": article.pubDate = value + default: break + } + activeField = nil; text = "" + } + + private func attribute(_ values: [String: String], named name: String) -> String? { + values.first { $0.key.caseInsensitiveCompare(name) == .orderedSame }?.value + } + private func firstImageURL(in input: String) -> String? { + guard let expression = try? NSRegularExpression(pattern: "(]*>)", options: .caseInsensitive), + let image = expression.firstMatch(in: input, range: NSRange(input.startIndex..., in: input)), + let tagRange = Range(image.range(at: 1), in: input) else { return nil } + let tag = String(input[tagRange]) + guard let expression = try? NSRegularExpression(pattern: #"src\s*=\s*\"([^\"]+)\""#, options: .caseInsensitive), + let source = expression.firstMatch(in: tag, range: NSRange(tag.startIndex..., in: tag)), + let sourceRange = Range(source.range(at: 1), in: tag) else { return nil } + return String(tag[sourceRange]).trimmingCharacters(in: .whitespacesAndNewlines) + } + } +} + +public enum RSSParserError: Error, LocalizedError, Sendable { + case invalidXML + case emptyBody(String) + public var errorDescription: String? { + switch self { + case .invalidXML: "RSS XML解析失败" + case .emptyBody(let source): "获取网页内容失败:\(source)" + } + } +} diff --git a/Sources/LegadoCore/RSS/RSSReadingPolicy.swift b/Sources/LegadoCore/RSS/RSSReadingPolicy.swift new file mode 100644 index 0000000..82f6254 --- /dev/null +++ b/Sources/LegadoCore/RSS/RSSReadingPolicy.swift @@ -0,0 +1,130 @@ +import Foundation + +public enum RSSReadingMode: Equatable, Sendable { + case html(content: String, baseURL: String?) + case startHTML(content: String, baseURL: String?) + case web(url: String) + case image(url: String) + case video(url: String) +} + +public struct RSSReadingPolicy: Sendable { + public init() {} + + public func mode(article: RssArticle, source: RssSource, resolvedContent: String? = nil) -> RSSReadingMode { + if article.type == 1 || article.type == 2 { + let raw = resolvedContent?.trimmed.nilIfEmpty ?? article.link + let articleURL = absoluteURL(article.link, base: article.origin) + let url = absoluteURL(raw, base: articleURL) + return article.type == 1 ? .image(url: url) : .video(url: url) + } + if let description = article.description?.trimmed.nilIfEmpty { + return .html(content: description, baseURL: source.loadWithBaseUrl ? absoluteURL(article.link, base: article.origin) : nil) + } + if source.ruleContent?.trimmed.nilIfEmpty != nil, let content = resolvedContent { + return .html(content: content, baseURL: source.loadWithBaseUrl ? absoluteURL(article.link, base: article.origin) : nil) + } + return .web(url: absoluteURL(article.link, base: article.origin)) + } + + private func absoluteURL(_ value: String, base: String) -> String { + URL(string: value, relativeTo: URL(string: base))?.absoluteURL.absoluteString ?? value + } +} + +public struct RSSHTMLProcessor: Sendable { + public static let defaultStyle = "img{max-width:100% !important; width:auto; height:auto;}video{object-fit:fill; max-width:100% !important; width:auto; height:auto;}body{word-wrap:break-word; height:auto;max-width: 100%; width:auto;}" + public init() {} + + public func contentHTML(_ content: String, style: String?) -> String { + injectStyle(into: content, style: style?.trimmed.nilIfEmpty ?? Self.defaultStyle) + } + + public func startHTML(_ content: String, source: RssSource) -> String { + var html: String + if let script = source.startJs?.trimmed.nilIfEmpty { + if let range = content.range(of: "") { + html = String(content[..\(script)" + String(content[range.lowerBound...]) + } else { + html = "\(content)" + } + } else { html = content } + return contentHTML(html, style: source.startStyle?.trimmed.nilIfEmpty ?? source.style) + } + + private func injectStyle(into content: String, style: String) -> String { + let tag = "" + if let range = content.range(of: "") { + return String(content[..") { + return String(content[.. RSSReadingMode? { + guard let raw = source.startHtml?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { return nil } + let html: String + if raw.hasPrefix("@js:") { + let result = try JavaScriptRuleEvaluator().evaluate(script: String(raw.dropFirst(4)), result: .string(""), executionContext: .init(baseURL: source.sourceUrl, source: source.bookSourceAdapter)) + html = result.value.stringValue + } else if raw.hasPrefix(""), let end = raw.range(of: "", options: .backwards) { + let script = String(raw[raw.index(raw.startIndex, offsetBy: 4).. Bool { + if !blacklist.isEmpty { return !blacklist.contains { matches(url, rule: $0) } } + if !whitelist.isEmpty { return whitelist.contains { matches(url, rule: $0) } } + return true + } + public func contentBlockerJSON() -> String? { + var rules: [[String: Any]] = [] + if !blacklist.isEmpty { + for value in blacklist { + rules.append(["trigger": ["url-filter": filter(for: value)], "action": ["type": "block"]]) + } + } else if !whitelist.isEmpty { + rules.append(["trigger": ["url-filter": ".*"], "action": ["type": "block"]]) + for value in whitelist { + rules.append(["trigger": ["url-filter": filter(for: value)], "action": ["type": "ignore-previous-rules"]]) + } + } + guard !rules.isEmpty, let data = try? JSONSerialization.data(withJSONObject: rules) else { return nil } + return String(decoding: data, as: UTF8.self) + } + private func matches(_ value: String, rule: String) -> Bool { + if value.hasPrefix(rule) { return true } + guard let expression = try? NSRegularExpression(pattern: rule) else { return false } + let range = NSRange(value.startIndex..., in: value) + guard let match = expression.firstMatch(in: value, range: range) else { return false } + return match.range == range + } + private func filter(for rule: String) -> String { + let literalPrefix = "^" + NSRegularExpression.escapedPattern(for: rule) + return "(?:\(literalPrefix))|(?:^(?:\(rule))$)" + } + private static func rules(_ value: String?) -> [String] { + value?.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty } ?? [] + } +} + +private extension String { + var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) } + var nilIfEmpty: String? { isEmpty ? nil : self } +} diff --git a/Sources/LegadoCore/RSS/RSSRuleParser.swift b/Sources/LegadoCore/RSS/RSSRuleParser.swift new file mode 100644 index 0000000..3a85744 --- /dev/null +++ b/Sources/LegadoCore/RSS/RSSRuleParser.swift @@ -0,0 +1,107 @@ +import Foundation + +public struct RSSRuleParseReport: Sendable { + public var result: RSSParseResult + public var diagnostics: [RuleDiagnostic] + public init(result: RSSParseResult, diagnostics: [RuleDiagnostic] = []) { + self.result = result + self.diagnostics = diagnostics + } +} + +public struct RSSRuleParser: Sendable { + private let engine: RuleEngine + public init(engine: RuleEngine = .init()) { self.engine = engine } + + public func parse( + sortName: String, + sortURL: String, + redirectURL: String, + body: String, + source: RssSource, + variables: [String: String] = [:] + ) async throws -> RSSRuleParseReport { + guard !body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw RSSParserError.emptyBody(source.sourceUrl) + } + guard var listRule = source.ruleArticles, !listRule.isEmpty else { + return .init(result: try RSSDefaultParser.parse(sortName: sortName, xml: body, sourceURL: source.sourceUrl)) + } + let reverse = listRule.hasPrefix("-") + if reverse { listRule.removeFirst() } + let adapter = source.bookSourceAdapter + let baseContext = RuleExecutionContext(variables: variables, baseURL: sortURL, source: adapter) + let list = await engine.evaluate(listRule, inText: body, baseURL: sortURL, listMode: true, context: baseContext) + var diagnostics = list.diagnostics + var nextURL: String? + if let nextRule = source.ruleNextPage, !nextRule.isEmpty { + if nextRule.uppercased() == "PAGE" { + nextURL = sortURL + } else { + let next = await engine.evaluate(nextRule, inText: body, baseURL: sortURL, context: .init(variables: list.variables, baseURL: sortURL, source: adapter)) + diagnostics += next.diagnostics + if let value = next.joinedString, !value.isEmpty { + nextURL = URL(string: value, relativeTo: URL(string: sortURL))?.absoluteURL.absoluteString ?? value + } + } + } + let variable = encodeVariables(list.variables) + var articles: [RssArticle] = [] + for item in flatten(list.values) { + var context = RuleExecutionContext(variables: list.variables, baseURL: sortURL, source: adapter) + let title = await evaluate(source.ruleTitle, item: item, context: context); context.variables = title.variables + let pubDate = await evaluate(source.rulePubDate, item: item, context: context); context.variables = pubDate.variables + let description = await evaluate(source.ruleDescription, item: item, context: context); context.variables = description.variables + let image = await evaluate(source.ruleImage, item: item, context: context); context.variables = image.variables + let link = await evaluate(source.ruleLink, item: item, context: context) + diagnostics += [title, pubDate, description, image, link].flatMap(\.diagnostics) + guard let titleValue = title.joinedString?.trimmingCharacters(in: .whitespacesAndNewlines), !titleValue.isEmpty else { continue } + let rawLink = link.leadingString ?? "" + let resolvedLink = URL(string: rawLink, relativeTo: URL(string: sortURL))? + .absoluteURL.absoluteString ?? rawLink + var article = RssArticle(origin: source.sourceUrl, sort: sortName, title: titleValue, link: resolvedLink) + article.pubDate = pubDate.joinedString + article.description = source.ruleDescription?.isEmpty == false ? description.joinedString : nil + if let imageValue = image.joinedString, !imageValue.isEmpty { + article.image = URL(string: imageValue, relativeTo: URL(string: source.sourceUrl))?.absoluteURL.absoluteString ?? imageValue + } + article.variable = variable + article.type = source.type + articles.append(article) + } + if reverse { articles.reverse() } + _ = redirectURL + return .init(result: .init(articles: articles, nextURL: nextURL), diagnostics: diagnostics) + } + + private func evaluate(_ rule: String?, item: JSONValue, context: RuleExecutionContext) async -> RuleEvaluation { + guard let rule, !rule.isEmpty else { return .init(variables: context.variables) } + if case .string(let text) = item { + return await engine.evaluate(rule, inText: text, baseURL: context.baseURL, context: context) + } + return await engine.evaluate(rule, in: item, context: context) + } + private func flatten(_ values: [JSONValue]) -> [JSONValue] { + values.flatMap { value in + if case .array(let items) = value { return items } + return [value] + } + } + private func encodeVariables(_ values: [String: String]) -> String? { + guard !values.isEmpty, let data = try? JSONEncoder().encode(values) else { return nil } + return String(decoding: data, as: UTF8.self) + } +} + +extension RssSource { + var bookSourceAdapter: BookSource { + var value = BookSource(bookSourceUrl: sourceUrl, bookSourceName: sourceName) + value.bookSourceGroup = sourceGroup; value.bookSourceType = type; value.customOrder = customOrder + value.enabled = enabled; value.jsLib = jsLib; value.enabledCookieJar = enabledCookieJar + value.concurrentRate = concurrentRate; value.header = header; value.loginUrl = loginUrl + value.loginUi = loginUi; value.loginCheckJs = loginCheckJs; value.coverDecodeJs = coverDecodeJs + value.bookSourceComment = sourceComment; value.variableComment = variableComment + value.lastUpdateTime = lastUpdateTime; value.searchUrl = searchUrl + return value + } +} diff --git a/Sources/LegadoCore/RSS/RSSService.swift b/Sources/LegadoCore/RSS/RSSService.swift new file mode 100644 index 0000000..3ec996e --- /dev/null +++ b/Sources/LegadoCore/RSS/RSSService.swift @@ -0,0 +1,132 @@ +import Foundation + +public struct RSSFetchResult: Sendable { + public var requestURL: String + public var finalURL: String + public var statusCode: Int + public var body: String + public var report: RSSRuleParseReport +} + +public struct RSSContentFetchResult: Sendable { + public var requestURL: String + public var finalURL: String + public var statusCode: Int + public var body: String + public var content: String + public var diagnostics: [RuleDiagnostic] +} + +public struct RSSService: Sendable { + private let transport: any HTTPTransport + private let urlBuilder: LegadoURLBuilder + private let engine: RuleEngine + private let parser: RSSRuleParser + 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.parser = RSSRuleParser(engine: engine) + self.networkPolicy = networkPolicy + self.webViewExecutor = webViewExecutor + } + + public func articles( + sortName: String, + sortURL: String, + source: RssSource, + page: Int, + key: String? = nil + ) async throws -> RSSRuleParseReport { + try await articlesDetailed(sortName: sortName, sortURL: sortURL, source: source, page: page, key: key).report + } + + public func articlesDetailed( + sortName: String, + sortURL: String, + source: RssSource, + page: Int, + key: String? = nil + ) async throws -> RSSFetchResult { + let adapter = source.bookSourceAdapter + let headers = try BookSourceHeaderResolver().resolve(adapter) + let prepared = try urlBuilder.prepare( + sortURL, + context: .init(key: key, page: page, source: adapter), + baseURL: URL(string: source.sourceUrl), + defaultHeaders: headers + ) + let response = try await networkPolicy.execute( + prepared, + source: adapter, + transport: transport, + timeout: 180, + webViewExecutor: webViewExecutor, + snapshotStage: .debug + ) + let report = try await parser.parse( + sortName: sortName, + sortURL: sortURL, + redirectURL: response.finalURL.absoluteString, + body: response.decodedText, + source: source, + variables: response.requestVariables + ) + return .init( + requestURL: prepared.url.absoluteString, + finalURL: response.finalURL.absoluteString, + statusCode: response.statusCode, + body: response.decodedText, + report: report + ) + } + + public func content(article: RssArticle, rule: String, source: RssSource) async throws -> String { + try await contentDetailed(article: article, rule: rule, source: source).content + } + + public func contentDetailed(article: RssArticle, rule: String, source: RssSource) async throws -> RSSContentFetchResult { + let adapter = source.bookSourceAdapter + let headers = try BookSourceHeaderResolver().resolve(adapter) + let prepared = try urlBuilder.prepare( + article.link, + context: .init(variables: article.variableMap, source: adapter), + baseURL: URL(string: article.origin), + defaultHeaders: headers + ) + let response = try await networkPolicy.execute( + prepared, + source: adapter, + transport: transport, + timeout: 180, + webViewExecutor: webViewExecutor, + snapshotStage: .content + ) + let result = await engine.evaluate( + rule, + inText: response.decodedText, + baseURL: URL(string: article.link, relativeTo: URL(string: article.origin))?.absoluteURL.absoluteString ?? article.link, + context: .init(variables: article.variableMap, baseURL: response.finalURL.absoluteString, source: adapter) + ) + if let error = result.diagnostics.first(where: { $0.severity == .error }) { + throw RuleEngineError.malformedRule(rule: rule, detail: error.message) + } + return .init( + requestURL: prepared.url.absoluteString, + finalURL: response.finalURL.absoluteString, + statusCode: response.statusCode, + body: response.decodedText, + content: result.joinedString ?? "", + diagnostics: result.diagnostics + ) + } +} diff --git a/Sources/LegadoCore/RSS/RSSSortResolver.swift b/Sources/LegadoCore/RSS/RSSSortResolver.swift new file mode 100644 index 0000000..3f214f7 --- /dev/null +++ b/Sources/LegadoCore/RSS/RSSSortResolver.swift @@ -0,0 +1,33 @@ +import Foundation + +public struct RSSSort: Hashable, Identifiable, Sendable { + public var name: String + public var url: String + public var id: String { "\(name)\u{0}\(url)" } + public init(name: String, url: String) { self.name = name; self.url = url } +} + +public struct RSSSortResolver: Sendable { + private let engine: RuleEngine + public init(engine: RuleEngine = .init()) { self.engine = engine } + + public func resolve(_ source: RssSource) async -> [RSSSort] { + var value = source.sortUrl + if let rule = value, rule.hasPrefix("@js:") || rule.hasPrefix("") { + let result = await engine.evaluate(rule, in: .null, context: .init(source: source.bookSourceAdapter)) + value = result.joinedString + } + guard let value else { return [.init(name: "", url: source.sourceUrl)] } + let pattern = try? NSRegularExpression(pattern: "(?:&&|\\n)+") + let range = NSRange(value.startIndex..., in: value) + let normalized = pattern?.stringByReplacingMatches(in: value, range: range, withTemplate: "\u{1F}") ?? value + let sorts = normalized.split(separator: "\u{1F}", omittingEmptySubsequences: true).compactMap { item -> RSSSort? in + let text = String(item) + let parts = text.components(separatedBy: "::") + let name = parts.first ?? "" + let url = parts.dropFirst().joined(separator: "::") + return url.isEmpty ? nil : .init(name: name, url: url) + } + return sorts.isEmpty ? [.init(name: "", url: source.sourceUrl)] : sorts + } +} diff --git a/Sources/LegadoCore/RuleEngine/HTMLRuleEvaluator.swift b/Sources/LegadoCore/RuleEngine/HTMLRuleEvaluator.swift new file mode 100644 index 0000000..67045f5 --- /dev/null +++ b/Sources/LegadoCore/RuleEngine/HTMLRuleEvaluator.swift @@ -0,0 +1,312 @@ +import Foundation +import Kanna +import SwiftSoup + +public struct HTMLRuleEvaluator: Sendable { + private enum IndexItem { + case index(Int) + case range(Int?, Int?, Int) + } + + private struct IndexedSelector { + var selector: String + var excludes: Bool + var items: [IndexItem] + } + + public init() {} + + public func selectCSSFragments(_ rule: String, html: String, baseURL: String = "", isFragment: Bool = false) throws -> [String] { + do { + let root = try parseRoot(html, baseURL: baseURL, isFragment: isFragment) + let stages = splitChain(rule).filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + return try selectElements(stages: stages, from: root).map { try $0.outerHtml() } + } catch { + throw RuleEngineError.malformedRule(rule: rule, detail: cssFailureDetail(error)) + } + } + + public func extractCSS(_ rule: String, html: String, baseURL: String = "", isFragment: Bool = false) throws -> [String] { + do { + let parsed = try parseCSSExtraction(rule) + let root = try parseRoot(html, baseURL: baseURL, isFragment: isFragment) + let elements = try selectElements(stages: parsed.selectors, from: root) + if parsed.output == "html" { + for element in elements { + try element.select("script").remove() + try element.select("style").remove() + } + let value = try elements.map { try $0.outerHtml() }.joined(separator: "\n") + return value.isEmpty ? [] : [value] + } + if parsed.output == "all" { + let value = try elements.map { try $0.outerHtml() }.joined(separator: "\n") + return value.isEmpty ? [] : [value] + } + var seenAttributes: Set = [] + return try elements.compactMap { element in + let value: String + switch parsed.output { + case "text": value = try element.text() + case "ownText": value = element.ownText() + case "textNodes": + value = element.textNodes().map { $0.text().trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .joined(separator: "\n") + default: + value = try element.attr(parsed.output) + guard !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + seenAttributes.insert(value).inserted else { return nil } + } + return value.isEmpty ? nil : value + } + } catch let error as RuleEngineError { + throw error + } catch { + throw RuleEngineError.malformedRule(rule: rule, detail: cssFailureDetail(error)) + } + } + + public func selectXPathFragments(_ rule: String, html: String, isFragment: Bool = false) throws -> [String] { + try xpathResult(rule, html: html, isFragment: isFragment).compactMap(\.toHTML) + } + + public func extractXPath(_ rule: String, html: String, isFragment: Bool = false) throws -> [String] { + let result = try xpathResult(rule, html: html, isFragment: isFragment) + switch result { + case .none: + return [] + case .NodeSet: + return result.compactMap { node in + let value = node.text ?? node.content ?? "" + return value.isEmpty ? nil : value + } + case .Bool(let value): + return [String(value)] + case .Number(let value): + return [value.rounded() == value ? String(format: "%.0f", value) : String(value)] + case .String(let value): + return value.isEmpty ? [] : [value] + } + } + + private func xpathResult(_ rule: String, html: String, isFragment: Bool) throws -> XPathObject { + let document = try parseKanna(html, rule: rule) + if isFragment, let element = document.at_xpath("/html/body/*[1]") { + return element.xpath(rule) + } + return document.xpath(rule) + } + + private func parseKanna(_ html: String, rule: String) throws -> any HTMLDocument { + do { + return try Kanna.HTML(html: html, encoding: .utf8) + } catch { + throw RuleEngineError.malformedRule(rule: rule, detail: "HTML 解析失败:\(error.localizedDescription)") + } + } + + private func parseCSSExtraction(_ rule: String) throws -> (selectors: [String], output: String) { + let components = splitChain(rule) + guard components.count > 1 else { + let selector = rule.trimmingCharacters(in: .whitespacesAndNewlines) + guard !selector.isEmpty else { + throw RuleEngineError.malformedRule(rule: rule, detail: "CSS selector 不能为空") + } + if selector.range(of: #"^[\w:-]+$"#, options: .regularExpression) != nil { + return ([], selector) + } + return ([selector], "text") + } + var selectors = components.dropLast().map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + let output = components.last?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + while selectors.first?.isEmpty == true { selectors.removeFirst() } + guard selectors.allSatisfy({ !$0.isEmpty }), !output.isEmpty else { + throw RuleEngineError.malformedRule(rule: rule, detail: "CSS 取值规则必须是 selector@text 或 selector@attribute") + } + return (selectors, output) + } + + private func cssFailureDetail(_ error: Error) -> String { + if case SwiftSoup.Exception.Error(_, let message) = error { + return "CSS 规则执行失败:\(message)" + } + return "CSS 规则执行失败:\(error.localizedDescription)" + } + + private func splitChain(_ rule: String) -> [String] { + var parts: [String] = [] + var quote: Character? + var escaped = false + var squareDepth = 0 + var roundDepth = 0 + var start = rule.startIndex + for index in rule.indices { + let character = rule[index] + if escaped { escaped = false; continue } + if character == "\\" { escaped = true; continue } + if let activeQuote = quote { + if character == activeQuote { quote = nil } + continue + } + if character == "\"" || character == "'" { quote = character; continue } + if character == "[" { squareDepth += 1 } + if character == "]" { squareDepth -= 1 } + if character == "(" { roundDepth += 1 } + if character == ")" { roundDepth -= 1 } + if character == "@", squareDepth == 0, roundDepth == 0 { + parts.append(String(rule[start.. Element { + guard isFragment else { return try SwiftSoup.parse(html, baseURL) } + if html.range(of: #"^\s*(?: [Element] { + var current: [Element] = [document] + for stage in stages { + var selected: [Element] = [] + let parsed = parseIndexedSelector(stage) + for parent in current { + let candidates: [Element] + switch parsed.selector { + case "", "children": candidates = parent.children().array() + default: candidates = try parent.select(cssSelector(parsed.selector)).array() + } + selected += applyIndex(parsed, to: candidates) + } + current = selected + } + return current + } + + private func cssSelector(_ legacy: String) -> String { + if legacy.hasPrefix("class.") { return "." + legacy.dropFirst(6) } + if legacy.hasPrefix("tag.") { return String(legacy.dropFirst(4)) } + if legacy.hasPrefix("id.") { return "#" + legacy.dropFirst(3) } + if legacy.hasPrefix("text.") { + let value = String(legacy.dropFirst(5)).replacingOccurrences(of: "\"", with: "\\\"") + return ":containsOwn(\(value))" + } + return legacy + } + + private func parseIndexedSelector(_ source: String) -> IndexedSelector { + let value = source.trimmingCharacters(in: .whitespacesAndNewlines) + if value.hasSuffix("]"), let open = value.lastIndex(of: "[") { + var content = String(value[value.index(after: open).. IndexItem? { + let values = source.split(separator: ":", omittingEmptySubsequences: false) + if values.count == 1, let index = Int(values[0].trimmingCharacters(in: .whitespaces)) { + return .index(index) + } + guard (2...3).contains(values.count) else { return nil } + let start = values[0].isEmpty ? nil : Int(values[0].trimmingCharacters(in: .whitespaces)) + let end = values[1].isEmpty ? nil : Int(values[1].trimmingCharacters(in: .whitespaces)) + let step = values.count == 3 ? Int(values[2].trimmingCharacters(in: .whitespaces)) ?? 1 : 1 + return step == 0 ? nil : .range(start, end, step) + } + + private func applyIndex(_ selector: IndexedSelector, to elements: [Element]) -> [Element] { + guard !selector.items.isEmpty, !elements.isEmpty else { return elements } + var indexes: [Int] = [] + func append(_ index: Int) { + let resolved = index < 0 ? elements.count + index : index + if elements.indices.contains(resolved), !indexes.contains(resolved) { indexes.append(resolved) } + } + for item in selector.items { + switch item { + case .index(let index): append(index) + case .range(let rawStart, let rawEnd, let rawStep): + var start = rawStart ?? 0 + var end = rawEnd ?? (elements.count - 1) + if start < 0 { start += elements.count } + if end < 0 { end += elements.count } + if (start < 0 && end < 0) || (start >= elements.count && end >= elements.count) { + continue + } + start = min(max(start, 0), elements.count - 1) + end = min(max(end, 0), elements.count - 1) + let direction = end >= start ? 1 : -1 + let normalizedStep: Int + if rawStep > 0 { + normalizedStep = rawStep + } else if -rawStep < elements.count { + normalizedStep = rawStep + elements.count + } else { + normalizedStep = 1 + } + let step = max(normalizedStep, 1) * direction + var cursor = start + while direction > 0 ? cursor <= end : cursor >= end { + append(cursor) + cursor += step + } + } + } + if selector.excludes { + let excluded = Set(indexes) + return elements.enumerated().compactMap { excluded.contains($0.offset) ? nil : $0.element } + } + return indexes.map { elements[$0] } + } +} diff --git a/Sources/LegadoCore/RuleEngine/JSONPathEvaluator.swift b/Sources/LegadoCore/RuleEngine/JSONPathEvaluator.swift new file mode 100644 index 0000000..4936c43 --- /dev/null +++ b/Sources/LegadoCore/RuleEngine/JSONPathEvaluator.swift @@ -0,0 +1,496 @@ +import Foundation + +public struct JSONPathEvaluator: Sendable { + private enum Selector: Equatable { + case key(String) + case index(Int) + case wildcard + } + + private enum Segment: Equatable { + case child(Selector) + case recursive(Selector) + case union([Selector]) + case slice(start: Int?, end: Int?, step: Int) + case filter(String) + } + + public init() {} + + public func evaluate(path: String, in root: JSONValue) throws -> [JSONValue] { + let segments = try parse(path) + var current = [root] + for segment in segments { + try Task.checkCancellation() + switch segment { + case .child(let selector): + current = current.flatMap { select(selector, from: $0) } + case .recursive(let selector): + current = current.flatMap { recursivelySelect(selector, from: $0) } + case .union(let selectors): + current = current.flatMap { node in selectors.flatMap { select($0, from: node) } } + case .slice(let start, let end, let step): + current = current.flatMap { slice($0, start: start, end: end, step: step) } + case .filter(let predicate): + var filtered: [JSONValue] = [] + for node in current { + guard case .array(let values) = node else { continue } + for value in values where try matches(predicate, candidate: value, root: root) { + filtered.append(value) + } + } + current = filtered + } + } + return current + } + + private func parse(_ path: String) throws -> [Segment] { + let rule = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard rule.first == "$" else { + throw RuleEngineError.malformedRule(rule: path, detail: "JSONPath 必须以 $ 开始") + } + var segments: [Segment] = [] + var index = rule.index(after: rule.startIndex) + while index < rule.endIndex { + let character = rule[index] + if character == "." { + let next = rule.index(after: index) + guard next < rule.endIndex else { + throw RuleEngineError.malformedRule(rule: path, detail: "JSONPath 不能以点结尾") + } + if rule[next] == "." { + index = rule.index(after: next) + let parsed = try parseDotSelector(rule, from: index, original: path) + segments.append(.recursive(parsed.selector)) + index = parsed.next + } else { + index = next + let parsed = try parseDotSelector(rule, from: index, original: path) + segments.append(.child(parsed.selector)) + index = parsed.next + } + } else if character == "[" { + let parsed = try parseBracket(rule, from: index, original: path) + segments.append(parsed.segment) + index = parsed.next + } else { + throw RuleEngineError.malformedRule( + rule: path, + detail: "JSONPath 在 \(rule.distance(from: rule.startIndex, to: index)) 处包含意外字符" + ) + } + } + return segments + } + + private func parseDotSelector( + _ rule: String, + from start: String.Index, + original: String + ) throws -> (selector: Selector, next: String.Index) { + if rule[start] == "*" { + return (.wildcard, rule.index(after: start)) + } + var end = start + while end < rule.endIndex, rule[end] != ".", rule[end] != "[" { + end = rule.index(after: end) + } + let key = String(rule[start.. (segment: Segment, next: String.Index) { + var index = rule.index(after: open) + var quote: Character? + var escaped = false + var depth = 1 + while index < rule.endIndex { + let character = rule[index] + if escaped { + escaped = false + } else if character == "\\" { + escaped = true + } else if let activeQuote = quote { + if character == activeQuote { quote = nil } + } else if character == "\"" || character == "'" { + quote = character + } else if character == "[" { + depth += 1 + } else if character == "]" { + depth -= 1 + guard depth == 0 else { + index = rule.index(after: index) + continue + } + let content = String(rule[rule.index(after: open).. Segment { + guard !content.isEmpty else { + throw RuleEngineError.malformedRule(rule: original, detail: "JSONPath 方括号不能为空") + } + if content.hasPrefix("?(") && content.hasSuffix(")") { + let predicate = String(content.dropFirst(2).dropLast()).trimmingCharacters(in: .whitespacesAndNewlines) + guard !predicate.isEmpty else { + throw RuleEngineError.malformedRule(rule: original, detail: "JSONPath filter 不能为空") + } + return .filter(predicate) + } + if content == "*" { return .child(.wildcard) } + if content.contains(":") { + let values = content.split(separator: ":", omittingEmptySubsequences: false) + guard (2 ... 3).contains(values.count), + let step = values.count == 3 ? Int(values[2]) : 1, + step != 0 else { + throw RuleEngineError.malformedRule(rule: original, detail: "JSONPath slice 格式无效") + } + return .slice( + start: values[0].isEmpty ? nil : Int(values[0]), + end: values[1].isEmpty ? nil : Int(values[1]), + step: step + ) + } + let parts = splitCommaSeparated(content) + let selectors = try parts.map { try parseSelector($0, original: original) } + return selectors.count == 1 ? .child(selectors[0]) : .union(selectors) + } + + private func splitCommaSeparated(_ content: String) -> [String] { + var parts: [String] = [] + var quote: Character? + var escaped = false + var start = content.startIndex + var index = start + while index < content.endIndex { + let character = content[index] + if escaped { + escaped = false + } else if character == "\\" { + escaped = true + } else if let activeQuote = quote { + if character == activeQuote { quote = nil } + } else if character == "\"" || character == "'" { + quote = character + } else if character == "," { + parts.append(String(content[start.. Selector { + if source == "*" { return .wildcard } + if let index = Int(source) { return .index(index) } + if source.count >= 2, + let first = source.first, + first == "\"" || first == "'", + source.last == first { + let key = String(source.dropFirst().dropLast()) + .replacingOccurrences(of: "\\\(first)", with: String(first)) + .replacingOccurrences(of: "\\\\", with: "\\") + return .key(key) + } + throw RuleEngineError.malformedRule(rule: original, detail: "不支持的 JSONPath 选择器:\(source)") + } + + private func select(_ selector: Selector, from value: JSONValue) -> [JSONValue] { + switch (selector, value) { + case (.key(let key), .object(let object)): + return object[key].map { [$0] } ?? [] + case (.index(let index), .array(let array)): + let resolved = index >= 0 ? index : array.count + index + return array.indices.contains(resolved) ? [array[resolved]] : [] + case (.wildcard, .object(let object)): + return object.keys.sorted().compactMap { object[$0] } + case (.wildcard, .array(let array)): + return array + default: + return [] + } + } + + private func recursivelySelect(_ selector: Selector, from value: JSONValue) -> [JSONValue] { + var result = select(selector, from: value) + switch value { + case .object(let object): + for key in object.keys.sorted() { + if let child = object[key] { result += recursivelySelect(selector, from: child) } + } + case .array(let array): + for child in array { result += recursivelySelect(selector, from: child) } + default: + break + } + return result + } + + private func slice(_ value: JSONValue, start: Int?, end: Int?, step: Int) -> [JSONValue] { + guard case .array(let array) = value, !array.isEmpty else { return [] } + let count = array.count + let defaultStart = step > 0 ? 0 : count - 1 + let defaultEnd = step > 0 ? count : -1 + var cursor = normalize(start ?? defaultStart, count: count, allowEnd: true) + let boundary = end == nil && step < 0 ? -1 : normalize(end ?? defaultEnd, count: count, allowEnd: true) + var result: [JSONValue] = [] + while step > 0 ? cursor < boundary : cursor > boundary { + if array.indices.contains(cursor) { result.append(array[cursor]) } + cursor += step + } + return result + } + + private func normalize(_ index: Int, count: Int, allowEnd: Bool) -> Int { + let value = index < 0 ? count + index : index + return min(max(value, 0), allowEnd ? count : count - 1) + } + + private func matches(_ source: String, candidate: JSONValue, root: JSONValue) throws -> Bool { + let predicate = stripOuterParentheses(source.trimmingCharacters(in: .whitespacesAndNewlines)) + if let parts = splitLogical(predicate, operator: "||"), parts.count > 1 { + for part in parts where try matches(part, candidate: candidate, root: root) { return true } + return false + } + if let parts = splitLogical(predicate, operator: "&&"), parts.count > 1 { + for part in parts where !(try matches(part, candidate: candidate, root: root)) { return false } + return true + } + if predicate.hasPrefix("!") && !predicate.hasPrefix("!=") { + return try !matches(String(predicate.dropFirst()), candidate: candidate, root: root) + } + if let comparison = findComparison(predicate) { + let lhs = try resolveOperand(String(predicate[.. [String]? { + var parts: [String] = [] + var quote: Character? + var regex = false + var escaped = false + var roundDepth = 0 + var squareDepth = 0 + var start = source.startIndex + var index = start + while index < source.endIndex { + let character = source[index] + if escaped { + escaped = false + } else if character == "\\" { + escaped = true + } else if let activeQuote = quote { + if character == activeQuote { quote = nil } + } else if character == "\"" || character == "'" { + quote = character + } else if character == "/" { + regex.toggle() + } else if !regex { + if character == "(" { roundDepth += 1 } + if character == ")" { roundDepth -= 1 } + if character == "[" { squareDepth += 1 } + if character == "]" { squareDepth -= 1 } + if roundDepth == 0, squareDepth == 0, source[index...].hasPrefix(operation) { + parts.append(String(source[start.. String { + var value = source + while value.first == "(", value.last == ")", outerParenthesesEncloseAll(value) { + value = String(value.dropFirst().dropLast()).trimmingCharacters(in: .whitespacesAndNewlines) + } + return value + } + + private func outerParenthesesEncloseAll(_ source: String) -> Bool { + var depth = 0 + var quote: Character? + var escaped = false + for index in source.indices { + let character = source[index] + if escaped { escaped = false; continue } + if character == "\\" { escaped = true; continue } + if let activeQuote = quote { + if character == activeQuote { quote = nil } + continue + } + if character == "\"" || character == "'" { quote = character; continue } + if character == "(" { depth += 1 } + if character == ")" { + depth -= 1 + if depth == 0 && index != source.index(before: source.endIndex) { return false } + } + } + return depth == 0 + } + + private func findComparison(_ source: String) -> (range: Range, operation: String)? { + let operations = ["==", "!=", ">=", "<=", "=~", ">", "<"] + var quote: Character? + var escaped = false + var roundDepth = 0 + var squareDepth = 0 + var index = source.startIndex + while index < source.endIndex { + let character = source[index] + if escaped { + escaped = false + } else if character == "\\" { + escaped = true + } else if let activeQuote = quote { + if character == activeQuote { quote = nil } + } else if character == "\"" || character == "'" { + quote = character + } else { + if character == "(" { roundDepth += 1 } + if character == ")" { roundDepth -= 1 } + if character == "[" { squareDepth += 1 } + if character == "]" { squareDepth -= 1 } + if roundDepth == 0, squareDepth == 0 { + for operation in operations where source[index...].hasPrefix(operation) { + return (index.. [JSONValue] { + let value = source.trimmingCharacters(in: .whitespacesAndNewlines) + guard !value.isEmpty else { + throw RuleEngineError.malformedRule(rule: source, detail: "JSONPath filter 比较值不能为空") + } + if value.first == "@" || value.first == "$" { + return try resolvePath(value, base: value.first == "@" ? candidate : root) + } + if value == "true" { return [.bool(true)] } + if value == "false" { return [.bool(false)] } + if value == "null" { return [.null] } + if let number = Double(value) { return [.number(number)] } + if let first = value.first, (first == "\"" || first == "'"), value.last == first { + let inner = String(value.dropFirst().dropLast()) + .replacingOccurrences(of: "\\\(first)", with: String(first)) + .replacingOccurrences(of: "\\n", with: "\n") + .replacingOccurrences(of: "\\\\", with: "\\") + return [.string(inner)] + } + if value.first == "/", value.dropFirst().lastIndex(of: "/") != nil { + return [.string(value)] + } + throw RuleEngineError.malformedRule(rule: source, detail: "不支持的 JSONPath filter 操作数:\(value)") + } + + private func resolvePath(_ path: String, base: JSONValue) throws -> [JSONValue] { + var current = [base] + var index = path.index(after: path.startIndex) + while index < path.endIndex { + if path[index] == "." { + index = path.index(after: index) + var end = index + while end < path.endIndex, path[end] != ".", path[end] != "[" { end = path.index(after: end) } + let key = String(path[index.. Bool { + if operation == "=~" { + let literal = rhs.stringValue + guard literal.first == "/", let closing = literal.dropFirst().lastIndex(of: "/") else { + throw RuleEngineError.malformedRule(rule: literal, detail: "filter 正则必须写成 /pattern/flags") + } + let pattern = String(literal[literal.index(after: literal.startIndex)..": ordering == .orderedDescending + case ">=": ordering != .orderedAscending + case "<": ordering == .orderedAscending + case "<=": ordering != .orderedDescending + default: false + } + } + + private func isTruthy(_ value: JSONValue) -> Bool { + switch value { + case .null: false + case .bool(let value): value + case .number(let value): value != 0 + case .string(let value): !value.isEmpty + case .array(let value): !value.isEmpty + case .object(let value): !value.isEmpty + } + } +} diff --git a/Sources/LegadoCore/RuleEngine/JSONValue.swift b/Sources/LegadoCore/RuleEngine/JSONValue.swift new file mode 100644 index 0000000..a73fe5a --- /dev/null +++ b/Sources/LegadoCore/RuleEngine/JSONValue.swift @@ -0,0 +1,102 @@ +import Foundation + +public enum JSONValue: Codable, Hashable, Sendable { + case object([String: JSONValue]) + case array([JSONValue]) + case string(String) + case number(Double) + case bool(Bool) + case null + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([JSONValue].self) { + self = .array(value) + } else { + self = .object(try container.decode([String: JSONValue].self)) + } + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .object(let value): try container.encode(value) + case .array(let value): try container.encode(value) + case .string(let value): try container.encode(value) + case .number(let value): try container.encode(value) + case .bool(let value): try container.encode(value) + case .null: try container.encodeNil() + } + } + + public static func decode(data: Data) throws -> JSONValue { + try JSONDecoder().decode(JSONValue.self, from: data) + } + + public var stringValue: String { + switch self { + case .string(let value): return value + case .number(let value): + return value.rounded() == value ? String(format: "%.0f", value) : String(value) + case .bool(let value): return String(value) + case .null: return "" + case .object, .array: + guard let data = try? JSONEncoder().encode(self) else { return "" } + return String(decoding: data, as: UTF8.self) + } + } + + public subscript(key: String) -> JSONValue? { + guard case .object(let value) = self else { return nil } + return value[key] + } + + public subscript(index: Int) -> JSONValue? { + guard case .array(let value) = self else { return nil } + let resolved = index >= 0 ? index : value.count + index + guard value.indices.contains(resolved) else { return nil } + return value[resolved] + } +} + +public extension JSONValue { + var foundationObject: Any { + switch self { + case .object(let value): value.mapValues(\.foundationObject) + case .array(let value): value.map(\.foundationObject) + case .string(let value): value + case .number(let value): value + case .bool(let value): value + case .null: NSNull() + } + } + + static func bridge(_ value: Any?) -> JSONValue { + guard let value else { return .null } + if value is NSNull { return .null } + if let value = value as? JSONValue { return value } + if let value = value as? String { return .string(value) } + if let value = value as? Bool { return .bool(value) } + if let value = value as? NSNumber { + if CFGetTypeID(value) == CFBooleanGetTypeID() { return .bool(value.boolValue) } + return .number(value.doubleValue) + } + if let value = value as? [Any] { return .array(value.map(bridge)) } + if let value = value as? [String: Any] { return .object(value.mapValues(bridge)) } + if let value = value as? NSDictionary { + var object: [String: JSONValue] = [:] + for (key, item) in value { object[String(describing: key)] = bridge(item) } + return .object(object) + } + if let value = value as? NSArray { return .array(value.map(bridge)) } + return .string(String(describing: value)) + } +} diff --git a/Sources/LegadoCore/RuleEngine/JavaScriptRuleEvaluator.swift b/Sources/LegadoCore/RuleEngine/JavaScriptRuleEvaluator.swift new file mode 100644 index 0000000..3b594f5 --- /dev/null +++ b/Sources/LegadoCore/RuleEngine/JavaScriptRuleEvaluator.swift @@ -0,0 +1,1368 @@ +import CryptoKit +import CommonCrypto +import Foundation +import JavaScriptCore + +public struct RuleExecutionContext: Sendable { + public var variables: [String: String] + public var baseURL: String + public var key: String? + public var page: Int? + public var source: BookSource? + public var book: Book? + public var chapter: BookChapter? + public var isHTMLFragment: Bool + public var content: JSONValue? + + public init( + variables: [String: String] = [:], + baseURL: String = "", + key: String? = nil, + page: Int? = nil, + source: BookSource? = nil, + book: Book? = nil, + chapter: BookChapter? = nil, + isHTMLFragment: Bool = false, + content: JSONValue? = nil + ) { + self.variables = variables + self.baseURL = baseURL + self.key = key + self.page = page + self.source = source + self.book = book + self.chapter = chapter + self.isHTMLFragment = isHTMLFragment + self.content = content + } +} + +public struct JavaScriptRuleResult: Sendable { + public var value: JSONValue + public var variables: [String: String] +} + +/// JavaScript's synchronous network bridges can block. Keep those calls off +/// Swift's cooperative executor and off shared actors such as the cookie policy. +enum RuleExecutionWorker { + static func run(_ operation: @escaping @Sendable () throws -> Value) async throws -> Value { + try Task.checkCancellation() + let result = try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + continuation.resume(with: Result { try operation() }) + } + } + try Task.checkCancellation() + return result + } +} + +public protocol JavaScriptNetworkLoading: Sendable { + func load(_ request: URLRequest) throws -> HTTPResponse +} + +public protocol JavaScriptRedirectNetworkLoading: JavaScriptNetworkLoading { + func load(_ request: URLRequest, followRedirects: Bool) throws -> HTTPResponse +} + +public final class SynchronousJavaScriptNetworkLoader: JavaScriptRedirectNetworkLoading, @unchecked Sendable { + public init() {} + + public func load(_ request: URLRequest) throws -> HTTPResponse { + try load(request, followRedirects: true) + } + + public func load(_ original: URLRequest, followRedirects: Bool) throws -> HTTPResponse { + let settings = ApplicationSettingsStore.shared.settings + let request = settings.configure(original) + let address = try ApplicationSettings.parseHosts(settings.hosts)[request.url?.host?.lowercased() ?? ""] + if followRedirects || (settings.proxy.isEmpty && address != nil) { + let semaphore = DispatchSemaphore(value: 0) + let lock = NSLock() + nonisolated(unsafe) var result: Result? + // The synchronous JS caller waits for this request. Detached tasks + // otherwise default to medium priority and cause priority inversion + // during interactive search/source switching. + let task = Task.detached(priority: Task.currentPriority) { + let value: Result + do { + let response: HTTPResponse + if followRedirects { response = try await URLSessionHTTPTransport.shared.data(for: request) } + else { response = try await HostsHTTPTransport.data(for: request, address: address!, ignoreCertificateErrors: settings.ignoreCertificateErrors) } + value = .success(response) + } catch HTTPClientError.statusResponse(let response) { value = .success(response) } + catch { value = .failure(error) } + lock.withLock { result = value } + semaphore.signal() + } + guard semaphore.wait(timeout: .now() + max(1, request.timeoutInterval) + 1) == .success else { + task.cancel() + throw URLError(.timedOut) + } + lock.lock(); let value = result; lock.unlock() + return try value?.get() ?? { throw HTTPClientError.nonHTTPResponse }() + } + let configuration = URLSessionConfiguration.ephemeral + configuration.httpCookieAcceptPolicy = .always + configuration.httpShouldSetCookies = true + configuration.httpCookieStorage = .shared + try settings.configure(configuration) + let delegate: any URLSessionDelegate = followRedirects ? LegadoUnsafeTrustDelegate.shared : JavaScriptRedirectBlocker() + let callbackQueue = OperationQueue() + callbackQueue.qualityOfService = Thread.current.qualityOfService + let session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: callbackQueue) + defer { session.finishTasksAndInvalidate() } + let started = Date() + do { + let response = try load(request, session: session) + DiagnosticLog.shared.network(request, status: response.statusCode, startedAt: started) + return response + } catch { + DiagnosticLog.shared.network(request, error: error, startedAt: started) + throw error + } + } + + private func load(_ request: URLRequest, session: URLSession) throws -> HTTPResponse { + let semaphore = DispatchSemaphore(value: 0) + let lock = NSLock() + nonisolated(unsafe) var output: Result? + let task = session.dataTask(with: request) { data, response, error in + let result: Result + if let error { + result = .failure(error) + } else if let http = response as? HTTPURLResponse, let url = http.url { + result = .success(.init( + data: data ?? Data(), + statusCode: http.statusCode, + headers: http.allHeaderFields.reduce(into: [:]) { values, entry in + values[String(describing: entry.key)] = String(describing: entry.value) + }, + finalURL: url + )) + } else { + result = .failure(HTTPClientError.nonHTTPResponse) + } + lock.lock() + output = result + lock.unlock() + semaphore.signal() + } + task.resume() + let timeout = min(max(request.timeoutInterval, 1), 180) + guard semaphore.wait(timeout: .now() + timeout + 1) == .success else { + task.cancel() + throw URLError(.timedOut) + } + lock.lock() + let result = output + lock.unlock() + return try result?.get() ?? { throw HTTPClientError.nonHTTPResponse }() + } +} + +private final class JavaScriptRedirectBlocker: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + func urlSession( + _ session: URLSession, + didReceive challenge: URLAuthenticationChallenge + ) async -> (URLSession.AuthChallengeDisposition, URLCredential?) { + LegadoUnsafeTrustDelegate.resolution(for: challenge) + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + completionHandler(nil) + } +} + +public struct JavaScriptRuleEvaluator: Sendable { + public var maximumScriptLength: Int + public var maximumResultLength: Int + private let networkLoader: any JavaScriptNetworkLoading + private let urlBuilder: LegadoURLBuilder + + public func evaluateAsync(script: String, result: JSONValue, executionContext: RuleExecutionContext) async throws -> JavaScriptRuleResult { + try await RuleExecutionWorker.run { + try evaluate(script: script, result: result, executionContext: executionContext) + } + } + + public init( + maximumScriptLength: Int = 64 * 1_024, + maximumResultLength: Int = 2 * 1_024 * 1_024, + networkLoader: any JavaScriptNetworkLoading = SynchronousJavaScriptNetworkLoader(), + urlBuilder: LegadoURLBuilder = .init() + ) { + self.maximumScriptLength = maximumScriptLength + self.maximumResultLength = maximumResultLength + self.networkLoader = networkLoader + self.urlBuilder = urlBuilder + } + + public func evaluate( + script: String, + result: JSONValue, + executionContext: RuleExecutionContext + ) throws -> JavaScriptRuleResult { + guard script.utf8.count <= maximumScriptLength else { + throw RuleEngineError.resourceLimit(rule: script, detail: "JavaScript 规则超过长度上限") + } + try rejectUnboundedExecution(script) + + let box = JSVariableBox(executionContext.variables) + let vm = JSVirtualMachine() + guard let context = JSContext(virtualMachine: vm) else { + throw RuleEngineError.invalidInput(rule: script, expected: "无法创建 JavaScriptCore 上下文") + } + var exceptionMessage: String? + context.exceptionHandler = { _, exception in + exceptionMessage = exception?.toString() ?? "未知 JavaScript 异常" + DiagnosticLog.shared.record(.error, category: "JS", message: exceptionMessage ?? "未知 JavaScript 异常") + } + context.setObject(result.foundationObject, forKeyedSubscript: "result" as NSString) + context.setObject((executionContext.content ?? result).foundationObject, forKeyedSubscript: "src" as NSString) + context.setObject(result.foundationObject, forKeyedSubscript: "$" as NSString) + context.setObject(executionContext.baseURL, forKeyedSubscript: "baseUrl" as NSString) + context.setObject(executionContext.key ?? "", forKeyedSubscript: "key" as NSString) + context.setObject(executionContext.page ?? 0, forKeyedSubscript: "page" as NSString) + installJava(in: context, variables: box, result: result, executionContext: executionContext) + installCookie(in: context) + installJavaImporterCompatibility(in: context) + installSource(in: context, source: executionContext.source, variables: box) + installBook(in: context, book: executionContext.book) + installChapter(in: context, chapter: executionContext.chapter) + context.evaluateScript(""" + (function () { + var nativeParse = JSON.parse; + JSON.parse = function (value) { + if (typeof value !== 'string') return value; + var text = value; + if (text.charCodeAt(0) === 0xFEFF) text = text.slice(1); + text = text.trim(); + if (!text || '[{"-0123456789tfn'.indexOf(text.charAt(0)) < 0) return value; + return nativeParse(text); + }; + })(); + """) + + if let library = executionContext.source?.jsLib?.trimmingCharacters(in: .whitespacesAndNewlines), + !library.isEmpty { + guard library.utf8.count <= maximumScriptLength else { + throw RuleEngineError.resourceLimit(rule: library, detail: "书源 jsLib 超过长度上限") + } + try rejectUnboundedExecution(library) + context.evaluateScript(library) + if let exceptionMessage { + throw RuleEngineError.malformedRule(rule: library, detail: "书源 jsLib 执行失败:\(exceptionMessage)") + } + } + + let evaluated = context.evaluateScript(script) + if let exceptionMessage { + throw RuleEngineError.malformedRule(rule: script, detail: "JavaScript 执行失败:\(exceptionMessage)") + } + // JavaString supplies getBytes() on a boxed JS String. JSC otherwise + // bridges it as {"0":"第", "1":"一", ...}, corrupting decrypted text. + let normalized = evaluated.flatMap { + context.objectForKeyedSubscript("__legadoUnboxString")?.call(withArguments: [$0]) + } + let value = JSONValue.bridge(normalized?.toObject()) + guard value.stringValue.utf8.count <= maximumResultLength else { + throw RuleEngineError.resourceLimit(rule: script, detail: "JavaScript 结果超过长度上限") + } + return .init(value: value, variables: box.values) + } + + private func installJava( + in context: JSContext, + variables: AnyObject, + result: JSONValue, + executionContext: RuleExecutionContext + ) { + guard let box = variables as? VariableBoxProxy else { return } + installJavaBlocks( + in: context, + get: { key in + if key == "bookName", let book = executionContext.book { return book.name } + if key == "title", let chapter = executionContext.chapter { return chapter.title } + return box.get(key) + }, + put: { box.put($0, $1) }, + result: result, + executionContext: executionContext + ) + } + + private func installCookie(in context: JSContext) { + let object = JSValue(newObjectIn: context) + let getCookie: @convention(block) (String) -> String = { + LegadoCookieStore.shared.cookieHeader(for: $0) + } + let getKey: @convention(block) (String, String) -> String = { + LegadoCookieStore.shared.cookie(for: $0, key: $1) + } + let setCookie: @convention(block) (String, String?) -> Void = { url, value in + LegadoCookieStore.shared.setCookie(value ?? "", for: url) + } + let removeCookie: @convention(block) (String) -> Void = { + LegadoCookieStore.shared.removeCookies(for: $0) + } + object?.setObject(getCookie, forKeyedSubscript: "getCookie" as NSString) + object?.setObject(getKey, forKeyedSubscript: "getKey" as NSString) + object?.setObject(setCookie, forKeyedSubscript: "setCookie" as NSString) + object?.setObject(setCookie, forKeyedSubscript: "replaceCookie" as NSString) + object?.setObject(setCookie, forKeyedSubscript: "setWebCookie" as NSString) + object?.setObject(removeCookie, forKeyedSubscript: "removeCookie" as NSString) + context.setObject(object, forKeyedSubscript: "cookie" as NSString) + context.evaluateScript(""" + (function () { + cookie.cookieToMap = function (header) { + var map = {}; + String(header || '').split(';').forEach(function (part) { + var separator = part.indexOf('='); + if (separator <= 0) return; + var key = part.slice(0, separator).trim(); + var value = part.slice(separator + 1).trim(); + if (value || value === 'null') map[key] = value; + }); + return map; + }; + cookie.mapToCookie = function (map) { + if (!map) return null; + var values = []; + for (var key in map) { + if (Object.prototype.hasOwnProperty.call(map, key)) { + values.push(key + '=' + map[key]); + } + } + return values.length ? values.join('; ') : null; + }; + })(); + """) + } + + private func installJavaImporterCompatibility(in context: JSContext) { + let utf8Bytes: @convention(block) (String) -> [Int] = { value in + Data(value.utf8).map { Int(Int8(bitPattern: $0)) } + } + let utf8String: @convention(block) (Any?) -> String = { value in + String(decoding: Self.byteData(from: value), as: UTF8.self) + } + let maximumBytes = maximumResultLength + let aesCBC: @convention(block) (Int, Any?, Any?, Any?) -> [Int]? = { operation, keyValue, ivValue, dataValue in + let key = Self.byteData(from: keyValue) + let iv = Self.byteData(from: ivValue) + let input = Self.byteData(from: dataValue) + guard [1, 2].contains(operation), [16, 24, 32].contains(key.count), + iv.count == kCCBlockSizeAES128, input.count <= maximumBytes else { return nil } + var output = [UInt8](repeating: 0, count: input.count + kCCBlockSizeAES128) + let capacity = output.count + var written = 0 + let status = key.withUnsafeBytes { keyBytes in + iv.withUnsafeBytes { ivBytes in + input.withUnsafeBytes { inputBytes in + CCCrypt(operation == 1 ? CCOperation(kCCEncrypt) : CCOperation(kCCDecrypt), + CCAlgorithm(kCCAlgorithmAES), CCOptions(kCCOptionPKCS7Padding), + keyBytes.baseAddress, key.count, ivBytes.baseAddress, + inputBytes.baseAddress, input.count, &output, capacity, &written) + } + } + } + guard status == kCCSuccess else { return nil } + return output.prefix(written).map { Int(Int8(bitPattern: $0)) } + } + let base64EncodeBytes: @convention(block) (Any?) -> String = { value in + Self.byteData(from: value).base64EncodedString() + } + let base64DecodeBytes: @convention(block) (String) -> [Int] = { value in + guard let data = Data(base64Encoded: value) else { return [] } + return data.map { Int(Int8(bitPattern: $0)) } + } + let hexEncodeBytes: @convention(block) (Any?) -> String = { value in + Self.byteData(from: value).map { String(format: "%02X", $0) }.joined() + } + let hmacSHA1: @convention(block) (Any?, Any?) -> [Int] = { keyValue, dataValue in + let key = SymmetricKey(data: Self.byteData(from: keyValue)) + let authentication = HMAC.authenticationCode( + for: Self.byteData(from: dataValue), + using: key + ) + return authentication.map { Int(Int8(bitPattern: $0)) } + } + let formURLEncode: @convention(block) (String) -> String = { Self.formURLEncode($0) } + context.setObject(utf8Bytes, forKeyedSubscript: "__legadoUTF8Bytes" as NSString) + context.setObject(utf8String, forKeyedSubscript: "__legadoUTF8String" as NSString) + context.setObject(aesCBC, forKeyedSubscript: "__legadoAESCBC" as NSString) + context.setObject(base64EncodeBytes, forKeyedSubscript: "__legadoBase64EncodeBytes" as NSString) + context.setObject(base64DecodeBytes, forKeyedSubscript: "__legadoBase64DecodeBytes" as NSString) + context.setObject(hexEncodeBytes, forKeyedSubscript: "__legadoHexEncodeBytes" as NSString) + context.setObject(hmacSHA1, forKeyedSubscript: "__legadoHmacSHA1" as NSString) + context.setObject(formURLEncode, forKeyedSubscript: "__legadoFormURLEncode" as NSString) + context.evaluateScript(Self.javaImporterBootstrap) + } + + private func installJavaBlocks( + in context: JSContext, + get: @escaping (String) -> String, + put: @escaping (String, String) -> String, + result: JSONValue, + executionContext: RuleExecutionContext + ) { + let java = JSValue(newObjectIn: context) + let getBlock: @convention(block) (String) -> String = { key in get(key) } + let putBlock: @convention(block) (String, Any?) -> String = { key, value in + put(key, Self.stringify(value)) + } + let putVolumeBlock: @convention(block) (Any?) -> String = { value in + let title = Self.stringify(value) + var volumes = (try? JSONDecoder().decode([String].self, from: Data(get("__legadoVolumes").utf8))) ?? [] + volumes.append(title) + if let data = try? JSONEncoder().encode(volumes) { + _ = put("__legadoVolumes", String(decoding: data, as: UTF8.self)) + } + return title + } + let getStringBlock: @convention(block) (String, Any?, Bool) -> String = { rule, content, isURL in + let input = content.map(JSONValue.bridge) ?? executionContext.content ?? result + return (try? self.evaluateStringRule( + rule, + content: input, + isURL: isURL, + executionContext: executionContext + )) ?? "" + } + let md5Block: @convention(block) (String) -> String = { value in + Insecure.MD5.hash(data: Data(value.utf8)).map { String(format: "%02x", $0) }.joined() + } + let base64Encode: @convention(block) (String) -> String = { Data($0.utf8).base64EncodedString() } + let base64Decode: @convention(block) (String) -> String = { + Data(base64Encoded: $0).map { String(decoding: $0, as: UTF8.self) } ?? "" + } + let base64DecodeToByteArray: @convention(block) (String) -> [Int] = { + guard let data = Data(base64Encoded: $0) else { return [] } + return data.map { Int(Int8(bitPattern: $0)) } + } + let strToBytes: @convention(block) (String) -> [Int] = { + Array($0.utf8).map { Int(Int8(bitPattern: $0)) } + } + let bytesToStr: @convention(block) (Any?) -> String = { value in + guard let values = value as? [Any] else { return "" } + let bytes = values.compactMap { item -> UInt8? in + if let number = item as? NSNumber { return UInt8(truncatingIfNeeded: number.intValue) } + if let number = Int(Self.stringify(item)) { return UInt8(truncatingIfNeeded: number) } + return nil + } + return String(decoding: bytes, as: UTF8.self) + } + let hexDecodeToByteArray: @convention(block) (String) -> [Int] = { value in + Self.hexBytes(value).map { Int(Int8(bitPattern: $0)) } + } + let hexDecodeToString: @convention(block) (String) -> String = { value in + String(decoding: Self.hexBytes(value), as: UTF8.self) + } + let hexEncodeToString: @convention(block) (String) -> String = { value in + Data(value.utf8).map { String(format: "%02x", $0) }.joined() + } + let timeFormatUTC: @convention(block) (Double, String, Int) -> String = { milliseconds, format, offset in + let formatter = DateFormatter() + formatter.locale = Locale.current + formatter.timeZone = TimeZone(secondsFromGMT: offset / 1_000) + formatter.dateFormat = format + return formatter.string(from: Date(timeIntervalSince1970: milliseconds / 1_000)) + } + let timeFormat: @convention(block) (Double) -> String = { milliseconds in + let formatter = DateFormatter() + formatter.locale = Locale.current + formatter.dateFormat = "yyyy/MM/dd HH:mm" + return formatter.string(from: Date(timeIntervalSince1970: milliseconds / 1_000)) + } + let randomUUID: @convention(block) () -> String = { UUID().uuidString } + let deviceID: @convention(block) () -> String = { + let key = "ios.installation.identifier" + let stored = BookSourceRuntimeStore.shared.get(sourceID: "__app__", key: key) + if !stored.isEmpty { return stored } + let value = UUID().uuidString.replacingOccurrences(of: "-", with: "").lowercased() + BookSourceRuntimeStore.shared.put(sourceID: "__app__", key: key, value: value) + return value + } + let getTag: @convention(block) () -> String = { executionContext.source?.id ?? "" } + let logValue: @convention(block) (Any?) -> Any? = { value in + DiagnosticLog.shared.record(category: "JS", message: String(describing: value ?? "null")) + return value + } + context.setObject(logValue, forKeyedSubscript: "__legadoLog" as NSString) + context.evaluateScript("var console = { log: function() { __legadoLog(Array.prototype.join.call(arguments, ' ')); }, warn: function() { __legadoLog(Array.prototype.join.call(arguments, ' ')); }, error: function() { __legadoLog(Array.prototype.join.call(arguments, ' ')); } };") + let urlEncode: @convention(block) (String) -> String = { + Self.formURLEncode($0) + } + let getCookieHeader: @convention(block) (String) -> String = { + LegadoCookieStore.shared.cookieHeader(for: $0) + } + let getCookieValue: @convention(block) (String, String) -> String = { + LegadoCookieStore.shared.cookie(for: $0, key: $1) + } + let setCookie: @convention(block) (String, String) -> Void = { + LegadoCookieStore.shared.setCookie($1, for: $0) + } + let ajax: @convention(block) (Any?, Any?) -> String = { value, timeoutValue in + let rawURL = Self.firstURL(value) + do { + let response = try self.loadJavaScriptRequest( + rawURL, + extraHeaders: [:], + timeoutValue: timeoutValue, + executionContext: executionContext + ) + return response.decodedText + } catch { + return error.localizedDescription + } + } + let ajaxAll: @convention(block) (Any?, Any?) -> [[String: Any]] = { value, _ in + let urls: [String] + if let values = value as? [Any] { + urls = values.map(Self.stringify) + } else { + urls = [Self.stringify(value)] + } + return urls.map { rawURL in + do { + let response = try self.loadJavaScriptRequest( + rawURL, + extraHeaders: [:], + timeoutValue: nil, + executionContext: executionContext + ) + return [ + "url": response.finalURL.absoluteString, + "body": response.decodedText, + "code": response.statusCode, + "headers": response.headers + ] + } catch { + return [ + "url": rawURL, + "body": error.localizedDescription, + "code": 0, + "headers": [:] + ] + } + } + } + let directRequest: @convention(block) (String, String, Any?, Any?, Any?) -> [String: Any] = { + method, rawURL, body, headers, timeout in + do { + let response = try self.loadDirectJavaScriptRequest( + method: method, + rawURL: rawURL, + body: body.map { Self.stringify($0) }, + headers: Self.headerMap(headers), + timeoutValue: timeout, + executionContext: executionContext + ) + return [ + "urlText": response.finalURL.absoluteString, + "bodyText": response.decodedText, + "code": response.statusCode, + "headers": response.headers + ] + } catch { + return ["error": error.localizedDescription] + } + } + let connect: @convention(block) (Any?, Any?, Any?) -> [String: Any] = { value, headerValue, timeoutValue in + let rawURL = Self.firstURL(value) + do { + let response = try self.loadJavaScriptRequest( + rawURL, + extraHeaders: Self.headerMap(headerValue), + timeoutValue: timeoutValue, + executionContext: executionContext + ) + return [ + "url": response.finalURL.absoluteString, + "body": response.decodedText, + "code": response.statusCode, + "message": HTTPURLResponse.localizedString(forStatusCode: response.statusCode), + "headers": response.headers, + "successful": (200 ... 299).contains(response.statusCode), + "callTime": 0 + ] + } catch { + return [ + "url": rawURL, + "body": error.localizedDescription, + "code": 0, + "message": error.localizedDescription, + "headers": [:], + "successful": false, + "callTime": 0 + ] + } + } + java?.setObject(getBlock, forKeyedSubscript: "get" as NSString) + java?.setObject(putBlock, forKeyedSubscript: "put" as NSString) + java?.setObject(putVolumeBlock, forKeyedSubscript: "putVolume" as NSString) + java?.setObject(getStringBlock, forKeyedSubscript: "__getString" as NSString) + java?.setObject(md5Block, forKeyedSubscript: "md5Encode" as NSString) + java?.setObject(base64Encode, forKeyedSubscript: "base64Encode" as NSString) + java?.setObject(base64Decode, forKeyedSubscript: "base64Decode" as NSString) + java?.setObject(base64DecodeToByteArray, forKeyedSubscript: "base64DecodeToByteArray" as NSString) + java?.setObject(strToBytes, forKeyedSubscript: "strToBytes" as NSString) + java?.setObject(bytesToStr, forKeyedSubscript: "bytesToStr" as NSString) + java?.setObject(hexDecodeToByteArray, forKeyedSubscript: "hexDecodeToByteArray" as NSString) + java?.setObject(hexDecodeToString, forKeyedSubscript: "hexDecodeToString" as NSString) + java?.setObject(hexEncodeToString, forKeyedSubscript: "hexEncodeToString" as NSString) + java?.setObject(timeFormatUTC, forKeyedSubscript: "timeFormatUTC" as NSString) + java?.setObject(timeFormat, forKeyedSubscript: "timeFormat" as NSString) + java?.setObject(randomUUID, forKeyedSubscript: "randomUUID" as NSString) + java?.setObject(deviceID, forKeyedSubscript: "androidId" as NSString) + java?.setObject(getTag, forKeyedSubscript: "getTag" as NSString) + java?.setObject(logValue, forKeyedSubscript: "log" as NSString) + java?.setObject(logValue, forKeyedSubscript: "logType" as NSString) + java?.setObject(logValue, forKeyedSubscript: "toast" as NSString) + java?.setObject(urlEncode, forKeyedSubscript: "encodeURI" as NSString) + java?.setObject(getCookieHeader, forKeyedSubscript: "getCookieHeader" as NSString) + java?.setObject(getCookieValue, forKeyedSubscript: "__getCookieValue" as NSString) + java?.setObject(setCookie, forKeyedSubscript: "setCookie" as NSString) + java?.setObject(ajax, forKeyedSubscript: "ajax" as NSString) + java?.setObject(ajaxAll, forKeyedSubscript: "ajaxAll" as NSString) + java?.setObject(connect, forKeyedSubscript: "connect" as NSString) + java?.setObject(directRequest, forKeyedSubscript: "__directRequest" as NSString) + context.setObject(java, forKeyedSubscript: "java" as NSString) + context.evaluateScript(""" + (function () { + var getVariable = java.get; + function responseOf(raw) { + if (raw.error) throw new Error(raw.error); + return { + statusCode: function () { return raw.code; }, + body: function () { return raw.bodyText; }, + header: function (name) { + var wanted = String(name).toLowerCase(); + for (var key in raw.headers) if (key.toLowerCase() === wanted) return raw.headers[key]; + return null; + }, + headers: function () { return raw.headers; }, + url: function () { return { toString: function () { return raw.urlText; } }; } + }; + } + java.get = function (url, headers, timeout) { + var target = String(url); + var networkURL = /^(https?:|data:)/i.test(target) || target[0] === '/' || + target.indexOf('./') === 0 || target.indexOf('../') === 0; + if (arguments.length === 1 && !networkURL) { + return getVariable(target); + } + return responseOf(java.__directRequest('GET', target, null, headers || {}, timeout)); + }; + java.getString = function (rule, content, isUrl) { + return java.__getString( + String(rule == null ? '' : rule), + arguments.length < 2 || content == null ? src : content, + Boolean(isUrl) + ); + }; + java.head = function (url, headers, timeout) { + return responseOf(java.__directRequest('HEAD', String(url), null, headers || {}, timeout)); + }; + java.post = function (url, body, headers, timeout) { + return responseOf(java.__directRequest('POST', String(url), body, headers || {}, timeout)); + }; + java.getCookie = function (tag, key) { + return arguments.length < 2 || key == null + ? java.getCookieHeader(String(tag)) + : java.__getCookieValue(String(tag), String(key)); + }; + java.getSource = function () { return typeof source === 'undefined' ? null : source; }; + java.getWebViewUA = function () { return '\(LegadoURLBuilder.defaultUserAgent)'; }; + java.getHeaderMap = function () { + var map = typeof source === 'undefined' ? {} : source.getHeaderMap(true); + map.put = function (key, value) { map[String(key)] = String(value); return value; }; + map.putAll = function (other) { for (var key in other) if (typeof other[key] !== 'function') map[key] = other[key]; }; + return map; + }; + java.ajaxTestAll = function (urls, timeout) { + return java.ajaxAll(urls, timeout).map(function (raw) { + var bodyText = raw.body; + raw.callTime = function () { return raw.code >= 200 && raw.code < 300 ? 0 : -1; }; + raw.body = function () { return bodyText; }; + return raw; + }); + }; + java.clearTtsCache = function () { return true; }; + })(); + """) + } + + private func loadDirectJavaScriptRequest( + method: String, + rawURL: String, + body: String?, + headers: [String: String], + timeoutValue: Any?, + executionContext: RuleExecutionContext + ) throws -> HTTPResponse { + let baseText = executionContext.baseURL.isEmpty + ? executionContext.source?.bookSourceUrl ?? "" + : executionContext.baseURL + let baseURL = URL(string: baseText) + guard let url = URL(string: rawURL, relativeTo: baseURL)?.absoluteURL, + let scheme = url.scheme?.lowercased(), ["http", "https"].contains(scheme) else { + throw RuleEngineError.invalidInput(rule: rawURL, expected: "java.get/head/post URL") + } + let milliseconds = Self.number(timeoutValue) ?? 30_000 + var request = URLRequest(url: url, timeoutInterval: min(max(milliseconds / 1_000, 1), 180)) + request.httpMethod = method.uppercased() + request.httpShouldHandleCookies = executionContext.source?.enabledCookieJar == true + var requestHeaders = try executionContext.source.map { try BookSourceHeaderResolver().resolve($0) } ?? [:] + requestHeaders.merge(headers) { _, explicit in explicit } + if !requestHeaders.keys.contains(where: { $0.caseInsensitiveCompare("User-Agent") == .orderedSame }) { + requestHeaders["User-Agent"] = ApplicationSettingsStore.shared.settings.effectiveUserAgent + } + requestHeaders.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) } + if method.caseInsensitiveCompare("POST") == .orderedSame { + request.httpBody = Data((body ?? "").utf8) + } + if executionContext.source?.enabledCookieJar == true { + let stored = LegadoCookieStore.shared.cookieHeader(for: rawURL) + let explicit = request.value(forHTTPHeaderField: "Cookie") ?? "" + let merged = Self.mergeCookieHeaders(stored, explicit) + if !merged.isEmpty { request.setValue(merged, forHTTPHeaderField: "Cookie") } + } + let response: HTTPResponse + if let redirectLoader = networkLoader as? any JavaScriptRedirectNetworkLoading { + response = try redirectLoader.load(request, followRedirects: false) + } else { + response = try networkLoader.load(request) + } + if let cookie = response.headers.first(where: { $0.key.caseInsensitiveCompare("Set-Cookie") == .orderedSame })?.value { + LegadoCookieStore.shared.setCookie(cookie, for: response.finalURL.absoluteString) + } + return response + } + + private func loadJavaScriptRequest( + _ rawURL: String, + extraHeaders: [String: String], + timeoutValue: Any?, + executionContext: RuleExecutionContext + ) throws -> HTTPResponse { + guard !rawURL.isEmpty else { + throw RuleEngineError.invalidInput(rule: rawURL, expected: "java.ajax/connect URL") + } + let source = executionContext.source + var headers = try source.map { try BookSourceHeaderResolver().resolve($0) } ?? [:] + headers.merge(extraHeaders) { _, extra in extra } + let base = URL(string: executionContext.baseURL.isEmpty ? source?.bookSourceUrl ?? "" : executionContext.baseURL) + let prepared = try urlBuilder.prepare(rawURL, baseURL: base, defaultHeaders: headers) + if prepared.useWebView || prepared.webJavaScript != nil { + throw RuleEngineError.unsupportedRule(rule: rawURL, feature: "JS 内 WKWebView 请求") + } + let milliseconds = Self.number(timeoutValue) + let timeout = milliseconds.map { min(max($0 / 1_000, 1), 180) } + ?? min(max(Double(source?.respondTime ?? 30_000) / 1_000, 5), 180) + var request = prepared.urlRequest(timeout: timeout, handleCookies: source?.enabledCookieJar == true) + if source?.enabledCookieJar == true { + let stored = LegadoCookieStore.shared.cookieHeader(for: prepared.url.absoluteString) + let explicit = request.value(forHTTPHeaderField: "Cookie") ?? "" + let merged = Self.mergeCookieHeaders(stored, explicit) + if !merged.isEmpty { request.setValue(merged, forHTTPHeaderField: "Cookie") } + } + let retryCount = max(prepared.retry, 0) + let attempts = retryCount == Int.max ? Int.max : retryCount + 1 + var lastError: Error = HTTPClientError.nonHTTPResponse + for attempt in 0.. String = { key in + BookSourceRuntimeStore.shared.get(sourceID: sourceID, key: key) + } + let put: @convention(block) (String, Any?) -> String = { key, value in + let text = Self.stringify(value) + BookSourceRuntimeStore.shared.put(sourceID: sourceID, key: key, value: text) + return text + } + let getLoginHeader: @convention(block) () -> Any? = { + let value = BookSourceRuntimeStore.shared.get(sourceID: sourceID, key: "loginHeader") + return value.isEmpty ? nil : value + } + let putLoginHeader: @convention(block) (Any?) -> String = { + let value = Self.stringify($0) + BookSourceRuntimeStore.shared.put(sourceID: sourceID, key: "loginHeader", value: value) + return value + } + let getLoginInfo: @convention(block) () -> [String: Any] = { + let value = BookSourceRuntimeStore.shared.get(sourceID: sourceID, key: "loginInfo") + guard let data = value.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [:] } + return object + } + let putLoginInfo: @convention(block) (Any?) -> String = { + let value = Self.stringify($0) + BookSourceRuntimeStore.shared.put(sourceID: sourceID, key: "loginInfo", value: value) + return value + } + let getHeaderMap: @convention(block) (Bool) -> [String: String] = { includeLogin in + var headers: [String: String] = [:] + if let raw = source?.header?.trimmingCharacters(in: .whitespacesAndNewlines), + raw.hasPrefix("{"), + let data = raw.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { + headers = object.mapValues { Self.stringify($0) } + } + if includeLogin { + headers.merge(BookSourceRuntimeStore.shared.loginHeaders(sourceID: sourceID)) { _, login in login } + } + return headers + } + object?.setObject(get, forKeyedSubscript: "get" as NSString) + object?.setObject(put, forKeyedSubscript: "put" as NSString) + object?.setObject(getLoginHeader, forKeyedSubscript: "getLoginHeader" as NSString) + object?.setObject(putLoginHeader, forKeyedSubscript: "putLoginHeader" as NSString) + object?.setObject(getLoginInfo, forKeyedSubscript: "__getLoginInfoMap" as NSString) + object?.setObject(putLoginInfo, forKeyedSubscript: "putLoginInfo" as NSString) + object?.setObject(getHeaderMap, forKeyedSubscript: "getHeaderMap" as NSString) + object?.setObject(source?.bookSourceName ?? "", forKeyedSubscript: "bookSourceName" as NSString) + object?.setObject(source?.bookSourceUrl ?? "", forKeyedSubscript: "bookSourceUrl" as NSString) + object?.setObject(source?.bookSourceGroup ?? "", forKeyedSubscript: "bookSourceGroup" as NSString) + object?.setObject(source?.bookSourceType ?? 0, forKeyedSubscript: "bookSourceType" as NSString) + object?.setObject(source?.bookUrlPattern ?? "", forKeyedSubscript: "bookUrlPattern" as NSString) + object?.setObject(source?.customOrder ?? 0, forKeyedSubscript: "customOrder" as NSString) + object?.setObject(source?.enabled ?? false, forKeyedSubscript: "enabled" as NSString) + object?.setObject(source?.enabledExplore ?? false, forKeyedSubscript: "enabledExplore" as NSString) + object?.setObject(source?.header ?? "", forKeyedSubscript: "header" as NSString) + object?.setObject(source?.loginUrl ?? "", forKeyedSubscript: "loginUrl" as NSString) + object?.setObject(source?.searchUrl ?? "", forKeyedSubscript: "searchUrl" as NSString) + object?.setObject(source?.exploreUrl ?? "", forKeyedSubscript: "exploreUrl" as NSString) + object?.setObject(source?.respondTime ?? 0, forKeyedSubscript: "respondTime" as NSString) + object?.setObject(source?.weight ?? 0, forKeyedSubscript: "weight" as NSString) + context.setObject(object, forKeyedSubscript: "source" as NSString) + context.evaluateScript(""" + (function () { + source.getKey = function () { return source.bookSourceUrl; }; + source.getTag = function () { return source.bookSourceName; }; + source.getLoginInfoMap = function () { + var map = source.__getLoginInfoMap() || {}; + map.get = function (key) { return map[String(key)]; }; + map.put = function (key, value) { map[String(key)] = value; return value; }; + map.putAll = function (other) { for (var key in other) if (typeof other[key] !== 'function') map[key] = other[key]; }; + return map; + }; + source.login = function () { + if (!source.loginUrl) return null; + (0, eval)(String(source.loginUrl)); + return typeof login === 'function' ? login() : null; + }; + })(); + """) + } + + private func installBook(in context: JSContext, book: Book?) { + let object = JSValue(newObjectIn: context) + let getName: @convention(block) () -> String = { book?.name ?? "" } + let getAuthor: @convention(block) () -> String = { book?.author ?? "" } + let getBookURL: @convention(block) () -> String = { book?.bookUrl ?? "" } + let getCoverURL: @convention(block) () -> String = { book?.coverUrl ?? "" } + let getTotal: @convention(block) () -> Int = { book?.totalChapterNum ?? 0 } + object?.setObject(getName, forKeyedSubscript: "getName" as NSString) + object?.setObject(getAuthor, forKeyedSubscript: "getAuthor" as NSString) + object?.setObject(getBookURL, forKeyedSubscript: "getBookUrl" as NSString) + object?.setObject(getCoverURL, forKeyedSubscript: "getCoverUrl" as NSString) + object?.setObject(getTotal, forKeyedSubscript: "getTotalChapterNum" as NSString) + object?.setObject(book?.name ?? "", forKeyedSubscript: "name" as NSString) + object?.setObject(book?.author ?? "", forKeyedSubscript: "author" as NSString) + object?.setObject(book?.bookUrl ?? "", forKeyedSubscript: "bookUrl" as NSString) + object?.setObject(book?.tocUrl ?? "", forKeyedSubscript: "tocUrl" as NSString) + object?.setObject(book?.origin ?? "", forKeyedSubscript: "origin" as NSString) + object?.setObject(book?.originName ?? "", forKeyedSubscript: "originName" as NSString) + object?.setObject(book?.kind ?? "", forKeyedSubscript: "kind" as NSString) + object?.setObject(book?.coverUrl ?? "", forKeyedSubscript: "coverUrl" as NSString) + object?.setObject(book?.intro ?? "", forKeyedSubscript: "intro" as NSString) + object?.setObject(book?.type ?? 0, forKeyedSubscript: "type" as NSString) + object?.setObject(book?.group ?? 0, forKeyedSubscript: "group" as NSString) + object?.setObject(book?.latestChapterTitle ?? "", forKeyedSubscript: "latestChapterTitle" as NSString) + object?.setObject(book?.totalChapterNum ?? 0, forKeyedSubscript: "totalChapterNum" as NSString) + object?.setObject(book?.durChapterIndex ?? 0, forKeyedSubscript: "durChapterIndex" as NSString) + object?.setObject(book?.durChapterTitle ?? "", forKeyedSubscript: "durChapterTitle" as NSString) + object?.setObject(book?.durChapterPos ?? 0, forKeyedSubscript: "durChapterPos" as NSString) + object?.setObject(book?.wordCount ?? "", forKeyedSubscript: "wordCount" as NSString) + object?.setObject(book?.variable ?? "", forKeyedSubscript: "variable" as NSString) + context.setObject(object, forKeyedSubscript: "book" as NSString) + } + + private func installChapter(in context: JSContext, chapter: BookChapter?) { + let object = JSValue(newObjectIn: context) + let getTitle: @convention(block) () -> String = { chapter?.title ?? "" } + let getIndex: @convention(block) () -> Int = { chapter?.index ?? 0 } + let getURL: @convention(block) () -> String = { chapter?.url ?? "" } + object?.setObject(getTitle, forKeyedSubscript: "getTitle" as NSString) + object?.setObject(getIndex, forKeyedSubscript: "getIndex" as NSString) + object?.setObject(getURL, forKeyedSubscript: "getUrl" as NSString) + object?.setObject(chapter?.title ?? "", forKeyedSubscript: "title" as NSString) + object?.setObject(chapter?.index ?? 0, forKeyedSubscript: "index" as NSString) + object?.setObject(chapter?.url ?? "", forKeyedSubscript: "url" as NSString) + object?.setObject(chapter?.baseUrl ?? "", forKeyedSubscript: "baseUrl" as NSString) + object?.setObject(chapter?.bookUrl ?? "", forKeyedSubscript: "bookUrl" as NSString) + object?.setObject(chapter?.isVolume ?? false, forKeyedSubscript: "isVolume" as NSString) + object?.setObject(chapter?.isVip ?? false, forKeyedSubscript: "isVip" as NSString) + object?.setObject(chapter?.isPay ?? false, forKeyedSubscript: "isPay" as NSString) + object?.setObject(chapter?.resourceUrl ?? "", forKeyedSubscript: "resourceUrl" as NSString) + object?.setObject(chapter?.tag ?? "", forKeyedSubscript: "tag" as NSString) + object?.setObject(chapter?.wordCount ?? "", forKeyedSubscript: "wordCount" as NSString) + object?.setObject(chapter?.variable ?? "", forKeyedSubscript: "variable" as NSString) + context.setObject(object, forKeyedSubscript: "chapter" as NSString) + context.setObject(chapter?.title ?? "", forKeyedSubscript: "title" as NSString) + } + + private func evaluateStringRule( + _ rule: String, + content: JSONValue, + isURL: Bool, + executionContext: RuleExecutionContext + ) throws -> String { + let trimmedRule = rule.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedRule.isEmpty else { return "" } + + if case .object(let object) = content, + let direct = object[trimmedRule] { + return resolveJavaScriptRuleURL(direct.stringValue, enabled: isURL, context: executionContext) + } + + let plan = try RuleExpressionParser().parse(trimmedRule) + var branchResults: [[String]] = [] + for branch in plan.branches { + var values: [String] + switch branch.mode { + case .css: + values = try HTMLRuleEvaluator().extractCSS( + branch.expression, + html: content.stringValue, + baseURL: executionContext.baseURL, + isFragment: executionContext.isHTMLFragment + ) + case .xpath: + values = try HTMLRuleEvaluator().extractXPath(branch.expression, html: content.stringValue, isFragment: executionContext.isHTMLFragment) + case .jsonPath: + let json: JSONValue + if case .string(let text) = content { + json = try JSONValue.decode(data: Data(text.utf8)) + } else { + json = content + } + let path = branch.expression.hasPrefix("$") ? branch.expression : "." + branch.expression + let normalizedPath = path.hasPrefix("$") ? path : "$" + path + values = try JSONPathEvaluator().evaluate(path: normalizedPath, in: json) + .map(\.stringValue) + case .regex: + values = try RegexEvaluator().captureGroups(pattern: branch.expression, input: content.stringValue) + .map { $0.count > 1 ? $0.dropFirst().joined() : $0.first ?? "" } + case .javaScript: + values = [try evaluate( + script: branch.expression, + result: content, + executionContext: executionContext + ).value.stringValue] + case .webJavaScript: + throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "java.getString WebView JavaScript") + } + if let pattern = branch.replacePattern, !pattern.isEmpty { + values = try values.map { + try RegexEvaluator().replacing( + pattern: pattern, + in: $0, + with: branch.replacement, + firstOnly: branch.replaceFirst + ) + } + } + branchResults.append(values) + if plan.combination == .fallback, !values.isEmpty { break } + } + + let combined: [String] + switch plan.combination { + case .single, .concatenate: + combined = branchResults.flatMap { $0 } + case .fallback: + combined = branchResults.first(where: { !$0.isEmpty }) ?? [] + case .interleave: + guard let firstCount = branchResults.first?.count else { return "" } + combined = (0.. String { + guard enabled else { return value } + let baseText = context.baseURL.isEmpty ? context.source?.bookSourceUrl ?? "" : context.baseURL + guard !value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return baseText } + guard let baseURL = URL(string: baseText), + let absolute = URL(string: value, relativeTo: baseURL)?.absoluteURL else { return value } + return absolute.absoluteString + } + + private func rejectUnboundedExecution(_ script: String) throws { + let compact = script.replacingOccurrences(of: #"\s+"#, with: "", options: .regularExpression).lowercased() + let rejected = ["while(true)", "while(1)", "for(;;)", "eval(", "functionconstructor("] + if let token = rejected.first(where: compact.contains) { + throw RuleEngineError.resourceLimit(rule: script, detail: "JavaScript 包含未受控执行结构:\(token)") + } + } + + private static func stringify(_ value: Any?) -> String { + guard let value else { return "" } + if let value = value as? String { return value } + if JSONSerialization.isValidJSONObject(value), + let data = try? JSONSerialization.data(withJSONObject: value), + let string = String(data: data, encoding: .utf8) { return string } + return String(describing: value) + } + + private static func firstURL(_ value: Any?) -> String { + if let values = value as? [Any], let first = values.first { return stringify(first) } + return stringify(value) + } + + private static func number(_ value: Any?) -> Double? { + if let value = value as? NSNumber { return value.doubleValue } + return Double(stringify(value)) + } + + private static func hexBytes(_ value: String) -> [UInt8] { + let characters = Array(value.trimmingCharacters(in: .whitespacesAndNewlines)) + guard characters.count.isMultiple(of: 2) else { return [] } + return stride(from: 0, to: characters.count, by: 2).compactMap { index in + UInt8(String(characters[index ... index + 1]), radix: 16) + } + } + + private static func byteData(from value: Any?) -> Data { + let values: [Any] + if let value = value as? [Any] { + values = value + } else if let value = value as? NSArray { + values = value.map { $0 } + } else { + return Data() + } + return Data(values.compactMap { item in + if let number = item as? NSNumber { return UInt8(truncatingIfNeeded: number.intValue) } + if let number = Int(stringify(item)) { return UInt8(truncatingIfNeeded: number) } + return nil + }) + } + + private static let javaImporterBootstrap = #""" + (function (global) { + 'use strict'; + var NativeString = global.String; + global.__legadoUnboxString = function (value) { + return Object.prototype.toString.call(value) === '[object String]' ? NativeString(value) : value; + }; + function javaClass(name, value) { + Object.defineProperty(value, '__javaSimpleName', { value: name }); + return Object.freeze(value); + } + function checkedBytes(value) { + if (!Array.isArray(value)) throw new TypeError('Java byte[] required'); + return value; + } + function JavaString(value) { + var text = new NativeString(Array.isArray(value) ? __legadoUTF8String(value) : (value == null ? '' : value)); + Object.defineProperty(text, 'getBytes', { + value: function (charset) { + if (charset && !/^utf-?8$/i.test(NativeString(charset))) { + throw new Error('Unsupported charset: ' + charset); + } + return __legadoUTF8Bytes(NativeString(this)); + } + }); + return text; + } + function SecretKeySpec(bytes, algorithm) { + if (!(this instanceof SecretKeySpec)) return new SecretKeySpec(bytes, algorithm); + this.key = checkedBytes(bytes).slice(); + this.algorithm = NativeString(algorithm || ''); + Object.freeze(this.key); + Object.freeze(this); + } + function IvParameterSpec(bytes) { + if (!(this instanceof IvParameterSpec)) return new IvParameterSpec(bytes); + this.bytes = checkedBytes(bytes).slice(); + if (this.bytes.length !== 16) throw new Error('AES IV must contain 16 bytes'); + Object.freeze(this.bytes); + Object.freeze(this); + } + var Cipher = javaClass('Cipher', { + ENCRYPT_MODE: 1, + DECRYPT_MODE: 2, + getInstance: function (algorithm) { + if (NativeString(algorithm).toUpperCase() !== 'AES/CBC/PKCS5PADDING') { + throw new Error('Unsupported Cipher algorithm: ' + algorithm); + } + var key = null, iv = null, operation = null; + return { + init: function (mode, spec, parameters) { + if ((mode !== 1 && mode !== 2) || !(spec instanceof SecretKeySpec) || + spec.algorithm.toUpperCase() !== 'AES' || !(parameters instanceof IvParameterSpec)) { + throw new TypeError('AES key, IV and cipher mode required'); + } + operation = mode; key = spec.key; iv = parameters.bytes; + }, + doFinal: function (bytes) { + if (key === null) throw new Error('Cipher not initialized'); + var result = __legadoAESCBC(operation, key, iv, checkedBytes(bytes)); + if (!Array.isArray(result)) throw new Error('AES/CBC operation failed'); + return result; + } + }; + } + }); + var Mac = javaClass('Mac', { + getInstance: function (algorithm) { + if (NativeString(algorithm).toUpperCase() !== 'HMACSHA1') { + throw new Error('Unsupported Mac algorithm: ' + algorithm); + } + var key = null; + return { + init: function (spec) { + if (!(spec instanceof SecretKeySpec) || spec.algorithm.toUpperCase() !== 'HMACSHA1') { + throw new TypeError('HmacSHA1 SecretKeySpec required'); + } + key = spec.key; + }, + doFinal: function (bytes) { + if (key === null) throw new Error('Mac not initialized'); + return __legadoHmacSHA1(key, checkedBytes(bytes)); + } + }; + } + }); + javaClass('SecretKeySpec', SecretKeySpec); + javaClass('IvParameterSpec', IvParameterSpec); + javaClass('String', JavaString); + var Base64 = javaClass('Base64', { + DEFAULT: 0, + NO_WRAP: 2, + encodeToString: function (bytes) { return __legadoBase64EncodeBytes(checkedBytes(bytes)); }, + decode: function (value) { return __legadoBase64DecodeBytes(NativeString(value)); }, + getDecoder: function () { return Object.freeze({ decode: this.decode }); }, + getEncoder: function () { return Object.freeze({ encodeToString: this.encodeToString }); } + }); + var Arrays = javaClass('Arrays', { + copyOfRange: function (bytes, from, to) { + checkedBytes(bytes); + if (!Number.isInteger(from) || !Number.isInteger(to) || from < 0 || from > bytes.length || to < from || to - from > 2097152) { + throw new RangeError('Invalid byte array range'); + } + var copied = bytes.slice(from, to); + while (copied.length < to - from) copied.push(0); + return copied; + } + }); + var URLEncoder = javaClass('URLEncoder', { + encode: function (value, charset) { + if (charset && !/^utf-?8$/i.test(NativeString(charset))) { + throw new Error('Unsupported charset: ' + charset); + } + return __legadoFormURLEncode(NativeString(value)); + } + }); + var DatatypeConverter = javaClass('DatatypeConverter', { + printBase64Binary: function (bytes) { return __legadoBase64EncodeBytes(checkedBytes(bytes)); }, + parseBase64Binary: function (value) { return __legadoBase64DecodeBytes(NativeString(value)); }, + printHexBinary: function (bytes) { return __legadoHexEncodeBytes(checkedBytes(bytes)); } + }); + var packages = { + javax: { + crypto: { Mac: Mac, Cipher: Cipher, spec: { SecretKeySpec: SecretKeySpec, IvParameterSpec: IvParameterSpec } }, + xml: { bind: { DatatypeConverter: DatatypeConverter } } + }, + java: { net: { URLEncoder: URLEncoder }, lang: { String: JavaString }, util: { Base64: Base64, Arrays: Arrays } }, + android: { util: { Base64: Base64 } } + }; + Object.freeze(packages.javax.crypto.spec); + Object.freeze(packages.javax.crypto); + Object.freeze(packages.javax.xml.bind); + Object.freeze(packages.javax.xml); + Object.freeze(packages.javax); + Object.freeze(packages.java.net); + Object.freeze(packages.java.lang); + Object.freeze(packages.java.util); + Object.freeze(packages.java); + Object.freeze(packages.android.util); + Object.freeze(packages.android); + global.Packages = Object.freeze(packages); + global.JavaImporter = Object.freeze(function () { + var imported = Object.create(null); + var allowedPackages = [packages.java.lang, packages.java.net, packages.java.util, + packages.javax.crypto, packages.javax.crypto.spec, packages.javax.xml.bind, packages.android.util]; + function addClass(value) { + if (!value || typeof value.__javaSimpleName !== 'string') throw new TypeError('Unsupported Java class import'); + if (!Object.prototype.hasOwnProperty.call(imported, value.__javaSimpleName)) { + Object.defineProperty(imported, value.__javaSimpleName, { value: value, enumerable: true }); + } + } + function addPackage(value) { + if (allowedPackages.indexOf(value) < 0) throw new TypeError('Unsupported Java package import'); + Object.keys(value).forEach(function (name) { + if (value[name] && typeof value[name].__javaSimpleName === 'string') addClass(value[name]); + }); + } + Object.defineProperty(imported, 'importPackage', { value: function () { + for (var i = 0; i < arguments.length; i++) addPackage(arguments[i]); + }}); + Object.defineProperty(imported, 'importClass', { value: function () { + for (var i = 0; i < arguments.length; i++) addClass(arguments[i]); + }}); + for (var index = 0; index < arguments.length; index++) { + var value = arguments[index]; + if (allowedPackages.indexOf(value) >= 0) addPackage(value); else addClass(value); + } + return imported; + }); + })(this); + """# + + private static func formURLEncode(_ value: String) -> String { + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-._*")) + return value.unicodeScalars.map { scalar in + if scalar == " " { return "+" } + if scalar.isASCII, allowed.contains(scalar) { return String(scalar) } + return String(scalar).utf8.map { String(format: "%%%02X", $0) }.joined() + }.joined() + } + + private static func headerMap(_ value: Any?) -> [String: String] { + if let values = value as? [String: String] { return values } + if let values = value as? [String: Any] { return values.mapValues(stringify) } + let text = stringify(value) + guard let data = text.data(using: .utf8), + let values = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [:] } + return values.mapValues(stringify) + } + + private static func mergeCookieHeaders(_ stored: String, _ explicit: String) -> String { + var order: [String] = [] + var values: [String: String] = [:] + for header in [stored, explicit] { + for part in header.split(separator: ";") { + let pair = part.split(separator: "=", maxSplits: 1).map(String.init) + guard pair.count == 2 else { continue } + let name = pair[0].trimmingCharacters(in: .whitespaces) + if values[name] == nil { order.append(name) } + values[name] = pair[1].trimmingCharacters(in: .whitespaces) + } + } + return order.compactMap { name in values[name].map { "\(name)=\($0)" } }.joined(separator: "; ") + } +} + +private protocol VariableBoxProxy: AnyObject { + func get(_ key: String) -> String + @discardableResult func put(_ key: String, _ value: String) -> String +} + +private final class JSVariableBox: VariableBoxProxy { + var values: [String: String] + + init(_ values: [String: String]) { + self.values = values + } + + func get(_ key: String) -> String { values[key] ?? "" } + + @discardableResult + func put(_ key: String, _ value: String) -> String { + values[key] = value + return value + } +} + +public final class BookSourceRuntimeStore: @unchecked Sendable { + public static let shared = BookSourceRuntimeStore() + + private let lock = NSLock() + private var values: [String: [String: String]] = [:] + private let persistent = PersistentRuntimeCache.shared + + public func get(sourceID: String, key: String) -> String { + lock.lock() + defer { lock.unlock() } + if let value = values[sourceID]?[key] { return value } + let cacheKey = persistentKey(sourceID: sourceID, key: key) + let value = persistent.get(cacheKey) ?? "" + if !value.isEmpty { values[sourceID, default: [:]][key] = value } + return value + } + + public func put(sourceID: String, key: String, value: String) { + lock.lock() + values[sourceID, default: [:]][key] = value + lock.unlock() + persistent.put(persistentKey(sourceID: sourceID, key: key), value: value) + } + + public func loginHeaders(sourceID: String) -> [String: String] { + let value = get(sourceID: sourceID, key: "loginHeader") + guard 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 func persistentKey(sourceID: String, key: String) -> String { + key == "loginHeader" ? "loginHeader_\(sourceID)" : "v_\(sourceID)_\(key)" + } +} diff --git a/Sources/LegadoCore/RuleEngine/RegexEvaluator.swift b/Sources/LegadoCore/RuleEngine/RegexEvaluator.swift new file mode 100644 index 0000000..c7c4573 --- /dev/null +++ b/Sources/LegadoCore/RuleEngine/RegexEvaluator.swift @@ -0,0 +1,174 @@ +import Foundation + +public struct RegexLimits: Sendable { + public var maximumInputLength: Int + public var maximumPatternLength: Int + public var maximumMatches: Int + + public init( + maximumInputLength: Int = 2_000_000, + maximumPatternLength: Int = 8_192, + maximumMatches: Int = 10_000 + ) { + self.maximumInputLength = maximumInputLength + self.maximumPatternLength = maximumPatternLength + self.maximumMatches = maximumMatches + } +} + +public struct RegexEvaluator: Sendable { + public var limits: RegexLimits + + public init(limits: RegexLimits = .init()) { + self.limits = limits + } + + public func captureGroups(pattern: String, input: String) throws -> [[String]] { + guard pattern.count <= limits.maximumPatternLength else { + throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则表达式超过长度限制") + } + guard input.utf16.count <= limits.maximumInputLength else { + throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则输入超过长度限制") + } + guard !hasObviousNestedQuantifier(pattern) else { + throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则包含高风险嵌套量词") + } + let expression: NSRegularExpression + do { + expression = try NSRegularExpression(pattern: pattern) + } catch { + throw RuleEngineError.malformedRule(rule: pattern, detail: error.localizedDescription) + } + let range = NSRange(input.startIndex..= deadline { + timedOut = true + stop.pointee = true + return + } + guard let match else { return } + if captures.count >= limits.maximumMatches { + stop.pointee = true + return + } + captures.append((0 ..< match.numberOfRanges).map { group in + let range = match.range(at: group) + guard range.location != NSNotFound, let swiftRange = Range(range, in: input) else { return "" } + return String(input[swiftRange]) + }) + } + if timedOut { throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则匹配超时(3000 ms)") } + if captures.count >= limits.maximumMatches { + throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则匹配数量超过限制") + } + return captures + } + + public func replacing( + pattern: String, + in input: String, + with replacement: String, + firstOnly: Bool + ) throws -> String { + guard pattern.count <= limits.maximumPatternLength else { + throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则表达式超过长度限制") + } + guard input.utf16.count <= limits.maximumInputLength else { + throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则输入超过长度限制") + } + guard !hasObviousNestedQuantifier(pattern) else { + throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则包含高风险嵌套量词") + } + let expression: NSRegularExpression + do { + expression = try NSRegularExpression(pattern: pattern) + } catch { + if firstOnly { return replacement } + return input.replacingOccurrences(of: pattern, with: replacement) + } + let range = NSRange(input.startIndex..= deadline { + timedOut = true + stop.pointee = true + } else if let match { + first = match + stop.pointee = true + } + } + if timedOut { throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则匹配超时(3000 ms)") } + guard let first else { return "" } + return expression.replacementString(for: first, in: input, offset: 0, template: replacement) + } + return try replacingStrict(pattern: pattern, in: input, with: replacement) + } + + public func replacingStrict(pattern: String, in input: String, with replacement: String, + timeoutMilliseconds: Int64 = 3_000) throws -> String { + guard pattern.count <= limits.maximumPatternLength, input.utf16.count <= limits.maximumInputLength, + !hasObviousNestedQuantifier(pattern) else { + throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则超过资源限制或包含高风险嵌套量词") + } + let expression: NSRegularExpression + do { expression = try NSRegularExpression(pattern: pattern) } + catch { throw RuleEngineError.malformedRule(rule: pattern, detail: error.localizedDescription) } + let milliseconds = timeoutMilliseconds <= 0 ? 3_000 : min(timeoutMilliseconds, 60_000) + let deadline = DispatchTime.now().uptimeNanoseconds + UInt64(milliseconds) * 1_000_000 + let range = NSRange(location: 0, length: input.utf16.count) + var matches: [NSTextCheckingResult] = [] + var timedOut = false + var exceeded = false + expression.enumerateMatches(in: input, options: .reportProgress, range: range) { match, _, stop in + if DispatchTime.now().uptimeNanoseconds >= deadline { + timedOut = true + stop.pointee = true + } else if let match { + if matches.count >= limits.maximumMatches { exceeded = true; stop.pointee = true } + else { matches.append(match) } + } + } + guard !timedOut else { throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则替换超时(\(milliseconds) ms)") } + guard !exceeded else { throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则匹配数量超过限制") } + let original = input as NSString + var output = "" + var offset = 0 + var outputLength = 0 + for match in matches { + guard DispatchTime.now().uptimeNanoseconds < deadline else { + throw RuleEngineError.resourceLimit(rule: pattern, detail: "正则替换超时(\(milliseconds) ms)") + } + let untouched = original.substring(with: NSRange(location: offset, length: match.range.location - offset)) + let replaced = expression.replacementString(for: match, in: input, offset: 0, template: replacement) + output += untouched + replaced + outputLength += untouched.utf16.count + replaced.utf16.count + offset = NSMaxRange(match.range) + guard outputLength <= limits.maximumInputLength * 2 else { + throw RuleEngineError.resourceLimit(rule: pattern, detail: "替换结果超过长度限制") + } + } + output += original.substring(from: offset) + return output + } + + private func hasObviousNestedQuantifier(_ pattern: String) -> Bool { + let risky = #"\(((?:[^()\\]|\\.)*[+*](?:[^()\\]|\\.)*)\)([+*]|\{[^}]+\})"# + guard let expression = try? NSRegularExpression(pattern: risky) else { return false } + return expression.matches(in: pattern, range: NSRange(pattern.startIndex..., in: pattern)).contains { match in + guard let innerRange = Range(match.range(at: 1), in: pattern), + let outerRange = Range(match.range(at: 2), in: pattern) else { return true } + let inner = String(pattern[innerRange]) + let outer = String(pattern[outerRange]) + if outer.range(of: #"^\{[1-9]\}$"#, options: .regularExpression) != nil { return false } + if !pattern.contains("(?s"), + inner.range(of: #"^(?:\\n[^\[\]()*+?\\]*\.\*|\.\+\\n)$"#, options: .regularExpression) != nil { return false } + return true + } + } +} diff --git a/Sources/LegadoCore/RuleEngine/RuleDiagnostic.swift b/Sources/LegadoCore/RuleEngine/RuleDiagnostic.swift new file mode 100644 index 0000000..484fdb5 --- /dev/null +++ b/Sources/LegadoCore/RuleEngine/RuleDiagnostic.swift @@ -0,0 +1,103 @@ +import Foundation + +public struct RuleDiagnostic: Codable, Hashable, Identifiable, Sendable { + public enum Severity: String, Codable, Sendable { + case warning + case error + } + + public var severity: Severity + public var code: String + public var message: String + public var rule: String + + public var id: String { "\(severity.rawValue):\(code):\(rule):\(message)" } + + public init(severity: Severity, code: String, message: String, rule: String) { + self.severity = severity + self.code = code + self.message = message + self.rule = rule + } +} + +public enum RuleEngineError: Error, Equatable, LocalizedError, Sendable { + case malformedRule(rule: String, detail: String) + case unsupportedRule(rule: String, feature: String) + case invalidInput(rule: String, expected: String) + case resourceLimit(rule: String, detail: String) + + public var code: String { + switch self { + case .malformedRule: "rule.malformed" + case .unsupportedRule: "rule.unsupported" + case .invalidInput: "rule.invalid_input" + case .resourceLimit: "rule.resource_limit" + } + } + + public var errorDescription: String? { + switch self { + case .malformedRule(_, let detail), + .invalidInput(_, let detail), + .resourceLimit(_, let detail): + detail + case .unsupportedRule(_, let feature): + "当前规则后端尚不支持:\(feature)" + } + } + + public var rule: String { + switch self { + case .malformedRule(let rule, _), + .unsupportedRule(let rule, _), + .invalidInput(let rule, _), + .resourceLimit(let rule, _): + rule + } + } + + public var diagnostic: RuleDiagnostic { + .init(severity: .error, code: code, message: localizedDescription, rule: rule) + } +} + +public struct RuleEvaluation: Sendable { + public var values: [JSONValue] + public var diagnostics: [RuleDiagnostic] + public var variables: [String: String] + + public init( + values: [JSONValue] = [], + diagnostics: [RuleDiagnostic] = [], + variables: [String: String] = [:] + ) { + self.values = values + self.diagnostics = diagnostics + self.variables = variables + } + + public var strings: [String] { + values.flatMap { value in + if case .array(let items) = value { return items.map(\.stringValue) } + return [value.stringValue] + } + } + + func listItems(rule: String) -> [JSONValue] { + if rule.trimmingCharacters(in: .whitespacesAndNewlines).hasPrefix(":") { return values } + return values.flatMap { value in + if case .array(let items) = value { return items } + return [value] + } + } + public var firstString: String? { strings.first(where: { !$0.isEmpty }) } + public var leadingString: String? { + guard let value = strings.first, !value.isEmpty else { return nil } + return value + } + public var joinedString: String? { + let value = strings.joined(separator: "\n") + return value.isEmpty ? nil : value + } +} diff --git a/Sources/LegadoCore/RuleEngine/RuleEngine.swift b/Sources/LegadoCore/RuleEngine/RuleEngine.swift new file mode 100644 index 0000000..74d7da7 --- /dev/null +++ b/Sources/LegadoCore/RuleEngine/RuleEngine.swift @@ -0,0 +1,533 @@ +import Foundation + +public struct RuleEngine: Sendable { + private let parser: RuleExpressionParser + private let jsonPath: JSONPathEvaluator + private let regex: RegexEvaluator + private let html: HTMLRuleEvaluator + private let javaScript: JavaScriptRuleEvaluator + + public init( + parser: RuleExpressionParser = .init(), + jsonPath: JSONPathEvaluator = .init(), + regex: RegexEvaluator = .init(), + html: HTMLRuleEvaluator = .init(), + javaScript: JavaScriptRuleEvaluator = .init() + ) { + self.parser = parser + self.jsonPath = jsonPath + self.regex = regex + self.html = html + self.javaScript = javaScript + } + + public func evaluate( + _ rule: String, + in input: JSONValue, + listMode: Bool = false, + context: RuleExecutionContext = .init() + ) async -> RuleEvaluation { + var context = context + if context.content == nil { context.content = input } + if let pipeline = splitJavaScriptPipeline(rule) { + return await evaluatePipeline(pipeline, input: input, listMode: listMode, context: context) + } + do { + let extracted = try extractPutRules(rule) + if !extracted.puts.isEmpty { + var nextContext = context + var diagnostics: [RuleDiagnostic] = [] + for key in extracted.puts.keys.sorted() { + guard let valueRule = extracted.puts[key] else { continue } + let value = await evaluate(valueRule, in: input, context: nextContext) + diagnostics += value.diagnostics + nextContext.variables.merge(value.variables) { _, new in new } + nextContext.variables[key] = value.strings.joined(separator: "\n") + } + if extracted.rule.isEmpty { + return .init(values: [input], diagnostics: diagnostics, variables: nextContext.variables) + } + var result = await evaluate(extracted.rule, in: input, listMode: listMode, context: nextContext) + result.diagnostics = diagnostics + result.diagnostics + result.variables = nextContext.variables.merging(result.variables) { _, new in new } + return result + } + let rendered = try await renderDynamicRule(extracted.rule, result: input, context: context) + if rendered.captureLiteral { + return literalEvaluation(rendered.rule, context: rendered.context) + } + if rendered.context.variables != context.variables { + var result = await evaluatePrepared(rendered.rule, in: input, context: rendered.context) + result.variables = rendered.context.variables.merging(result.variables) { _, new in new } + return result + } + return await evaluatePrepared(rendered.rule, in: input, context: rendered.context) + } catch let error as RuleEngineError { + return .init(diagnostics: [error.diagnostic], variables: context.variables) + } catch { + return .init(diagnostics: [.init(severity: .error, code: "rule.unexpected", message: error.localizedDescription, rule: rule)], variables: context.variables) + } + } + + private func evaluatePrepared( + _ rule: String, + in input: JSONValue, + context: RuleExecutionContext + ) async -> RuleEvaluation { + do { + let plan = try parser.parse(rule, inputLooksLikeJSON: true) + var results: [[JSONValue]] = [] + var diagnostics: [RuleDiagnostic] = [] + for branch in plan.branches { + do { + let values = try await evaluate(branch, in: input, context: context) + results.append(values) + if plan.combination == .fallback, !values.isEmpty { break } + } catch let error as RuleEngineError { + diagnostics.append(error.diagnostic) + results.append([]) + } catch { + diagnostics.append(.init( + severity: .error, + code: "rule.unexpected", + message: error.localizedDescription, + rule: branch.expression + )) + results.append([]) + } + } + return .init( + values: combine(results, with: plan.combination), + diagnostics: diagnostics, + variables: context.variables + ) + } catch let error as RuleEngineError { + return .init(diagnostics: [error.diagnostic], variables: context.variables) + } catch { + return .init(diagnostics: [.init( + severity: .error, + code: "rule.unexpected", + message: error.localizedDescription, + rule: rule + )], variables: context.variables) + } + } + + public func evaluateJSONPath(_ rule: String, in input: JSONValue) throws -> [JSONValue] { + try jsonPath.evaluate(path: rule, in: input) + } + + public func evaluate( + _ rule: String, + inText input: String, + baseURL: String = "", + listMode: Bool = false, + context: RuleExecutionContext = .init() + ) async -> RuleEvaluation { + var context = context + if context.baseURL.isEmpty { context.baseURL = baseURL } + if context.content == nil { context.content = .string(input) } + if let pipeline = splitJavaScriptPipeline(rule) { + return await evaluatePipeline(pipeline, input: .string(input), listMode: listMode, context: context) + } + do { + let extracted = try extractPutRules(rule) + if !extracted.puts.isEmpty { + var nextContext = context + var diagnostics: [RuleDiagnostic] = [] + for key in extracted.puts.keys.sorted() { + guard let valueRule = extracted.puts[key] else { continue } + let value = await evaluate(valueRule, inText: input, baseURL: baseURL, listMode: false, context: nextContext) + diagnostics += value.diagnostics + nextContext.variables.merge(value.variables) { _, new in new } + nextContext.variables[key] = value.strings.joined(separator: "\n") + } + if extracted.rule.isEmpty { + return .init(values: [.string(input)], diagnostics: diagnostics, variables: nextContext.variables) + } + var result = await evaluate(extracted.rule, inText: input, baseURL: baseURL, listMode: listMode, context: nextContext) + result.diagnostics = diagnostics + result.diagnostics + result.variables = nextContext.variables.merging(result.variables) { _, new in new } + return result + } + let rendered = try await renderDynamicRule(extracted.rule, result: .string(input), context: context) + if rendered.captureLiteral { + return literalEvaluation(rendered.rule, context: rendered.context) + } + if rendered.context.variables != context.variables { + var result = await evaluatePrepared(rendered.rule, inText: input, baseURL: baseURL, listMode: listMode, context: rendered.context) + result.variables = rendered.context.variables.merging(result.variables) { _, new in new } + return result + } + return await evaluatePrepared(rendered.rule, inText: input, baseURL: baseURL, listMode: listMode, context: rendered.context) + } catch let error as RuleEngineError { + return .init(diagnostics: [error.diagnostic], variables: context.variables) + } catch { + return .init(diagnostics: [.init(severity: .error, code: "rule.unexpected", message: error.localizedDescription, rule: rule)], variables: context.variables) + } + } + + private func evaluatePrepared( + _ rule: String, + inText input: String, + baseURL: String, + listMode: Bool, + context: RuleExecutionContext + ) async -> RuleEvaluation { + if rule.hasPrefix(":"), rule.count > 1 { + do { + let patterns = rule.dropFirst().components(separatedBy: "&&").filter { !$0.isEmpty } + guard !patterns.isEmpty else { return .init(variables: context.variables) } + var stage = input + var captures: [[String]] = [] + for (index, pattern) in patterns.enumerated() { + captures = try regex.captureGroups(pattern: pattern, input: stage) + if index + 1 < patterns.count { + stage = captures.compactMap(\.first).joined() + } + } + return .init( + values: captures.map { .array($0.map(JSONValue.string)) }, + variables: context.variables + ) + } catch let error as RuleEngineError { + return .init(diagnostics: [error.diagnostic], variables: context.variables) + } catch { + return .init(diagnostics: [.init(severity: .error, code: "rule.unexpected", message: error.localizedDescription, rule: rule)], variables: context.variables) + } + } + do { + let plan = try parser.parse(rule, inputLooksLikeJSON: (try? JSONValue.decode(data: Data(input.utf8))) != nil) + var results: [[JSONValue]] = [] + var diagnostics: [RuleDiagnostic] = [] + for branch in plan.branches { + do { + let strings = try await evaluateTextBranch( + branch, + input: input, + baseURL: baseURL, + listMode: listMode, + context: context + ) + results.append(strings.map(JSONValue.string)) + if plan.combination == .fallback, !strings.isEmpty { break } + } catch let error as RuleEngineError { + diagnostics.append(error.diagnostic) + results.append([]) + } + } + return .init( + values: combine(results, with: plan.combination), + diagnostics: diagnostics, + variables: context.variables + ) + } catch let error as RuleEngineError { + return .init(diagnostics: [error.diagnostic], variables: context.variables) + } catch { + return .init(diagnostics: [.init( + severity: .error, + code: "rule.unexpected", + message: error.localizedDescription, + rule: rule + )], variables: context.variables) + } + } + + private func evaluate( + _ branch: RuleBranch, + in input: JSONValue, + context: RuleExecutionContext + ) async throws -> [JSONValue] { + try Task.checkCancellation() + let rawValues: [JSONValue] + if branch.expression.isEmpty { + return try [JSONValue.string(regex.replacing( + pattern: branch.replacePattern ?? "", in: input.stringValue, + with: branch.replacement, firstOnly: branch.replaceFirst + ))] + } + switch branch.mode { + case .jsonPath: + let path = branch.expression.hasPrefix("$") ? branch.expression : "$.\(branch.expression)" + rawValues = try jsonPath.evaluate(path: path, in: input) + case .regex: + let matches = try regex.captureGroups(pattern: branch.expression, input: input.stringValue) + rawValues = matches.map { .array($0.map(JSONValue.string)) } + case .css: + throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "CSS selector") + case .xpath: + throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "XPath") + case .javaScript: + rawValues = [try await javaScript.evaluateAsync( + script: branch.expression, + result: input, + executionContext: context + ).value] + case .webJavaScript: + throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "WKWebView JavaScript") + } + guard let pattern = branch.replacePattern, !pattern.isEmpty else { return rawValues } + return try rawValues.map { + .string(try regex.replacing( + pattern: pattern, + in: $0.stringValue, + with: branch.replacement, + firstOnly: branch.replaceFirst + )) + } + } + + private func evaluateTextBranch( + _ branch: RuleBranch, + input: String, + baseURL: String, + listMode: Bool, + context: RuleExecutionContext + ) async throws -> [String] { + try Task.checkCancellation() + let raw: [String] + if branch.expression.isEmpty { + return try [regex.replacing( + pattern: branch.replacePattern ?? "", in: input, + with: branch.replacement, firstOnly: branch.replaceFirst + )] + } + switch branch.mode { + case .css: + raw = try listMode + ? html.selectCSSFragments(branch.expression, html: input, baseURL: baseURL, isFragment: context.isHTMLFragment) + : html.extractCSS(branch.expression, html: input, baseURL: baseURL, isFragment: context.isHTMLFragment) + case .xpath: + raw = try listMode + ? html.selectXPathFragments(branch.expression, html: input, isFragment: context.isHTMLFragment) + : html.extractXPath(branch.expression, html: input, isFragment: context.isHTMLFragment) + case .jsonPath: + let root: JSONValue + do { + root = try JSONValue.decode(data: Data(input.utf8)) + } catch { + throw RuleEngineError.invalidInput(rule: branch.expression, expected: "JSON") + } + let path = branch.expression.hasPrefix("$") ? branch.expression : "$.\(branch.expression)" + raw = try jsonPath.evaluate(path: path, in: root).map(\.stringValue) + case .regex: + raw = try regex.captureGroups(pattern: branch.expression, input: input).map { captures in + captures.count > 1 ? captures.dropFirst().joined() : captures.first ?? "" + } + case .javaScript: + raw = [try await javaScript.evaluateAsync( + script: branch.expression, + result: .string(input), + executionContext: context + ).value.stringValue] + case .webJavaScript: + throw RuleEngineError.unsupportedRule(rule: branch.expression, feature: "WKWebView JavaScript") + } + guard let pattern = branch.replacePattern, !pattern.isEmpty else { return raw } + return try raw.map { + try regex.replacing( + pattern: pattern, + in: $0, + with: branch.replacement, + firstOnly: branch.replaceFirst + ) + } + } + + private func combine(_ results: [[JSONValue]], with combination: RuleCombination) -> [JSONValue] { + switch combination { + case .single, .concatenate: + return results.flatMap { $0 } + case .fallback: + return results.first(where: { !$0.isEmpty }) ?? [] + case .interleave: + guard let firstCount = results.first?.count else { return [] } + return (0 ..< firstCount).flatMap { index in + results.compactMap { $0.indices.contains(index) ? $0[index] : nil } + } + } + } + + private func evaluatePipeline( + _ pipeline: JavaScriptPipeline, + input: JSONValue, + listMode: Bool, + context: RuleExecutionContext + ) async -> RuleEvaluation { + var execution = context + let initial: RuleEvaluation + if pipeline.prefix.isEmpty { + initial = .init(values: [input], variables: context.variables) + } else { + initial = await evaluateValue(pipeline.prefix, input: input, listMode: listMode, context: context) + } + execution.variables = initial.variables + var diagnostics = initial.diagnostics + let scriptInput: JSONValue + if pipeline.prefix.isEmpty { + scriptInput = input + } else if listMode { + if initial.values.count == 1, case .array = initial.values[0] { + scriptInput = initial.values[0] + } else { + scriptInput = .array(initial.values) + } + } else { + scriptInput = .string(initial.strings.joined(separator: "\n")) + } + do { + let rendered = try await renderDynamicRule(pipeline.script, result: scriptInput, context: execution) + let result = try await javaScript.evaluateAsync( + script: rendered.rule, result: scriptInput, executionContext: rendered.context + ) + execution.variables = result.variables + guard !pipeline.suffix.isEmpty else { + return .init(values: result.value == .null ? [] : [result.value], diagnostics: diagnostics, variables: execution.variables) + } + execution.isHTMLFragment = false + var next = await evaluateValue(pipeline.suffix, input: result.value, listMode: listMode, context: execution) + next.diagnostics = diagnostics + next.diagnostics + return next + } catch let error as RuleEngineError { + diagnostics.append(error.diagnostic) + } catch { + diagnostics.append(.init(severity: .error, code: "rule.javascript", message: error.localizedDescription, rule: pipeline.script)) + } + return .init(diagnostics: diagnostics, variables: execution.variables) + } + + private func evaluateValue(_ rule: String, input: JSONValue, listMode: Bool, context: RuleExecutionContext) async -> RuleEvaluation { + if case .string(let text) = input { + return await evaluate(rule, inText: text, baseURL: context.baseURL, listMode: listMode, context: context) + } + return await evaluate(rule, in: input, listMode: listMode, context: context) + } + + private func splitJavaScriptPipeline(_ rule: String) -> JavaScriptPipeline? { + guard let pattern = try? NSRegularExpression(pattern: #"([\s\S]*?)|@js:([\s\S]*)"#, options: .caseInsensitive), + let match = pattern.firstMatch(in: rule, range: NSRange(rule.startIndex..., in: rule)), + let full = Range(match.range, in: rule), + let script = Range(match.range(at: match.range(at: 2).location == NSNotFound ? 1 : 2), in: rule) else { return nil } + return .init( + prefix: String(rule[.. (rule: String, puts: [String: String]) { + let expression = try NSRegularExpression(pattern: #"@put:(\{[^}]+?\})"#, options: .caseInsensitive) + let matches = expression.matches(in: source, range: NSRange(source.startIndex..., in: source)) + guard !matches.isEmpty else { return (source, [:]) } + var puts: [String: String] = [:] + var rule = source + for match in matches.reversed() { + guard let fullRange = Range(match.range(at: 0), in: source), + let jsonRange = Range(match.range(at: 1), in: source) else { continue } + if case .object(let object) = try LegadoURLBuilder.decodeLenientJSON(String(source[jsonRange]), quoteUnquotedValues: true) { + puts.merge(object.mapValues(\.stringValue)) { _, new in new } + } + if let currentRange = Range(match.range(at: 0), in: rule) { + rule.removeSubrange(currentRange) + } else { + rule = rule.replacingOccurrences(of: String(source[fullRange]), with: "") + } + } + return (rule.trimmingCharacters(in: .whitespacesAndNewlines), puts) + } + + private func renderDynamicRule( + _ source: String, + result: JSONValue, + context: RuleExecutionContext + ) async throws -> (rule: String, context: RuleExecutionContext, captureLiteral: Bool) { + var source = source + var captureLiteral = false + if case .array(let captures) = result { + let captureExpression = try NSRegularExpression(pattern: #"\$(\d{1,2})"#) + let captureEnd = source.range(of: "##")?.lowerBound ?? source.endIndex + let matches = captureExpression.matches(in: source, range: NSRange(source.startIndex.. RuleEvaluation { + let components = source.components(separatedBy: "##") + var value = components.first ?? "" + if components.count > 1, !components[1].isEmpty { + value = (try? regex.replacing( + pattern: components[1], + in: value, + with: components.count > 2 ? components[2] : "", + firstOnly: components.count > 3 + )) ?? value + } + return .init(values: [.string(value)], variables: context.variables) + } + + private struct JavaScriptPipeline { + var prefix: String + var script: String + var suffix: String + } +} diff --git a/Sources/LegadoCore/RuleEngine/RuleExpression.swift b/Sources/LegadoCore/RuleEngine/RuleExpression.swift new file mode 100644 index 0000000..d038733 --- /dev/null +++ b/Sources/LegadoCore/RuleEngine/RuleExpression.swift @@ -0,0 +1,189 @@ +import Foundation + +public enum RuleMode: String, Codable, Sendable { + case css + case xpath + case jsonPath + case regex + case javaScript + case webJavaScript +} + +public enum RuleCombination: String, Codable, Sendable { + case single + case concatenate = "&&" + case fallback = "||" + case interleave = "%%" +} + +public struct RuleBranch: Codable, Hashable, Sendable { + public var mode: RuleMode + public var expression: String + public var replacePattern: String? + public var replacement: String + public var replaceFirst: Bool + + public init( + mode: RuleMode, + expression: String, + replacePattern: String? = nil, + replacement: String = "", + replaceFirst: Bool = false + ) { + self.mode = mode + self.expression = expression + self.replacePattern = replacePattern + self.replacement = replacement + self.replaceFirst = replaceFirst + } +} + +public struct RulePlan: Codable, Hashable, Sendable { + public var source: String + public var combination: RuleCombination + public var branches: [RuleBranch] +} + +public struct RuleExpressionParser: Sendable { + public init() {} + + public func parse(_ source: String, inputLooksLikeJSON: Bool = false) throws -> RulePlan { + let normalized = source.trimmingCharacters(in: .newlines) + guard !normalized.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw RuleEngineError.malformedRule(rule: source, detail: "规则不能为空") + } + let split = try splitAtTopLevel(normalized) + let branches = try split.parts.map { try parseBranch($0, inputLooksLikeJSON: inputLooksLikeJSON) } + return .init(source: source, combination: split.combination, branches: branches) + } + + private func parseBranch(_ source: String, inputLooksLikeJSON: Bool) throws -> RuleBranch { + var value = String(source.drop(while: { $0.isWhitespace })) + var mode: RuleMode + if value.lowercased().hasPrefix("@css:") { + mode = .css + value.removeFirst(5) + } else if value.hasPrefix("@@") { + mode = .css + value.removeFirst(2) + } else if value.lowercased().hasPrefix("@xpath:") { + mode = .xpath + value.removeFirst(7) + } else if value.lowercased().hasPrefix("@json:") { + mode = .jsonPath + value.removeFirst(6) + } else if value.lowercased().hasPrefix("@js:") { + mode = .javaScript + value.removeFirst(4) + } else if value.lowercased().hasPrefix("") && value.lowercased().hasSuffix("") { + mode = .javaScript + value = String(value.dropFirst(4).dropLast(5)) + } else if value.lowercased().hasPrefix("") && value.lowercased().hasSuffix("") { + mode = .webJavaScript + value = String(value.dropFirst(7).dropLast(8)) + } else if value.hasPrefix(":") { + mode = .regex + value.removeFirst() + } else if value.hasPrefix("$.") || value.hasPrefix("$[") || inputLooksLikeJSON { + mode = .jsonPath + } else if value.hasPrefix("/") || value.hasPrefix("./") { + mode = .xpath + } else { + mode = .css + } + + let replacement = value.components(separatedBy: "##") + let expression = replacement[0].trimmingCharacters(in: .whitespacesAndNewlines) + guard !expression.isEmpty || replacement.count > 1 else { + throw RuleEngineError.malformedRule(rule: source, detail: "规则主体不能为空") + } + return .init( + mode: mode, + expression: expression, + replacePattern: replacement.count > 1 ? replacement[1] : nil, + replacement: replacement.count > 2 ? replacement[2] : "", + replaceFirst: replacement.count > 3 + ) + } + + private func splitAtTopLevel(_ source: String) throws -> (parts: [String], combination: RuleCombination) { + var quote: Character? + var escaped = false + var roundDepth = 0 + var squareDepth = 0 + var curlyDepth = 0 + var found: RuleCombination? + var parts: [String] = [] + var partStart = source.startIndex + var index = source.startIndex + + while index < source.endIndex { + let character = source[index] + if escaped { + escaped = false + index = source.index(after: index) + continue + } + if character == "\\" { + escaped = true + index = source.index(after: index) + continue + } + if let activeQuote = quote { + if character == activeQuote { quote = nil } + index = source.index(after: index) + continue + } + if character == "\"" || character == "'" { + quote = character + index = source.index(after: index) + continue + } + switch character { + case "(": roundDepth += 1 + case ")": roundDepth -= 1 + case "[": squareDepth += 1 + case "]": squareDepth -= 1 + case "{": curlyDepth += 1 + case "}": curlyDepth -= 1 + default: break + } + if roundDepth < 0 || squareDepth < 0 || curlyDepth < 0 { + throw RuleEngineError.malformedRule(rule: source, detail: "规则括号未平衡") + } + if roundDepth == 0, squareDepth == 0, curlyDepth == 0 { + let remainder = source[index...] + let candidate: RuleCombination? = if remainder.hasPrefix("&&") { + .concatenate + } else if remainder.hasPrefix("||") { + .fallback + } else if remainder.hasPrefix("%%") { + .interleave + } else { + nil + } + if let candidate { + if found == nil { found = candidate } + if found == candidate { + parts.append(String(source[partStart..? + + public init(defaults: UserDefaults? = nil) { + self.defaults = defaults + entries = defaults?.data(forKey: Self.key).flatMap { try? JSONDecoder().decode([String: Entry].self, from: $0) } ?? [:] + } + + public func ordered(_ sources: [BookSource], preferred: Set, now: Date = Date()) -> [BookSource] { + func rank(_ source: BookSource) -> Int { + if preferred.contains(source.id) { return 0 } + guard let entry = entries[source.id] else { return 2 } + if let failure = entry.failure, now.timeIntervalSince(failure) < 300, entry.failures > 0 { return 3 } + if let success = entry.success, now.timeIntervalSince(success) < 7 * 86_400 { return 1 } + return 2 + } + return sources.enumerated().sorted { + let lhs = rank($0.element), rhs = rank($1.element) + if lhs != rhs { return lhs < rhs } + return $0.offset < $1.offset + }.map(\.element) + } + + public func record(_ outcome: SourceSearchOutcome, now: Date = Date()) { + guard !outcome.diagnostics.contains(where: { $0.code == "search.cancelled" }) else { return } + var entry = entries[outcome.sourceID] ?? Entry() + if !outcome.books.isEmpty { + entry.success = now + entry.failures = 0 + } else if outcome.diagnostics.contains(where: { $0.severity == .error }) { + entry.failure = now + entry.failures += 1 + } else { return } + entries[outcome.sourceID] = entry + guard defaults != nil, saveTask == nil else { return } + saveTask = Task { [weak self] in + try? await Task.sleep(nanoseconds: 1_000_000_000) + guard !Task.isCancelled else { return } + await self?.flush() + } + } + + public func flush() { + saveTask?.cancel() + saveTask = nil + guard let defaults else { return } + let retained = entries.sorted { + if ($0.value.success != nil) != ($1.value.success != nil) { return $0.value.success != nil } + return ($0.value.success ?? $0.value.failure ?? .distantPast) > ($1.value.success ?? $1.value.failure ?? .distantPast) + }.prefix(512) + entries = Dictionary(uniqueKeysWithValues: retained.map { ($0.key, $0.value) }) + if let data = try? JSONEncoder().encode(entries) { defaults.set(data, forKey: Self.key) } + } +} + +public struct BookSearchAccumulator: Sendable { + private var books: [SearchBook] = [] + private var indices: [String: Int] = [:] + public init() {} + + public mutating func add(_ incoming: [SearchBook], replacingExisting: Bool = false) { + for book in incoming { + let identity = BookSearchService.mergeIdentity(for: book) + if let index = indices[identity] { + let origins = books[index].origins.union(book.origins) + if replacingExisting { books[index] = book } + books[index].origins = origins + } else { + indices[identity] = books.count + books.append(book) + } + } + } + + public func results(keyword: String) -> [SearchBook] { + var buckets = Array(repeating: [SearchBook](), count: 4) + for book in books { + let rank: Int + if book.name == keyword || book.author == keyword { rank = 0 } + else if book.kind?.contains(keyword) == true { rank = 1 } + else if book.name.contains(keyword) || book.author.contains(keyword) { rank = 2 } + else { rank = 3 } + buckets[rank].append(book) + } + return buckets.flatMap { $0 } + } +} + +public struct BookSearchService: 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)? + private let history: BookSearchHistory + + public init( + transport: any HTTPTransport = URLSessionHTTPTransport(), + urlBuilder: LegadoURLBuilder = .init(), + engine: RuleEngine = .init(), + networkPolicy: SourceNetworkPolicy = .shared, + webViewExecutor: (any SourceWebViewRequestExecuting)? = nil, + snapshotRecorder: (any DifferentialSnapshotRecording)? = nil, + history: BookSearchHistory = .shared + ) { + self.transport = transport + self.urlBuilder = urlBuilder + self.engine = engine + self.networkPolicy = networkPolicy + self.webViewExecutor = webViewExecutor + self.snapshotRecorder = snapshotRecorder + self.history = history + } + + public func search( + keyword: String, + page: Int = 1, + sources: [BookSource] + ) async -> BookSearchReport { + let enabled = sources.filter { $0.enabled && $0.searchUrl?.isEmpty == false } + let sourceOrder = Dictionary(enabled.enumerated().map { ($0.element.id, $0.offset) }, uniquingKeysWith: { first, _ in first }) + var outcomes: [SourceSearchOutcome] = [] + for await outcome in searchOutcomes(keyword: keyword, page: page, sources: enabled) { + outcomes.append(outcome) + } + outcomes.sort { (sourceOrder[$0.sourceID] ?? .max) < (sourceOrder[$1.sourceID] ?? .max) } + return .init(books: merge(outcomes: outcomes, keyword: keyword), outcomes: outcomes) + } + + public func searchOutcomes( + keyword: String, + page: Int = 1, + sources: [BookSource], + maximumConcurrentSources: Int = ApplicationSettingsStore.shared.settings.concurrentSources, + includeDisabled: Bool = false, + preferredSourceIDs: Set = [] + ) -> AsyncStream { + var seen = Set() + let enabled = sources.filter { (includeDisabled || $0.enabled) && $0.searchUrl?.isEmpty == false && seen.insert($0.id).inserted } + let concurrency = min(max(maximumConcurrentSources, 1), enabled.count) + guard concurrency > 0 else { + return AsyncStream { $0.finish() } + } + + return AsyncStream { continuation in + let task = Task { + let ordered = await history.ordered(enabled, preferred: preferredSourceIDs) + await withTaskGroup(of: SourceSearchOutcome.self) { group in + var complexRunning = 0 + let complexLimit = min(concurrency, 4) + func isComplex(_ source: BookSource) -> Bool { + let template = (source.searchUrl ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return template.hasPrefix("@js:") || template.hasPrefix("") || template.contains("webview") || template.contains("webjs") + } + let complexIDs = Set(ordered.filter(isComplex).map(\.id)) + let normal = ordered.enumerated().filter { !complexIDs.contains($0.element.id) }.map(\.offset) + let complex = ordered.enumerated().filter { complexIDs.contains($0.element.id) }.map(\.offset) + var normalIndex = 0 + var complexIndex = 0 + func enqueue() -> Bool { + guard !Task.isCancelled else { return false } + let nextNormal = normalIndex < normal.count ? normal[normalIndex] : Int.max + let nextComplex = complexIndex < complex.count && complexRunning < complexLimit ? complex[complexIndex] : Int.max + guard min(nextNormal, nextComplex) != Int.max else { return false } + let source: BookSource + if nextComplex < nextNormal { + source = ordered[nextComplex] + complexIndex += 1 + complexRunning += 1 + } else { + source = ordered[nextNormal] + normalIndex += 1 + } + group.addTask { + await search(keyword: keyword, page: page, source: source, + timeoutLimit: enabled.count > 1 ? 8 : 30) + } + return true + } + for _ in 0.. SourceSearchOutcome { + await search(keyword: keyword, page: page, source: source, timeoutLimit: 30) + } + + private func search(keyword: String, page: Int, source: BookSource, timeoutLimit: TimeInterval) async -> SourceSearchOutcome { + guard let template = source.searchUrl, let rules = source.ruleSearch else { + return failure(source, code: "search.configuration", message: "书源缺少搜索地址或规则") + } + do { + var prepared = try await RuleExecutionWorker.run { + let headers = try BookSourceHeaderResolver().resolve(source) + return try urlBuilder.prepare(template, context: .init(key: keyword, page: page, source: source), + baseURL: URL(string: source.bookSourceUrl), defaultHeaders: headers) + } + if timeoutLimit < 30 { prepared.retry = min(prepared.retry, 1) } + let timeout = min(max(Double(source.respondTime) / 1_000, 5), timeoutLimit) + let response = try await networkPolicy.execute( + prepared, + source: source, + transport: transport, + timeout: timeout, + webViewExecutor: webViewExecutor, + snapshotRecorder: snapshotRecorder, + snapshotStage: .search + ) + guard let listRule = rules.bookList, !listRule.isEmpty else { + return failure(source, code: "search.book_list_rule", message: "搜索列表规则为空") + } + let root = response.decodedJSON + let body = response.decodedText + let listEvaluation = if let root { + await engine.evaluate( + listRule.trimmingListPrefix, + in: root, + listMode: true, + context: .init(variables: response.requestVariables, baseURL: response.finalURL.absoluteString, key: keyword, page: page, source: source) + ) + } else { + await engine.evaluate( + listRule.trimmingListPrefix, + inText: body, + baseURL: response.finalURL.absoluteString, + listMode: true, + context: .init(variables: response.requestVariables, key: keyword, page: page, source: source) + ) + } + var diagnostics = listEvaluation.diagnostics + var books: [SearchBook] = [] + for item in listEvaluation.listItems(rule: listRule.trimmingListPrefix) { + try Task.checkCancellation() + let fields = await evaluateFields( + rules, + item: item, + isHTML: root == nil, + baseURL: response.finalURL.absoluteString, + source: source, + variables: listEvaluation.variables, + keyword: keyword, + page: page + ) + diagnostics += fields.diagnostics + guard let name = fields.name?.trimmingCharacters(in: .whitespacesAndNewlines), !name.isEmpty else { continue } + let bookURLValue = fields.bookURL?.nilIfEmpty ?? response.finalURL.absoluteString + let bookURL = resolve(bookURLValue, 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 seenURLs = Set() + let unique = books.filter { seenURLs.insert($0.bookURL).inserted } + return .init(sourceID: source.id, books: unique, diagnostics: diagnostics) + } catch is CancellationError { + return failure(source, code: "search.cancelled", message: "搜索已取消") + } catch let error as RuleEngineError { + return .init(sourceID: source.id, books: [], diagnostics: [error.diagnostic]) + } catch { + return failure(source, code: "search.request", message: error.localizedDescription) + } + } + + private func evaluateFields( + _ rules: SearchRule, + item: JSONValue, + isHTML: Bool, + baseURL: String, + source: BookSource, + variables: [String: String], + keyword: String, + page: Int + ) async -> FieldEvaluation { + var result = FieldEvaluation() + let currentBook = Book(bookUrl: baseURL, name: "", origin: source.bookSourceUrl) + var context = RuleExecutionContext(variables: variables, baseURL: baseURL, key: keyword, page: page, source: source, book: currentBook, isHTMLFragment: true) + let name = await evaluate(rules.name, item: item, isHTML: isHTML, baseURL: baseURL, context: context) + context.variables = name.variables + context.book?.name = name.joinedString ?? "" + let author = await evaluate(rules.author, item: item, isHTML: isHTML, baseURL: baseURL, context: context) + context.variables = author.variables + context.book?.author = author.joinedString ?? "" + 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 + context.book?.bookUrl = bookURL.leadingString ?? baseURL + let coverURL = await evaluate(rules.coverUrl, item: item, isHTML: isHTML, baseURL: baseURL, context: context) + context.variables = coverURL.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 + let values = (name, author, kind, bookURL, coverURL, intro, wordCount, lastChapter) + result.name = values.0.joinedString + result.author = values.1.joinedString + result.kind = values.2.strings.filter { !$0.isEmpty }.joined(separator: ",").nilIfEmpty + result.bookURL = isHTML ? values.3.leadingString : values.3.joinedString + result.coverURL = values.4.joinedString + result.intro = MetadataTextSanitizer().clean(values.5.joinedString) + result.wordCount = values.6.joinedString + result.lastChapter = values.7.joinedString + result.diagnostics = [values.0, values.1, values.2, values.3, values.4, values.5, values.6, values.7] + .flatMap(\.diagnostics) + result.variables = context.variables + return result + } + + private func evaluate( + _ rule: String?, + item: JSONValue, + isHTML: Bool, + baseURL: String, + context: RuleExecutionContext = .init() + ) async -> RuleEvaluation { + guard let rule, !rule.isEmpty else { return .init(variables: context.variables) } + if case .string(let text) = item { + return await engine.evaluate(rule, inText: text, baseURL: baseURL, context: context) + } + return await engine.evaluate(rule, in: item, context: context) + } + + private func resolve(_ value: String, against baseURL: URL) -> String { + LegadoURLBuilder.resolve(value, against: baseURL) + } + + private func failure(_ source: BookSource, code: String, message: String) -> SourceSearchOutcome { + .init( + sourceID: source.id, + books: [], + diagnostics: [.init(severity: .error, code: code, message: message, rule: source.searchUrl ?? "")] + ) + } + + public func merge(outcomes: [SourceSearchOutcome], keyword: String) -> [SearchBook] { + merge(outcomes.flatMap(\.books), keyword: keyword) + } + + private func merge(_ books: [SearchBook], keyword: String) -> [SearchBook] { + var accumulator = BookSearchAccumulator() + accumulator.add(books) + return accumulator.results(keyword: keyword) + } + + public static func mergeIdentity(for book: SearchBook) -> String { + "\(normalizedIdentityText(book.name))\u{0}\(normalizedAuthor(book.author))" + } + + private static func normalizedIdentityText(_ value: String) -> String { + String(value.unicodeScalars.filter { + !CharacterSet.whitespacesAndNewlines.contains($0) + && !CharacterSet.punctuationCharacters.contains($0) + && !CharacterSet.symbols.contains($0) + }).lowercased() + } + + private static func normalizedAuthor(_ value: String) -> String { + var value = normalizedIdentityText(value) + for prefix in ["作者", "author", "著者"] where value.hasPrefix(prefix) { + value.removeFirst(prefix.count) + } + for suffix in ["著", "撰"] where value.hasSuffix(suffix) { + value.removeLast(suffix.count) + } + return value + } + + private struct FieldEvaluation { + 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 diagnostics: [RuleDiagnostic] = [] + var variables: [String: String] = [:] + } +} + +private extension String { + var nilIfEmpty: String? { isEmpty ? nil : self } +} diff --git a/Sources/LegadoCore/TTS/HttpTTSService.swift b/Sources/LegadoCore/TTS/HttpTTSService.swift new file mode 100644 index 0000000..e46130d --- /dev/null +++ b/Sources/LegadoCore/TTS/HttpTTSService.swift @@ -0,0 +1,407 @@ +import Foundation +import CryptoKit + +public enum HttpTTSServiceError: Error, LocalizedError, Sendable { + case emptyText + case invalidContentType(expected: String, actual: String, body: String) + case textResponse(contentType: String, body: String) + + public var errorDescription: String? { + switch self { + case .emptyText: + "朗读文本为空" + case .invalidContentType(let expected, let actual, let body): + "TTS 返回类型不匹配(期望 \(expected),实际 \(actual)):\(body)" + case .textResponse(_, let body): + body + } + } +} + +public struct HttpTTSAudio: Sendable { + public var data: Data + public var contentType: String? + public var finalURL: URL + + public init(data: Data, contentType: String?, finalURL: URL) { + self.data = data + self.contentType = contentType + self.finalURL = finalURL + } +} + +public struct HttpTTSAudioStream: Sendable { + public var contentType: String? + public var finalURL: URL + public var expectedContentLength: Int64? + public var chunks: AsyncThrowingStream + public var isIncremental: Bool + + public init( + contentType: String?, + finalURL: URL, + expectedContentLength: Int64?, + chunks: AsyncThrowingStream, + isIncremental: Bool + ) { + self.contentType = contentType + self.finalURL = finalURL + self.expectedContentLength = expectedContentLength + self.chunks = chunks + self.isIncremental = isIncremental + } +} + +public actor HttpTTSAudioCache { + public static let shared = HttpTTSAudioCache() + private let directory: URL + private let maximumBytes: Int64 + + public init(directory: URL? = nil, maximumBytes: Int64 = 128 * 1_024 * 1_024) { + self.directory = directory ?? FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + .appendingPathComponent("Legado/httpTTS_cache", isDirectory: true) + self.maximumBytes = maximumBytes + } + + public func data(for key: String) -> Data? { + let url = directory.appendingPathComponent(key + ".audio") + guard let data = try? Data(contentsOf: url) else { return nil } + try? FileManager.default.setAttributes([.modificationDate: Date()], ofItemAtPath: url.path) + return data + } + + public func store(_ data: Data, for key: String) { + do { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + try data.write(to: directory.appendingPathComponent(key + ".audio"), options: .atomic) + trimIfNeeded() + } catch { } + } + + public func clear() { try? FileManager.default.removeItem(at: directory) } + + private func trimIfNeeded() { + let keys: Set = [.fileSizeKey, .contentModificationDateKey, .isRegularFileKey] + guard let files = try? FileManager.default.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: Array(keys), + options: [.skipsHiddenFiles] + ) else { return } + var entries: [(URL, Int64, Date)] = files.compactMap { url in + guard let values = try? url.resourceValues(forKeys: keys), values.isRegularFile == true else { return nil } + return (url, Int64(values.fileSize ?? 0), values.contentModificationDate ?? .distantPast) + } + var total = entries.reduce(Int64(0)) { $0 + $1.1 } + guard total > maximumBytes else { return } + entries.sort { $0.2 < $1.2 } + for entry in entries where total > maximumBytes { + try? FileManager.default.removeItem(at: entry.0) + total -= entry.1 + } + } +} + +public actor HttpTTSService { + private let urlBuilder: LegadoURLBuilder + private let networkPolicy: SourceNetworkPolicy + private let transport: any HTTPTransport + private let cache: HttpTTSAudioCache + + public init( + urlBuilder: LegadoURLBuilder = .init(), + networkPolicy: SourceNetworkPolicy = .shared, + transport: any HTTPTransport = URLSessionHTTPTransport(), + cache: HttpTTSAudioCache = .shared + ) { + self.urlBuilder = urlBuilder + self.networkPolicy = networkPolicy + self.transport = transport + self.cache = cache + } + + public func audio(engine: HttpTTS, text: String, speechRate: Int) async throws -> HttpTTSAudio { + let text = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { throw HttpTTSServiceError.emptyText } + let source = engine.bookSourceAdapter + let cacheKey = Self.cacheKey(engine: engine, text: text, speechRate: speechRate) + if let data = await cache.data(for: cacheKey) { + return .init(data: data, contentType: engine.contentType, finalURL: cacheURL(engine: engine)) + } + let context = URLTemplateContext( + key: text, + variables: [ + "speakText": text, + "speakSpeed": String(speechRate) + ], + source: source + ) + var lastError: Error = HttpTTSServiceError.emptyText + for attempt in 0..<6 { + try Task.checkCancellation() + do { + let prepared = try urlBuilder.prepare( + engine.url, + context: context, + defaultHeaders: try BookSourceHeaderResolver().resolve(source) + ) + var response = try await networkPolicy.execute( + prepared, + source: source, + transport: transport, + timeout: 300 + ) + if try evaluateLoginCheckIfNeeded(engine: engine, source: source, response: response) { + let rebuilt = try urlBuilder.prepare( + engine.url, + context: context, + defaultHeaders: try BookSourceHeaderResolver().resolve(source) + ) + response = try await networkPolicy.execute( + rebuilt, + source: source, + transport: transport, + timeout: 300 + ) + } + let contentType = response.headers.first { + $0.key.caseInsensitiveCompare("Content-Type") == .orderedSame + }?.value.components(separatedBy: ";").first?.trimmingCharacters(in: .whitespacesAndNewlines) + try validateContentType(engine: engine, response: response, contentType: contentType) + await cache.store(response.data, for: cacheKey) + return .init(data: response.data, contentType: contentType, finalURL: response.finalURL) + } catch is CancellationError { + throw CancellationError() + } catch HTTPClientError.statusResponse(let response) { + if try evaluateLoginCheckIfNeeded(engine: engine, source: source, response: response) { + let rebuilt = try urlBuilder.prepare( + engine.url, + context: context, + defaultHeaders: try BookSourceHeaderResolver().resolve(source) + ) + let recovered = try await networkPolicy.execute( + rebuilt, + source: source, + transport: transport, + timeout: 300 + ) + let contentType = Self.contentType(from: recovered.headers) + try validateContentType(engine: engine, response: recovered, contentType: contentType) + await cache.store(recovered.data, for: cacheKey) + return .init(data: recovered.data, contentType: contentType, finalURL: recovered.finalURL) + } + lastError = HTTPClientError.statusResponse(response) + if attempt == 5 { throw lastError } + } catch let error as RuleEngineError { + throw error + } catch { + lastError = error + if attempt == 5 { throw error } + } + } + throw lastError + } + + public func audioStream(engine: HttpTTS, text: String, speechRate: Int) async throws -> HttpTTSAudioStream { + let text = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { throw HttpTTSServiceError.emptyText } + let cacheKey = Self.cacheKey(engine: engine, text: text, speechRate: speechRate) + if let data = await cache.data(for: cacheKey) { + return Self.bufferedStream( + data: data, + contentType: engine.contentType, + finalURL: cacheURL(engine: engine) + ) + } + let requiresBufferedValidation = engine.loginCheckJs?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + guard !requiresBufferedValidation, + let streamingTransport = transport as? any StreamingHTTPTransport else { + let value = try await audio(engine: engine, text: text, speechRate: speechRate) + return Self.bufferedStream(data: value.data, contentType: value.contentType, finalURL: value.finalURL) + } + let source = engine.bookSourceAdapter + let context = URLTemplateContext( + key: text, + variables: ["speakText": text, "speakSpeed": String(speechRate)], + source: source + ) + let prepared = try urlBuilder.prepare( + engine.url, + context: context, + defaultHeaders: try BookSourceHeaderResolver().resolve(source) + ) + if prepared.bodyJavaScript != nil || prepared.responseType != nil || prepared.proxy != nil || prepared.useWebView { + let value = try await audio(engine: engine, text: text, speechRate: speechRate) + return Self.bufferedStream(data: value.data, contentType: value.contentType, finalURL: value.finalURL) + } + let response = try await networkPolicy.executeStream( + prepared, + source: source, + transport: streamingTransport, + timeout: 300 + ) + let contentType = Self.contentType(from: response.headers) + try validateStreamContentType(engine: engine, contentType: contentType) + let upstream = response.chunks + let cache = self.cache + let cachedChunks = AsyncThrowingStream { continuation in + let task = Task { + var complete = Data() + if let length = response.expectedContentLength, length > 0, length <= Int64(Int.max) { + complete.reserveCapacity(Int(length)) + } + do { + for try await chunk in upstream { + try Task.checkCancellation() + complete.append(chunk) + continuation.yield(chunk) + } + await cache.store(complete, for: cacheKey) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + return .init( + contentType: contentType, + finalURL: response.finalURL, + expectedContentLength: response.expectedContentLength, + chunks: cachedChunks, + isIncremental: true + ) + } + + private static func cacheKey(engine: HttpTTS, text: String, speechRate: Int) -> String { + let seed = "\(engine.url)-|-\(speechRate)-|-\(text)" + let digest = Insecure.MD5.hash(data: Data(seed.utf8)).map { String(format: "%02x", $0) }.joined() + return String(digest.dropFirst(8).prefix(16)) + } + + private static func bufferedStream(data: Data, contentType: String?, finalURL: URL) -> HttpTTSAudioStream { + .init( + contentType: contentType, + finalURL: finalURL, + expectedContentLength: Int64(data.count), + chunks: AsyncThrowingStream { continuation in + continuation.yield(data) + continuation.finish() + }, + isIncremental: false + ) + } + + private static func contentType(from headers: [String: String]) -> String? { + headers.first { $0.key.caseInsensitiveCompare("Content-Type") == .orderedSame }? + .value.components(separatedBy: ";").first?.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func cacheURL(engine: HttpTTS) -> URL { + URL(string: engine.url.components(separatedBy: ",").first ?? "") + ?? URL(fileURLWithPath: "/httpTTS-cache") + } + + private func evaluateLoginCheckIfNeeded(engine: HttpTTS, source: BookSource, response: HTTPResponse) throws -> Bool { + guard let script = engine.loginCheckJs?.trimmingCharacters(in: .whitespacesAndNewlines), + !script.isEmpty else { return false } + let headers = response.headers.mapValues(JSONValue.string) + let input = JSONValue.object([ + "code": .number(Double(response.statusCode)), + "body": .string(response.decodedText), + "headers": .object(headers), + "url": .string(response.finalURL.absoluteString) + ]) + let wrapped = """ + (function () { + var raw = result; + var rebuildRequest = false; + function header(name) { + var wanted = String(name).toLowerCase(); + for (var key in raw.headers) if (key.toLowerCase() === wanted) return raw.headers[key]; + return null; + } + result = { + code: function () { return raw.code; }, + statusCode: function () { return raw.code; }, + body: function () { return { string: function () { return raw.body; } }; }, + header: header, + headers: function () { return { get: header }; }, + url: function () { return { toString: function () { return raw.url; } }; } + }; + java.getHeaderMap = function () { + return { putAll: function () {}, put: function () {} }; + }; + java.getResponse = function () { rebuildRequest = true; return result; }; + java.initUrl = function () { rebuildRequest = true; return result; }; + java.ruleUrl = raw.url; + (function () { + \(script) + })(); + return { rebuildRequest: rebuildRequest }; + })() + """ + let result = try JavaScriptRuleEvaluator().evaluate( + script: wrapped, + result: input, + executionContext: .init( + variables: ["speakSpeed": "", "speakText": ""], + baseURL: response.finalURL.absoluteString, + source: source + ) + ) + if case .object(let object) = result.value, + case .bool(let requested)? = object["rebuildRequest"] { + return requested + } + return false + } + + private func validateContentType(engine: HttpTTS, response: HTTPResponse, contentType: String?) throws { + guard let contentType, !contentType.isEmpty else { return } + if contentType == "application/json" || contentType.hasPrefix("text/") { + throw HttpTTSServiceError.textResponse(contentType: contentType, body: response.decodedText) + } + guard let expected = engine.contentType?.trimmingCharacters(in: .whitespacesAndNewlines), + !expected.isEmpty else { return } + let expression = try NSRegularExpression(pattern: expected) + let range = NSRange(contentType.startIndex..., in: contentType) + guard expression.firstMatch(in: contentType, range: range) != nil else { + throw HttpTTSServiceError.invalidContentType( + expected: expected, + actual: contentType, + body: response.decodedText + ) + } + } + + private func validateStreamContentType(engine: HttpTTS, contentType: String?) throws { + guard let contentType, !contentType.isEmpty else { return } + if contentType == "application/json" || contentType.hasPrefix("text/") { + throw HttpTTSServiceError.textResponse(contentType: contentType, body: "TTS 返回了文本响应") + } + guard let expected = engine.contentType?.trimmingCharacters(in: .whitespacesAndNewlines), + !expected.isEmpty else { return } + let expression = try NSRegularExpression(pattern: expected) + let range = NSRange(contentType.startIndex..., in: contentType) + guard expression.firstMatch(in: contentType, range: range) != nil else { + throw HttpTTSServiceError.invalidContentType(expected: expected, actual: contentType, body: "") + } + } +} + +public extension HttpTTS { + var sourceKey: String { "httpTts:\(id)" } + + var bookSourceAdapter: BookSource { + var source = BookSource(bookSourceUrl: sourceKey, bookSourceName: name) + source.concurrentRate = concurrentRate + source.loginUrl = loginUrl + source.loginUi = loginUi + source.header = header + source.jsLib = jsLib + source.enabledCookieJar = enabledCookieJar + source.loginCheckJs = loginCheckJs + source.lastUpdateTime = lastUpdateTime + return source + } +} diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..f8d5a86 --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,28 @@ +# 第三方许可 + +小浣熊阅读(项目仓库:)由 Aminark 维护。本项目的新增与修改部分以 GNU GPL v3 提供;第三方材料保留其原版权与适用许可。根目录 [LICENSE](LICENSE) 是完整 GPL v3 文本。 + +## 阅读 Legado + +- 项目:。 +- 许可:GNU General Public License v3,完整文本见 [Legado-LICENSE.txt](App/Resources/Legado-LICENSE.txt)。 +- 材料:部分业务模块、默认 TXT 目录规则 [txtTocRule.json](App/Resources/txtTocRule.json) 和 HTTP TTS 配置 [httpTTS.json](App/Resources/httpTTS.json)。两份配置对应 `app/src/main/assets/defaultData/` 下的同名文件,原作者署名保留在配置中。 +- 版权归 Legado 原作者及其贡献者。Aminark 于 2026-10-13 起修改应用品牌、界面与公开文档;后续修改日期和内容见 [CHANGELOG](CHANGELOG.md) 与 Git 历史。 + +## SwiftSoup + +- 项目:。 +- 用途:HTML 与 CSS 解析。 +- 许可:MIT,完整版权及许可见 [SwiftSoup-LICENSE.txt](App/Resources/SwiftSoup-LICENSE.txt)。 + +## Kanna + +- 项目:。 +- 用途:XPath 解析。 +- 许可:MIT,完整版权及许可见 [Kanna-LICENSE.txt](App/Resources/Kanna-LICENSE.txt)。 + +## 测试数据与演示内容 + +测试中的规则样本仅用于兼容验证,不打包为 App 默认书源。具体说明见 [测试样本说明](Tests/LegadoCoreTests/Fixtures/README.md)。演示模式使用虚构书籍元数据和项目内演示文本;发布截图不使用个人书架、登录信息或付费章节。 + +第三方材料继续遵循其各自许可。GPL 覆盖软件的使用和分发,不提供第三方网站、书籍、封面或服务的内容授权。 diff --git a/Tests/LegadoAppModelsTests/BookDetailRegressionTests.swift b/Tests/LegadoAppModelsTests/BookDetailRegressionTests.swift new file mode 100644 index 0000000..7563f5f --- /dev/null +++ b/Tests/LegadoAppModelsTests/BookDetailRegressionTests.swift @@ -0,0 +1,160 @@ +import Foundation +import Testing +import LegadoCore +@testable import LegadoAppModels + +@MainActor +struct BookDetailRegressionTests { + @Test func restoringShelfSourceWithSameURLKeepsSourceAndMetadataConsistent() async { + let oldSource = BookSource(bookSourceUrl: "https://old-detail.test", bookSourceName: "旧源") + var newSource = BookSource(bookSourceUrl: "https://new-detail.test", bookSourceName: "新源") + var toc = TocRule() + toc.chapterList = "@CSS:li" + toc.chapterName = "@CSS:a@text" + toc.chapterUrl = "@CSS:a@href" + newSource.ruleToc = toc + let search = SearchBook(bookURL: "https://shared.test/book", origin: oldSource.id, originName: oldSource.bookSourceName, + type: 0, name: "书") + let model = BookDetailViewModel(searchBook: search, source: oldSource, + service: NetworkBookService(transport: DetailTransport()), + chapterRepository: InMemoryBookChapterRepository()) + var stored = Book(bookUrl: search.bookURL, name: "书架标题", origin: newSource.id) + stored.originName = oldSource.bookSourceName + stored.tocUrl = newSource.id + "/toc" + stored.durChapterIndex = 1 + stored.durChapterPos = 120 + model.restoreLibraryBook(from: stored, sources: [oldSource, newSource]) + await model.load() + #expect(model.state == .ready) + #expect(model.source.id == newSource.id) + #expect(model.book.origin == newSource.id) + #expect(model.book.originName == newSource.bookSourceName) + #expect(model.book.name == stored.name) + #expect(model.book.durChapterIndex == 1) + #expect(model.book.durChapterPos == 120) + #expect(model.chapters.count == 2) + } + + @Test func refreshAfterSourceSwitchUsesNewBookURLInsteadOfOriginalSearchResult() async { + var oldSource = BookSource(bookSourceUrl: "https://old-detail.test", bookSourceName: "旧源") + var toc = TocRule() + toc.chapterList = "@CSS:li" + toc.chapterName = "@CSS:a@text" + toc.chapterUrl = "@CSS:a@href" + oldSource.ruleToc = toc + var newSource = oldSource + newSource.bookSourceUrl = "https://new-detail.test" + let originalSearch = SearchBook(bookURL: oldSource.id + "/book", origin: oldSource.id, originName: "旧源", type: 0, name: "书") + let model = BookDetailViewModel(searchBook: originalSearch, source: oldSource, + service: NetworkBookService(transport: DetailTransport()), + chapterRepository: InMemoryBookChapterRepository()) + var replacement = Book(bookUrl: newSource.id + "/book", name: "书", origin: newSource.id) + replacement.tocUrl = newSource.id + "/toc" + let chapters = [BookChapter(url: newSource.id + "/1", title: "第一章", bookUrl: replacement.bookUrl, index: 0)] + model.applySourceSwitch(.init(book: replacement, chapters: chapters, exactChapterMatch: true), source: newSource) + await model.refresh() + #expect(model.state == .ready) + #expect(model.book.bookUrl == replacement.bookUrl) + #expect(model.book.origin == newSource.id) + #expect(model.chapters.allSatisfy { $0.bookUrl == replacement.bookUrl }) + } + + @Test func returningFromReaderAdoptsSwitchedShelfSourceBeforeSavingDetail() { + let old = Book(bookUrl: "old-book", name: "书", author: "作者", origin: "old-source") + let oldSource = BookSource(bookSourceUrl: "old-source", bookSourceName: "旧源") + let newSource = BookSource(bookSourceUrl: "new-source", bookSourceName: "新源") + let chapters = [BookChapter(url: "old-chapter", title: "旧章节", bookUrl: old.bookUrl, index: 0)] + let model = BookDetailViewModel(demoBook: old, source: oldSource, chapters: chapters) + var replacement = old + replacement.bookUrl = "new-book" + replacement.origin = newSource.id + replacement.durChapterPos = 42 + model.restoreSourceAfterReading(from: [replacement], sources: [oldSource, newSource]) + #expect(model.book == replacement) + #expect(model.source == newSource) + #expect(model.state == .idle) + #expect(model.chapters.isEmpty) + } + + @Test func refreshingSearchDetailPreservesShelfSettingsAndProgress() async { + var source = BookSource(bookSourceUrl: "https://detail-regression.test", bookSourceName: "测试源") + var info = BookInfoRule() + info.name = "@CSS:h1@text" + info.intro = "@CSS:p@text" + info.tocUrl = "@CSS:a.toc@href" + source.ruleBookInfo = info + var toc = TocRule() + toc.chapterList = "@CSS:li" + toc.chapterName = "@CSS:a@text" + toc.chapterUrl = "@CSS:a@href" + source.ruleToc = toc + let search = SearchBook( + bookURL: source.bookSourceUrl + "/book", origin: source.bookSourceUrl, + originName: source.bookSourceName, type: 0, name: "搜索结果" + ) + var stored = Book(bookUrl: search.bookURL, name: "旧标题", origin: source.bookSourceUrl) + stored.group = 8 + stored.order = 7 + stored.canUpdate = false + stored.customTag = "自定义标签" + stored.customCoverUrl = "https://custom.test/cover" + stored.customIntro = "自定义简介" + var config = BookReadConfig() + config.pageAnim = 3 + config.ttsEngine = "custom-tts" + stored.readConfig = config + stored.syncTime = 900 + stored.durChapterIndex = 1 + stored.durChapterTitle = "第二章" + stored.durChapterPos = 123 + stored.durChapterTime = 500 + stored.totalChapterNum = 2 + stored.lastCheckCount = 1 + let model = BookDetailViewModel( + searchBook: search, source: source, + service: NetworkBookService(transport: DetailTransport()), + chapterRepository: InMemoryBookChapterRepository() + ) + model.restoreLibraryBook(from: stored) + + await model.load() + + #expect(model.state == .ready) + #expect(model.book.name == "最新标题") + #expect(model.book.intro == "最新简介") + #expect(model.book.group == stored.group) + #expect(model.book.order == stored.order) + #expect(model.book.canUpdate == false) + #expect(model.book.customTag == stored.customTag) + #expect(model.book.customCoverUrl == stored.customCoverUrl) + #expect(model.book.customIntro == stored.customIntro) + #expect(model.book.readConfig == stored.readConfig) + #expect(model.book.syncTime == 900) + #expect(model.book.durChapterIndex == 1) + #expect(model.book.durChapterPos == 123) + #expect(model.book.durChapterTime == 500) + #expect(model.book.lastCheckCount == 1) + #expect(model.book.totalChapterNum == 2) + + stored.group = 16 + stored.canUpdate = true + stored.durChapterPos = 321 + stored.durChapterTime = 600 + model.restoreUserSettings(from: stored) + model.restoreReadingProgress(from: stored) + #expect(model.book.group == 16) + #expect(model.book.canUpdate) + #expect(model.book.durChapterPos == 321) + #expect(model.book.totalChapterNum == 2) + } +} + +private struct DetailTransport: HTTPTransport { + func data(for request: URLRequest) async throws -> HTTPResponse { + let url = try #require(request.url) + let html = url.path == "/book" + ? "

最新标题

最新简介

目录" + : "" + return .init(data: Data(html.utf8), statusCode: 200, headers: [:], finalURL: url) + } +} diff --git a/Tests/LegadoAppModelsTests/BookLibraryRegressionTests.swift b/Tests/LegadoAppModelsTests/BookLibraryRegressionTests.swift new file mode 100644 index 0000000..814ba4c --- /dev/null +++ b/Tests/LegadoAppModelsTests/BookLibraryRegressionTests.swift @@ -0,0 +1,138 @@ +import Foundation +import Testing +import LegadoCore +@testable import LegadoAppModels + +@MainActor +struct BookLibraryRegressionTests { + private let source = BookSource(bookSourceUrl: "https://source.test", bookSourceName: "测试源") + + private func fixture() -> (Book, [BookChapter]) { + var book = Book(bookUrl: "https://source.test/book", name: "书", origin: source.bookSourceUrl) + book.totalChapterNum = 2 + book.durChapterTitle = "第一章" + let chapters = (0..<3).map { + BookChapter(url: "https://source.test/\($0)", title: ["第一章", "第二章", "第三章"][$0], bookUrl: book.bookUrl, index: $0) + } + return (book, chapters) + } + + @Test func directoryRefreshPreservesConcurrentReadingAndGroupChanges() async throws { + let (book, chapters) = fixture() + let repository = InMemoryBookRepository(books: [book]) + let loader = DelayedChapterLoader(chapters: chapters) + let model = BookLibraryModel( + repository: repository, + updateCoordinator: .init(repository: InMemoryBookChapterRepository(), loader: loader) + ) + await model.load() + let update = Task { await model.checkForUpdates(sources: [source]) } + await loader.waitForRequest() + await model.updateProgress(for: book, chapter: chapters[1], position: 123) + await model.setBook(book, groupIDs: [8]) + await loader.finish() + await update.value + + let stored = try #require(model.books.first) + #expect(stored.durChapterIndex == 1) + #expect(stored.durChapterPos == 123) + #expect(stored.group == 8) + #expect(stored.totalChapterNum == 3) + #expect(stored.latestChapterTitle == "第三章") + #expect(try await repository.load() == model.books) + } + + @Test func directoryRefreshDoesNotResurrectRemovedBooksOrDropNewBooks() async throws { + let (book, chapters) = fixture() + let repository = InMemoryBookRepository(books: [book]) + let loader = DelayedChapterLoader(chapters: chapters) + let model = BookLibraryModel( + repository: repository, + updateCoordinator: .init(repository: InMemoryBookChapterRepository(), loader: loader), + chapterRepository: InMemoryBookChapterRepository() + ) + await model.load() + let update = Task { await model.checkForUpdates(sources: [source]) } + await loader.waitForRequest() + await model.remove(bookURL: book.bookUrl) + let added = Book(bookUrl: "local://new", name: "新增书") + await model.addOrUpdate(added) + await loader.finish() + await update.value + + #expect(model.books == [added]) + #expect(try await repository.load() == [added]) + } + + @Test func overlappingShelfWritesKeepProgressAndGroupChanges() async throws { + let (book, chapters) = fixture() + let repository = SuspendedBookRepository(books: [book]) + let model = BookLibraryModel(repository: repository) + await model.load() + let progress = Task { await model.updateProgress(for: book, chapter: chapters[1], position: 42) } + await repository.waitForSave() + var groupStarted = false + let group = Task { + groupStarted = true + await model.setBook(book, groupIDs: [4]) + } + while !groupStarted { await Task.yield() } + #expect(model.books.first?.group == 4) + await repository.finishSave() + await progress.value + await group.value + + #expect(model.books.first?.durChapterPos == 42) + #expect(model.books.first?.group == 4) + #expect(try await repository.load() == model.books) + } + + @Test func failedSaveRestoresLastPersistedShelf() async throws { + let (book, _) = fixture() + let repository = FailingBookRepository(books: [book]) + let model = BookLibraryModel(repository: repository) + await model.load() + await model.setBook(book, groupIDs: [4]) + #expect(model.books == [book]) + #expect(model.errorMessage != nil) + #expect(try await repository.load() == [book]) + } +} + +private actor DelayedChapterLoader: ChapterListLoading { + let chapters: [BookChapter] + private var requested = false + private var continuation: CheckedContinuation? + init(chapters: [BookChapter]) { self.chapters = chapters } + func loadChapters(book: Book, source: BookSource, maxPages: Int) async -> ChapterListReport { + requested = true + await withCheckedContinuation { continuation = $0 } + return .init(chapters: chapters) + } + func waitForRequest() async { while !requested { await Task.yield() } } + func finish() { continuation?.resume(); continuation = nil } +} + +private actor SuspendedBookRepository: BookRepository { + private var books: [Book] + private var started = false + private var continuation: CheckedContinuation? + init(books: [Book]) { self.books = books } + func load() -> [Book] { books } + func save(_ books: [Book]) async { + if !started { + started = true + await withCheckedContinuation { continuation = $0 } + } + self.books = books + } + func waitForSave() async { while !started { await Task.yield() } } + func finishSave() { continuation?.resume(); continuation = nil } +} + +private actor FailingBookRepository: BookRepository { + let books: [Book] + init(books: [Book]) { self.books = books } + func load() -> [Book] { books } + func save(_ books: [Book]) throws { throw CocoaError(.fileWriteOutOfSpace) } +} diff --git a/Tests/LegadoAppModelsTests/LocalFileManagementTests.swift b/Tests/LegadoAppModelsTests/LocalFileManagementTests.swift new file mode 100644 index 0000000..3192cc1 --- /dev/null +++ b/Tests/LegadoAppModelsTests/LocalFileManagementTests.swift @@ -0,0 +1,110 @@ +import Foundation +import Testing +import LegadoCore +@testable import LegadoAppModels + +@MainActor +struct LocalFileManagementTests { + private func fixture(fails: Bool = false) async throws -> (URL, Book, BookLibraryModel) { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let file = root.appendingPathComponent("原文件.txt") + try "第一章 开始\n正文".write(to: file, atomically: true, encoding: .utf8) + var book = Book(bookUrl: file.absoluteString, name: "原书名", origin: "local") + book.originName = file.lastPathComponent + book.durChapterIndex = 2 + book.durChapterTitle = "第三章" + book.durChapterPos = 42 + book.group = 8 + let repository: any BookRepository = fails ? FileOperationFailingRepository(books: [book]) : InMemoryBookRepository(books: [book]) + let model = BookLibraryModel(repository: repository, localDirectory: root, chapterRepository: InMemoryBookChapterRepository()) + await model.load() + return (root, book, model) + } + + @Test func renamingFileRetargetsShelfAndPreservesTitleAndReadingState() async throws { + let (root, original, model) = try await fixture() + defer { try? FileManager.default.removeItem(at: root) } + try await model.renameLocalFile(at: URL(string: original.bookUrl)!, to: "新文件") + let book = try #require(model.books.first) + #expect(book.bookUrl == root.appendingPathComponent("新文件.txt").absoluteString) + #expect(book.originName == "新文件.txt") + #expect(book.name == original.name) + #expect(book.durChapterIndex == 2 && book.durChapterPos == 42 && book.group == 8) + #expect(FileManager.default.fileExists(atPath: URL(string: book.bookUrl)!.path)) + #expect(!FileManager.default.fileExists(atPath: URL(string: original.bookUrl)!.path)) + } + + @Test func failedShelfSaveRestoresRenamedFileAndShelf() async throws { + let (root, book, model) = try await fixture(fails: true) + defer { try? FileManager.default.removeItem(at: root) } + await #expect(throws: (any Error).self) { try await model.renameLocalFile(at: URL(string: book.bookUrl)!, to: "新文件") } + #expect(model.books == [book]) + #expect(FileManager.default.fileExists(atPath: URL(string: book.bookUrl)!.path)) + #expect(!FileManager.default.fileExists(atPath: root.appendingPathComponent("新文件.txt").path)) + } + + @Test func failedShelfSaveRestoresDeletedFileBytes() async throws { + let (root, book, model) = try await fixture(fails: true) + defer { try? FileManager.default.removeItem(at: root) } + let file = URL(string: book.bookUrl)! + let data = try Data(contentsOf: file) + await #expect(throws: (any Error).self) { try await model.deleteLocalFile(at: file) } + #expect(model.books == [book]) + #expect(try Data(contentsOf: file) == data) + } + + @Test func deletingFileAlsoRemovesShelfReference() async throws { + let (root, book, model) = try await fixture() + defer { try? FileManager.default.removeItem(at: root) } + let file = URL(string: book.bookUrl)! + try await model.deleteLocalFile(at: file) + #expect(model.books.isEmpty) + #expect(!FileManager.default.fileExists(atPath: file.path)) + #expect(try await model.localFiles.list().isEmpty) + } + + @Test func mixedImportKeepsSuccessfulFilesAndReportsUnsupportedOnes() async throws { + let (root, original, model) = try await fixture() + defer { try? FileManager.default.removeItem(at: root) } + let source = root.appendingPathComponent("原文件.txt") + let unsupported = root.appendingPathComponent("unsupported.pdf") + try Data("pdf".utf8).write(to: unsupported) + await model.importLocalFiles([source, unsupported, source]) + #expect(model.books.count == 3) + #expect(model.books.first == original) + #expect(model.errorMessage?.contains("unsupported.pdf") == true) + #expect(try await model.localFiles.list().count == 3) + } + + @Test func missingFileFromOldContainerCanBeRemovedFromShelf() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let missing = root.appendingPathComponent("old-container/missing.txt") + let book = Book(bookUrl: missing.absoluteString, name: "丢失的书", origin: "local") + let model = BookLibraryModel(repository: InMemoryBookRepository(books: [book]), localDirectory: root.appendingPathComponent("current-container")) + await model.load() + try await model.deleteLocalFile(at: missing) + #expect(model.books.isEmpty) + #expect(!FileManager.default.fileExists(atPath: root.path)) + } + + @Test func existingFileOutsideLibraryCannotBeDeletedThroughShelf() async throws { + let (root, _, _) = try await fixture() + defer { try? FileManager.default.removeItem(at: root) } + let external = root.appendingPathComponent("原文件.txt") + let bytes = try Data(contentsOf: external) + let book = Book(bookUrl: external.absoluteString, name: "外部文件", origin: "local") + let model = BookLibraryModel(repository: InMemoryBookRepository(books: [book]), localDirectory: root.appendingPathComponent("managed")) + await model.load() + await #expect(throws: LocalFileError.outsideLibrary) { try await model.deleteLocalFile(at: external) } + #expect(model.books == [book]) + #expect(try Data(contentsOf: external) == bytes) + } +} + +private actor FileOperationFailingRepository: BookRepository { + let books: [Book] + init(books: [Book]) { self.books = books } + func load() -> [Book] { books } + func save(_ books: [Book]) throws { throw CocoaError(.fileWriteOutOfSpace) } +} diff --git a/Tests/LegadoAppModelsTests/ReaderRegressionTests.swift b/Tests/LegadoAppModelsTests/ReaderRegressionTests.swift new file mode 100644 index 0000000..fe17032 --- /dev/null +++ b/Tests/LegadoAppModelsTests/ReaderRegressionTests.swift @@ -0,0 +1,368 @@ +import Foundation +import Testing +import LegadoCore +@testable import LegadoAppModels + +@MainActor +struct ReaderRegressionTests { + @Test func failedContinuousChapterPreservesReasonAndRetryFetchesFreshBody() async throws { + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let cache = ChapterContentCache(directory: directory) + let loader = RecoveringReaderLoader() + let book = Book(bookUrl: "book", name: "书", origin: "source") + let chapters = (0..<3).map { BookChapter(url: "chapter\($0)", title: "第\($0)章", bookUrl: book.bookUrl, index: $0) } + let model = ReaderViewModel(book: book, chapters: chapters, initialIndex: 0, + source: .init(bookSourceUrl: book.origin, bookSourceName: "源"), service: loader, cache: cache) + await model.load(rules: [], prefetchNextChapter: false) + #expect(model.currentChapterError == "HTTP 403:正文访问失败") + await model.prepareContinuousReading(rules: []) + #expect(model.scrollChapters.allSatisfy { $0.error == "HTTP 403:正文访问失败" }) + model.updateContinuousPosition(chapter: 1) + #expect(model.currentChapterError == "HTTP 403:正文访问失败") + let revision = model.scrollRevision + try await cache.save(.init(bookURL: book.bookUrl, chapterURL: chapters[1].url, content: "旧缓存正文", sourceID: book.origin)) + await loader.recover() + await model.retryContinuousChapter(index: 1, rules: []) + #expect(model.currentChapterError == nil) + #expect(model.content == "恢复正文1") + #expect(model.scrollChapters.first(where: { $0.id == 1 })?.error == nil) + #expect(model.currentIndex == 1) + #expect(model.scrollRevision == revision) + #expect(model.scrollRetries.isEmpty) + } + + @Test func switchedReaderUsesVerifiedBodyWithoutRepeatingTheRequestAndCachesIt() async throws { + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let cache = ChapterContentCache(directory: directory) + let loader = RecoveringReaderLoader() + let book = Book(bookUrl: "book", name: "书", origin: "old") + let chapters = [BookChapter(url: "chapter", title: "第一章", bookUrl: book.bookUrl, index: 0)] + let model = ReaderViewModel(book: book, chapters: chapters, initialIndex: 0, + source: .init(bookSourceUrl: "old", bookSourceName: "旧源"), service: loader, cache: cache) + var replacement = book + replacement.origin = "new" + replacement.durChapterPos = 2 + model.applySourceSwitch(.init(book: replacement, chapters: chapters, exactChapterMatch: true, + verifiedContent: .init(content: "已验证的正文")), + source: .init(bookSourceUrl: "new", bookSourceName: "新源")) + await model.load(rules: [], prefetchNextChapter: false) + #expect(model.content == "已验证的正文") + #expect(model.currentChapterError == nil) + #expect(model.requestedPosition == 2) + #expect(await loader.calls == 0) + let cached = try #require(try await cache.load(bookURL: book.bookUrl, chapterURL: chapters[0].url)) + #expect(cached.content == "已验证的正文") + #expect(cached.sourceID == "new") + } + + @Test func sameURLSourceSwitchFetchesReplacementInsteadOfOldCachedBody() async throws { + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let cache = ChapterContentCache(directory: directory) + let book = Book(bookUrl: "shared-book", name: "书", origin: "old-source") + let chapters = (0..<2).map { BookChapter(url: "shared-\($0)", title: "第\($0)章", bookUrl: book.bookUrl, index: $0) } + let model = ReaderViewModel(book: book, chapters: chapters, initialIndex: 0, + source: .init(bookSourceUrl: "old-source", bookSourceName: "旧源"), + service: SourceAwareReaderLoader(), cache: cache) + await model.load(rules: [], prefetchNextChapter: false) + await model.prepareContinuousReading(rules: []) + #expect(model.content == "old-source正文0") + var replacement = book + replacement.origin = "new-source" + model.applySourceSwitch(.init(book: replacement, chapters: chapters, exactChapterMatch: true), + source: .init(bookSourceUrl: "new-source", bookSourceName: "新源")) + await model.load(rules: [], prefetchNextChapter: false) + #expect(model.content == "new-source正文0") + #expect(!model.loadedFromCache) + await model.prepareContinuousReading(rules: []) + #expect(model.scrollChapters.allSatisfy { $0.content.contains("new-source") }) + try await cache.save(.init(bookURL: book.bookUrl, chapterURL: chapters[1].url, + content: "迟到的旧源缓存", sourceID: "old-source")) + model.select(index: 1) + await model.load(rules: [], prefetchNextChapter: false) + #expect(model.content == "new-source正文1") + #expect(!model.loadedFromCache) + } + + @Test func sourceSwitchReplacesDirectoryBodyAndScrollWindowAtMappedPosition() async throws { + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let oldBook = Book(bookUrl: "old-book", name: "书", origin: "old-source") + let oldChapters = (0..<3).map { BookChapter(url: "old-\($0)", title: "旧\($0)", bookUrl: oldBook.bookUrl, index: $0) } + let model = ReaderViewModel(book: oldBook, chapters: oldChapters, initialIndex: 0, + source: .init(bookSourceUrl: "old-source", bookSourceName: "旧源"), + service: SourceAwareReaderLoader(), cache: .init(directory: directory)) + await model.load(rules: [], prefetchNextChapter: false) + await model.prepareContinuousReading(rules: []) + model.updateContinuousPosition(chapter: 1) + let current = model.bookForSourceSwitch(position: 4) + #expect(current.durChapterIndex == 1) + #expect(current.durChapterTitle == "旧1") + #expect(current.durChapterPos == 4) + let generation = model.chapterLoadGeneration + var newBook = Book(bookUrl: "new-book", name: "书", origin: "new-source") + newBook.durChapterIndex = 2 + newBook.durChapterPos = 4 + let newChapters = (0..<4).map { BookChapter(url: "new-\($0)", title: "新\($0)", bookUrl: newBook.bookUrl, index: $0) } + model.applySourceSwitch(.init(book: newBook, chapters: newChapters, exactChapterMatch: true), + source: .init(bookSourceUrl: "new-source", bookSourceName: "新源")) + #expect(model.book.origin == "new-source") + #expect(model.chapters == newChapters) + #expect(model.currentIndex == 2) + #expect(model.requestedPosition == 4) + #expect(model.content.isEmpty) + #expect(model.scrollChapters.isEmpty) + #expect(model.chapterLoadGeneration > generation) + await model.load(rules: [], prefetchNextChapter: false) + #expect(model.content == "new-source正文2") + await model.prepareContinuousReading(rules: []) + #expect(model.scrollChapters.allSatisfy { $0.content.contains("new-source") }) + } + + @Test func localBookshelfLoadProvidesBookAndDirectory() async throws { + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let file = directory.appending(path: "本地书.txt") + try "第一章 开始\n正文\n第二章 继续\n后续正文".write(to: file, atomically: true, encoding: .utf8) + let imported = try LocalBookService().importTextFile(at: file, into: directory.appending(path: "books")) + let model = BookshelfReadViewModel(updateCoordinator: .init(repository: InMemoryBookChapterRepository())) + + await model.load(book: imported.book, source: nil) + + #expect(!model.chapters.isEmpty) + #expect(model.resolvedBook == imported.book) + #expect(!model.isLoading) + #expect(model.errorMessage == nil) + } + + @Test func selectingSameChapterRestartsLoadAtRequestedPosition() async { + let book = Book(bookUrl: "demo://book", name: "书") + let chapters = [BookChapter(url: "demo://1", title: "第一章", bookUrl: book.bookUrl, index: 0)] + let model = ReaderViewModel(demoBook: book, chapters: chapters, initialIndex: 0, content: "正文第一段\n正文第二段") + let generation = model.chapterLoadGeneration + + model.select(index: 0, position: 6) + #expect(model.currentIndex == 0) + #expect(model.chapterLoadGeneration != generation) + await model.load(rules: []) + + #expect(model.hasLoadedCurrentChapter) + #expect(!model.content.isEmpty) + #expect(model.requestedPosition == 6) + #expect(!model.isLoading) + } + + @Test func previousChapterEndPositionCannotOverflowRenderedOffset() async { + let book = Book(bookUrl: "demo://book", name: "书") + let chapters = (0..<2).map { BookChapter(url: "demo://\($0)", title: "第\($0)章", bookUrl: book.bookUrl, index: $0) } + let model = ReaderViewModel(demoBook: book, chapters: chapters, initialIndex: 1, content: "正文内容") + model.goPrevious(position: Int.max) + await model.load(rules: []) + let prefix = model.title.utf16.count + 1 + let renderedLength = prefix + model.content.utf16.count + + #expect(ReaderPositionResolver.renderedLocation( + position: model.requestedPosition, prefixLength: prefix, renderedLength: renderedLength + ) == renderedLength - 1) + #expect(ReaderPositionResolver.renderedLocation(position: 2, prefixLength: 4, renderedLength: 20) == 6) + #expect(ReaderPositionResolver.renderedLocation(position: Int.max, prefixLength: Int.max, renderedLength: 0) == 0) + } + + @Test func enteringScrollModePreparesContinuationWithoutChangingCurrentChapter() async { + let book = Book(bookUrl: "demo://scroll", name: "书") + let chapters = (0..<3).map { BookChapter(url: "demo://\($0)", title: "第\($0)章", bookUrl: book.bookUrl, index: $0) } + let model = ReaderViewModel(demoBook: book, chapters: chapters, initialIndex: 1, content: "章节正文") + await model.load(rules: []) + #expect(!model.hasContinuationWindow) + + await model.loadAdjacentChapters(rules: []) + + #expect(model.currentIndex == 1) + #expect(model.isPreviousWindowReady) + #expect(model.isContinuationWindowReady) + let generation = model.chapterLoadGeneration + model.promoteContinuation(position: 2) + #expect(model.currentIndex == 2) + #expect(model.chapterLoadGeneration != generation) + #expect(model.requestedPosition == 2) + #expect(!model.content.isEmpty) + } + + @Test func bookmarkPrefersChapterTitleAfterDirectoryReordering() { + let chapters = [ + BookChapter(url: "notice", title: "前言", bookUrl: "book", index: 0), + BookChapter(url: "first", title: "第一章", bookUrl: "book", index: 1), + BookChapter(url: "second", title: "第二章", bookUrl: "book", index: 2) + ] + var bookmark = Bookmark(time: 1, bookName: "书") + bookmark.chapterIndex = 1 + bookmark.chapterName = "第二章" + #expect(bookmark.resolvedChapterIndex(in: chapters) == 2) + bookmark.chapterName = "旧标题" + #expect(bookmark.resolvedChapterIndex(in: chapters) == 1) + bookmark.chapterIndex = 99 + #expect(bookmark.resolvedChapterIndex(in: chapters) == nil) + } + + @Test func continuousReadingExtendsPastThreeChaptersWithoutNavigating() async { + let book = Book(bookUrl: "demo://continuous", name: "书") + let chapters = (0..<20).map { BookChapter(url: "demo://\($0)", title: "第\($0)章", bookUrl: book.bookUrl, index: $0) } + let model = ReaderViewModel(demoBook: book, chapters: chapters, initialIndex: 5, content: "很短的正文👨‍👩‍👧‍👦") + let generation = model.chapterLoadGeneration + await model.prepareContinuousReading(rules: []) + for _ in 0..<4 { await model.extendContinuousReading(direction: 1, rules: []) } + + #expect(model.scrollChapters.map(\.id) == Array(3...15)) + #expect(model.currentIndex == 5) + #expect(model.chapterLoadGeneration == generation) + let revision = model.scrollRevision + #expect(model.updateContinuousPosition(chapter: 10)) + #expect(model.currentIndex == 10) + #expect(model.title == chapters[10].title) + #expect(model.chapterLoadGeneration == generation) + #expect(model.scrollRevision == revision) + #expect(model.scrollChapters.count == 13) + #expect(model.updateContinuousPosition(chapter: 4)) + #expect(model.currentIndex == 4) + } + + @Test func continuousReadingSkipsVolumesAndReloadsRecycledChaptersInOrder() async { + let book = Book(bookUrl: "demo://volumes", name: "书") + var chapters = (0..<16).map { BookChapter(url: "demo://\($0)", title: "第\($0)章", bookUrl: book.bookUrl, index: $0) } + chapters[3].isVolume = true + chapters[7].isVolume = true + let model = ReaderViewModel(demoBook: book, chapters: chapters, initialIndex: 5, content: "正文") + await model.prepareContinuousReading(rules: []) + #expect(model.scrollChapters.map(\.id) == [2, 4, 5, 6, 8]) + model.retainContinuousChapters([5, 6, 8]) + await model.extendContinuousReading(direction: -1, rules: []) + #expect(model.scrollChapters.map(\.id) == [2, 4, 5, 6, 8]) + #expect(Set(model.scrollChapters.map(\.id)).count == model.scrollChapters.count) + } + + @Test func explicitNavigationResetsContinuousSessionAndKeepsRequestedOffset() async { + let book = Book(bookUrl: "demo://jump", name: "书") + let chapters = (0..<10).map { BookChapter(url: "demo://\($0)", title: "第\($0)章", bookUrl: book.bookUrl, index: $0) } + let model = ReaderViewModel(demoBook: book, chapters: chapters, initialIndex: 1, content: "第一段\n第二段") + await model.prepareContinuousReading(rules: []) + let revision = model.scrollRevision + model.select(index: 7, position: 4) + #expect(model.scrollChapters.isEmpty) + #expect(model.scrollRevision != revision) + await model.load(rules: []) + await model.prepareContinuousReading(rules: []) + #expect(model.currentIndex == 7) + #expect(model.requestedPosition == 4) + #expect(model.scrollChapters.map(\.id) == Array(5...9)) + } + + @Test func failedContinuousChapterCanBeRetriedWithoutMovingTheViewport() async throws { + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let file = directory.appending(path: "retry.txt") + try "原始正文".write(to: file, atomically: true, encoding: .utf8) + var book = Book(bookUrl: file.absoluteString, name: "书") + book.charset = "UTF-8" + let chapters = (0..<2).map { BookChapter(url: "local:\($0)", title: "第\($0)章", bookUrl: book.bookUrl, index: $0) } + let model = ReaderViewModel(book: book, chapters: chapters, initialIndex: 0) + await model.load(rules: []) + try FileManager.default.removeItem(at: file) + await model.prepareContinuousReading(rules: []) + #expect(model.scrollChapters.last?.error != nil) + let revision = model.scrollRevision + try "恢复后的正文".write(to: file, atomically: true, encoding: .utf8) + await model.retryContinuousChapter(index: 1, rules: []) + #expect(model.scrollChapters.last?.error == nil) + #expect(model.scrollChapters.last?.content.contains("恢复后的正文") == true) + #expect(model.currentIndex == 0) + #expect(model.scrollRevision == revision) + } + + @Test func genuinelyEmptyChaptersDoNotBecomeFailuresOrArtificialBlankScreens() async throws { + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let file = directory.appending(path: "empty.txt") + try "正文".write(to: file, atomically: true, encoding: .utf8) + var book = Book(bookUrl: file.absoluteString, name: "书") + book.charset = "UTF-8" + var chapters = (0..<2).map { BookChapter(url: "local:\($0)", title: "第\($0)章", bookUrl: book.bookUrl, index: $0) } + chapters[1].start = 0 + chapters[1].end = 0 + let model = ReaderViewModel(book: book, chapters: chapters, initialIndex: 0) + await model.load(rules: []) + await model.prepareContinuousReading(rules: []) + #expect(model.scrollChapters.last?.content == "") + #expect(model.scrollChapters.last?.error == nil) + } + + @Test func aLatePrefetchCannotRestoreAnOldScrollSessionAfterDirectoryNavigation() async throws { + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let cache = ChapterContentCache(directory: directory) + let book = Book(bookUrl: "https://test.invalid/book", name: "书") + let chapters = (0..<6).map { BookChapter(url: "https://test.invalid/\($0)", title: "第\($0)章", bookUrl: book.bookUrl, index: $0) } + try await cache.save(.init(bookURL: book.bookUrl, chapterURL: chapters[0].url, content: "缓存正文")) + let loader = SuspendedReaderChapterLoader() + let model = ReaderViewModel(book: book, chapters: chapters, initialIndex: 0, + source: .init(bookSourceUrl: "https://test.invalid", bookSourceName: "测试源"), + service: loader, cache: cache) + await model.load(rules: []) + let prefetch = Task { await model.prepareContinuousReading(rules: []) } + await loader.waitUntilSuspended() + model.select(index: 4, position: 2) + await model.load(rules: []) + await loader.release() + await prefetch.value + #expect(model.currentIndex == 4) + #expect(model.requestedPosition == 2) + #expect(model.content == "正文4") + #expect(model.scrollChapters.isEmpty) + #expect(!model.isLoadingScrollAhead) + } +} + +private actor RecoveringReaderLoader: ChapterContentLoading { + var calls = 0 + private var recovered = false + func recover() { recovered = true } + func loadContent(book: Book, chapter: BookChapter, source: BookSource, nextChapterURL: String?, maxPages: Int) async -> ChapterContentReport { + calls += 1 + if recovered { return .init(content: "恢复正文\(chapter.index)") } + return .init(diagnostics: [.init(severity: .error, code: "content.request", message: "HTTP 403:正文访问失败", rule: chapter.url)]) + } +} + +private struct SourceAwareReaderLoader: ChapterContentLoading { + func loadContent(book: Book, chapter: BookChapter, source: BookSource, nextChapterURL: String?, maxPages: Int) async -> ChapterContentReport { + .init(title: chapter.title, content: source.id + "正文\(chapter.index)") + } +} + +private actor SuspendedReaderChapterLoader: ChapterContentLoading { + private var pending: CheckedContinuation? + private var started: CheckedContinuation? + + func loadContent(book: Book, chapter: BookChapter, source: BookSource, nextChapterURL: String?, maxPages: Int) async -> ChapterContentReport { + guard chapter.index == 1 else { return .init(title: chapter.title, content: "正文\(chapter.index)") } + return await withCheckedContinuation { continuation in + pending = continuation + started?.resume() + started = nil + } + } + + func waitUntilSuspended() async { + if pending != nil { return } + await withCheckedContinuation { started = $0 } + } + + func release() { + pending?.resume(returning: .init(title: "旧章节", content: "迟到的正文")) + pending = nil + } +} diff --git a/Tests/LegadoAppModelsTests/RuleLibraryRegressionTests.swift b/Tests/LegadoAppModelsTests/RuleLibraryRegressionTests.swift new file mode 100644 index 0000000..c13fb8a --- /dev/null +++ b/Tests/LegadoAppModelsTests/RuleLibraryRegressionTests.swift @@ -0,0 +1,156 @@ +import Foundation +import Testing +import LegadoCore +@testable import LegadoAppModels + +private actor FailingSourceRepository: BookSourceRepository { + func load() -> [BookSource] { [] } + func save(_ sources: [BookSource]) throws { throw CocoaError(.fileWriteNoPermission) } +} +private actor FailingReplaceRepository: ReplaceRuleRepository { + func load() -> [ReplaceRule] { [] } + func save(_ sources: [ReplaceRule]) throws { throw CocoaError(.fileWriteNoPermission) } +} + +private actor ImportFixtureTransport: HTTPTransport { + let documents: [String: String] + private(set) var requests: [URLRequest] = [] + init(documents: [String: String]) { self.documents = documents } + func data(for request: URLRequest) throws -> HTTPResponse { + requests.append(request) + guard let url = request.url, let document = documents[url.absoluteString] else { throw URLError(.fileDoesNotExist) } + return .init(data: Data(document.utf8), statusCode: 200, headers: [:], finalURL: url) + } +} + +@MainActor +struct RuleLibraryRegressionTests { + @Test func largeImportKeepsTheMainActorResponsive() async throws { + let comment = String(repeating: "规则说明", count: 1_024) + let entries = (0..<1_000).map { index in + ["bookSourceUrl": "https://large-\(index).test", "bookSourceName": "书源\(index)", "bookSourceComment": comment] + } + let input = String(decoding: try JSONSerialization.data(withJSONObject: entries), as: UTF8.self) + let model = SourceLibraryModel.demo(sources: []) + var servicedUIWork = false + let heartbeat = Task { @MainActor in servicedUIWork = true } + defer { heartbeat.cancel() } + + await model.previewText(input) + + #expect(servicedUIWork, "Parsing a large catalog must yield the main actor to UI work") + #expect(model.pendingImport?.validCount == entries.count) + #expect(model.sources.isEmpty) + #expect(!model.isWorking) + } + + @Test func failedSourceSaveAndImportKeepDraftAndReview() async throws { + let model = SourceLibraryModel(repository: FailingSourceRepository()) + let source = BookSource(bookSourceUrl: "https://test", bookSourceName: "测试") + #expect(await model.saveSource(source) == false) + #expect(model.sources.isEmpty) + await model.previewText(String(decoding: try JSONEncoder().encode(source), as: UTF8.self)) + #expect(model.pendingImport?.validCount == 1) + await model.commitPendingImport(selectedIDs: [source.id], keepOriginalName: false, keepOriginalGroup: false, keepOriginalEnabled: false, customGroup: nil, addCustomGroup: true) + #expect(model.pendingImport != nil) + #expect(model.sources.isEmpty) + } + + @Test func replacingSourcePreservesOrderAndBothEnabledFlags() async throws { + var old = BookSource(bookSourceUrl: "https://test", bookSourceName: "旧名称") + old.customOrder = 42; old.enabled = false; old.enabledExplore = false + old.bookSourceGroup = "本地分组" + var new = BookSource(bookSourceUrl: old.id, bookSourceName: "新名称") + new.lastUpdateTime = 10 + let model = SourceLibraryModel(repository: InMemoryBookSourceRepository(sources: [old])) + await model.load() + await model.previewText(String(decoding: try JSONEncoder().encode(new), as: UTF8.self)) + await model.commitPendingImport(selectedIDs: [old.id], keepOriginalName: true, keepOriginalGroup: true, keepOriginalEnabled: true, customGroup: nil, addCustomGroup: true) + let saved = try #require(model.sources.first) + #expect(saved.customOrder == 42) + #expect(!saved.enabled && !saved.enabledExplore) + #expect(saved.bookSourceName == "旧名称") + #expect(saved.bookSourceGroup == "本地分组") + } + + @Test func importReviewDeduplicatesIDsAndKeepsValidEntries() async { + let model = SourceLibraryModel.demo(sources: []) + await model.previewText(#"[{"bookSourceUrl":"https://test","bookSourceName":"旧"},{"bookSourceUrl":"https://test","bookSourceName":"新"},{"bookSourceName":"缺地址"},{"bookSourceUrl":"https://bad","bookSourceName":"坏类型","ruleContent":99}]"#) + #expect(model.pendingImport?.validCount == 1) + #expect(model.pendingImport?.invalidCount == 2) + #expect(model.pendingImport?.items.first?.source.bookSourceName == "新") + } + + @Test func changingSourceURLCannotSilentlyDeleteAnotherSource() async { + let first = BookSource(bookSourceUrl: "https://first", bookSourceName: "一") + let second = BookSource(bookSourceUrl: "https://second", bookSourceName: "二") + let model = SourceLibraryModel(repository: InMemoryBookSourceRepository(sources: [first, second])) + await model.load() + var edited = first + edited.bookSourceUrl = second.id + #expect(await model.saveSource(edited, replacing: first.id) == false) + #expect(model.sources == [first, second]) + } + + @Test func remoteManifestFeedsPreviewAndHonorsWithoutUserAgent() async throws { + let transport = ImportFixtureTransport(documents: [ + "https://import.test/manifest": #"{"sourceUrls":["https://import.test/a#requestWithoutUA","https://import.test/b"]}"#, + "https://import.test/a": #"{"bookSourceUrl":"https://a.test","bookSourceName":"A"}"#, + "https://import.test/b": #"[{"bookSourceUrl":"https://b.test","bookSourceName":"B"},{"bookSourceName":"损坏"}]"# + ]) + let model = SourceLibraryModel(repository: InMemoryBookSourceRepository(), importTransport: transport) + await model.previewRemoteURL("https://import.test/manifest") + #expect(model.pendingImport?.validCount == 2) + #expect(model.pendingImport?.invalidCount == 1) + let requests = await transport.requests + #expect(requests.count == 3) + #expect(requests[1].value(forHTTPHeaderField: "User-Agent") == "null") + #expect(model.sources.isEmpty) + } + + @Test func cyclicImportManifestReportsErrorWithoutMutation() async { + let transport = ImportFixtureTransport(documents: ["https://import.test/cycle": #"{"sourceUrls":["https://import.test/cycle"]}"#]) + let model = SourceLibraryModel(repository: InMemoryBookSourceRepository(), importTransport: transport) + await model.previewRemoteURL("https://import.test/cycle") + #expect(model.pendingImport == nil) + #expect(model.sources.isEmpty) + #expect(model.notice?.detail.contains("循环") == true) + #expect(await transport.requests.count == 1) + } + + @Test func directSubscriptionImportUsesSameManifestResolution() async { + let transport = ImportFixtureTransport(documents: [ + "https://import.test/list": #"{"sourceUrls":["https://import.test/one"]}"#, + "https://import.test/one": #"{"bookSourceUrl":"https://one.test","bookSourceName":"订阅"}"# + ]) + let model = SourceLibraryModel(repository: InMemoryBookSourceRepository(), importTransport: transport) + await model.importRemoteURL("https://import.test/list") + #expect(model.sources.first?.bookSourceName == "订阅") + #expect(model.pendingImport == nil) + } + + @Test func purificationSaveValidatesAndFailureLeavesLibraryUntouched() async { + let model = ReplaceRuleLibraryModel(repository: FailingReplaceRepository()) + var rule = model.newRule() + #expect(await model.save(rule) == false) + #expect(model.rules.isEmpty) + rule.pattern = "广告" + #expect(await model.save(rule) == false) + #expect(model.rules.isEmpty) + } + + @Test func purificationCopyGetsDistinctIDAndMovePersistsExecutionOrder() async { + let repository = InMemoryReplaceRuleRepository() + let model = ReplaceRuleLibraryModel(repository: repository) + var rule = model.newRule(); rule.name = "清理"; rule.pattern = "广告" + #expect(await model.save(rule)) + let copy = model.newRule(copying: rule) + #expect(copy.id != rule.id) + #expect(copy.name == "清理 副本") + #expect(await model.save(copy)) + await model.move(from: [1], to: 0) + #expect(model.rules.map(\.id) == [copy.id, rule.id]) + #expect(model.rules.map(\.sortOrder) == [0, 1]) + #expect(await repository.load() == model.rules) + } +} diff --git a/Tests/LegadoAppModelsTests/SearchRegressionTests.swift b/Tests/LegadoAppModelsTests/SearchRegressionTests.swift new file mode 100644 index 0000000..7a03076 --- /dev/null +++ b/Tests/LegadoAppModelsTests/SearchRegressionTests.swift @@ -0,0 +1,129 @@ +import Foundation +import Testing +import LegadoCore +@testable import LegadoAppModels + +@MainActor +struct SearchRegressionTests { + @Test func cachedResultsAppearBeforeNetworkAndFreshResultsReplaceTheirMetadata() async throws { + let source = source("slow") + let cached = SearchBook(bookURL: source.id + "/cached", origin: source.id, originName: "源", type: 0, name: "书", author: "作者") + let transport = GatedModelSearchTransport() + let model = model(transport: transport, repository: InMemorySearchBookRepository(values: [cached])) + defer { model.cancel(reset: true) } + model.search(keyword: "书", sources: [source]) + try await waitUntil { model.isShowingCachedResults } + #expect(model.books.map(\.bookURL) == [cached.bookURL]) + #expect(model.state == .searching) + #expect(model.completedSourceCount == 0) + await transport.release() + try await waitUntil { model.state == .finished } + #expect(model.books.first?.bookURL == source.id + "/fresh") + #expect(!model.isShowingCachedResults) + } + + @Test func pausedSearchPersistsEarlyResultsAndResumesOnlyUnfinishedSourcesAtSamePage() async throws { + let transport = GatedModelSearchTransport(fastHost: "fast.example") + let repository = InMemorySearchBookRepository() + let model = model(transport: transport, repository: repository) + defer { model.cancel(reset: true) } + let fast = source("fast"), slow = source("slow") + model.search(keyword: "书", sources: [fast, slow]) + try await waitUntil("first source result") { !model.books.isEmpty } + model.cancel() + try await waitUntil("paused result persistence") { try await repository.cachedMatches(keyword: "书").count == 1 } + #expect(model.isPaused) + #expect(model.completedSourceCount == 1) + model.resume() + await transport.release() + try await waitUntil("resume completion") { model.state == .finished && !model.isPaused } + let requests = await transport.requests() + #expect(requests.filter { $0.url?.host == "fast.example" }.count == 1) + #expect(requests.allSatisfy { URLComponents(url: $0.url!, resolvingAgainstBaseURL: false)?.queryItems?.first(where: { $0.name == "page" })?.value == "1" }) + #expect(model.completedSourceCount == 2) + #expect(model.books.first?.origins == [fast.id, slow.id]) + model.loadMore() + try await waitUntil("second page completion") { !model.isLoadingMore } + #expect(!model.hasMore) + } + + @Test func disabledOutOfScopeExpiredAndEditedSourceCachesAreNotDisplayed() async throws { + var active = source("slow") + let other = source("other") + var expired = SearchBook(bookURL: active.id + "/expired", origin: active.id, originName: "源", type: 0, name: "书") + expired.time = 0 + let outOfScope = SearchBook(bookURL: other.id + "/cached", origin: other.id, originName: "源", type: 0, name: "书") + let edited = SearchBook(bookURL: active.id + "/edited", origin: active.id, originName: "源", type: 0, name: "书") + active.lastUpdateTime = edited.time + 1 + let transport = GatedModelSearchTransport() + let model = model(transport: transport, repository: InMemorySearchBookRepository(values: [expired, outOfScope, edited])) + defer { model.cancel(reset: true) } + var started = transport.started.makeAsyncIterator() + model.search(keyword: "书", sources: [active]) + _ = await started.next() + #expect(model.books.isEmpty) + #expect(!model.isShowingCachedResults) + active.enabled = false + model.search(keyword: "书", sources: [active]) + #expect(model.state == .unavailable) + #expect(model.books.isEmpty) + } + + private func source(_ name: String) -> BookSource { + var value = BookSource(bookSourceUrl: "https://\(name).example", bookSourceName: name) + value.searchUrl = "/search?q={{key}}&page={{page}}" + value.ruleSearch = .init(bookList: "$.books", name: "$.name", author: "$.author", bookUrl: "$.url") + return value + } + + private func model(transport: GatedModelSearchTransport, repository: any SearchBookRepository) -> SearchViewModel { + .init(service: BookSearchService(transport: transport, history: .init()), + keywordRepository: InMemorySearchKeywordRepository(), searchBookRepository: repository) + } + + private func waitUntil(_ reason: String = "Search state did not settle", _ condition: @MainActor () async throws -> Bool) async throws { + let deadline = ContinuousClock.now + .seconds(3) + while try await !condition() { + try #require(ContinuousClock.now < deadline, Comment(rawValue: reason)) + try await Task.sleep(for: .milliseconds(5)) + } + } +} + +private actor GatedModelSearchTransport: HTTPTransport { + nonisolated let started: AsyncStream + private let continuation: AsyncStream.Continuation + private let fastHost: String? + private var released = false + private var pending: [UUID: (URLRequest, CheckedContinuation)] = [:] + private var recorded: [URLRequest] = [] + init(fastHost: String? = nil) { + self.fastHost = fastHost + (started, continuation) = AsyncStream.makeStream(of: Void.self) + } + func data(for request: URLRequest) async throws -> HTTPResponse { + recorded.append(request) + if released || request.url?.host == fastHost { return response(request) } + let id = UUID() + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { value in + pending[id] = (request, value) + continuation.yield(()) + } + } onCancel: { Task { await self.cancel(id) } } + } + private func cancel(_ id: UUID) { + pending.removeValue(forKey: id)?.1.resume(throwing: CancellationError()) + } + func release() { + released = true + let values = pending.values + pending = [:] + for (request, continuation) in values { continuation.resume(returning: response(request)) } + } + func requests() -> [URLRequest] { recorded } + private func response(_ request: URLRequest) -> HTTPResponse { + .init(data: Data(#"{"books":[{"name":"书","author":"作者","url":"/fresh"}]}"#.utf8), + statusCode: 200, headers: [:], finalURL: request.url!) + } +} diff --git a/Tests/LegadoCoreTests/AddBookURLServiceTests.swift b/Tests/LegadoCoreTests/AddBookURLServiceTests.swift new file mode 100644 index 0000000..2eca787 --- /dev/null +++ b/Tests/LegadoCoreTests/AddBookURLServiceTests.swift @@ -0,0 +1,37 @@ +import Foundation +import Testing +@testable import LegadoCore + +@Suite struct AddBookURLServiceTests { + @Test func explicitOriginHasPriority() { + var exact = BookSource(bookSourceUrl: "https://site.example", bookSourceName: "域名源") + exact.customOrder = 0 + var explicit = BookSource(bookSourceUrl: "https://chosen.example", bookSourceName: "指定源") + explicit.customOrder = 9 + let matched = AddBookURLService.matchingSource( + bookURL: "https://site.example/book/1,{\"origin\":\"https://chosen.example\"}", + sources: [exact, explicit] + ) + #expect(matched?.bookSourceUrl == explicit.bookSourceUrl) + } + + @Test func exactBasePrecedesPattern() { + var pattern = BookSource(bookSourceUrl: "https://fallback.example", bookSourceName: "正则源") + pattern.bookUrlPattern = #"https://site\.example/book/.*"# + pattern.customOrder = 0 + var exact = BookSource(bookSourceUrl: "https://site.example", bookSourceName: "域名源") + exact.customOrder = 10 + let matched = AddBookURLService.matchingSource(bookURL: "https://site.example/book/1", sources: [pattern, exact]) + #expect(matched?.bookSourceUrl == exact.bookSourceUrl) + } + + @Test func patternRequiresWholeURLMatchAndEnabledSource() { + var disabled = BookSource(bookSourceUrl: "https://disabled.example", bookSourceName: "停用") + disabled.bookUrlPattern = #"https://site\.example/book/.*"#; disabled.enabled = false + var enabled = BookSource(bookSourceUrl: "https://enabled.example", bookSourceName: "启用") + enabled.bookUrlPattern = #"https://site\.example/book/\d+"# + let matched = AddBookURLService.matchingSource(bookURL: "https://site.example/book/12", sources: [disabled, enabled]) + #expect(matched?.bookSourceUrl == enabled.bookSourceUrl) + #expect(AddBookURLService.matchingSource(bookURL: "https://site.example/book/12/extra", sources: [enabled]) == nil) + } +} diff --git a/Tests/LegadoCoreTests/ApplicationSettingsTests.swift b/Tests/LegadoCoreTests/ApplicationSettingsTests.swift new file mode 100644 index 0000000..30c3c34 --- /dev/null +++ b/Tests/LegadoCoreTests/ApplicationSettingsTests.swift @@ -0,0 +1,104 @@ +import Foundation +import Testing +@testable import LegadoCore + +struct ApplicationSettingsTests { + @Test func validatesAndPersistsWithoutOverwritingOnFailure() throws { + let suite = "legado.settings.tests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let store = ApplicationSettingsStore(defaults: defaults) + #expect(store.settings == ApplicationSettings()) + var value = ApplicationSettings() + value.userAgent = " Test Browser " + value.timeoutSeconds = 60 + value.concurrentSources = 3 + value.hosts = "127.0.0.1 books.example.com" + try store.save(value) + #expect(store.settings.userAgent == "Test Browser") + #expect(store.settings.timeoutSeconds == 60) + value.userAgent = "invalid\r\nHeader: injected" + #expect(throws: SettingsError.self) { try store.save(value) } + #expect(store.settings.userAgent == "Test Browser") + } + + @Test func parsesIPv4IPv6CommentsAndMultipleDomains() throws { + let hosts = try ApplicationSettings.parseHosts(""" + # local mirror + 127.0.0.1 Books.Example.com mirror.example.com # comment + ::1 ipv6.example.com + 192.168.0.2 books.example.com + """) + #expect(hosts["books.example.com"] == "192.168.0.2") + #expect(hosts["mirror.example.com"] == "127.0.0.1") + #expect(hosts["ipv6.example.com"] == "::1") + for invalid in ["no-ip example.com", "256.0.0.1 example.com", "127.0.0.1 https://example.com", "127.0.0.1 bad..example", "127.0.0.1 -bad.com"] { + #expect(throws: SettingsError.self) { _ = try ApplicationSettings.parseHosts(invalid) } + } + } + + @Test func proxyAndRequestPrecedence() throws { + var settings = ApplicationSettings() + settings.proxy = "socks5://127.0.0.1:1089" + settings.userAgent = "Global UA" + settings.timeoutSeconds = 45 + let config = URLSessionConfiguration.ephemeral + try settings.configure(config) + #expect(config.connectionProxyDictionary?["SOCKSPort"] as? Int == 1089) + try settings.configure(config, proxyOverride: "http://localhost:8000") + #expect(config.connectionProxyDictionary?["HTTPPort"] as? Int == 8000) + var request = URLRequest(url: URL(string: "https://example.com")!, timeoutInterval: 5) + #expect(settings.configure(request).value(forHTTPHeaderField: "User-Agent") == "Global UA") + request.setValue("Source UA", forHTTPHeaderField: "user-agent") + #expect(settings.configure(request).value(forHTTPHeaderField: "User-Agent") == "Source UA") + #expect(settings.configure(request).timeoutInterval == 45) + request.setValue("", forHTTPHeaderField: "User-Agent") + #expect(settings.configure(request).value(forHTTPHeaderField: "User-Agent") == "") + for invalid in ["ftp://localhost:3", "http://localhost:0", "http://localhost:65536", "http://localhost/path", "http://user:pass@localhost:3"] { + #expect(throws: SettingsError.self) { _ = try ApplicationSettings.proxyDictionary(invalid) } + } + } + + @Test func existingTransportUsesSavedSettingsOnNextRequest() async throws { + let suite = "legado.transport.tests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let store = ApplicationSettingsStore(defaults: defaults) + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [SettingsEchoURLProtocol.self] + let transport = URLSessionHTTPTransport(configuration: config, settingsStore: store) + let request = URLRequest(url: URL(string: "https://settings.example/test")!) + var settings = ApplicationSettings() + settings.userAgent = "First UA" + try store.save(settings) + #expect(try await transport.data(for: request).decodedText == "First UA") + settings.userAgent = "Second UA" + settings.timeoutSeconds = 15 + try store.save(settings) + #expect(try await transport.data(for: request).decodedText == "Second UA") + var explicit = request + explicit.setValue("Source UA", forHTTPHeaderField: "User-Agent") + #expect(try await transport.data(for: explicit).decodedText == "Source UA") + } + + @Test func suppressedSourceUAStaysSuppressedInURLRequest() throws { + let prepared = try LegadoURLBuilder().prepare(#"https://example.com,{headers:{'User-Agent':'null'}}"#) + #expect(prepared.headers["User-Agent"] == nil) + #expect(prepared.urlRequest().value(forHTTPHeaderField: "User-Agent") == nil) + var settings = ApplicationSettings() + settings.userAgent = "Global UA" + #expect(settings.configure(prepared.urlRequest()).value(forHTTPHeaderField: "User-Agent") == nil) + } +} + +private final class SettingsEchoURLProtocol: URLProtocol, @unchecked Sendable { + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + override func startLoading() { + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: Data((request.value(forHTTPHeaderField: "User-Agent") ?? "").utf8)) + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} +} diff --git a/Tests/LegadoCoreTests/BookChapterRepositoryTests.swift b/Tests/LegadoCoreTests/BookChapterRepositoryTests.swift new file mode 100644 index 0000000..6e6d360 --- /dev/null +++ b/Tests/LegadoCoreTests/BookChapterRepositoryTests.swift @@ -0,0 +1,34 @@ +import Foundation +import Testing +@testable import LegadoCore + +struct BookChapterRepositoryTests { + @Test func normalizesAndPersistsChaptersPerBook() async throws { + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: directory) } + let repository = FileBookChapterRepository(directory: directory) + var chapter = BookChapter(url: "1", title: "第一章", bookUrl: "wrong", index: 9) + chapter.wordCount = "1000" + + try await repository.save([chapter], bookURL: "book-a") + let restored = try await repository.load(bookURL: "book-a") + + #expect(restored.first?.bookUrl == "book-a") + #expect(restored.first?.index == 0) + #expect(restored.first?.wordCount == "1000") + #expect(try await repository.load(bookURL: "book-b").isEmpty) + } + + @Test func removesOnlyRequestedBook() async throws { + let repository = InMemoryBookChapterRepository() + let a = BookChapter(url: "1", title: "A", bookUrl: "a", index: 0) + let b = BookChapter(url: "1", title: "B", bookUrl: "b", index: 0) + await repository.save([a], bookURL: "a") + await repository.save([b], bookURL: "b") + + await repository.remove(bookURL: "a") + + #expect(await repository.load(bookURL: "a").isEmpty) + #expect(await repository.load(bookURL: "b") == [b]) + } +} diff --git a/Tests/LegadoCoreTests/BookExploreServiceTests.swift b/Tests/LegadoCoreTests/BookExploreServiceTests.swift new file mode 100644 index 0000000..1652cf0 --- /dev/null +++ b/Tests/LegadoCoreTests/BookExploreServiceTests.swift @@ -0,0 +1,99 @@ +import Foundation +import Testing +@testable import LegadoCore + +private struct ExploreMockTransport: HTTPTransport { + let pages: [String: String] + + func data(for request: URLRequest) async throws -> HTTPResponse { + let url = try #require(request.url) + let body = try #require(pages[url.absoluteString]) + return .init(data: Data(body.utf8), statusCode: 200, headers: [:], finalURL: url) + } +} + +struct BookExploreServiceTests { + @Test func parsesLegacyAndJavaScriptExploreKinds() async { + var legacy = BookSource(bookSourceUrl: "https://example.test", bookSourceName: "传统发现") + legacy.exploreUrl = "热门::/hot?page={{page}}&&完结::/finished?page={{page}}\n新书::/new" + let service = BookExploreService() + + let legacyReport = await service.kinds(for: legacy) + + #expect(legacyReport.kinds.map(\.title) == ["热门", "完结", "新书"]) + #expect(legacyReport.kinds[0].url == "/hot?page={{page}}") + #expect(legacyReport.diagnostics.isEmpty) + + var scripted = legacy + scripted.exploreUrl = "@js:JSON.stringify([{title:'科幻',url:'/sci?page={{page}}'},{title:'文学',url:'/lit'}])" + let scriptedReport = await service.kinds(for: scripted) + + #expect(scriptedReport.kinds.map(\.title) == ["科幻", "文学"]) + #expect(scriptedReport.diagnostics.isEmpty) + } + + @Test func distinguishesExploreDestinationsFromDynamicControlsAndPlaceholders() async { + var source = BookSource(bookSourceUrl: "https://example.test", bookSourceName: "动态发现") + source.exploreUrl = #"[{"title":"关键词","type":"text"},{"title":"刷新","type":"button","action":"refresh()"},{"title":"占位"},{"title":"热门","url":" /hot?page={{page}} "}]"# + + let report = await BookExploreService().kinds(for: source) + + #expect(report.kinds.count == 4) + #expect(report.kinds[0].navigableURL == nil) + #expect(report.kinds[1].navigableURL == nil) + #expect(report.kinds[2].navigableURL == nil) + #expect(report.kinds[3].navigableURL == "/hot?page={{page}}") + #expect(report.diagnostics.isEmpty) + } + + @Test func toleratesGsonStyleScalarValuesInExploreJSON() async { + var source = BookSource(bookSourceUrl: "https://example.test", bookSourceName: "宽松分类") + source.exploreUrl = #"[{"title":2026,"url":123,"type":true,"action":7,"chars":["全部",2,false,null],"default":0}]"# + + let report = await BookExploreService().kinds(for: source) + + #expect(report.kinds.count == 1) + #expect(report.kinds[0].title == "2026") + #expect(report.kinds[0].url == "123") + #expect(report.kinds[0].type == "true") + #expect(report.kinds[0].action == "7") + #expect(report.kinds[0].chars == ["全部", "2", "false", nil]) + #expect(report.kinds[0].defaultValue == "0") + #expect(report.diagnostics.isEmpty) + } + + @Test func toleratesLegacyJSON5ExploreSyntaxAcceptedByGson() async { + var source = BookSource(bookSourceUrl: "https://example.test", bookSourceName: "旧式分类") + source.exploreUrl = "[{title:'热门', url:'/hot',}, // old source comment\n]" + + let report = await BookExploreService().kinds(for: source) + + #expect(report.kinds.map(\.title) == ["热门"]) + #expect(report.kinds.first?.url == "/hot") + #expect(report.diagnostics.isEmpty) + } + + @Test func loadsPaginatedJSONExploreBooksAndFallsBackToSearchRules() async throws { + let transport = ExploreMockTransport(pages: [ + "https://example.test/hot?page=2": #"{"data":[{"id":2,"name":"第二页新书","author":"作者乙","cover":"/2.jpg"}]}"# + ]) + var source = BookSource(bookSourceUrl: "https://example.test", bookSourceName: "发现源") + source.exploreUrl = "热门::/hot?page={{page}}" + source.ruleSearch = SearchRule( + bookList: "$.data", + name: "$.name", + author: "$.author", + bookUrl: "$.id@js:'/book/' + result", + coverUrl: "$.cover" + ) + let kind = ExploreKind(title: "热门", url: "/hot?page={{page}}") + + let outcome = await BookExploreService(transport: transport).explore(source: source, kind: kind, page: 2) + + let book = try #require(outcome.books.first) + #expect(book.name == "第二页新书") + #expect(book.bookURL == "https://example.test/book/2") + #expect(book.coverURL == "https://example.test/2.jpg") + #expect(outcome.diagnostics.isEmpty) + } +} diff --git a/Tests/LegadoCoreTests/BookRepositoryTests.swift b/Tests/LegadoCoreTests/BookRepositoryTests.swift new file mode 100644 index 0000000..f19c93b --- /dev/null +++ b/Tests/LegadoCoreTests/BookRepositoryTests.swift @@ -0,0 +1,21 @@ +import Foundation +import Testing +@testable import LegadoCore + +struct BookRepositoryTests { + @Test func persistsBooksAtomically() async throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString, directoryHint: .isDirectory) + let fileURL = directory.appending(path: "books.json") + defer { try? FileManager.default.removeItem(at: directory) } + let repository = FileBookRepository(fileURL: fileURL) + var book = Book(bookUrl: "https://example.test/book/1", name: "测试书", author: "作者", origin: "https://example.test") + book.durChapterIndex = 3 + + try await repository.save([book]) + let restored = try await repository.load() + + #expect(restored == [book]) + #expect(restored.first?.durChapterIndex == 3) + } +} diff --git a/Tests/LegadoCoreTests/BookSearchServiceTests.swift b/Tests/LegadoCoreTests/BookSearchServiceTests.swift new file mode 100644 index 0000000..81da3b4 --- /dev/null +++ b/Tests/LegadoCoreTests/BookSearchServiceTests.swift @@ -0,0 +1,286 @@ +import Foundation +import Testing +@testable import LegadoCore + +private struct SearchMockTransport: HTTPTransport { + let response: HTTPResponse + var expectedQueryFragment: String? + + func data(for request: URLRequest) async throws -> HTTPResponse { + if let expectedQueryFragment { + #expect(request.url?.absoluteString.contains(expectedQueryFragment) == true) + } + return response + } +} + +private struct DelayedSearchTransport: HTTPTransport { + func data(for request: URLRequest) async throws -> HTTPResponse { + let isSlow = request.url?.host == "slow.example.test" + try await Task.sleep(for: isSlow ? .milliseconds(200) : .milliseconds(10)) + let name = isSlow ? "慢源结果" : "快源结果" + let data = Data(#"{"books":[{"name":"\#(name)","author":"作者","url":"/book/1"}]}"#.utf8) + return .init(data: data, statusCode: 200, headers: [:], finalURL: request.url!) + } +} + +struct BookSearchServiceTests { + @Test func streamsFastSourceWithoutWaitingForSlowSource() async throws { + func makeSource(host: String, name: String) -> BookSource { + var source = BookSource(bookSourceUrl: "https://\(host)", bookSourceName: name) + source.searchUrl = "https://\(host)/search?q={{key}}" + source.ruleSearch = SearchRule(bookList: "$.books[*]", name: "name", author: "author", bookUrl: "url") + return source + } + let slow = makeSource(host: "slow.example.test", name: "慢源") + let fast = makeSource(host: "fast.example.test", name: "快源") + let stream = BookSearchService(transport: DelayedSearchTransport()) + .searchOutcomes(keyword: "结果", sources: [slow, fast]) + var iterator = stream.makeAsyncIterator() + + let first = try #require(await iterator.next()) + + #expect(first.sourceID == fast.id) + #expect(first.books.first?.name == "快源结果") + } + + @Test func ordersMergedResultsBySearchRelevance() async throws { + let finalURL = try #require(URL(string: "https://api.example.test/search")) + let data = Data(#"{"books":[{"name":"其他","author":"甲","kind":"普通","url":"/4"},{"name":"关键词扩展","author":"乙","kind":"普通","url":"/3"},{"name":"分类书","author":"丙","kind":"关键词","url":"/2"},{"name":"关键词","author":"丁","kind":"普通","url":"/1"}]}"#.utf8) + let transport = SearchMockTransport( + response: .init(data: data, statusCode: 200, headers: [:], finalURL: finalURL), + expectedQueryFragment: nil + ) + var source = BookSource(bookSourceUrl: "https://api.example.test", bookSourceName: "排序源") + source.searchUrl = "https://api.example.test/search?q={{key}}" + source.ruleSearch = SearchRule(bookList: "$.books[*]", name: "name", author: "author", kind: "kind", bookUrl: "url") + + let report = await BookSearchService(transport: transport).search(keyword: "关键词", sources: [source]) + + #expect(report.books.map(\.name) == ["关键词", "分类书", "关键词扩展", "其他"]) + } + + @Test func searchesJSONSourceAndResolvesRelativeURLs() async throws { + let finalURL = try #require(URL(string: "https://api.example.test/search?q=swift")) + let data = Data( + """ + {"data":{"books":[ + {"name":"Swift 入门","author":"A","url":"/book/1","cover":"/cover/1.jpg","tags":["编程","Swift"]}, + {"name":"Swift 进阶","author":"B","url":"/book/2","cover":"/cover/2.jpg","tags":["编程"]} + ]}} + """.utf8 + ) + let transport = SearchMockTransport(response: .init( + data: data, + statusCode: 200, + headers: [:], + finalURL: finalURL + ), expectedQueryFragment: "q=swift") + var source = BookSource(bookSourceUrl: "https://api.example.test", bookSourceName: "JSON 源") + source.searchUrl = "/search?q={{key}}&page=<1,2,3>" + source.ruleSearch = SearchRule( + bookList: "$.data.books[*]", + name: "name", + author: "author", + kind: "tags[*]", + bookUrl: "url", + coverUrl: "cover" + ) + let service = BookSearchService(transport: transport) + + let report = await service.search(keyword: "swift", sources: [source]) + + #expect(report.books.count == 2) + #expect(report.books.map(\.name).sorted() == ["Swift 入门", "Swift 进阶"]) + #expect(report.books.allSatisfy { $0.bookURL.hasPrefix("https://api.example.test/book/") }) + #expect(report.outcomes[0].diagnostics.isEmpty) + } + + @Test func mergesSameBookAcrossSources() async throws { + let finalURL = try #require(URL(string: "https://api.example.test/search")) + let data = Data(#"{"books":[{"name":"同一本书","author":"作者","url":"/book/1"}]}"#.utf8) + let transport = SearchMockTransport( + response: .init(data: data, statusCode: 200, headers: [:], finalURL: finalURL), + expectedQueryFragment: nil + ) + func makeSource(_ id: String) -> BookSource { + var source = BookSource(bookSourceUrl: id, bookSourceName: id) + source.searchUrl = "https://api.example.test/search?q={{key}}" + source.ruleSearch = SearchRule(bookList: "$.books[*]", name: "name", author: "author", bookUrl: "url") + return source + } + let service = BookSearchService(transport: transport) + + let report = await service.search(keyword: "同一本书", sources: [makeSource("https://one.test"), makeSource("https://two.test")]) + + #expect(report.books.count == 1) + #expect(report.books[0].origins.count == 2) + } + + @Test func mergesDecoratedAuthorAndPunctuatedTitleAcrossSources() { + let plain = SearchBook(bookURL: "one", origin: "one", originName: "一源", type: 0, name: "长夜里的灯", author: "林舟") + let decorated = SearchBook(bookURL: "two", origin: "two", originName: "二源", type: 0, name: "长夜里的灯!", author: "作者:林舟 著") + let outcomes = [ + SourceSearchOutcome(sourceID: "one", books: [plain], diagnostics: []), + SourceSearchOutcome(sourceID: "two", books: [decorated], diagnostics: []) + ] + + let merged = BookSearchService().merge(outcomes: outcomes, keyword: "长夜里的灯") + + #expect(merged.count == 1) + #expect(merged[0].origins == ["one", "two"]) + } + + @Test func searchesHTMLSourceWithCSSRules() async throws { + let finalURL = try #require(URL(string: "https://html.example.test/search?q=ios")) + let data = Data( + """ +
+ + +
+ """.utf8 + ) + let transport = SearchMockTransport( + response: .init(data: data, statusCode: 200, headers: [:], finalURL: finalURL), + expectedQueryFragment: "q=ios" + ) + var source = BookSource(bookSourceUrl: "https://html.example.test", bookSourceName: "HTML 源") + source.searchUrl = "/search?q={{key}}" + source.ruleSearch = SearchRule( + bookList: "@CSS:article.book", + name: "@CSS:a.name@text", + author: "@CSS:.author@text", + bookUrl: "@CSS:a.name@href", + coverUrl: "@CSS:img@src" + ) + let service = BookSearchService(transport: transport) + + let report = await service.search(keyword: "ios", sources: [source]) + + #expect(report.books.count == 2) + #expect(report.books.first(where: { $0.name == "iOS 阅读" })?.bookURL == "https://html.example.test/book/ios") + #expect(report.outcomes[0].diagnostics.isEmpty) + } + + @Test func searchesLegadoStyleSourceWithJavaScriptPipelineAndVariables() async throws { + let finalURL = try #require(URL(string: "https://audio.example.test/search")) + let data = Data( + #"{"content":{"content":[{"id":1001,"title":"听书测试","author":"播音员","cover":{"path":"cover/a.jpg"}}]}}"#.utf8 + ) + let transport = SearchMockTransport( + response: .init(data: data, statusCode: 200, headers: [:], finalURL: finalURL), + expectedQueryFragment: nil + ) + var source = BookSource(bookSourceUrl: "https://audio.example.test", bookSourceName: "JS 源") + source.searchUrl = "https://audio.example.test/search,{\"method\":\"POST\",\"body\":{\"title\":\"searchKey\",\"page\":{{searchPage}}}}" + source.ruleSearch = SearchRule( + bookList: "$.content.content", + name: "$.title", + author: "$.author", + bookUrl: "$.id@js:java.put('bookId', result); '/toc?bookId=' + result", + coverUrl: "$.cover@js:var cover = JSON.parse(result); '/file/' + cover.path" + ) + + let report = await BookSearchService(transport: transport).search(keyword: "听书", sources: [source]) + + #expect(report.outcomes.flatMap(\.diagnostics).isEmpty) + let book = try #require(report.books.first) + #expect(book.bookURL == "https://audio.example.test/toc?bookId=1001") + #expect(book.coverURL == "https://audio.example.test/file/cover/a.jpg") + #expect(book.variables["bookId"] == "1001") + #expect(report.outcomes[0].diagnostics.isEmpty) + } + + @Test func searchesLegacyEncodedHTMLUsingURLCharsetOption() async throws { + let finalURL = try #require(URL(string: "https://legacy.example.test/search")) + let encoding = try #require(HTTPTextDecoder.encoding(named: "gb18030")) + let html = #""# + let data = try #require(html.data(using: encoding)) + let transport = SearchMockTransport( + response: .init(data: data, statusCode: 200, headers: [:], finalURL: finalURL), + expectedQueryFragment: nil + ) + var source = BookSource(bookSourceUrl: "https://legacy.example.test", bookSourceName: "旧编码源") + source.searchUrl = #"/search?q={{key}}, {"charset":"gb18030"}"# + source.ruleSearch = SearchRule( + bookList: "@CSS:article.book", + name: "@CSS:a.name@text", + author: "@CSS:.author@text", + bookUrl: "@CSS:a.name@href" + ) + + let report = await BookSearchService(transport: transport).search(keyword: "旧城", sources: [source]) + + #expect(report.books.first?.name == "旧城故事") + #expect(report.books.first?.author == "林舟") + } + + @Test func evaluatesDynamicRequestJavaScriptAndPassesVariablesToRules() async throws { + let finalURL = try #require(URL(string: "https://dynamic.example.test/search?q=DUNE")) + let transport = SearchMockTransport( + response: .init( + data: Data(#"{"books":[{"name":"沙丘","author":"Frank","id":"42"}]}"#.utf8), + statusCode: 200, + headers: [:], + finalURL: finalURL + ), + expectedQueryFragment: "q=DUNE" + ) + var source = BookSource(bookSourceUrl: "https://dynamic.example.test", bookSourceName: "动态源") + source.searchUrl = "@js:java.put('requestToken', key.toUpperCase()); '/search?q=' + java.get('requestToken')" + source.ruleSearch = SearchRule( + bookList: "$.books[*]", + name: "name", + author: "author", + bookUrl: "$.id@js:'/book/' + result + '?token=' + java.get('requestToken')" + ) + + let report = await BookSearchService(transport: transport).search(keyword: "dune", sources: [source]) + + #expect(report.outcomes.flatMap(\.diagnostics).isEmpty) + #expect(report.books.first?.bookURL == "https://dynamic.example.test/book/42?token=DUNE") + #expect(report.books.first?.variables["requestToken"] == "DUNE") + } + + @Test func searchesRenderedWebViewHTMLThroughInjectedExecutor() async throws { + let url = try #require(URL(string: "https://web-search.example/results")) + let executor = await MainActor.run { SearchWebViewExecutor(response: .init( + data: Data(#""#.utf8), + statusCode: 200, + headers: [:], + finalURL: url + )) } + var source = BookSource(bookSourceUrl: "https://web-search.example", bookSourceName: "WebView 源") + source.searchUrl = #"/search?q={{key}}, {"webView":true,"webJs":"document.body.dataset.loaded='yes'"}"# + source.ruleSearch = SearchRule( + bookList: "@CSS:article.book", + name: "@CSS:a@text", + author: "@CSS:span@text", + bookUrl: "@CSS:a@href" + ) + + let report = await BookSearchService( + transport: SearchMockTransport(response: .init(data: Data(), statusCode: 500, headers: [:], finalURL: url)), + webViewExecutor: executor + ).search(keyword: "网页", sources: [source]) + + #expect(report.outcomes.flatMap(\.diagnostics).isEmpty) + #expect(report.books.first?.name == "渲染后的书") + #expect(report.books.first?.bookURL == "https://web-search.example/book/9") + #expect(await executor.callCount == 1) + } +} + +@MainActor +private final class SearchWebViewExecutor: SourceWebViewRequestExecuting, @unchecked Sendable { + let response: HTTPResponse + private(set) var callCount = 0 + + init(response: HTTPResponse) { self.response = response } + + func execute(_ prepared: PreparedRequest, source: BookSource, timeout: TimeInterval) async throws -> HTTPResponse { + callCount += 1 + return response + } +} diff --git a/Tests/LegadoCoreTests/BookSourceDebuggerTests.swift b/Tests/LegadoCoreTests/BookSourceDebuggerTests.swift new file mode 100644 index 0000000..fe15fdc --- /dev/null +++ b/Tests/LegadoCoreTests/BookSourceDebuggerTests.swift @@ -0,0 +1,64 @@ +import Foundation +import Testing +@testable import LegadoCore + +private struct DebuggerMockTransport: HTTPTransport { + let pages: [String: String] + + func data(for request: URLRequest) async throws -> HTTPResponse { + let url = try #require(request.url) + let body = try #require(pages[url.absoluteString], "缺少调试页面:\(url.absoluteString)") + return .init(data: Data(body.utf8), statusCode: 200, headers: [:], finalURL: url) + } +} + +struct BookSourceDebuggerTests { + @Test func runsSearchDetailTocAndContentInOrder() async throws { + let pages = [ + "https://debug.example.test/search?q=demo": #"{"books":[{"name":"调试之书","url":"/book/1"}]}"#, + "https://debug.example.test/book/1": #"

调试之书

目录"#, + "https://debug.example.test/toc/1": #"
  • 第一章
  • "#, + "https://debug.example.test/chapter/1": "
    调试正文成功
    " + ] + var source = BookSource(bookSourceUrl: "https://debug.example.test", bookSourceName: "调试源") + source.searchUrl = "/search?q={{key}}" + source.ruleSearch = .init(bookList: "$.books", name: "$.name", bookUrl: "$.url") + var info = BookInfoRule() + info.name = "@CSS:h1@text" + info.tocUrl = "@CSS:a.toc@href" + source.ruleBookInfo = info + var toc = TocRule() + toc.chapterList = "@CSS:li.chapter" + toc.chapterName = "@CSS:a@text" + toc.chapterUrl = "@CSS:a@href" + source.ruleToc = toc + var content = ContentRule() + content.content = "@CSS:article@text" + source.ruleContent = content + + let report = await BookSourceDebugger(transport: DebuggerMockTransport(pages: pages)) + .run(source: source, keyword: "demo") + + #expect(report.steps.map(\.stage) == SourceDebugStage.allCases) + #expect(report.steps.allSatisfy { $0.status == .passed }) + #expect(report.sampleBookName == "调试之书") + #expect(report.sampleChapterTitle == "第一章") + #expect(report.contentPreview == "调试正文成功") + #expect(report.passed) + } + + @Test func reportsConfigurationAndSkipsAfterEmptySearch() async { + let pages = ["https://debug.example.test/search?q=none": #"{"books":[]}"#] + var source = BookSource(bookSourceUrl: "https://debug.example.test", bookSourceName: "空源") + source.searchUrl = "/search?q={{key}}" + source.ruleSearch = .init(bookList: "$.books", name: "$.name", bookUrl: "$.url") + + let report = await BookSourceDebugger(transport: DebuggerMockTransport(pages: pages)) + .run(source: source, keyword: "none") + + #expect(report.steps.first?.status == .failed) + #expect(report.steps.first(where: { $0.stage == .search })?.status == .failed) + #expect(report.steps.first(where: { $0.stage == .detail })?.status == .skipped) + #expect(!report.passed) + } +} diff --git a/Tests/LegadoCoreTests/BookSourceImporterTests.swift b/Tests/LegadoCoreTests/BookSourceImporterTests.swift new file mode 100644 index 0000000..2f80b8e --- /dev/null +++ b/Tests/LegadoCoreTests/BookSourceImporterTests.swift @@ -0,0 +1,191 @@ +import Compression +import CoreFoundation +import Foundation +import Testing +@testable import LegadoCore + +struct BookSourceImporterTests { + private let importer = BookSourceImporter() + + @Test func decodesArrayAndObjectRules() throws { + let data = Data( + """ + [{ + "bookSourceUrl": "https://example.test", + "bookSourceName": "示例书源", + "enabled": true, + "bookSourceType": 0, + "searchUrl": "https://example.test/search?q={{key}}", + "ruleSearch": { + "bookList": "@css:.book", + "name": "@css:h2@text", + "bookUrl": "@css:a@href" + }, + "ruleContent": "{\\\"content\\\":\\\"@css:article@html\\\"}" + }] + """.utf8 + ) + + let sources = try importer.decode(data) + + #expect(sources.count == 1) + #expect(sources[0].bookSourceName == "示例书源") + #expect(sources[0].ruleSearch?.bookList == "@css:.book") + #expect(sources[0].ruleContent?.content == "@css:article@html") + } + + @Test func acceptsUTF8BOMAndLossyPrimitiveTypes() throws { + var data = Data([0xEF, 0xBB, 0xBF]) + data.append( + contentsOf: """ + { + "bookSourceUrl": "https://example.test", + "bookSourceName": "兼容书源", + "enabled": "1", + "enabledExplore": 0, + "bookSourceType": "2", + "respondTime": "1200" + } + """.utf8 + ) + + let source = try #require(importer.decode(data).first) + + #expect(source.enabled) + #expect(!source.enabledExplore) + #expect(source.bookSourceType == 2) + #expect(source.respondTime == 1_200) + } + + @Test func preservesEnabledCookieJarDefaultAndExplicitNull() throws { + let sources = try importer.decode(Data( + #"[{"bookSourceUrl":"https://default-cookie.test","bookSourceName":"默认"},{"bookSourceUrl":"https://null-cookie.test","bookSourceName":"空值","enabledCookieJar":null}]"#.utf8 + )) + + #expect(sources[0].enabledCookieJar == true) + #expect(sources[1].enabledCookieJar == nil) + } + + @Test func rejectsWholeBatchBeforeMutation() throws { + let existing = [BookSource(bookSourceUrl: "https://kept.test", bookSourceName: "保留")] + let data = Data( + """ + [ + {"bookSourceUrl":"https://valid.test","bookSourceName":"有效"}, + {"bookSourceUrl":"","bookSourceName":"无效"} + ] + """.utf8 + ) + + #expect(throws: BookSourceImportError.self) { + try importer.prepare(data: data, existing: existing) + } + #expect(existing.count == 1) + } + + @Test func handlesDuplicatePolicies() throws { + var old = BookSource(bookSourceUrl: "https://same.test", bookSourceName: "旧名称") + old.enabled = false + let data = Data( + """ + {"bookSourceUrl":"https://same.test","bookSourceName":"新名称"} + """.utf8 + ) + + let replaced = try importer.prepare(data: data, existing: [old], duplicatePolicy: .replace) + #expect(replaced.replaced == 1) + #expect(replaced.sources[0].bookSourceName == "新名称") + + let skipped = try importer.prepare(data: data, existing: [old], duplicatePolicy: .skip) + #expect(skipped.skipped == 1) + #expect(skipped.sources[0].bookSourceName == "旧名称") + + #expect(throws: BookSourceImportError.self) { + try importer.prepare(data: data, existing: [old], duplicatePolicy: .fail) + } + } + + @Test func roundTripsWithoutLosingCompatibilityFields() throws { + let originalData = Data( + """ + { + "bookSourceUrl":"https://roundtrip.test", + "bookSourceName":"往返测试", + "bookSourceGroup":"测试,文本", + "header":"{\\"User-Agent\\":\\"Legado\\"}", + "eventListener":true, + "customButton":true, + "ruleToc":{"chapterList":"//li","chapterName":"a/text()"} + } + """.utf8 + ) + let source = try #require(importer.decode(originalData).first) + + let encoded = try JSONEncoder().encode(source) + let decoded = try JSONDecoder().decode(BookSource.self, from: encoded) + + #expect(decoded == source) + } + + @Test func importsDesensitizedJavaScriptFixture() throws { + let url = try #require(Bundle.module.url( + forResource: "legado-js-audio-source", + withExtension: "json", + subdirectory: "Fixtures" + )) + let source = try #require(importer.decode(Data(contentsOf: url)).first) + + #expect(source.bookSourceType == 1) + #expect(source.searchUrl?.contains("searchKey") == true) + #expect(source.ruleSearch?.bookUrl?.contains("@js:") == true) + #expect(source.ruleToc?.chapterUrl?.contains("java.get('bookId')") == true) + #expect(source.ruleReview?.reviewUrl == "/review/{{chapterId}}") + + let roundTrip = try JSONDecoder().decode(BookSource.self, from: JSONEncoder().encode(source)) + #expect(roundTrip == source) + } + + @Test func importsSourceDocumentsLargerThanTwentyMegabytes() throws { + var data = Data(#"[{"bookSourceUrl":"https://large.test","bookSourceName":"大书源"}]"#.utf8) + data.append(Data(repeating: 0x20, count: 21 * 1_024 * 1_024)) + + let source = try #require(importer.decode(data).first) + #expect(source.bookSourceName == "大书源") + } + + @Test func decodesGBKEncodedSourceDocuments() throws { + let json = #"[{"bookSourceUrl":"https://gbk.test","bookSourceName":"编码书源","searchUrl":"/search?q={{key}}"}]"# + let gbk = String.Encoding( + rawValue: CFStringConvertEncodingToNSStringEncoding(CFStringEncoding(CFStringEncodings.GB_18030_2000.rawValue)) + ) + let data = try #require(json.data(using: gbk)) + + let source = try #require(importer.decode(data).first) + + #expect(source.bookSourceName == "编码书源") + #expect(source.searchUrl == "/search?q={{key}}") + } + + @Test func decodesGzipCompressedSourceDocuments() throws { + let json = Data(#"[{"bookSourceUrl":"https://gzip.test","bookSourceName":"压缩书源"}]"#.utf8) + let destination = UnsafeMutablePointer.allocate(capacity: json.count + 4_096) + defer { destination.deallocate() } + let produced = json.withUnsafeBytes { (source: UnsafeRawBufferPointer) -> Int in + guard let base = source.bindMemory(to: UInt8.self).baseAddress else { return 0 } + return compression_encode_buffer( + destination, json.count + 4_096, + base, json.count, + nil, + COMPRESSION_ZLIB + ) + } + #expect(produced > 0) + var gzipped = Data([0x1F, 0x8B, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF]) + gzipped.append(destination, count: produced) + gzipped.append(Data(repeating: 0, count: 8)) + + let source = try #require(importer.decode(gzipped).first) + + #expect(source.bookSourceName == "压缩书源") + } +} diff --git a/Tests/LegadoCoreTests/BookSourceRepositoryTests.swift b/Tests/LegadoCoreTests/BookSourceRepositoryTests.swift new file mode 100644 index 0000000..0bbb407 --- /dev/null +++ b/Tests/LegadoCoreTests/BookSourceRepositoryTests.swift @@ -0,0 +1,45 @@ +import Foundation +import Testing +@testable import LegadoCore + +struct BookSourceRepositoryTests { + @Test func fileRepositoryPersistsAtomically() async throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString, directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: directory) } + let repository = FileBookSourceRepository(fileURL: directory.appending(path: "sources.json")) + let expected = [BookSource(bookSourceUrl: "https://saved.test", bookSourceName: "已保存")] + + try await repository.save(expected) + let loaded = try await repository.load() + + #expect(loaded == expected) + } + + @Test func groupOperationsSplitSeparatorsAndDeduplicate() { + var source = BookSource(bookSourceUrl: "https://groups.test", bookSourceName: "分组源") + source.bookSourceGroup = "文学, 科幻;文学;听书" + + #expect(source.groups == ["文学", "科幻", "听书"]) + #expect(source.hasGroup("科幻")) + + source.addGroups("悬疑,科幻") + #expect(source.groups == ["文学", "科幻", "听书", "悬疑"]) + + source.removeGroups("文学;听书") + #expect(source.bookSourceGroup == "科幻,悬疑") + } + + @Test func renamesAndDeletesGroupsWithoutLeavingEmptyText() { + var source = BookSource(bookSourceUrl: "https://rename.test", bookSourceName: "重命名源") + source.bookSourceGroup = "旧组,保留" + + source.replaceGroup("旧组", with: "新组") + #expect(source.groups == ["保留", "新组"]) + source.replaceGroup("保留", with: nil) + source.removeGroups("新组") + + #expect(source.groups.isEmpty) + #expect(source.bookSourceGroup == nil) + } +} diff --git a/Tests/LegadoCoreTests/BookSourceSwitchServiceTests.swift b/Tests/LegadoCoreTests/BookSourceSwitchServiceTests.swift new file mode 100644 index 0000000..e02de92 --- /dev/null +++ b/Tests/LegadoCoreTests/BookSourceSwitchServiceTests.swift @@ -0,0 +1,270 @@ +import Testing +@testable import LegadoCore + +struct BookSourceSwitchServiceTests { + @Test(.timeLimit(.minutes(1))) func automaticRecoveryUsesEarlyValidSourceWithoutWaitingForSlowSources() async throws { + let (stream, continuation) = AsyncStream.makeStream(of: SourceSearchOutcome.self) + defer { continuation.finish() } + let loader = CandidateAwareSwitchLoader() + let service = BookSourceSwitchService(searcher: StreamingSwitchSearch(stream: stream), + detailLoader: loader, chapterLoader: loader, + contentLoader: SwitchContentLoader(), + chapterRepository: InMemoryBookChapterRepository()) + var source = BookSource(bookSourceUrl: "valid", bookSourceName: "好源") + source.searchUrl = "/search" + let candidate = SearchBook(bookURL: "valid-book", origin: "valid", originName: "好源", type: 0, name: "书") + continuation.yield(.init(sourceID: "valid", books: [candidate], diagnostics: [])) + let result = await service.firstAvailableSource(for: Book(bookUrl: "old-book", name: "书", origin: "old"), sources: [source]) + #expect(result?.source.id == "valid") + #expect(result?.report.chapters.first?.title == "有效目录") + } + + @Test(.timeLimit(.minutes(1))) func streamsCandidatesBeforeAllSourcesFinishAndKeepsDistinctOrigins() async throws { + let (stream, continuation) = AsyncStream.makeStream(of: SourceSearchOutcome.self) + defer { continuation.finish() } + let service = BookSourceSwitchService(searcher: StreamingSwitchSearch(stream: stream)) + let book = Book(bookUrl: "old-book", name: "书", author: "作者", origin: "old") + let sources = ["a", "b"].map { id in + var source = BookSource(bookSourceUrl: id, bookSourceName: id) + source.searchUrl = "/search" + return source + } + var iterator = await service.candidateUpdates(for: book, sources: sources).makeAsyncIterator() + let first = SearchBook(bookURL: "shared-url", origin: "a", originName: "A", type: 0, name: "书", author: "作者") + continuation.yield(.init(sourceID: "a", books: [first], diagnostics: [])) + let early = try #require(await iterator.next()) + #expect(early.map(\.book.origin) == ["a"]) + var second = first + second.origin = "b" + continuation.yield(.init(sourceID: "b", books: [second, second], diagnostics: [])) + let updated = try #require(await iterator.next()) + #expect(Set(updated.map(\.book.origin)) == ["a", "b"]) + #expect(Set(updated.map(\.id)).count == 2) + continuation.finish() + #expect(await iterator.next() == nil) + } + + @Test func ranksExactAuthorAndExcludesCurrentSource() async { + var current = Book(bookUrl: "old", name: "长夜里的灯", author: "林舟", origin: "old-source") + current.originName = "旧源" + let exact = SearchBook(bookURL: "new-a", origin: "source-a", originName: "甲源", type: 0, name: "长夜里的灯", author: "作者:林舟") + let otherAuthor = SearchBook(bookURL: "new-b", origin: "source-b", originName: "乙源", type: 0, name: "长夜里的灯", author: "另一位") + let wrongTitle = SearchBook(bookURL: "new-c", origin: "source-c", originName: "丙源", type: 0, name: "长夜的灯", author: "林舟") + let report = BookSearchReport(books: [], outcomes: [ + .init(sourceID: "source-a", books: [exact], diagnostics: []), + .init(sourceID: "source-b", books: [otherAuthor], diagnostics: []), + .init(sourceID: "source-c", books: [wrongTitle], diagnostics: []) + ]) + let service = BookSourceSwitchService(searcher: StubSwitchSearch(report: report)) + let sources = ["old-source", "source-a", "source-b", "source-c"].map { + var source = BookSource(bookSourceUrl: $0, bookSourceName: $0) + source.searchUrl = "/search" + return source + } + + let candidates = await service.candidates(for: current, sources: sources) + + #expect(candidates.map(\.book.bookURL) == ["new-a", "new-b"]) + #expect(candidates[0].score > candidates[1].score) + } + + @Test func preservesCharacterPositionWhenChapterTitleMatches() { + var current = Book(bookUrl: "old", name: "书", origin: "old") + current.customTag = "收藏" + current.group = 8 + current.totalChapterNum = 10 + current.durChapterIndex = 4 + current.durChapterTitle = "第五章 · 重逢" + current.durChapterPos = 388 + current.durChapterTime = 77 + var readConfig = BookReadConfig() + readConfig.pageAnim = 3 + readConfig.useReplaceRule = false + current.readConfig = readConfig + let replacement = Book(bookUrl: "new", name: "书", origin: "new") + let chapters = (0..<8).map { + BookChapter(url: "n\($0)", title: $0 == 6 ? "第五章 重逢" : "第\($0)章", bookUrl: "new", index: $0) + } + + let report = BookSourceProgressMapper().migrate(current: current, replacement: replacement, chapters: chapters, now: 100) + + #expect(report.exactChapterMatch) + #expect(report.book.durChapterIndex == 6) + #expect(report.book.durChapterPos == 388) + #expect(report.book.durChapterTime == 77) + #expect(report.book.customTag == "收藏") + #expect(report.book.group == 8) + #expect(report.book.readConfig == current.readConfig) + } + + @Test func fallsBackToRelativeProgressAndResetsCharacterOffset() { + var current = Book(bookUrl: "old", name: "书", origin: "old") + current.totalChapterNum = 11 + current.durChapterIndex = 5 + current.durChapterTitle = "旧目录章节" + current.durChapterPos = 999 + let replacement = Book(bookUrl: "new", name: "书", origin: "new") + let chapters = (0..<21).map { BookChapter(url: "n\($0)", title: "新\($0)", bookUrl: "new", index: $0) } + + let report = BookSourceProgressMapper().migrate(current: current, replacement: replacement, chapters: chapters, now: 100) + + #expect(!report.exactChapterMatch) + #expect(report.book.durChapterIndex == 10) + #expect(report.book.durChapterPos == 0) + } + + @Test func refusesSwitchWhenCandidateDirectoryCannotBeLoaded() async { + let repository = InMemoryBookChapterRepository() + let service = BookSourceSwitchService( + searcher: StubSwitchSearch(report: .init(books: [], outcomes: [])), + detailLoader: StubSwitchLoader(chapters: []), + chapterLoader: StubSwitchLoader(chapters: []), + chapterRepository: repository + ) + let current = Book(bookUrl: "old", name: "书", origin: "old") + let candidate = SearchBook(bookURL: "new", origin: "new", originName: "新源", type: 0, name: "书") + let source = BookSource(bookSourceUrl: "new", bookSourceName: "新源") + + #expect(await service.prepareSwitch(current: current, candidate: candidate, source: source) == nil) + #expect(await repository.load(bookURL: "new").isEmpty) + } + + @Test func automaticallySkipsBrokenCandidateAndUsesFirstValidDirectory() async { + let broken = SearchBook(bookURL: "broken-book", origin: "broken", originName: "A 坏源", type: 0, name: "书") + let valid = SearchBook(bookURL: "valid-book", origin: "valid", originName: "B 好源", type: 0, name: "书") + let search = StubSwitchSearch(report: .init(books: [], outcomes: [ + .init(sourceID: "broken", books: [broken], diagnostics: []), + .init(sourceID: "valid", books: [valid], diagnostics: []) + ])) + let loader = CandidateAwareSwitchLoader() + let service = BookSourceSwitchService( + searcher: search, + detailLoader: loader, + chapterLoader: loader, + contentLoader: SwitchContentLoader(), + chapterRepository: InMemoryBookChapterRepository() + ) + let current = Book(bookUrl: "old-book", name: "书", origin: "old") + let sources = ["old", "broken", "valid"].map { id in + var source = BookSource(bookSourceUrl: id, bookSourceName: id) + source.searchUrl = "/search" + return source + } + + let available = await service.firstAvailableSource(for: current, sources: sources, now: 100) + + #expect(available?.source.bookSourceUrl == "valid") + #expect(available?.report.chapters.map(\.title) == ["有效目录"]) + } + + @Test func refusesCandidateWithDirectoryButNoBodyWithoutSavingItsDirectory() async { + let repository = InMemoryBookChapterRepository() + let chapter = BookChapter(url: "new/1", title: "第一章", bookUrl: "new", index: 0) + let loader = StubSwitchLoader(chapters: [chapter]) + for body in ["", " \n\t"] { + let service = BookSourceSwitchService(detailLoader: loader, chapterLoader: loader, + contentLoader: SwitchContentLoader(body: body), chapterRepository: repository) + let report = await service.prepareSwitch( + current: Book(bookUrl: "old", name: "书", origin: "old"), + candidate: SearchBook(bookURL: "new", origin: "new", originName: "新源", type: 0, name: "书"), + source: BookSource(bookSourceUrl: "new", bookSourceName: "新源") + ) + #expect(report == nil) + #expect(await repository.load(bookURL: "new").isEmpty) + } + } + + @Test func automaticRecoverySkipsSearchableSourcesWithDirectoriesButBrokenBodies() async throws { + let candidates = ["broken-body", "valid"].map { + SearchBook(bookURL: $0 + "-book", origin: $0, originName: $0, type: 0, name: "书") + } + let sources = candidates.map { candidate in + var source = BookSource(bookSourceUrl: candidate.origin, bookSourceName: candidate.originName) + source.searchUrl = "/search" + return source + } + let loader = StubSwitchLoader(chapters: [BookChapter(url: "chapter", title: "第一章", bookUrl: "new", index: 0)]) + let service = BookSourceSwitchService( + searcher: StubSwitchSearch(report: .init(books: [], outcomes: candidates.map { + .init(sourceID: $0.origin, books: [$0], diagnostics: []) + })), detailLoader: loader, chapterLoader: loader, contentLoader: SwitchContentLoader(), + chapterRepository: InMemoryBookChapterRepository() + ) + let available = try #require(await service.firstAvailableSource(for: Book(bookUrl: "old", name: "书", origin: "old"), sources: sources)) + #expect(available.source.id == "valid") + #expect(available.report.verifiedContent?.content == "验证后的正文") + } + + @Test func validatesMappedChapterWithNextReadableURLAndKeepsReadingPosition() async throws { + var current = Book(bookUrl: "old", name: "书", origin: "old") + current.durChapterTitle = "第二章" + current.durChapterPos = 88 + var volume = BookChapter(url: "volume", title: "卷二", bookUrl: "new", index: 2) + volume.isVolume = true + let chapters = [BookChapter(url: "first", title: "第一章", bookUrl: "new", index: 0), + BookChapter(url: "second", title: "第二章", bookUrl: "new", index: 1), volume, + BookChapter(url: "third", title: "第三章", bookUrl: "new", index: 3)] + let loader = StubSwitchLoader(chapters: chapters) + let contentLoader = RecordingSwitchContentLoader() + let service = BookSourceSwitchService(detailLoader: loader, chapterLoader: loader, contentLoader: contentLoader, + chapterRepository: InMemoryBookChapterRepository()) + let report = try #require(await service.prepareSwitch(current: current, + candidate: SearchBook(bookURL: "new", origin: "new", originName: "新源", type: 0, name: "书"), + source: BookSource(bookSourceUrl: "new", bookSourceName: "新源"))) + #expect(await contentLoader.chapterURL == "second") + #expect(await contentLoader.nextURL == "third") + #expect(report.book.durChapterPos == 88) + #expect(report.verifiedContent?.content == "验证后的正文") + } +} + +private struct SwitchContentLoader: ChapterContentLoading { + var body = "验证后的正文" + func loadContent(book: Book, chapter: BookChapter, source: BookSource, nextChapterURL: String?, maxPages: Int) async -> ChapterContentReport { + .init(content: source.id == "broken-body" ? "" : body) + } +} + +private actor RecordingSwitchContentLoader: ChapterContentLoading { + var chapterURL: String? + var nextURL: String? + func loadContent(book: Book, chapter: BookChapter, source: BookSource, nextChapterURL: String?, maxPages: Int) async -> ChapterContentReport { + chapterURL = chapter.url + nextURL = nextChapterURL + return .init(content: "验证后的正文") + } +} + +private struct StreamingSwitchSearch: BookSourceSearching { + let stream: AsyncStream + func search(keyword: String, page: Int, sources: [BookSource]) async -> BookSearchReport { + .init(books: [], outcomes: []) + } + func searchStream(keyword: String, sources: [BookSource]) -> AsyncStream { stream } +} + +private struct StubSwitchSearch: BookSourceSearching { + let report: BookSearchReport + func search(keyword: String, page: Int, sources: [BookSource]) async -> BookSearchReport { report } +} + +private struct StubSwitchLoader: BookDetailLoading, ChapterListLoading { + let chapters: [BookChapter] + func loadDetail(searchBook: SearchBook, source: BookSource) async -> BookDetailReport { + .init(book: Book(bookUrl: searchBook.bookURL, name: searchBook.name, author: searchBook.author, origin: source.bookSourceUrl)) + } + func loadChapters(book: Book, source: BookSource, maxPages: Int) async -> ChapterListReport { + .init(chapters: chapters) + } +} + +private struct CandidateAwareSwitchLoader: BookDetailLoading, ChapterListLoading { + func loadDetail(searchBook: SearchBook, source: BookSource) async -> BookDetailReport { + .init(book: Book(bookUrl: searchBook.bookURL, name: searchBook.name, origin: source.bookSourceUrl)) + } + + func loadChapters(book: Book, source: BookSource, maxPages: Int) async -> ChapterListReport { + guard source.bookSourceUrl == "valid" else { return .init(chapters: []) } + return .init(chapters: [BookChapter(url: "chapter", title: "有效目录", bookUrl: book.bookUrl, index: 0)]) + } +} diff --git a/Tests/LegadoCoreTests/BookUpdateCoordinatorTests.swift b/Tests/LegadoCoreTests/BookUpdateCoordinatorTests.swift new file mode 100644 index 0000000..6fc99d0 --- /dev/null +++ b/Tests/LegadoCoreTests/BookUpdateCoordinatorTests.swift @@ -0,0 +1,101 @@ +import Testing +@testable import LegadoCore + +struct BookUpdateCoordinatorTests { + @Test func countsOnlyGrowthAndPreservesChapterMetadata() { + var book = Book(bookUrl: "book", name: "书", origin: "source") + book.totalChapterNum = 2 + book.durChapterIndex = 1 + book.durChapterTitle = "第二章 旧雨" + var oldFirst = BookChapter(url: "old-1", title: "第一章", bookUrl: "book", index: 0) + oldFirst.wordCount = "一千字" + oldFirst.variable = #"{"token":"keep"}"# + let oldSecond = BookChapter(url: "old-2", title: "第二章 旧雨", bookUrl: "book", index: 1) + let incoming = [ + BookChapter(url: "new-1", title: "第一章", bookUrl: "book", index: 8), + BookChapter(url: "new-2", title: "第二章 旧雨", bookUrl: "book", index: 9), + BookChapter(url: "new-3", title: "第三章 天晴", bookUrl: "book", index: 10) + ] + + let result = BookUpdateMerger().merge( + book: book, + oldChapters: [oldFirst, oldSecond], + newChapters: incoming, + now: 99 + ) + + #expect(result.addedChapterCount == 1) + #expect(result.book.lastCheckCount == 1) + #expect(result.book.totalChapterNum == 3) + #expect(result.book.latestChapterTitle == "第三章 天晴") + #expect(result.book.durChapterIndex == 1) + #expect(result.chapters[0].wordCount == "一千字") + #expect(result.chapters[0].variable == oldFirst.variable) + #expect(result.chapters.map(\.index) == [0, 1, 2]) + } + + @Test func retainsUnreadCountWhenDirectoryDoesNotGrow() { + var book = Book(bookUrl: "book", name: "书", origin: "source") + book.totalChapterNum = 2 + book.lastCheckCount = 4 + let chapters = [ + BookChapter(url: "1", title: "第一章", bookUrl: "book", index: 0), + BookChapter(url: "2", title: "第二章", bookUrl: "book", index: 1) + ] + + let result = BookUpdateMerger().merge(book: book, oldChapters: chapters, newChapters: chapters, now: 10) + + #expect(result.addedChapterCount == 0) + #expect(result.book.lastCheckCount == 4) + #expect(result.book.latestChapterTime == 0) + #expect(result.book.lastCheckTime == 10) + } + + @Test func remapsProgressByTitleAndResetsPositionWhenChapterIsRemoved() { + var book = Book(bookUrl: "book", name: "书", origin: "source") + book.durChapterIndex = 2 + book.durChapterTitle = "第二章" + book.durChapterPos = 130 + let old = [ + BookChapter(url: "notice", title: "后记", bookUrl: "book", index: 0), + BookChapter(url: "first", title: "第一章", bookUrl: "book", index: 1), + BookChapter(url: "second", title: "第二章", bookUrl: "book", index: 2) + ] + let refreshed = Array(old.dropFirst()) + + let remapped = BookUpdateMerger().merge(book: book, oldChapters: old, newChapters: refreshed, now: 10) + #expect(remapped.book.durChapterIndex == 1) + #expect(remapped.book.durChapterPos == 130) + + book.durChapterIndex = 0 + book.durChapterTitle = "后记" + book.durChapterPos = 130 + let removed = BookUpdateMerger().merge(book: book, oldChapters: old, newChapters: refreshed, now: 10) + #expect(removed.book.durChapterIndex == 0) + #expect(removed.book.durChapterTitle == "第一章") + #expect(removed.book.durChapterPos == 0) + } + + @Test func coordinatorFallsBackToStoredDirectoryWhenRefreshFails() async throws { + let old = [BookChapter(url: "1", title: "第一章", bookUrl: "book", index: 0)] + let repository = InMemoryBookChapterRepository(values: ["book": old]) + let loader = StubChapterListLoader(report: .init( + chapters: [], + diagnostics: [.init(severity: .error, code: "toc.failed", message: "失败", rule: "toc")] + )) + let coordinator = BookUpdateCoordinator(repository: repository, loader: loader) + let book = Book(bookUrl: "book", name: "书", origin: "source") + let source = BookSource(bookSourceUrl: "source", bookSourceName: "源") + + let result = await coordinator.refresh(book: book, source: source, now: 20) + + #expect(!result.refreshed) + #expect(result.chapters == old) + #expect(result.diagnostics.first?.code == "toc.failed") + } +} + +private struct StubChapterListLoader: ChapterListLoading { + let report: ChapterListReport + func loadChapters(book: Book, source: BookSource, maxPages: Int) async -> ChapterListReport { report } +} diff --git a/Tests/LegadoCoreTests/ChapterContentCacheTests.swift b/Tests/LegadoCoreTests/ChapterContentCacheTests.swift new file mode 100644 index 0000000..fc8f9f1 --- /dev/null +++ b/Tests/LegadoCoreTests/ChapterContentCacheTests.swift @@ -0,0 +1,128 @@ +import Foundation +import Testing +@testable import LegadoCore + +struct ChapterContentCacheTests { + @Test func savesLoadsAndSeparatesBooks() async throws { + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: directory) } + let cache = ChapterContentCache(directory: directory) + let first = CachedChapterContent(bookURL: "book-a", chapterURL: "same", title: "一", content: "正文 A") + let second = CachedChapterContent(bookURL: "book-b", chapterURL: "same", title: "二", content: "正文 B") + + try await cache.save(first) + try await cache.save(second) + + #expect(try await cache.load(bookURL: "book-a", chapterURL: "same") == first) + #expect(try await cache.load(bookURL: "book-b", chapterURL: "same") == second) + let statistics = try await cache.statistics() + #expect(statistics.entries == 2) + } + + @Test func removesOnlyRequestedBookAndRejectsEmptyEntries() async throws { + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: directory) } + let cache = ChapterContentCache(directory: directory) + try await cache.save(.init(bookURL: "a", chapterURL: "1", content: "A")) + try await cache.save(.init(bookURL: "b", chapterURL: "1", content: "B")) + + try await cache.remove(bookURL: "a") + + #expect(try await cache.load(bookURL: "a", chapterURL: "1") == nil) + #expect(try await cache.load(bookURL: "b", chapterURL: "1")?.content == "B") + await #expect(throws: ChapterContentCacheError.invalidEntry) { + try await cache.save(.init(bookURL: "a", chapterURL: "2", content: "")) + } + } + + @Test func coordinatorUsesDiskCacheBeforeNetwork() async throws { + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: directory) } + let loader = StubChapterLoader(content: "网络正文") + let coordinator = ChapterContentCoordinator(loader: loader, cache: .init(directory: directory)) + let book = Book(bookUrl: "book", name: "书", origin: "source") + let chapter = BookChapter(url: "chapter", title: "章", bookUrl: book.bookUrl, index: 0) + let source = BookSource(bookSourceUrl: "https://source.test", bookSourceName: "源") + + let first = await coordinator.load(book: book, chapter: chapter, source: source) + let second = await coordinator.load(book: book, chapter: chapter, source: source) + + #expect(!first.loadedFromCache) + #expect(second.loadedFromCache) + #expect(second.report.content == "网络正文") + #expect(await loader.calls() == 1) + } + + @Test func removesOneChapterAndForceRefreshesIt() async throws { + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: directory) } + let cache = ChapterContentCache(directory: directory) + try await cache.save(.init(bookURL: "book", chapterURL: "one", content: "旧正文")) + try await cache.save(.init(bookURL: "book", chapterURL: "two", content: "保留正文")) + try await cache.remove(bookURL: "book", chapterURL: "one") + #expect(try await cache.load(bookURL: "book", chapterURL: "one") == nil) + #expect(try await cache.load(bookURL: "book", chapterURL: "two")?.content == "保留正文") + + let loader = StubChapterLoader(content: "刷新正文") + let coordinator = ChapterContentCoordinator(loader: loader, cache: cache) + let book = Book(bookUrl: "book", name: "书", origin: "source") + let chapter = BookChapter(url: "two", title: "章", bookUrl: book.bookUrl, index: 0) + let source = BookSource(bookSourceUrl: "https://source.test", bookSourceName: "源") + let refreshed = await coordinator.load(book: book, chapter: chapter, source: source, forceRefresh: true) + + #expect(!refreshed.loadedFromCache) + #expect(refreshed.report.content == "刷新正文") + #expect(await loader.calls() == 1) + } + + @Test func coordinatorPrefetchesOnce() async { + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: directory) } + let loader = StubChapterLoader(content: "下一章") + let coordinator = ChapterContentCoordinator(loader: loader, cache: .init(directory: directory)) + let book = Book(bookUrl: "book", name: "书", origin: "source") + let chapter = BookChapter(url: "next", title: "下一章", bookUrl: book.bookUrl, index: 1) + let source = BookSource(bookSourceUrl: "https://source.test", bookSourceName: "源") + + #expect(await coordinator.prefetch(book: book, chapter: chapter, source: source)) + #expect(!(await coordinator.prefetch(book: book, chapter: chapter, source: source))) + #expect(await loader.calls() == 1) + } + + @Test func cachePlannerSelectsRangeAndSkipsVolumeRows() { + var chapters = (0..<5).map { BookChapter(url: "c\($0)", title: "第 \($0) 章", bookUrl: "book", index: $0) } + chapters[2].isVolume = true + + let jobs = ChapterCachePlanner.plan(chapters: chapters, startIndex: 1, limit: 2) + + #expect(jobs.map(\.chapter.index) == [1, 3]) + #expect(jobs.map(\.nextChapterURL) == ["c3", "c4"]) + } + + @Test func cachePlannerClampsStartAndRejectsEmptyLimit() { + let chapters = (0..<2).map { BookChapter(url: "c\($0)", title: "章", bookUrl: "book", index: $0) } + + #expect(ChapterCachePlanner.plan(chapters: chapters, startIndex: 99).map(\.chapter.index) == [1]) + #expect(ChapterCachePlanner.plan(chapters: chapters, startIndex: 0, limit: 0).isEmpty) + } +} + +private actor StubChapterLoader: ChapterContentLoading { + private let content: String + private var callCount = 0 + + init(content: String) { self.content = content } + + func loadContent( + book: Book, + chapter: BookChapter, + source: BookSource, + nextChapterURL: String?, + maxPages: Int + ) async -> ChapterContentReport { + callCount += 1 + return .init(title: chapter.title, content: content) + } + + func calls() -> Int { callCount } +} diff --git a/Tests/LegadoCoreTests/ContentSanitizerTests.swift b/Tests/LegadoCoreTests/ContentSanitizerTests.swift new file mode 100644 index 0000000..254d5f0 --- /dev/null +++ b/Tests/LegadoCoreTests/ContentSanitizerTests.swift @@ -0,0 +1,52 @@ +import Testing +@testable import LegadoCore + +struct ContentSanitizerTests { + @Test func metadataCleanerRemovesHTMLDecodesEntitiesAndKeepsParagraphs() { + let input = "

    第一段 文字

    第二段
    换行
    " + + let value = MetadataTextSanitizer().clean(input) + + #expect(value == "第一段 文字\n第二段\n换行") + #expect(MetadataTextSanitizer().clean(" 纯文本简介 ") == "纯文本简介") + #expect(MetadataTextSanitizer().clean("

    ") == nil) + } + + @Test func appliesOrderedScopedRulesAndRemovesDuplicateTitle() { + let book = Book(bookUrl: "https://source.test/book/1", name: "测试书", origin: "https://source.test") + let chapter = BookChapter(url: "chapter/1", title: "第一章 开始", bookUrl: book.bookUrl, index: 0) + var titleRule = ReplaceRule(id: 1, name: "标题", pattern: "开始", replacement: "启程") + titleRule.scopeTitle = true + titleRule.scopeContent = false + titleRule.scope = "测试书" + var adRule = ReplaceRule(id: 2, name: "广告", pattern: "广告[^\n]*", replacement: "") + adRule.scopeContent = true + adRule.sortOrder = 1 + var excluded = ReplaceRule(id: 3, name: "排除", pattern: "正文", replacement: "错误") + excluded.excludeScope = "https://source.test" + + let report = ContentSanitizer().process( + book: book, + chapter: chapter, + content: "第一章 开始\n 正文第一段 \n广告请访问 example.com\n正文第二段", + rules: [excluded, adRule, titleRule] + ) + + #expect(report.title == "第一章 启程") + #expect(report.content == "正文第一段\n\n正文第二段") + #expect(report.removedDuplicateTitle) + #expect(report.effectiveRuleIDs == [1, 2]) + #expect(report.diagnostics.isEmpty) + } + + @Test func rejectsHighRiskRegexWithoutChangingContent() { + let book = Book(bookUrl: "local", name: "书", origin: "local") + let chapter = BookChapter(url: "1", title: "章节", bookUrl: book.bookUrl, index: 0) + let risky = ReplaceRule(id: 7, name: "危险", pattern: "(a+)+$", replacement: "") + + let report = ContentSanitizer().process(book: book, chapter: chapter, content: "aaaa!", rules: [risky]) + + #expect(report.content == "aaaa!") + #expect(report.diagnostics.contains { $0.code == "rule.resource_limit" }) + } +} diff --git a/Tests/LegadoCoreTests/DiagnosticLogTests.swift b/Tests/LegadoCoreTests/DiagnosticLogTests.swift new file mode 100644 index 0000000..e8ea482 --- /dev/null +++ b/Tests/LegadoCoreTests/DiagnosticLogTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Testing +@testable import LegadoCore + +struct DiagnosticLogTests { + @Test func boundedJournalSurvivesRelaunchAndCanBeCleared() throws { + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let log = DiagnosticLog(directory: directory, maximumEntries: 2, isEnabled: { true }) + log.record(category: "test", message: "first") + log.record(.warning, category: "test", message: "second") + log.record(.error, category: "test", message: "third") + let restored = DiagnosticLog(directory: directory, maximumEntries: 2, isEnabled: { true }) + #expect(restored.snapshot().map(\.message) == ["third", "second"]) + #expect(restored.export().contains("[ERROR] [test] third")) + #expect(!restored.export().contains("first")) + try restored.clear() + #expect(restored.snapshot().isEmpty) + #expect(DiagnosticLog(directory: directory).snapshot().isEmpty) + } + + @Test func disabledLoggingAndSensitiveRequestFields() { + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let disabled = DiagnosticLog(directory: directory, isEnabled: { false }) + disabled.record(category: "JS", message: "secret") + #expect(disabled.snapshot().isEmpty) + let log = DiagnosticLog(directory: directory, isEnabled: { true }) + var request = URLRequest(url: URL(string: "https://user:pass@example.com/chapter?token=TOP_SECRET#private")!) + request.setValue("Bearer PRIVATE_HEADER", forHTTPHeaderField: "Authorization") + request.httpBody = Data("PRIVATE_BODY".utf8) + log.network(request, status: 200, startedAt: Date()) + log.record(category: "JS", message: "token=PRIVATE_TOKEN password=PRIVATE_PASS https://example.com/?key=PRIVATE_QUERY") + log.record(category: "JS", message: "Authorization: Bearer PRIVATE_AUTH") + log.record(category: "JS", message: #"{"password": "PRIVATE_JSON"}"#) + let exported = log.export() + for secret in ["TOP_SECRET", "private", "user:pass", "PRIVATE_HEADER", "PRIVATE_BODY", "PRIVATE_TOKEN", "PRIVATE_PASS", "PRIVATE_QUERY", "PRIVATE_AUTH", "PRIVATE_JSON"] { + #expect(!exported.contains(secret)) + } + #expect(exported.contains("https://example.com/chapter")) + } +} diff --git a/Tests/LegadoCoreTests/Fixtures/README.md b/Tests/LegadoCoreTests/Fixtures/README.md new file mode 100644 index 0000000..8648443 --- /dev/null +++ b/Tests/LegadoCoreTests/Fixtures/README.md @@ -0,0 +1,8 @@ +# 规则测试样本 + +这些文件是自动化测试输入,不是 App 的内置书源库。测试包通过 SwiftPM 单独复制该目录,App 目标不会打包它。 + +- `synthetic-html-rules.json`:最小 HTML 规则,使用保留域名 `fixture.invalid`,验证倒序目录、裸字段提取和带章节变量的内容净化。网络响应在测试中自造,不访问真实网站。 +- `legado-js-audio-source.json`:脱敏 JS 听书规则结构样本。地址使用 `fixture.invalid`,用于测试导入字段及脚本保留,不在常规导入测试中请求网络或执行脚本。 + +新增样本优先自造数据;第三方材料须保留版权与许可。不要把账号凭据或真实付费内容放入 fixture。 diff --git a/Tests/LegadoCoreTests/Fixtures/legado-js-audio-source.json b/Tests/LegadoCoreTests/Fixtures/legado-js-audio-source.json new file mode 100644 index 0000000..fbbc2fd --- /dev/null +++ b/Tests/LegadoCoreTests/Fixtures/legado-js-audio-source.json @@ -0,0 +1,32 @@ +{ + "bookSourceUrl": "https://fixture.invalid", + "bookSourceName": "脱敏 JS 听书源", + "bookSourceGroup": "兼容夹具", + "bookSourceType": 1, + "enabled": true, + "enabledExplore": true, + "searchUrl": "https://fixture.invalid/search,{\"method\":\"POST\",\"body\":{\"title\":\"searchKey\",\"page\":{{searchPage}}}}", + "ruleSearch": { + "bookList": "$.content.content", + "name": "$.title", + "author": "$.author", + "bookUrl": "$.id@js:java.put('bookId', result);'/toc?bookId='+result", + "coverUrl": "$.cover@js:var cover=JSON.parse(result);'/file/'+cover.path" + }, + "ruleBookInfo": {}, + "ruleToc": { + "chapterList": "$.content.content", + "chapterName": "$.chapterTitle", + "chapterUrl": "$.id@js:java.put('chapterId', result);'/audio?bookId='+java.get('bookId')+'&chapterId='+result" + }, + "ruleContent": { + "content": "@js:result" + }, + "ruleReview": { + "reviewUrl": "/review/{{chapterId}}", + "contentRule": "$.content", + "postTimeRule": "$.time" + }, + "eventListener": false, + "customButton": false +} diff --git a/Tests/LegadoCoreTests/Fixtures/synthetic-html-rules.json b/Tests/LegadoCoreTests/Fixtures/synthetic-html-rules.json new file mode 100644 index 0000000..c849ed1 --- /dev/null +++ b/Tests/LegadoCoreTests/Fixtures/synthetic-html-rules.json @@ -0,0 +1,15 @@ +[ + { + "bookSourceName": "合成 HTML 规则", + "bookSourceUrl": "https://fixture.invalid", + "ruleToc": { + "chapterList": "id.chapter-list.0@tag.a[-1:0]", + "chapterName": "text", + "chapterUrl": "href" + }, + "ruleContent": { + "content": "class.chapter-body@html", + "replaceRegex": "##(?m)^(?:第一章|信息|{{book.durChapterTitle}}|\\(本章完\\))\\s*$" + } + } +] diff --git a/Tests/LegadoCoreTests/HTMLRuleEvaluatorTests.swift b/Tests/LegadoCoreTests/HTMLRuleEvaluatorTests.swift new file mode 100644 index 0000000..24aec64 --- /dev/null +++ b/Tests/LegadoCoreTests/HTMLRuleEvaluatorTests.swift @@ -0,0 +1,116 @@ +import Testing +@testable import LegadoCore + +struct HTMLRuleEvaluatorTests { + private let evaluator = HTMLRuleEvaluator() + private let html = """ + + + + """ + + @Test func legacyListChainsPreserveTableRowsAndCurrentElementOutputs() throws { + let page = "
    书名
    测试书作者
    " + let rows = try evaluator.selectCSSFragments("class.grid@tag.tr!0", html: page) + #expect(rows.count == 1) + let row = try #require(rows.first) + #expect(try evaluator.extractCSS("tag.td.0@text", html: row, isFragment: true) == ["测试书"]) + #expect(try evaluator.extractCSS("data-id", html: row, isFragment: true) == ["42"]) + let links = try evaluator.selectCSSFragments("tag.a", html: row, isFragment: true) + let link = try #require(links.first) + #expect(try evaluator.extractCSS("text", html: link, isFragment: true) == ["测试书"]) + #expect(try evaluator.extractCSS("href", html: link, isFragment: true) == ["/42"]) + #expect(try evaluator.extractXPath("./@href", html: link, isFragment: true) == ["/42"]) + #expect(try evaluator.extractXPath("./text()", html: link, isFragment: true) == ["测试书"]) + } + + @Test func selectsCSSFragmentsAndExtractsOutputs() throws { + let fragments = try evaluator.selectCSSFragments("li.book", html: html) + + #expect(fragments.count == 2) + #expect(try evaluator.extractCSS(".title@text", html: fragments[0]) == ["A & One"]) + #expect(try evaluator.extractCSS("a@href", html: fragments[0]) == ["/a"]) + #expect(try evaluator.extractCSS("p@html", html: fragments[0]) == ["

    Intro A

    "]) + #expect(try evaluator.extractCSS("a@all", html: fragments[0]).first?.contains("href=\"/a\"") == true) + } + + @Test func selectsXPathFragmentsAndScalarValues() throws { + let fragments = try evaluator.selectXPathFragments("//li[@class='book']", html: html) + + #expect(fragments.count == 2) + #expect(try evaluator.extractXPath("//span[@class='title']/text()", html: fragments[1]) == ["B"]) + #expect(try evaluator.extractXPath("count(//li[@class='book'])", html: html) == ["2"]) + } + + @Test func ruleEngineAppliesHTMLCombination() async { + let engine = RuleEngine() + + let result = await engine.evaluate("@CSS:.missing@text||@CSS:.title@text", inText: html) + + #expect(result.strings == ["A & One", "B"]) + #expect(result.diagnostics.isEmpty) + } + + @Test func supportsLegadoChainAndIndexedSelectors() throws { + #expect(try evaluator.extractCSS("id.books@tag.li[1]@class.title@text", html: html) == ["B"]) + #expect(try evaluator.extractCSS("li.book[-1:0]@class.title@text", html: html) == ["B", "A & One"]) + #expect(try evaluator.extractCSS("li.book[!0]@class.title@text", html: html) == ["B"]) + } + + @Test func supportsLegacyOutputOnlyRulesOnCurrentDocument() throws { + #expect(try evaluator.extractCSS("@text", html: "
    正文
    ") == ["正文"]) + #expect(try evaluator.extractCSS("@html", html: "
    正文
    ").first?.contains("
    ") == true) + #expect(try evaluator.extractCSS("meta@content", html: "正文", isFragment: true) == ["书名"]) + } + + @Test func reportsSwiftSoupSelectorMessageInsteadOfOpaqueNSError() throws { + do { + _ = try evaluator.extractCSS("div[", html: html) + Issue.record("应拒绝未闭合的 CSS selector") + } catch let error as RuleEngineError { + #expect(!error.localizedDescription.contains("SwiftSoup.Exception error 0")) + #expect(error.localizedDescription.contains("CSS 规则执行失败")) + } + } + + @Test func supportsLegacyDiscreteNegativeIndexes() throws { + #expect(try evaluator.extractCSS("tag.li.-1:0@class.title@text", html: html) == ["B", "A & One"]) + #expect(try evaluator.selectCSSFragments("li.book[-1]", html: html).count == 1) + } + + @Test func htmlAndAllCombineOuterHTMLAndDeduplicateAttributes() throws { + let value = """ +
    A
    +
    B
    + onetwo + """ + + let sanitized = try evaluator.extractCSS(".item@html", html: value) + let all = try evaluator.extractCSS(".item@all", html: value) + let links = try evaluator.extractCSS("a.same@href", html: value) + + #expect(sanitized.count == 1) + #expect(sanitized[0].contains("
    ") == true) + #expect(sanitized[0].contains("script") == false) + #expect(sanitized[0].contains("style") == false) + #expect(all.count == 1) + #expect(links == ["/same"]) + } + + @Test func negativeRangeStepUsesListLengthAdjustment() throws { + let value = "

    0

    1

    2

    3

    4

    " + + #expect(try evaluator.extractCSS("p[0:4:-2]@text", html: value) == ["0", "3"]) + } + + @Test func rangeWhollyOutsideOneSideIsIgnoredInsteadOfClamped() throws { + let value = "

    0

    1

    2

    " + + #expect(try evaluator.extractCSS("p[8:9]@text", html: value).isEmpty) + #expect(try evaluator.extractCSS("p[-9:-8]@text", html: value).isEmpty) + #expect(try evaluator.extractCSS("p[-9:1]@text", html: value) == ["0", "1"]) + } +} diff --git a/Tests/LegadoCoreTests/HostsHTTPTransportTests.swift b/Tests/LegadoCoreTests/HostsHTTPTransportTests.swift new file mode 100644 index 0000000..01e2f2c --- /dev/null +++ b/Tests/LegadoCoreTests/HostsHTTPTransportTests.swift @@ -0,0 +1,142 @@ +import Foundation +import Network +import Testing +@testable import LegadoCore + +struct HostsHTTPTransportTests { + let url = URL(string: "https://books.example.com/chapter")! + + @Test func handlesPartialContentLengthAndHeadResponses() throws { + let partial = Data("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhel".utf8) + #expect(try HostsHTTPResponseDecoder.decode(partial, url: url) == nil) + var complete = partial + complete.append(Data("lo".utf8)) + let response = try HostsHTTPResponseDecoder.decode(complete, url: url) + #expect(response?.decodedText == "hello") + #expect(response?.finalURL == url) + #expect(try HostsHTTPResponseDecoder.decode(partial, url: url, method: "HEAD")?.data.isEmpty == true) + } + + @Test func handlesChunkExtensionsTrailersAndInterimResponses() throws { + let raw = "HTTP/1.1 100 Continue\r\n\r\nHTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n3;ext=a\r\nhel\r\n2\r\nlo\r\n0\r\nX-Trailer: yes\r\n\r\n" + #expect(try HostsHTTPResponseDecoder.decode(Data(raw.utf8), url: url)?.decodedText == "hello") + #expect(try HostsHTTPResponseDecoder.decode(Data(raw.dropLast(2).utf8), url: url) == nil) + } + + @Test func rejectsMalformedFramesAndHandlesConnectionClose() throws { + let raw = "HTTP/1.1 200 OK\r\n\r\nbody" + #expect(try HostsHTTPResponseDecoder.decode(Data(raw.utf8), url: url) == nil) + #expect(try HostsHTTPResponseDecoder.decode(Data(raw.utf8), url: url, complete: true)?.decodedText == "body") + for malformed in ["Content-Length: -1\r\n\r\n", "Transfer-Encoding: chunked\r\n\r\n\r\n", "Transfer-Encoding: chunked\r\n\r\n-1\r\n", "Transfer-Encoding: chunked\r\n\r\n1\r\nxXX"] { + #expect(throws: (any Error).self) { + _ = try HostsHTTPResponseDecoder.decode(Data(("HTTP/1.1 200 OK\r\n" + malformed).utf8), url: url) + } + } + } + + @Test func hostsOverrideConnectsToIPAndRetainsOriginHost() async throws { + let server = try HostsLoopbackServer() + let port = try await server.start() + defer { server.stop() } + let origin = URL(string: "http://books.invalid:\(port)/chapter?lang=zh")! + let response = try await HostsHTTPTransport.data(for: URLRequest(url: origin, timeoutInterval: 3), address: "127.0.0.1", ignoreCertificateErrors: false) + #expect(response.finalURL == origin) + #expect(response.decodedText.contains("GET /chapter?lang=zh HTTP/1.1")) + #expect(response.decodedText.contains("Host: books.invalid:\(port)")) + } + + @Test func redirectIntoHostsUsesOverrideAndDropsCrossOriginCredentials() async throws { + let server = try HostsLoopbackServer() + let port = try await server.start() + defer { server.stop() } + let suite = "legado.hosts.redirect.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let store = ApplicationSettingsStore(defaults: defaults) + var settings = ApplicationSettings() + settings.hosts = "127.0.0.1 books.invalid" + try store.save(settings) + let transport = URLSessionHTTPTransport(settingsStore: store) + var request = URLRequest(url: URL(string: "http://127.0.0.1:\(port)/redirect")!, timeoutInterval: 3) + request.setValue("Bearer PRIVATE", forHTTPHeaderField: "Authorization") + request.setValue("origin=PRIVATE", forHTTPHeaderField: "Cookie") + let response = try await transport.data(for: request) + #expect(response.finalURL.host == "books.invalid") + #expect(response.finalURL.path == "/chapter") + #expect(response.decodedText.contains("Host: books.invalid:\(port)")) + #expect(!response.decodedText.contains("PRIVATE")) + } + + @Test func databaseCompactionPreservesUserData() async throws { + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let database = try LegadoDatabase(url: directory.appending(path: "test.sqlite3")) + let sources = [BookSource(bookSourceUrl: "https://example.com", bookSourceName: "test")] + try await database.replaceBookSources(sources) + try await database.compact() + #expect(try await database.loadBookSources() == sources) + } +} + +private final class HostsLoopbackServer: @unchecked Sendable { + private let queue = DispatchQueue(label: "legado.hosts.loopback.test") + private let listener: NWListener + private var continuation: CheckedContinuation? + + init() throws { + let parameters = NWParameters.tcp + parameters.requiredLocalEndpoint = .hostPort(host: "127.0.0.1", port: .any) + listener = try NWListener(using: parameters) + } + + func start() async throws -> UInt16 { + try await withCheckedThrowingContinuation { continuation in + queue.async { + self.continuation = continuation + self.listener.stateUpdateHandler = { [weak self] state in + guard let self, let continuation = self.continuation else { return } + if case .ready = state, let port = self.listener.port { + self.continuation = nil + continuation.resume(returning: port.rawValue) + } else if case .failed(let error) = state { + self.continuation = nil + continuation.resume(throwing: error) + } + } + self.listener.newConnectionHandler = { connection in + connection.start(queue: self.queue) + Self.receive(connection, buffer: Data()) + } + self.listener.start(queue: self.queue) + self.queue.asyncAfter(deadline: .now() + 3) { + guard let continuation = self.continuation else { return } + self.continuation = nil + self.listener.cancel() + continuation.resume(throwing: URLError(.timedOut)) + } + } + } + } + + func stop() { listener.cancel() } + + private static func receive(_ connection: NWConnection, buffer: Data) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { data, _, complete, error in + var buffer = buffer + if let data { buffer.append(data) } + if buffer.range(of: Data("\r\n\r\n".utf8)) != nil { + let text = String(decoding: buffer, as: UTF8.self) + var response: Data + if text.hasPrefix("GET /redirect "), let port = connection.currentPath?.localEndpoint, + case .hostPort(_, let localPort) = port { + response = Data("HTTP/1.1 302 Found\r\nLocation: http://books.invalid:\(localPort.rawValue)/chapter\r\nContent-Length: 0\r\n\r\n".utf8) + } else { + response = Data("HTTP/1.1 200 OK\r\nContent-Length: \(buffer.count)\r\n\r\n".utf8) + response.append(buffer) + } + connection.send(content: response, completion: .contentProcessed { _ in connection.cancel() }) + } else if complete || error != nil || buffer.count > 65536 { connection.cancel() } + else { receive(connection, buffer: buffer) } + } + } +} diff --git a/Tests/LegadoCoreTests/ImportedSourcePipelineTests.swift b/Tests/LegadoCoreTests/ImportedSourcePipelineTests.swift new file mode 100644 index 0000000..91a0083 --- /dev/null +++ b/Tests/LegadoCoreTests/ImportedSourcePipelineTests.swift @@ -0,0 +1,47 @@ +import Foundation +import Testing +@testable import LegadoCore + +private struct ImportedSourceTransport: HTTPTransport { + func data(for request: URLRequest) async throws -> HTTPResponse { + let url = try #require(request.url) + let text: String + switch url.path { + case "/search": + text = #"{"books":[{"name":"第一本","url":"/book/1"},{"name":"第二本","url":"/book/2"}]}"# + case "/book/2": + text = #"

    第二本

    目录"# + case "/toc/2": + text = #"
  • 第一章
  • "# + case "/chapter/2": text = "
    导入后正文解析成功
    " + default: throw URLError(.fileDoesNotExist) + } + return .init(data: Data(text.utf8), statusCode: 200, headers: [:], finalURL: url) + } +} + +struct ImportedSourcePipelineTests { + @Test func importedAndEditedSourceRunsSearchDetailTocAndContentInOrder() async throws { + let json = #"{"bookSourceUrl":"https://pipeline.test","bookSourceName":"导入源","searchUrl":"/search?q={{key}}","ruleSearch":"{\"bookList\":\"-$.books\",\"name\":\"$.name\",\"bookUrl\":\"$.url\"}","ruleBookInfo":{"name":"@CSS:h1@text","tocUrl":"@CSS:a.toc@href"},"ruleToc":{"chapterList":"@CSS:li","chapterName":"@CSS:a@text","chapterUrl":"@CSS:a@href"},"ruleContent":{"content":"@CSS:missing@text","future":true}}"# + let imported = try #require(BookSourceImporter().decode(Data(json.utf8)).first) + var draft = try BookSourceEditorDocument(source: imported) + draft.set(.string("@CSS:article@text"), at: "ruleContent.content") + let saved = try JSONDecoder().decode(BookSource.self, from: JSONEncoder().encode(draft.source())) + let report = await BookSourceDebugger(transport: ImportedSourceTransport()).run(source: saved, keyword: "任意") + #expect(report.passed) + #expect(report.sampleBookName == "第二本") + #expect(report.sampleChapterTitle == "第一章") + #expect(report.contentPreview == "导入后正文解析成功") + #expect(saved.additionalFields["ruleContent"]?["future"] == .bool(true)) + } + + @Test func plusListPrefixKeepsSearchOrder() async throws { + var source = BookSource(bookSourceUrl: "https://pipeline.test", bookSourceName: "正序") + source.searchUrl = "/search?q={{key}}" + source.ruleSearch = .init(bookList: "+$.books", name: "$.name", bookUrl: "$.url") + var iterator = BookSearchService(transport: ImportedSourceTransport()).searchOutcomes(keyword: "任意", sources: [source]).makeAsyncIterator() + let outcome = try #require(await iterator.next()) + #expect(outcome.books.map(\.name) == ["第一本", "第二本"]) + #expect(outcome.diagnostics.isEmpty) + } +} diff --git a/Tests/LegadoCoreTests/JSONPathEvaluatorTests.swift b/Tests/LegadoCoreTests/JSONPathEvaluatorTests.swift new file mode 100644 index 0000000..449eb4a --- /dev/null +++ b/Tests/LegadoCoreTests/JSONPathEvaluatorTests.swift @@ -0,0 +1,48 @@ +import Foundation +import Testing +@testable import LegadoCore + +struct JSONPathEvaluatorTests { + private let evaluator = JSONPathEvaluator() + + private var fixture: JSONValue { + get throws { + try JSONValue.decode(data: Data( + """ + { + "store": { + "books": [ + {"title":"A","price":10,"tags":["x","y"]}, + {"title":"B","price":20,"tags":["z"]}, + {"title":"C","price":30,"tags":[]} + ] + }, + "title":"Root" + } + """.utf8 + )) + } + } + + @Test func evaluatesChildrenWildcardsAndNegativeIndex() throws { + #expect(try evaluator.evaluate(path: "$.store.books[*].title", in: fixture).map(\.stringValue) == ["A", "B", "C"]) + #expect(try evaluator.evaluate(path: "$.store.books[-1].title", in: fixture).map(\.stringValue) == ["C"]) + } + + @Test func evaluatesUnionSliceAndRecursiveDescent() throws { + #expect(try evaluator.evaluate(path: "$.store.books[0,2].title", in: fixture).map(\.stringValue) == ["A", "C"]) + #expect(try evaluator.evaluate(path: "$.store.books[0:3:2].title", in: fixture).map(\.stringValue) == ["A", "C"]) + #expect(try evaluator.evaluate(path: "$..title", in: fixture).map(\.stringValue) == ["Root", "A", "B", "C"]) + } + + @Test func filtersByComparisonExistenceAndBooleanLogic() throws { + #expect(try evaluator.evaluate(path: "$.store.books[?(@.price > 10)].title", in: fixture).map(\.stringValue) == ["B", "C"]) + #expect(try evaluator.evaluate(path: "$.store.books[?(@.price >= 20 && @.price < 30)].title", in: fixture).map(\.stringValue) == ["B"]) + #expect(try evaluator.evaluate(path: "$.store.books[?(@.tags[0])].title", in: fixture).map(\.stringValue) == ["A", "B"]) + } + + @Test func filtersByRegexRootReferenceAndNestedBrackets() throws { + #expect(try evaluator.evaluate(path: "$.store.books[?(@.title =~ /a|b/i)].title", in: fixture).map(\.stringValue) == ["A", "B"]) + #expect(try evaluator.evaluate(path: "$.store.books[?(@.title != $.title && (@.price == 10 || @.price == 30))].title", in: fixture).map(\.stringValue) == ["A", "C"]) + } +} diff --git a/Tests/LegadoCoreTests/JavaScriptRuleEvaluatorTests.swift b/Tests/LegadoCoreTests/JavaScriptRuleEvaluatorTests.swift new file mode 100644 index 0000000..fee22cb --- /dev/null +++ b/Tests/LegadoCoreTests/JavaScriptRuleEvaluatorTests.swift @@ -0,0 +1,500 @@ +import Foundation +import Testing +@testable import LegadoCore + +private struct JavaScriptNetworkMock: JavaScriptNetworkLoading { + func load(_ request: URLRequest) throws -> HTTPResponse { + #expect(request.url?.absoluteString == "https://js-network.example.test/api?page=2") + #expect(request.httpMethod == "POST") + #expect(String(data: request.httpBody ?? Data(), encoding: .utf8) == #"{"id":7}"#) + #expect(request.value(forHTTPHeaderField: "X-Source") == "source") + #expect(request.value(forHTTPHeaderField: "X-Extra") == "extra") + let url = try #require(request.url) + return .init( + data: Data(#"{"ok":true}"#.utf8), + statusCode: 201, + headers: ["X-Reply": "yes"], + finalURL: url + ) + } +} + +private final class RetryingLegacyJavaScriptNetworkMock: JavaScriptNetworkLoading, @unchecked Sendable { + private let lock = NSLock() + private var count = 0 + + func load(_ request: URLRequest) throws -> HTTPResponse { + lock.lock() + count += 1 + let current = count + lock.unlock() + if current == 1 { + return .init( + data: Data("temporary".utf8), + statusCode: 503, + headers: [:], + finalURL: try #require(request.url) + ) + } + #expect(request.value(forHTTPHeaderField: "Cookie")?.contains("explicit=yes") == true) + let encoding = try #require(HTTPTextDecoder.encoding(named: "gb18030")) + return .init( + data: try #require("旧编码辅助请求".data(using: encoding)), + statusCode: 200, + headers: ["Set-Cookie": "session=js-ok; Path=/"], + finalURL: try #require(request.url) + ) + } + + func calls() -> Int { + lock.lock() + defer { lock.unlock() } + return count + } +} + +private struct JavaScriptAjaxAllMock: JavaScriptNetworkLoading { + func load(_ request: URLRequest) throws -> HTTPResponse { + let url = try #require(request.url) + return .init( + data: Data("body:\(url.lastPathComponent)".utf8), + statusCode: 200, + headers: [:], + finalURL: url + ) + } +} + +private final class JavaScriptDirectRequestMock: JavaScriptRedirectNetworkLoading, @unchecked Sendable { + private let lock = NSLock() + private var recorded: [(URLRequest, Bool)] = [] + + func load(_ request: URLRequest) throws -> HTTPResponse { + try load(request, followRedirects: true) + } + + func load(_ request: URLRequest, followRedirects: Bool) throws -> HTTPResponse { + lock.lock() + recorded.append((request, followRedirects)) + lock.unlock() + let url = try #require(request.url) + return .init( + data: Data("reply-\(request.httpMethod ?? "")".utf8), + statusCode: request.httpMethod == "GET" ? 302 : 200, + headers: ["X-Reply": "direct"], + finalURL: url + ) + } + + func calls() -> [(URLRequest, Bool)] { + lock.lock() + defer { lock.unlock() } + return recorded + } +} + +struct JavaScriptRuleEvaluatorTests { + @Test func decryptsSourceContentUsingRhinoPackageImportsAndAESByteArrays() throws { + // OpenSSL AES-128-CBC/PKCS7 fixture, with a 16-byte IV prepended. + let script = #""" + var javaImport = new JavaImporter(); + javaImport.importPackage(Packages.java.lang, Packages.javax.crypto.spec, + Packages.javax.crypto, Packages.java.util); + with (javaImport) { + var encrypted = Base64.getDecoder().decode('ZmVkY2JhOTg3NjU0MzIxMLgd3vmXCW/KPKUrbea1zRjjTRawCuSDRVQflh/mHcYAv/gm12GQKUL0NhitscZMdw=='); + var key = SecretKeySpec(String('0123456789abcdef').getBytes(), 'AES'); + var iv = IvParameterSpec(Arrays.copyOfRange(encrypted, 0, 16)); + var cipher = Cipher.getInstance('AES/CBC/PKCS5Padding'); + cipher.init(Cipher.DECRYPT_MODE, key, iv); + var decoded = String(cipher.doFinal(Arrays.copyOfRange(encrypted, 16, encrypted.length))); + cipher.init(Cipher.ENCRYPT_MODE, key, iv); + var encoded = Base64.getEncoder().encodeToString(cipher.doFinal(decoded.getBytes())); + decoded + '|' + encoded; + } + """# + let value = try JavaScriptRuleEvaluator().evaluate(script: script, result: .null, executionContext: .init()) + #expect(value.value.stringValue == "第一段正文\n第二段正文。|uB3e+ZcJb8o8pStt5rXNGONNFrAK5INFVB+WH+YdxgC/+CbXYZApQvQ2GK2xxkx3") + } + + @Test func javaCipherRejectsInvalidInputsAndUnsupportedPackageImports() throws { + for script in [ + "var i = new JavaImporter(); i.importPackage(Packages.java.io);", + "Packages.javax.crypto.Cipher.getInstance('DES/ECB/NoPadding');", + "var i = new JavaImporter(Packages.java.lang, Packages.javax.crypto.spec, Packages.javax.crypto); with(i) { var c = Cipher.getInstance('AES/CBC/PKCS5Padding'); c.init(2, SecretKeySpec(String('short').getBytes(), 'AES'), IvParameterSpec(String('0123456789abcdef').getBytes())); c.doFinal([1, 2, 3]); }" + ] { + #expect(throws: RuleEngineError.self) { + try JavaScriptRuleEvaluator().evaluate(script: script, result: .null, executionContext: .init()) + } + } + } + + @Test func importedAESHelperDeclaredInsideWithRetainsJavaStringByteDecoding() throws { + let script = #""" + var javaImport = new JavaImporter(); + javaImport.importPackage(Packages.java.lang, Packages.javax.crypto.spec, + Packages.javax.crypto, Packages.java.util); + with (javaImport) { + function decode(content) { + var bytes = Base64.getDecoder().decode(String(content)); + var key = SecretKeySpec(String('0123456789abcdef').getBytes(), 'AES'); + var iv = IvParameterSpec(Arrays.copyOfRange(bytes, 0, 16)); + var cipher = Cipher.getInstance('AES/CBC/PKCS5Padding'); + cipher.init(2, key, iv); + return String(cipher.doFinal(Arrays.copyOfRange(bytes, 16, bytes.length))); + } + } + decode('ZmVkY2JhOTg3NjU0MzIxMLgd3vmXCW/KPKUrbea1zRjjTRawCuSDRVQflh/mHcYAv/gm12GQKUL0NhitscZMdw=='); + """# + let value = try JavaScriptRuleEvaluator().evaluate(script: script, result: .null, executionContext: .init()) + #expect(value.value.stringValue == "第一段正文\n第二段正文。") + } + + @Test func supportsBundledHttpTTSJavaImporterHmacSHA1Script() throws { + let script = #""" + var 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 + ); + with (aly) { + var mac = Mac.getInstance('HmacSHA1'); + mac.init(new SecretKeySpec(String('key').getBytes('UTF-8'), 'HmacSHA1')); + var signature = Base64.encodeToString( + mac.doFinal(String('The quick brown fox jumps over the lazy dog').getBytes('UTF-8')), + Base64.NO_WRAP + ); + signature + ':' + URLEncoder.encode('a b*~中', 'UTF-8'); + } + """# + + let value = try JavaScriptRuleEvaluator().evaluate( + script: script, + result: .null, + executionContext: .init() + ) + + #expect(value.value.stringValue == "3nybhbi3iqa8ino29wqQcBydtNk=:a+b*%7E%E4%B8%AD") + } + + @Test func javaImporterDoesNotExposeArbitraryNativeClasses() throws { + let value = try JavaScriptRuleEvaluator().evaluate( + script: "typeof Packages.java.io + ':' + typeof Packages.java.lang.Runtime + ':' + Object.isFrozen(Packages)", + result: .null, + executionContext: .init() + ) + + #expect(value.value.stringValue == "undefined:undefined:true") + } + + @Test func exposesDollarAsCurrentJSONForLegadoInlineExpressions() throws { + let value = try JavaScriptRuleEvaluator().evaluate( + script: "$.book_id", + result: .object(["book_id": .string("42")]), + executionContext: .init() + ) + + #expect(value.value.stringValue == "42") + } + + @Test func exposesJavaGetStringRuleBridge() throws { + let evaluator = JavaScriptRuleEvaluator() + let html = #""# + + let value = try evaluator.evaluate( + script: "java.getString('a@text', result) + ':' + java.getString('a@href', result, true)", + result: .string(html), + executionContext: .init(baseURL: "https://reader.example/book/") + ) + let directJSON = try evaluator.evaluate( + script: "java.getString('name', result) + ':' + java.getString('$.id', result)", + result: .object(["name": .string("书名"), "id": .number(42)]), + executionContext: .init() + ) + + #expect(value.value.stringValue == "第一章:https://reader.example/chapter/1") + #expect(directJSON.value.stringValue == "书名:42") + } + + @Test func jsonParseKeepsPlainTextResponsesButStillRejectsBrokenJSON() throws { + let evaluator = JavaScriptRuleEvaluator() + let plain = try evaluator.evaluate( + script: "JSON.parse(result)", + result: .string("The server is temporarily unavailable"), + executionContext: .init() + ) + + #expect(plain.value.stringValue == "The server is temporarily unavailable") + #expect(throws: RuleEngineError.self) { + _ = try evaluator.evaluate( + script: "JSON.parse(result)", + result: .string(#"{"broken":}"#), + executionContext: .init() + ) + } + } + + @Test func directJavaGetResolvesRelativeURLAndAppliesSourceHeaders() throws { + var source = BookSource(bookSourceUrl: "https://direct.example.test/base/", bookSourceName: "相对请求") + source.header = #"{"X-Source":"yes"}"# + let loader = JavaScriptDirectRequestMock() + let evaluator = JavaScriptRuleEvaluator(networkLoader: loader) + + _ = try evaluator.evaluate( + script: "java.get('/chapter/1').body()", + result: .null, + executionContext: .init(baseURL: source.bookSourceUrl, source: source) + ) + + let call = try #require(loader.calls().first) + #expect(call.0.url?.absoluteString == "https://direct.example.test/chapter/1") + #expect(call.0.value(forHTTPHeaderField: "X-Source") == "yes") + #expect(call.0.value(forHTTPHeaderField: "User-Agent") != nil) + } + + @Test func loadsSharedSourceJavaScriptLibrary() throws { + var source = BookSource(bookSourceUrl: "https://js-lib.example.test", bookSourceName: "共享脚本源") + source.jsLib = "function decorate(value) { return 'lib:' + value; }" + + let value = try JavaScriptRuleEvaluator().evaluate( + script: "decorate(result)", + result: .string("ok"), + executionContext: .init(source: source) + ) + + #expect(value.value.stringValue == "lib:ok") + } + @Test func providesAjaxAndConnectWithLegadoURLOptions() throws { + var source = BookSource(bookSourceUrl: "https://js-network.example.test", bookSourceName: "JS 网络源") + source.header = #"{"X-Source":"source"}"# + let evaluator = JavaScriptRuleEvaluator(networkLoader: JavaScriptNetworkMock()) + let url = #"/api?page=2,{"method":"POST","body":{"id":7},"headers":{"X-Extra":"extra"}}"# + + let ajax = try evaluator.evaluate( + script: "java.ajax(result)", + result: .string(url), + executionContext: .init(baseURL: source.bookSourceUrl, source: source) + ) + let connect = try evaluator.evaluate( + script: "var r=java.connect(result); r.body + ':' + r.code + ':' + r.headers['X-Reply']", + result: .string(url), + executionContext: .init(baseURL: source.bookSourceUrl, source: source) + ) + + #expect(ajax.value.stringValue == #"{"ok":true}"#) + #expect(connect.value.stringValue == #"{"ok":true}:201:yes"#) + } + @Test func ajaxUsesCharsetHTTPRetryCookieMergeAndBodyJavaScript() throws { + let host = "https://legacy-js.example.test" + LegadoCookieStore.shared.removeCookies(for: host) + defer { LegadoCookieStore.shared.removeCookies(for: host) } + LegadoCookieStore.shared.setCookie("stored=one; Path=/", for: host) + var source = BookSource(bookSourceUrl: host, bookSourceName: "旧编码 JS 源") + source.enabledCookieJar = true + let loader = RetryingLegacyJavaScriptNetworkMock() + let evaluator = JavaScriptRuleEvaluator(networkLoader: loader) + let rule = #"/api, {"charset":"gb18030","retry":1,"headers":{"Cookie":"explicit=yes"},"bodyJs":"result.replace('辅助', '内部')"}"# + + let value = try evaluator.evaluate( + script: "java.ajax(result) + ':' + java.getCookie('\(host)', 'session')", + result: .string(rule), + executionContext: .init(baseURL: host, source: source) + ) + + #expect(value.value.stringValue == "旧编码内部请求:js-ok") + #expect(loader.calls() == 2) + } + @Test func readsAndWritesSharedCookies() throws { + let host = "https://cookie-js-bridge.test" + LegadoCookieStore.shared.removeCookies(for: host) + defer { LegadoCookieStore.shared.removeCookies(for: host) } + let evaluator = JavaScriptRuleEvaluator() + + let value = try evaluator.evaluate( + script: "java.setCookie('\(host)', 'token=abc; Path=/'); java.getCookie('\(host)', 'token') + ':' + java.getCookie('\(host)').indexOf('token=abc')", + result: .null, + executionContext: .init() + ) + + #expect(value.value.stringValue == "abc:0") + #expect(LegadoCookieStore.shared.cookieHeader(for: host).contains("token=abc")) + } + @Test func exposesGlobalCookieStore() throws { + // Use a separate registrable domain because Swift Testing runs these + // cookie-store tests concurrently. + let host = "https://global-cookie.test" + LegadoCookieStore.shared.removeCookies(for: host) + defer { LegadoCookieStore.shared.removeCookies(for: host) } + + let value = try JavaScriptRuleEvaluator().evaluate( + script: "cookie.setCookie('\(host)', 'token=abc; Path=/'); var map=cookie.cookieToMap(cookie.getCookie('\(host)')); var header=cookie.mapToCookie(map); cookie.getKey('\(host)', 'token') + ':' + header", + result: .null, + executionContext: .init() + ) + + #expect(value.value.stringValue == "abc:token=abc") + + let removed = try JavaScriptRuleEvaluator().evaluate( + script: "cookie.removeCookie('\(host)'); cookie.getCookie('\(host)')", + result: .null, + executionContext: .init() + ) + #expect(removed.value.stringValue.isEmpty) + } + @Test func ajaxAllReturnsResponseObjectsInInputOrder() throws { + let evaluator = JavaScriptRuleEvaluator(networkLoader: JavaScriptAjaxAllMock()) + + let value = try evaluator.evaluate( + script: "var rows=java.ajaxAll(['https://ajax-all.test/a','https://ajax-all.test/b'], false); rows[0].body + '|' + rows[1].url", + result: .null, + executionContext: .init() + ) + + #expect(value.value.stringValue == "body:a|https://ajax-all.test/b") + } + @Test func getHeadPostExposeJsoupStyleResponseAndDisableRedirects() throws { + let loader = JavaScriptDirectRequestMock() + let evaluator = JavaScriptRuleEvaluator(networkLoader: loader) + + let value = try evaluator.evaluate( + script: "var g=java.get('https://direct.test/get', {'X-Test':'yes'}, 5000); var h=java.head('https://direct.test/head', {}); var p=java.post('https://direct.test/post', 'raw=正文', {'Content-Type':'text/plain'}); g.statusCode() + ':' + g.header('x-reply') + ':' + h.body() + ':' + p.body()", + result: .null, + executionContext: .init() + ) + + let calls = loader.calls() + #expect(value.value.stringValue == "302:direct:reply-HEAD:reply-POST") + #expect(calls.count == 3) + #expect(calls.allSatisfy { !$0.1 }) + #expect(calls[0].0.value(forHTTPHeaderField: "X-Test") == "yes") + #expect(calls[1].0.httpBody == nil) + #expect(String(decoding: calls[2].0.httpBody ?? Data(), as: UTF8.self) == "raw=正文") + } + @Test func executesPureJavaScriptAndJSONPathPipeline() async throws { + let engine = RuleEngine() + let root = try JSONValue.decode(data: Data(#"{"id":42,"name":"测试"}"#.utf8)) + + let pure = await engine.evaluate("@js:result.name + '-JS'", in: root) + #expect(pure.firstString == "测试-JS") + #expect(pure.diagnostics.isEmpty) + + let pipeline = await engine.evaluate( + "$.id@js:java.put('bookId', result); '/book/' + result", + in: root + ) + #expect(pipeline.firstString == "/book/42") + #expect(pipeline.variables["bookId"] == "42") + #expect(pipeline.diagnostics.isEmpty) + } + + @Test func carriesVariablesAndProvidesLegadoHelpers() async { + let engine = RuleEngine() + let context = RuleExecutionContext(variables: ["bookId": "1001"]) + + let result = await engine.evaluate( + "@js:java.md5Encode(java.get('bookId')).length + ':' + java.get('bookId')", + in: .null, + context: context + ) + + #expect(result.firstString == "32:1001") + #expect(result.variables["bookId"] == "1001") + } + + @Test func providesByteHexTimeAndUUIDHelpers() throws { + let value = try JavaScriptRuleEvaluator().evaluate( + script: "java.bytesToStr(java.strToBytes('正文')) + ':' + java.hexDecodeToString(java.hexEncodeToString('A')) + ':' + java.timeFormatUTC(0, 'yyyy-MM-dd HH:mm', 0) + ':' + java.randomUUID().length", + result: .null, + executionContext: .init() + ) + + #expect(value.value.stringValue == "正文:A:1970-01-01 00:00:36") + } + + @Test func exposesSourceTagBase64BytesAndStableInstallationIdentifier() throws { + let source = BookSource(bookSourceUrl: "https://bridge-source.test", bookSourceName: "桥接源") + let evaluator = JavaScriptRuleEvaluator() + + let value = try evaluator.evaluate( + script: "java.getSource().bookSourceName + ':' + java.getTag() + ':' + java.bytesToStr(java.base64DecodeToByteArray('b2s=')) + ':' + (java.androidId() === java.androidId())", + result: .null, + executionContext: .init(source: source) + ) + + #expect(value.value.stringValue == "桥接源:https://bridge-source.test:ok:true") + } + + @Test func exposesEntityPropertiesAndFormStyleEncodeURI() throws { + var book = Book(bookUrl: "https://book.test/1", name: "书名", author: "作者") + book.kind = "玄幻" + let chapter = BookChapter(url: "/chapter/1", title: "第一章", bookUrl: book.bookUrl, index: 3) + + let value = try JavaScriptRuleEvaluator().evaluate( + script: "book.name + ':' + book.kind + ':' + chapter.title + ':' + chapter.index + ':' + java.encodeURI('a b&中')", + result: .null, + executionContext: .init(book: book, chapter: chapter) + ) + + #expect(value.value.stringValue == "书名:玄幻:第一章:3:a+b%26%E4%B8%AD") + } + + @Test func keepsJavaSpecialVariablesAndSourceVariablesInSeparateScopes() throws { + let source = BookSource(bookSourceUrl: "https://variable-source.test", bookSourceName: "变量源") + let book = Book(bookUrl: "https://book.test/variable", name: "变量书") + let chapter = BookChapter(url: "/one", title: "变量章", bookUrl: book.bookUrl, index: 0) + let evaluator = JavaScriptRuleEvaluator() + + let first = try evaluator.evaluate( + script: "source.put('token','persisted'); java.get('bookName') + ':' + java.get('title')", + result: .null, + executionContext: .init(source: source, book: book, chapter: chapter) + ) + let second = try evaluator.evaluate( + script: "source.get('token')", + result: .null, + executionContext: .init(source: source) + ) + + #expect(first.value.stringValue == "变量书:变量章") + #expect(second.value.stringValue == "persisted") + } + + @Test func resolvesDynamicLenientAndLoginHeadersByPriority() throws { + var source = BookSource(bookSourceUrl: "https://header-source.test", bookSourceName: "Header源") + source.header = #"@js:"{'X-Static':'base','X-Dynamic':'js'}""# + BookSourceRuntimeStore.shared.put( + sourceID: source.id, + key: "loginHeader", + value: #"{"X-Static":"login","Cookie":"token=ok"}"# + ) + + let headers = try BookSourceHeaderResolver().resolve(source) + + #expect(headers["X-Dynamic"] == "js") + #expect(headers["X-Static"] == "login") + #expect(headers["Cookie"] == "token=ok") + } + + @Test func exposesSourceLoginInfoMapAndHeaderMap() throws { + var source = BookSource(bookSourceUrl: "httpTts:99", bookSourceName: "在线朗读") + source.header = #"{"X-Base":"one"}"# + BookSourceRuntimeStore.shared.put(sourceID: source.id, key: "loginHeader", value: #"{"X-Login":"two"}"#) + + let value = try JavaScriptRuleEvaluator().evaluate( + script: "source.putLoginInfo(JSON.stringify({token:'abc'})); source.getLoginInfoMap().get('token') + ':' + source.getHeaderMap(true)['X-Login'] + ':' + source.getKey()", + result: .null, + executionContext: .init(source: source) + ) + + #expect(value.value.stringValue == "abc:two:httpTts:99") + } + + @Test func rejectsObviousUnboundedScripts() async { + let result = await RuleEngine().evaluate("@js:while(true){}", in: .null) + + #expect(result.values.isEmpty) + #expect(result.diagnostics.contains { $0.code == "rule.resource_limit" }) + } +} diff --git a/Tests/LegadoCoreTests/LegadoDatabaseTests.swift b/Tests/LegadoCoreTests/LegadoDatabaseTests.swift new file mode 100644 index 0000000..86dcb19 --- /dev/null +++ b/Tests/LegadoCoreTests/LegadoDatabaseTests.swift @@ -0,0 +1,472 @@ +import Foundation +import Testing +@testable import LegadoCore + +struct LegadoDatabaseTests { + @Test func decodesBookAndChapterFieldsWithoutBreakingLegacyPayloads() throws { + let payload = Data(#""" + { + "bookUrl":"book","name":"书","durVolumeIndex":2,"chapterInVolumeIndex":4, + "readConfig":{"reverseToc":true,"dailyChapters":5,"playSpeed":1.25} + } + """#.utf8) + let book = try JSONDecoder().decode(Book.self, from: payload) + #expect(book.durVolumeIndex == 2) + #expect(book.chapterInVolumeIndex == 4) + #expect(book.readConfig?.reverseToc == true) + #expect(book.readConfig?.dailyChapters == 5) + + let chapter = try JSONDecoder().decode( + BookChapter.self, + from: Data(#"{"url":"/1","bookUrl":"book","startFragmentId":"a","endFragmentId":"b","imgUrl":"cover"}"#.utf8) + ) + #expect(chapter.startFragmentId == "a") + #expect(chapter.endFragmentId == "b") + #expect(chapter.imageURL == "cover") + let encoded = String(decoding: try JSONEncoder().encode(chapter), as: UTF8.self) + #expect(encoded.contains("\"imgUrl\":\"cover\"")) + } + + @Test func storesSourcesBooksAndChapterKeys() async throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString, directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: directory) } + let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3")) + let sources = SQLiteBookSourceRepository(database: database) + let books = SQLiteBookRepository(database: database) + let chapters = SQLiteBookChapterRepository(database: database) + + var source = BookSource(bookSourceUrl: "https://source.example", bookSourceName: "示例") + source.customOrder = 7 + try await sources.save([source]) + + var book = Book(bookUrl: "https://source.example/book/1", name: "书", author: "作者", origin: source.bookSourceUrl) + book.durChapterPos = 42 + try await books.save([book]) + + var first = BookChapter(url: "/1", title: "第一章", bookUrl: book.bookUrl, index: 99) + first.variable = #"{"token":"x"}"# + let second = BookChapter(url: "/2", title: "第二章", bookUrl: book.bookUrl, index: 100) + try await chapters.save([first, second], bookURL: book.bookUrl) + + #expect(try await sources.load() == [source]) + #expect(try await books.load() == [book]) + let stored = try await chapters.load(bookURL: book.bookUrl) + #expect(stored.map(\.index) == [0, 1]) + #expect(stored.first?.variable == first.variable) + + try await books.save([]) + #expect(try await chapters.load(bookURL: book.bookUrl).isEmpty) + } + + @Test func importsLegacyJSONOnlyIntoEmptyTables() async throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString, directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: directory) } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let sourceURL = directory.appending(path: "book-sources.json") + let booksURL = directory.appending(path: "books.json") + let source = BookSource(bookSourceUrl: "https://legacy.example", bookSourceName: "旧源") + let book = Book(bookUrl: "https://legacy.example/1", name: "旧书", origin: source.bookSourceUrl) + try JSONEncoder().encode([source]).write(to: sourceURL) + try JSONEncoder().encode([book]).write(to: booksURL) + + let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3")) + try await database.importLegacyJSON(bookSourcesURL: sourceURL, booksURL: booksURL) + #expect(try await database.loadBookSources() == [source]) + #expect(try await database.loadBooks() == [book]) + + try JSONEncoder().encode([BookSource(bookSourceUrl: "https://ignored", bookSourceName: "忽略")]) + .write(to: sourceURL) + try await database.importLegacyJSON(bookSourcesURL: sourceURL, booksURL: booksURL) + #expect(try await database.loadBookSources() == [source]) + } + + @Test func doesNotReimportLegacyChaptersAfterRemovingBook() async throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString, directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: directory) } + let legacyDirectory = directory.appending(path: "Chapters", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: legacyDirectory, withIntermediateDirectories: true) + let book = Book(bookUrl: "https://source.example/book", name: "书", origin: "https://source.example") + let stale = BookChapter(url: "https://source.example/old", title: "后记", bookUrl: book.bookUrl, index: 0) + try JSONEncoder().encode([stale]).write(to: legacyDirectory.appending(path: "old.json")) + + let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3")) + let books = SQLiteBookRepository(database: database) + let chapters = SQLiteBookChapterRepository(database: database, legacyDirectory: legacyDirectory) + try await books.save([book]) + #expect(try await chapters.load(bookURL: book.bookUrl).map(\.title) == ["后记"]) + + try await books.save([]) + try await books.save([book]) + #expect(try await chapters.load(bookURL: book.bookUrl).isEmpty) + } + + @Test func storesBookmarksAndCompositeReadRecords() async throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString, directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: directory) } + let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3")) + let repository = SQLiteReadingActivityRepository(database: database) + let bookmark = Bookmark(time: 123, bookName: "书", bookAuthor: "作者") + let records = [ + ReadRecord(deviceId: "A", bookName: "书", readTime: 10, lastRead: 100), + ReadRecord(deviceId: "B", bookName: "书", readTime: 20, lastRead: 200) + ] + try await repository.saveBookmarks([bookmark]) + try await repository.saveReadRecords(records) + #expect(try await repository.loadBookmarks() == [bookmark]) + #expect(try await repository.loadReadRecords() == Array(records.reversed())) + } + + @Test func storesBookGroupsInExplicitOrder() async throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString, directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: directory) } + let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3")) + let repository = SQLiteBookGroupRepository(database: database) + let groups = [ + BookGroup(groupId: 2, groupName: "二", order: 9), + BookGroup(groupId: 1, groupName: "一", order: 1) + ] + try await repository.save(groups) + #expect(try await repository.load() == groups) + } + + @Test func searchKeywordUsageOrderingUsesCountAndTimestamp() async throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString, directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: directory) } + let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3")) + let repository = SQLiteSearchKeywordRepository(database: database, legacyWords: ["旧词"]) + + #expect(try await repository.load(order: .lastUseTime).map(\.word) == ["旧词"]) + _ = try await repository.record("甲", at: 100) + _ = try await repository.record("乙", at: 200) + let updated = try await repository.record("甲", at: 300) + #expect(updated.usage == 2) + #expect(try await repository.load(order: .usage).map(\.word).first == "甲") + #expect(try await repository.load(order: .lastUseTime).map(\.word).first == "甲") + try await repository.delete("甲") + #expect(try await repository.load(order: .usage).allSatisfy { $0.word != "甲" }) + } + + @Test func preservesCacheDeadlinesSourceCleanupAndCookieKeys() async throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString, directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: directory) } + let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3")) + let caches = SQLiteLegadoCacheRepository(database: database) + let cookies = SQLiteLegadoCookieRepository(database: database) + + try await caches.put(.init(key: "alive", value: "A", deadline: 200)) + try await caches.put(.init(key: "expired", value: "E", deadline: 50)) + try await caches.put(.init(key: "loginHeader_source", value: "H")) + #expect(try await caches.value(for: "alive", now: 100) == "A") + #expect(try await caches.value(for: "expired", now: 100) == nil) + try await caches.clearExpired(now: 100) + try await caches.deleteSourceVariables("source") + #expect(try await caches.value(for: "loginHeader_source", now: 100) == nil) + + try await cookies.put(.init(url: "host", cookie: "a=1")) + try await cookies.put(.init(url: "source|example.com|/", cookie: "b=2")) + #expect(try await cookies.get("host")?.cookie == "a=1") + #expect(try await cookies.getOkHTTP().count == 1) + try await cookies.deleteOkHTTP() + #expect(try await cookies.getOkHTTP().isEmpty) + } + + @Test func searchBookStoreUsesSourceEnableGroupAndOrderForChangeSource() async throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString, directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: directory) } + let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3")) + let sourceRepository = SQLiteBookSourceRepository(database: database) + let repository = SQLiteSearchBookRepository(database: database) + var firstSource = BookSource(bookSourceUrl: "https://one", bookSourceName: "一号") + firstSource.bookSourceGroup = "文字" + firstSource.customOrder = 20 + var secondSource = BookSource(bookSourceUrl: "https://two", bookSourceName: "二号") + secondSource.bookSourceGroup = "文字,精选" + secondSource.customOrder = 10 + try await sourceRepository.save([firstSource, secondSource]) + var one = SearchBook(bookURL: "https://one/book", origin: firstSource.bookSourceUrl, originName: "一号", type: 0, name: "书", author: "作者") + one.latestChapterTitle = "第一章" + one.originOrder = 20 + var two = SearchBook(bookURL: "https://two/book", origin: secondSource.bookSourceUrl, originName: "二号", type: 0, name: "书", author: "作者") + two.coverURL = "cover" + two.originOrder = 10 + try await repository.put([one, two]) + + #expect(try await repository.first(name: "书", author: "作者")?.bookURL == two.bookURL) + let changed = try await repository.changeSources(name: "书", author: "作", sourceGroup: "文字", key: "二号") + #expect(changed.map(\.bookURL) == [two.bookURL]) + #expect(changed.first?.originOrder == 10) + #expect(try await repository.enabledWithCover(name: "书", author: "作者").map(\.bookURL) == [two.bookURL]) + + secondSource.enabled = false + try await sourceRepository.save([firstSource, secondSource]) + #expect(try await repository.enabledWithCover(name: "书", author: "作者").isEmpty) + #expect(try await repository.get(bookURL: two.bookURL) != nil) + } + + @Test func storesConfigurationEntitiesWithOrderingAndKeys() async throws { + let directory = FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString, directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: directory) } + let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3")) + let repository = SQLiteConfigurationRepository(database: database) + + let toc = [ + TxtTocRule(id: 2, name: "后", serialNumber: 2), + TxtTocRule(id: 1, name: "前", serialNumber: 1) + ] + try await repository.saveTxtTocRules(toc) + #expect(try await repository.loadTxtTocRules().map(\.id) == [1, 2]) + + let engines = [HttpTTS(id: 2, name: "B"), HttpTTS(id: 1, name: "A")] + try await repository.saveHttpTTS(engines) + #expect(try await repository.loadHttpTTS().map(\.name) == ["A", "B"]) + + let subscriptions = [ + RuleSub(id: 2, name: "后", url: "2"), + RuleSub(id: 1, name: "前", url: "1") + ] + var orderedSubscriptions = subscriptions + orderedSubscriptions[0].customOrder = 2 + orderedSubscriptions[1].customOrder = 1 + try await repository.saveRuleSubs(orderedSubscriptions) + #expect(try await repository.loadRuleSubs().map(\.id) == [1, 2]) + + let keys = [ + KeyboardAssist(type: 1, key: "b", value: "B", serialNo: 2), + KeyboardAssist(type: 0, key: "a", value: "A", serialNo: 1) + ] + try await repository.saveKeyboardAssists(keys) + #expect(try await repository.loadKeyboardAssists().map(\.key) == ["a", "b"]) + + var serverB = Server(id: 2, name: "B") + serverB.sortNumber = 2 + var serverA = Server(id: 1, name: "A") + serverA.sortNumber = 1 + try await repository.saveServers([serverB, serverA]) + #expect(try await repository.loadServers().map(\.id) == [1, 2]) + } + + @Test func decodesRssModelsAndPreservesDefaults() throws { + let source = try JSONDecoder().decode( + RssSource.self, + from: Data(#"{"sourceUrl":"https://rss","sourceName":"订阅","enabled":"false","articleStyle":"2","loginUi":{"type":"input"}}"#.utf8) + ) + #expect(source.enabled == false) + #expect(source.articleStyle == 2) + #expect(source.loginUi?.contains("input") == true) + #expect(source.enableJs == true) + + let article = try JSONDecoder().decode( + RssArticle.self, + from: Data(#"{"origin":"https://rss","sort":"all","title":"文章","link":"/1","order":"42","read":1}"#.utf8) + ) + #expect(article.order == 42) + #expect(article.read) + #expect(article.record(at: 100).record == "/1") + #expect(article.star(at: 200).starTime == 200) + } + + @Test func rssSourceRepositorySupportsGroupsSearchAndOrdering() async throws { + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: directory) } + let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3")) + let repository = SQLiteRssSourceRepository(database: database) + var later = RssSource(sourceUrl: "https://b", sourceName: "科技日报") + later.sourceGroup = "资讯,科技"; later.customOrder = 20 + var earlier = RssSource(sourceUrl: "https://a", sourceName: "新闻") + earlier.sourceGroup = "资讯"; earlier.sourceComment = "每日"; earlier.customOrder = 10 + try await repository.save([later, earlier]) + + #expect(try await repository.all().map(\.sourceUrl) == ["https://a", "https://b"]) + #expect(Set(try await repository.groups()) == Set(["资讯", "科技"])) + #expect(try await repository.inGroup("科技").map(\.sourceUrl) == ["https://b"]) + #expect(try await repository.search("每日").map(\.sourceUrl) == ["https://a"]) + try await repository.setEnabled(false, sourceURL: "https://a") + #expect(try await repository.disabled().map(\.sourceUrl) == ["https://a"]) + } + + @Test func rssArticlesUseCompositeKeyIgnoreAppendAndGlobalReadLink() async throws { + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: directory) } + let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3")) + let articles = SQLiteRssArticleRepository(database: database) + let records = SQLiteRssReadRecordRepository(database: database) + var old = RssArticle(origin: "one", sort: "all", title: "旧标题", link: "/same") + old.order = 1 + try await articles.append([old]) + var duplicate = old; duplicate.title = "不应覆盖"; duplicate.order = 99 + var secondSort = old; secondSort.sort = "other"; secondSort.title = "另一分类" + try await articles.append([duplicate, secondSort]) + + #expect(try await articles.articles(origin: "one", sort: "all").first?.title == "旧标题") + #expect(try await articles.articles(origin: "one", sort: "other").count == 1) + try await records.insertIgnoringExisting(old.record(at: 100)) + var sameLinkOtherOrigin = RssArticle(origin: "two", sort: "all", title: "同链接", link: "/same") + sameLinkOtherOrigin.order = 2 + try await articles.append([sameLinkOtherOrigin]) + #expect(try await articles.articles(origin: "two", sort: "all").first?.read == true) + try await articles.clearOld(origin: "one", sort: "all", before: 2) + #expect(try await articles.articles(origin: "one", sort: "all").isEmpty) + try await articles.updateOrigin(from: "one", to: "renamed") + #expect(try await articles.article(origin: "renamed", link: "/same", sort: "other")?.origin == "renamed") + } + + @Test func rssStarsReplaceCompositeKeyAndSortNewestFirst() async throws { + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: directory) } + let database = try LegadoDatabase(url: directory.appending(path: "legado.sqlite3")) + let stars = SQLiteRssStarRepository(database: database) + var firstArticle = RssArticle(origin: "one", sort: "all", title: "甲", link: "/1") + firstArticle.group = "技术" + var secondArticle = RssArticle(origin: "one", sort: "all", title: "乙", link: "/2") + secondArticle.group = "生活" + try await stars.save(firstArticle.star(at: 100)) + try await stars.save(secondArticle.star(at: 200)) + var replacement = firstArticle.star(at: 300); replacement.title = "甲更新" + try await stars.save(replacement) + + #expect(try await stars.all().map(\.title) == ["甲更新", "乙"]) + #expect(Set(try await stars.groups()) == Set(["技术", "生活"])) + try await stars.updateOrigin(from: "one", to: "renamed") + #expect(try await stars.star(origin: "renamed", link: "/1")?.origin == "renamed") + try await stars.remove(origin: "renamed", link: "/1") + #expect(try await stars.all().map(\.link) == ["/2"]) + } + + @Test func defaultRssParserExtractsItemFieldsAndPrioritizesImages() throws { + let xml = #""" + + 第一篇 /one + x

    ]]>
    + 正文]]> + today +
    + 第二篇/two + + + + +
    + """# + let result = try RSSDefaultParser.parse(sortName: "全部", xml: xml, sourceURL: "https://rss") + #expect(result.nextURL == nil) + #expect(result.articles.count == 2) + #expect(result.articles[0].title == "第一篇") + #expect(result.articles[0].image == "description.jpg") + #expect(result.articles[0].content?.contains("正文") == true) + #expect(result.articles[0].origin == "https://rss") + #expect(result.articles[0].sort == "全部") + #expect(result.articles[1].image == "enclosure.jpg") + #expect(result.articles[1].pubDate == "later") + } + + @Test func rssRuleParserExtractsFieldsReversesAndSupportsPageSentinel() async throws { + var source = RssSource(sourceUrl: "https://rss.example/root/", sourceName: "规则源") + source.ruleArticles = "-@CSS:.item" + source.ruleTitle = "@CSS:h2@text" + source.rulePubDate = "@CSS:time@text" + source.ruleDescription = "@CSS:p@text" + source.ruleImage = "@CSS:img@src" + source.ruleLink = "@CSS:a@href" + source.ruleNextPage = "PAGE" + source.type = 2 + let body = #""" +

    甲

    A

    go
    +

    乙

    B

    go
    + """# + let report = try await RSSRuleParser().parse( + sortName: "分类", sortURL: "https://rss.example/list?page=1", + redirectURL: "https://rss.example/list?page=1", body: body, source: source + ) + #expect(report.result.articles.map(\.title) == ["乙", "甲"]) + #expect(report.result.articles.first?.link == "https://rss.example/b") + #expect(report.result.articles.first?.image == "https://rss.example/root/b.jpg") + #expect(report.result.articles.first?.type == 2) + #expect(report.result.nextURL == "https://rss.example/list?page=1") + } + + @Test func rssSourceImporterSupportsObjectArrayManifestAndUpdateSelection() throws { + let importer = RSSSourceImporter() + let object = try importer.decode(Data(#"{"sourceUrl":"https://one","sourceName":"一"}"#.utf8)) + #expect(object.sources.map(\.sourceUrl) == ["https://one"]) + let array = try importer.decode(Data(#"[{"sourceUrl":"https://two","sourceName":"二"}]"#.utf8)) + #expect(array.sources.map(\.sourceUrl) == ["https://two"]) + let manifest = try importer.decode(Data(#"{"sourceUrls":["https://list/one.json","https://list/two.json"]}"#.utf8)) + #expect(manifest.subscriptionURLs.count == 2) + + var old = RssSource(sourceUrl: "https://one", sourceName: "旧"); old.lastUpdateTime = 100 + var newer = RssSource(sourceUrl: "https://one", sourceName: "新"); newer.lastUpdateTime = 200 + #expect(importer.defaultSelection(incoming: [newer], existing: [old]) == [true]) + let merged = try importer.merge([newer], into: [old], addGroup: "导入") + #expect(merged.first?.sourceName == "新") + #expect(merged.first?.groups.contains("导入") == true) + #expect(try importer.decode(importer.encode(merged)).sources.first?.sourceUrl == "https://one") + } + + @Test func rssSortResolverParsesSeparatorsAndFallback() async { + var source = RssSource(sourceUrl: "https://rss", sourceName: "源") + source.sortUrl = "新闻::/news&&科技::/tech\n生活::/life" + let sorts = await RSSSortResolver().resolve(source) + #expect(sorts.map(\.name) == ["新闻", "科技", "生活"]) + #expect(sorts.map(\.url) == ["/news", "/tech", "/life"]) + source.sortUrl = "invalid" + #expect(await RSSSortResolver().resolve(source) == [.init(name: "", url: "https://rss")]) + } + + @Test func rssReadingPolicyHandlesDescriptionRuleAndMediaBranches() { + let policy = RSSReadingPolicy() + var source = RssSource(sourceUrl: "https://rss.example", sourceName: "源") + var article = RssArticle(origin: source.sourceUrl, sort: "", title: "文章", link: "/post/1") + article.description = "

    摘要正文

    " + #expect(policy.mode(article: article, source: source) == .html(content: "

    摘要正文

    ", baseURL: "https://rss.example/post/1")) + article.description = nil + #expect(policy.mode(article: article, source: source) == .web(url: "https://rss.example/post/1")) + source.ruleContent = "@CSS:article@html" + #expect(policy.mode(article: article, source: source, resolvedContent: "
    正文
    ") == .html(content: "
    正文
    ", baseURL: "https://rss.example/post/1")) + article.type = 1 + #expect(policy.mode(article: article, source: source, resolvedContent: "../cover.jpg") == .image(url: "https://rss.example/cover.jpg")) + article.type = 2 + #expect(policy.mode(article: article, source: source, resolvedContent: "video.mp4") == .video(url: "https://rss.example/post/video.mp4")) + } + + @Test func rssHTMLAndResourcePoliciesApplyRulesInOrder() { + let processor = RSSHTMLProcessor() + #expect(processor.contentHTML("x", style: "body{color:red}").contains("")) + #expect(processor.contentHTML("

    x

    ", style: nil).hasPrefix("")) + var source = RssSource(sourceUrl: "https://rss", sourceName: "源") + source.startJs = "window.ready=true"; source.startStyle = "p{font-size:20px}" + let start = processor.startHTML("

    x

    ", source: source) + #expect(start.contains("")) + #expect(start.contains("")) + + let blacklistWins = RSSResourcePolicy(blacklist: "https://ads,.*\\.track\\.js", whitelist: "https://ads/ok") + #expect(!blacklistWins.allows("https://ads/ok")) + #expect(!blacklistWins.allows("https://cdn.track.js")) + #expect(blacklistWins.allows("https://cdn.example/a.js")) + let whitelist = RSSResourcePolicy(blacklist: nil, whitelist: "https://safe,https://cdn\\.example/.*") + #expect(whitelist.allows("https://safe/image")) + #expect(!whitelist.allows("https://other/image")) + #expect(blacklistWins.contentBlockerJSON()?.contains("\"type\":\"block\"") == true) + #expect(whitelist.contentBlockerJSON()?.contains("ignore-previous-rules") == true) + } + + @Test func rssStartPageUsesDedicatedStartJSAndStyle() throws { + var source = RssSource(sourceUrl: "https://rss.example", sourceName: "源") + source.startHtml = "

    入口

    " + source.startJs = "window.started=true" + source.startStyle = "p{color:green}" + #expect(try RSSStartPageResolver().resolve(source) == .startHTML(content: "

    入口

    ", baseURL: "https://rss.example")) + source.startHtml = "@js:'脚本入口'" + #expect(try RSSStartPageResolver().resolve(source) == .startHTML(content: "脚本入口", baseURL: "https://rss.example")) + source.startHtml = nil + #expect(try RSSStartPageResolver().resolve(source) == nil) + } +} diff --git a/Tests/LegadoCoreTests/LegadoURLBuilderTests.swift b/Tests/LegadoCoreTests/LegadoURLBuilderTests.swift new file mode 100644 index 0000000..cc1aa95 --- /dev/null +++ b/Tests/LegadoCoreTests/LegadoURLBuilderTests.swift @@ -0,0 +1,179 @@ +import Foundation +import Testing +@testable import LegadoCore + +struct LegadoURLBuilderTests { + @Test func canDisableCookieHandlingPerSourceRequest() throws { + let prepared = try LegadoURLBuilder().prepare("https://example.test/books") + #expect(prepared.urlRequest(handleCookies: false).httpShouldHandleCookies == false) + #expect(prepared.urlRequest().httpShouldHandleCookies) + } + private let builder = LegadoURLBuilder() + + @Test func relativeRequestOptionsAndHeaderCasingSurviveResolution() throws { + let base = try #require(URL(string: "https://example.test/book/1")) + let resolved = LegadoURLBuilder.resolve(#"../chapter,{"method":"POST","headers":{"referer":"custom"},"body":"id=1"}"#, against: base) + let request = try builder.prepare(resolved, defaultHeaders: ["Referer": "old"]) + #expect(request.url.absoluteString == "https://example.test/chapter") + #expect(request.method == .post) + #expect(request.headers.filter { $0.key.lowercased() == "referer" }.count == 1) + #expect(request.headers["referer"] == "custom") + #expect(String(decoding: try #require(request.body), as: UTF8.self) == "id=1") + } + + @Test func rendersKeywordPageAndRequestOptions() throws { + let template = """ + /search?q={{key}}&page=<0,1,2>, {"method":"POST","headers":{"X-Test":"yes"},"body":"token={{token}}","retry":2} + """ + let prepared = try builder.prepare( + template, + context: .init(key: "三体", page: 2, variables: ["token": "abc"]), + baseURL: URL(string: "https://example.test/base/"), + defaultHeaders: ["User-Agent": "Legado-iOS"] + ) + + #expect(prepared.url.absoluteString.contains("q=%E4%B8%89%E4%BD%93")) + #expect(prepared.url.absoluteString.contains("page=1")) + #expect(prepared.method == .post) + #expect(prepared.headers["X-Test"] == "yes") + #expect(prepared.headers["User-Agent"] == "Legado-iOS") + #expect(String(decoding: try #require(prepared.body), as: UTF8.self) == "token=abc") + #expect(prepared.retry == 2) + } + + @Test func evaluatesInlineJavaScriptWithSearchBindings() throws { + let prepared = try builder.prepare( + "https://example.test/{{key.toUpperCase()}}?page={{page + 1}}", + context: .init(key: "swift", page: 2) + ) + + #expect(prepared.url.absoluteString == "https://example.test/SWIFT?page=3") + } + + @Test func evaluatesWholeURLAndOptionJavaScriptWhileCarryingVariables() throws { + let wholeScript = try builder.prepare( + "@js:'/search?q=' + key", + context: .init(key: "book", page: 4), + baseURL: URL(string: "https://example.test") + ) + let prepared = try builder.prepare( + #"/search?q=book, {"js":"java.put('signature', 's-' + page); result + '&sig=' + java.get('signature')"}"#, + context: .init(key: "book", page: 4), + baseURL: URL(string: "https://example.test") + ) + + #expect(wholeScript.url.absoluteString == "https://example.test/search?q=book") + #expect(prepared.url.absoluteString == "https://example.test/search?q=book&sig=s-4") + #expect(prepared.variables["signature"] == "s-4") + } + + @Test func supportsLegacySearchTokensAndJSONObjectBody() throws { + let prepared = try builder.prepare( + "https://example.test/search, {\"method\":\"POST\",\"body\":{\"title\":\"searchKey\",\"page\":{{searchPage}}}}", + context: .init(key: "三体", page: 3) + ) + + #expect(prepared.method == .post) + let body = try #require(prepared.body) + let json = try JSONValue.decode(data: body) + #expect(json["title"]?.stringValue == "三体") + #expect(json["page"]?.stringValue == "3") + } + + @Test func encodesLegacyCharsetQueryAndFormBody() throws { + let query = try builder.prepare( + #"https://example.test/search?q={{key}}, {"charset":"gb18030"}"#, + context: .init(key: "旧城") + ) + let form = try builder.prepare( + #"https://example.test/search, {"method":"POST","charset":"gb18030","body":"q={{key}}&tag=中文 空格"}"#, + context: .init(key: "旧城") + ) + + #expect(query.url.absoluteString.contains("q=%BE%C9%B3%C7")) + #expect(String(decoding: try #require(form.body), as: UTF8.self) == "q=%BE%C9%B3%C7&tag=%D6%D0%CE%C4+%BF%D5%B8%F1") + #expect(form.headers["Content-Type"] == "application/x-www-form-urlencoded; charset=gb18030") + } + + @Test func parsesGsonLenientStyleOptions() throws { + let prepared = try builder.prepare( + #"https://example.test/search?q={{key}}, {'method':'POST', body:'key={{key}}', charset:'utf-8',}"#, + context: .init(key: "三体") + ) + + #expect(prepared.method == .post) + #expect(prepared.charset == "utf-8") + #expect(String(decoding: try #require(prepared.body), as: UTF8.self) == "key=%E4%B8%89%E4%BD%93") + } + + @Test func keepsJsonInQueryWhenLaterSplitParses() throws { + let prepared = try builder.prepare( + #"https://example.test/search?data={"q":"{{key}}"}, {"method":"POST","body":"key={{key}}"}"#, + context: .init(key: "剑来") + ) + + #expect(prepared.method == .post) + #expect(prepared.url.absoluteString.contains("data=")) + #expect(String(decoding: try #require(prepared.body), as: UTF8.self) == "key=%E5%89%91%E6%9D%A5") + } + + @Test func appliesDefaultBrowserUserAgentWhenMissing() throws { + let prepared = try builder.prepare("https://example.test/books") + #expect(prepared.headers["User-Agent"] == LegadoURLBuilder.defaultUserAgent) + #expect(prepared.headers["User-Agent"]?.contains("Chrome/128.0.0.0") == true) + let explicit = try builder.prepare( + "https://example.test/books", + defaultHeaders: ["User-Agent": "Custom/1.0"] + ) + #expect(explicit.headers["User-Agent"] == "Custom/1.0") + } + + @Test func nullUserAgentSuppressesDefaultHeader() throws { + let prepared = try builder.prepare( + "https://example.test/books", + defaultHeaders: ["user-agent": "null"] + ) + + #expect(prepared.headers.keys.allSatisfy { $0.caseInsensitiveCompare("User-Agent") != .orderedSame }) + #expect(prepared.urlRequest().value(forHTTPHeaderField: "User-Agent") == nil) + } + + @Test func preservesTypeDnsServerAndProxyOptions() throws { + let prepared = try builder.prepare( + #"https://example.test/file, {"type":"image","dnsIp":"1.1.1.1,8.8.8.8","serverID":12}"#, + defaultHeaders: ["proxy": "http://127.0.0.1:7890", "X-Test": "yes"] + ) + + #expect(prepared.responseType == "image") + #expect(prepared.dnsIP == "1.1.1.1,8.8.8.8") + #expect(prepared.serverID == 12) + #expect(prepared.proxy == "http://127.0.0.1:7890") + #expect(prepared.headers["proxy"] == nil) + #expect(prepared.headers["X-Test"] == "yes") + } + + @Test func rendersPostFormAndIgnoresBodyForGetAndHead() throws { + let post = try builder.prepare( + #"https://example.test/search, {"method":"POST","body":"q={{key}}&space=空 格"}"#, + context: .init(key: "三体") + ) + let emptyPost = try builder.prepare(#"https://example.test/search, {"method":"POST"}"#) + let get = try builder.prepare(#"https://example.test/search, {"body":"ignored=1"}"#) + let head = try builder.prepare(#"https://example.test/search, {"method":"HEAD","body":"ignored=1"}"#) + + #expect(String(decoding: try #require(post.body), as: UTF8.self) == "q=%E4%B8%89%E4%BD%93&space=%E7%A9%BA+%E6%A0%BC") + #expect(emptyPost.body == Data()) + #expect(post.headers["Content-Type"] == "application/x-www-form-urlencoded; charset=utf-8") + #expect(get.urlRequest().httpBody == nil) + #expect(head.urlRequest().httpBody == nil) + } + + @Test func explicitContentTypeKeepsRawPostBody() throws { + let prepared = try builder.prepare( + #"https://example.test/raw, {"method":"POST","headers":{"content-type":"text/plain; charset=utf-8"},"body":"原始 空格"}"# + ) + + #expect(String(decoding: try #require(prepared.body), as: UTF8.self) == "原始 空格") + #expect(prepared.headers["content-type"] == "text/plain; charset=utf-8") + } +} diff --git a/Tests/LegadoCoreTests/LocalBookServiceTests.swift b/Tests/LegadoCoreTests/LocalBookServiceTests.swift new file mode 100644 index 0000000..389c7f3 --- /dev/null +++ b/Tests/LegadoCoreTests/LocalBookServiceTests.swift @@ -0,0 +1,226 @@ +import Foundation +import Testing +@testable import LegadoCore + +struct LocalBookServiceTests { + @Test func decodesDefaultTocRuleWithMissingReplacement() throws { + let data = Data(#"{"id":-1,"name":"目录","rule":"^第.+章$","serialNumber":0,"enable":true}"#.utf8) + let value = try JSONDecoder().decode(TxtTocRule.self, from: data) + #expect(value.replacement == "") + #expect(value.example == nil) + #expect(value.enable) + } + + @Test func importsUTF8TextSplitsChaptersAndReadsContent() throws { + let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let source = root.appending(path: "测试书.txt") + let sourceData = Data("序言\n第一章 相遇\n这是第一章正文\n第二章 远行\n这是第二章正文".utf8) + try sourceData.write(to: source) + + let service = LocalBookService() + let report = try service.importTextFile(at: source, into: root.appending(path: "library")) + + #expect(report.book.origin == "local") + #expect(report.book.charset == "UTF-8") + #expect(report.chapters.map(\.title) == ["前言", "第一章 相遇", "第二章 远行"]) + #expect(report.chapters[1].url == "82e56047094237f3") + let firstTitle = try #require(sourceData.range(of: Data("第一章 相遇".utf8))) + let secondTitle = try #require(sourceData.range(of: Data("第二章 远行".utf8))) + #expect(report.chapters[1].start == Int64(firstTitle.upperBound)) + #expect(report.chapters[1].end == Int64(secondTitle.lowerBound)) + #expect(report.chapters.map(\.wordCount) == ["3字", "9字", "8字"]) + #expect(report.book.wordCount == "20字") + #expect(report.book.intro == "序言\n") + let content = try service.loadContent(book: report.book, chapter: report.chapters[1]) + #expect(content.content == "  这是第一章正文\n") + #expect(FileManager.default.fileExists(atPath: try #require(URL(string: report.book.bookUrl)).path)) + } + + @Test func createsSingleChapterWhenNoHeadingExists() throws { + let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let source = root.appending(path: "短篇.txt") + try Data("这是一个没有章节标题的短篇。".utf8).write(to: source) + + let report = try LocalBookService().importTextFile(at: source, into: root.appending(path: "library")) + + #expect(report.chapters.count == 1) + #expect(report.chapters[0].title == "短篇") + #expect(try LocalBookService().loadContent(book: report.book, chapter: report.chapters[0]).content == "这是一个没有章节标题的短篇。") + } + + @Test func splitsLongHeadinglessTextAtTenKiBThreshold() throws { + let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let source = root.appending(path: "无目录长文.txt") + let paragraph = String(repeating: "a", count: 10_500) + try Data("\(paragraph)\n\(paragraph)\n尾声".utf8).write(to: source) + + let report = try LocalBookService(tocRules: []).importTextFile( + at: source, + into: root.appending(path: "library") + ) + + #expect(report.chapters.count == 2) + #expect(report.chapters.map(\.title) == ["第1章(1)", "第1章(2)"]) + #expect(report.chapters[0].end == report.chapters[1].start) + #expect(report.book.wordCount == "2.1万字") + #expect(report.book.intro == nil) + } + + @Test func numbersHeadinglessChaptersBy512KBBlock() throws { + let root = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let source = root.appending(path: "多块无目录.txt") + let paragraph = String(repeating: "a", count: 10_500) + "\n" + try Data(String(repeating: paragraph, count: 55).utf8).write(to: source) + + let report = try LocalBookService(tocRules: []).importTextFile( + at: source, + into: root.appending(path: "library") + ) + + #expect(report.chapters.first?.title == "第1章(1)") + #expect(report.chapters.contains { $0.title.hasPrefix("第2章(") }) + for index in 1.. URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + @Test func importsSameNamedFilesWithoutOverwritingAndIgnoresSymlinks() async throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + let source = root.appendingPathComponent("书.txt") + try "第一章\n正文".write(to: source, atomically: true, encoding: .utf8) + let directory = root.appendingPathComponent("books", isDirectory: true) + let service = LocalFileService(directory: directory) + let first = try await service.importFile(from: source) + let second = try await service.importFile(from: source) + try FileManager.default.createSymbolicLink(at: directory.appendingPathComponent("link.txt"), withDestinationURL: source) + let files = try await service.list() + #expect(files.count == 2) + #expect(Set(files.map { $0.url.lastPathComponent }) == ["书.txt", "书 (2).txt"]) + #expect(try Data(contentsOf: first) == Data(contentsOf: second)) + } + + @Test func rejectsEscapesSymlinksAndOverwriteDuringRename() async throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + let directory = root.appendingPathComponent("books", isDirectory: true) + let service = LocalFileService(directory: directory) + let source = root.appendingPathComponent("source.txt") + try "正文".write(to: source, atomically: true, encoding: .utf8) + let file = try await service.importFile(from: source) + let second = try await service.importFile(from: source) + await #expect(throws: LocalFileError.invalidName) { try await service.rename(file, to: "../escape") } + await #expect(throws: LocalFileError.alreadyExists) { try await service.rename(file, to: second.lastPathComponent) } + await #expect(throws: LocalFileError.outsideLibrary) { try await service.stageRemoval(source) } + let link = directory.appendingPathComponent("link.txt") + try FileManager.default.createSymbolicLink(at: link, withDestinationURL: source) + await #expect(throws: LocalFileError.outsideLibrary) { try await service.stageRemoval(link) } + #expect(FileManager.default.fileExists(atPath: source.path)) + #expect(FileManager.default.fileExists(atPath: file.path)) + } + + @Test func stagedDeletionCanRestoreOriginalBytes() async throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + let source = root.appendingPathComponent("书.txt") + let data = Data([0xff, 0xfe, 0x61, 0x00]) + try data.write(to: source) + let service = LocalFileService(directory: root) + let staged = try #require(try await service.stageRemoval(source)) + #expect(!FileManager.default.fileExists(atPath: source.path)) + #expect(try await service.list().isEmpty) + try await service.restore(staged) + #expect(try Data(contentsOf: source) == data) + let final = try #require(try await service.stageRemoval(source)) + try await service.finishRemoval(final) + #expect(!FileManager.default.fileExists(atPath: final.staged.path)) + } + + @Test func originalExportPreservesEncodingAndNeverMovesSource() async throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + let source = root.appendingPathComponent("书.txt") + let data = Data([0xff, 0xfe, 0x61, 0x00]) + try data.write(to: source) + let exporter = BookFileExporter(directory: root.appendingPathComponent("exports")) + let result = try await exporter.originalFile(source, filename: "../书.txt") + #expect(try Data(contentsOf: result.url) == data) + #expect(try Data(contentsOf: source) == data) + #expect(result.url.lastPathComponent == ".._书.txt") + try await exporter.discard(result) + #expect(!FileManager.default.fileExists(atPath: result.url.path)) + #expect(FileManager.default.fileExists(atPath: source.path)) + } + + @Test func cachedExportUsesDirectoryOrderAndMarksMissingChapters() async throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + let cache = ChapterContentCache(directory: root.appendingPathComponent("cache")) + let book = Book(bookUrl: "book", name: "书", author: "作者") + let chapters = (0..<3).map { BookChapter(url: "c\($0)", title: "第\($0 + 1)章", bookUrl: "book", index: $0) } + try await cache.save(.init(bookURL: "book", chapterURL: "c2", content: "第三章正文", supplementalContent: "补充")) + try await cache.save(.init(bookURL: "book", chapterURL: "c0", content: "第一章正文")) + let exporter = BookFileExporter(directory: root.appendingPathComponent("exports"), cache: cache) + let result = try await exporter.cachedBook(book, chapters: chapters.reversed()) + let text = try String(contentsOf: result.url, encoding: .utf8) + #expect(result.cachedChapters == 2) + #expect(result.totalChapters == 3) + #expect(result.isPartial) + #expect(text.contains("第2章\n\n【本章尚未缓存】")) + #expect(text.contains("第三章正文\n补充")) + #expect(try #require(text.range(of: "第一章正文")).lowerBound < #require(text.range(of: "第三章正文")).lowerBound) + } + + @Test func emptyCacheCannotProduceMisleadingEmptyBook() async throws { + let root = try root() + defer { try? FileManager.default.removeItem(at: root) } + let exports = root.appendingPathComponent("exports") + let exporter = BookFileExporter(directory: exports, cache: ChapterContentCache(directory: root.appendingPathComponent("cache"))) + let book = Book(bookUrl: "book", name: "书") + let chapter = BookChapter(url: "c", title: "第一章", bookUrl: "book", index: 0) + await #expect(throws: LocalFileError.emptyCache) { try await exporter.cachedBook(book, chapters: [chapter]) } + #expect(try FileManager.default.contentsOfDirectory(atPath: exports.path).isEmpty) + } +} diff --git a/Tests/LegadoCoreTests/NetworkBookServiceTests.swift b/Tests/LegadoCoreTests/NetworkBookServiceTests.swift new file mode 100644 index 0000000..1f4b8e9 --- /dev/null +++ b/Tests/LegadoCoreTests/NetworkBookServiceTests.swift @@ -0,0 +1,162 @@ +import Foundation +import Testing +@testable import LegadoCore + +private struct BookFlowMockTransport: HTTPTransport { + let pages: [String: String] + + func data(for request: URLRequest) async throws -> HTTPResponse { + let url = try #require(request.url) + let body = try #require(pages[url.absoluteString], "缺少 mock 页面:\(url.absoluteString)") + return .init(data: Data(body.utf8), statusCode: 200, headers: [:], finalURL: url) + } +} + +struct NetworkBookServiceTests { + @Test func loadsDetailPaginatedTocAndPaginatedContent() async throws { + let transport = BookFlowMockTransport(pages: [ + "https://reader.example.test/book/1": """ +

    迁移实战

    作者甲 +

    一本测试书

    + 目录
    + """, + "https://reader.example.test/book/1/toc/1": """ + + + """, + "https://reader.example.test/book/1/toc/2": """ + + """, + "https://reader.example.test/chapter/1": """ +

    第一章 新标题

    第一段

    + + """, + "https://reader.example.test/chapter/1?page=2": """ +

    第二段

    + """ + ]) + var source = BookSource( + bookSourceUrl: "https://reader.example.test", + bookSourceName: "完整链路源" + ) + var info = BookInfoRule() + info.`init` = "@CSS:main.book" + info.name = "@CSS:h1@text" + info.author = "@CSS:.author@text" + info.intro = "@CSS:.intro@text" + info.coverUrl = "@CSS:img.cover@src" + info.tocUrl = "@CSS:a.toc@href" + source.ruleBookInfo = info + var toc = TocRule() + toc.chapterList = "@CSS:li.chapter" + toc.chapterName = "@CSS:a@text" + toc.chapterUrl = "@CSS:a@href" + toc.nextTocUrl = "@CSS:a.next@href" + source.ruleToc = toc + var content = ContentRule() + content.content = "@CSS:article p@text" + content.title = "@CSS:h1@text" + content.nextContentUrl = "@CSS:a.next@href" + source.ruleContent = content + let searchBook = SearchBook( + bookURL: "https://reader.example.test/book/1", + origin: source.bookSourceUrl, + originName: source.bookSourceName, + type: 0, + name: "搜索结果名" + ) + let service = NetworkBookService(transport: transport) + + let detail = await service.loadDetail(searchBook: searchBook, source: source) + #expect(detail.book.name == "迁移实战") + #expect(detail.book.author == "作者甲") + #expect(detail.book.coverUrl == "https://reader.example.test/cover.jpg") + #expect(detail.book.tocUrl == "https://reader.example.test/book/1/toc/1") + #expect(detail.diagnostics.isEmpty) + + let tocReport = await service.loadChapters(book: detail.book, source: source) + #expect(tocReport.chapters.map(\.title) == ["第一章", "第二章"]) + #expect(tocReport.chapters.map(\.index) == [0, 1]) + #expect(tocReport.pagesVisited == 2) + #expect(tocReport.diagnostics.isEmpty) + + let chapter = try #require(tocReport.chapters.first) + let contentReport = await service.loadContent( + book: detail.book, + chapter: chapter, + source: source, + nextChapterURL: tocReport.chapters[1].url + ) + #expect(contentReport.title == "第一章 新标题") + #expect(contentReport.content == "第一段\n第二段") + #expect(contentReport.pagesVisited == 2) + #expect(contentReport.diagnostics.isEmpty) + } + + @Test func returnsDiagnosticsForEmptyContentAndStopsTocLoops() async throws { + let transport = BookFlowMockTransport(pages: [ + "https://reader.example.test/toc": """ +
  • 空章
  • + + """, + "https://reader.example.test/chapter/empty": "
    " + ]) + var source = BookSource(bookSourceUrl: "https://reader.example.test", bookSourceName: "诊断源") + var toc = TocRule() + toc.chapterList = "@CSS:li.chapter" + toc.chapterName = "@CSS:a@text" + toc.chapterUrl = "@CSS:a@href" + toc.nextTocUrl = "@CSS:a.next@href" + source.ruleToc = toc + var content = ContentRule() + content.content = "@CSS:article@text" + source.ruleContent = content + var book = Book( + bookUrl: "https://reader.example.test/book/empty", + name: "空书", + origin: source.bookSourceUrl + ) + book.tocUrl = "https://reader.example.test/toc" + let service = NetworkBookService(transport: transport) + + let tocReport = await service.loadChapters(book: book, source: source) + #expect(tocReport.pagesVisited == 1) + #expect(tocReport.chapters.count == 1) + let chapter = try #require(tocReport.chapters.first) + let contentReport = await service.loadContent(book: book, chapter: chapter, source: source) + + #expect(contentReport.content.isEmpty) + #expect(contentReport.diagnostics.contains { $0.code == "content.empty" }) + } + + @Test func carriesBookVariablesIntoJavaScriptChapterRules() async throws { + let transport = BookFlowMockTransport(pages: [ + "https://audio.example.test/toc": #"{"content":{"content":[{"id":2002,"chapterTitle":"第一集"}]}}"# + ]) + var source = BookSource(bookSourceUrl: "https://audio.example.test", bookSourceName: "变量源") + var toc = TocRule() + toc.chapterList = "$.content.content" + toc.chapterName = "$.chapterTitle" + toc.chapterUrl = "$.id@js:java.put('chapterId', result); '/audio?bookId=' + java.get('bookId') + '&chapterId=' + result" + source.ruleToc = toc + var book = Book( + bookUrl: "https://audio.example.test/book/1001", + name: "听书", + origin: source.bookSourceUrl + ) + book.tocUrl = "https://audio.example.test/toc" + book.variable = #"{"bookId":"1001"}"# + + let report = await NetworkBookService(transport: transport).loadChapters(book: book, source: source) + + let chapter = try #require(report.chapters.first) + #expect(chapter.url == "https://audio.example.test/audio?bookId=1001&chapterId=2002") + let variables = try JSONDecoder().decode( + [String: String].self, + from: Data(try #require(chapter.variable).utf8) + ) + #expect(variables["bookId"] == "1001") + #expect(variables["chapterId"] == "2002") + #expect(report.diagnostics.isEmpty) + } +} diff --git a/Tests/LegadoCoreTests/QRCodeDecoderTests.swift b/Tests/LegadoCoreTests/QRCodeDecoderTests.swift new file mode 100644 index 0000000..9e1bda2 --- /dev/null +++ b/Tests/LegadoCoreTests/QRCodeDecoderTests.swift @@ -0,0 +1,28 @@ +import CoreImage +import CoreImage.CIFilterBuiltins +import Foundation +import ImageIO +import Testing +import UniformTypeIdentifiers +@testable import LegadoCore + +struct QRCodeDecoderTests { + @Test func decodesActualPNGQRCodeAndResolvesImportPayload() throws { + let payload = "legado://import/bookSource?src=https%3A%2F%2Fexample.test%2Fsources.json" + let filter = CIFilter.qrCodeGenerator() + filter.message = Data(payload.utf8) + let output = try #require(filter.outputImage?.transformed(by: CGAffineTransform(scaleX: 8, y: 8))) + let image = try #require(CIContext().createCGImage(output, from: output.extent)) + let data = NSMutableData() + let destination = try #require(CGImageDestinationCreateWithData(data, UTType.png.identifier as CFString, 1, nil)) + CGImageDestinationAddImage(destination, image, nil) + #expect(CGImageDestinationFinalize(destination)) + let values = try QRCodeDecoder().detect(in: data as Data) + #expect(values == [payload]) + #expect(try RuleImportInput.parse(values[0]) == .remote(URL(string: "https://example.test/sources.json")!, withoutUserAgent: false)) + } + + @Test func invalidImageReportsDecodeFailure() { + #expect(throws: (any Error).self) { try QRCodeDecoder().detect(in: Data("invalid image".utf8)) } + } +} diff --git a/Tests/LegadoCoreTests/ReadingActivityRepositoryTests.swift b/Tests/LegadoCoreTests/ReadingActivityRepositoryTests.swift new file mode 100644 index 0000000..ba7423d --- /dev/null +++ b/Tests/LegadoCoreTests/ReadingActivityRepositoryTests.swift @@ -0,0 +1,137 @@ +import Foundation +import Testing +@testable import LegadoCore + +struct ReadingActivityRepositoryTests { + @Test func decodesAndEncodesBookmarkPosition() throws { + let data = Data(#"{"time":7,"bookName":"书","bookAuthor":"作者","chapterIndex":2,"chapterPos":88,"chapterName":"第二章","bookText":"片段","content":"备注"}"#.utf8) + let bookmark = try JSONDecoder().decode(Bookmark.self, from: data) + let encoded = try JSONEncoder().encode(bookmark) + let object = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + + #expect(bookmark.chapterPosition == 88) + #expect(object["chapterPos"] as? Int == 88) + #expect(object["chapterPosition"] == nil) + } + + @Test func accumulatesPerDeviceAndSummarizesAcrossDevices() { + let service = ReadingActivityService() + let now: Int64 = 2_000 + var records = [ReadRecord(deviceId: "a", bookName: "书", readTime: 1_000, lastRead: 1_000)] + records = service.addingReadTime(500, deviceID: "a", bookName: "书", now: now, to: records) + records = service.addingReadTime(700, deviceID: "b", bookName: "书", now: now + 1, to: records) + + let summary = service.summaries(records) + #expect(records.count == 2) + #expect(summary == [.init(bookName: "书", readTime: 2_200, lastRead: 2_001)]) + } + + @Test func decodesLegacyReadRecordWithoutDailyStatistics() throws { + let data = Data(#"{"deviceId":"ios","bookName":"旧书","readTime":120000,"lastRead":7}"#.utf8) + let record = try JSONDecoder().decode(ReadRecord.self, from: data) + + #expect(record.dailyReadTimes.isEmpty) + #expect(record.readTime == 120_000) + } + + @Test func calculatesTodayStreakTotalsAndUniqueBooks() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "Asia/Shanghai")) + let now = try #require(calendar.date(from: DateComponents(year: 2026, month: 8, day: 20, hour: 12))) + let service = ReadingActivityService() + let records = [ + ReadRecord( + deviceId: "a", + bookName: "第一本", + readTime: 9_000_000, + lastRead: Int64(now.timeIntervalSince1970 * 1_000), + dailyReadTimes: ["2026-08-18": 600_000, "2026-08-19": 1_200_000, "2026-08-20": 1_800_000] + ), + ReadRecord( + deviceId: "b", + bookName: "第一本", + readTime: 3_000_000, + dailyReadTimes: ["2026-08-20": 300_000] + ), + ReadRecord( + deviceId: "a", + bookName: "第二本", + readTime: 4_000_000, + dailyReadTimes: ["2026-08-17": 60_000] + ) + ] + + let statistics = service.statistics(records, now: now, calendar: calendar) + + #expect(statistics.todayReadTime == 2_100_000) + #expect(statistics.currentStreak == 4) + #expect(statistics.totalReadTime == 16_000_000) + #expect(statistics.bookCount == 2) + #expect(statistics.recentDays.count == 7) + } + + @Test func keepsCurrentStreakUntilTheDayAfterLastReading() throws { + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = try #require(TimeZone(identifier: "Asia/Shanghai")) + let now = try #require(calendar.date(from: DateComponents(year: 2026, month: 8, day: 20, hour: 8))) + let records = [ReadRecord( + deviceId: "a", + bookName: "书", + readTime: 120_000, + dailyReadTimes: ["2026-08-18": 60_000, "2026-08-19": 60_000] + )] + + let statistics = ReadingActivityService().statistics(records, now: now, calendar: calendar) + + #expect(statistics.todayReadTime == 0) + #expect(statistics.currentStreak == 2) + } + + @Test func filtersAndSortsReadRecords() { + let records = [ + ReadRecord(deviceId: "a", bookName: "Beta", readTime: 5_000, lastRead: 20), + ReadRecord(deviceId: "a", bookName: "Alpha", readTime: 1_000, lastRead: 30), + ReadRecord(deviceId: "b", bookName: "Beta", readTime: 2_000, lastRead: 40) + ] + let service = ReadingActivityService() + + #expect(service.displayedSummaries(records, searchKey: "", sortMode: 0).map(\.bookName) == ["Alpha", "Beta"]) + #expect(service.displayedSummaries(records, searchKey: "", sortMode: 1).map(\.bookName) == ["Beta", "Alpha"]) + #expect(service.displayedSummaries(records, searchKey: "", sortMode: 2).map(\.bookName) == ["Beta", "Alpha"]) + #expect(service.displayedSummaries(records, searchKey: "alp", sortMode: 0).map(\.bookName) == ["Alpha"]) + } + + @Test func sortsBookmarksByBookAndPosition() { + var later = Bookmark(time: 1, bookName: "B", bookAuthor: "作者") + later.chapterIndex = 1 + later.chapterPosition = 20 + var earlierPosition = Bookmark(time: 2, bookName: "A", bookAuthor: "作者") + earlierPosition.chapterIndex = 2 + earlierPosition.chapterPosition = 30 + var first = Bookmark(time: 3, bookName: "A", bookAuthor: "作者") + first.chapterIndex = 1 + first.chapterPosition = 40 + + let sorted = ReadingActivityService().sortedBookmarks([later, earlierPosition, first]) + + #expect(sorted.map(\.time) == [first.time, earlierPosition.time, later.time]) + } + + @Test func persistsBookmarksAndRecordsAtomically() async throws { + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: directory) } + let repository = FileReadingActivityRepository( + bookmarksURL: directory.appending(path: "bookmarks.json"), + recordsURL: directory.appending(path: "records.json") + ) + var bookmark = Bookmark(time: 4, bookName: "书") + bookmark.chapterPosition = 19 + let record = ReadRecord(deviceId: "ios", bookName: "书", readTime: 3_000, lastRead: 5) + + try await repository.saveBookmarks([bookmark]) + try await repository.saveReadRecords([record]) + + #expect(try await repository.loadBookmarks() == [bookmark]) + #expect(try await repository.loadReadRecords() == [record]) + } +} diff --git a/Tests/LegadoCoreTests/ReplaceRuleRepositoryTests.swift b/Tests/LegadoCoreTests/ReplaceRuleRepositoryTests.swift new file mode 100644 index 0000000..19dcd66 --- /dev/null +++ b/Tests/LegadoCoreTests/ReplaceRuleRepositoryTests.swift @@ -0,0 +1,43 @@ +import Foundation +import Testing +@testable import LegadoCore + +struct ReplaceRuleRepositoryTests { + @Test func decodesOrderFieldAndDefaults() throws { + let data = Data(#"{"id":9,"name":"广告","pattern":"ad","replacement":"","order":12}"#.utf8) + let rule = try #require(ReplaceRuleImporter().decode(data).first) + + #expect(rule.id == 9) + #expect(rule.sortOrder == 12) + #expect(rule.scopeContent) + #expect(rule.isEnabled) + #expect(rule.isRegex) + #expect(rule.timeoutMillisecond == 3_000) + } + + @Test func mergesByIdentifierAndSorts() { + var old = ReplaceRule(id: 1, name: "旧", pattern: "old") + old.sortOrder = 10 + var replacement = ReplaceRule(id: 1, name: "新", pattern: "new") + replacement.sortOrder = 3 + var first = ReplaceRule(id: 2, name: "先", pattern: "first") + first.sortOrder = 1 + + let merged = ReplaceRuleImporter().merge([replacement, first], into: [old]) + + #expect(merged.map(\.id) == [2, 1]) + #expect(merged.last?.name == "新") + } + + @Test func persistsRulesAtomically() async throws { + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString, directoryHint: .isDirectory) + defer { try? FileManager.default.removeItem(at: directory) } + let repository = FileReplaceRuleRepository(fileURL: directory.appending(path: "rules.json")) + let rule = ReplaceRule(id: 3, name: "空行", pattern: "\\n{3,}", replacement: "\n\n") + + try await repository.save([rule]) + let restored = try await repository.load() + + #expect(restored == [rule]) + } +} diff --git a/Tests/LegadoCoreTests/RuleEditingCompatibilityTests.swift b/Tests/LegadoCoreTests/RuleEditingCompatibilityTests.swift new file mode 100644 index 0000000..52bdee0 --- /dev/null +++ b/Tests/LegadoCoreTests/RuleEditingCompatibilityTests.swift @@ -0,0 +1,116 @@ +import Foundation +import Testing +@testable import LegadoCore + +struct RuleEditingCompatibilityTests { + @Test func unknownFieldsSurviveImportFieldEditingAndRepositoryRoundTrip() async throws { + let input = #"{"bookSourceUrl":"https://roundtrip.test","bookSourceName":"扩展源","futureOption":{"token":"keep"},"ruleSearch":"{\"bookList\":\"$.books\",\"name\":\"$.name\",\"bookUrl\":\"$.url\",\"futureRule\":{\"x\":true}}"}"# + let original = try #require(BookSourceImporter().decode(Data(input.utf8)).first) + var document = try BookSourceEditorDocument(source: original) + document.set(.string("已编辑"), at: "bookSourceName") + document.set(.string("$.title"), at: "ruleSearch.name") + let edited = try document.source() + let repository = InMemoryBookSourceRepository() + try await repository.save([edited]) + let restored = try #require(await repository.load().first) + let json = try JSONValue.decode(data: JSONEncoder().encode(restored)) + #expect(json["futureOption"]?["token"] == .string("keep")) + #expect(json["ruleSearch"]?["futureRule"]?["x"] == .bool(true)) + #expect(restored.ruleSearch?.name == "$.title") + #expect(restored.bookSourceName == "已编辑") + #expect(json["additionalFields"] == nil) + } + + @Test func clearingKnownRuleDoesNotRestoreOldValueFromPreservedFields() throws { + let source = try JSONDecoder().decode(BookSource.self, from: Data(#"{"bookSourceUrl":"https://test","bookSourceName":"书源","ruleContent":{"content":"article@text","future":42}}"#.utf8)) + var document = try BookSourceEditorDocument(source: source) + document.set(nil, at: "ruleContent.content") + let json = try JSONValue.decode(data: JSONEncoder().encode(document.source())) + #expect(json["ruleContent"]?["content"] == nil) + #expect(json["ruleContent"]?["future"] == .number(42)) + } + + @Test func emptyRuleBlocksAndExplicitCookieNullRoundTrip() throws { + let original = try JSONDecoder().decode(BookSource.self, from: Data(#"{"bookSourceUrl":"https://test","bookSourceName":"空规则","ruleExplore":{},"enabledCookieJar":null}"#.utf8)) + let restored = try JSONDecoder().decode(BookSource.self, from: JSONEncoder().encode(original)) + #expect(restored.ruleExplore != nil) + #expect(restored.enabledCookieJar == nil) + #expect(restored == original) + } + + @Test func partialInspectionReportsMalformedEntriesAndKeepsValidSiblings() throws { + let input = #"[{"bookSourceUrl":"https://good","bookSourceName":"有效"},{"bookSourceUrl":"https://bad","bookSourceName":"错误","ruleSearch":{"name":12}},17,{"bookSourceName":"缺少地址"}]"# + let inspected = try BookSourceImporter().inspect(Data(input.utf8)) + #expect(inspected.sources.map(\.bookSourceName) == ["有效"]) + #expect(inspected.rejected.count == 3) + #expect(throws: BookSourceImportError.self) { try BookSourceImporter().decode(Data(input.utf8)) } + } + + @Test func editorRetainsRawInvalidJSONAndRejectsIncompleteSourceAtSave() throws { + #expect(throws: (any Error).self) { try BookSourceEditorDocument(json: "{") } + #expect(throws: BookSourceImportError.self) { try BookSourceEditorDocument(json: "[]") } + let draft = try BookSourceEditorDocument(json: #"{"bookSourceName":"未完成"}"#) + #expect(throws: BookSourceImportError.self) { try draft.source() } + } + + @Test func importInputAcceptsQRJSONDeepLinksAndWithoutUA() throws { + let deep = "legado://import/bookSource?src=https%3A%2F%2Fexample.test%2Fsources.json%23requestWithoutUA" + let parsed = try RuleImportInput.parse(deep) + #expect(parsed == .remote(URL(string: "https://example.test/sources.json")!, withoutUserAgent: true)) + #expect(parsed.request()?.value(forHTTPHeaderField: "User-Agent") == "null") + #expect(try RuleImportInput.parse("[合集](https://example.test/source.json)") == .remote(URL(string: "https://example.test/source.json")!, withoutUserAgent: false)) + #expect(try RuleImportInput.parse(#"{"bookSourceName":"测试"}"#) == .json(#"{"bookSourceName":"测试"}"#)) + #expect(throws: BookSourceImportError.self) { try RuleImportInput.parse("legado://import/replaceRule?src=https://example.test/rules") } + #expect(throws: BookSourceImportError.self) { try RuleImportInput.parse("file:///etc/passwd") } + #expect(throws: BookSourceImportError.self) { try RuleImportInput.parse("https://") } + } + + @Test func purificationTestMatchesReaderScopesAndCaptures() throws { + var rule = ReplaceRule(id: 1, name: "替换", pattern: "第([0-9]+)章", replacement: "Chapter $1") + rule.scopeTitle = true + rule.scopeContent = true + rule.scope = "示例书;https://test" + let test = try ReplaceRuleTester().test(rule: rule, title: "第12章", content: "第34章 正文", bookName: "示例书", sourceURL: "https://test") + #expect(test.report.title == "Chapter 12") + #expect(test.report.content == "Chapter 34 正文") + #expect(test.report.effectiveRuleIDs == [1]) + rule.excludeScope = "示例书" + let excluded = try ReplaceRuleTester().test(rule: rule, title: "第12章", content: "第34章 正文", bookName: "示例书", sourceURL: "https://test") + #expect(excluded.report.title == "第12章") + #expect(excluded.report.effectiveRuleIDs.isEmpty) + } + + @Test func purificationLiteralReplacementKeepsDollarSignsAndWhitespace() throws { + var rule = ReplaceRule(id: 2, name: "字面", pattern: "[广告]", replacement: "$1") + rule.isRegex = false + let result = try ReplaceRuleTester().test(rule: rule, title: "标题", content: " 正文\n[广告]\n", bookName: "", sourceURL: "") + #expect(result.report.content == " 正文\n$1\n") + } + + @Test func invalidPurificationRegexReportsErrorInsteadOfLiteralFallback() throws { + let rule = ReplaceRule(id: 3, name: "损坏", pattern: "[") + #expect(throws: ReplaceRuleImportError.self) { try ReplaceRuleValidator.validate(rule) } + let book = Book(bookUrl: "test", name: "书", origin: "test") + let chapter = BookChapter(url: "chapter", title: "标题", bookUrl: "test", index: 0) + let report = ContentSanitizer().process(book: book, chapter: chapter, content: "[原文", rules: [rule]) + #expect(report.content == "[原文") + #expect(report.diagnostics.first?.code == "rule.malformed") + } + + @Test func regexDeadlineInterruptsBacktrackingAndDoesNotReturnPartialReplacement() { + let input = String(repeating: "a", count: 100) + "!" + #expect(throws: RuleEngineError.self) { + try RegexEvaluator().replacingStrict(pattern: "(a|aa)+$", in: input, with: "changed", timeoutMilliseconds: 1) + } + } + + @Test func purificationResourceLimitsPreserveInputAndReportDiagnostic() throws { + let rule = ReplaceRule(id: 3, name: "嵌套", pattern: "(a+)+$", replacement: "changed") + let result = try ReplaceRuleTester().test(rule: rule, title: "标题", content: "aaaa!", bookName: "书", sourceURL: "test") + #expect(result.report.content == "aaaa!") + #expect(result.report.diagnostics.first?.code == "rule.resource_limit") + let regex = RegexEvaluator(limits: .init(maximumMatches: 2)) + #expect(throws: RuleEngineError.self) { try regex.replacingStrict(pattern: "a", in: "aaa", with: "b") } + #expect(try regex.replacingStrict(pattern: "a", in: "aa", with: "b") == "bb") + } +} diff --git a/Tests/LegadoCoreTests/RuleEngineTests.swift b/Tests/LegadoCoreTests/RuleEngineTests.swift new file mode 100644 index 0000000..4ae0fff --- /dev/null +++ b/Tests/LegadoCoreTests/RuleEngineTests.swift @@ -0,0 +1,198 @@ +import Foundation +import Testing +@testable import LegadoCore + +struct RuleEngineTests { + @Test func scalarResultMatchesLegadoJoinAndLeadingURLSemantics() { + let result = RuleEvaluation(values: [.string(""), .string("乙"), .string("丙")]) + #expect(result.joinedString == "\n乙\n丙") + #expect(result.leadingString == nil) + #expect(result.firstString == "乙") + } + + private let engine = RuleEngine() + + @Test func javaScriptStagesContinueIntoSelectorsAndReplacementOnlyRules() async { + let result = await engine.evaluate( + "result.replace('旧', '新')class.body@textresult + ' 广告'## 广告", + inText: "
    旧正文
    " + ) + #expect(result.strings == ["新正文"]) + #expect(result.diagnostics.isEmpty) + let replaced = await engine.evaluate("##广告##", inText: "正文广告") + #expect(replaced.strings == ["正文"]) + #expect(replaced.diagnostics.isEmpty) + } + + @Test func listJavaScriptReceivesWholeListAndScalarJavaScriptReceivesJoinedText() async { + let list = await engine.evaluate( + "tag.aresult.reverse()", + inText: "AB", listMode: true + ) + #expect(list.diagnostics.isEmpty) + #expect(list.values.first?[0]?.stringValue.contains(">B<") == true) + let scalar = await engine.evaluate("tag.p@text@js:result.split('\\n').reverse().join('|')", inText: "

    A

    B

    ") + #expect(scalar.strings == ["B|A"]) + } + + @Test func javaScriptCanReadOriginalContentAfterExtractionAndReturnJSON() async { + let result = await engine.evaluate( + "tag.h1@text@js:result + ':' + java.getString('tag.p@text')", + inText: "

    书名

    作者

    " + ) + #expect(result.strings == ["书名:作者"]) + let json = await engine.evaluate("JSON.parse(result.slice(4))$.data.name", inText: "data{\"data\":{\"name\":\"书名\"}}") + #expect(json.strings == ["书名"]) + #expect(json.diagnostics.isEmpty) + } + + @Test func standaloneDynamicRulesReturnValuesAndJavaScriptIsNotParsedAsRuleSyntax() async { + let variable = await engine.evaluate("@get:{token}", inText: "

    ", context: .init(variables: ["token": "abc"])) + #expect(variable.strings == ["abc"]) + let inline = await engine.evaluate("{{'https://example.test/' + 42}}", inText: "

    ") + #expect(inline.strings == ["https://example.test/42"]) + let script = await engine.evaluate("@js:result.replace('##', '')", inText: "正文##") + #expect(script.strings == ["正文"]) + let nested = await engine.evaluate("{{@CSS:h1@text}}-{{//p/text()}}", inText: "

    书名

    作者

    ") + #expect(nested.strings == ["书名-作者"]) + let jsonString = await engine.evaluate("result{{$.name}}", inText: #"{"name":"书名"}"#) + #expect(jsonString.strings == ["书名"]) + let captures = await engine.evaluate("$1##(甲)##$1乙", in: .array([.string("甲"), .string("甲")])) + #expect(captures.strings == ["甲乙"]) + } + + @Test func importedQQBookURLsAndNumericCoverIDsUseStringPipelineSemantics() async throws { + let input = try JSONValue.decode(data: Data(#"{"bid":1009704712}"#.utf8)) + let url = await engine.evaluate( + "$.bid\n1100000000+parseInt(result)\nhttps://bookshelf.html5.qq.com/qbread/api/novel/intro-info?bookid={{result}}", + in: input + ) + #expect(url.strings == ["https://bookshelf.html5.qq.com/qbread/api/novel/intro-info?bookid=2109704712"]) + #expect(url.diagnostics.isEmpty) + let cover = await engine.evaluate("$.bid@js:result.slice(-3)", in: input) + #expect(cover.strings == ["712"]) + #expect(cover.diagnostics.isEmpty) + let literal = await engine.evaluate("https://example.test/@get:{id}", in: input, context: .init(variables: ["id": "42"])) + #expect(literal.strings == ["https://example.test/42"]) + } + + @Test func combinesFallbackAndConcatenation() async throws { + let input = try JSONValue.decode(data: Data(#"{"a":"A","b":"B"}"#.utf8)) + + let fallback = await engine.evaluate("$.missing||$.b", in: input) + #expect(fallback.strings == ["B"]) + #expect(fallback.diagnostics.isEmpty) + + let concatenated = await engine.evaluate("$.a&&$.b", in: input) + #expect(concatenated.strings == ["A", "B"]) + } + + @Test func appliesRegexReplacement() async throws { + let input = try JSONValue.decode(data: Data(#"{"name":"A B"}"#.utf8)) + + let result = await engine.evaluate("$.name##\\s+## ", in: input) + + #expect(result.firstString == "A B") + } + + @Test func regexCapturesAndRejectsHighRiskPattern() throws { + let evaluator = RegexEvaluator() + #expect(try evaluator.captureGroups(pattern: #"(\w+):(\d+)"#, input: "a:1 b:2") == [ + ["a:1", "a", "1"], ["b:2", "b", "2"] + ]) + #expect(throws: RuleEngineError.self) { + try evaluator.captureGroups(pattern: "(a+)+", input: String(repeating: "a", count: 100)) + } + } + + @Test func interleaveStopsAtFirstBranchLength() async throws { + let input = try JSONValue.decode(data: Data(#"{"a":["A"],"b":["B1","B2"]}"#.utf8)) + let result = await engine.evaluate("$.a[*]%%$.b[*]", in: input) + #expect(result.strings == ["A", "B1"]) + } + + @Test func replacementUsesFirstMatchAndInvalidRegexFallback() throws { + let evaluator = RegexEvaluator() + + #expect(try evaluator.replacing(pattern: #"\d+"#, in: "a12b34", with: "<$0>", firstOnly: true) == "<12>") + #expect(try evaluator.replacing(pattern: #"\d+"#, in: "abc", with: "x", firstOnly: true) == "") + #expect(try evaluator.replacing(pattern: "[", in: "a[b[", with: "x", firstOnly: false) == "axbx") + #expect(try evaluator.replacing(pattern: "[", in: "unchanged", with: "fallback", firstOnly: true) == "fallback") + } + + @Test func supportsPutGetAndInlineJavaScriptInRules() async throws { + let input = try JSONValue.decode(data: Data(#"{"selector":"field","field":"ok"}"#.utf8)) + + let putGet = await engine.evaluate(#"@put:{"selected":"$.selector"}$.@get:{selected}"#, in: input) + let inline = await engine.evaluate(#"$.{{java.put('inlineUsed','yes'); 'field'}}"#, in: input) + + #expect(putGet.firstString == "ok") + #expect(putGet.variables["selected"] == "field") + #expect(inline.firstString == "ok") + #expect(inline.variables["inlineUsed"] == "yes") + } + + @Test func unquotedPutRulesPreserveBookIDsAndReturnTheSelectedTitle() async throws { + let input = try JSONValue.decode(data: Data(#"{"id":42,"title":"书名","selector":"field","field":"正文"}"#.utf8)) + let result = await engine.evaluate("title@put:{bid:id, selected:$.selector}", in: input) + #expect(result.diagnostics.isEmpty) + #expect(result.firstString == "书名") + #expect(result.variables["bid"] == "42") + #expect(result.variables["selected"] == "field") + let body = await engine.evaluate("$.@get:{selected}", in: input, context: .init(variables: result.variables)) + #expect(body.firstString == "正文") + let html = await engine.evaluate("h1@text@put:{chapter:a@href}", inText: "

    章节

    目录") + #expect(html.diagnostics.isEmpty) + #expect(html.firstString == "章节") + #expect(html.variables["chapter"] == "/1") + } + + @Test func dynamicDoubleBracesTreatJSONPathAsNestedRuleInsteadOfJavaScript() async throws { + let input = try JSONValue.decode(data: Data(#"{"book_id":"42","category":"科幻"}"#.utf8)) + + let value = await engine.evaluate(#"{{$.category}}-{{$.book_id}}"#, in: input) + let piped = await engine.evaluate(#"{{$.category}}@js:result + '-已处理'"#, in: input) + + #expect(value.firstString == "科幻-42") + #expect(value.diagnostics.isEmpty) + #expect(piped.firstString == "科幻-已处理") + #expect(piped.diagnostics.isEmpty) + } + + @Test func putOnlyRuleKeepsCurrentResultWhileWritingVariable() async throws { + let input = try JSONValue.decode(data: Data(#"{"name":"保留"}"#.utf8)) + + let result = await engine.evaluate(#"@put:{"saved":"$.name"}"#, in: input) + + #expect(result.values == [input]) + #expect(result.variables["saved"] == "保留") + } + + @Test func allInOneRegexProducesCaptureRowsAndDollarFields() async throws { + let rows = await engine.evaluate( + #":name=([^;]+);url=([^\n]+)"#, + inText: "name=甲;url=/a\nname=乙;url=/b", + listMode: true + ) + let first = try #require(rows.values.first) + + let name = await engine.evaluate("$1", in: first) + let url = await engine.evaluate("$2", in: first) + + #expect(rows.values.count == 2) + #expect(name.firstString == "甲") + #expect(url.firstString == "/a") + } + + @Test func allInOneRegexSupportsSequentialAndChain() async { + let rows = await engine.evaluate( + #":.*?&&name=(\w+)"#, + inText: "name=firstxname=second", + listMode: true + ) + + #expect(rows.values.count == 2) + #expect(rows.values[0][1]?.stringValue == "first") + #expect(rows.values[1][1]?.stringValue == "second") + } +} diff --git a/Tests/LegadoCoreTests/RuleExpressionParserTests.swift b/Tests/LegadoCoreTests/RuleExpressionParserTests.swift new file mode 100644 index 0000000..2557c83 --- /dev/null +++ b/Tests/LegadoCoreTests/RuleExpressionParserTests.swift @@ -0,0 +1,32 @@ +import Testing +@testable import LegadoCore + +struct RuleExpressionParserTests { + private let parser = RuleExpressionParser() + + @Test func detectsModesAndReplacement() throws { + let plan = try parser.parse("@Json:$.book.name##\\s+## ") + + #expect(plan.combination == .single) + #expect(plan.branches.count == 1) + #expect(plan.branches[0].mode == .jsonPath) + #expect(plan.branches[0].expression == "$.book.name") + #expect(plan.branches[0].replacePattern == "\\s+") + #expect(plan.branches[0].replacement == " ") + } + + @Test func doesNotSplitOperatorsInsideJSONPathFilter() throws { + let plan = try parser.parse("$.books[?(@.enabled && @.score > 1)]||$.fallback") + + #expect(plan.combination == .fallback) + #expect(plan.branches.count == 2) + #expect(plan.branches[0].expression.contains("&&")) + } + + @Test func rejectsUnbalancedRules() { + #expect(throws: RuleEngineError.self) { + try parser.parse("$.books[0") + } + } +} + diff --git a/Tests/LegadoCoreTests/SearchPerformanceTests.swift b/Tests/LegadoCoreTests/SearchPerformanceTests.swift new file mode 100644 index 0000000..369be0f --- /dev/null +++ b/Tests/LegadoCoreTests/SearchPerformanceTests.swift @@ -0,0 +1,161 @@ +import Foundation +import Testing +@testable import LegadoCore + +struct SearchPerformanceTests { + @Test @MainActor func complexSourcesCannotOccupyEverySearchSlot() async throws { + let webView = DelayedPerformanceWebView() + var sources = (0..<8).map { source("web\($0)") } + for index in sources.indices { + sources[index].searchUrl = #"/search,{"webView":true}"# + } + let fast = source("fast") + sources.append(fast) + var outcomes: [SourceSearchOutcome] = [] + let service = BookSearchService(transport: PerformanceSearchTransport(), webViewExecutor: webView, history: .init()) + for await value in service.searchOutcomes(keyword: "书", sources: sources, maximumConcurrentSources: 8) { + outcomes.append(value) + } + #expect(outcomes.first?.sourceID == fast.id) + #expect(webView.maximumRunning == 4) + #expect(outcomes.count == 9) + } + + @Test func preferredSourcesRunBeforeUnknownSourcesAndDuplicateIDsRunOnce() async { + let transport = PerformanceSearchTransport() + let service = BookSearchService(transport: transport, history: .init()) + let sources = [source("a"), source("b"), source("a")] + var results: [String] = [] + for await outcome in service.searchOutcomes(keyword: "书", sources: sources, maximumConcurrentSources: 1, preferredSourceIDs: [sources[1].id]) { + results.append(outcome.sourceID) + } + #expect(results == [sources[1].id, sources[0].id]) + #expect(await transport.requests().count == 2) + } + + @Test func historySurvivesRelaunchAndFailuresRemainEligibleForRecovery() async { + let suite = "search.history.\(UUID().uuidString)" + defer { UserDefaults(suiteName: suite)?.removePersistentDomain(forName: suite) } + let history = BookSearchHistory(defaults: UserDefaults(suiteName: suite)!) + let a = source("a"), b = source("b") + let now = Date() + let book = SearchBook(bookURL: "book", origin: a.id, originName: "A", type: 0, name: "书") + await history.record(.init(sourceID: a.id, books: [book], diagnostics: []), now: now) + await history.flush() + let reloaded = BookSearchHistory(defaults: UserDefaults(suiteName: suite)!) + #expect(await reloaded.ordered([b, a], preferred: [], now: now).map(\.id) == [a.id, b.id]) + await reloaded.record(.init(sourceID: a.id, books: [], diagnostics: [ + .init(severity: .error, code: "search.request", message: "offline", rule: "") + ]), now: now) + #expect(await reloaded.ordered([a, b], preferred: [], now: now).map(\.id) == [b.id, a.id]) + #expect(await reloaded.ordered([a, b], preferred: [], now: now.addingTimeInterval(301)).map(\.id) == [a.id, b.id]) + } + + @Test func multiSourceRequestsHaveShorterTimeoutWithoutChangingSingleSourceAllowance() async { + let transport = PerformanceSearchTransport() + let service = BookSearchService(transport: transport, history: .init()) + let a = source("a"), b = source("b") + _ = await service.search(keyword: "书", sources: [a, b]) + let multi = await transport.requests() + #expect(multi.allSatisfy { $0.timeoutInterval <= 8 }) + _ = await service.search(keyword: "书", page: 1, source: a) + #expect(await transport.requests().last?.timeoutInterval == 30) + } + + @Test func incrementalMergeKeepsRelevanceOriginsAndCanReplaceCachedMetadata() { + var accumulator = BookSearchAccumulator() + let cached = SearchBook(bookURL: "old", origin: "a", originName: "A", type: 0, name: "书", author: "作者") + let fresh = SearchBook(bookURL: "fresh", origin: "b", originName: "B", type: 0, name: "书", author: "作者") + accumulator.add([.init(bookURL: "other", origin: "a", originName: "A", type: 0, name: "其他"), cached]) + accumulator.add([fresh], replacingExisting: true) + let results = accumulator.results(keyword: "书") + #expect(results.map(\.bookURL) == ["fresh", "other"]) + #expect(results[0].origins == ["a", "b"]) + } + + @Test func cacheQueryUsesLiteralKeywordAndDoesNotRequireDecodingSourceCatalog() async throws { + let directory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + defer { try? FileManager.default.removeItem(at: directory) } + let database = try LegadoDatabase(url: directory.appending(path: "cache.sqlite3")) + try await database.replaceBookSources([.init(bookSourceUrl: "source", bookSourceName: "源")]) + let repository = SQLiteSearchBookRepository(database: database) + let exact = SearchBook(bookURL: "exact", origin: "source", originName: "源", type: 0, name: "Swift") + let literal = SearchBook(bookURL: "literal", origin: "source", originName: "源", type: 0, name: "100% 小说") + try await repository.put([exact, literal]) + #expect(try await repository.cachedMatches(keyword: "%").map(\.bookURL) == ["literal"]) + #expect(try await repository.cachedMatches(keyword: "swift").map(\.bookURL) == ["exact"]) + #expect(try await repository.cachedMatches(keyword: "").isEmpty) + } + + @Test(.timeLimit(.minutes(1))) func blockingBodyJavaScriptDoesNotBlockOtherSourcesNetworkPolicy() async throws { + let gate = BlockingSearchJavaScriptLoader() + defer { gate.release() } + let policy = SourceNetworkPolicy(javaScript: .init(networkLoader: gate)) + var prepared = try LegadoURLBuilder().prepare("https://body.example/search") + prepared.bodyJavaScript = "java.ajax('https://gate.example/body')" + let blockingPrepared = prepared + let transport = PerformanceSearchTransport() + let slow = Task { + try await policy.execute(blockingPrepared, source: source("body"), transport: transport, timeout: 1) + } + var started = gate.started.makeAsyncIterator() + _ = await started.next() + let plain = try await policy.execute(LegadoURLBuilder().prepare("https://plain.example/search"), + source: source("plain"), transport: transport, timeout: 1) + #expect(plain.statusCode == 200) + #expect(!gate.didTimeOut) + gate.release() + #expect(try await slow.value.decodedText == "released") + } + + private func source(_ name: String) -> BookSource { + var value = BookSource(bookSourceUrl: "https://\(name).example", bookSourceName: name) + value.searchUrl = "/search?q={{key}}" + value.ruleSearch = .init(bookList: "$.books", name: "$.name", author: "$.author", bookUrl: "$.url") + return value + } +} + +@MainActor private final class DelayedPerformanceWebView: SourceWebViewRequestExecuting { + private var running = 0 + private(set) var maximumRunning = 0 + func execute(_ prepared: PreparedRequest, source: BookSource, timeout: TimeInterval) async throws -> HTTPResponse { + running += 1 + maximumRunning = max(maximumRunning, running) + defer { running -= 1 } + try await Task.sleep(for: .milliseconds(200)) + return .init(data: Data(#"{"books":[{"name":"书","author":"作者","url":"/book"}]}"#.utf8), + statusCode: 200, headers: [:], finalURL: prepared.url) + } +} + +private actor PerformanceSearchTransport: HTTPTransport { + private var recorded: [URLRequest] = [] + func data(for request: URLRequest) async throws -> HTTPResponse { + recorded.append(request) + return .init(data: Data(#"{"books":[{"name":"书","author":"作者","url":"/book"}]}"#.utf8), + statusCode: 200, headers: [:], finalURL: request.url!) + } + func requests() -> [URLRequest] { recorded } +} + +private final class BlockingSearchJavaScriptLoader: JavaScriptNetworkLoading, @unchecked Sendable { + let started: AsyncStream + private let continuation: AsyncStream.Continuation + private let semaphore = DispatchSemaphore(value: 0) + private let lock = NSLock() + private var timedOut = false + var didTimeOut: Bool { lock.withLock { timedOut } } + init() { + (started, continuation) = AsyncStream.makeStream(of: Void.self) + } + func load(_ request: URLRequest) throws -> HTTPResponse { + continuation.yield(()) + if semaphore.wait(timeout: .now() + 2) == .timedOut { + lock.withLock { timedOut = true } + throw URLError(.timedOut) + } + return .init(data: Data("released".utf8), statusCode: 200, headers: [:], finalURL: request.url!) + } + func release() { semaphore.signal() } +} diff --git a/Tests/LegadoCoreTests/SourceCompatibilityTests.swift b/Tests/LegadoCoreTests/SourceCompatibilityTests.swift new file mode 100644 index 0000000..0fbd4b0 --- /dev/null +++ b/Tests/LegadoCoreTests/SourceCompatibilityTests.swift @@ -0,0 +1,321 @@ +import Foundation +import Testing +@testable import LegadoCore + +private struct CompatibilityTransport: HTTPTransport { + let pages: [String: String] + + func data(for request: URLRequest) async throws -> HTTPResponse { + let url = try #require(request.url) + let page = try #require(pages[url.absoluteString], "Unexpected URL: \(url)") + if url.path == "/book/42" { + #expect(request.httpMethod == "POST") + #expect(request.value(forHTTPHeaderField: "X-Token") == "token") + #expect(request.httpBody.map { String(decoding: $0, as: UTF8.self) } == "id=42") + } + return .init(data: Data(page.utf8), statusCode: 200, headers: [:], finalURL: url) + } +} + +private struct PostPaginationTransport: HTTPTransport { + func data(for request: URLRequest) async throws -> HTTPResponse { + let url = try #require(request.url) + #expect(request.httpMethod == "POST") + let second = request.httpBody == Data("page=2".utf8) + let page = second ? "2" : "1" + let next = second ? "" : #"/pages,{"method":"POST","body":"page=2"}"# + let data = try JSONSerialization.data(withJSONObject: [ + "chapters": [["name": "第\(page)章", "url": "/chapter/\(page)"]], + "content": "第\(page)段", "next": next + ]) + return .init(data: data, statusCode: 200, headers: [:], finalURL: url) + } +} + +struct SourceCompatibilityTests { + @Test(.enabled(if: ProcessInfo.processInfo.environment["LEGADO_LIVE_READABILITY_AUDIT"] != nil)) + func liveSearchResultReadability() async throws { + let path = try #require(ProcessInfo.processInfo.environment["LEGADO_SOURCE_CATALOG"]) + let imported = try BookSourceImporter().inspect(Data(contentsOf: URL(fileURLWithPath: path))) + let sources = Dictionary(imported.sources.map { ($0.id, $0) }, uniquingKeysWith: { _, last in last }).values + .filter { $0.enabled && $0.bookSourceType == 0 } + let worker = Task { () -> [SearchBook] in + var books: [SearchBook] = [] + for await outcome in BookSearchService().searchOutcomes(keyword: "极道天魔", sources: Array(sources)) { + guard !Task.isCancelled else { break } + books += outcome.books.filter { $0.name == "极道天魔" && $0.author.contains("滚开") } + if books.count >= 24 { break } + } + return books + } + let deadline = Task { + try? await Task.sleep(for: .seconds(30)) + if !Task.isCancelled { worker.cancel() } + } + let books = await worker.value + deadline.cancel() + await withTaskGroup(of: String.self) { group in + for book in books { + guard let source = sources.first(where: { $0.id == book.origin }) else { continue } + group.addTask { + let service = NetworkBookService() + let detail = await service.loadDetail(searchBook: book, source: source) + let toc = await service.loadChapters(book: detail.book, source: source) + guard let chapter = toc.chapters.first(where: { !$0.isVolume }) else { + return "READABILITY \(source.bookSourceName): no directory; \((detail.diagnostics + toc.diagnostics).map(\.code))" + } + let body = await service.loadContent(book: detail.book, chapter: chapter, source: source, + nextChapterURL: toc.chapters.dropFirst(chapter.index + 1).first(where: { !$0.isVolume })?.url) + let errors = body.diagnostics.filter { $0.severity == .error }.map { "\($0.code): \($0.message)" } + return "READABILITY \(source.bookSourceName): \(toc.chapters.count) chapters, \(body.content.count) characters; \(errors)" + } + } + for await result in group { print(result) } + } + print("READABILITY audited \(books.count) search results") + } + + @Test(.enabled(if: ProcessInfo.processInfo.environment["LEGADO_SEARCH_BENCHMARK"] != nil)) + func liveMultiSourceSearchLatency() async throws { + let environment = ProcessInfo.processInfo.environment + let path = try #require(environment["LEGADO_SOURCE_CATALOG"]) + let decoded = try BookSourceImporter().inspect(Data(contentsOf: URL(fileURLWithPath: path))) + let sources = Dictionary(decoded.sources.map { ($0.id, $0) }, uniquingKeysWith: { _, last in last }) + .values.sorted { $0.bookSourceName.localizedStandardCompare($1.bookSourceName) == .orderedAscending } + let started = ContinuousClock.now + let worker = Task { () -> [String: Double] in + var times: [String: Double] = [:] + var completed = 0 + for await outcome in BookSearchService().searchOutcomes(keyword: "极道天魔", sources: sources) { + guard !Task.isCancelled else { break } + completed += 1 + let elapsed = started.duration(to: .now) + let seconds = Double(elapsed.components.seconds) + Double(elapsed.components.attoseconds) / 1e18 + if !outcome.books.isEmpty, times["firstResultSeconds"] == nil { + times["firstResultSeconds"] = seconds + print("Search benchmark first result: \(seconds)s, \(outcome.sourceID)") + } + if outcome.books.contains(where: { $0.name == "极道天魔" && $0.author == "滚开" }) { + times["firstExactSeconds"] = seconds + print("Search benchmark exact result: \(seconds)s, \(outcome.sourceID)") + break + } + } + times["completedSources"] = Double(completed) + times["totalSources"] = Double(sources.filter { $0.enabled && $0.searchUrl?.isEmpty == false }.count) + return times + } + let deadline = Task { + try? await Task.sleep(for: .seconds(60)) + if !Task.isCancelled { worker.cancel() } + } + let measurements = await worker.value + deadline.cancel() + print("Search benchmark: \(measurements)") + if let output = environment["LEGADO_SEARCH_BENCHMARK_OUTPUT"] { + try JSONSerialization.data(withJSONObject: measurements, options: [.prettyPrinted, .sortedKeys]) + .write(to: URL(fileURLWithPath: output)) + } + } + + @Test func qqArrayContentBecomesParagraphsWithoutOptionalCleanupRule() async throws { + var source = BookSource(bookSourceUrl: "https://fixture.test", bookSourceName: "JSON 正文") + var rule = ContentRule() + rule.content = "

    {{$.data.Content[0].Content}}

    " + source.ruleContent = rule + let transport = CompatibilityTransport(pages: [ + "https://fixture.test/chapter/1": #"{"data":{"Content":[{"Content":["第一段\r\n 第二段","

    第三段

    "]}]}}"# + ]) + let book = Book(bookUrl: "https://fixture.test/book", name: "测试", origin: source.id) + let chapter = BookChapter(url: "https://fixture.test/chapter/1", title: "第一章", bookUrl: book.bookUrl, index: 0) + let report = await NetworkBookService(transport: transport).loadContent(book: book, chapter: chapter, source: source) + #expect(report.diagnostics.isEmpty) + #expect(report.content == "第一段\n第二段\n第三段\n") + } + + @Test func paginatedPostRequestsToSameEndpointAreDistinctPages() async throws { + var source = BookSource(bookSourceUrl: "https://fixture.test", bookSourceName: "POST 分页") + var tocRule = TocRule() + tocRule.chapterList = "$.chapters" + tocRule.chapterName = "$.name" + tocRule.chapterUrl = "$.url" + tocRule.nextTocUrl = "$.next" + source.ruleToc = tocRule + var contentRule = ContentRule() + contentRule.content = "$.content" + contentRule.nextContentUrl = "$.next" + source.ruleContent = contentRule + var book = Book(bookUrl: "https://fixture.test/book", name: "测试", origin: source.id) + book.tocUrl = #"https://fixture.test/pages,{"method":"POST","body":"page=1"}"# + let service = NetworkBookService(transport: PostPaginationTransport()) + let toc = await service.loadChapters(book: book, source: source) + #expect(toc.diagnostics.isEmpty) + #expect(toc.chapters.map(\.title) == ["第1章", "第2章"]) + #expect(toc.pagesVisited == 2) + let chapter = BookChapter(url: book.tocUrl, title: "第一章", bookUrl: book.bookUrl, index: 0) + let content = await service.loadContent(book: book, chapter: chapter, source: source) + #expect(content.diagnostics.isEmpty) + #expect(content.content == "第1段\n第2段") + #expect(content.pagesVisited == 2) + } + + @Test func responseHonorsLegacyHTMLCharsetWithoutRequestOverride() throws { + let encoding = try #require(HTTPTextDecoder.encoding(named: "gbk")) + let html = "

    中文正文

    " + let data = try #require(html.data(using: encoding)) + let response = HTTPResponse(data: data, statusCode: 200, headers: [:], finalURL: URL(string: "https://fixture.test")!) + #expect(response.decodedText == html) + } + + @Test func tableSearchCarriesRequestOptionsThroughDetailTocAndContent() async throws { + let transport = CompatibilityTransport(pages: [ + "https://fixture.test/search": "
    书名
    测试书作者
    ", + "https://fixture.test/book/42": "

    测试书详情

    目录", + "https://fixture.test/toc": "", + "https://fixture.test/chapter/1": "

    真正正文

    广告

    " + ]) + var source = BookSource(bookSourceUrl: "https://fixture.test", bookSourceName: "传统表格源") + source.searchUrl = "/search" + source.ruleSearch = .init( + bookList: "class.grid@tag.tr!0", name: "tag.td.0@text", author: "tag.td.1@text", + bookUrl: #"@js:'/book/42,{"method":"POST","headers":{"X-Token":"token"},"body":"id=42"}'"#, + coverUrl: "@js:book.origin + '/cover/' + key + '-' + page + '.jpg'" + ) + var info = BookInfoRule() + info.name = "tag.h1@text" + info.tocUrl = "class.toc@href" + source.ruleBookInfo = info + var toc = TocRule() + toc.chapterList = "id.list@tag.a" + toc.chapterName = "text" + toc.chapterUrl = "href" + source.ruleToc = toc + var content = ContentRule() + content.content = "resultclass.body@html" + content.replaceRegex = "##广告##" + source.ruleContent = content + + let search = await BookSearchService(transport: transport).search(keyword: "test", page: 2, source: source) + #expect(search.diagnostics.isEmpty) + let found = try #require(search.books.first) + #expect(found.name == "测试书") + #expect(found.author == "作者") + #expect(found.coverURL == "https://fixture.test/cover/test-2.jpg") + #expect(found.bookURL.contains(#",{"method":"POST""#)) + let service = NetworkBookService(transport: transport) + let detail = await service.loadDetail(searchBook: found, source: source) + #expect(detail.diagnostics.isEmpty) + #expect(detail.book.name == "测试书详情") + let chapters = await service.loadChapters(book: detail.book, source: source) + #expect(chapters.diagnostics.isEmpty) + #expect(chapters.chapters.map(\.title) == ["第一章", "第二章"]) + let chapter = try #require(chapters.chapters.first) + let body = await service.loadContent(book: detail.book, chapter: chapter, source: source) + #expect(body.diagnostics.isEmpty) + #expect(body.content.trimmingCharacters(in: .whitespacesAndNewlines) == "真正正文") + } + + @Test func syntheticHTMLRulesExtractBareTocFieldsAndCleanContent() async throws { + let fixture = try #require(Bundle.module.url(forResource: "synthetic-html-rules", withExtension: "json", subdirectory: "Fixtures")) + let source = try #require(BookSourceImporter().decode(Data(contentsOf: fixture)).first) + let transport = CompatibilityTransport(pages: [ + "https://fixture.invalid/toc": "", + "https://fixture.invalid/chapter/1": "

    第一章

    信息

    第一章 新开端

    真正正文

    (本章完)

    " + ]) + var book = Book(bookUrl: "https://fixture.invalid/book/1", name: "测试", origin: source.id) + book.tocUrl = "https://fixture.invalid/toc" + book.durChapterTitle = "第一章 新开端" + let service = NetworkBookService(transport: transport) + let toc = await service.loadChapters(book: book, source: source) + #expect(toc.diagnostics.isEmpty) + #expect(toc.chapters.map(\.title) == ["第一章 新开端", "第二章"]) + let first = try #require(toc.chapters.first) + let content = await service.loadContent(book: book, chapter: first, source: source) + #expect(content.diagnostics.isEmpty) + #expect(content.content.trimmingCharacters(in: .whitespacesAndNewlines) == "真正正文") + } + + @Test func searchUsesJavaScriptObjectsAndKeepsRegexCaptureRows() async throws { + let transport = CompatibilityTransport(pages: ["https://fixture.test/search": "name=甲;url=/a\nname=乙;url=/b"]) + var source = BookSource(bookSourceUrl: "https://fixture.test", bookSourceName: "转换源") + source.searchUrl = "/search" + source.ruleSearch = .init(bookList: #":name=([^;]+);url=([^\n]+)"#, name: "$1", bookUrl: "$2") + let regex = await BookSearchService(transport: transport).search(keyword: "甲", page: 1, source: source) + #expect(regex.diagnostics.isEmpty) + #expect(regex.books.map(\.name) == ["甲", "乙"]) + #expect(regex.books.map(\.bookURL) == ["https://fixture.test/a", "https://fixture.test/b"]) + + source.ruleSearch = .init(bookList: "[{name: '对象结果', url: '/object'}]", name: "name", bookUrl: "url") + let objects = await BookSearchService(transport: transport).search(keyword: "甲", page: 1, source: source) + #expect(objects.diagnostics.isEmpty) + #expect(objects.books.first?.name == "对象结果") + #expect(objects.books.first?.bookURL == "https://fixture.test/object") + } + + @Test(.enabled(if: ProcessInfo.processInfo.environment["LEGADO_SOURCE_CATALOG"] != nil)) + func inspectsDownloadedCatalog() throws { + let path = try #require(ProcessInfo.processInfo.environment["LEGADO_SOURCE_CATALOG"]) + let data = try Data(contentsOf: URL(fileURLWithPath: path)) + let result = try BookSourceImporter().inspect(data) + let raw = try JSONDecoder().decode([JSONValue].self, from: data) + #expect(result.sources.count + result.rejected.count == raw.count) + #expect(!result.sources.isEmpty) + print("Catalog import: \(result.sources.count) valid, \(result.rejected.count) rejected of \(raw.count)") + for item in result.rejected.prefix(10) { + print("Rejected: \(item.source.bookSourceName): \(item.issues.map(\.message).joined(separator: "; "))") + } + } + + @Test(.enabled(if: ProcessInfo.processInfo.environment["LEGADO_LIVE_SOURCE_ID"] != nil)) + func liveSearchToReading() async throws { + let environment = ProcessInfo.processInfo.environment + let path = try #require(environment["LEGADO_SOURCE_CATALOG"]) + let sourceID = try #require(environment["LEGADO_LIVE_SOURCE_ID"]) + let catalog = try BookSourceImporter().inspect(Data(contentsOf: URL(fileURLWithPath: path))) + let source = try #require(catalog.sources.last { $0.id == sourceID }) + let search = await BookSearchService().search(keyword: "极道天魔", page: 1, source: source) + for issue in search.diagnostics { print("Live search diagnostic: \(issue.code): \(issue.message)") } + print("Live search: \(search.books.count) books") + let found = try #require(search.books.first { $0.name == "极道天魔" && $0.author == "滚开" }) + let service = NetworkBookService() + let detail = await service.loadDetail(searchBook: found, source: source) + #expect(detail.book.name == "极道天魔") + let toc = await service.loadChapters(book: detail.book, source: source) + #expect(toc.chapters.count > 2) + for chapter in toc.chapters.prefix(2) { + let body = await service.loadContent(book: detail.book, chapter: chapter, source: source) + for issue in body.diagnostics { print("Live content diagnostic: \(issue.code): \(issue.message)") } + print("Live content shape: numeric=\(body.content.prefix(100).allSatisfy { $0.isNumber || $0 == "," || $0 == "-" }), escapedNewlines=\(body.content.contains(#"\n"#)), paragraphs=\(body.content.components(separatedBy: "\n").count)") + #expect(body.content.count > 500) + #expect(!body.content.contains("

    ")) + #expect(!body.content.contains(#"\r\n"#)) + #expect(body.content.components(separatedBy: "\n").count > 5) + #expect(!body.diagnostics.contains { $0.severity == .error }) + print("Live reading: \(source.bookSourceName), \(chapter.title), \(body.content.count) characters") + } + for issue in detail.diagnostics + toc.diagnostics { + print("Live diagnostic: \(issue.code): \(issue.message)") + } + print("Live directory: \(toc.chapters.count) chapters") + if let replacementID = environment["LEGADO_LIVE_SWITCH_SOURCE_ID"] { + let replacementSource = try #require(catalog.sources.last { $0.id == replacementID }) + var current = detail.book + current.totalChapterNum = toc.chapters.count + current.durChapterIndex = 1 + current.durChapterTitle = toc.chapters[1].title + current.durChapterPos = 120 + let switcher = BookSourceSwitchService(chapterRepository: InMemoryBookChapterRepository()) + let candidates = await switcher.candidates(for: current, sources: [source, replacementSource]) + let candidate = try #require(candidates.first { $0.book.author == "滚开" }) + let switched = try #require(await switcher.prepareSwitch(current: current, candidate: candidate.book, source: replacementSource)) + #expect(switched.book.origin == replacementID) + #expect(switched.exactChapterMatch) + #expect(switched.book.durChapterPos == 120) + let chapter = switched.chapters[switched.book.durChapterIndex] + let body = await service.loadContent(book: switched.book, chapter: chapter, source: replacementSource) + #expect(body.content.count > 500) + #expect(!body.diagnostics.contains { $0.severity == .error }) + print("Live switched reading: \(replacementSource.bookSourceName), \(chapter.title), \(body.content.count) characters, position \(switched.book.durChapterPos)") + } + } +} diff --git a/Tests/LegadoCoreTests/SourceNetworkPolicyTests.swift b/Tests/LegadoCoreTests/SourceNetworkPolicyTests.swift new file mode 100644 index 0000000..b8617e4 --- /dev/null +++ b/Tests/LegadoCoreTests/SourceNetworkPolicyTests.swift @@ -0,0 +1,534 @@ +import Foundation +import Testing +@testable import LegadoCore + +struct SourceNetworkPolicyTests { + @Test func persistentCookieDomainGroupsSubdomains() { + #expect(LegadoCookieStore.persistenceDomain(for: "www.example.com") == "example.com") + #expect(LegadoCookieStore.persistenceDomain(for: "www.content.example.com") == "example.com") + #expect(LegadoCookieStore.persistenceDomain(for: "www.biquge.com.cn") == "biquge.com.cn") + #expect(LegadoCookieStore.persistenceDomain(for: "1.2.3.4") == "1.2.3.4") + #expect(LegadoCookieStore.persistenceDomain(for: "localhost") == "localhost") + } + + @Test func parsesConcurrentRateFormats() { + let burst = SourceRateLimit(rawValue: "3/1000") + let serial = SourceRateLimit(rawValue: "250") + #expect(burst?.accessLimit == 3) + #expect(burst?.intervalMilliseconds == 1_000) + #expect(serial?.accessLimit == 1) + #expect(serial?.intervalMilliseconds == 250) + #expect(SourceRateLimit(rawValue: "0") == nil) + #expect(SourceRateLimit(rawValue: "broken") == nil) + } + + @Test func fixedWindowReservesAndReportsWait() { + let limit = SourceRateLimit(rawValue: "2/100")! + var window = SourceRateWindow() + + #expect(window.reserve(now: 1_000, limit: limit) == 0) + #expect(window.reserve(now: 1_010, limit: limit) == 0) + #expect(window.reserve(now: 1_020, limit: limit) == 80) + #expect(window.reserve(now: 1_100, limit: limit) == 0) + } + + @Test func cookiesAreIsolatedBySourceAndExplicitValuesWin() async throws { + let policy = SourceNetworkPolicy() + let url = URL(string: "https://same.example/path")! + await policy.setCookieHeader("token=A; shared=stored", sourceID: "source-a", url: url) + await policy.setCookieHeader("token=B", sourceID: "source-b", url: url) + + #expect(await policy.cookieHeader(sourceID: "source-a", url: url).contains("token=A")) + #expect(await policy.cookieHeader(sourceID: "source-b", url: url) == "token=B") + + var source = BookSource(bookSourceUrl: "source-a", bookSourceName: "A") + source.enabledCookieJar = true + let prepared = try LegadoURLBuilder().prepare(#"https://same.example/path, {"headers":{"Cookie":"shared=explicit"}}"#) + let transport = RecordingPolicyTransport(results: [.success(.init(data: Data(), statusCode: 200, headers: [:], finalURL: url))]) + _ = try await policy.execute(prepared, source: source, transport: transport, timeout: 1) + let cookie = await transport.requests().first?.value(forHTTPHeaderField: "Cookie") ?? "" + #expect(cookie.contains("token=A")) + #expect(cookie.contains("shared=explicit")) + #expect(!cookie.contains("shared=stored")) + } + + @Test func retriesFailedHTTPResponsesConfiguredNumberAndHonorsDisabledCookieJar() async throws { + let policy = SourceNetworkPolicy() + let url = URL(string: "https://retry.example/data")! + await policy.setCookieHeader("session=secret", sourceID: "source", url: url) + var source = BookSource(bookSourceUrl: "source", bookSourceName: "源") + source.enabledCookieJar = false + let prepared = try LegadoURLBuilder().prepare(#"https://retry.example/data, {"retry":2}"#) + let response = HTTPResponse(data: Data("ok".utf8), statusCode: 200, headers: [:], finalURL: url) + let failed500 = HTTPResponse(data: Data(), statusCode: 500, headers: [:], finalURL: url) + let failed502 = HTTPResponse(data: Data(), statusCode: 502, headers: [:], finalURL: url) + let transport = RecordingPolicyTransport(results: [ + .failure(HTTPClientError.statusResponse(failed500)), + .failure(HTTPClientError.statusResponse(failed502)), + .success(response) + ]) + + let result = try await policy.execute(prepared, source: source, transport: transport, timeout: 1) + + #expect(String(decoding: result.data, as: UTF8.self) == "ok") + #expect(await transport.requests().count == 3) + #expect(await transport.requests().allSatisfy { $0.value(forHTTPHeaderField: "Cookie") == nil }) + } + + @Test func doesNotRetryTransportFailures() async throws { + let prepared = try LegadoURLBuilder().prepare(#"https://retry.example/data, {"retry":3}"#) + let transport = RecordingPolicyTransport(results: [.failure(URLError(.timedOut))]) + + await #expect(throws: URLError.self) { + _ = try await SourceNetworkPolicy().execute( + prepared, + source: .init(bookSourceUrl: "retry", bookSourceName: "重试源"), + transport: transport, + timeout: 1 + ) + } + + #expect(await transport.requests().count == 1) + } + + @Test func capturesCookiesWhileRetryingFailedHTTPResponse() async throws { + let policy = SourceNetworkPolicy() + let url = URL(string: "https://auth.example/data")! + let failed = HTTPResponse( + data: Data(), + statusCode: 429, + headers: ["Set-Cookie": "session=renewed; Path=/; Secure"], + finalURL: url + ) + let success = HTTPResponse(data: Data("ok".utf8), statusCode: 200, headers: [:], finalURL: url) + let transport = RecordingPolicyTransport(results: [ + .failure(HTTPClientError.statusResponse(failed)), + .success(success) + ]) + let source = BookSource(bookSourceUrl: "auth-source", bookSourceName: "登录源") + let prepared = try LegadoURLBuilder().prepare(#"https://auth.example/data, {"retry":1}"#) + + _ = try await policy.execute(prepared, source: source, transport: transport, timeout: 1) + + #expect(await policy.cookieHeader(sourceID: source.id, url: url).contains("session=renewed")) + #expect(await transport.requests().count == 2) + } + + @Test func decodesExplicitLegacyCharsetAndHeaderCharset() throws { + let gb18030 = try #require(HTTPTextDecoder.encoding(named: "GB18030")) + let chinese = "旧编码正文" + let legacyData = try #require(chinese.data(using: gb18030)) + let url = try #require(URL(string: "https://encoding.example")) + + let explicit = HTTPResponse(data: legacyData, statusCode: 200, headers: [:], finalURL: url, requestedCharset: "gbk") + let header = HTTPResponse(data: legacyData, statusCode: 200, headers: ["Content-Type": "text/html; charset=GB18030"], finalURL: url) + + #expect(explicit.decodedText == chinese) + #expect(header.decodedText == chinese) + } + + @Test func removesUnicodeBOMBeforeDecoding() throws { + let url = try #require(URL(string: "https://encoding.example")) + let text = "目录正文" + var data = Data([0xFF, 0xFE]) + data.append(try #require(text.data(using: .utf16LittleEndian))) + + #expect(HTTPResponse(data: data, statusCode: 200, headers: [:], finalURL: url).decodedText == text) + } + + @Test func appliesResponseBodyJavaScriptAndCarriesVariables() async throws { + let url = try #require(URL(string: "https://script.example/content")) + let prepared = try LegadoURLBuilder().prepare( + #"https://script.example/content, {"bodyJs":"java.put('bodyChanged', 'yes'); result.replace('旧正文', '新正文')"}"# + ) + let transport = RecordingPolicyTransport(results: [.success(.init( + data: Data("旧正文".utf8), + statusCode: 200, + headers: [:], + finalURL: url + ))]) + + let response = try await SourceNetworkPolicy().execute( + prepared, + source: .init(bookSourceUrl: "script", bookSourceName: "脚本源"), + transport: transport, + timeout: 1 + ) + + #expect(response.decodedText == "新正文") + #expect(response.requestVariables["bodyChanged"] == "yes") + } + + @Test func routesWebViewRequestsThroughExecutorAndStillAppliesBodyJS() async throws { + let url = try #require(URL(string: "https://web.example/rendered")) + let prepared = try LegadoURLBuilder().prepare( + #"https://web.example/page, {"webView":true,"webJs":"document.body.dataset.ready='1'","webViewDelayTime":250,"bodyJs":"result.replace('原始', '渲染')"}"# + ) + let executor = await MainActor.run { RecordingWebViewExecutor(response: .init( + data: Data("原始正文".utf8), + statusCode: 200, + headers: [:], + finalURL: url + )) } + + let response = try await SourceNetworkPolicy().execute( + prepared, + source: .init(bookSourceUrl: "web", bookSourceName: "网页源"), + transport: RecordingPolicyTransport(results: []), + timeout: 2, + webViewExecutor: executor + ) + + #expect(response.decodedText == "渲染正文") + #expect(await executor.callCount == 1) + #expect(await executor.lastPrepared?.webJavaScript?.contains("dataset.ready") == true) + #expect(await executor.lastPrepared?.webViewDelayMilliseconds == 250) + } + + @Test func recordsRealPreparedRequestAndFinalResponseForDifferentialTesting() async throws { + let url = try #require(URL(string: "https://snapshot.example/final")) + let prepared = try LegadoURLBuilder().prepare( + #"https://snapshot.example/search?q={{key}}, {"headers":{"Cookie":"session=secret","X-Test":"yes"},"retry":2}"#, + context: .init(key: "测试") + ) + let response = HTTPResponse( + data: Data("正文".utf8), + statusCode: 200, + headers: ["Content-Type": "text/plain; charset=utf-8", "Set-Cookie": "token=private; Path=/"], + finalURL: url + ) + let recorder = MemoryDifferentialSnapshotRecorder(caseID: "html-001") + + _ = try await SourceNetworkPolicy().execute( + prepared, + source: .init(bookSourceUrl: "snapshot-source", bookSourceName: "快照源"), + transport: RecordingPolicyTransport(results: [.success(response)]), + timeout: 12, + snapshotRecorder: recorder, + snapshotStage: .search + ) + + let snapshots = await recorder.snapshots + let snapshot = try #require(snapshots.first) + #expect(snapshot.caseID == "html-001") + #expect(snapshot.stage == .search) + #expect(snapshot.request.ruleURL.contains("{{key}}")) + #expect(snapshot.request.renderedURL.contains("%E6%B5%8B%E8%AF%95")) + #expect(snapshot.request.headers["x-test"] == "yes") + #expect(snapshot.request.headers["cookie"]?.contains("secret") == false) + #expect(snapshot.response.finalURL == url.absoluteString) + #expect(snapshot.response.bodySHA256.count == 64) + #expect(snapshot.response.setCookies["token"]?.contains("private") == false) + } + + @Test func emitsBusinessSnapshotFromContentReport() throws { + let report = ChapterContentReport( + title: "第一章", + content: "正文", + supplementalContent: "副正文", + diagnostics: [], + pagesVisited: 2 + ) + let snapshot = DifferentialBusinessSnapshot.content(caseID: "json-001", report: report) + let object = try JSONSerialization.jsonObject(with: snapshot.encoded()) as? [String: Any] + let result = object?["result"] as? [String: Any] + let content = result?["content"] as? [String: Any] + + #expect(object?["platform"] as? String == "ios") + #expect(object?["flow"] as? String == "content") + #expect(content?["text"] as? String == "正文") + #expect(content?["pagesVisited"] as? Int == 2) + } + + @Test func typeOptionReturnsLowercaseHex() async throws { + let url = try #require(URL(string: "https://binary.example/image")) + let prepared = try LegadoURLBuilder().prepare(#"https://binary.example/image, {"type":"image"}"#) + let transport = RecordingPolicyTransport(results: [.success(.init( + data: Data([0x00, 0x7F, 0xA5, 0xFF]), statusCode: 200, headers: [:], finalURL: url + ))]) + + let response = try await SourceNetworkPolicy().execute( + prepared, + source: .init(bookSourceUrl: "binary", bookSourceName: "二进制源"), + transport: transport, + timeout: 1 + ) + + #expect(response.decodedText == "007fa5ff") + } + + @Test func readsBase64DataURIWithoutCallingNetworkTransport() async throws { + let prepared = try LegadoURLBuilder().prepare( + #"data:application/octet-stream;base64,AP+l, {"type":"image"}"# + ) + let transport = RecordingPolicyTransport(results: []) + + let response = try await SourceNetworkPolicy().execute( + prepared, + source: .init(bookSourceUrl: "data-uri", bookSourceName: "内嵌数据源"), + transport: transport, + timeout: 1 + ) + + #expect(response.decodedText == "00ffa5") + #expect(response.headers["Content-Type"] == "application/octet-stream") + #expect(await transport.requests().isEmpty) + } + + @Test func sourceProxyHeaderIsRemovedAndRoutedThroughProxyTransport() async throws { + let url = try #require(URL(string: "https://proxy.example/data")) + let prepared = try LegadoURLBuilder().prepare( + "https://proxy.example/data", + defaultHeaders: ["proxy": "socks5://127.0.0.1:1080"] + ) + let transport = ProxyRecordingPolicyTransport(response: .init( + data: Data("ok".utf8), statusCode: 200, headers: [:], finalURL: url + )) + + _ = try await SourceNetworkPolicy().execute( + prepared, + source: .init(bookSourceUrl: "proxy", bookSourceName: "代理源"), + transport: transport, + timeout: 1 + ) + + #expect(await transport.proxy == "socks5://127.0.0.1:1080") + #expect(await transport.request?.value(forHTTPHeaderField: "proxy") == nil) + } + + @Test func recordsFinalHTTPFailureAndErrorBodyForDifferentialTesting() async throws { + let url = try #require(URL(string: "https://failure.example/data")) + let failed = HTTPResponse( + data: Data("temporary unavailable".utf8), + statusCode: 503, + headers: ["Retry-After": "0"], + finalURL: url + ) + let recorder = MemoryDifferentialSnapshotRecorder(caseID: "html-002") + let prepared = try LegadoURLBuilder().prepare("https://failure.example/data") + + await #expect(throws: HTTPClientError.self) { + _ = try await SourceNetworkPolicy().execute( + prepared, + source: .init(bookSourceUrl: "failure", bookSourceName: "失败源"), + transport: RecordingPolicyTransport(results: [.failure(HTTPClientError.statusResponse(failed))]), + timeout: 1, + snapshotRecorder: recorder, + snapshotStage: .detail + ) + } + + let snapshots = await recorder.snapshots + let snapshot = try #require(snapshots.first) + #expect(snapshot.response.statusCode == 503) + #expect(snapshot.response.bodySHA256.count == 64) + #expect(snapshot.response.error?.code == "http.503") + #expect(snapshot.response.error?.legadoCode == -7) + } + + @Test func httpTTSRendersSpeakVariablesAndReturnsBinaryAudio() async throws { + let url = try #require(URL(string: "https://tts.example/audio")) + let audio = Data([0x52, 0x49, 0x46, 0x46]) + let transport = RecordingPolicyTransport(results: [.success(.init( + data: audio, + statusCode: 200, + headers: ["Content-Type": "audio/wav; charset=binary"], + finalURL: url + ))]) + var engine = HttpTTS(id: 9, name: "测试", url: #"https://tts.example/audio, {"method":"POST","body":"text={{speakText}}&speed={{speakSpeed}}"}"#) + engine.contentType = #"audio/.*"# + engine.header = #"{"X-Engine":"legado"}"# + engine.loginCheckJs = #"if (result.headers().get('Content-Type').indexOf('audio/') !== 0) throw 'bad content'; result"# + let cache = HttpTTSAudioCache(directory: FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)) + + let service = HttpTTSService( + networkPolicy: .init(), + transport: transport, + cache: cache + ) + let result = try await service.audio(engine: engine, text: "第一段", speechRate: 7) + let cached = try await service.audio(engine: engine, text: "第一段", speechRate: 7) + + #expect(result.data == audio) + #expect(cached.data == audio) + #expect(result.contentType == "audio/wav") + let requests = await transport.requests() + let request = try #require(requests.first) + #expect(request.httpMethod == "POST") + #expect(String(decoding: request.httpBody ?? Data(), as: UTF8.self).contains("speed=7")) + #expect(request.value(forHTTPHeaderField: "X-Engine") == "legado") + #expect(requests.count == 1) + } + + @Test func httpTTSStreamsChunksAndStoresCompletedResponseInCache() async throws { + let url = try #require(URL(string: "https://tts.example/stream")) + let first = Data(repeating: 0x41, count: 4) + let second = Data(repeating: 0x42, count: 3) + let transport = RecordingStreamingTransport(url: url, chunks: [first, second]) + var engine = HttpTTS(id: 12, name: "流式", url: url.absoluteString) + engine.contentType = #"audio/.*"# + let cache = HttpTTSAudioCache(directory: FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)) + let service = HttpTTSService(networkPolicy: .init(), transport: transport, cache: cache) + + let streamed = try await service.audioStream(engine: engine, text: "第一段", speechRate: 10) + #expect(streamed.isIncremental) + #expect(streamed.expectedContentLength == 7) + var received: [Data] = [] + for try await chunk in streamed.chunks { received.append(chunk) } + #expect(received == [first, second]) + + let cached = try await service.audioStream(engine: engine, text: "第一段", speechRate: 10) + #expect(!cached.isIncremental) + var cachedData = Data() + for try await chunk in cached.chunks { cachedData.append(chunk) } + #expect(cachedData == first + second) + #expect(await transport.requestCount() == 1) + } + + @Test func httpTTSDecodesDefaultsWithMissingFieldsAndLoginUIArray() throws { + let data = Data(#"{"id":-100,"name":"百度","url":"https://tts.example","loginUi":[{"name":"token","type":"text"}]}"#.utf8) + let value = try JSONDecoder().decode(HttpTTS.self, from: data) + #expect(value.id == -100) + #expect(value.concurrentRate == nil) + #expect(value.enabledCookieJar == false) + #expect(value.loginUi?.contains("token") == true) + #expect(value.lastUpdateTime > 0) + } + + @Test func httpTTSRejectsTextErrorResponse() async throws { + let url = try #require(URL(string: "https://tts.example/error")) + let response = HTTPResponse( + data: Data("额度不足".utf8), + statusCode: 200, + headers: ["Content-Type": "application/json"], + finalURL: url + ) + let transport = RecordingPolicyTransport(results: Array(repeating: .success(response), count: 6)) + let engine = HttpTTS(id: 10, name: "错误", url: url.absoluteString) + let cache = HttpTTSAudioCache(directory: FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)) + + await #expect(throws: HttpTTSServiceError.self) { + _ = try await HttpTTSService( + networkPolicy: .init(), + transport: transport, + cache: cache + ).audio(engine: engine, text: "测试", speechRate: 5) + } + #expect(await transport.requests().count == 6) + } + + @Test func httpTTSRebuildsRequestWhenLoginCheckInitializesURL() async throws { + let url = try #require(URL(string: "https://tts.example/recover")) + let transport = RecordingPolicyTransport(results: [ + .success(.init( + data: Data(#"{"status":40000001}"#.utf8), + statusCode: 401, + headers: ["Content-Type": "application/json"], + finalURL: url + )), + .success(.init( + data: Data([0x49, 0x44, 0x33]), + statusCode: 200, + headers: ["Content-Type": "audio/mpeg"], + finalURL: url + )) + ]) + var engine = HttpTTS(id: 11, name: "恢复", url: url.absoluteString) + engine.loginCheckJs = "java.initUrl(); java.getResponse()" + engine.contentType = "audio/.*" + let cache = HttpTTSAudioCache(directory: FileManager.default.temporaryDirectory.appending(path: UUID().uuidString)) + + let audio = try await HttpTTSService( + networkPolicy: .init(), + transport: transport, + cache: cache + ).audio(engine: engine, text: "测试", speechRate: 5) + + #expect(audio.contentType == "audio/mpeg") + #expect(await transport.requests().count == 2) + } +} + +private actor RecordingPolicyTransport: HTTPTransport { + private var results: [Result] + private var recorded: [URLRequest] = [] + + init(results: [Result]) { self.results = results } + + func data(for request: URLRequest) async throws -> HTTPResponse { + recorded.append(request) + guard !results.isEmpty else { throw URLError(.badServerResponse) } + return try results.removeFirst().get() + } + + func requests() -> [URLRequest] { recorded } +} + +private actor RecordingStreamingTransport: StreamingHTTPTransport { + let url: URL + let values: [Data] + private var count = 0 + + init(url: URL, chunks: [Data]) { + self.url = url + values = chunks + } + + func data(for request: URLRequest) async throws -> HTTPResponse { + count += 1 + return .init( + data: values.reduce(into: Data()) { $0.append($1) }, + statusCode: 200, + headers: ["Content-Type": "audio/mpeg"], + finalURL: url + ) + } + + func stream(for request: URLRequest) async throws -> HTTPStreamResponse { + count += 1 + let chunks = values + return .init( + statusCode: 200, + headers: ["Content-Type": "audio/mpeg"], + finalURL: url, + expectedContentLength: Int64(chunks.reduce(0) { $0 + $1.count }), + chunks: AsyncThrowingStream { continuation in + for chunk in chunks { continuation.yield(chunk) } + continuation.finish() + } + ) + } + + func requestCount() -> Int { count } +} + +private actor ProxyRecordingPolicyTransport: ProxyHTTPTransport { + let response: HTTPResponse + private(set) var proxy: String? + private(set) var request: URLRequest? + + init(response: HTTPResponse) { self.response = response } + + func data(for request: URLRequest) async throws -> HTTPResponse { response } + + func data(for request: URLRequest, proxy: String) async throws -> HTTPResponse { + self.request = request + self.proxy = proxy + return response + } +} + +@MainActor +private final class RecordingWebViewExecutor: SourceWebViewRequestExecuting, @unchecked Sendable { + let response: HTTPResponse + private(set) var callCount = 0 + private(set) var lastPrepared: PreparedRequest? + + init(response: HTTPResponse) { self.response = response } + + func execute(_ prepared: PreparedRequest, source: BookSource, timeout: TimeInterval) async throws -> HTTPResponse { + callCount += 1 + lastPrepared = prepared + return response + } +} diff --git a/Tests/LegadoReaderTests/ReaderScrollGeometryTests.swift b/Tests/LegadoReaderTests/ReaderScrollGeometryTests.swift new file mode 100644 index 0000000..0519fab --- /dev/null +++ b/Tests/LegadoReaderTests/ReaderScrollGeometryTests.swift @@ -0,0 +1,138 @@ +import SwiftUI +import UIKit +import XCTest +@testable import Legado + +@MainActor +final class ReaderScrollGeometryTests: XCTestCase { + func testPrependingAndRecyclingStayWithinOnePhysicalPixel() async throws { + let chapters = (4...6).map { chapter($0) } + let fixture = host(chapters: chapters, active: 5) + defer { fixture.coordinator.displayLink?.invalidate(); fixture.window.isHidden = true } + let coordinator = fixture.coordinator, view = fixture.view + let layout = view.collectionViewLayout as! ReaderContinuousLayout + let row = try XCTUnwrap(coordinator.blocks.firstIndex { $0.chapter.chapter.id == 5 }) + let id = coordinator.blocks[row].id + view.setContentOffset(CGPoint(x: 0, y: layout.frames[row].minY + 71.25), animated: false) + let screenY = layout.frames[row].minY - view.contentOffset.y + let chapterLayout = coordinator.blocks[row].chapter + + coordinator.parent = surface(chapters: [chapter(2), chapter(3)] + chapters, active: 5) + coordinator.update(view) + let insertedRow = try XCTUnwrap(coordinator.blocks.firstIndex { $0.id == id }) + XCTAssertEqual(layout.frames[insertedRow].minY - view.contentOffset.y, screenY, accuracy: 1 / fixture.window.screen.scale) + XCTAssertTrue(coordinator.blocks[insertedRow].chapter === chapterLayout) + + coordinator.parent = surface(chapters: [chapter(5), chapter(6), chapter(7)], active: 5) + coordinator.update(view) + let recycledRow = try XCTUnwrap(coordinator.blocks.firstIndex { $0.id == id }) + XCTAssertEqual(layout.frames[recycledRow].minY - view.contentOffset.y, screenY, accuracy: 1 / fixture.window.screen.scale) + XCTAssertTrue(coordinator.blocks[recycledRow].chapter === chapterLayout) + } + + func testAppendingAndNaturalChapterChangesDoNotRebuildVisibleText() async throws { + let chapters = (0...2).map { chapter($0) } + let fixture = host(chapters: chapters, active: 0) + defer { fixture.coordinator.displayLink?.invalidate(); fixture.window.isHidden = true } + let coordinator = fixture.coordinator, view = fixture.view + let original = try XCTUnwrap(coordinator.blocks.first?.chapter) + view.setContentOffset(CGPoint(x: 0, y: 123.75), animated: false) + let offset = view.contentOffset + coordinator.parent = surface(chapters: chapters + [chapter(3), chapter(4)], active: 1) + coordinator.update(view) + XCTAssertEqual(view.contentOffset.y, offset.y, accuracy: 0.01) + XCTAssertTrue(coordinator.blocks[0].chapter === original) + XCTAssertEqual(Set(coordinator.blocks.map { $0.chapter.chapter.id }), Set(0...4)) + } + + func testReflowKeepsTheSameTextLineAtTheSameScreenPosition() async throws { + let fixture = host(chapters: [chapter(0), chapter(1)], active: 0) + defer { fixture.coordinator.displayLink?.invalidate(); fixture.window.isHidden = true } + let coordinator = fixture.coordinator, view = fixture.view + let old = try XCTUnwrap(coordinator.blocks.first?.chapter) + let glyph = old.manager.glyphIndexForCharacter(at: old.bodyStart + 200) + let lineY = old.manager.lineFragmentRect(forGlyphAt: glyph, effectiveRange: nil).minY + let startOfLine = old.manager.characterIndexForGlyph(at: old.manager.glyphIndex( + for: CGPoint(x: 0, y: lineY + 0.5), in: old.container)) + let layout = view.collectionViewLayout as! ReaderContinuousLayout + let chapterY = layout.frames[0].minY + view.setContentOffset(CGPoint(x: 0, y: chapterY + lineY + 3.25), animated: false) + let previousScreenY = chapterY + lineY - view.contentOffset.y + coordinator.parent = surface(chapters: [chapter(0), chapter(1)], active: 0, fontSize: 25) + coordinator.update(view) + let new = try XCTUnwrap(coordinator.blocks.first?.chapter) + let newGlyph = new.manager.glyphIndexForCharacter(at: startOfLine) + let newLineY = new.manager.lineFragmentRect(forGlyphAt: newGlyph, effectiveRange: nil).minY + XCTAssertEqual(layout.frames[0].minY + newLineY - view.contentOffset.y, previousScreenY, accuracy: 1 / fixture.window.screen.scale) + } + + func testChunksCoverAllGlyphsAndDoNotRetainTheirChapter() async { + weak var released: ReaderScrollChapterLayout? + autoreleasepool { + let layout = ReaderScrollChapterLayout(chapter: chapter(0), configuration: configuration(), width: 390) + released = layout + XCTAssertGreaterThan(layout.blocks.count, 2) + var nextGlyph = 0 + for block in layout.blocks { + XCTAssertEqual(block.glyphRange.location, nextGlyph) + XCTAssertGreaterThan(block.height, 0) + nextGlyph = NSMaxRange(block.glyphRange) + } + XCTAssertEqual(nextGlyph, layout.manager.numberOfGlyphs) + XCTAssertTrue(layout.storage.string.contains("👨‍👩‍👧‍👦")) + } + XCTAssertNil(released, "Recycled TextKit chapter layouts must be released") + } + + func testShortLastChapterCanReachTheTopWithoutChangingTheRequestedChapter() async throws { + let short = ReaderScrollChapter(id: 8, title: "短章", content: "仅一行正文") + let fixture = host(chapters: [chapter(7), short], active: 8) + defer { fixture.coordinator.displayLink?.invalidate(); fixture.window.isHidden = true } + let layout = fixture.view.collectionViewLayout as! ReaderContinuousLayout + let row = try XCTUnwrap(fixture.coordinator.blocks.firstIndex { $0.chapter.chapter.id == 8 }) + XCTAssertEqual(fixture.view.contentOffset.y, layout.frames[row].minY, accuracy: 1 / fixture.window.screen.scale) + } + + private func chapter(_ index: Int) -> ReaderScrollChapter { + .init(id: index, title: "第\(index)章", content: String(repeating: "正文包含中文、英文 words 与家庭表情👨‍👩‍👧‍👦,用于验证连续阅读的排版和位置。\n", count: 100)) + } + + private func host(chapters: [ReaderScrollChapter], active: Int) -> (window: UIWindow, view: ReaderScrollCollectionView, coordinator: ReaderScrollView.Coordinator) { + let parent = surface(chapters: chapters, active: active) + let coordinator = parent.makeCoordinator() + let view = coordinator.makeView() + let window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844)) + let controller = UIViewController() + controller.view.addSubview(view) + view.frame = window.bounds + window.rootViewController = controller + window.makeKeyAndVisible() + coordinator.update(view) + view.layoutIfNeeded() + return (window, view, coordinator) + } + + private func surface(chapters: [ReaderScrollChapter], active: Int, fontSize: Double = 20) -> ReaderScrollView { + .init(configuration: configuration(fontSize: fontSize), chapters: chapters, + resetID: 0, activeIndex: active, loadingAhead: false, loadingBehind: false, + onNeedChapters: { _ in }, onPositionChanged: { _, _ in }, + onRetainChapters: { _ in }, onRetry: { _ in }) + } + + private func configuration(fontSize: Double = 20) -> ReaderTextView { + .init(text: "", title: "", previousTitle: nil, previousText: "", isPreviousReady: false, + continuationTitle: nil, continuationText: "", isContinuationReady: false, isVolume: false, + fontSize: fontSize, fontPath: "", letterSpacing: 0, lineSpacing: 8, paragraphSpacing: 12, + paragraphIndent: "  ", paddingLeft: 20, paddingRight: 20, paddingTop: 16, paddingBottom: 16, + headerInset: 0, footerInset: 0, fontWeight: 0, titleMode: 0, titleSize: 4, + titleTopSpacing: 16, titleBottomSpacing: 24, underlineMode: 0, isSelectable: false, + fullJustify: true, pageAnimation: 3, isEInkMode: false, clickActions: Array(repeating: 0, count: 9), + backgroundColor: .white, foregroundColor: .black, requestedPosition: 0, jumpPosition: nil, + renderRevision: 0, readerPosition: .constant(0), pageRanges: .constant([]), + currentPage: .constant(1), totalPages: .constant(1), isAutoReading: false, + isAutoReadPaused: false, isLoadingNextChapter: false, secondsPerScreen: 10, + onReachedEnd: { _ in }, onReachedStart: { _ in }, onEnteredPrevious: { _ in }, + onEnteredContinuation: { _ in }, onDeferredRender: {}, onCancelNextLoading: {}, + onPageChanged: {}, onTapAction: { _ in }) + } +} diff --git a/Tests/LegadoReaderTests/WebReaderBridgeTests.swift b/Tests/LegadoReaderTests/WebReaderBridgeTests.swift new file mode 100644 index 0000000..3bdd2a5 --- /dev/null +++ b/Tests/LegadoReaderTests/WebReaderBridgeTests.swift @@ -0,0 +1,40 @@ +import UIKit +import WebKit +import XCTest +@testable import Legado + +@MainActor +final class WebReaderBridgeTests: XCTestCase { + func testPageJavaScriptCanStoreVariablesAndEvaluateContentRules() async throws { + let source = RssSource(sourceUrl: "https://bridge-regression.test/\(UUID().uuidString)", sourceName: "测试") + let loaded = expectation(description: "HTML loaded") + let surface = RSSWebReaderView(mode: .html(content: "

    页面正文

    ", baseURL: source.sourceUrl), source: source, + onTextChange: { _ in loaded.fulfill() }) + let coordinator = surface.makeCoordinator() + let configuration = WKWebViewConfiguration() + configuration.websiteDataStore = .nonPersistent() + configuration.userContentController.add(coordinator, name: "legado") + configuration.userContentController.addUserScript(.init(source: coordinator.bridgeScript, injectionTime: .atDocumentStart, forMainFrameOnly: true)) + let webView = WKWebView(frame: CGRect(x: 0, y: 0, width: 390, height: 844), configuration: configuration) + webView.navigationDelegate = coordinator + webView.uiDelegate = coordinator + let window = UIWindow(frame: webView.frame) + let controller = UIViewController() + controller.view.addSubview(webView) + window.rootViewController = controller + window.makeKeyAndVisible() + defer { + RSSWebReaderView.dismantleUIView(webView, coordinator: coordinator) + window.isHidden = true + } + coordinator.installRulesAndLoad(mode: surface.mode, in: webView) + await fulfillment(of: [loaded], timeout: 20) + + let result = try await webView.evaluateJavaScript(""" + java.put('key', '已保存'); + java.setContent('

    规则正文

    '); + [java.get('key'), java.getString('tag.p@text')].join('|'); + """) + XCTAssertEqual(result as? String, "已保存|规则正文") + } +} diff --git a/Tests/LegadoReaderUITests/LiveReadingFlowUITests.swift b/Tests/LegadoReaderUITests/LiveReadingFlowUITests.swift new file mode 100644 index 0000000..e5ce4a0 --- /dev/null +++ b/Tests/LegadoReaderUITests/LiveReadingFlowUITests.swift @@ -0,0 +1,187 @@ +import XCTest + +@MainActor +final class LiveReadingFlowUITests: XCTestCase { + private let app = XCUIApplication() + + override func setUp() { + super.setUp() + // Xcode's runtime-issue callback can crash on Network.framework's log + // quarantine with thousands of failing hosts. Keep server testing active. + app.launchEnvironment["OS_ACTIVITY_MODE"] = "disable" + } + + func testSearchLatencyAndCachedQuery() throws { + guard ProcessInfo.processInfo.environment["LEGADO_SEARCH_UI_BENCHMARK"] == "1" else { + throw XCTSkip("Use the LegadoSearchPerformanceUITests scheme with the imported catalog") + } + continueAfterFailure = false + app.launchArguments = ["-uiDemoTab", "search", "-uiAutomationSearch", "极道天魔", "-themeMode", "1"] + let started = Date() + app.launch() + let exact = app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "极道天魔, 滚开")).firstMatch + XCTAssertTrue(exact.waitForExistence(timeout: 90)) + print("[SearchPerformance] launch_to_exact_seconds=\(Date().timeIntervalSince(started))") + attach("all-sources-search-latency") + app.buttons["搜索选项"].tap() + if app.buttons["停止搜索"].exists { app.buttons["停止搜索"].tap() } + let search = app.searchFields["书名或作者"] + search.tap() + search.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: 4) + "极道天") + let repeated = Date() + search.typeText("\n") + XCTAssertTrue(app.staticTexts["“极道天”"].waitForExistence(timeout: 10)) + XCTAssertTrue(exact.exists) + print("[SearchPerformance] cached_query_to_result_seconds=\(Date().timeIntervalSince(repeated))") + attach("cached-search-result") + exact.tap() + XCTAssertTrue(app.navigationBars["书籍详情"].waitForExistence(timeout: 10)) + attach("detail-from-running-search") + } + + func testImportSearchDetailReadSwitchAndResume() throws { + guard ProcessInfo.processInfo.environment["LEGADO_LIVE_UI_TESTS"] == "1" else { + throw XCTSkip("Use the LegadoLiveReadingUITests scheme for real-server testing") + } + continueAfterFailure = false + defer { + if (testRun?.failureCount ?? 0) > 0 { + attach("failure") + let tree = XCTAttachment(string: app.debugDescription) + tree.name = "failure-accessibility-tree" + tree.lifetime = .keepAlways + add(tree) + } + } + app.launchArguments = ["-uiDemoTab", "search", "-uiAutomationSearch", "极道天魔", "-pageAnim", "0", "-themeMode", "1"] + app.launch() + XCTAssertTrue(app.navigationBars["搜索"].waitForExistence(timeout: 30)) + app.buttons["搜索选项"].tap() + app.buttons["书源管理"].tap() + XCTAssertTrue(app.navigationBars["书源"].waitForExistence(timeout: 10)) + app.buttons["更多"].firstMatch.tap() + app.buttons["URL 或粘贴 JSON"].tap() + let url = app.textFields["https://example.com/sources.json"] + XCTAssertTrue(url.waitForExistence(timeout: 10)) + url.tap() + url.typeText("https://legado.aoaostar.com/sources/b778fe6b.json") + app.navigationBars["导入书源"].buttons["导入"].tap() + XCTAssertTrue(app.navigationBars["书源导入预检"].waitForExistence(timeout: 120)) + app.navigationBars["书源导入预检"].buttons["选择"].tap() + app.buttons["全选"].tap() + attach("01-import-preview") + XCTAssertTrue(app.navigationBars["书源导入预检"].buttons["导入"].isEnabled) + app.navigationBars["书源导入预检"].buttons["导入"].tap() + XCTAssertTrue(app.alerts["书源导入完成"].waitForExistence(timeout: 90)) + attach("02-import-completed") + app.alerts.buttons["好"].tap() + app.navigationBars["书源"].buttons.element(boundBy: 0).tap() + selectSearchSource("DragonQuestQBall") + let search = app.searchFields["书名或作者"] + search.tap() + search.typeText("\n") + let title = app.buttons.matching(NSPredicate(format: "label BEGINSWITH %@", "极道天魔, 滚开")).firstMatch + XCTAssertTrue(title.waitForExistence(timeout: 90)) + attach("03-search") + title.tap() + XCTAssertTrue(app.navigationBars["书籍详情"].waitForExistence(timeout: 10)) + let start = app.buttons.matching(NSPredicate(format: "label IN %@", ["开始阅读", "继续阅读"])).firstMatch + waitUntil("Readable directory", timeout: 90) { start.exists && start.isEnabled } + XCTAssertTrue(app.staticTexts["滚开"].exists) + attach("04-detail") + if app.buttons["继续阅读"].exists { + let firstChapter = app.staticTexts.matching(NSPredicate(format: "label CONTAINS %@", "道法无情")).firstMatch + XCTAssertTrue(firstChapter.exists) + firstChapter.tap() + } else { + start.tap() + } + waitForBody(containing: "道法无情") + attach("05-reading") + showReaderMenu() + let currentSourceName = app.buttons["reader.changeSource"].value as? String + let replacementID = currentSourceName == "💰QQ阅读[浏览器]" ? "DragonQuestQBall" : "https://bookshelf.html5.qq.com/" + let replacementName = currentSourceName == "💰QQ阅读[浏览器]" ? "●QB男生 500 + } + let value = body.value as? String ?? "" + XCTAssertGreaterThan(value.components(separatedBy: "\n").count, 5) + XCTAssertFalse(value.contains("

    ")) + XCTAssertFalse(value.contains(#"\r\n"#)) + } + + private func showReaderMenu() { + if !app.buttons["换源"].exists { + app.textViews["reader.text"].coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + } + XCTAssertTrue(app.buttons["换源"].waitForExistence(timeout: 10)) + } + + private func waitUntil(_ name: String, timeout: TimeInterval, condition: @escaping () -> Bool) { + let ready = expectation(for: NSPredicate { _, _ in condition() }, evaluatedWith: nil) + ready.expectationDescription = name + wait(for: [ready], timeout: timeout) + } + + private func attach(_ name: String) { + let screenshot = XCTAttachment(screenshot: app.screenshot()) + screenshot.name = name + screenshot.lifetime = .keepAlways + add(screenshot) + } + +} diff --git a/Tests/LegadoReaderUITests/ReaderScrollGestureTests.swift b/Tests/LegadoReaderUITests/ReaderScrollGestureTests.swift new file mode 100644 index 0000000..9073b7a --- /dev/null +++ b/Tests/LegadoReaderUITests/ReaderScrollGestureTests.swift @@ -0,0 +1,206 @@ +import XCTest + +@MainActor +final class ReaderScrollGestureTests: XCTestCase { + func testFailedChaptersAllowRetryMenuAndSourceSwitchInBothReadingModes() throws { + continueAfterFailure = false + for mode in ["3", "0"] { + let app = XCUIApplication() + app.launchArguments = ["-uiDemo", "-uiDemoReader", "-uiReaderFailure", "-pageAnim", mode, "-themeMode", "0"] + app.launch() + let retry = app.buttons["reader.retryChapter"] + XCTAssertTrue(retry.waitForExistence(timeout: 20)) + retry.tap() + XCTAssertTrue(app.buttons["reader.failureChangeSource"].waitForExistence(timeout: 10)) + app.buttons["reader.failureChangeSource"].tap() + XCTAssertTrue(app.navigationBars["为《长夜里的灯》换源"].waitForExistence(timeout: 10)) + app.buttons["取消"].tap() + XCTAssertTrue(retry.waitForExistence(timeout: 10)) + if mode == "3" { + app.collectionViews["reader.continuous"].coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.4)).tap() + } else { + app.buttons["reader.failureMenu"].tap() + } + let changeSource = app.buttons["reader.changeSource"] + XCTAssertTrue(changeSource.waitForExistence(timeout: 5)) + changeSource.tap() + XCTAssertTrue(app.navigationBars["为《长夜里的灯》换源"].waitForExistence(timeout: 10)) + attachScreenshot(app) + app.terminate() + } + } + + func testPagedReadingReportsPageCountAndTurnsWithinTheSameChapter() throws { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-uiDemo", "-uiDemoReader", "-uiScrollRegression", "-uiPagingDiagnostics", "-pageAnim", "4", "-themeMode", "1", "-tipFooterRight", "6", "-textSize", "28"] + app.launch() + let body = app.textViews["reader.text"] + XCTAssertTrue(body.waitForExistence(timeout: 20)) + let page = app.staticTexts.matching(NSPredicate(format: "label MATCHES %@", "[0-9]+/[0-9]+.*")).firstMatch + let ready = expectation(for: NSPredicate { _, _ in + print("[PagedReader] \(body.label) footer=\(page.label)") + let total = page.label.split(separator: "/").last?.split(separator: " ").first.flatMap { Int($0) } ?? 0 + return page.exists && page.label.hasPrefix("1/") && total > 1 + }, evaluatedWith: nil) + wait(for: [ready], timeout: 10) + let chapter = body.value as? String + app.coordinate(withNormalizedOffset: CGVector(dx: 0.8, dy: 0.5)).tap() + let next = expectation(for: NSPredicate { _, _ in page.label.hasPrefix("2/") }, evaluatedWith: nil) + wait(for: [next], timeout: 5) + XCTAssertEqual(body.value as? String, chapter) + app.coordinate(withNormalizedOffset: CGVector(dx: 0.2, dy: 0.5)).tap() + let previous = expectation(for: NSPredicate { _, _ in page.label.hasPrefix("1/") }, evaluatedWith: nil) + wait(for: [previous], timeout: 5) + XCTAssertEqual(body.value as? String, chapter) + attachScreenshot(app) + } + + func testPagedReadingChapterNavigationDirectorySearchAndSettings() throws { + try exerciseChapterNavigationAndSettings(usesLegacyUI: false) + } + + func testLegacyReadingChapterNavigationDirectorySearchAndPhotoPicker() throws { + try exerciseChapterNavigationAndSettings(usesLegacyUI: true) + } + + private func exerciseChapterNavigationAndSettings(usesLegacyUI: Bool) throws { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-uiDemo", "-uiDemoReader", "-pageAnim", "0", "-themeMode", "1"] + if usesLegacyUI { app.launchArguments.append("-uiLegacyCompatibility") } + app.launch() + let body = app.textViews["reader.text"] + XCTAssertTrue(body.waitForExistence(timeout: 20)) + func waitForChapter(_ title: String) { + let ready = expectation(for: NSPredicate { _, _ in + (body.value as? String ?? "").contains(title) + }, evaluatedWith: nil) + wait(for: [ready], timeout: 15) + } + func showMenu() { + if !app.buttons["目录"].exists { + app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + } + XCTAssertTrue(app.buttons["目录"].waitForExistence(timeout: 5)) + } + waitForChapter("第一章 · 雨夜") + showMenu() + XCTAssertFalse(app.buttons["上一章"].isEnabled) + app.buttons["下一章"].tap() + waitForChapter("第二章 · 灯塔") + showMenu() + app.buttons["上一章"].tap() + waitForChapter("第一章 · 雨夜") + + showMenu() + app.buttons["目录"].tap() + XCTAssertTrue(app.navigationBars["目录"].waitForExistence(timeout: 5)) + let chapterSearch = app.searchFields["搜索章节"] + chapterSearch.tap() + chapterSearch.typeText("天明") + app.staticTexts["第五章 · 天明"].tap() + waitForChapter("第五章 · 天明") + showMenu() + XCTAssertFalse(app.buttons["下一章"].isEnabled) + + app.buttons["更多"].tap() + app.buttons["搜索正文"].tap() + XCTAssertTrue(app.navigationBars["正文搜索"].waitForExistence(timeout: 5)) + let contentSearch = app.searchFields["搜索本章"] + contentSearch.tap() + contentSearch.typeText("灯塔") + let match = app.buttons.matching(NSPredicate(format: "label CONTAINS %@", "林深推开阁楼的窗")).firstMatch + XCTAssertTrue(match.waitForExistence(timeout: 5)) + match.tap() + XCTAssertFalse(app.navigationBars["正文搜索"].exists) + + showMenu() + app.buttons["书签"].tap() + app.buttons["更多"].tap() + app.buttons["书签列表"].tap() + XCTAssertTrue(app.navigationBars["书签"].waitForExistence(timeout: 5)) + XCTAssertTrue(app.staticTexts["第五章 · 天明"].firstMatch.waitForExistence(timeout: 5)) + app.buttons["关闭"].tap() + + showMenu() + app.buttons["外观"].tap() + XCTAssertTrue(app.navigationBars["阅读样式"].waitForExistence(timeout: 5)) + if usesLegacyUI { + app.buttons["选择背景图片"].tap() + let cancel = app.buttons.matching(NSPredicate(format: "label IN %@", ["Cancel", "取消"])).firstMatch + XCTAssertTrue(cancel.waitForExistence(timeout: 10)) + cancel.tap() + XCTAssertTrue(app.navigationBars["阅读样式"].waitForExistence(timeout: 5)) + } + app.buttons["完成"].tap() + showMenu() + app.buttons["设置"].tap() + XCTAssertTrue(app.navigationBars["阅读设置"].waitForExistence(timeout: 5)) + app.buttons["完成"].tap() + waitForChapter("第五章 · 天明") + attachScreenshot(app) + } + + func testForwardAndReverseFlicksKeepMomentumAcrossChapterInsertion() throws { + let (app, reader) = launchReader(shortChapters: false) + let initial = try value("chapter", in: reader) + for _ in 0..<6 { flick(reader, forward: true) } + let forward = try value("chapter", in: reader) + XCTAssertGreaterThan(forward, initial + 2) + for _ in 0..<6 { flick(reader, forward: false) } + XCTAssertLessThan(try value("chapter", in: reader), forward) + XCTAssertGreaterThan(try value("decelInsertions", in: reader), 0) + XCTAssertGreaterThan(try value("continued", in: reader), 0) + XCTAssertEqual(try value("interrupted", in: reader), 0) + attachScreenshot(app) + } + + func testManyShortChaptersFlowPastTheInitialWindow() throws { + let (app, reader) = launchReader(shortChapters: true) + let initial = try value("chapter", in: reader) + for _ in 0..<4 { flick(reader, forward: true) } + let forward = try value("chapter", in: reader) + XCTAssertGreaterThan(forward, initial + 5) + for _ in 0..<4 { flick(reader, forward: false) } + XCTAssertLessThan(try value("chapter", in: reader), forward) + XCTAssertEqual(try value("interrupted", in: reader), 0) + attachScreenshot(app) + } + + private func launchReader(shortChapters: Bool) -> (XCUIApplication, XCUIElement) { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-uiDemo", "-uiDemoReader", "-uiScrollRegression", "-pageAnim", "3", "-themeMode", "0"] + if shortChapters { app.launchArguments.append("-uiShortChapters") } + app.launch() + let reader = app.collectionViews["reader.continuous"] + XCTAssertTrue(reader.waitForExistence(timeout: 20)) + let ready = expectation(for: NSPredicate { _, _ in + let text = reader.value as? String ?? "" + return text.contains("chapter=20 ") && !text.contains("chapters=1 ") + }, evaluatedWith: nil) + wait(for: [ready], timeout: 20) + return (app, reader) + } + + private func flick(_ reader: XCUIElement, forward: Bool) { + let start = reader.coordinate(withNormalizedOffset: CGVector(dx: 0.65, dy: forward ? 0.8 : 0.2)) + let end = reader.coordinate(withNormalizedOffset: CGVector(dx: 0.65, dy: forward ? 0.2 : 0.8)) + start.press(forDuration: 0.01, thenDragTo: end, withVelocity: .fast, thenHoldForDuration: 0) + } + + private func value(_ name: String, in reader: XCUIElement) throws -> Int { + let fields = (reader.value as? String ?? "").split(separator: " ") + let field = try XCTUnwrap(fields.first { $0.hasPrefix(name + "=") }) + let value = try XCTUnwrap(Int(field.dropFirst(name.count + 1))) + print("[ContinuousReader] \(reader.value ?? "")") + return value + } + + private func attachScreenshot(_ app: XCUIApplication) { + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/Tests/LegadoReaderUITests/RuleEditorUITests.swift b/Tests/LegadoReaderUITests/RuleEditorUITests.swift new file mode 100644 index 0000000..edfbcd4 --- /dev/null +++ b/Tests/LegadoReaderUITests/RuleEditorUITests.swift @@ -0,0 +1,130 @@ +import XCTest + +@MainActor +final class RuleEditorUITests: XCTestCase { + func testSourceFormJSONSwitchKeepsHighlightedTextAndSaves() throws { + try exerciseSourceEditor(usesLegacyUI: false) + } + + func testLegacySourceFormJSONSwitchKeepsHighlightedTextAndSaves() throws { + try exerciseSourceEditor(usesLegacyUI: true) + } + + private func exerciseSourceEditor(usesLegacyUI: Bool) throws { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-uiDemo", "-uiDemoTab", "sources"] + if usesLegacyUI { app.launchArguments.append("-uiLegacyCompatibility") } + app.launch() + XCTAssertTrue(app.navigationBars["书源"].waitForExistence(timeout: 20)) + app.buttons["更多"].firstMatch.tap() + app.buttons["新建书源"].tap() + XCTAssertTrue(app.navigationBars["新建书源"].waitForExistence(timeout: 10)) + app.buttons.matching(NSPredicate(format: "label CONTAINS %@", "书源名称")).firstMatch.tap() + let editor = app.textViews["syntaxCodeEditor"] + XCTAssertTrue(editor.waitForExistence(timeout: 5)) + editor.tap(); editor.typeText("UI Source") + attach(app, name: "Typed source name") + XCTAssertEqual(editor.value as? String, "UI Source") + app.navigationBars.buttons.element(boundBy: 0).tap() + app.buttons.matching(NSPredicate(format: "label CONTAINS %@", "书源地址")).firstMatch.tap() + editor.tap(); editor.typeText("https://ui-editor.test") + XCTAssertEqual(editor.value as? String, "https://ui-editor.test") + app.navigationBars.buttons.element(boundBy: 0).tap() + app.segmentedControls.buttons["JSON"].tap() + attach(app, name: "Source JSON editor") + let json = editor.value as? String ?? "" + XCTAssertTrue(json.contains("UI Source"), "Actual JSON: \(json)") + XCTAssertTrue(json.contains("https://ui-editor.test"), "Actual JSON: \(json)") + app.segmentedControls.buttons["字段编辑"].tap() + app.buttons["保存"].tap() + if app.alerts.firstMatch.waitForExistence(timeout: 5) { app.alerts.buttons["好"].tap() } + XCTAssertTrue(app.staticTexts["UI Source"].waitForExistence(timeout: 10)) + } + + func testPurificationDraftRunsThroughReaderPipeline() throws { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-uiDemo", "-uiDemoTab", "settings", "-uiDemoRules"] + app.launch() + XCTAssertTrue(app.buttons["新建规则"].waitForExistence(timeout: 20)) + app.buttons["新建规则"].tap() + app.textFields["名称"].tap(); app.textFields["名称"].typeText("UI Rule") + app.buttons.matching(NSPredicate(format: "label CONTAINS %@", "匹配内容")).firstMatch.tap() + let editor = app.textViews["syntaxCodeEditor"] + XCTAssertTrue(editor.waitForExistence(timeout: 5)) + editor.tap(); editor.typeText(".") + app.navigationBars.buttons.element(boundBy: 0).tap() + app.segmentedControls.buttons["测试规则"].tap() + app.buttons["运行当前草稿"].tap() + XCTAssertTrue(app.staticTexts["规则已生效"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.staticTexts["(正文为空)"].exists) + attach(app, name: "Purification test result") + app.buttons["保存"].tap() + XCTAssertTrue(app.navigationBars["净化规则"].waitForExistence(timeout: 10)) + XCTAssertTrue(app.staticTexts["UI Rule"].exists) + } + + func testLegacySearchSubmitsAndFiltersSources() throws { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-uiDemo", "-uiDemoTab", "search", "-uiLegacyCompatibility"] + app.launch() + let search = app.textFields["书名或作者"] + XCTAssertTrue(search.waitForExistence(timeout: 20)) + app.buttons["搜索选项"].tap() + app.buttons["分组或书源"].tap() + XCTAssertTrue(app.navigationBars["分组或书源"].waitForExistence(timeout: 10)) + app.segmentedControls.buttons["书源"].tap() + let filter = app.textFields["筛选书源"] + XCTAssertTrue(filter.waitForExistence(timeout: 5)) + filter.tap() + filter.typeText("no-match") + XCTAssertFalse(app.staticTexts["经典文学馆"].exists) + app.navigationBars["分组或书源"].buttons["取消"].tap() + XCTAssertTrue(search.waitForExistence(timeout: 5)) + search.tap() + search.typeText("legacy-probe\n") + XCTAssertTrue(app.buttons["修改关键词"].waitForExistence(timeout: 30)) + app.buttons["修改关键词"].tap() + XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5)) + attach(app, name: "Legacy search after keyboard submission") + } + + func testLegacyAboutCanPresentSystemShareSheet() throws { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-uiDemo", "-uiDemoTab", "settings", "-uiDemoAbout", "-uiLegacyCompatibility"] + app.launch() + let share = app.buttons["分享版本信息"] + XCTAssertTrue(share.waitForExistence(timeout: 20)) + share.tap() + let copy = app.descendants(matching: .any).matching(NSPredicate(format: "label IN %@", ["Copy", "拷贝", "复制"])).firstMatch + let appeared = copy.waitForExistence(timeout: 10) + if !appeared { print("[Share sheet hierarchy] \(app.debugDescription)") } + XCTAssertTrue(appeared) + attach(app, name: "Legacy system share sheet") + } + + func testLegacyRuleMenuCanExportThroughSystemShareSheet() throws { + continueAfterFailure = false + let app = XCUIApplication() + app.launchArguments = ["-uiDemo", "-uiDemoTab", "settings", "-uiDemoRules", "-uiLegacyCompatibility"] + app.launch() + XCTAssertTrue(app.navigationBars["净化规则"].waitForExistence(timeout: 20)) + app.buttons["更多"].tap() + app.buttons["导出全部规则"].tap() + let copy = app.descendants(matching: .any).matching(NSPredicate(format: "label IN %@", ["Copy", "拷贝", "复制"])).firstMatch + let appeared = copy.waitForExistence(timeout: 10) + if !appeared { print("[Share sheet hierarchy] \(app.debugDescription)") } + XCTAssertTrue(appeared) + attach(app, name: "Legacy rule menu export") + } + + private func attach(_ app: XCUIApplication, name: String) { + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = name + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md new file mode 100644 index 0000000..943cc9c --- /dev/null +++ b/docs/DEVELOPMENT.md @@ -0,0 +1,64 @@ +# 开发与验证 + +## 环境 + +- macOS;支持 Swift 6 的 Xcode,当前开发环境为 Xcode 26.1。 +- App 最低 iOS 15.0;核心包也可在 macOS 14+ 测试。 +- Swift Package Manager 依赖由 `Package.resolved` 锁定,初次解析需要网络。 +- 运行脚本需 Python 3 和 `rg`(ripgrep)。 + +在独立仓库根目录打开 `Legado.xcodeproj`,选择 `Legado` Scheme。真机运行需要自行设置签名团队;请勿将证书或描述文件提交到仓库。 + +## 代码结构 + +| 路径 | 用途 | +| --- | --- | +| `App/` | SwiftUI 页面、UIKit 阅读器、应用入口与资源 | +| `Sources/LegadoCore/` | 书源、规则引擎、网络、持久化、搜索、阅读及 RSS 等核心逻辑 | +| `Tests/LegadoCoreTests/` | 核心行为与书源兼容测试 | +| `Tests/LegadoAppModelsTests/` | 页面模型回归 | +| `Tests/LegadoReaderTests/` | iOS 阅读器与网页桥接测试 | +| `Tests/LegadoReaderUITests/` | 模拟器 UI 与阅读链路测试 | +| `App/Resources/` | 默认 TXT 目录规则、HTTP TTS 配置和第三方许可证 | +| `scripts/` | 自检与源码导出 | + +## 验证命令 + +```sh +swift test --disable-sandbox +xcodebuild -project Legado.xcodeproj -scheme Legado \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath DerivedData CODE_SIGNING_ALLOWED=NO build +``` + +完整本地自检: + +```sh +sh scripts/self-check.sh +``` + +首次运行可先在 Xcode 解析包;自检的 App 构建阶段固定使用已解析依赖。可用 `LEGADO_DERIVED_DATA_PATH` 和 `LEGADO_SOURCE_PACKAGES_PATH` 指定已有缓存。 + +阅读器测试在 Xcode 选择 `LegadoReaderTests` / `LegadoReaderUITests` Scheme 和具体模拟器运行。联网书源测试需要显式配置对应环境变量,默认核心回归主要采用自造响应,不能将其通过解读为所有线上书源均可用。 + +## 兼容性验证 + +最低部署版本为 iOS 15.0。`App/Compatibility.swift` 为导航、分享、照片选择和弹窗提供兼容实现;新增系统 API 时需保留最低版本路径。调试构建可用 `-uiLegacyCompatibility` 启动参数在新系统上执行旧版界面分支,对应回归位于 `Tests/LegadoReaderUITests/`。这些测试不能替代 iOS 15 真机或模拟器实测。 + +搜索调度与缓存回归见 `Tests/LegadoCoreTests/SearchPerformanceTests.swift`,包括复杂书源限流和普通书源的及时执行;这些测试验证行为,不提供真实网络速度排名。 + +## 演示与公开截图 + +模拟器启动参数 `-uiDemo` 使用演示书架;`-uiDemoReader` 打开演示阅读页,`-uiDemoTab settings` 打开「我的」,`-uiDemoAbout` 打开关于页。部分演示入口仍保留 `settings` 参数名以兼容测试脚本。 + +提交截图前确认没有真实账号、个人书架或第三方付费正文。公开截图放在 `docs/images/`;`artifacts/`、构建缓存和原始测试日志仅供本地验证,已加入忽略清单。 + +## 导出源码 + +在多项目父仓库中开发时,可以运行以下命令导出本项目当前源码,排除父仓库历史、构建产物及签名文件: + +```sh +python3 scripts/export-open-source.py /tmp/legado-ios-open-source +``` + +目标目录必须尚不存在。导出后检查内容,再用独立仓库管理;`.gitignore` 不会自动清理已经跟踪的文件。 diff --git a/docs/images/about.png b/docs/images/about.png new file mode 100644 index 0000000..e5754c4 Binary files /dev/null and b/docs/images/about.png differ diff --git a/docs/images/bookshelf.png b/docs/images/bookshelf.png new file mode 100644 index 0000000..2f65478 Binary files /dev/null and b/docs/images/bookshelf.png differ diff --git a/docs/images/explore.png b/docs/images/explore.png new file mode 100644 index 0000000..bcef11c Binary files /dev/null and b/docs/images/explore.png differ diff --git a/docs/images/profile.png b/docs/images/profile.png new file mode 100644 index 0000000..913d8f1 Binary files /dev/null and b/docs/images/profile.png differ diff --git a/docs/images/reader-dark.png b/docs/images/reader-dark.png new file mode 100644 index 0000000..11709f7 Binary files /dev/null and b/docs/images/reader-dark.png differ diff --git a/docs/images/reader.png b/docs/images/reader.png new file mode 100644 index 0000000..2e62567 Binary files /dev/null and b/docs/images/reader.png differ diff --git a/docs/images/search.png b/docs/images/search.png new file mode 100644 index 0000000..d759cbf Binary files /dev/null and b/docs/images/search.png differ diff --git a/docs/images/sources.png b/docs/images/sources.png new file mode 100644 index 0000000..9678171 Binary files /dev/null and b/docs/images/sources.png differ diff --git a/docs/images/statistics.png b/docs/images/statistics.png new file mode 100644 index 0000000..baa9787 Binary files /dev/null and b/docs/images/statistics.png differ diff --git a/scripts/export-open-source.py b/scripts/export-open-source.py new file mode 100644 index 0000000..cee8daf --- /dev/null +++ b/scripts/export-open-source.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Export this project's current source tree without parent Git history or local artifacts.""" + +import argparse +import shutil +from pathlib import Path + + +ROOT_ENTRIES = { + "App", "Sources", "Tests", "scripts", "docs", "Legado.xcodeproj", + ".github", ".gitignore", "Package.swift", "Package.resolved", "README.md", + "LICENSE", "THIRD_PARTY_NOTICES.md", "CONTRIBUTING.md", "CHANGELOG.md", +} +SKIP_DIRECTORIES = { + ".git", ".build", ".swiftpm", "DerivedData", "artifacts", "build", "node_modules", + ".gradle", ".idea", ".vscode", ".sisyphus", "xcuserdata", "__pycache__", + ".codex", ".agents", ".aws", +} +SKIP_SUFFIXES = { + ".ipa", ".apk", ".aab", ".log", ".pyc", ".pyo", ".p12", ".p8", ".pem", + ".key", ".keystore", ".jks", ".mobileprovision", ".xcuserstate", +} + + +def excluded(path: Path) -> bool: + return ( + path.is_symlink() + or path.name in SKIP_DIRECTORIES + or path.name == ".DS_Store" + or path.name == ".env" + or (path.name.startswith(".env.") and path.name != ".env.example") + or path.suffix in SKIP_SUFFIXES + or path.name.endswith((".xcresult", ".dSYM")) + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("destination", type=Path, help="New, empty destination outside the project") + parser.add_argument("--without-github-actions", action="store_true", help="Exclude GitHub Actions workflow files") + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + destination = args.destination.expanduser().resolve() + if destination.exists(): + parser.error("destination must not already exist; choose a new directory") + if destination.is_relative_to(root) or root.is_relative_to(destination): + parser.error("destination must be outside the source project") + missing = [name for name in ROOT_ENTRIES if not (root / name).exists()] + if missing: + parser.error("missing required project entries: " + ", ".join(sorted(missing))) + + def ignore(directory: str, names: list[str]) -> list[str]: + return [ + name for name in names + if excluded(Path(directory) / name) + or (args.without_github_actions and Path(directory) == root / ".github" and name == "workflows") + ] + + destination.mkdir(parents=True) + for name in sorted(ROOT_ENTRIES): + source = root / name + if excluded(source): + continue + if source.is_dir(): + shutil.copytree(source, destination / name, ignore=ignore) + else: + shutil.copy2(source, destination / name) + count = sum(1 for path in destination.rglob("*") if path.is_file()) + print(f"Exported {count} source files to {destination}") + print("No repository was created or published. Review this copy before initializing a new Git repository.") + + +if __name__ == "__main__": + main() diff --git a/scripts/self-check.sh b/scripts/self-check.sh new file mode 100755 index 0000000..26a7a03 --- /dev/null +++ b/scripts/self-check.sh @@ -0,0 +1,43 @@ +#!/bin/sh +set -eu + +workspace_dir=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$workspace_dir" + +derived_data_path=${LEGADO_DERIVED_DATA_PATH:-"$workspace_dir/DerivedData"} +source_packages_path=${LEGADO_SOURCE_PACKAGES_PATH:-"$derived_data_path/SourcePackages"} + +echo "[1/5] Validate bundled resources" +python3 -m json.tool App/Resources/txtTocRule.json >/dev/null +python3 -m json.tool App/Resources/httpTTS.json >/dev/null + +echo "[2/5] Check project and Swift formatting hazards" +plutil -lint Legado.xcodeproj/project.pbxproj >/dev/null +if rg --glob '*.swift' '[[:blank:]]+$' App Sources Tests; then + echo "Trailing whitespace found" >&2 + exit 1 +fi + +echo "[3/5] Run core tests" +mkdir -p .build/cache/clang .build/cache/swiftpm +XDG_CACHE_HOME="$workspace_dir/.build/cache" \ +CLANG_MODULE_CACHE_PATH="$workspace_dir/.build/cache/clang" \ +SWIFTPM_MODULECACHE_OVERRIDE="$workspace_dir/.build/cache/clang" \ +swift test --disable-sandbox + +echo "[4/5] Validate project and shared scheme" +test -s Legado.xcodeproj/project.pbxproj +test -s Legado.xcodeproj/xcshareddata/xcschemes/Legado.xcscheme + +echo "[5/5] Build the iOS app without signing" +xcodebuild \ + -project Legado.xcodeproj \ + -scheme Legado \ + -destination 'generic/platform=iOS Simulator' \ + -derivedDataPath "$derived_data_path" \ + -clonedSourcePackagesDirPath "$source_packages_path" \ + -disableAutomaticPackageResolution \ + CODE_SIGNING_ALLOWED=NO \ + build + +echo "Self-check passed"