feat: open-source 小浣熊阅读 for iOS

This commit is contained in:
Aminark
2026-10-14 11:37:36 +08:00
commit ffc9e9d91d
193 changed files with 41382 additions and 0 deletions
@@ -0,0 +1,225 @@
import SwiftUI
import AVFoundation
import Vision
import VisionKit
struct QRCodeImportView: View {
@Environment(\.dismiss) private var dismiss
let onValue: (String) -> Void
@State private var cameraAuthorized = false
@State private var cameraMessage = "正在检查相机…"
@State private var values: [String] = []
@State private var errorMessage: String?
@State private var isReading = false
@State private var scanError: String?
@State private var delivered = false
var body: some View {
ReadingNavigationStack {
VStack(spacing: 16) {
if cameraAuthorized, scanError == nil {
cameraScanner
.clipShape(RoundedRectangle(cornerRadius: 18))
.overlay(alignment: .bottom) {
Text("将书源或净化规则二维码放入取景框").font(.footnote)
.padding(10).background(.ultraThinMaterial, in: Capsule()).padding()
}
} else {
ReadingUnavailableView("相机扫描暂不可用", systemImage: "qrcode.viewfinder",
description: Text(scanError ?? cameraMessage))
if AVCaptureDevice.authorizationStatus(for: .video) == .denied {
Link("打开系统设置", destination: URL(string: UIApplication.openSettingsURLString)!)
}
}
if isReading { ProgressView("识别图片中的二维码…") }
ReadingPhotoPicker { data in
readPhoto(data)
} onError: { error in
errorMessage = error.localizedDescription
} label: {
Label("从照片选择二维码", systemImage: "photo")
.frame(maxWidth: .infinity)
}.buttonStyle(.borderedProminent).disabled(isReading)
Text("支持 HTTP(S) 链接、Legado 导入深链和 JSON 内容。识别后进入导入流程。")
.font(.footnote).foregroundStyle(.secondary)
if values.count > 1 {
List(values, id: \.self) { value in
Button { accept(value) } label: { Text(value).font(.caption).lineLimit(3) }
}.frame(maxHeight: 220)
}
}
.padding()
.navigationTitle("二维码导入")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } } }
.task {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized: cameraAuthorized = true
case .notDetermined: cameraAuthorized = await AVCaptureDevice.requestAccess(for: .video)
default: break
}
cameraMessage = cameraAuthorized ? "当前设备不支持实时扫描,请从照片选择二维码。" : "未获得相机权限,仍可从照片选择二维码。"
}
.alert("识别失败", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) {
Button("好", role: .cancel) {}
} message: { Text(errorMessage ?? "") }
}
}
@ViewBuilder
private var cameraScanner: some View {
if #available(iOS 16.0, *), !ReadingCompatibility.usesLegacyUI,
DataScannerViewController.isSupported, DataScannerViewController.isAvailable {
QRScanner { accept($0) } onError: { scanError = $0 }
} else {
LegacyQRScanner { accept($0) } onError: { scanError = $0 }
}
}
private func readPhoto(_ data: Data) {
Task {
isReading = true
defer { isReading = false }
do {
let found = try await Task.detached(priority: .userInitiated) {
try QRCodeDecoder().detect(in: data)
}.value
values = found
if found.count == 1 { accept(found[0]) }
else if found.isEmpty { errorMessage = "图片中没有识别到二维码,请选择清晰的原图。" }
} catch { errorMessage = error.localizedDescription }
}
}
private func accept(_ value: String) {
guard !delivered else { return }
delivered = true
dismiss()
Task { @MainActor in
try? await Task.sleep(nanoseconds: 400_000_000)
onValue(value)
}
}
}
@available(iOS 16.0, *)
private struct QRScanner: UIViewControllerRepresentable {
let onValue: (String) -> Void
let onError: (String) -> Void
func makeCoordinator() -> Coordinator { Coordinator(onValue: onValue) }
func makeUIViewController(context: Context) -> DataScannerViewController {
let scanner = DataScannerViewController(recognizedDataTypes: [.barcode(symbologies: [.qr])],
qualityLevel: .balanced, recognizesMultipleItems: false, isHighFrameRateTrackingEnabled: false,
isPinchToZoomEnabled: true, isGuidanceEnabled: true, isHighlightingEnabled: true)
scanner.delegate = context.coordinator
return scanner
}
func updateUIViewController(_ scanner: DataScannerViewController, context: Context) {
guard !scanner.isScanning else { return }
do { try scanner.startScanning() }
catch { Task { @MainActor in onError(error.localizedDescription) } }
}
static func dismantleUIViewController(_ scanner: DataScannerViewController, coordinator: Coordinator) { scanner.stopScanning() }
final class Coordinator: NSObject, DataScannerViewControllerDelegate {
let onValue: (String) -> Void
init(onValue: @escaping (String) -> Void) { self.onValue = onValue }
func dataScanner(_ dataScanner: DataScannerViewController, didAdd addedItems: [RecognizedItem], allItems: [RecognizedItem]) {
for item in addedItems {
if case .barcode(let code) = item, let value = code.payloadStringValue {
dataScanner.stopScanning()
onValue(value)
return
}
}
}
}
}
private struct LegacyQRScanner: UIViewControllerRepresentable {
let onValue: (String) -> Void
let onError: (String) -> Void
func makeUIViewController(context: Context) -> Controller {
Controller(onValue: onValue, onError: onError)
}
func updateUIViewController(_ controller: Controller, context: Context) {}
static func dismantleUIViewController(_ controller: Controller, coordinator: Void) {
controller.capture.stop()
}
final class Controller: UIViewController, AVCaptureMetadataOutputObjectsDelegate {
let capture = VideoCapture()
let onValue: (String) -> Void
let onError: (String) -> Void
private var preview: AVCaptureVideoPreviewLayer?
private var didDeliver = false
init(onValue: @escaping (String) -> Void, onError: @escaping (String) -> Void) {
self.onValue = onValue
self.onError = onError
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
do {
guard let device = AVCaptureDevice.default(for: .video) else { throw AVError(.deviceNotConnected) }
let input = try AVCaptureDeviceInput(device: device)
let output = AVCaptureMetadataOutput()
capture.session.beginConfiguration()
defer { capture.session.commitConfiguration() }
guard capture.session.canAddInput(input), capture.session.canAddOutput(output) else {
throw AVError(.deviceInUseByAnotherApplication)
}
capture.session.addInput(input)
capture.session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: .main)
output.metadataObjectTypes = [.qr]
let preview = AVCaptureVideoPreviewLayer(session: capture.session)
preview.videoGravity = .resizeAspectFill
view.layer.addSublayer(preview)
self.preview = preview
} catch {
let message = error.localizedDescription
DispatchQueue.main.async { self.onError(message) }
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
preview?.frame = view.bounds
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if preview != nil { capture.start() }
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
capture.stop()
}
nonisolated func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput objects: [AVMetadataObject], from connection: AVCaptureConnection) {
guard let value = objects.compactMap({ ($0 as? AVMetadataMachineReadableCodeObject)?.stringValue }).first else { return }
Task { @MainActor [weak self] in
guard let self, !didDeliver else { return }
didDeliver = true
capture.stop()
onValue(value)
}
}
}
final class VideoCapture: @unchecked Sendable {
let session = AVCaptureSession()
private let queue = DispatchQueue(label: "com.epiboly.legado.qr-camera")
func start() { queue.async { self.session.startRunning() } }
func stop() { queue.async { self.session.stopRunning() } }
}
}
@@ -0,0 +1,190 @@
import SwiftUI
import UIKit
struct SyntaxCodeEditor: UIViewRepresentable {
enum Language { case json, rule, javascript, regex }
@Binding var text: String
var language: Language = .rule
func makeCoordinator() -> Coordinator { Coordinator(self) }
func makeUIView(context: Context) -> UITextView {
let view = UITextView()
view.delegate = context.coordinator
view.font = .monospacedSystemFont(ofSize: 14, weight: .regular)
view.backgroundColor = .secondarySystemGroupedBackground
view.textContainerInset = UIEdgeInsets(top: 14, left: 12, bottom: 20, right: 12)
view.autocorrectionType = .no
view.autocapitalizationType = .none
view.smartQuotesType = .no
view.smartDashesType = .no
view.smartInsertDeleteType = .no
view.keyboardDismissMode = .interactive
view.alwaysBounceVertical = true
view.accessibilityIdentifier = "syntaxCodeEditor"
if #available(iOS 16.0, *) { view.isFindInteractionEnabled = true }
let bar = UIToolbar()
bar.sizeToFit()
bar.items = [
UIBarButtonItem(image: UIImage(systemName: "magnifyingglass"), primaryAction: UIAction(title: "", subtitle: nil) { [weak view, coordinator = context.coordinator] _ in
guard let view else { return }
if #available(iOS 16.0, *) { view.findInteraction?.presentFindNavigator(showingReplace: true) }
else { coordinator.presentFindAndReplace(in: view) }
}),
UIBarButtonItem(image: UIImage(systemName: "arrow.uturn.backward"), primaryAction: UIAction(title: "", subtitle: nil) { [weak view] _ in view?.undoManager?.undo() }),
UIBarButtonItem(image: UIImage(systemName: "arrow.uturn.forward"), primaryAction: UIAction(title: "", subtitle: nil) { [weak view] _ in view?.undoManager?.redo() }),
] + ["@", "$", "{}", "[]", "##", "\n", "\t"].map { symbol in
UIBarButtonItem(title: symbol == "\n" ? "↵" : symbol == "\t" ? "⇥" : symbol,
primaryAction: UIAction(title: "", subtitle: nil) { [weak view] _ in view?.insertText(symbol) })
} + [UIBarButtonItem(systemItem: .flexibleSpace), UIBarButtonItem(title: "完成", primaryAction: UIAction(title: "", subtitle: nil) { [weak view] _ in view?.resignFirstResponder() })]
view.inputAccessoryView = bar
view.text = text
context.coordinator.highlight(view)
return view
}
func updateUIView(_ view: UITextView, context: Context) {
context.coordinator.parent = self
guard view.markedTextRange == nil else { return }
// SwiftUI can render an older binding snapshot while the next native
// keystroke is already in the text view. Wait for acknowledgement of
// the latest local edit instead of replacing it with that snapshot.
if let pending = context.coordinator.pendingLocalText {
guard text == pending else { return }
context.coordinator.pendingLocalText = nil
}
if view.text != text {
let selection = view.selectedRange
view.text = text
view.selectedRange = NSRange(location: min(selection.location, view.text.utf16.count), length: 0)
}
context.coordinator.highlight(view)
}
final class Coordinator: NSObject, UITextViewDelegate {
var parent: SyntaxCodeEditor
private var highlightedText: String?
private var highlightedStyle: UIUserInterfaceStyle?
private var highlightedLanguage: Language?
var pendingLocalText: String?
init(_ parent: SyntaxCodeEditor) { self.parent = parent }
func presentFindAndReplace(in view: UITextView) {
guard var presenter = view.window?.rootViewController else { return }
while let presented = presenter.presentedViewController { presenter = presented }
let alert = UIAlertController(title: "查找与替换", message: nil, preferredStyle: .alert)
alert.addTextField { $0.placeholder = "查找内容" }
alert.addTextField { $0.placeholder = "替换为(可留空)" }
alert.addAction(UIAlertAction(title: "查找下一个", style: .default) { [weak view, weak alert] _ in
guard let view, let query = alert?.textFields?.first?.text, !query.isEmpty else { return }
let text = (view.text ?? "") as NSString
let start = min(NSMaxRange(view.selectedRange), text.length)
var range = text.range(of: query, range: NSRange(location: start, length: text.length - start))
if range.location == NSNotFound { range = text.range(of: query) }
guard range.location != NSNotFound else { return }
view.becomeFirstResponder()
view.selectedRange = range
view.scrollRangeToVisible(range)
})
alert.addAction(UIAlertAction(title: "全部替换", style: .default) { [weak view, weak alert] _ in
guard let view, let fields = alert?.textFields,
let query = fields.first?.text, !query.isEmpty else { return }
let original = view.text ?? ""
let replacement = original.replacingOccurrences(of: query, with: fields.last?.text ?? "")
guard replacement != original else { return }
view.becomeFirstResponder()
view.selectedRange = NSRange(location: 0, length: original.utf16.count)
view.insertText(replacement)
})
alert.addAction(UIAlertAction(title: "取消", style: .cancel))
presenter.present(alert, animated: true)
}
func textViewDidChange(_ view: UITextView) {
guard view.markedTextRange == nil else { return }
pendingLocalText = view.text
parent.text = view.text
highlight(view)
}
func textViewDidEndEditing(_ view: UITextView) {
pendingLocalText = nil
parent.text = view.text
highlight(view)
}
func highlight(_ view: UITextView) {
guard view.markedTextRange == nil,
highlightedText != view.text || highlightedStyle != view.traitCollection.userInterfaceStyle || highlightedLanguage != parent.language else { return }
highlightedText = view.text
highlightedStyle = view.traitCollection.userInterfaceStyle
highlightedLanguage = parent.language
let storage = view.textStorage
let selection = view.selectedRange
let all = NSRange(location: 0, length: storage.length)
storage.beginEditing()
storage.addAttributes([.font: UIFont.monospacedSystemFont(ofSize: 14, weight: .regular), .foregroundColor: UIColor.label], range: all)
if storage.length <= 500_000 {
let tokens: [(String, UIColor)] = [
(#"\b(?:true|false|null|undefined|return|var|let|const|if|else|function|new|for|while|try|catch|throw|await|async)\b"#, .systemPurple),
(#"\b\d+(?:\.\d+)?\b"#, .systemOrange),
(#"@(?:css|xpath|json|js|put|get):?|\$\.|&&|\|\||%%|##|\{\{|\}\}"#, .systemPink),
(#"[{}\[\]()]"#, .systemTeal),
(#""(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`"#, .systemGreen),
(#""(?:\\.|[^"\\])*"(?=\s*:)"#, .systemBlue),
]
for (pattern, color) in tokens {
guard let regex = try? NSRegularExpression(pattern: pattern) else { continue }
regex.enumerateMatches(in: view.text, range: all) { result, _, _ in
if let result { storage.addAttribute(.foregroundColor, value: color, range: result.range) }
}
}
if parent.language == .regex {
for pattern in [#"[.*+?^$|]"#, #"\\[dDsSwWbB0-9]|\{[0-9,]+\}"#] {
let regex = try? NSRegularExpression(pattern: pattern)
regex?.enumerateMatches(in: view.text, range: all) { result, _, _ in
if let result { storage.addAttribute(.foregroundColor, value: UIColor.systemPink, range: result.range) }
}
}
}
if parent.language == .javascript {
let pattern = #"(?m)^\s*//.*$|/\*[\s\S]*?\*/"#
let regex = try? NSRegularExpression(pattern: pattern)
regex?.enumerateMatches(in: view.text, range: all) { result, _, _ in
if let result { storage.addAttribute(.foregroundColor, value: UIColor.secondaryLabel, range: result.range) }
}
}
}
storage.endEditing()
view.selectedRange = selection
view.typingAttributes = [.font: UIFont.monospacedSystemFont(ofSize: 14, weight: .regular), .foregroundColor: UIColor.label]
}
}
}
struct RuleCodeFieldView: View {
let title: String
let path: String
@Binding var text: String
var language: SyntaxCodeEditor.Language = .rule
var help: String?
var body: some View {
VStack(spacing: 0) {
Text(path).font(.caption.monospaced()).foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading).padding(12)
if let help {
Text(help).font(.footnote).foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal, 12).padding(.bottom, 10)
}
SyntaxCodeEditor(text: $text, language: language)
HStack {
Text("\(text.components(separatedBy: "\n").count) 行 · \(text.utf16.count) 字符")
Spacer()
if language == .rule { Text("CSS · XPath · JSONPath · JS") }
}.font(.caption).foregroundStyle(.secondary).padding(12)
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
}
}
@@ -0,0 +1,185 @@
import SwiftUI
import UIKit
struct ReplaceRuleEditorView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var library: ReplaceRuleLibraryModel
@State private var rule: ReplaceRule
@State private var mode: Int
@State private var testTitle = "第一章 开始"
@State private var testContent = "在这里粘贴需要净化的正文。"
@State private var testBook = ""
@State private var testSource = ""
@State private var testResult: ReplaceRuleTestResult?
@State private var errorMessage: String?
@State private var isTesting = false
@State private var isSaving = false
@State private var showsDiscard = false
private let original: ReplaceRule
init(rule: ReplaceRule, startsWithTest: Bool = false) {
original = rule
_rule = State(initialValue: rule)
_mode = State(initialValue: startsWithTest ? 1 : 0)
}
var body: some View {
ReadingNavigationStack {
VStack(spacing: 0) {
Picker("模式", selection: $mode) { Text("编辑规则").tag(0); Text("测试规则").tag(1) }
.pickerStyle(.segmented).padding()
Form { if mode == 0 { configuration } else { testForm } }
}
.navigationTitle(rule.name.isEmpty ? "净化规则" : rule.name)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") { if rule != original { showsDiscard = true } else { dismiss() } }
}
ToolbarItem(placement: .topBarTrailing) {
Menu("更多", systemImage: "ellipsis.circle") {
Button("复制规则 JSON", systemImage: "doc.on.doc") { UIPasteboard.general.string = json }
Button("粘贴规则 JSON", systemImage: "doc.on.clipboard") {
do {
guard let text = UIPasteboard.general.string else { return }
var pasted = try JSONDecoder().decode(ReplaceRule.self, from: Data(text.utf8))
pasted.id = rule.id
pasted.sortOrder = rule.sortOrder
rule = pasted
} catch { errorMessage = error.localizedDescription }
}
ReadingShareLink(item: json) { Label("分享规则", systemImage: "square.and.arrow.up") }
}
}
ToolbarItem(placement: .confirmationAction) {
Button(isSaving ? "保存中" : "保存") {
isSaving = true
Task {
let saved = await library.save(rule)
isSaving = false
if saved { dismiss() }
else {
let message = library.notice?.detail ?? "保存失败"
library.notice = nil
errorMessage = message
}
}
}.disabled(isSaving || isTesting)
}
}
.alert("规则提示", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) {
Button("好", role: .cancel) {}
} message: { Text(errorMessage ?? "") }
.confirmationDialog("放弃未保存的修改?", isPresented: $showsDiscard, titleVisibility: .visible) {
Button("放弃修改", role: .destructive) { dismiss() }
}
.onChange(of: rule) { _ in testResult = nil }
.onChange(of: testTitle + testContent + testBook + testSource) { _ in testResult = nil }
}
.interactiveDismissDisabled(rule != original || isSaving)
}
private var configuration: some View {
Group {
Section("基本信息") {
TextField("名称", text: $rule.name)
TextField("分组", text: optional(\.group))
Toggle("启用", isOn: $rule.isEnabled)
}
Section("匹配与替换") {
Toggle("使用正则表达式", isOn: $rule.isRegex)
NavigationLink {
RuleCodeFieldView(title: "匹配内容", path: "pattern", text: $rule.pattern, language: .regex)
} label: { fieldPreview("匹配内容", value: rule.pattern) }
NavigationLink {
RuleCodeFieldView(title: "替换为", path: "replacement", text: $rule.replacement, language: .regex)
} label: { fieldPreview("替换为(留空即删除)", value: rule.replacement) }
Text("正则替换使用 $1、$2 引用捕获组;普通文本替换按字面匹配。")
.font(.footnote).foregroundStyle(.secondary)
}
Section("作用范围") {
Toggle("章节标题", isOn: $rule.scopeTitle)
Toggle("正文", isOn: $rule.scopeContent)
ReadingTextField("限定书名或书源(留空为全部)", text: optional(\.scope), axis: .vertical)
ReadingTextField("排除书名或书源", text: optional(\.excludeScope), axis: .vertical)
}
Section("执行设置") {
HStack {
Text("超时 (ms)")
TextField("3000", value: $rule.timeoutMillisecond, format: .number.grouping(.never))
.keyboardType(.numbersAndPunctuation).multilineTextAlignment(.trailing)
}
HStack {
Text("排序编号")
TextField("0", value: $rule.sortOrder, format: .number.grouping(.never))
.keyboardType(.numbersAndPunctuation).multilineTextAlignment(.trailing)
}
Text("超时小于等于 0 时使用 3000 ms。单次正则最多运行 60 秒。规则按排序编号依次执行;资源超限或超时会保留原文并报告错误。")
.font(.footnote).foregroundStyle(.secondary)
}
}
}
private var testForm: some View {
Group {
Section("测试上下文") {
TextField("书名(用于作用范围判断)", text: $testBook)
TextField("书源地址(用于作用范围判断)", text: $testSource).textInputAutocapitalization(.never)
TextField("章节标题", text: $testTitle)
NavigationLink {
RuleCodeFieldView(title: "测试正文", path: "测试输入", text: $testContent)
} label: { fieldPreview("正文", value: testContent) }
}
Section {
Button { runTest() } label: {
HStack { Label(isTesting ? "测试中…" : "运行当前草稿", systemImage: "play.fill"); Spacer(); if isTesting { ProgressView() } }
}.disabled(isTesting)
if !rule.isEnabled { Text("规则处于停用状态,结果将保持原文。可在编辑页启用后测试。").font(.footnote).foregroundStyle(.secondary) }
}
if let result = testResult {
Section("测试结果 · \(result.elapsedMilliseconds.formatted(.number.precision(.fractionLength(1)))) ms") {
Label(result.report.effectiveRuleIDs.isEmpty ? "未改变文本(请检查匹配、启用状态及作用范围)" : "规则已生效",
systemImage: result.report.effectiveRuleIDs.isEmpty ? "info.circle" : "checkmark.circle")
ReadingLabeledContent("标题", value: result.report.title)
Text(result.report.content.isEmpty ? "(正文为空)" : result.report.content)
.font(.body.monospaced()).textSelection(.enabled)
}
if !result.report.diagnostics.isEmpty {
Section("执行错误") {
ForEach(result.report.diagnostics) { diagnostic in Text(diagnostic.message).foregroundStyle(.red) }
}
}
}
}
}
private var json: String {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]
return (try? encoder.encode(rule)).map { String(decoding: $0, as: UTF8.self) } ?? "{}"
}
private func optional(_ path: WritableKeyPath<ReplaceRule, String?>) -> Binding<String> {
Binding(get: { rule[keyPath: path] ?? "" }, set: { rule[keyPath: path] = $0.isEmpty ? nil : $0 })
}
private func fieldPreview(_ title: String, value: String) -> some View {
VStack(alignment: .leading, spacing: 5) {
Text(title)
Text(value.isEmpty ? "未填写" : value).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(3)
}
}
private func runTest() {
let snapshot = rule
let title = testTitle, content = testContent, book = testBook, source = testSource
isTesting = true
testResult = nil
Task {
do {
let result = try await Task.detached(priority: .userInitiated) {
try ReplaceRuleTester().test(rule: snapshot, title: title, content: content, bookName: book, sourceURL: source)
}.value
if rule == snapshot, title == testTitle, content == testContent, book == testBook, source == testSource { testResult = result }
} catch { errorMessage = error.localizedDescription }
isTesting = false
}
}
}
@@ -0,0 +1,141 @@
#if SWIFT_PACKAGE
import LegadoCore
#endif
import Foundation
@MainActor
final class ReplaceRuleLibraryModel: ObservableObject {
@Published private(set) var rules: [ReplaceRule] = []
@Published var notice: Notice?
struct Notice: Identifiable {
enum Kind { case success, error }
let id = UUID()
let kind: Kind
let title: String
let detail: String
}
private let repository: any ReplaceRuleRepository
private let importer = ReplaceRuleImporter()
init(repository: any ReplaceRuleRepository) { self.repository = repository }
static func live() -> ReplaceRuleLibraryModel {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Legado", isDirectory: true)
return ReplaceRuleLibraryModel(repository: FileReplaceRuleRepository(fileURL: base.appendingPathComponent("replace-rules.json")))
}
static func demo() -> ReplaceRuleLibraryModel {
ReplaceRuleLibraryModel(repository: InMemoryReplaceRuleRepository())
}
func load() async {
do { rules = try await repository.load() }
catch { notice = .init(kind: .error, title: "净化规则读取失败", detail: error.localizedDescription) }
}
func importFiles(_ urls: [URL]) async {
do {
var updated = rules
var count = 0
for url in urls {
let granted = url.startAccessingSecurityScopedResource()
defer { if granted { url.stopAccessingSecurityScopedResource() } }
let incoming = try importer.decode(Data(contentsOf: url, options: .mappedIfSafe))
updated = importer.merge(incoming, into: updated)
count += incoming.count
}
try await repository.save(updated)
rules = updated
notice = .init(kind: .success, title: "净化规则已导入", detail: "本次读取 \(count) 条,当前共 \(rules.count) 条。")
} catch {
notice = .init(kind: .error, title: "净化规则导入失败", detail: error.localizedDescription)
}
}
func importRemoteURL(_ value: String) async { await importText(value) }
func importText(_ value: String) async {
do {
let input = try RuleImportInput.parse(value, kind: "replaceRule")
let data: Data
switch input {
case .json(let value): data = Data(value.utf8)
case .remote:
let (body, response) = try await URLSessionHTTPTransport.shared.download(for: input.request()!)
if let response = response as? HTTPURLResponse, !(200...299).contains(response.statusCode) { throw URLError(.badServerResponse) }
data = body
}
let incoming = try importer.decode(data)
let updated = importer.merge(incoming, into: rules)
try await repository.save(updated)
rules = updated
notice = .init(kind: .success, title: "净化规则已导入", detail: "本次读取 \(incoming.count) 条,当前共 \(rules.count) 条。")
} catch { notice = .init(kind: .error, title: "净化规则导入失败", detail: error.localizedDescription) }
}
func newRule(copying original: ReplaceRule? = nil) -> ReplaceRule {
let now = Int64(Date().timeIntervalSince1970 * 1_000)
let maximumID = rules.map(\.id).max() ?? 0
let id = max(now, maximumID < Int64.max ? maximumID + 1 : now)
var rule = original ?? ReplaceRule(id: id, name: "", pattern: "")
rule.id = id
if original != nil { rule.name += " 副本" }
rule.sortOrder = (rules.map(\.sortOrder).filter { $0 != Int.min && $0 < Int.max }.max() ?? -1) + 1
return rule
}
func save(_ rule: ReplaceRule) async -> Bool {
do { try ReplaceRuleValidator.validate(rule) }
catch { notice = .init(kind: .error, title: "规则无效", detail: error.localizedDescription); return false }
return await persist(importer.merge([rule], into: rules))
}
func move(from offsets: IndexSet, to destination: Int) async {
var updated = rules
updated.move(fromOffsets: offsets, toOffset: destination)
for index in updated.indices { updated[index].sortOrder = index }
await persist(updated)
}
func remove(id: Int64) async { await persist(rules.filter { $0.id != id }) }
func applySilentSubscription(_ data: Data) async throws {
let incoming = try importer.decode(data)
let changed = incoming.filter { value in
guard let old = rules.first(where: { $0.id == value.id }) else { return true }
return old.pattern != value.pattern || old.replacement != value.replacement
}
guard !changed.isEmpty else { return }
let updated = importer.merge(changed, into: rules)
try await repository.save(updated)
rules = updated
}
func setEnabled(_ enabled: Bool, for id: Int64) async {
guard let index = rules.firstIndex(where: { $0.id == id }) else { return }
var updated = rules
updated[index].isEnabled = enabled
await persist(updated)
}
func remove(at offsets: IndexSet) async {
var updated = rules
updated.remove(atOffsets: offsets)
await persist(updated)
}
@discardableResult
private func persist(_ updated: [ReplaceRule]) async -> Bool {
do {
try await repository.save(updated)
rules = updated
return true
} catch {
notice = .init(kind: .error, title: "净化规则保存失败", detail: error.localizedDescription)
return false
}
}
}
@@ -0,0 +1,130 @@
import SwiftUI
import UniformTypeIdentifiers
struct ReplaceRuleManagementView: View {
@EnvironmentObject private var library: ReplaceRuleLibraryModel
@State private var showsImporter = false
@State private var showsTextImporter = false
@State private var showsQRImporter = false
@State private var editingRule: ReplaceRule?
@State private var startsWithTest = false
@State private var query = ""
@State private var group: String?
private var filtered: [ReplaceRule] {
library.rules.filter { rule in
(group == nil || rule.group == group) && (query.isEmpty || rule.name.localizedCaseInsensitiveContains(query)
|| rule.pattern.localizedCaseInsensitiveContains(query) || (rule.group?.localizedCaseInsensitiveContains(query) ?? false))
}
}
var body: some View {
List {
if library.rules.isEmpty {
ReadingUnavailableView("还没有净化规则", systemImage: "wand.and.stars", description: Text("新建规则,或导入 Legado 净化规则。"))
Button("新建规则", systemImage: "plus") { edit(library.newRule()) }
} else {
Section {
ReadingLabeledContent("已启用", value: "\(library.rules.filter(\.isEnabled).count) / \(library.rules.count)")
Picker("分组", selection: $group) {
Text("全部").tag(String?.none)
ForEach(Array(Set(library.rules.compactMap(\.group))).sorted(), id: \.self) { Text($0).tag(String?.some($0)) }
}
}
Section {
ForEach(filtered) { rule in
HStack(spacing: 12) {
Button { edit(rule) } label: {
HStack(spacing: 12) {
Image(systemName: rule.isRegex ? "textformat.alt" : "character.cursor.ibeam")
.foregroundStyle(rule.isEnabled ? ReadingTheme.accent : .secondary)
VStack(alignment: .leading, spacing: 4) {
Text(rule.name.isEmpty ? rule.pattern : rule.name).foregroundStyle(.primary).lineLimit(1)
Text(rule.group ?? (rule.scopeTitle && rule.scopeContent ? "标题与正文" : rule.scopeTitle ? "标题" : "正文"))
.font(.caption).foregroundStyle(.secondary)
Text(rule.pattern).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(1)
}
Spacer()
}
}.buttonStyle(.plain)
Toggle("启用", isOn: Binding(get: { rule.isEnabled }, set: { value in Task { await library.setEnabled(value, for: rule.id) } }))
.labelsHidden().accessibilityLabel("启用\(rule.name)")
}
.contextMenu {
Button("编辑", systemImage: "pencil") { edit(rule) }
Button("测试", systemImage: "play") { edit(rule, test: true) }
Button("复制", systemImage: "doc.on.doc") { edit(library.newRule(copying: rule)) }
Button("删除", systemImage: "trash", role: .destructive) { Task { await library.remove(id: rule.id) } }
}
}
.onDelete { offsets in
let ids = offsets.compactMap { filtered.indices.contains($0) ? filtered[$0].id : nil }
Task { for id in ids { await library.remove(id: id) } }
}
.onMove { offsets, destination in Task { await library.move(from: offsets, to: destination) } }
.moveDisabled(group != nil || !query.isEmpty)
} footer: { Text("点击规则编辑,长按可复制或测试。拖动排序决定执行顺序。").font(.footnote) }
}
}
.listStyle(.insetGrouped)
.navigationTitle("净化规则")
.searchable(text: $query, prompt: "名称、匹配内容或分组")
.toolbar {
ToolbarItem(placement: .topBarTrailing) { EditButton().disabled(library.rules.isEmpty || group != nil || !query.isEmpty) }
ToolbarItem(placement: .primaryAction) {
Menu("更多", systemImage: "plus") {
Button("新建规则", systemImage: "plus") { edit(library.newRule()) }
Button("从文件导入", systemImage: "doc.badge.plus") { showsImporter = true }
Button("URL 或粘贴 JSON", systemImage: "link.badge.plus") { showsTextImporter = true }
Button("二维码导入", systemImage: "qrcode.viewfinder") { showsQRImporter = true }
ReadingShareLink(item: exportJSON) { Label("导出全部规则", systemImage: "square.and.arrow.up") }
}
}
}
.sheet(item: $editingRule) { ReplaceRuleEditorView(rule: $0, startsWithTest: startsWithTest).environmentObject(library) }
.sheet(isPresented: $showsQRImporter) { QRCodeImportView { value in Task { await library.importText(value) } } }
.sheet(isPresented: $showsTextImporter) { ReplaceRuleTextImportView().environmentObject(library) }
.fileImporter(isPresented: $showsImporter, allowedContentTypes: [.item], allowsMultipleSelection: true) { result in
switch result {
case .success(let urls): Task { await library.importFiles(urls) }
case .failure(let error): library.notice = .init(kind: .error, title: "无法选择文件", detail: error.localizedDescription)
}
}
.alert(item: $library.notice) { notice in
Alert(title: Text(notice.title), message: Text(notice.detail), dismissButton: .default(Text("好")))
}
}
private func edit(_ rule: ReplaceRule, test: Bool = false) { startsWithTest = test; editingRule = rule }
private var exportJSON: String {
let encoder = JSONEncoder(); encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return (try? encoder.encode(library.rules)).map { String(decoding: $0, as: UTF8.self) } ?? "[]"
}
}
private struct ReplaceRuleTextImportView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var library: ReplaceRuleLibraryModel
@State private var value = ""
var body: some View {
ReadingNavigationStack {
VStack(alignment: .leading, spacing: 12) {
Text("粘贴净化规则 JSON、HTTP(S) 链接或 Legado 净化规则深链。").font(.footnote).foregroundStyle(.secondary)
SyntaxCodeEditor(text: $value, language: .json)
}.padding()
.navigationTitle("导入净化规则").navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
ToolbarItem(placement: .confirmationAction) {
Button("导入") {
let text = value
dismiss()
Task {
try? await Task.sleep(nanoseconds: 400_000_000)
await library.importText(text)
}
}.disabled(value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
}
}
@@ -0,0 +1,162 @@
import SwiftUI
@MainActor
final class RuleSubscriptionModel: ObservableObject {
@Published private(set) var values: [RuleSub] = []
@Published var errorMessage: String?
@Published private(set) var pendingIDs = Set<Int64>()
private let repository: SQLiteConfigurationRepository?
init() {
repository = (try? LegadoDatabase(url: LegadoDatabase.defaultURL())).map { SQLiteConfigurationRepository(database: $0) }
}
func load() async {
guard let repository else { errorMessage = "无法打开规则订阅数据库"; return }
do { values = try await repository.loadRuleSubs().sorted { $0.customOrder < $1.customOrder } }
catch { errorMessage = error.localizedDescription }
}
func save(_ value: RuleSub) async -> Bool {
guard !value.url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { errorMessage = "URL不能为空"; return false }
guard !values.contains(where: { $0.id != value.id && $0.url == value.url }) else { errorMessage = "URL已存在"; return false }
var updated = values
if let index = updated.firstIndex(where: { $0.id == value.id }) { updated[index] = value }
else { var item = value; item.customOrder = (updated.map(\.customOrder).max() ?? -1) + 1; updated.append(item) }
return await persist(updated)
}
func remove(at offsets: IndexSet) async { _ = await persist(values.enumerated().filter { !offsets.contains($0.offset) }.map(\.element)) }
func move(from offsets: IndexSet, to destination: Int) async {
var updated = values; updated.move(fromOffsets: offsets, toOffset: destination)
for index in updated.indices { updated[index].customOrder = index + 1 }
_ = await persist(updated)
}
func checkAutomaticUpdates(bookSources: SourceLibraryModel, rssSources: RSSLibraryModel, replaceRules: ReplaceRuleLibraryModel) async {
let now = Int64(Date().timeIntervalSince1970 * 1_000)
for index in values.indices where values[index].autoUpdate && values[index].type != 1 {
let interval = Int64(max(values[index].updateInterval, 0)) * 3_600_000
guard values[index].update + interval <= now else { continue }
var subscription = values[index]
subscription.update = now
var timestamped = values; timestamped[index] = subscription
guard await persist(timestamped) else { continue }
do {
let data = try await fetch(subscription.url)
if subscription.silentUpdate {
switch subscription.type {
case 1: try await rssSources.applySilentSubscription(data)
case 2: try await replaceRules.applySilentSubscription(data)
default: try await bookSources.applySilentSubscription(data)
}
} else if try hasUpdates(data, type: subscription.type, bookSources: bookSources, rssSources: rssSources, replaceRules: replaceRules) {
pendingIDs.insert(subscription.id)
}
} catch { errorMessage = "\(subscription.name.isEmpty ? subscription.url : subscription.name):\(error.localizedDescription)" }
}
}
func clearPending(_ id: Int64) { pendingIDs.remove(id) }
private func fetch(_ value: String) async throws -> Data {
let withoutUA = value.hasSuffix("#requestWithoutUA")
let raw = withoutUA ? String(value.dropLast("#requestWithoutUA".count)) : value
guard let url = URL(string: raw) else { throw URLError(.badURL) }
var request = URLRequest(url: url)
if withoutUA { request.setValue("", forHTTPHeaderField: "User-Agent") }
let (data, response) = try await URLSessionHTTPTransport.shared.download(for: request)
if let response = response as? HTTPURLResponse, !(200...299).contains(response.statusCode) { throw URLError(.badServerResponse) }
return data
}
private func hasUpdates(_ data: Data, type: Int, bookSources: SourceLibraryModel, rssSources: RSSLibraryModel, replaceRules: ReplaceRuleLibraryModel) throws -> Bool {
switch type {
case 1:
let incoming = try RSSSourceImporter().decode(data).sources
guard incoming.first?.sourceUrl.isEmpty == false else { throw RSSSourceImportError.invalidSource }
return incoming.contains { value in rssSources.sources.first(where: { $0.sourceUrl == value.sourceUrl }).map { $0.lastUpdateTime < value.lastUpdateTime } ?? true }
case 2:
let incoming = try ReplaceRuleImporter().decode(data)
return incoming.contains { value in
guard let old = replaceRules.rules.first(where: { $0.id == value.id }) else { return true }
return old.pattern != value.pattern || old.replacement != value.replacement
}
default:
let incoming = try BookSourceImporter().decode(data)
guard incoming.first?.bookSourceUrl.isEmpty == false else { throw BookSourceImportError.emptyDocument }
return incoming.contains { value in bookSources.sources.first(where: { $0.bookSourceUrl == value.bookSourceUrl }).map { $0.lastUpdateTime < value.lastUpdateTime } ?? true }
}
}
private func persist(_ updated: [RuleSub]) async -> Bool {
guard let repository else { errorMessage = "无法打开规则订阅数据库"; return false }
do { try await repository.saveRuleSubs(updated); values = try await repository.loadRuleSubs().sorted { $0.customOrder < $1.customOrder }; return true }
catch { errorMessage = error.localizedDescription; return false }
}
}
struct RuleSubscriptionView: View {
@EnvironmentObject private var bookSources: SourceLibraryModel
@EnvironmentObject private var replaceRules: ReplaceRuleLibraryModel
@EnvironmentObject private var model: RuleSubscriptionModel
@State private var editing: RuleSub?
private var visibleValues: [RuleSub] { model.values.filter { $0.type != 1 } }
var body: some View {
List {
if visibleValues.isEmpty { ReadingUnavailableView("没有规则订阅", systemImage: "link.badge.plus") }
ForEach(visibleValues) { value in
Button { open(value) } label: {
HStack(spacing: 12) {
Text(typeName(value.type)).font(.caption.bold()).foregroundStyle(ReadingTheme.accent).frame(width: 48)
VStack(alignment: .leading, spacing: 4) { Text(value.name.isEmpty ? value.url : value.name).foregroundStyle(.primary); Text(value.url).font(.caption).foregroundStyle(.secondary).lineLimit(1) }
Spacer()
if value.autoUpdate { Image(systemName: value.silentUpdate ? "arrow.triangle.2.circlepath.circle.fill" : "arrow.triangle.2.circlepath.circle").foregroundStyle(.secondary) }
if model.pendingIDs.contains(value.id) { Text("有更新").font(.caption2).foregroundStyle(.orange) }
}
}.swipeActions(edge: .leading) { Button("编辑", systemImage: "pencil") { editing = value }.tint(.blue) }
}.onDelete { offsets in
let ids = Set(offsets.compactMap { visibleValues.indices.contains($0) ? visibleValues[$0].id : nil })
let sourceOffsets = IndexSet(model.values.indices.filter { ids.contains(model.values[$0].id) })
Task { await model.remove(at: sourceOffsets) }
}
}
.navigationTitle("规则订阅")
.toolbar { ToolbarItem(placement: .primaryAction) { Button("新建", systemImage: "plus") { editing = RuleSub() } }; ToolbarItem(placement: .primaryAction) { EditButton() } }
.task { await model.load() }
.sheet(item: $editing) { value in RuleSubscriptionEditView(value: value) { item in await model.save(item) } }
.alert("规则订阅", isPresented: Binding(get: { model.errorMessage != nil }, set: { if !$0 { model.errorMessage = nil } })) { Button("好") { model.errorMessage = nil } } message: { Text(model.errorMessage ?? "") }
}
private func open(_ value: RuleSub) {
model.clearPending(value.id)
Task {
switch value.type {
case 2: await replaceRules.importRemoteURL(value.url)
default: await bookSources.importRemoteURL(value.url)
}
}
}
private func typeName(_ type: Int) -> String { type == 2 ? "净化" : "书源" }
}
private struct RuleSubscriptionEditView: View {
@Environment(\.dismiss) private var dismiss
@State private var value: RuleSub
@State private var saving = false
let save: (RuleSub) async -> Bool
init(value: RuleSub, save: @escaping (RuleSub) async -> Bool) { _value = State(initialValue: value); self.save = save }
var body: some View {
ReadingNavigationStack {
Form {
Picker("类型", selection: $value.type) { Text("书源").tag(0); Text("净化规则").tag(2) }
TextField("名称", text: $value.name)
ReadingTextField("订阅URL", text: $value.url, axis: .vertical).textInputAutocapitalization(.never).disableAutocorrection(true)
Toggle("自动更新", isOn: $value.autoUpdate)
if value.autoUpdate {
Stepper("更新间隔:\(value.updateInterval) 小时", value: $value.updateInterval, in: 1...720)
Toggle("静默更新", isOn: $value.silentUpdate)
}
}.navigationTitle("规则订阅").toolbar {
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
ToolbarItem(placement: .confirmationAction) { Button(saving ? "保存中…" : "保存") { saving = true; Task { if await save(value) { dismiss() }; saving = false } }.disabled(saving || value.url.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) }
}
}
}
}
@@ -0,0 +1,224 @@
import SwiftUI
import UniformTypeIdentifiers
@MainActor
final class TxtTocRuleManagementModel: ObservableObject {
@Published private(set) var rules: [TxtTocRule] = []
@Published var message: String?
private let repository: SQLiteConfigurationRepository?
init() {
repository = (try? LegadoDatabase(url: LegadoDatabase.defaultURL())).map {
SQLiteConfigurationRepository(database: $0)
}
}
func load() async {
guard let repository else { message = "无法打开目录规则数据库"; return }
do {
rules = try await repository.loadTxtTocRules().sorted { $0.serialNumber < $1.serialNumber }
if rules.isEmpty { await importDefaults(report: false) }
}
catch { message = error.localizedDescription }
}
func importDefaults(report: Bool = true) async {
let defaults = TxtTocRuleDefaults.load()
guard !defaults.isEmpty else { message = "应用内置目录规则缺失"; return }
var updated = rules.filter { $0.id >= 0 }
updated.append(contentsOf: defaults)
if await persist(updated), report { message = "已恢复内置目录规则" }
}
func save(_ rule: TxtTocRule) async -> Bool {
guard !rule.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
!rule.rule.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || rule.id == -100 else {
message = "名称和正则不能为空"
return false
}
guard rule.rule.isEmpty || (try? NSRegularExpression(pattern: rule.rule, options: .anchorsMatchLines)) != nil else {
message = "目录正则语法错误"
return false
}
var updated = rules
if let index = updated.firstIndex(where: { $0.id == rule.id }) { updated[index] = rule }
else { var value = rule; value.serialNumber = (updated.map(\.serialNumber).max() ?? 0) + 1; updated.append(value) }
return await persist(updated)
}
func toggle(_ rule: TxtTocRule) async {
var value = rule; value.enable.toggle(); _ = await save(value)
}
func remove(at offsets: IndexSet) async {
_ = await persist(rules.enumerated().filter { !offsets.contains($0.offset) }.map(\.element))
}
func move(from offsets: IndexSet, to destination: Int) async {
var updated = rules
updated.move(fromOffsets: offsets, toOffset: destination)
for index in updated.indices { updated[index].serialNumber = index + 1 }
_ = await persist(updated)
}
func importData(_ data: Data) async {
do {
let decoded: [TxtTocRule]
if let values = try? JSONDecoder().decode([TxtTocRule].self, from: data) { decoded = values }
else { decoded = [try JSONDecoder().decode(TxtTocRule.self, from: data)] }
var merged = rules
for value in decoded {
if let index = merged.firstIndex(where: { $0.id == value.id }) { merged[index] = value }
else { merged.append(value) }
}
for index in merged.indices { merged[index].serialNumber = index + 1 }
if await persist(merged) { message = "已导入 \(decoded.count) 条目录规则" }
} catch { message = error.localizedDescription }
}
private func persist(_ values: [TxtTocRule]) async -> Bool {
guard let repository else { message = "无法打开目录规则数据库"; return false }
do {
try await repository.saveTxtTocRules(values)
rules = try await repository.loadTxtTocRules().sorted { $0.serialNumber < $1.serialNumber }
return true
} catch { message = error.localizedDescription; return false }
}
}
struct TxtTocRuleManagementView: View {
@StateObject private var model = TxtTocRuleManagementModel()
@State private var editing: TxtTocRule?
@State private var importing = false
@State private var exporting = false
var body: some View {
List {
if model.rules.isEmpty {
ReadingUnavailableView("没有TXT目录规则", systemImage: "list.bullet.rectangle")
}
ForEach(model.rules) { rule in
Button { editing = rule } label: {
HStack(spacing: 12) {
Image(systemName: rule.enable ? "checkmark.circle.fill" : "circle")
.foregroundStyle(rule.enable ? ReadingTheme.accent : .secondary)
VStack(alignment: .leading, spacing: 4) {
Text(rule.name).foregroundStyle(.primary)
Text(rule.rule).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(2)
}
}
}
.swipeActions(edge: .leading) {
Button(rule.enable ? "停用" : "启用") { Task { await model.toggle(rule) } }
.tint(rule.enable ? .orange : .green)
}
}
.onDelete { offsets in Task { await model.remove(at: offsets) } }
.onMove { offsets, destination in Task { await model.move(from: offsets, to: destination) } }
}
.navigationTitle("TXT目录规则")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button("新建", systemImage: "plus") { editing = TxtTocRule() }
}
ToolbarItem(placement: .primaryAction) {
Menu {
Button("导入JSON", systemImage: "square.and.arrow.down") { importing = true }
Button("导出JSON", systemImage: "square.and.arrow.up") { exporting = true }
.disabled(model.rules.isEmpty)
Button("恢复内置规则", systemImage: "arrow.counterclockwise") {
Task { await model.importDefaults() }
}
EditButton()
} label: { Image(systemName: "ellipsis.circle") }
}
}
.task { await model.load() }
.sheet(item: $editing) { value in
TxtTocRuleEditView(value: value) { await model.save($0) }
}
.fileImporter(isPresented: $importing, allowedContentTypes: [.json, .plainText]) { result in
guard case .success(let url) = result else { return }
let granted = url.startAccessingSecurityScopedResource()
defer { if granted { url.stopAccessingSecurityScopedResource() } }
if let data = try? Data(contentsOf: url) { Task { await model.importData(data) } }
}
.fileExporter(
isPresented: $exporting,
document: TxtTocRuleDocument(rules: model.rules),
contentType: .json,
defaultFilename: "txtTocRules.json"
) { result in
if case .failure(let error) = result { model.message = error.localizedDescription }
}
.alert("TXT目录规则", isPresented: Binding(
get: { model.message != nil },
set: { if !$0 { model.message = nil } }
)) { Button("好") { model.message = nil } } message: { Text(model.message ?? "") }
}
}
private struct TxtTocRuleEditView: View {
@Environment(\.dismiss) private var dismiss
@State private var value: TxtTocRule
@State private var sample = ""
@State private var saving = false
let save: (TxtTocRule) async -> Bool
init(value: TxtTocRule, save: @escaping (TxtTocRule) async -> Bool) {
_value = State(initialValue: value)
_sample = State(initialValue: value.example ?? "")
self.save = save
}
private var testResult: String {
guard let expression = try? NSRegularExpression(pattern: value.rule, options: .anchorsMatchLines) else { return "正则语法错误" }
let range = NSRange(sample.startIndex..., in: sample)
let values = expression.matches(in: sample, range: range).prefix(20).compactMap { match -> String? in
guard let range = Range(match.range, in: sample) else { return nil }
return String(sample[range])
}
return values.isEmpty ? "没有匹配" : "匹配 \(values.count) 条:\n" + values.joined(separator: "\n")
}
var body: some View {
ReadingNavigationStack {
Form {
TextField("名称", text: $value.name)
ReadingTextField("目录正则", text: $value.rule, axis: .vertical)
.font(.body.monospaced()).textInputAutocapitalization(.never).disableAutocorrection(true)
ReadingTextField("标题处理JavaScript(可选)", text: Binding(
get: { value.replacement }, set: { value.replacement = $0 }
), axis: .vertical).font(.body.monospaced())
Toggle("启用", isOn: $value.enable)
Section("测试文本") {
TextEditor(text: $sample).frame(minHeight: 130)
Text(testResult).font(.caption.monospaced()).foregroundStyle(.secondary).textSelection(.enabled)
}
}
.navigationTitle("TXT目录规则")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
ToolbarItem(placement: .confirmationAction) {
Button(saving ? "保存中…" : "保存") {
value.example = sample.isEmpty ? nil : sample
saving = true
Task { if await save(value) { dismiss() }; saving = false }
}.disabled(saving)
}
}
}
}
}
private struct TxtTocRuleDocument: FileDocument {
static var readableContentTypes: [UTType] { [.json] }
let rules: [TxtTocRule]
init(rules: [TxtTocRule]) { self.rules = rules }
init(configuration: ReadConfiguration) throws { rules = [] }
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
let encoder = JSONEncoder(); encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
return FileWrapper(regularFileWithContents: try encoder.encode(rules))
}
}