Files
guangya_client/guangya_mac/BatchRenameTool.swift
T

375 lines
17 KiB
Swift
Raw 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 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 }
}
}