added multirename tool
This commit is contained in:
parent
86204d59b4
commit
351b59065e
@ -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.
|
||||
|
||||
351
src/main/java/cz/kamma/kfmanager/ui/MultiRenameDialog.java
Normal file
351
src/main/java/cz/kamma/kfmanager/ui/MultiRenameDialog.java
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user