diff --git a/src/main/java/cz/kamma/kfmanager/ui/MainWindow.java b/src/main/java/cz/kamma/kfmanager/ui/MainWindow.java index c6b17be..e11a48b 100644 --- a/src/main/java/cz/kamma/kfmanager/ui/MainWindow.java +++ b/src/main/java/cz/kamma/kfmanager/ui/MainWindow.java @@ -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; @@ -1177,6 +1220,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)); @@ -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(); @@ -1539,6 +1587,11 @@ public class MainWindow extends JFrame { rootPane.registerKeyboardAction(e -> showSearchDialog(), 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), @@ -2558,6 +2611,87 @@ public class MainWindow extends JFrame { } } + private void showMultiRenameDialog() { + if (activePanel == null) { + return; + } + + List selectedItems = activePanel.getSelectedItems(); + if (selectedItems.isEmpty()) { + JOptionPane.showMessageDialog(this, + "Select files for multi-rename first.", + "Multi-Rename Tool", + JOptionPane.INFORMATION_MESSAGE); + requestFocusInActivePanel(); + return; + } + + List candidates = new ArrayList<>(); + for (FileItem item : selectedItems) { + if (!"..".equals(item.getName())) { + candidates.add(item); + } + } + + if (candidates.isEmpty()) { + requestFocusInActivePanel(); + return; + } + + List 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 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. diff --git a/src/main/java/cz/kamma/kfmanager/ui/MultiRenameDialog.java b/src/main/java/cz/kamma/kfmanager/ui/MultiRenameDialog.java new file mode 100644 index 0000000..b51ca3b --- /dev/null +++ b/src/main/java/cz/kamma/kfmanager/ui/MultiRenameDialog.java @@ -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 items; + private final List allPlans = new ArrayList<>(); + private final List 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 items) { + super(owner, "Multi-Rename Tool", ModalityType.APPLICATION_MODAL); + this.items = new ArrayList<>(items); + initComponents(); + updatePreview(); + } + + public static List showDialog(Window owner, List 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 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; + } + } +}