375 lines
17 KiB
Swift
375 lines
17 KiB
Swift
import Foundation
|
||
import SwiftUI
|
||
|
||
private enum BatchRenameItemType: String, CaseIterable, Identifiable {
|
||
case all = "全部"
|
||
case files = "仅文件"
|
||
case folders = "仅文件夹"
|
||
var id: String { rawValue }
|
||
}
|
||
|
||
private enum BatchRenameRuleKind: String, CaseIterable, Identifiable {
|
||
case remove = "删除字符"
|
||
case replace = "查找替换"
|
||
case regex = "正则替换"
|
||
case prefix = "添加前缀"
|
||
case suffix = "添加后缀"
|
||
var id: String { rawValue }
|
||
}
|
||
|
||
private struct BatchRenameRule: Identifiable, Equatable {
|
||
let id: UUID
|
||
var isEnabled: Bool
|
||
var kind: BatchRenameRuleKind
|
||
var pattern: String
|
||
var replacement: String
|
||
var ignoresCase: Bool
|
||
|
||
init(id: UUID = UUID(), isEnabled: Bool = true, kind: BatchRenameRuleKind = .remove, pattern: String = "", replacement: String = "", ignoresCase: Bool = false) {
|
||
self.id = id
|
||
self.isEnabled = isEnabled
|
||
self.kind = kind
|
||
self.pattern = pattern
|
||
self.replacement = replacement
|
||
self.ignoresCase = ignoresCase
|
||
}
|
||
|
||
func applying(to value: String) throws -> String {
|
||
guard isEnabled else { return value }
|
||
switch kind {
|
||
case .remove:
|
||
guard !pattern.isEmpty else { return value }
|
||
return value.replacingOccurrences(of: pattern, with: "", options: ignoresCase ? [.caseInsensitive] : [])
|
||
case .replace:
|
||
guard !pattern.isEmpty else { return value }
|
||
return value.replacingOccurrences(of: pattern, with: replacement, options: ignoresCase ? [.caseInsensitive] : [])
|
||
case .regex:
|
||
guard !pattern.isEmpty else { return value }
|
||
let expression = try NSRegularExpression(pattern: pattern, options: ignoresCase ? [.caseInsensitive] : [])
|
||
return expression.stringByReplacingMatches(in: value, range: NSRange(value.startIndex..., in: value), withTemplate: replacement)
|
||
case .prefix:
|
||
return pattern + value
|
||
case .suffix:
|
||
return value + pattern
|
||
}
|
||
}
|
||
}
|
||
|
||
private struct BatchRenamePreview: Identifiable {
|
||
let file: CloudFile
|
||
let newName: String
|
||
var error: String?
|
||
var id: String { file.id }
|
||
var hasChanged: Bool { file.name != newName }
|
||
}
|
||
|
||
struct BatchRenameToolPage: View {
|
||
@ObservedObject var model: AppModel
|
||
@State private var sourceID: String?
|
||
@State private var sourceName = "云盘根目录"
|
||
@State private var usesExplicitSelection = false
|
||
@State private var recursive = false
|
||
@State private var itemType: BatchRenameItemType = .all
|
||
@State private var preserveExtension = true
|
||
@State private var filterText = ""
|
||
@State private var rules = [BatchRenameRule()]
|
||
@State private var candidates: [CloudFile] = []
|
||
@State private var selectedIDs: Set<String> = []
|
||
@State private var isLoading = false
|
||
@State private var loadError = ""
|
||
@State private var showFolderPicker = false
|
||
@State private var showConfirmation = false
|
||
|
||
private var previews: [BatchRenamePreview] {
|
||
var values = candidates.filter { file in
|
||
let matchesType = itemType == .all || (itemType == .files && !file.isDirectory) || (itemType == .folders && file.isDirectory)
|
||
let matchesFilter = filterText.isEmpty || file.name.localizedCaseInsensitiveContains(filterText)
|
||
return matchesType && matchesFilter
|
||
}.map(makePreview)
|
||
|
||
let proposedGroups = Dictionary(grouping: values.filter { $0.hasChanged && $0.error == nil }) {
|
||
destinationKey(path: $0.file.cloudPath, name: $0.newName)
|
||
}
|
||
let duplicateKeys = Set(proposedGroups.filter { $0.value.count > 1 }.map(\.key))
|
||
let existing = Dictionary(grouping: candidates) { destinationKey(path: $0.cloudPath, name: $0.name) }
|
||
|
||
for index in values.indices where values[index].hasChanged && values[index].error == nil {
|
||
let key = destinationKey(path: values[index].file.cloudPath, name: values[index].newName)
|
||
if duplicateKeys.contains(key) {
|
||
values[index].error = "规则产生了同名项目"
|
||
} else if existing[key]?.contains(where: { $0.id != values[index].file.id }) == true {
|
||
values[index].error = "目标名称已存在"
|
||
}
|
||
}
|
||
return values
|
||
}
|
||
|
||
private var applicablePreviews: [BatchRenamePreview] {
|
||
previews.filter { $0.hasChanged && $0.error == nil }
|
||
}
|
||
|
||
private var selectedChanges: [BatchRenameChange] {
|
||
applicablePreviews.filter { selectedIDs.contains($0.id) }.map { BatchRenameChange(file: $0.file, newName: $0.newName) }
|
||
}
|
||
|
||
var body: some View {
|
||
ScrollView {
|
||
VStack(spacing: 14) {
|
||
sourceSection
|
||
rulesSection
|
||
previewSection
|
||
}
|
||
.padding(.bottom, 12)
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||
.sheet(isPresented: $showFolderPicker) {
|
||
ScanFolderPickerSheet(model: model, title: "选择重命名目录", selectionLabel: "选择此目录") { id, name in
|
||
model.batchRenameSelection = []
|
||
model.batchRenameTarget = nil
|
||
usesExplicitSelection = false
|
||
sourceID = id
|
||
sourceName = name
|
||
candidates = []
|
||
selectedIDs = []
|
||
}
|
||
}
|
||
.confirmationDialog("确认批量重命名?", isPresented: $showConfirmation) {
|
||
Button("应用 \(selectedChanges.count) 项重命名") {
|
||
let changes = selectedChanges
|
||
Task {
|
||
_ = await model.applyBatchRenames(changes)
|
||
if usesExplicitSelection {
|
||
model.batchRenameSelection = []
|
||
candidates = []
|
||
selectedIDs = []
|
||
sourceName = "已完成所选项目"
|
||
} else {
|
||
await loadCandidates()
|
||
}
|
||
}
|
||
}
|
||
Button("取消", role: .cancel) { }
|
||
} message: {
|
||
Text("名称修改会直接同步到云盘。执行前请再次核对预览。")
|
||
}
|
||
.task {
|
||
syncTarget()
|
||
if !usesExplicitSelection { await loadCandidates() }
|
||
}
|
||
.onChange(of: model.batchRenameTarget?.id) { _, _ in
|
||
syncTarget()
|
||
if !usesExplicitSelection { Task { await loadCandidates() } }
|
||
}
|
||
.onChange(of: model.batchRenameSelection.map(\.id)) { _, _ in
|
||
syncTarget()
|
||
}
|
||
}
|
||
|
||
private var sourceSection: some View {
|
||
ToolPageCard(icon: "folder.badge.gearshape", tint: .orange, title: "数据源", detail: sourceName) {
|
||
HStack(spacing: 10) {
|
||
Picker("类型", selection: $itemType) {
|
||
ForEach(BatchRenameItemType.allCases) { Text($0.rawValue).tag($0) }
|
||
}
|
||
.frame(width: 150)
|
||
TextField("结果内过滤", text: $filterText).textFieldStyle(.roundedBorder)
|
||
Toggle("保留扩展名", isOn: $preserveExtension).fixedSize()
|
||
Toggle("包含子文件夹", isOn: $recursive).fixedSize().disabled(usesExplicitSelection)
|
||
Button { showFolderPicker = true } label: { Label("选择目录", systemImage: "folder") }
|
||
.buttonStyle(.bordered)
|
||
Button {
|
||
if usesExplicitSelection { syncTarget() }
|
||
else { Task { await loadCandidates() } }
|
||
} label: {
|
||
if isLoading { ProgressView().controlSize(.small) } else { Image(systemName: "arrow.clockwise") }
|
||
}
|
||
.buttonStyle(.bordered)
|
||
.help("重新读取目录")
|
||
.disabled(isLoading || model.isBatchRenaming)
|
||
}
|
||
if !loadError.isEmpty {
|
||
Label(loadError, systemImage: "exclamationmark.triangle.fill").font(.caption).foregroundStyle(.red)
|
||
}
|
||
}
|
||
}
|
||
|
||
private var rulesSection: some View {
|
||
ToolPageCard(icon: "list.number", tint: .blue, title: "规则链", detail: "按从上到下的顺序应用 \(rules.count) 条规则") {
|
||
VStack(spacing: 8) {
|
||
ForEach(Array(rules.enumerated()), id: \.element.id) { index, rule in
|
||
ruleRow(index: index, rule: rule)
|
||
}
|
||
}
|
||
HStack {
|
||
Button { rules.append(BatchRenameRule(kind: .replace)) } label: { Label("添加规则", systemImage: "plus") }
|
||
.buttonStyle(.bordered)
|
||
Spacer()
|
||
Button("清空规则") { rules = [BatchRenameRule()] }.disabled(rules.count == 1 && rules[0].pattern.isEmpty && rules[0].isEnabled)
|
||
}
|
||
}
|
||
}
|
||
|
||
private var previewSection: some View {
|
||
ToolPageCard(icon: "rectangle.and.pencil.and.ellipsis", tint: .green, title: "预览", detail: previewSummary) {
|
||
if isLoading {
|
||
VStack(spacing: 12) { ProgressView(); Text("正在读取目录内容…").foregroundStyle(.secondary) }
|
||
.frame(maxWidth: .infinity).padding(.vertical, 42)
|
||
} else if candidates.isEmpty {
|
||
ContentUnavailableView("目录中没有项目", systemImage: "folder", description: Text("选择目录或重新读取数据源。"))
|
||
.frame(minHeight: 180)
|
||
} else {
|
||
HStack {
|
||
Button("全选可应用项") { selectedIDs = Set(applicablePreviews.map(\.id)) }.buttonStyle(.bordered)
|
||
Button("全不选") { selectedIDs.removeAll() }.buttonStyle(.bordered)
|
||
Spacer()
|
||
Text("已选 \(selectedChanges.count) 项").font(.caption.weight(.semibold)).foregroundStyle(.secondary)
|
||
Button {
|
||
showConfirmation = true
|
||
} label: {
|
||
HStack(spacing: 7) {
|
||
if model.isBatchRenaming { ProgressView().controlSize(.small) }
|
||
Label(model.isBatchRenaming ? "正在应用 \(model.batchRenameProgress.completed)/\(model.batchRenameProgress.total)" : "应用重命名", systemImage: "play.fill")
|
||
}
|
||
}
|
||
.buttonStyle(.borderedProminent).tint(.orange)
|
||
.disabled(selectedChanges.isEmpty || model.isBatchRenaming)
|
||
}
|
||
Divider()
|
||
LazyVStack(spacing: 0) {
|
||
ForEach(previews) { preview in previewRow(preview) }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private var previewSummary: String {
|
||
let invalid = previews.filter { $0.error != nil }.count
|
||
return "共 \(previews.count) 项,可重命名 \(applicablePreviews.count) 项" + (invalid > 0 ? ",\(invalid) 项冲突" : "")
|
||
}
|
||
|
||
private func ruleRow(index: Int, rule: BatchRenameRule) -> some View {
|
||
HStack(spacing: 8) {
|
||
Toggle("启用", isOn: $rules[index].isEnabled).labelsHidden().help("启用或停用此规则")
|
||
Picker("规则", selection: $rules[index].kind) {
|
||
ForEach(BatchRenameRuleKind.allCases) { Text($0.rawValue).tag($0) }
|
||
}
|
||
.labelsHidden().frame(width: 135)
|
||
TextField(patternPlaceholder(for: rule.kind), text: $rules[index].pattern).textFieldStyle(.roundedBorder)
|
||
if rule.kind == .replace || rule.kind == .regex {
|
||
Image(systemName: "arrow.right").foregroundStyle(.tertiary)
|
||
TextField("替换为", text: $rules[index].replacement).textFieldStyle(.roundedBorder)
|
||
}
|
||
if rule.kind == .remove || rule.kind == .replace || rule.kind == .regex {
|
||
Toggle("忽略大小写", isOn: $rules[index].ignoresCase).fixedSize()
|
||
}
|
||
Button { moveRule(index, offset: -1) } label: { Image(systemName: "arrow.up") }
|
||
.buttonStyle(.plain).disabled(index == 0).help("上移")
|
||
Button { moveRule(index, offset: 1) } label: { Image(systemName: "arrow.down") }
|
||
.buttonStyle(.plain).disabled(index == rules.count - 1).help("下移")
|
||
Button(role: .destructive) { removeRule(index) } label: { Image(systemName: "trash") }
|
||
.buttonStyle(.plain).help("删除规则")
|
||
}
|
||
.padding(10)
|
||
.background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||
}
|
||
|
||
private func previewRow(_ preview: BatchRenamePreview) -> some View {
|
||
let canSelect = preview.hasChanged && preview.error == nil
|
||
return HStack(spacing: 10) {
|
||
Toggle("", isOn: Binding(
|
||
get: { selectedIDs.contains(preview.id) },
|
||
set: { if $0 && canSelect { selectedIDs.insert(preview.id) } else { selectedIDs.remove(preview.id) } }
|
||
))
|
||
.labelsHidden().disabled(!canSelect)
|
||
Image(systemName: preview.file.icon).foregroundStyle(preview.file.isDirectory ? .orange : .accentColor).frame(width: 22)
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
Text(preview.file.name).font(.caption).foregroundStyle(.secondary).lineLimit(1)
|
||
Text(preview.newName).font(.callout.weight(.medium)).lineLimit(1)
|
||
}
|
||
Spacer()
|
||
if let error = preview.error {
|
||
Label(error, systemImage: "exclamationmark.triangle.fill").font(.caption).foregroundStyle(.red)
|
||
} else if preview.hasChanged {
|
||
Text("将修改").font(.caption.weight(.semibold)).foregroundStyle(.green)
|
||
} else {
|
||
Text("无变化").font(.caption).foregroundStyle(.tertiary)
|
||
}
|
||
}
|
||
.padding(.vertical, 8)
|
||
.overlay(alignment: .bottom) { Divider() }
|
||
}
|
||
|
||
private func makePreview(_ file: CloudFile) -> BatchRenamePreview {
|
||
let extensionText = (!file.isDirectory && preserveExtension) ? (file.name as NSString).pathExtension : ""
|
||
let originalBase = extensionText.isEmpty ? file.name : (file.name as NSString).deletingPathExtension
|
||
do {
|
||
let renamedBase = try rules.reduce(originalBase) { try $1.applying(to: $0) }
|
||
let newName = extensionText.isEmpty ? renamedBase : renamedBase + "." + extensionText
|
||
let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
if trimmed.isEmpty { return BatchRenamePreview(file: file, newName: newName, error: "名称不能为空") }
|
||
if newName.contains("/") || newName.contains("\0") { return BatchRenamePreview(file: file, newName: newName, error: "名称包含非法字符") }
|
||
return BatchRenamePreview(file: file, newName: newName, error: nil)
|
||
} catch {
|
||
return BatchRenamePreview(file: file, newName: file.name, error: "正则表达式无效")
|
||
}
|
||
}
|
||
|
||
private func destinationKey(path: String, name: String) -> String {
|
||
let parent = (path as NSString).deletingLastPathComponent
|
||
return parent.lowercased() + "/" + name.lowercased()
|
||
}
|
||
|
||
private func patternPlaceholder(for kind: BatchRenameRuleKind) -> String {
|
||
switch kind {
|
||
case .remove: return "要删除的字符"
|
||
case .replace: return "查找内容"
|
||
case .regex: return "正则表达式,例如 \\s+"
|
||
case .prefix: return "前缀"
|
||
case .suffix: return "后缀"
|
||
}
|
||
}
|
||
|
||
private func moveRule(_ index: Int, offset: Int) {
|
||
let destination = index + offset
|
||
guard rules.indices.contains(index), rules.indices.contains(destination) else { return }
|
||
rules.swapAt(index, destination)
|
||
}
|
||
|
||
private func removeRule(_ index: Int) {
|
||
guard rules.indices.contains(index) else { return }
|
||
rules.remove(at: index)
|
||
if rules.isEmpty { rules = [BatchRenameRule()] }
|
||
}
|
||
|
||
private func syncTarget() {
|
||
if !model.batchRenameSelection.isEmpty {
|
||
usesExplicitSelection = true
|
||
candidates = model.batchRenameSelection
|
||
selectedIDs = Set(candidates.map(\.id))
|
||
sourceID = nil
|
||
sourceName = "已选择 \(candidates.count) 项"
|
||
} else if let target = model.batchRenameTarget, target.isDirectory {
|
||
usesExplicitSelection = false
|
||
sourceID = target.id
|
||
sourceName = target.name
|
||
} else {
|
||
usesExplicitSelection = false
|
||
sourceID = model.currentScanRootID
|
||
sourceName = model.scanScopeLocationName
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private func loadCandidates() async {
|
||
guard !isLoading, !usesExplicitSelection else { return }
|
||
isLoading = true
|
||
loadError = ""
|
||
defer { isLoading = false }
|
||
do {
|
||
candidates = try await model.batchRenameCandidates(parentID: sourceID, rootName: sourceName, recursive: recursive)
|
||
selectedIDs = Set(candidates.map(\.id))
|
||
} catch is CancellationError { }
|
||
catch { loadError = error.localizedDescription }
|
||
}
|
||
}
|