mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-24 04:41:23 +08:00
feat: open-source 小浣熊阅读 for iOS
This commit is contained in:
@@ -0,0 +1,440 @@
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class SourceDebugViewModel: ObservableObject {
|
||||
@Published var keyword = ""
|
||||
@Published private(set) var isRunning = false
|
||||
@Published private(set) var report: BookSourceDebugReport?
|
||||
|
||||
private let debugger: BookSourceDebugger
|
||||
private var task: Task<Void, Never>?
|
||||
|
||||
init(debugger: BookSourceDebugger = .init()) {
|
||||
self.debugger = debugger
|
||||
}
|
||||
|
||||
deinit { task?.cancel() }
|
||||
|
||||
func run(source: BookSource) {
|
||||
let keyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !keyword.isEmpty else { return }
|
||||
task?.cancel()
|
||||
isRunning = true
|
||||
report = nil
|
||||
task = Task { [debugger] in
|
||||
let value = await debugger.run(source: source, keyword: keyword)
|
||||
guard !Task.isCancelled else { return }
|
||||
report = value
|
||||
isRunning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SourceDebugView: View {
|
||||
let source: BookSource
|
||||
@StateObject private var model = SourceDebugViewModel()
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 16) {
|
||||
introCard
|
||||
runCard
|
||||
if model.isRunning { runningCard }
|
||||
if let report = model.report { reportView(report) }
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 30)
|
||||
}
|
||||
.background(ReadingTheme.canvas)
|
||||
.navigationTitle("书源调试")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
}
|
||||
|
||||
private var introCard: some View {
|
||||
ReadingCard {
|
||||
HStack(spacing: 13) {
|
||||
Image(systemName: "stethoscope")
|
||||
.font(.title2).foregroundStyle(ReadingTheme.coral)
|
||||
.frame(width: 46, height: 46)
|
||||
.background(ReadingTheme.coral.opacity(0.11), in: RoundedRectangle(cornerRadius: 14))
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(source.bookSourceName).font(.headline)
|
||||
Text("依次验证搜索 → 详情 → 目录 → 第一章正文")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
|
||||
private var runCard: some View {
|
||||
ReadingCard {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("样本关键词").font(.caption.bold()).foregroundStyle(.secondary)
|
||||
TextField("输入该书源能搜到的书名", text: $model.keyword)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.textInputAutocapitalization(.never)
|
||||
.submitLabel(.go)
|
||||
.onSubmit { model.run(source: source) }
|
||||
Button { model.run(source: source) } label: {
|
||||
Label(model.isRunning ? "正在调试…" : "运行完整链路", systemImage: "play.fill")
|
||||
.font(.headline).frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(ReadingTheme.accent)
|
||||
.disabled(model.isRunning || model.keyword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var runningCard: some View {
|
||||
ReadingCard {
|
||||
HStack(spacing: 12) {
|
||||
ProgressView()
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("端到端检查进行中").font(.subheadline.bold())
|
||||
Text("请求只发送给这个书源,最多读取 5 页目录与正文。")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func reportView(_ report: BookSourceDebugReport) -> some View {
|
||||
ReadingCard {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(report.passed ? "完整链路通过" : "发现兼容问题").font(.headline)
|
||||
Text(report.passed ? "该样本已成功读取到正文" : "展开失败阶段查看规则诊断")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: report.passed ? "checkmark.seal.fill" : "exclamationmark.triangle.fill")
|
||||
.font(.title2).foregroundStyle(report.passed ? .green : .orange)
|
||||
}
|
||||
}
|
||||
VStack(spacing: 10) {
|
||||
ForEach(report.steps) { step in DebugStepCard(step: step) }
|
||||
}
|
||||
if let preview = report.contentPreview {
|
||||
ReadingCard {
|
||||
VStack(alignment: .leading, spacing: 9) {
|
||||
Label(report.sampleChapterTitle ?? "正文样本", systemImage: "text.quote")
|
||||
.font(.headline)
|
||||
Text(preview).font(.footnote).foregroundStyle(.secondary).lineSpacing(3)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class SourceBatchTestModel: ObservableObject {
|
||||
enum Status: Equatable {
|
||||
case waiting
|
||||
case testing
|
||||
case passed(Int)
|
||||
case failed(String)
|
||||
}
|
||||
|
||||
@Published private(set) var statuses: [String: Status] = [:]
|
||||
@Published private(set) var completedCount = 0
|
||||
@Published private(set) var totalCount = 0
|
||||
@Published private(set) var isTesting = false
|
||||
@Published var selectedFailedIDs = Set<String>()
|
||||
|
||||
var failedCount: Int {
|
||||
statuses.values.reduce(into: 0) { count, status in
|
||||
if case .failed = status { count += 1 }
|
||||
}
|
||||
}
|
||||
|
||||
private let service: BookSearchService
|
||||
private var task: Task<Void, Never>?
|
||||
|
||||
init(service: BookSearchService = .init()) {
|
||||
self.service = service
|
||||
}
|
||||
|
||||
deinit { task?.cancel() }
|
||||
|
||||
func start(sources: [BookSource], keyword: String) {
|
||||
stop()
|
||||
let keyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !keyword.isEmpty, !sources.isEmpty else { return }
|
||||
statuses = Dictionary(uniqueKeysWithValues: sources.map { ($0.id, .waiting) })
|
||||
selectedFailedIDs = []
|
||||
completedCount = 0
|
||||
totalCount = sources.count
|
||||
isTesting = true
|
||||
|
||||
task = Task { [service] in
|
||||
for source in sources.prefix(16) { statuses[source.id] = .testing }
|
||||
var startedCount = min(16, sources.count)
|
||||
for await outcome in service.searchOutcomes(
|
||||
keyword: keyword,
|
||||
sources: sources,
|
||||
maximumConcurrentSources: ApplicationSettingsStore.shared.settings.concurrentSources,
|
||||
includeDisabled: true
|
||||
) {
|
||||
guard !Task.isCancelled else { return }
|
||||
completedCount += 1
|
||||
if outcome.books.isEmpty {
|
||||
let message = outcome.diagnostics.first(where: { $0.severity == .error })?.message
|
||||
?? "没有搜索到结果"
|
||||
statuses[outcome.sourceID] = .failed(message)
|
||||
selectedFailedIDs.insert(outcome.sourceID)
|
||||
} else {
|
||||
statuses[outcome.sourceID] = .passed(outcome.books.count)
|
||||
}
|
||||
if startedCount < sources.count {
|
||||
statuses[sources[startedCount].id] = .testing
|
||||
startedCount += 1
|
||||
}
|
||||
}
|
||||
guard !Task.isCancelled else { return }
|
||||
isTesting = false
|
||||
task = nil
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
task?.cancel()
|
||||
task = nil
|
||||
isTesting = false
|
||||
for (id, status) in statuses where status == .testing || status == .waiting {
|
||||
statuses[id] = .waiting
|
||||
}
|
||||
}
|
||||
|
||||
func toggleSelection(_ sourceID: String) {
|
||||
if !selectedFailedIDs.insert(sourceID).inserted { selectedFailedIDs.remove(sourceID) }
|
||||
}
|
||||
}
|
||||
|
||||
struct SourceBatchTestView: View {
|
||||
private enum CleanupAction: String, Identifiable {
|
||||
case disable
|
||||
case delete
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
@EnvironmentObject private var library: SourceLibraryModel
|
||||
@StateObject private var model = SourceBatchTestModel()
|
||||
@AppStorage("sourceTestKeyword") private var keyword = "我的"
|
||||
@State private var includesDisabled = false
|
||||
@State private var onlyShowsFailed = false
|
||||
@State private var cleanupAction: CleanupAction?
|
||||
|
||||
private var testedSources: [BookSource] {
|
||||
includesDisabled ? library.sources : library.sources.filter(\.enabled)
|
||||
}
|
||||
|
||||
private var visibleSources: [BookSource] {
|
||||
guard onlyShowsFailed else { return testedSources }
|
||||
return testedSources.filter {
|
||||
if case .failed? = model.statuses[$0.id] { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
TextField("校验搜索词", text: $keyword)
|
||||
.textInputAutocapitalization(.never)
|
||||
Toggle("包含已停用书源", isOn: $includesDisabled)
|
||||
.disabled(model.isTesting)
|
||||
Toggle("只看失败", isOn: $onlyShowsFailed)
|
||||
} footer: {
|
||||
Text("快速测试以搜索是否能返回结果为准。部分正常书源可能不收录当前关键词,删除前请复核或更换关键词重测。")
|
||||
}
|
||||
|
||||
if model.totalCount > 0 {
|
||||
Section {
|
||||
ProgressView(value: Double(model.completedCount), total: Double(model.totalCount))
|
||||
HStack {
|
||||
Text("已完成 \(model.completedCount)/\(model.totalCount)")
|
||||
Spacer()
|
||||
Text("失败 \(model.failedCount) · 已选 \(model.selectedFailedIDs.count)")
|
||||
.foregroundStyle(model.failedCount == 0 ? Color.secondary : Color.red)
|
||||
}
|
||||
.font(.subheadline.monospacedDigit())
|
||||
}
|
||||
}
|
||||
|
||||
Section("书源") {
|
||||
ForEach(visibleSources) { source in
|
||||
SourceBatchTestRow(
|
||||
source: source,
|
||||
status: model.statuses[source.id] ?? .waiting,
|
||||
selected: model.selectedFailedIDs.contains(source.id),
|
||||
onToggle: { model.toggleSelection(source.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("书源测试")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
if model.isTesting {
|
||||
Button("停止", systemImage: "stop.fill") { model.stop() }
|
||||
} else {
|
||||
Button("开始", systemImage: "play.fill") {
|
||||
model.start(sources: testedSources, keyword: keyword)
|
||||
}
|
||||
.disabled(testedSources.isEmpty || keyword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
if !model.isTesting, !model.selectedFailedIDs.isEmpty {
|
||||
HStack {
|
||||
Button("停用所选", systemImage: "pause.circle") { cleanupAction = .disable }
|
||||
Spacer()
|
||||
Button("删除所选", systemImage: "trash", role: .destructive) { cleanupAction = .delete }
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.horizontal, 18).padding(.vertical, 12)
|
||||
.background(.ultraThinMaterial)
|
||||
}
|
||||
}
|
||||
.alert(item: $cleanupAction) { action in
|
||||
Alert(
|
||||
title: Text(action == .delete ? "删除失败书源?" : "停用失败书源?"),
|
||||
message: Text("将处理已勾选的 \(model.selectedFailedIDs.count) 个书源。搜索不到当前关键词不一定代表书源永久失效。"),
|
||||
primaryButton: .destructive(Text(action == .delete ? "删除" : "停用")) {
|
||||
let ids = model.selectedFailedIDs
|
||||
Task {
|
||||
if action == .delete { await library.remove(sourceIDs: ids) }
|
||||
else { await library.setEnabled(false, for: ids) }
|
||||
model.selectedFailedIDs = []
|
||||
}
|
||||
},
|
||||
secondaryButton: .cancel()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SourceBatchTestRow: View {
|
||||
let source: BookSource
|
||||
let status: SourceBatchTestModel.Status
|
||||
let selected: Bool
|
||||
let onToggle: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.foregroundStyle(color)
|
||||
.frame(width: 28)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(source.bookSourceName).font(.headline)
|
||||
Text(detail).font(.caption).foregroundStyle(.secondary).lineLimit(2)
|
||||
}
|
||||
Spacer()
|
||||
if case .failed = status {
|
||||
Button(action: onToggle) {
|
||||
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||
.font(.title3)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var detail: String {
|
||||
switch status {
|
||||
case .waiting: "等待测试"
|
||||
case .testing: "正在测试…"
|
||||
case .passed(let count): "通过,搜索到 \(count) 条结果"
|
||||
case .failed(let message): message
|
||||
}
|
||||
}
|
||||
|
||||
private var icon: String {
|
||||
switch status {
|
||||
case .waiting: "clock"
|
||||
case .testing: "arrow.triangle.2.circlepath"
|
||||
case .passed: "checkmark.circle.fill"
|
||||
case .failed: "xmark.circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private var color: Color {
|
||||
switch status {
|
||||
case .waiting: .secondary
|
||||
case .testing: ReadingTheme.accent
|
||||
case .passed: .green
|
||||
case .failed: .red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct DebugStepCard: View {
|
||||
let step: SourceDebugStep
|
||||
|
||||
var body: some View {
|
||||
DisclosureGroup {
|
||||
if step.diagnostics.isEmpty {
|
||||
Text("该阶段没有规则诊断。")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, 8)
|
||||
} else {
|
||||
VStack(alignment: .leading, spacing: 9) {
|
||||
ForEach(step.diagnostics) { diagnostic in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(diagnostic.message).font(.caption)
|
||||
Text(diagnostic.code).font(.caption2.monospaced()).foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.foregroundStyle(color)
|
||||
.frame(width: 34, height: 34)
|
||||
.background(color.opacity(0.1), in: Circle())
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(step.stage.title).font(.subheadline.bold())
|
||||
Text(step.summary).font(.caption).foregroundStyle(.secondary).lineLimit(2)
|
||||
}
|
||||
Spacer()
|
||||
if step.elapsedMilliseconds > 0 {
|
||||
Text("\(step.elapsedMilliseconds) ms")
|
||||
.font(.caption2.monospacedDigit()).foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.background(.background, in: RoundedRectangle(cornerRadius: 17, style: .continuous))
|
||||
.overlay { RoundedRectangle(cornerRadius: 17, style: .continuous).stroke(.primary.opacity(0.06)) }
|
||||
}
|
||||
|
||||
private var color: Color {
|
||||
switch step.status {
|
||||
case .passed: .green
|
||||
case .warning: .orange
|
||||
case .failed: .red
|
||||
case .skipped: .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private var icon: String {
|
||||
switch step.status {
|
||||
case .passed: "checkmark"
|
||||
case .warning: "exclamationmark"
|
||||
case .failed: "xmark"
|
||||
case .skipped: "forward.end"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
struct SourceEditorView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject private var library: SourceLibraryModel
|
||||
let original: BookSource?
|
||||
let onSaved: ((BookSource) -> Void)?
|
||||
@State private var document: BookSourceEditorDocument
|
||||
@State private var jsonText: String
|
||||
@State private var mode = 0
|
||||
@State private var sectionID = "basic"
|
||||
@State private var errorMessage: String?
|
||||
@State private var validation: [BookSourceIssue] = []
|
||||
@State private var debugSource: BookSource?
|
||||
@State private var isSaving = false
|
||||
@State private var showsDiscard = false
|
||||
private let initialJSON: String
|
||||
|
||||
init(source: BookSource? = nil, onSaved: ((BookSource) -> Void)? = nil) {
|
||||
self.onSaved = onSaved
|
||||
original = source
|
||||
let doc = try! BookSourceEditorDocument(source: source ?? BookSource(bookSourceUrl: "", bookSourceName: ""))
|
||||
let json = (try? doc.json()) ?? "{}"
|
||||
initialJSON = json
|
||||
_document = State(initialValue: doc)
|
||||
_jsonText = State(initialValue: json)
|
||||
}
|
||||
|
||||
private var isDirty: Bool { mode == 1 ? jsonText != initialJSON : (try? document.json()) != initialJSON }
|
||||
private var currentSection: BookSourceEditorSection { BookSourceEditorSchema.sections.first { $0.id == sectionID }! }
|
||||
|
||||
var body: some View {
|
||||
ReadingNavigationStack {
|
||||
VStack(spacing: 0) {
|
||||
Picker("编辑模式", selection: Binding(get: { mode }, set: { value in switchMode(value) })) {
|
||||
Text("字段编辑").tag(0)
|
||||
Text("JSON").tag(1)
|
||||
}.pickerStyle(.segmented).padding(.horizontal).padding(.vertical, 8)
|
||||
if mode == 1 {
|
||||
SyntaxCodeEditor(text: $jsonText, language: .json)
|
||||
} else {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack {
|
||||
ForEach(BookSourceEditorSchema.sections) { section in
|
||||
Button(section.title) { sectionID = section.id }
|
||||
.buttonStyle(.bordered)
|
||||
.tint(sectionID == section.id ? ReadingTheme.accent : .secondary)
|
||||
}
|
||||
Button("选项") { sectionID = "options" }.buttonStyle(.bordered)
|
||||
.tint(sectionID == "options" ? ReadingTheme.accent : .secondary)
|
||||
}.padding(.horizontal)
|
||||
}.padding(.vertical, 6)
|
||||
Form {
|
||||
if sectionID == "options" { options }
|
||||
else {
|
||||
Section(currentSection.title) {
|
||||
ForEach(currentSection.fields) { field in
|
||||
NavigationLink {
|
||||
RuleCodeFieldView(title: field.title, path: field.path, text: string(field.path),
|
||||
language: field.isJavaScript ? .javascript : .rule, help: field.help)
|
||||
} label: {
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
Text(field.title)
|
||||
Text(document.value(at: field.path)?.stringValue.isEmpty == false ? document.value(at: field.path)!.stringValue : field.path)
|
||||
.font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Section {
|
||||
Text("点击字段可全屏编辑规则。支持 @css:、XPath、$. JSONPath、@js:、{{key}}、{{page}} 和 ## 替换语法。未识别的扩展字段保留在 JSON 中。")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if !validation.isEmpty {
|
||||
Section("静态检查") {
|
||||
ForEach(validation) { issue in
|
||||
Label("\(issue.field):\(issue.message)", systemImage: issue.severity == .error ? "xmark.circle" : "exclamationmark.triangle")
|
||||
.font(.footnote).foregroundStyle(issue.severity == .error ? .red : .orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(original == nil ? "新建书源" : "编辑书源")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("取消") { if isDirty { showsDiscard = true } else { dismiss() } }
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu("更多", systemImage: "ellipsis.circle") {
|
||||
Button("格式化 JSON", systemImage: "text.alignleft") { perform { try synchronize(); jsonText = try document.json() } }
|
||||
Button("校验配置", systemImage: "checkmark.shield") { check() }
|
||||
Button("调试当前草稿", systemImage: "stethoscope") { perform { try synchronize(); debugSource = try document.source() } }
|
||||
Button("复制 JSON", systemImage: "doc.on.doc") { perform { try synchronize(); UIPasteboard.general.string = try document.json() } }
|
||||
Button("粘贴 JSON", systemImage: "doc.on.clipboard") {
|
||||
perform {
|
||||
guard let value = UIPasteboard.general.string else { return }
|
||||
let pasted = try BookSourceEditorDocument(json: value)
|
||||
_ = try JSONDecoder().decode(BookSource.self, from: Data(pasted.json().utf8))
|
||||
document = pasted
|
||||
jsonText = try pasted.json()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(isSaving ? "保存中" : "保存", action: save).disabled(isSaving)
|
||||
}
|
||||
}
|
||||
.readingNavigationDestination(isPresented: Binding(
|
||||
get: { debugSource != nil },
|
||||
set: { if !$0 { debugSource = nil } }
|
||||
)) {
|
||||
if let debugSource { SourceDebugView(source: debugSource) }
|
||||
}
|
||||
.alert("无法完成操作", isPresented: Binding(get: { errorMessage != nil }, set: { if !$0 { errorMessage = nil } })) {
|
||||
Button("好", role: .cancel) {}
|
||||
} message: { Text(errorMessage ?? "") }
|
||||
.confirmationDialog("放弃未保存的修改?", isPresented: $showsDiscard, titleVisibility: .visible) {
|
||||
Button("放弃修改", role: .destructive) { dismiss() }
|
||||
}
|
||||
}
|
||||
.interactiveDismissDisabled(isDirty || isSaving)
|
||||
}
|
||||
|
||||
private var options: some View {
|
||||
Group {
|
||||
Section("书源类型") {
|
||||
Picker("类型", selection: Binding(get: { Int(document.value(at: "bookSourceType")?.stringValue ?? "0") ?? 0 },
|
||||
set: { document.set(.number(Double($0)), at: "bookSourceType") })) {
|
||||
Text("文本").tag(0); Text("音频").tag(1); Text("图片").tag(2); Text("文件").tag(3); Text("视频").tag(4)
|
||||
}
|
||||
}
|
||||
Section("开关") {
|
||||
Toggle("启用书源", isOn: boolean("enabled", fallback: true))
|
||||
Toggle("启用发现", isOn: boolean("enabledExplore", fallback: true))
|
||||
Toggle("Cookie Jar", isOn: boolean("enabledCookieJar", fallback: true))
|
||||
Toggle("事件监听", isOn: boolean("eventListener"))
|
||||
Toggle("定制按钮", isOn: boolean("customButton"))
|
||||
}
|
||||
Section("排序与状态") {
|
||||
ForEach(["customOrder", "weight", "lastUpdateTime", "respondTime"], id: \.self) { key in
|
||||
HStack {
|
||||
Text(["customOrder": "排序编号", "weight": "搜索权重", "lastUpdateTime": "更新时间 (ms)", "respondTime": "响应时间 (ms)"][key]!)
|
||||
TextField(key, text: number(key)).keyboardType(.numbersAndPunctuation).multilineTextAlignment(.trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func string(_ path: String) -> Binding<String> {
|
||||
Binding(get: { document.value(at: path)?.stringValue ?? "" }, set: { document.set($0.isEmpty ? nil : .string($0), at: path) })
|
||||
}
|
||||
private func boolean(_ path: String, fallback: Bool = false) -> Binding<Bool> {
|
||||
Binding(get: { if case .bool(let value) = document.value(at: path) { return value }; return fallback },
|
||||
set: { document.set(.bool($0), at: path) })
|
||||
}
|
||||
private func number(_ path: String) -> Binding<String> {
|
||||
Binding(get: { document.value(at: path)?.stringValue ?? "0" }, set: { document.set(.string($0), at: path) })
|
||||
}
|
||||
private func synchronize() throws { if mode == 1 { document = try BookSourceEditorDocument(json: jsonText) } }
|
||||
private func switchMode(_ next: Int) {
|
||||
guard next != mode else { return }
|
||||
perform {
|
||||
if next == 0 { try synchronize() }
|
||||
else { jsonText = try document.json() }
|
||||
mode = next
|
||||
}
|
||||
}
|
||||
private func perform(_ action: () throws -> Void) {
|
||||
do { try action() } catch { errorMessage = readable(error) }
|
||||
}
|
||||
private func readable(_ error: Error) -> String {
|
||||
if case DecodingError.typeMismatch(_, let context) = error {
|
||||
return "\(context.codingPath.map(\.stringValue).joined(separator: ".")):字段类型不正确。\(context.debugDescription)"
|
||||
}
|
||||
return error.localizedDescription
|
||||
}
|
||||
private func check() {
|
||||
perform {
|
||||
try synchronize()
|
||||
let source = try JSONDecoder().decode(BookSource.self, from: Data(document.json().utf8))
|
||||
validation = BookSourceValidator.validate(source)
|
||||
if validation.isEmpty { errorMessage = "静态检查通过。可通过“调试当前草稿”验证实际请求及解析结果。" }
|
||||
}
|
||||
}
|
||||
private func save() {
|
||||
perform {
|
||||
try synchronize()
|
||||
var source = try document.source()
|
||||
if library.sources.contains(where: { $0.id == source.id && $0.id != original?.id }) {
|
||||
throw BookSourceImportError.duplicate(source.id)
|
||||
}
|
||||
source.lastUpdateTime = Int64(Date().timeIntervalSince1970 * 1_000)
|
||||
isSaving = true
|
||||
Task {
|
||||
let saved = await library.saveSource(source, replacing: original?.id)
|
||||
isSaving = false
|
||||
if saved { onSaved?(source); dismiss() }
|
||||
else {
|
||||
let message = library.notice?.detail ?? "保存失败"
|
||||
library.notice = nil
|
||||
errorMessage = message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,434 @@
|
||||
#if SWIFT_PACKAGE
|
||||
import LegadoCore
|
||||
#endif
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class SourceLibraryModel: ObservableObject {
|
||||
@Published private(set) var sources: [BookSource] = []
|
||||
@Published private(set) var isWorking = false
|
||||
@Published var notice: Notice?
|
||||
@Published var pendingImport: PendingImport?
|
||||
|
||||
struct PendingImport: Identifiable, Sendable {
|
||||
struct Item: Identifiable, Sendable {
|
||||
let source: BookSource
|
||||
let existing: BookSource?
|
||||
var id: String { source.id }
|
||||
|
||||
var state: String {
|
||||
guard let existing else { return "新增" }
|
||||
return source.lastUpdateTime > existing.lastUpdateTime ? "更新" : "已有"
|
||||
}
|
||||
}
|
||||
|
||||
struct InvalidItem: Identifiable, Sendable {
|
||||
let id = UUID()
|
||||
let source: BookSource
|
||||
let issues: [BookSourceIssue]
|
||||
}
|
||||
|
||||
let id = UUID()
|
||||
let items: [Item]
|
||||
let invalidItems: [InvalidItem]
|
||||
|
||||
var validCount: Int { items.count }
|
||||
var invalidCount: Int { invalidItems.count }
|
||||
var totalCount: Int { validCount + invalidCount }
|
||||
}
|
||||
|
||||
struct Notice: Identifiable {
|
||||
enum Kind { case success, error }
|
||||
|
||||
let id = UUID()
|
||||
let kind: Kind
|
||||
let title: String
|
||||
let detail: String
|
||||
}
|
||||
|
||||
private let repository: any BookSourceRepository
|
||||
private let importer = BookSourceImporter()
|
||||
private let importTransport: any HTTPTransport
|
||||
|
||||
var groups: [String] {
|
||||
Array(Set(sources.flatMap(\.groups))).sorted { $0.localizedStandardCompare($1) == .orderedAscending }
|
||||
}
|
||||
|
||||
init(repository: any BookSourceRepository, importTransport: any HTTPTransport = URLSessionHTTPTransport.shared) {
|
||||
self.repository = repository
|
||||
self.importTransport = importTransport
|
||||
}
|
||||
|
||||
static func live() -> SourceLibraryModel {
|
||||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
||||
.appendingPathComponent("Legado", isDirectory: true)
|
||||
let legacy = base.appendingPathComponent("book-sources.json")
|
||||
guard let database = try? LegadoDatabase(url: base.appendingPathComponent("legado.sqlite3")) else {
|
||||
return SourceLibraryModel(repository: FileBookSourceRepository(fileURL: legacy))
|
||||
}
|
||||
return SourceLibraryModel(repository: SQLiteBookSourceRepository(database: database, legacyFileURL: legacy))
|
||||
}
|
||||
|
||||
static func demo(sources: [BookSource]) -> SourceLibraryModel {
|
||||
SourceLibraryModel(repository: InMemoryBookSourceRepository(sources: sources))
|
||||
}
|
||||
|
||||
func load() async {
|
||||
guard sources.isEmpty else { return }
|
||||
isWorking = true
|
||||
defer { isWorking = false }
|
||||
do {
|
||||
sources = try await repository.load()
|
||||
} catch {
|
||||
notice = .init(kind: .error, title: "无法读取书源", detail: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func importFiles(_ urls: [URL]) async {
|
||||
isWorking = true
|
||||
defer { isWorking = false }
|
||||
var combined = sources
|
||||
var inserted = 0
|
||||
var replaced = 0
|
||||
var warnings = 0
|
||||
do {
|
||||
for url in urls {
|
||||
let granted = url.startAccessingSecurityScopedResource()
|
||||
defer { if granted { url.stopAccessingSecurityScopedResource() } }
|
||||
let result = try await Task.detached(priority: .userInitiated) { [importer, existing = combined] in
|
||||
try importer.prepare(data: Data(contentsOf: url), existing: existing, duplicatePolicy: .replace)
|
||||
}.value
|
||||
combined = result.sources
|
||||
inserted += result.inserted
|
||||
replaced += result.replaced
|
||||
warnings += result.warnings.count
|
||||
}
|
||||
try await repository.save(combined)
|
||||
sources = combined
|
||||
notice = .init(
|
||||
kind: .success,
|
||||
title: "书源导入完成",
|
||||
detail: "新增 \(inserted) 个,覆盖 \(replaced) 个,警告 \(warnings) 条"
|
||||
)
|
||||
} catch {
|
||||
notice = .init(kind: .error, title: "书源导入失败", detail: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func importText(_ text: String) async { await importRemoteURL(text) }
|
||||
|
||||
func importRemoteURL(_ value: String) async {
|
||||
isWorking = true
|
||||
defer { isWorking = false }
|
||||
do {
|
||||
var budget = 100
|
||||
let batch = try await resolveImport(value, depth: 0, visited: [], budget: &budget)
|
||||
guard batch.invalidItems.isEmpty else {
|
||||
throw BookSourceImportError.invalidSources(batch.invalidItems.flatMap(\.issues))
|
||||
}
|
||||
try await applyImport(JSONEncoder().encode(batch.sources), successTitle: "书源导入完成")
|
||||
} catch {
|
||||
notice = .init(kind: .error, title: "书源导入失败", detail: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func remove(at offsets: IndexSet) async {
|
||||
var updated = sources
|
||||
updated.remove(atOffsets: offsets)
|
||||
await persist(updated, successTitle: "已删除书源")
|
||||
}
|
||||
|
||||
func remove(sourceIDs: Set<String>) async {
|
||||
guard !sourceIDs.isEmpty else { return }
|
||||
await persist(sources.filter { !sourceIDs.contains($0.id) }, successTitle: "已删除 \(sourceIDs.count) 个书源")
|
||||
}
|
||||
|
||||
func setEnabled(_ enabled: Bool, for sourceID: String) async {
|
||||
var updated = sources
|
||||
guard let index = updated.firstIndex(where: { $0.id == sourceID }) else { return }
|
||||
updated[index].enabled = enabled
|
||||
await persist(updated, successTitle: nil)
|
||||
}
|
||||
|
||||
func applySilentSubscription(_ data: Data) async throws {
|
||||
let incoming = try await Task.detached(priority: .userInitiated) { [importer] in
|
||||
try importer.decode(data)
|
||||
}.value
|
||||
var updated = sources
|
||||
for var source in incoming {
|
||||
if let index = updated.firstIndex(where: { $0.bookSourceUrl == source.bookSourceUrl }) {
|
||||
guard updated[index].lastUpdateTime < source.lastUpdateTime else { continue }
|
||||
source.bookSourceGroup = updated[index].bookSourceGroup
|
||||
updated[index] = source
|
||||
} else { updated.append(source) }
|
||||
}
|
||||
try await repository.save(updated)
|
||||
sources = updated
|
||||
}
|
||||
|
||||
func previewFiles(_ urls: [URL]) async {
|
||||
isWorking = true
|
||||
defer { isWorking = false }
|
||||
do {
|
||||
var incoming: [String: BookSource] = [:]
|
||||
var invalidItems: [PendingImport.InvalidItem] = []
|
||||
for url in urls {
|
||||
let granted = url.startAccessingSecurityScopedResource()
|
||||
defer { if granted { url.stopAccessingSecurityScopedResource() } }
|
||||
let data = try await Task.detached(priority: .userInitiated) { [importer] in
|
||||
try importer.normalizedData(Data(contentsOf: url))
|
||||
}.value
|
||||
var budget = 100
|
||||
let batch = try await resolveImport(String(decoding: data, as: UTF8.self), depth: 0, visited: [], budget: &budget)
|
||||
invalidItems.append(contentsOf: batch.invalidItems)
|
||||
for source in batch.sources {
|
||||
incoming[source.id] = source
|
||||
}
|
||||
}
|
||||
// UIDocumentPicker is still dismissing when its completion runs.
|
||||
// Presenting the review sheet in that same frame is ignored by
|
||||
// SwiftUI, so stage it after the picker has left the hierarchy.
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
stage(Array(incoming.values), invalidItems: invalidItems)
|
||||
} catch {
|
||||
notice = .init(kind: .error, title: "书源导入失败", detail: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
func previewText(_ text: String) async { await previewInput(text) }
|
||||
|
||||
func previewRemoteURL(_ value: String) async { await previewInput(value) }
|
||||
|
||||
func previewInput(_ value: String) async {
|
||||
isWorking = true
|
||||
defer { isWorking = false }
|
||||
do {
|
||||
var budget = 100
|
||||
let batch = try await resolveImport(value, depth: 0, visited: [], budget: &budget)
|
||||
stage(batch.sources, invalidItems: batch.invalidItems)
|
||||
} catch {
|
||||
notice = .init(kind: .error, title: "书源导入失败", detail: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated func resolveImport(_ value: String, depth: Int, visited: Set<String>, budget: inout Int) async throws -> (
|
||||
sources: [BookSource], invalidItems: [PendingImport.InvalidItem]
|
||||
) {
|
||||
guard depth < 8 else { throw BookSourceImportError.invalidDocument("订阅清单嵌套过深") }
|
||||
let input = try RuleImportInput.parse(value)
|
||||
let data: Data
|
||||
var visited = visited
|
||||
switch input {
|
||||
case .json(let json): data = Data(json.utf8)
|
||||
case .remote(let url, _):
|
||||
guard budget > 0 else { throw BookSourceImportError.invalidDocument("订阅清单的总链接数超过 100") }
|
||||
budget -= 1
|
||||
guard visited.insert(url.absoluteString).inserted else {
|
||||
throw BookSourceImportError.invalidDocument("订阅清单出现循环链接")
|
||||
}
|
||||
let response = try await importTransport.data(for: input.request()!)
|
||||
guard (200...299).contains(response.statusCode) else {
|
||||
throw BookSourceImportError.invalidDocument("服务器返回 HTTP \(response.statusCode)")
|
||||
}
|
||||
data = response.data
|
||||
}
|
||||
let normalized = try importer.normalizedData(data)
|
||||
if case .array(let urls) = (try JSONValue.decode(data: normalized))["sourceUrls"] {
|
||||
guard !urls.isEmpty, urls.count <= 100 else { throw BookSourceImportError.invalidDocument("订阅清单需包含 1–100 个链接") }
|
||||
var sources: [BookSource] = []
|
||||
var invalid: [PendingImport.InvalidItem] = []
|
||||
for item in urls {
|
||||
guard case .string(let url) = item else { throw BookSourceImportError.invalidDocument("sourceUrls 必须是链接数组") }
|
||||
let batch = try await resolveImport(url, depth: depth + 1, visited: visited, budget: &budget)
|
||||
sources += batch.sources
|
||||
invalid += batch.invalidItems
|
||||
}
|
||||
return (sources, invalid)
|
||||
}
|
||||
return try validatedSources(from: normalized)
|
||||
}
|
||||
|
||||
func cancelPendingImport() { pendingImport = nil }
|
||||
|
||||
func commitPendingImport(
|
||||
selectedIDs: Set<String>,
|
||||
keepOriginalName: Bool,
|
||||
keepOriginalGroup: Bool,
|
||||
keepOriginalEnabled: Bool,
|
||||
customGroup: String?,
|
||||
addCustomGroup: Bool
|
||||
) async {
|
||||
guard let pendingImport else { return }
|
||||
var updated = sources
|
||||
var inserted = 0
|
||||
var replaced = 0
|
||||
for item in pendingImport.items where selectedIDs.contains(item.id) {
|
||||
var source = item.source
|
||||
if let existing = item.existing {
|
||||
if keepOriginalName { source.bookSourceName = existing.bookSourceName }
|
||||
if keepOriginalGroup { source.bookSourceGroup = existing.bookSourceGroup }
|
||||
if keepOriginalEnabled {
|
||||
source.enabled = existing.enabled
|
||||
source.enabledExplore = existing.enabledExplore
|
||||
}
|
||||
source.customOrder = existing.customOrder
|
||||
}
|
||||
if let customGroup, !BookSource.parseGroups(customGroup).isEmpty {
|
||||
if addCustomGroup { source.addGroups(customGroup) }
|
||||
else { source.bookSourceGroup = BookSource.joinGroups(BookSource.parseGroups(customGroup)) }
|
||||
}
|
||||
if let index = updated.firstIndex(where: { $0.id == source.id }) {
|
||||
updated[index] = source
|
||||
replaced += 1
|
||||
} else {
|
||||
updated.append(source)
|
||||
inserted += 1
|
||||
}
|
||||
}
|
||||
do {
|
||||
try await repository.save(updated)
|
||||
sources = updated
|
||||
self.pendingImport = nil
|
||||
notice = .init(
|
||||
kind: .success,
|
||||
title: "书源导入完成",
|
||||
detail: "新增 \(inserted) 个,更新 \(replaced) 个"
|
||||
)
|
||||
} catch {
|
||||
notice = .init(kind: .error, title: "保存失败", detail: error.localizedDescription)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func setExploreEnabled(_ enabled: Bool, for sourceID: String) async {
|
||||
var updated = sources
|
||||
guard let index = updated.firstIndex(where: { $0.id == sourceID }) else { return }
|
||||
updated[index].enabledExplore = enabled
|
||||
await persist(updated, successTitle: nil)
|
||||
}
|
||||
|
||||
func saveSource(_ source: BookSource, replacing oldSourceID: String? = nil) async -> Bool {
|
||||
let errors = BookSourceValidator.validate(source).filter { $0.severity == .error }
|
||||
guard errors.isEmpty else {
|
||||
notice = .init(kind: .error, title: "书源无效", detail: errors.map(\.message).joined(separator: ";"))
|
||||
return false
|
||||
}
|
||||
let oldSourceID = oldSourceID ?? source.id
|
||||
var updated = sources
|
||||
if updated.contains(where: { $0.id == oldSourceID }) {
|
||||
if source.id != oldSourceID, updated.contains(where: { $0.id == source.id }) {
|
||||
notice = .init(kind: .error, title: "地址冲突", detail: "该地址已有书源,请使用导入预览选择更新。")
|
||||
return false
|
||||
}
|
||||
guard let replacement = updated.firstIndex(where: { $0.id == oldSourceID }) else { return false }
|
||||
updated[replacement] = source
|
||||
} else if let index = updated.firstIndex(where: { $0.id == source.id }) {
|
||||
updated[index] = source
|
||||
} else {
|
||||
updated.append(source)
|
||||
}
|
||||
return await persist(updated, successTitle: "书源已保存")
|
||||
}
|
||||
|
||||
func setEnabled(_ enabled: Bool, for sourceIDs: Set<String>) async {
|
||||
guard !sourceIDs.isEmpty else { return }
|
||||
var updated = sources
|
||||
for index in updated.indices where sourceIDs.contains(updated[index].id) {
|
||||
updated[index].enabled = enabled
|
||||
}
|
||||
await persist(updated, successTitle: enabled ? "已批量启用" : "已批量停用")
|
||||
}
|
||||
|
||||
func addGroup(_ group: String, to sourceIDs: Set<String>) async {
|
||||
let values = BookSource.parseGroups(group)
|
||||
guard !values.isEmpty, !sourceIDs.isEmpty else { return }
|
||||
var updated = sources
|
||||
for index in updated.indices where sourceIDs.contains(updated[index].id) {
|
||||
updated[index].addGroups(values.joined(separator: ","))
|
||||
}
|
||||
await persist(updated, successTitle: "已加入分组 \(values.joined(separator: "、"))")
|
||||
}
|
||||
|
||||
func removeGroup(_ group: String, from sourceIDs: Set<String>) async {
|
||||
guard !sourceIDs.isEmpty else { return }
|
||||
var updated = sources
|
||||
for index in updated.indices where sourceIDs.contains(updated[index].id) {
|
||||
updated[index].removeGroups(group)
|
||||
}
|
||||
await persist(updated, successTitle: "已移出分组")
|
||||
}
|
||||
|
||||
func renameGroup(_ oldValue: String, to newValue: String) async {
|
||||
guard !BookSource.parseGroups(newValue).isEmpty else { return }
|
||||
var updated = sources
|
||||
for index in updated.indices where updated[index].hasGroup(oldValue) {
|
||||
updated[index].replaceGroup(oldValue, with: newValue)
|
||||
}
|
||||
await persist(updated, successTitle: "分组已重命名")
|
||||
}
|
||||
|
||||
func deleteGroup(_ group: String) async {
|
||||
var updated = sources
|
||||
for index in updated.indices where updated[index].hasGroup(group) {
|
||||
updated[index].removeGroups(group)
|
||||
}
|
||||
await persist(updated, successTitle: "已删除分组 \(group)")
|
||||
}
|
||||
|
||||
func handleDeepLink(_ url: URL) async {
|
||||
if url.isFileURL {
|
||||
await previewFiles([url])
|
||||
return
|
||||
}
|
||||
await previewInput(url.absoluteString)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func persist(_ updated: [BookSource], successTitle: String?) async -> Bool {
|
||||
do {
|
||||
try await repository.save(updated)
|
||||
sources = updated
|
||||
if let successTitle {
|
||||
notice = .init(kind: .success, title: successTitle, detail: "")
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
notice = .init(kind: .error, title: "保存失败", detail: error.localizedDescription)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func applyImport(_ data: Data, successTitle: String) async throws {
|
||||
let result = try await Task.detached(priority: .userInitiated) { [importer, existing = sources] in
|
||||
try importer.prepare(data: data, existing: existing, duplicatePolicy: .replace)
|
||||
}.value
|
||||
try await repository.save(result.sources)
|
||||
sources = result.sources
|
||||
notice = .init(
|
||||
kind: .success,
|
||||
title: successTitle,
|
||||
detail: "新增 \(result.inserted) 个,覆盖 \(result.replaced) 个,警告 \(result.warnings.count) 条"
|
||||
)
|
||||
}
|
||||
|
||||
private nonisolated func validatedSources(from data: Data) throws -> (
|
||||
sources: [BookSource],
|
||||
invalidItems: [PendingImport.InvalidItem]
|
||||
) {
|
||||
let result = try importer.inspect(data)
|
||||
return (result.sources, result.rejected.map { .init(source: $0.source, issues: $0.issues) })
|
||||
}
|
||||
|
||||
private func stage(
|
||||
_ incoming: [BookSource],
|
||||
invalidItems: [PendingImport.InvalidItem] = []
|
||||
) {
|
||||
let existingByID = Dictionary(uniqueKeysWithValues: sources.map { ($0.id, $0) })
|
||||
let unique = incoming.reduce(into: [String: BookSource]()) { $0[$1.id] = $1 }
|
||||
pendingImport = .init(items: Array(unique.values)
|
||||
.sorted { $0.bookSourceName.localizedStandardCompare($1.bookSourceName) == .orderedAscending }
|
||||
.map { .init(source: $0, existing: existingByID[$0.id]) },
|
||||
invalidItems: invalidItems)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
@preconcurrency import WebKit
|
||||
import SwiftUI
|
||||
|
||||
struct SourceLoginView: View {
|
||||
let source: BookSource
|
||||
@State private var reloadID = UUID()
|
||||
@State private var status = "打开登录页后,Cookie 会自动同步给搜索与阅读请求。"
|
||||
@State private var isChecking = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: "lock.shield.fill").foregroundStyle(ReadingTheme.accent)
|
||||
Text(status).font(.caption).foregroundStyle(.secondary).lineLimit(2)
|
||||
Spacer()
|
||||
if isChecking { ProgressView().controlSize(.small) }
|
||||
}
|
||||
.padding(.horizontal, 14).padding(.vertical, 10)
|
||||
.background(Color(uiColor: .secondarySystemBackground))
|
||||
|
||||
if let request = loginRequest {
|
||||
SourceLoginWebView(
|
||||
request: request,
|
||||
sourceURL: source.bookSourceUrl,
|
||||
sourceID: source.id,
|
||||
loginCheckJavaScript: source.loginCheckJs,
|
||||
reloadID: reloadID,
|
||||
status: $status,
|
||||
isChecking: $isChecking
|
||||
)
|
||||
} else {
|
||||
ReadingUnavailableView("登录地址无效", systemImage: "link.badge.plus", description: Text(source.loginUrl ?? "书源没有配置登录地址"))
|
||||
}
|
||||
}
|
||||
.navigationTitle("登录 · \(source.bookSourceName)")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("重新载入", systemImage: "arrow.clockwise") { reloadID = UUID() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var loginRequest: URLRequest? {
|
||||
guard let value = source.loginUrl,
|
||||
let prepared = try? LegadoURLBuilder().prepare(
|
||||
value,
|
||||
baseURL: URL(string: source.bookSourceUrl),
|
||||
defaultHeaders: sourceHeaders
|
||||
) else { return nil }
|
||||
return prepared.urlRequest(handleCookies: source.enabledCookieJar ?? true)
|
||||
}
|
||||
|
||||
private var sourceHeaders: [String: String] {
|
||||
guard let value = source.header,
|
||||
let data = value.data(using: .utf8),
|
||||
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [:] }
|
||||
return object.mapValues { String(describing: $0) }
|
||||
}
|
||||
}
|
||||
|
||||
private struct SourceLoginWebView: UIViewRepresentable {
|
||||
let request: URLRequest
|
||||
let sourceURL: String
|
||||
let sourceID: String
|
||||
let loginCheckJavaScript: String?
|
||||
let reloadID: UUID
|
||||
@Binding var status: String
|
||||
@Binding var isChecking: Bool
|
||||
|
||||
func makeCoordinator() -> Coordinator { Coordinator(parent: self) }
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
let configuration = WKWebViewConfiguration()
|
||||
configuration.websiteDataStore = .default()
|
||||
let webView = WKWebView(frame: .zero, configuration: configuration)
|
||||
webView.navigationDelegate = context.coordinator
|
||||
context.coordinator.installExistingCookies(in: webView) { webView.load(request) }
|
||||
return webView
|
||||
}
|
||||
|
||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||
context.coordinator.parent = self
|
||||
guard context.coordinator.lastReloadID != reloadID else { return }
|
||||
context.coordinator.lastReloadID = reloadID
|
||||
webView.reload()
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, WKNavigationDelegate {
|
||||
var parent: SourceLoginWebView
|
||||
var lastReloadID: UUID
|
||||
|
||||
init(parent: SourceLoginWebView) {
|
||||
self.parent = parent
|
||||
lastReloadID = parent.reloadID
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation?) {
|
||||
parent.status = "正在打开登录页…"
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation?) {
|
||||
syncCookies(from: webView)
|
||||
guard let script = parent.loginCheckJavaScript?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!script.isEmpty else {
|
||||
parent.status = "页面已载入;登录后 Cookie 会自动同步。"
|
||||
return
|
||||
}
|
||||
parent.isChecking = true
|
||||
webView.evaluateJavaScript(script) { [weak self] value, error in
|
||||
guard let self else { return }
|
||||
DispatchQueue.main.async {
|
||||
self.parent.isChecking = false
|
||||
if let error {
|
||||
self.parent.status = "登录检测脚本失败:\(error.localizedDescription)"
|
||||
} else {
|
||||
self.parent.status = Self.truthy(value) ? "书源登录检测已通过" : "页面已载入,尚未检测到登录状态"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation?, withError error: Error) {
|
||||
parent.status = "登录页加载失败:\(error.localizedDescription)"
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation?, withError error: Error) {
|
||||
parent.status = "登录页加载失败:\(error.localizedDescription)"
|
||||
}
|
||||
|
||||
func installExistingCookies(in webView: WKWebView, completion: @escaping () -> Void) {
|
||||
guard let url = URL(string: parent.sourceURL) else {
|
||||
completion()
|
||||
return
|
||||
}
|
||||
let cookies = HTTPCookieStorage.shared.cookies(for: url) ?? []
|
||||
guard !cookies.isEmpty else {
|
||||
completion()
|
||||
return
|
||||
}
|
||||
let group = DispatchGroup()
|
||||
for cookie in cookies {
|
||||
group.enter()
|
||||
webView.configuration.websiteDataStore.httpCookieStore.setCookie(cookie) { group.leave() }
|
||||
}
|
||||
group.notify(queue: .main, execute: completion)
|
||||
}
|
||||
|
||||
private func syncCookies(from webView: WKWebView) {
|
||||
webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { cookies in
|
||||
cookies.forEach(HTTPCookieStorage.shared.setCookie)
|
||||
guard let url = URL(string: self.parent.sourceURL) else { return }
|
||||
let matching = HTTPCookieStorage.shared.cookies(for: url) ?? []
|
||||
let header = HTTPCookie.requestHeaderFields(with: matching)["Cookie"] ?? ""
|
||||
Task {
|
||||
await SourceNetworkPolicy.shared.setCookieHeader(
|
||||
header,
|
||||
sourceID: self.parent.sourceID,
|
||||
url: url
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func truthy(_ value: Any?) -> Bool {
|
||||
if let value = value as? Bool { return value }
|
||||
if let value = value as? NSNumber { return value.boolValue }
|
||||
guard let text = value.map(String.init(describing:))?.lowercased() else { return false }
|
||||
return !text.isEmpty && !["false", "0", "null", "undefined"].contains(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class WKSourceWebViewExecutor: NSObject, SourceWebViewRequestExecuting, @unchecked Sendable {
|
||||
@MainActor
|
||||
func execute(_ prepared: PreparedRequest, source: BookSource, timeout: TimeInterval) async throws -> HTTPResponse {
|
||||
let override = ApplicationSettingsStore.shared.settings.timeoutSeconds
|
||||
return try await BackgroundWebViewRequest(prepared: prepared, source: source, timeout: override > 0 ? Double(override) : timeout).run()
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class BackgroundWebViewRequest: NSObject, WKNavigationDelegate {
|
||||
private let prepared: PreparedRequest
|
||||
private let source: BookSource
|
||||
private let timeout: TimeInterval
|
||||
private let webView: WKWebView
|
||||
private var continuation: CheckedContinuation<HTTPResponse, Error>?
|
||||
private var responseStatus = 200
|
||||
private var responseHeaders: [String: String] = [:]
|
||||
private var completed = false
|
||||
private var timeoutTask: Task<Void, Never>?
|
||||
|
||||
init(prepared: PreparedRequest, source: BookSource, timeout: TimeInterval) {
|
||||
self.prepared = prepared
|
||||
self.source = source
|
||||
self.timeout = timeout
|
||||
let configuration = WKWebViewConfiguration()
|
||||
configuration.websiteDataStore = .nonPersistent()
|
||||
webView = WKWebView(frame: .zero, configuration: configuration)
|
||||
super.init()
|
||||
webView.navigationDelegate = self
|
||||
}
|
||||
|
||||
func run() async throws -> HTTPResponse {
|
||||
try await withTaskCancellationHandler {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
self.continuation = continuation
|
||||
installRequestCookies { [weak self] in self?.startLoading() }
|
||||
}
|
||||
} onCancel: {
|
||||
Task { @MainActor [weak self] in self?.finish(.failure(CancellationError())) }
|
||||
}
|
||||
}
|
||||
|
||||
private func startLoading() {
|
||||
timeoutTask = Task { [weak self] in
|
||||
let seconds = min(max(self?.timeout ?? 30, 1), 300)
|
||||
try? await Task.sleep(nanoseconds: UInt64(seconds * 1_000_000_000))
|
||||
guard !Task.isCancelled else { return }
|
||||
await MainActor.run { self?.finish(.failure(URLError(.timedOut))) }
|
||||
}
|
||||
webView.load(prepared.urlRequest(timeout: timeout, handleCookies: true))
|
||||
}
|
||||
|
||||
private func installRequestCookies(completion: @escaping () -> Void) {
|
||||
guard let header = prepared.headers.first(where: { $0.key.caseInsensitiveCompare("Cookie") == .orderedSame })?.value,
|
||||
let host = prepared.url.host, !header.isEmpty else {
|
||||
completion()
|
||||
return
|
||||
}
|
||||
let cookies = header.split(separator: ";").compactMap { part -> HTTPCookie? in
|
||||
let pair = part.split(separator: "=", maxSplits: 1).map(String.init)
|
||||
guard pair.count == 2 else { return nil }
|
||||
return HTTPCookie(properties: [
|
||||
.name: pair[0].trimmingCharacters(in: .whitespaces),
|
||||
.value: pair[1],
|
||||
.domain: host,
|
||||
.path: "/",
|
||||
.secure: prepared.url.scheme == "https" ? "TRUE" : "FALSE"
|
||||
])
|
||||
}
|
||||
guard !cookies.isEmpty else { completion(); return }
|
||||
let group = DispatchGroup()
|
||||
for cookie in cookies {
|
||||
group.enter()
|
||||
webView.configuration.websiteDataStore.httpCookieStore.setCookie(cookie) { group.leave() }
|
||||
}
|
||||
group.notify(queue: .main, execute: completion)
|
||||
}
|
||||
|
||||
func webView(
|
||||
_ webView: WKWebView,
|
||||
decidePolicyFor navigationResponse: WKNavigationResponse,
|
||||
decisionHandler: @escaping @MainActor (WKNavigationResponsePolicy) -> Void
|
||||
) {
|
||||
if let response = navigationResponse.response as? HTTPURLResponse {
|
||||
responseStatus = response.statusCode
|
||||
responseHeaders = response.allHeaderFields.reduce(into: [:]) { result, entry in
|
||||
result[String(describing: entry.key)] = String(describing: entry.value)
|
||||
}
|
||||
}
|
||||
decisionHandler(.allow)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation?) {
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
if prepared.webViewDelayMilliseconds > 0 {
|
||||
try? await Task.sleep(nanoseconds: UInt64(max(0, prepared.webViewDelayMilliseconds)) * 1_000_000)
|
||||
}
|
||||
guard !Task.isCancelled, !completed else { return }
|
||||
if let script = prepared.webJavaScript, !script.isEmpty {
|
||||
do { _ = try await webView.evaluateJavaScript(script) }
|
||||
catch { finish(.failure(error)); return }
|
||||
}
|
||||
do {
|
||||
let html = try await webView.evaluateJavaScript("document.documentElement.outerHTML") as? String ?? ""
|
||||
let cookies = await webView.configuration.websiteDataStore.httpCookieStore.allCookies()
|
||||
cookies.forEach(HTTPCookieStorage.shared.setCookie)
|
||||
let cookieHeader = HTTPCookie.requestHeaderFields(with: cookies)["Cookie"] ?? ""
|
||||
if !cookieHeader.isEmpty {
|
||||
await SourceNetworkPolicy.shared.setCookieHeader(cookieHeader, sourceID: source.id, url: prepared.url)
|
||||
}
|
||||
var headers = responseHeaders
|
||||
if !cookieHeader.isEmpty { headers["Set-Cookie"] = cookieHeader }
|
||||
finish(.success(.init(
|
||||
data: Data(html.utf8),
|
||||
statusCode: responseStatus,
|
||||
headers: headers,
|
||||
finalURL: webView.url ?? prepared.url,
|
||||
requestedCharset: "utf-8"
|
||||
)))
|
||||
} catch {
|
||||
finish(.failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation?, withError error: Error) {
|
||||
finish(.failure(error))
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation?, withError error: Error) {
|
||||
finish(.failure(error))
|
||||
}
|
||||
|
||||
private func finish(_ result: Result<HTTPResponse, Error>) {
|
||||
guard !completed else { return }
|
||||
completed = true
|
||||
timeoutTask?.cancel()
|
||||
webView.stopLoading()
|
||||
continuation?.resume(with: result)
|
||||
continuation = nil
|
||||
}
|
||||
}
|
||||
|
||||
private extension WKHTTPCookieStore {
|
||||
func allCookies() async -> [HTTPCookie] {
|
||||
await withCheckedContinuation { continuation in
|
||||
getAllCookies { continuation.resume(returning: $0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,695 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct SourceManagementView: View {
|
||||
@EnvironmentObject private var library: SourceLibraryModel
|
||||
@State private var showsImporter = false
|
||||
@State private var showsNewSource = false
|
||||
@State private var showsQRImporter = false
|
||||
@State private var showsTextImporter = false
|
||||
@State private var showsGroupManager = false
|
||||
@State private var showsBatchGroups = false
|
||||
@State private var selectedGroup: String?
|
||||
@State private var searchText = ""
|
||||
@State private var isSelecting = false
|
||||
@State private var selectedSourceIDs = Set<String>()
|
||||
@AppStorage("bookSourceSort") private var sortMode = "manual"
|
||||
@AppStorage("bookSourceSortDescending") private var sortDescending = false
|
||||
|
||||
private var filteredSources: [BookSource] {
|
||||
let filtered = library.sources.filter { source in
|
||||
let matchesGroup: Bool
|
||||
if let selectedGroup {
|
||||
matchesGroup = selectedGroup.isEmpty ? source.groups.isEmpty : source.hasGroup(selectedGroup)
|
||||
} else {
|
||||
matchesGroup = true
|
||||
}
|
||||
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return matchesGroup && (query.isEmpty
|
||||
|| source.bookSourceName.localizedCaseInsensitiveContains(query)
|
||||
|| source.bookSourceUrl.localizedCaseInsensitiveContains(query)
|
||||
|| source.groups.contains(where: { $0.localizedCaseInsensitiveContains(query) }))
|
||||
}
|
||||
guard sortMode != "manual" else { return filtered }
|
||||
return filtered.sorted { lhs, rhs in
|
||||
sortDescending ? isOrdered(rhs, before: lhs) : isOrdered(lhs, before: rhs)
|
||||
}
|
||||
}
|
||||
|
||||
private func isOrdered(_ lhs: BookSource, before rhs: BookSource) -> Bool {
|
||||
switch sortMode {
|
||||
case "weight":
|
||||
return lhs.weight == rhs.weight ? lhs.bookSourceName.localizedStandardCompare(rhs.bookSourceName) == .orderedAscending : lhs.weight < rhs.weight
|
||||
case "name": return lhs.bookSourceName.localizedStandardCompare(rhs.bookSourceName) == .orderedAscending
|
||||
case "url": return lhs.bookSourceUrl.localizedStandardCompare(rhs.bookSourceUrl) == .orderedAscending
|
||||
case "update": return lhs.lastUpdateTime == rhs.lastUpdateTime ? lhs.bookSourceName < rhs.bookSourceName : lhs.lastUpdateTime > rhs.lastUpdateTime
|
||||
case "respond": return lhs.respondTime == rhs.respondTime ? lhs.bookSourceName < rhs.bookSourceName : lhs.respondTime < rhs.respondTime
|
||||
case "enabled": return lhs.enabled == rhs.enabled ? lhs.bookSourceName < rhs.bookSourceName : lhs.enabled && !rhs.enabled
|
||||
default: return lhs.customOrder < rhs.customOrder
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if library.sources.isEmpty, !library.isWorking {
|
||||
ReadingUnavailableView {
|
||||
Label("还没有书源", systemImage: "network")
|
||||
} description: {
|
||||
Text("新建书源,或从文件、链接、JSON、二维码导入。")
|
||||
} actions: {
|
||||
Button("导入书源") { showsImporter = true }
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
} else {
|
||||
List {
|
||||
Section {
|
||||
Picker("分组", selection: $selectedGroup) {
|
||||
Text("全部").tag(String?.none)
|
||||
Text("未分组").tag(String?.some(""))
|
||||
ForEach(library.groups, id: \.self) { group in
|
||||
Text(group).tag(String?.some(group))
|
||||
}
|
||||
}
|
||||
}
|
||||
Section {
|
||||
ForEach(filteredSources) { source in
|
||||
if isSelecting {
|
||||
Button {
|
||||
if !selectedSourceIDs.insert(source.id).inserted { selectedSourceIDs.remove(source.id) }
|
||||
} label: {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: selectedSourceIDs.contains(source.id) ? "checkmark.circle.fill" : "circle")
|
||||
.font(.title3)
|
||||
.foregroundStyle(selectedSourceIDs.contains(source.id) ? ReadingTheme.accent : .secondary)
|
||||
SourceRow(source: source, showsToggle: false) { _ in }
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
} else {
|
||||
NavigationLink {
|
||||
SourceDetailView(source: source)
|
||||
} label: {
|
||||
SourceRow(source: source) { enabled in
|
||||
Task { await library.setEnabled(enabled, for: source.id) }
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
NavigationLink {
|
||||
SearchView(fixedSource: source)
|
||||
} label: {
|
||||
Label("搜索", systemImage: "magnifyingglass")
|
||||
}
|
||||
Button(source.enabledExplore ? "停用发现" : "启用发现", systemImage: "safari") {
|
||||
Task { await library.setExploreEnabled(!source.enabledExplore, for: source.id) }
|
||||
}
|
||||
Button("删除", systemImage: "trash", role: .destructive) {
|
||||
Task { await library.remove(sourceIDs: [source.id]) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDelete { offsets in
|
||||
let ids = Set(offsets.compactMap { filteredSources.indices.contains($0) ? filteredSources[$0].id : nil })
|
||||
Task { await library.remove(sourceIDs: ids) }
|
||||
}
|
||||
} header: {
|
||||
Text("\(filteredSources.count) 个书源")
|
||||
} footer: {
|
||||
Text("共 \(library.sources.count) 个书源,\(library.sources.filter(\.enabled).count) 个已启用")
|
||||
}
|
||||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.readingScrollContentBackgroundHidden()
|
||||
.background(ReadingTheme.groupedCanvas)
|
||||
.overlay {
|
||||
if library.isWorking { ProgressView() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("书源")
|
||||
.background(ReadingTheme.groupedCanvas)
|
||||
.searchable(text: $searchText, prompt: "名称、地址或分组")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarLeading) {
|
||||
Button(isSelecting ? "完成" : "选择") {
|
||||
isSelecting.toggle()
|
||||
if !isSelecting { selectedSourceIDs.removeAll() }
|
||||
}
|
||||
.disabled(library.sources.isEmpty)
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
Menu("更多", systemImage: "ellipsis.circle") {
|
||||
Button("新建书源", systemImage: "plus") { showsNewSource = true }
|
||||
Button("二维码导入", systemImage: "qrcode.viewfinder") { showsQRImporter = true }
|
||||
Button("从文件导入", systemImage: "doc.badge.plus") { showsImporter = true }
|
||||
Button("URL 或粘贴 JSON", systemImage: "link.badge.plus") { showsTextImporter = true }
|
||||
Divider()
|
||||
NavigationLink {
|
||||
SourceBatchTestView()
|
||||
} label: {
|
||||
Label("测试书源", systemImage: "checkmark.circle.badge.questionmark")
|
||||
}
|
||||
Button("管理分组", systemImage: "folder.badge.gearshape") { showsGroupManager = true }
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu("排序", systemImage: "arrow.up.arrow.down") {
|
||||
Toggle("倒序", isOn: $sortDescending)
|
||||
Picker("排序方式", selection: $sortMode) {
|
||||
Text("手动排序").tag("manual")
|
||||
Text("自动排序").tag("weight")
|
||||
Text("按名称").tag("name")
|
||||
Text("按网址").tag("url")
|
||||
Text("按更新时间").tag("update")
|
||||
Text("按响应时间").tag("respond")
|
||||
Text("按启用状态").tag("enabled")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
if isSelecting {
|
||||
HStack(spacing: 18) {
|
||||
Button(selectedSourceIDs.count == filteredSources.count ? "取消全选" : "全选") {
|
||||
if selectedSourceIDs.count == filteredSources.count { selectedSourceIDs.removeAll() }
|
||||
else { selectedSourceIDs = Set(filteredSources.map(\.id)) }
|
||||
}
|
||||
Spacer()
|
||||
Button("分组", systemImage: "folder") { showsBatchGroups = true }
|
||||
.disabled(selectedSourceIDs.isEmpty)
|
||||
Button("启用", systemImage: "checkmark.circle") {
|
||||
Task { await library.setEnabled(true, for: selectedSourceIDs) }
|
||||
}
|
||||
.disabled(selectedSourceIDs.isEmpty)
|
||||
Button("停用", systemImage: "pause.circle") {
|
||||
Task { await library.setEnabled(false, for: selectedSourceIDs) }
|
||||
}
|
||||
.disabled(selectedSourceIDs.isEmpty)
|
||||
}
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.padding(.horizontal, 18).padding(.vertical, 12)
|
||||
.background(.ultraThinMaterial)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showsNewSource) { SourceEditorView().environmentObject(library) }
|
||||
.sheet(isPresented: $showsQRImporter) {
|
||||
QRCodeImportView { value in Task { await library.previewInput(value) } }
|
||||
}
|
||||
.sheet(isPresented: $showsTextImporter) {
|
||||
SourceTextImportView()
|
||||
.environmentObject(library)
|
||||
}
|
||||
.sheet(isPresented: $showsGroupManager) {
|
||||
SourceGroupManagerView().environmentObject(library)
|
||||
}
|
||||
.sheet(isPresented: $showsBatchGroups) {
|
||||
SourceBatchGroupView(sourceIDs: selectedSourceIDs).environmentObject(library)
|
||||
}
|
||||
.fileImporter(
|
||||
isPresented: $showsImporter,
|
||||
allowedContentTypes: [.item],
|
||||
allowsMultipleSelection: false
|
||||
) { result in
|
||||
switch result {
|
||||
case .success(let urls):
|
||||
let files = urls.filter { !$0.hasDirectoryPath }
|
||||
if files.isEmpty {
|
||||
library.notice = .init(kind: .error, title: "无法选择文件", detail: "请选择书源 JSON 文件,而不是文件夹。")
|
||||
} else {
|
||||
Task { await library.previewFiles(files) }
|
||||
}
|
||||
case .failure(let error):
|
||||
library.notice = .init(kind: .error, title: "无法选择文件", detail: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SourceTextImportView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject private var library: SourceLibraryModel
|
||||
@State private var mode = 0
|
||||
@State private var value = ""
|
||||
|
||||
var body: some View {
|
||||
ReadingNavigationStack {
|
||||
Form {
|
||||
Picker("导入方式", selection: $mode) {
|
||||
Text("URL").tag(0)
|
||||
Text("粘贴 JSON").tag(1)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
|
||||
Section(mode == 0 ? "书源 URL" : "书源 JSON") {
|
||||
if mode == 0 {
|
||||
TextField("https://example.com/sources.json", text: $value)
|
||||
.textInputAutocapitalization(.never)
|
||||
.keyboardType(.URL)
|
||||
} else {
|
||||
SyntaxCodeEditor(text: $value, language: .json)
|
||||
.frame(minHeight: 220)
|
||||
}
|
||||
}
|
||||
Section {
|
||||
Text("导入前可预览并选择书源。地址相同的书源将被替换。")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.navigationTitle("导入书源")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("导入") {
|
||||
let input = value
|
||||
let selectedMode = mode
|
||||
dismiss()
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: 350_000_000)
|
||||
if selectedMode == 0 { await library.previewRemoteURL(input) }
|
||||
else { await library.previewText(input) }
|
||||
}
|
||||
}
|
||||
.disabled(value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SourceRow: View {
|
||||
let source: BookSource
|
||||
var showsToggle = true
|
||||
let onEnabledChanged: @MainActor @Sendable (Bool) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: source.enabled ? "network" : "network.slash")
|
||||
.font(.system(size: 20))
|
||||
.frame(width: 28)
|
||||
.foregroundStyle(.secondary)
|
||||
.accessibilityHidden(true)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(source.bookSourceName)
|
||||
.font(.body)
|
||||
.foregroundStyle(source.enabled ? Color.primary : .secondary)
|
||||
Text(source.groups.isEmpty ? (URL(string: source.bookSourceUrl)?.host ?? source.bookSourceUrl) : source.groups.joined(separator: " · "))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
if showsToggle {
|
||||
Toggle("启用", isOn: Binding(
|
||||
get: { source.enabled },
|
||||
set: { enabled in
|
||||
Task { @MainActor in onEnabledChanged(enabled) }
|
||||
}
|
||||
))
|
||||
.labelsHidden()
|
||||
.tint(.green)
|
||||
.accessibilityLabel("启用\(source.bookSourceName)")
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 5)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SourceGroupManagerView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject private var library: SourceLibraryModel
|
||||
@State private var newGroup = ""
|
||||
@State private var renaming: String?
|
||||
@State private var renamedValue = ""
|
||||
|
||||
var body: some View {
|
||||
ReadingNavigationStack {
|
||||
List {
|
||||
Section("新建分组") {
|
||||
HStack {
|
||||
TextField("例如:文学", text: $newGroup)
|
||||
Button("添加") {
|
||||
let value = newGroup
|
||||
newGroup = ""
|
||||
let ungrouped = Set(library.sources.filter { $0.groups.isEmpty }.map(\.id))
|
||||
Task { await library.addGroup(value, to: ungrouped) }
|
||||
}
|
||||
.disabled(BookSource.parseGroups(newGroup).isEmpty)
|
||||
}
|
||||
Text("新分组会分配给当前未分组书源,与 Legado 的分组管理行为一致。")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
Section("已有分组") {
|
||||
ForEach(library.groups, id: \.self) { group in
|
||||
HStack {
|
||||
Label(group, systemImage: "folder.fill")
|
||||
Spacer()
|
||||
Text("\(library.sources.filter { $0.hasGroup(group) }.count)")
|
||||
.foregroundStyle(.secondary)
|
||||
Menu {
|
||||
Button("重命名", systemImage: "pencil") {
|
||||
renaming = group
|
||||
renamedValue = group
|
||||
}
|
||||
Button("删除分组", systemImage: "trash", role: .destructive) {
|
||||
Task { await library.deleteGroup(group) }
|
||||
}
|
||||
} label: { Image(systemName: "ellipsis.circle") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("书源分组")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("完成") { dismiss() } } }
|
||||
.alert("重命名分组", isPresented: Binding(
|
||||
get: { renaming != nil },
|
||||
set: { if !$0 { renaming = nil } }
|
||||
)) {
|
||||
TextField("分组名称", text: $renamedValue)
|
||||
Button("取消", role: .cancel) { renaming = nil }
|
||||
Button("保存") {
|
||||
if let old = renaming { Task { await library.renameGroup(old, to: renamedValue) } }
|
||||
renaming = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SourceBatchGroupView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject private var library: SourceLibraryModel
|
||||
let sourceIDs: Set<String>
|
||||
@State private var customGroup = ""
|
||||
|
||||
var body: some View {
|
||||
ReadingNavigationStack {
|
||||
List {
|
||||
Section("已选择 \(sourceIDs.count) 个书源") {
|
||||
HStack {
|
||||
TextField("输入新分组", text: $customGroup)
|
||||
Button("加入") {
|
||||
let value = customGroup
|
||||
customGroup = ""
|
||||
Task { await library.addGroup(value, to: sourceIDs) }
|
||||
}
|
||||
.disabled(BookSource.parseGroups(customGroup).isEmpty)
|
||||
}
|
||||
}
|
||||
Section("加入现有分组") {
|
||||
ForEach(library.groups, id: \.self) { group in
|
||||
Button { Task { await library.addGroup(group, to: sourceIDs) } } label: {
|
||||
Label(group, systemImage: "folder.badge.plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
Section("从分组移除") {
|
||||
ForEach(library.groups, id: \.self) { group in
|
||||
Button(role: .destructive) {
|
||||
Task { await library.removeGroup(group, from: sourceIDs) }
|
||||
} label: { Label(group, systemImage: "folder.badge.minus") }
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("批量归组")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { ToolbarItem(placement: .confirmationAction) { Button("完成") { dismiss() } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SourceDetailView: View {
|
||||
@EnvironmentObject private var library: SourceLibraryModel
|
||||
let source: BookSource
|
||||
@State private var showsEditor = false
|
||||
@State private var editedSourceID: String?
|
||||
private var current: BookSource { library.sources.first { $0.id == (editedSourceID ?? source.id) } ?? source }
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section("基本信息") {
|
||||
ReadingLabeledContent("名称", value: current.bookSourceName)
|
||||
ReadingLabeledContent("地址", value: current.bookSourceUrl)
|
||||
ReadingLabeledContent("分组", value: current.bookSourceGroup ?? "未分组")
|
||||
ReadingLabeledContent("类型", value: String(current.bookSourceType))
|
||||
}
|
||||
Section("能力") {
|
||||
CapabilityRow(title: "搜索", available: current.searchUrl?.isEmpty == false)
|
||||
CapabilityRow(title: "发现", available: current.exploreUrl?.isEmpty == false)
|
||||
CapabilityRow(title: "详情", available: current.ruleBookInfo != nil)
|
||||
CapabilityRow(title: "目录", available: current.ruleToc != nil)
|
||||
CapabilityRow(title: "正文", available: current.ruleContent != nil)
|
||||
}
|
||||
Section("状态") {
|
||||
Toggle("启用书源", isOn: Binding(
|
||||
get: { library.sources.first(where: { $0.id == current.id })?.enabled ?? current.enabled },
|
||||
set: { value in Task { await library.setEnabled(value, for: current.id) } }
|
||||
))
|
||||
Toggle("启用发现", isOn: Binding(
|
||||
get: { library.sources.first(where: { $0.id == current.id })?.enabledExplore ?? current.enabledExplore },
|
||||
set: { value in Task { await library.setExploreEnabled(value, for: current.id) } }
|
||||
))
|
||||
}
|
||||
Section("兼容检查") {
|
||||
NavigationLink {
|
||||
SearchView(fixedSource: current)
|
||||
} label: {
|
||||
Label("搜索此书源", systemImage: "magnifyingglass")
|
||||
}
|
||||
NavigationLink {
|
||||
SourceDebugView(source: current)
|
||||
} label: {
|
||||
Label("运行端到端调试", systemImage: "stethoscope")
|
||||
}
|
||||
Text("使用你输入的关键词,逐步验证搜索、详情、目录和正文规则。")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
if current.loginUrl?.isEmpty == false {
|
||||
Section("账户") {
|
||||
NavigationLink {
|
||||
SourceLoginView(source: current)
|
||||
} label: {
|
||||
Label("登录此书源", systemImage: "person.badge.key.fill")
|
||||
}
|
||||
Text("登录 Cookie 与阅读请求共享;若书源提供检测脚本,会在页面载入后自动验证。")
|
||||
.font(.footnote).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if let comment = current.bookSourceComment, !comment.isEmpty {
|
||||
Section("说明") { Text(comment) }
|
||||
}
|
||||
}
|
||||
.navigationTitle(current.bookSourceName)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Button("编辑") { showsEditor = true }
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showsEditor) {
|
||||
SourceEditorView(source: current) { editedSourceID = $0.id }.environmentObject(library)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SourceImportReviewView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@EnvironmentObject private var library: SourceLibraryModel
|
||||
let pending: SourceLibraryModel.PendingImport
|
||||
@State private var selectedIDs: Set<String>
|
||||
@State private var importError: String?
|
||||
@State private var isImporting = false
|
||||
@State private var keepOriginalName = false
|
||||
@State private var keepOriginalGroup = false
|
||||
@State private var keepOriginalEnabled = false
|
||||
@State private var showComments = false
|
||||
@State private var customGroup = ""
|
||||
@State private var addCustomGroup = true
|
||||
|
||||
init(pending: SourceLibraryModel.PendingImport) {
|
||||
self.pending = pending
|
||||
_selectedIDs = State(initialValue: Set(pending.items.filter { $0.existing == nil || $0.state == "更新" }.map(\.id)))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ReadingNavigationStack {
|
||||
List {
|
||||
Section {
|
||||
HStack(spacing: 10) {
|
||||
importCountCard(
|
||||
title: "有效",
|
||||
count: pending.validCount,
|
||||
color: .green,
|
||||
systemImage: "checkmark.circle.fill"
|
||||
)
|
||||
importCountCard(
|
||||
title: "失效",
|
||||
count: pending.invalidCount,
|
||||
color: .red,
|
||||
systemImage: "xmark.circle.fill"
|
||||
)
|
||||
importCountCard(
|
||||
title: "总计",
|
||||
count: pending.totalCount,
|
||||
color: .secondary,
|
||||
systemImage: "doc.text.fill"
|
||||
)
|
||||
}
|
||||
.listRowInsets(EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16))
|
||||
|
||||
Label("仅有效书源可被选择和导入", systemImage: "checkmark.shield.fill")
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if !pending.items.isEmpty {
|
||||
Section("导入设置") {
|
||||
HStack {
|
||||
TextField("自定义分组", text: $customGroup)
|
||||
Toggle("追加", isOn: $addCustomGroup).labelsHidden()
|
||||
}
|
||||
Toggle("保留原名称", isOn: $keepOriginalName)
|
||||
Toggle("保留原分组", isOn: $keepOriginalGroup)
|
||||
Toggle("保留原启用状态", isOn: $keepOriginalEnabled)
|
||||
Toggle("显示书源备注", isOn: $showComments)
|
||||
}
|
||||
}
|
||||
|
||||
Section("有效书源(\(pending.validCount))") {
|
||||
ForEach(pending.items) { item in
|
||||
Button {
|
||||
if !selectedIDs.insert(item.id).inserted { selectedIDs.remove(item.id) }
|
||||
} label: {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: selectedIDs.contains(item.id) ? "checkmark.square.fill" : "square")
|
||||
.foregroundStyle(selectedIDs.contains(item.id) ? ReadingTheme.accent : .secondary)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(item.source.bookSourceName).foregroundStyle(.primary)
|
||||
HStack {
|
||||
Text(item.state)
|
||||
.font(.caption.bold())
|
||||
.foregroundStyle(item.state == "新增" ? .green : item.state == "更新" ? .orange : .secondary)
|
||||
Text(item.source.bookSourceUrl)
|
||||
.font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||||
}
|
||||
if showComments, let comment = item.source.bookSourceComment, !comment.isEmpty {
|
||||
Text(comment).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !pending.invalidItems.isEmpty {
|
||||
Section("失效书源(\(pending.invalidCount))") {
|
||||
ForEach(pending.invalidItems) { item in
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.red)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(item.source.bookSourceName.isEmpty ? "未命名书源" : item.source.bookSourceName)
|
||||
.foregroundStyle(.primary)
|
||||
if !item.source.bookSourceUrl.isEmpty {
|
||||
Text(item.source.bookSourceUrl)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Text(item.issues.map(\.message).joined(separator: ";"))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("书源导入预检")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("取消") {
|
||||
library.cancelPendingImport()
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
Menu("选择") {
|
||||
Button("全选") { selectedIDs = Set(pending.items.map(\.id)) }
|
||||
Button("取消全选") { selectedIDs.removeAll() }
|
||||
Button("仅选择新增") {
|
||||
selectedIDs = Set(pending.items.filter { $0.existing == nil }.map(\.id))
|
||||
}
|
||||
Button("仅选择更新") {
|
||||
selectedIDs = Set(pending.items.filter { $0.existing != nil }.map(\.id))
|
||||
}
|
||||
}
|
||||
.disabled(pending.items.isEmpty)
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("导入") {
|
||||
let ids = selectedIDs
|
||||
isImporting = true
|
||||
Task {
|
||||
await library.commitPendingImport(
|
||||
selectedIDs: ids,
|
||||
keepOriginalName: keepOriginalName,
|
||||
keepOriginalGroup: keepOriginalGroup,
|
||||
keepOriginalEnabled: keepOriginalEnabled,
|
||||
customGroup: customGroup.isEmpty ? nil : customGroup,
|
||||
addCustomGroup: addCustomGroup
|
||||
)
|
||||
isImporting = false
|
||||
if library.pendingImport == nil { dismiss() }
|
||||
else {
|
||||
let message = library.notice?.detail ?? "保存失败,请重试"
|
||||
library.notice = nil
|
||||
importError = message
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(selectedIDs.isEmpty || isImporting)
|
||||
}
|
||||
}
|
||||
}
|
||||
.interactiveDismissDisabled()
|
||||
.alert("导入保存失败", isPresented: Binding(get: { importError != nil }, set: { if !$0 { importError = nil } })) {
|
||||
Button("好", role: .cancel) {}
|
||||
} message: { Text(importError ?? "") }
|
||||
}
|
||||
|
||||
private func importCountCard(
|
||||
title: String,
|
||||
count: Int,
|
||||
color: Color,
|
||||
systemImage: String
|
||||
) -> some View {
|
||||
VStack(spacing: 5) {
|
||||
Label(title, systemImage: systemImage)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(color)
|
||||
Text("\(count)")
|
||||
.font(.title2.bold())
|
||||
.foregroundStyle(.primary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 10)
|
||||
.background(color.opacity(0.1), in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
private struct CapabilityRow: View {
|
||||
let title: String
|
||||
let available: Bool
|
||||
|
||||
var body: some View {
|
||||
ReadingLabeledContent(title) {
|
||||
Image(systemName: available ? "checkmark.circle.fill" : "minus.circle")
|
||||
.foregroundStyle(available ? .green : .secondary)
|
||||
.accessibilityLabel(available ? "已配置" : "未配置")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user