feat: expand batch rename rule workflow

This commit is contained in:
ngfchl
2026-07-18 12:39:32 +08:00
parent cd598bfcda
commit 22d6615a84
2 changed files with 574 additions and 113 deletions
+31 -11
View File
@@ -2,6 +2,8 @@ import 'cloud_file.dart';
enum BatchRenameRuleKind { remove, replace, regex, prefix, suffix }
enum BatchRenameItemType { all, files, folders }
class BatchRenameRule {
final String id;
final bool enabled;
@@ -115,20 +117,38 @@ List<BatchRenamePreview> buildRenamePreviews(
}
}
final proposed = <String, int>{};
final existing = <String, Set<String>>{};
for (final file in files) {
final key = _destinationKey(file.cloudPath, file.name);
(existing[key] ??= <String>{}).add(file.id);
}
for (final item in values.where((item) => item.applicable)) {
final key =
'${item.file.cloudPath.toLowerCase()}/${item.newName.toLowerCase()}';
final key = _destinationKey(item.file.cloudPath, item.newName);
proposed[key] = (proposed[key] ?? 0) + 1;
}
return values.map((item) {
final key =
'${item.file.cloudPath.toLowerCase()}/${item.newName.toLowerCase()}';
return proposed[key] != null && proposed[key]! > 1
? BatchRenamePreview(
file: item.file,
newName: item.newName,
error: '规则产生同名项目',
)
: item;
if (!item.applicable) return item;
final key = _destinationKey(item.file.cloudPath, item.newName);
if (proposed[key] != null && proposed[key]! > 1) {
return BatchRenamePreview(
file: item.file,
newName: item.newName,
error: '规则产生同名项目',
);
}
if (existing[key]?.any((id) => id != item.file.id) ?? false) {
return BatchRenamePreview(
file: item.file,
newName: item.newName,
error: '目标名称已存在',
);
}
return item;
}).toList();
}
String _destinationKey(String path, String name) {
final separator = path.lastIndexOf('/');
final parent = separator < 0 ? '' : path.substring(0, separator);
return '${parent.toLowerCase()}/${name.toLowerCase()}';
}
+543 -102
View File
@@ -341,145 +341,586 @@ class _BatchRenameTool extends ConsumerStatefulWidget {
}
class _BatchRenameToolState extends ConsumerState<_BatchRenameTool> {
final _find = TextEditingController();
final _replace = TextEditingController();
final _filter = TextEditingController();
bool _preserveExtension = true;
bool _running = false;
int _completed = 0;
int _total = 0;
String _status = '';
BatchRenameItemType _itemType = BatchRenameItemType.all;
final _selectedIDs = <String>{};
late List<BatchRenameRule> _rules;
@override
void initState() {
super.initState();
_rules = [
BatchRenameRule(id: 'replace', kind: BatchRenameRuleKind.replace),
const BatchRenameRule(id: 'rule-0', kind: BatchRenameRuleKind.replace),
];
}
@override
void dispose() {
_find.dispose();
_replace.dispose();
_filter.dispose();
super.dispose();
}
String _newName(CloudFile file) {
final rule = _rules.first.copyWith(
pattern: _find.text,
replacement: _replace.text,
);
return buildRenamePreviews(
[file],
[rule],
preserveExtension: _preserveExtension,
).first.newName;
void _updateRule(int index, BatchRenameRule rule) {
setState(() => _rules[index] = rule);
}
Future<void> _apply(List<CloudFile> files) async {
final updates = files.where((file) => _newName(file) != file.name).toList();
if (updates.isEmpty) return;
setState(() => _running = true);
void _moveRule(int index, int offset) {
final destination = index + offset;
if (destination < 0 || destination >= _rules.length) return;
setState(() {
final next = List<BatchRenameRule>.of(_rules);
final rule = next[index];
next[index] = next[destination];
next[destination] = rule;
_rules = next;
});
}
Future<void> _confirmAndApply(List<BatchRenamePreview> previews) async {
final changes = previews
.where(
(preview) =>
preview.applicable && _selectedIDs.contains(preview.file.id),
)
.toList();
if (changes.isEmpty || _running) return;
final confirmed = await showShadDialog<bool>(
context: context,
builder: (dialogContext) => ShadDialog(
title: Text('应用 ${changes.length} 项重命名?'),
actions: [
ShadButton.outline(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('取消'),
),
ShadButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('应用重命名'),
),
],
child: const Padding(
padding: EdgeInsets.only(top: 10),
child: Text('名称修改会直接同步到云盘,请确认预览结果。'),
),
),
);
if (confirmed == true) await _apply(changes);
}
Future<void> _apply(List<BatchRenamePreview> changes) async {
setState(() {
_running = true;
_completed = 0;
_total = changes.length;
_status = '';
});
final api = ref.read(authProvider.notifier).api;
var succeeded = 0;
var failed = 0;
try {
final notifier = ref.read(fileProvider.notifier);
for (final file in updates) {
await notifier.renameFile(file, _newName(file));
for (final change in changes) {
if (!mounted) return;
setState(() => _status = change.file.name);
try {
await api.fsRename(change.file.id, change.newName);
succeeded += 1;
} catch (_) {
failed += 1;
}
if (mounted) setState(() => _completed += 1);
}
await ref.read(fileProvider.notifier).loadFiles();
} finally {
if (mounted) setState(() => _running = false);
if (mounted) {
setState(() {
_running = false;
_selectedIDs.clear();
_status = failed == 0
? '已完成 $succeeded 项重命名'
: '已完成 $succeeded 项,失败 $failed';
});
}
}
}
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
final files = ref.watch(fileProvider).files;
final previews = buildRenamePreviews(files, [
_rules.first.copyWith(pattern: _find.text, replacement: _replace.text),
], preserveExtension: _preserveExtension);
final changed = previews.where((item) => item.applicable).toList();
return Padding(
final allPreviews = buildRenamePreviews(
files,
_rules,
preserveExtension: _preserveExtension,
);
final filterText = _filter.text.trim().toLowerCase();
final previews = allPreviews.where((preview) {
final matchesType = switch (_itemType) {
BatchRenameItemType.all => true,
BatchRenameItemType.files => !preview.file.isDirectory,
BatchRenameItemType.folders => preview.file.isDirectory,
};
return matchesType &&
(filterText.isEmpty ||
preview.file.name.toLowerCase().contains(filterText));
}).toList();
final applicable = previews.where((preview) => preview.applicable).toList();
final selectedCount = applicable
.where((preview) => _selectedIDs.contains(preview.file.id))
.length;
return ListView(
padding: const EdgeInsets.all(18),
child: Column(
children: [
_ToolSection(
title: '重命名规则',
description: '对当前目录的文件和文件夹生成预览,确认后逐项同步到云盘。',
child: Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
children: [
Expanded(
child: ShadInput(
controller: _find,
placeholder: const Text('查找'),
),
children: [
_ToolSection(
title: '数据源',
description: '当前目录 ${files.length} 项;筛选后显示 ${previews.length} 项。',
child: Padding(
padding: const EdgeInsets.only(top: 12),
child: Wrap(
spacing: 10,
runSpacing: 10,
children: [
SizedBox(
width: 132,
child: ShadSelect<BatchRenameItemType>(
key: ValueKey(_itemType),
initialValue: _itemType,
selectedOptionBuilder: (_, value) =>
Text(_itemTypeLabel(value)),
options: [
for (final value in BatchRenameItemType.values)
ShadOption(
value: value,
child: Text(_itemTypeLabel(value)),
),
],
onChanged: (value) {
if (value != null) setState(() => _itemType = value);
},
),
const SizedBox(width: 8),
const Icon(Icons.arrow_right_alt_rounded),
const SizedBox(width: 8),
Expanded(
child: ShadInput(
controller: _replace,
placeholder: const Text('替换为'),
),
),
SizedBox(
width: 220,
child: ShadInput(
controller: _filter,
placeholder: const Text('按原名称过滤'),
onChanged: (_) => setState(() {}),
),
const SizedBox(width: 12),
ShadCheckbox(
value: _preserveExtension,
label: const Text('保留扩展名'),
onChanged: (value) =>
setState(() => _preserveExtension = value),
),
const SizedBox(width: 12),
ShadButton(
onPressed: _running
? null
: () =>
_apply(changed.map((item) => item.file).toList()),
child: Text(_running ? '正在应用' : '应用 ${changed.length}'),
),
],
),
),
ShadCheckbox(
value: _preserveExtension,
label: const Text('保留扩展名'),
onChanged: (value) =>
setState(() => _preserveExtension = value),
),
],
),
),
const SizedBox(height: 14),
Expanded(
child: Container(
decoration: BoxDecoration(
color: cs.card,
border: Border.all(color: cs.border),
borderRadius: BorderRadius.circular(8),
),
child: ListView.separated(
itemCount: files.length,
separatorBuilder: (_, _) =>
Divider(height: 1, color: cs.border),
itemBuilder: (context, index) {
final file = files[index];
final preview = previews[index];
final next = preview.newName;
return ListTile(
dense: true,
leading: Icon(
file.isDirectory
? Icons.folder_rounded
: Icons.insert_drive_file_rounded,
color: file.isDirectory ? cs.primary : cs.mutedForeground,
),
const SizedBox(height: 14),
_ToolSection(
title: '规则链',
description: '规则按从上到下的顺序应用。',
child: Padding(
padding: const EdgeInsets.only(top: 12),
child: Column(
children: [
for (var index = 0; index < _rules.length; index++)
_BatchRenameRuleRow(
key: ValueKey(_rules[index].id),
rule: _rules[index],
isFirst: index == 0,
isLast: index == _rules.length - 1,
onChanged: (rule) => _updateRule(index, rule),
onMoveUp: () => _moveRule(index, -1),
onMoveDown: () => _moveRule(index, 1),
onRemove: () => setState(() {
_rules.removeAt(index);
if (_rules.isEmpty) {
_rules = const [
BatchRenameRule(
id: 'rule-0',
kind: BatchRenameRuleKind.replace,
),
];
}
}),
),
const SizedBox(height: 10),
Row(
children: [
ShadButton.outline(
onPressed: () => setState(() {
_rules.add(
BatchRenameRule(
id: 'rule-${DateTime.now().microsecondsSinceEpoch}',
kind: BatchRenameRuleKind.replace,
),
);
}),
leading: const Icon(Icons.add_rounded, size: 16),
child: const Text('添加规则'),
),
title: Text(file.name),
subtitle: Text(
preview.error ?? next,
style: TextStyle(
color: preview.error != null
? cs.destructive
: next == file.name
? cs.mutedForeground
: cs.primary,
const SizedBox(width: 8),
ShadButton.ghost(
onPressed: () => setState(() {
_rules = const [
BatchRenameRule(
id: 'rule-0',
kind: BatchRenameRuleKind.replace,
),
];
}),
child: const Text('清空规则'),
),
],
),
],
),
),
),
const SizedBox(height: 14),
_ToolSection(
title: '预览',
description: '可应用 ${applicable.length} 项,已选择 $selectedCount 项。',
trailing: ShadButton(
onPressed: _running || selectedCount == 0
? null
: () => _confirmAndApply(previews),
leading: Icon(
_running ? Icons.hourglass_top_rounded : Icons.play_arrow_rounded,
size: 16,
),
child: Text(_running ? '正在应用 $_completed / $_total' : '应用重命名'),
),
child: Padding(
padding: const EdgeInsets.only(top: 12),
child: Column(
children: [
Row(
children: [
ShadButton.outline(
onPressed: _running
? null
: () => setState(() {
_selectedIDs
..clear()
..addAll(
applicable.map((item) => item.file.id),
);
}),
child: const Text('全选可应用项'),
),
const SizedBox(width: 8),
ShadButton.ghost(
onPressed: _running
? null
: () => setState(_selectedIDs.clear),
child: const Text('全不选'),
),
const Spacer(),
if (_status.isNotEmpty)
Expanded(
child: Text(
_status,
textAlign: TextAlign.right,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: ShadTheme.of(
context,
).colorScheme.mutedForeground,
),
),
),
],
),
const SizedBox(height: 10),
Container(
height: 360,
decoration: BoxDecoration(
border: Border.all(
color: ShadTheme.of(context).colorScheme.border,
),
);
},
borderRadius: BorderRadius.circular(8),
),
child: previews.isEmpty
? Center(
child: Text(
'当前筛选条件下没有项目',
style: TextStyle(
color: ShadTheme.of(
context,
).colorScheme.mutedForeground,
),
),
)
: ListView.separated(
itemCount: previews.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, index) {
final preview = previews[index];
final canSelect = preview.applicable;
return _BatchRenamePreviewRow(
preview: preview,
selected: _selectedIDs.contains(preview.file.id),
enabled: canSelect && !_running,
onChanged: (selected) => setState(() {
if (selected == true && canSelect) {
_selectedIDs.add(preview.file.id);
} else {
_selectedIDs.remove(preview.file.id);
}
}),
);
},
),
),
],
),
),
),
],
);
}
}
String _itemTypeLabel(BatchRenameItemType value) => switch (value) {
BatchRenameItemType.all => '全部项目',
BatchRenameItemType.files => '仅文件',
BatchRenameItemType.folders => '仅文件夹',
};
String _ruleKindLabel(BatchRenameRuleKind value) => switch (value) {
BatchRenameRuleKind.remove => '删除字符',
BatchRenameRuleKind.replace => '查找替换',
BatchRenameRuleKind.regex => '正则替换',
BatchRenameRuleKind.prefix => '添加前缀',
BatchRenameRuleKind.suffix => '添加后缀',
};
String _rulePatternPlaceholder(BatchRenameRuleKind value) => switch (value) {
BatchRenameRuleKind.remove => '要删除的字符',
BatchRenameRuleKind.replace => '查找内容',
BatchRenameRuleKind.regex => '正则表达式,例如 \\s+',
BatchRenameRuleKind.prefix => '前缀',
BatchRenameRuleKind.suffix => '后缀',
};
class _BatchRenameRuleRow extends StatelessWidget {
final BatchRenameRule rule;
final bool isFirst;
final bool isLast;
final ValueChanged<BatchRenameRule> onChanged;
final VoidCallback onMoveUp;
final VoidCallback onMoveDown;
final VoidCallback onRemove;
const _BatchRenameRuleRow({
super.key,
required this.rule,
required this.isFirst,
required this.isLast,
required this.onChanged,
required this.onMoveUp,
required this.onMoveDown,
required this.onRemove,
});
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
final hasReplacement =
rule.kind == BatchRenameRuleKind.replace ||
rule.kind == BatchRenameRuleKind.regex;
final supportsCase =
hasReplacement || rule.kind == BatchRenameRuleKind.remove;
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: cs.muted.withValues(alpha: 0.46),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: cs.border.withValues(alpha: 0.65)),
),
child: Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
ShadCheckbox(
value: rule.enabled,
label: const Text('启用'),
onChanged: (value) => onChanged(rule.copyWith(enabled: value)),
),
SizedBox(
width: 132,
child: ShadSelect<BatchRenameRuleKind>(
key: ValueKey('${rule.id}-${rule.kind}'),
initialValue: rule.kind,
selectedOptionBuilder: (_, value) => Text(_ruleKindLabel(value)),
options: [
for (final value in BatchRenameRuleKind.values)
ShadOption(value: value, child: Text(_ruleKindLabel(value))),
],
onChanged: (value) {
if (value != null) onChanged(rule.copyWith(kind: value));
},
),
),
SizedBox(
width: 190,
child: ShadInput(
initialValue: rule.pattern,
placeholder: Text(_rulePatternPlaceholder(rule.kind)),
onChanged: (value) => onChanged(rule.copyWith(pattern: value)),
),
),
if (hasReplacement) ...[
const Icon(Icons.arrow_right_alt_rounded, size: 18),
SizedBox(
width: 160,
child: ShadInput(
initialValue: rule.replacement,
placeholder: const Text('替换为'),
onChanged: (value) =>
onChanged(rule.copyWith(replacement: value)),
),
),
],
if (supportsCase)
ShadCheckbox(
value: rule.ignoreCase,
label: const Text('忽略大小写'),
onChanged: (value) => onChanged(rule.copyWith(ignoreCase: value)),
),
_RenameRuleIconButton(
icon: Icons.arrow_upward_rounded,
tooltip: '上移规则',
enabled: !isFirst,
onPressed: onMoveUp,
),
_RenameRuleIconButton(
icon: Icons.arrow_downward_rounded,
tooltip: '下移规则',
enabled: !isLast,
onPressed: onMoveDown,
),
_RenameRuleIconButton(
icon: Icons.delete_outline_rounded,
tooltip: '删除规则',
enabled: true,
destructive: true,
onPressed: onRemove,
),
],
),
);
}
}
class _RenameRuleIconButton extends StatelessWidget {
final IconData icon;
final String tooltip;
final bool enabled;
final bool destructive;
final VoidCallback onPressed;
const _RenameRuleIconButton({
required this.icon,
required this.tooltip,
required this.enabled,
required this.onPressed,
this.destructive = false,
});
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
return ShadTooltip(
builder: (_) => Text(tooltip),
child: ShadButton.ghost(
size: ShadButtonSize.sm,
onPressed: enabled ? onPressed : null,
child: Icon(icon, size: 16, color: destructive ? cs.destructive : null),
),
);
}
}
class _BatchRenamePreviewRow extends StatelessWidget {
final BatchRenamePreview preview;
final bool selected;
final bool enabled;
final ValueChanged<bool?> onChanged;
const _BatchRenamePreviewRow({
required this.preview,
required this.selected,
required this.enabled,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final cs = ShadTheme.of(context).colorScheme;
final changed = preview.changed;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
child: Row(
children: [
ShadCheckbox(value: selected, onChanged: enabled ? onChanged : null),
const SizedBox(width: 10),
Icon(
preview.file.isDirectory
? Icons.folder_rounded
: Icons.insert_drive_file_rounded,
size: 18,
color: preview.file.isDirectory ? cs.primary : cs.mutedForeground,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
preview.file.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: cs.mutedForeground),
),
const SizedBox(height: 2),
Text(
preview.newName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: preview.error != null
? cs.destructive
: changed
? cs.primary
: cs.mutedForeground,
),
),
],
),
),
const SizedBox(width: 10),
Text(
preview.error ?? (changed ? '将修改' : '无变化'),
style: TextStyle(
fontSize: 12,
color: preview.error != null
? cs.destructive
: changed
? cs.primary
: cs.mutedForeground,
),
),
],
),