mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-20 19:21:22 +08:00
225 lines
10 KiB
Swift
225 lines
10 KiB
Swift
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
|
|
@MainActor
|
|
final class TxtTocRuleManagementModel: ObservableObject {
|
|
@Published private(set) var rules: [TxtTocRule] = []
|
|
@Published var message: String?
|
|
private let repository: SQLiteConfigurationRepository?
|
|
|
|
init() {
|
|
repository = (try? LegadoDatabase(url: LegadoDatabase.defaultURL())).map {
|
|
SQLiteConfigurationRepository(database: $0)
|
|
}
|
|
}
|
|
|
|
func load() async {
|
|
guard let repository else { message = "无法打开目录规则数据库"; return }
|
|
do {
|
|
rules = try await repository.loadTxtTocRules().sorted { $0.serialNumber < $1.serialNumber }
|
|
if rules.isEmpty { await importDefaults(report: false) }
|
|
}
|
|
catch { message = error.localizedDescription }
|
|
}
|
|
|
|
func importDefaults(report: Bool = true) async {
|
|
let defaults = TxtTocRuleDefaults.load()
|
|
guard !defaults.isEmpty else { message = "应用内置目录规则缺失"; return }
|
|
var updated = rules.filter { $0.id >= 0 }
|
|
updated.append(contentsOf: defaults)
|
|
if await persist(updated), report { message = "已恢复内置目录规则" }
|
|
}
|
|
|
|
func save(_ rule: TxtTocRule) async -> Bool {
|
|
guard !rule.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
|
|
!rule.rule.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || rule.id == -100 else {
|
|
message = "名称和正则不能为空"
|
|
return false
|
|
}
|
|
guard rule.rule.isEmpty || (try? NSRegularExpression(pattern: rule.rule, options: .anchorsMatchLines)) != nil else {
|
|
message = "目录正则语法错误"
|
|
return false
|
|
}
|
|
var updated = rules
|
|
if let index = updated.firstIndex(where: { $0.id == rule.id }) { updated[index] = rule }
|
|
else { var value = rule; value.serialNumber = (updated.map(\.serialNumber).max() ?? 0) + 1; updated.append(value) }
|
|
return await persist(updated)
|
|
}
|
|
|
|
func toggle(_ rule: TxtTocRule) async {
|
|
var value = rule; value.enable.toggle(); _ = await save(value)
|
|
}
|
|
|
|
func remove(at offsets: IndexSet) async {
|
|
_ = await persist(rules.enumerated().filter { !offsets.contains($0.offset) }.map(\.element))
|
|
}
|
|
|
|
func move(from offsets: IndexSet, to destination: Int) async {
|
|
var updated = rules
|
|
updated.move(fromOffsets: offsets, toOffset: destination)
|
|
for index in updated.indices { updated[index].serialNumber = index + 1 }
|
|
_ = await persist(updated)
|
|
}
|
|
|
|
func importData(_ data: Data) async {
|
|
do {
|
|
let decoded: [TxtTocRule]
|
|
if let values = try? JSONDecoder().decode([TxtTocRule].self, from: data) { decoded = values }
|
|
else { decoded = [try JSONDecoder().decode(TxtTocRule.self, from: data)] }
|
|
var merged = rules
|
|
for value in decoded {
|
|
if let index = merged.firstIndex(where: { $0.id == value.id }) { merged[index] = value }
|
|
else { merged.append(value) }
|
|
}
|
|
for index in merged.indices { merged[index].serialNumber = index + 1 }
|
|
if await persist(merged) { message = "已导入 \(decoded.count) 条目录规则" }
|
|
} catch { message = error.localizedDescription }
|
|
}
|
|
|
|
private func persist(_ values: [TxtTocRule]) async -> Bool {
|
|
guard let repository else { message = "无法打开目录规则数据库"; return false }
|
|
do {
|
|
try await repository.saveTxtTocRules(values)
|
|
rules = try await repository.loadTxtTocRules().sorted { $0.serialNumber < $1.serialNumber }
|
|
return true
|
|
} catch { message = error.localizedDescription; return false }
|
|
}
|
|
}
|
|
|
|
struct TxtTocRuleManagementView: View {
|
|
@StateObject private var model = TxtTocRuleManagementModel()
|
|
@State private var editing: TxtTocRule?
|
|
@State private var importing = false
|
|
@State private var exporting = false
|
|
|
|
var body: some View {
|
|
List {
|
|
if model.rules.isEmpty {
|
|
ReadingUnavailableView("没有TXT目录规则", systemImage: "list.bullet.rectangle")
|
|
}
|
|
ForEach(model.rules) { rule in
|
|
Button { editing = rule } label: {
|
|
HStack(spacing: 12) {
|
|
Image(systemName: rule.enable ? "checkmark.circle.fill" : "circle")
|
|
.foregroundStyle(rule.enable ? ReadingTheme.accent : .secondary)
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(rule.name).foregroundStyle(.primary)
|
|
Text(rule.rule).font(.caption.monospaced()).foregroundStyle(.secondary).lineLimit(2)
|
|
}
|
|
}
|
|
}
|
|
.swipeActions(edge: .leading) {
|
|
Button(rule.enable ? "停用" : "启用") { Task { await model.toggle(rule) } }
|
|
.tint(rule.enable ? .orange : .green)
|
|
}
|
|
}
|
|
.onDelete { offsets in Task { await model.remove(at: offsets) } }
|
|
.onMove { offsets, destination in Task { await model.move(from: offsets, to: destination) } }
|
|
}
|
|
.navigationTitle("TXT目录规则")
|
|
.toolbar {
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Button("新建", systemImage: "plus") { editing = TxtTocRule() }
|
|
}
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Menu {
|
|
Button("导入JSON", systemImage: "square.and.arrow.down") { importing = true }
|
|
Button("导出JSON", systemImage: "square.and.arrow.up") { exporting = true }
|
|
.disabled(model.rules.isEmpty)
|
|
Button("恢复内置规则", systemImage: "arrow.counterclockwise") {
|
|
Task { await model.importDefaults() }
|
|
}
|
|
EditButton()
|
|
} label: { Image(systemName: "ellipsis.circle") }
|
|
}
|
|
}
|
|
.task { await model.load() }
|
|
.sheet(item: $editing) { value in
|
|
TxtTocRuleEditView(value: value) { await model.save($0) }
|
|
}
|
|
.fileImporter(isPresented: $importing, allowedContentTypes: [.json, .plainText]) { result in
|
|
guard case .success(let url) = result else { return }
|
|
let granted = url.startAccessingSecurityScopedResource()
|
|
defer { if granted { url.stopAccessingSecurityScopedResource() } }
|
|
if let data = try? Data(contentsOf: url) { Task { await model.importData(data) } }
|
|
}
|
|
.fileExporter(
|
|
isPresented: $exporting,
|
|
document: TxtTocRuleDocument(rules: model.rules),
|
|
contentType: .json,
|
|
defaultFilename: "txtTocRules.json"
|
|
) { result in
|
|
if case .failure(let error) = result { model.message = error.localizedDescription }
|
|
}
|
|
.alert("TXT目录规则", isPresented: Binding(
|
|
get: { model.message != nil },
|
|
set: { if !$0 { model.message = nil } }
|
|
)) { Button("好") { model.message = nil } } message: { Text(model.message ?? "") }
|
|
}
|
|
}
|
|
|
|
private struct TxtTocRuleEditView: View {
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var value: TxtTocRule
|
|
@State private var sample = ""
|
|
@State private var saving = false
|
|
let save: (TxtTocRule) async -> Bool
|
|
|
|
init(value: TxtTocRule, save: @escaping (TxtTocRule) async -> Bool) {
|
|
_value = State(initialValue: value)
|
|
_sample = State(initialValue: value.example ?? "")
|
|
self.save = save
|
|
}
|
|
|
|
private var testResult: String {
|
|
guard let expression = try? NSRegularExpression(pattern: value.rule, options: .anchorsMatchLines) else { return "正则语法错误" }
|
|
let range = NSRange(sample.startIndex..., in: sample)
|
|
let values = expression.matches(in: sample, range: range).prefix(20).compactMap { match -> String? in
|
|
guard let range = Range(match.range, in: sample) else { return nil }
|
|
return String(sample[range])
|
|
}
|
|
return values.isEmpty ? "没有匹配" : "匹配 \(values.count) 条:\n" + values.joined(separator: "\n")
|
|
}
|
|
|
|
var body: some View {
|
|
ReadingNavigationStack {
|
|
Form {
|
|
TextField("名称", text: $value.name)
|
|
ReadingTextField("目录正则", text: $value.rule, axis: .vertical)
|
|
.font(.body.monospaced()).textInputAutocapitalization(.never).disableAutocorrection(true)
|
|
ReadingTextField("标题处理JavaScript(可选)", text: Binding(
|
|
get: { value.replacement }, set: { value.replacement = $0 }
|
|
), axis: .vertical).font(.body.monospaced())
|
|
Toggle("启用", isOn: $value.enable)
|
|
Section("测试文本") {
|
|
TextEditor(text: $sample).frame(minHeight: 130)
|
|
Text(testResult).font(.caption.monospaced()).foregroundStyle(.secondary).textSelection(.enabled)
|
|
}
|
|
}
|
|
.navigationTitle("TXT目录规则")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) { Button("取消") { dismiss() } }
|
|
ToolbarItem(placement: .confirmationAction) {
|
|
Button(saving ? "保存中…" : "保存") {
|
|
value.example = sample.isEmpty ? nil : sample
|
|
saving = true
|
|
Task { if await save(value) { dismiss() }; saving = false }
|
|
}.disabled(saving)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct TxtTocRuleDocument: FileDocument {
|
|
static var readableContentTypes: [UTType] { [.json] }
|
|
let rules: [TxtTocRule]
|
|
init(rules: [TxtTocRule]) { self.rules = rules }
|
|
init(configuration: ReadConfiguration) throws { rules = [] }
|
|
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
|
|
let encoder = JSONEncoder(); encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
|
return FileWrapper(regularFileWithContents: try encoder.encode(rules))
|
|
}
|
|
}
|