added multirename tool

This commit is contained in:
Radek Davidek 2026-04-29 19:49:33 +02:00
parent 86204d59b4
commit 351b59065e
2 changed files with 485 additions and 0 deletions

View File

@ -551,6 +551,13 @@ public class MainWindow extends JFrame {
btnSync.addActionListener(e -> showSyncDialog());
toolBar.add(btnSync);
// Multi-rename button
JButton btnMultiRename = new JButton();
btnMultiRename.setIcon(createToolbarMultiRenameIcon());
setupMainToolbarButton(btnMultiRename, "Multi-Rename Tool (Ctrl+M)", new Color(190, 160, 230));
btnMultiRename.addActionListener(e -> showMultiRenameDialog());
toolBar.add(btnMultiRename);
toolBar.addSeparator();
// Load custom shortcuts from config
@ -903,6 +910,42 @@ public class MainWindow extends JFrame {
};
}
private Icon createToolbarMultiRenameIcon() {
return new Icon() {
private final int w = 16;
private final int h = 16;
@Override
public int getIconWidth() {
return w;
}
@Override
public int getIconHeight() {
return h;
}
@Override
public void paintIcon(Component c, Graphics g, int x, int y) {
Graphics2D g2 = (Graphics2D) g.create();
try {
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
g2.setColor(c != null ? c.getForeground() : Color.DARK_GRAY);
g2.setStroke(new BasicStroke(2f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND));
// Two file rows + edit pencil to suggest bulk rename tool.
g2.drawRoundRect(x + 1, y + 2, 9, 4, 2, 2);
g2.drawRoundRect(x + 1, y + 9, 9, 4, 2, 2);
g2.drawLine(x + 11, y + 12, x + 15, y + 8);
g2.drawLine(x + 10, y + 13, x + 11, y + 12);
g2.drawLine(x + 14, y + 7, x + 15, y + 8);
} finally {
g2.dispose();
}
}
};
}
private void setupMainToolbarButton(JButton btn, String tooltip, Color color) {
int btnSize = config != null ? config.getToolbarButtonSize() : 35;
if (btnSize < 35) btnSize = 35;
@ -1178,6 +1221,10 @@ public class MainWindow extends JFrame {
JMenuItem compareItem = new JMenuItem("Compare Files");
compareItem.addActionListener(e -> compareFiles());
JMenuItem multiRenameItem = new JMenuItem("Multi-Rename Tool...");
multiRenameItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_M, InputEvent.CTRL_DOWN_MASK));
multiRenameItem.addActionListener(e -> showMultiRenameDialog());
JMenuItem refreshItem = new JMenuItem("Refresh");
refreshItem.setAccelerator(KeyStroke.getKeyStroke(KeyEvent.VK_F5, InputEvent.CTRL_DOWN_MASK));
refreshItem.addActionListener(e -> refreshPanels());
@ -1201,6 +1248,7 @@ public class MainWindow extends JFrame {
fileMenu.add(selectAllItem);
fileMenu.add(selectWildcardItem);
fileMenu.add(compareItem);
fileMenu.add(multiRenameItem);
fileMenu.add(refreshItem);
fileMenu.add(queueItem);
fileMenu.addSeparator();
@ -1540,6 +1588,11 @@ public class MainWindow extends JFrame {
KeyStroke.getKeyStroke(KeyEvent.VK_F7, InputEvent.ALT_DOWN_MASK),
JComponent.WHEN_IN_FOCUSED_WINDOW);
// Ctrl+M - Multi-Rename Tool
rootPane.registerKeyboardAction(e -> showMultiRenameDialog(),
KeyStroke.getKeyStroke(KeyEvent.VK_M, InputEvent.CTRL_DOWN_MASK),
JComponent.WHEN_IN_FOCUSED_WINDOW);
// Ctrl+F1 - Full details
rootPane.registerKeyboardAction(e -> setActiveViewMode(ViewMode.FULL),
KeyStroke.getKeyStroke(KeyEvent.VK_F1, InputEvent.CTRL_DOWN_MASK),
@ -2558,6 +2611,87 @@ public class MainWindow extends JFrame {
}
}
private void showMultiRenameDialog() {
if (activePanel == null) {
return;
}
List<FileItem> selectedItems = activePanel.getSelectedItems();
if (selectedItems.isEmpty()) {
JOptionPane.showMessageDialog(this,
"Select files for multi-rename first.",
"Multi-Rename Tool",
JOptionPane.INFORMATION_MESSAGE);
requestFocusInActivePanel();
return;
}
List<FileItem> candidates = new ArrayList<>();
for (FileItem item : selectedItems) {
if (!"..".equals(item.getName())) {
candidates.add(item);
}
}
if (candidates.isEmpty()) {
requestFocusInActivePanel();
return;
}
List<MultiRenameDialog.RenamePlan> plans = MultiRenameDialog.showDialog(this, candidates);
if (plans.isEmpty()) {
requestFocusInActivePanel();
return;
}
// Validate collisions with existing files that are not being renamed in this batch.
java.util.Set<String> oldNamesLower = new java.util.HashSet<>();
for (MultiRenameDialog.RenamePlan plan : plans) {
oldNamesLower.add(plan.getOldName().toLowerCase());
}
for (MultiRenameDialog.RenamePlan plan : plans) {
FileItem item = plan.getItem();
if (item.isFtp()) {
continue;
}
File source = item.getFile();
if (source == null || source.getParentFile() == null) {
continue;
}
File target = new File(source.getParentFile(), plan.getNewName());
if (target.exists() && !oldNamesLower.contains(target.getName().toLowerCase())) {
JOptionPane.showMessageDialog(this,
"Target already exists: " + target.getName(),
"Multi-Rename Tool",
JOptionPane.ERROR_MESSAGE);
requestFocusInActivePanel();
return;
}
}
String[] firstRenamed = new String[] {plans.getFirst().getNewName()};
performFileOperation((callback) -> {
long total = plans.size();
long current = 0;
for (MultiRenameDialog.RenamePlan plan : plans) {
if (callback.isCancelled()) {
break;
}
FileOperations.rename(plan.getItem(), plan.getNewName());
current++;
callback.onProgress(current, total, plan.getOldName() + " -> " + plan.getNewName());
}
if (activePanel != null) {
syncTargetArchiveIfNeeded(activePanel, activePanel.getCurrentDirectory(), callback);
}
}, "Multi-rename completed", false, () -> {
if (activePanel != null && activePanel.getCurrentTab() != null && firstRenamed[0] != null) {
activePanel.getCurrentTab().selectItem(firstRenamed[0]);
}
}, activePanel);
}
/**
* Show the given file's parent directory in the panel that currently has focus
* and select the file in that panel.

View File

@ -0,0 +1,351 @@
package cz.kamma.kfmanager.ui;
import cz.kamma.kfmanager.MainApp;
import cz.kamma.kfmanager.model.FileItem;
import javax.swing.*;
import javax.swing.event.DocumentEvent;
import javax.swing.event.DocumentListener;
import javax.swing.table.DefaultTableModel;
import java.awt.*;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Multi-rename tool inspired by Total Commander.
* Supports masks for name/extension and a configurable counter token [C] or [C:n].
*/
public class MultiRenameDialog extends JDialog {
public static class RenamePlan {
private final FileItem item;
private final String oldName;
private final String newName;
public RenamePlan(FileItem item, String oldName, String newName) {
this.item = item;
this.oldName = oldName;
this.newName = newName;
}
public FileItem getItem() {
return item;
}
public String getOldName() {
return oldName;
}
public String getNewName() {
return newName;
}
public boolean isChanged() {
return !oldName.equals(newName);
}
}
private static final Pattern COUNTER_PATTERN = Pattern.compile("\\[C(?::(\\d+))?\\]");
private final List<FileItem> items;
private final List<RenamePlan> allPlans = new ArrayList<>();
private final List<RenamePlan> changedPlans = new ArrayList<>();
private JTextField nameMaskField;
private JTextField extMaskField;
private JSpinner startCounterSpinner;
private JSpinner stepCounterSpinner;
private JTable previewTable;
private DefaultTableModel previewModel;
private JLabel statusLabel;
private JButton renameButton;
private boolean approved;
public MultiRenameDialog(Window owner, List<FileItem> items) {
super(owner, "Multi-Rename Tool", ModalityType.APPLICATION_MODAL);
this.items = new ArrayList<>(items);
initComponents();
updatePreview();
}
public static List<RenamePlan> showDialog(Window owner, List<FileItem> items) {
MultiRenameDialog dialog = new MultiRenameDialog(owner, items);
dialog.setVisible(true);
if (!dialog.approved) {
return List.of();
}
return new ArrayList<>(dialog.changedPlans);
}
private void initComponents() {
setLayout(new BorderLayout(8, 8));
JPanel masksPanel = new JPanel(new GridBagLayout());
GridBagConstraints gbc = new GridBagConstraints();
gbc.insets = new Insets(4, 4, 4, 4);
gbc.fill = GridBagConstraints.HORIZONTAL;
gbc.gridx = 0;
gbc.gridy = 0;
masksPanel.add(new JLabel("Name mask:"), gbc);
gbc.gridx = 1;
nameMaskField = new JTextField("*", 24);
masksPanel.add(nameMaskField, gbc);
gbc.gridx = 0;
gbc.gridy = 1;
masksPanel.add(new JLabel("Extension mask:"), gbc);
gbc.gridx = 1;
extMaskField = new JTextField("*", 24);
masksPanel.add(extMaskField, gbc);
gbc.gridx = 0;
gbc.gridy = 2;
masksPanel.add(new JLabel("Counter start:"), gbc);
gbc.gridx = 1;
startCounterSpinner = new JSpinner(new SpinnerNumberModel(1, -999999, 999999, 1));
masksPanel.add(startCounterSpinner, gbc);
gbc.gridx = 0;
gbc.gridy = 3;
masksPanel.add(new JLabel("Counter step:"), gbc);
gbc.gridx = 1;
stepCounterSpinner = new JSpinner(new SpinnerNumberModel(1, -999999, 999999, 1));
masksPanel.add(stepCounterSpinner, gbc);
gbc.gridx = 0;
gbc.gridy = 4;
gbc.gridwidth = 2;
JLabel help = new JLabel("Use * and ? masks. Counter token: [C] or [C:3].");
help.setFont(help.getFont().deriveFont(Font.PLAIN, 11f));
masksPanel.add(help, gbc);
add(masksPanel, BorderLayout.NORTH);
previewModel = new DefaultTableModel(new Object[] {"Old name", "New name"}, 0) {
@Override
public boolean isCellEditable(int row, int column) {
return false;
}
};
previewTable = new JTable(previewModel);
previewTable.setFillsViewportHeight(true);
add(new JScrollPane(previewTable), BorderLayout.CENTER);
JPanel bottom = new JPanel(new BorderLayout(8, 8));
statusLabel = new JLabel(" ");
bottom.add(statusLabel, BorderLayout.CENTER);
JPanel buttons = new JPanel(new FlowLayout(FlowLayout.RIGHT));
renameButton = new JButton("Rename");
JButton cancelButton = new JButton("Cancel");
buttons.add(renameButton);
buttons.add(cancelButton);
bottom.add(buttons, BorderLayout.EAST);
add(bottom, BorderLayout.SOUTH);
DocumentListener listener = new DocumentListener() {
@Override
public void insertUpdate(DocumentEvent e) {
updatePreview();
}
@Override
public void removeUpdate(DocumentEvent e) {
updatePreview();
}
@Override
public void changedUpdate(DocumentEvent e) {
updatePreview();
}
};
nameMaskField.getDocument().addDocumentListener(listener);
extMaskField.getDocument().addDocumentListener(listener);
startCounterSpinner.addChangeListener(e -> updatePreview());
stepCounterSpinner.addChangeListener(e -> updatePreview());
renameButton.addActionListener(e -> {
approved = true;
setVisible(false);
dispose();
});
cancelButton.addActionListener(e -> {
approved = false;
setVisible(false);
dispose();
});
getRootPane().setDefaultButton(renameButton);
getRootPane().registerKeyboardAction(e -> {
approved = false;
setVisible(false);
dispose();
}, KeyStroke.getKeyStroke(java.awt.event.KeyEvent.VK_ESCAPE, 0), JComponent.WHEN_IN_FOCUSED_WINDOW);
MainApp.applyReflectiveCaretColor(this);
setSize(760, 520);
setLocationRelativeTo(getOwner());
}
private void updatePreview() {
String nameMask = nameMaskField.getText();
String extMask = extMaskField.getText();
int startCounter = (Integer) startCounterSpinner.getValue();
int stepCounter = (Integer) stepCounterSpinner.getValue();
previewModel.setRowCount(0);
allPlans.clear();
changedPlans.clear();
Set<String> targetNames = new HashSet<>();
boolean hasError = false;
String status = " ";
int counter = startCounter;
for (FileItem item : items) {
String oldName = item.getName();
if ("..".equals(oldName)) {
continue;
}
NameParts parts = splitName(oldName, item.isDirectory());
String newBase = applyMask(parts.base, nameMask, counter);
String newExt = item.isDirectory() ? "" : applyMask(parts.ext, extMask, counter);
String newName = buildName(newBase, newExt, item.isDirectory());
RenamePlan plan = new RenamePlan(item, oldName, newName);
allPlans.add(plan);
previewModel.addRow(new Object[] {oldName, newName});
if (plan.isChanged()) {
changedPlans.add(plan);
}
if (newName == null || newName.isBlank()) {
hasError = true;
status = "Invalid result: empty file name.";
} else if (containsInvalidFileNameChar(newName)) {
hasError = true;
status = "Invalid result: forbidden characters in file name.";
}
String key = newName.toLowerCase(Locale.ROOT);
if (!targetNames.add(key)) {
hasError = true;
status = "Conflict: duplicate target names in preview.";
}
counter += stepCounter;
}
if (!hasError) {
status = "Items: " + allPlans.size() + " | Will rename: " + changedPlans.size();
}
statusLabel.setText(status);
renameButton.setEnabled(!hasError && !changedPlans.isEmpty());
}
private static NameParts splitName(String oldName, boolean directory) {
if (directory) {
return new NameParts(oldName, "");
}
int idx = oldName.lastIndexOf('.');
if (idx <= 0 || idx == oldName.length() - 1) {
return new NameParts(oldName, "");
}
return new NameParts(oldName.substring(0, idx), oldName.substring(idx + 1));
}
private static String buildName(String base, String ext, boolean directory) {
if (directory || ext == null || ext.isBlank()) {
return base == null ? "" : base;
}
return (base == null ? "" : base) + "." + ext;
}
private static boolean containsInvalidFileNameChar(String value) {
if (value == null || value.isEmpty()) {
return true;
}
for (char c : value.toCharArray()) {
if (c == '/' || c == '\\' || c == ':' || c == '*' || c == '?' || c == '"' || c == '<' || c == '>' || c == '|') {
return true;
}
}
return false;
}
private static String applyMask(String original, String mask, int counter) {
if (mask == null || mask.isEmpty()) {
return "";
}
String withCounter = replaceCounterTokens(mask, counter);
StringBuilder out = new StringBuilder();
int srcIndex = 0;
for (int i = 0; i < withCounter.length(); i++) {
char ch = withCounter.charAt(i);
if (ch == '*') {
out.append(original);
} else if (ch == '?') {
if (srcIndex < original.length()) {
out.append(original.charAt(srcIndex));
srcIndex++;
}
} else {
out.append(ch);
}
}
return out.toString();
}
private static String replaceCounterTokens(String mask, int counter) {
Matcher m = COUNTER_PATTERN.matcher(mask);
StringBuffer sb = new StringBuffer();
while (m.find()) {
String widthGroup = m.group(1);
String replacement;
if (widthGroup == null || widthGroup.isBlank()) {
replacement = Integer.toString(counter);
} else {
int width;
try {
width = Integer.parseInt(widthGroup);
} catch (NumberFormatException ex) {
width = 1;
}
if (width < 1) {
width = 1;
}
replacement = String.format(Locale.ROOT, "%0" + width + "d", counter);
}
m.appendReplacement(sb, Matcher.quoteReplacement(replacement));
}
m.appendTail(sb);
return sb.toString();
}
private static final class NameParts {
private final String base;
private final String ext;
private NameParts(String base, String ext) {
this.base = base;
this.ext = ext;
}
}
}