Files
legado-ios/App/Features/Sources/SourceManagementView.swift

696 lines
32 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ? "已配置" : "未配置")
}
}
}