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)) } }