This article goes through an overview of how the app works, including: loading filenames, highlighting a selection in each filename, mark string on index (where it will also try to match the selection value), mark all strings (which will mark all of the strings that match the current selection's value), clearing all highlighting, and the selections dictionary (where highlighted text gets edited).
Introduction
This is a simple, yet powerful and fast Windows Forms app for batch renaming files. Batch renaming here means that you can edit all the files from a chosen folder at the same time based on certain criteria.
The idea is to allow the users whenever they have a large number of files with similar names to be able to edit the filenames all at once. For example, let's say you have files with these names:
File_1#somestring#228511.txt
File_2#somestring#876007.txt
File_3#somestring#231223.txt
File_4#somestring#401709.txt
File_5#somestring#663775.txt
File_6#somestring#151348.txt
File_7#somestring#880078.txt
File_8#somestring#392233.txt
File_9#somestring#906363.txt
File_10#somestring#327665.txt
Now let's say you want to replace „somestring“ with something else. Or you want to delete the random numbers in the end. Or you want to change the extension. Or insert some string at an index. This app allows you to apply each of these changes easily to all the files at once.
Using the App
This is how the app initially looks like:
Loading Filenames
First, browse for a folder by using the „Browse folder“ button. After choosing a folder, filenames will appear in the richtextbox
control. The filenames will be shown without the path (just the filenames), above each other (one file per line).
private void BrowseFolder()
{
if (folderBrowserDialog1.ShowDialog() == DialogResult.Cancel)
return;
folder = folderBrowserDialog1.SelectedPath;
richTextBox1.Text = "";
if (filenames != null)
filenames.Clear();
FillRTB(folder);
selections.Clear();
for (int i = 0; i < filenames.Count; i++)
selections.Add(i, new List<Tuple<int, int>>());
deque.ClearAll();
deque.Add(new ArrayList(filenames));
}
Highlighting
Before any actual editing of the filenames, first we need to highlight all the substrings we want a specific action done upon.
Mark on Index
This functionality will highlight a selection in each filename based on currently selected string's starting index and length of any of the filenames.
i.e., If you select „#somestring#“ in line no. 2 (File_10#somestring#327665.txt), this action will highlight all the substrings of all the lines between indexes 7 and 19, regardless of the selection value:
private void HighlightTextIndex(int startIndex, int length)
{
int i = 0;
for(int row = 0; row < filenames.Count; row++)
{
if (((string)filenames[row]).Length < startIndex + length)
{
i += ((string)filenames[row]).Length + 1;
continue;
}
richTextBox1.Select(i + startIndex, length);
richTextBox1.SelectionBackColor = Color.Yellow;
selections[row].Add(new Tuple<int, int>(startIndex, length));
i += ((string)filenames[row]).Length + 1;
}
richTextBox1.Select(0, 0);
}
Mark String on Index
This functionality will do the same thing as Mark on index, except it will also try to match the selection value – if the value does not match, it will not highlight it.
private void HighlightTextStringIndex(int startIndex, string searchText)
{
int i = 0;
for (int row = 0; row < filenames.Count; row++)
{
if (((string)filenames[row]).Length < startIndex + searchText.Length)
{
i += ((string)filenames[row]).Length + 1;
continue;
}
richTextBox1.Select(i + startIndex, searchText.Length);
if (richTextBox1.SelectedText == searchText)
{
richTextBox1.SelectionBackColor = Color.Yellow;
selections[row].Add(new Tuple<int, int>(startIndex, searchText.Length));
}
i += ((string)filenames[row]).Length + 1;
}
richTextBox1.Select(0, 0);
}
Mark All Strings
This function will simply go through all the lines and mark all of the strings that match the current selection's value.
private void HighlightTextAll(string searchText)
{
Regex regex = new Regex(Regex.Escape(searchText));
int i = 0;
for (int row = 0; row < filenames.Count; row++)
{
MatchCollection matches = regex.Matches((string)filenames[row]);
foreach (Match match in matches)
{
richTextBox1.Select(i + match.Index, match.Length);
richTextBox1.SelectionBackColor = Color.Yellow;
selections[row].Add(new Tuple<int, int>(match.Index, match.Length));
}
i += ((string)filenames[row]).Length + 1;
}
richTextBox1.Select(0, 0);
}
Clear Selection
Clears all highlighting.
private void HighlightClear()
{
richTextBox1.SelectionChanged -= RichTextBox1_SelectionChanged;
richTextBox1.SelectAll();
richTextBox1.SelectionChanged += RichTextBox1_SelectionChanged;
richTextBox1.SelectionBackColor = RichTextBox.DefaultBackColor;
richTextBox1.Select(0, 0);
foreach(int n in selections.Keys)
selections[n].Clear();
}
Apply Changes
ReplaceSelection
replaces all the highlighted text in all the lines with text entered in an input box. InsertBeforeSelection
inserts entered text in front of all the highlighted text in all the lines. DeleteSelection
deletes all the highlighted text in all the lines.
All methods are using a sub-method called ReplaceInsert
, which switches between Replace
/Insert
based on its second argument. DeleteSelection
uses the Replace
functionality with an empty string.
private void ReplaceInsert(string newString, bool replace)
{
int startIndex;
if (selections.Where(x => x.Value.Count > 0).Count() == 0)
{
startIndex = FindSelectionIndex();
foreach (int n in selections.Keys)
selections[n].Add(new Tuple<int, int>(startIndex, 0));
}
int offset;
foreach (int n in selections.Keys)
{
for(int i = 0; i < selections[n].Count; i++)
{
offset = 0;
if (replace)
{
filenames[n] = ((string)filenames[n]).Remove
(selections[n][i].Item1, selections[n][i].Item2);
offset -= selections[n][i].Item2;
}
filenames[n] = ((string)filenames[n]).Insert(selections[n][i].Item1, newString);
offset += newString.Length;
ApplyOffset(n, i, offset);
}
}
FillRTB();
}
private void ApplyOffset(int rowkey, int start, int offset)
{
for(int i = start + 1; i < selections[rowkey].Count; i++)
{
selections[rowkey][i] = new Tuple<int, int>(selections[rowkey][i].Item1 +
offset, selections[rowkey][i].Item2);
}
}
MyInputBox
For the purpose of entering the replacement/insertion string, I have designed a special input box (separate class), which basically consists of only an empty textbox (no surrounding form). The MyInputBox
is shown by calling the static
method ShowDialog
, similar to MessageBox
. It is always shown on current mouse position.
The entered string is confirmed (OK) by pressing ENTER, and it is rejected (Cancel) by pressing ESCAPE.
using System.Drawing;
using System.Windows.Forms;
namespace FileRenamerAdvanced
{
public static class MyInputBox
{
internal class MyInputForm : Form
{
TextBox tbx;
public MyInputForm()
{
this.FormBorderStyle = FormBorderStyle.None;
this.ShowInTaskbar = false;
tbx = new TextBox();
tbx.Width = 200;
tbx.Location = new System.Drawing.Point(0, 0);
tbx.KeyUp += Tbx_KeyUp;
this.Controls.Add(tbx);
this.Size = tbx.Size;
this.BackColor = Color.Magenta;
this.TransparencyKey = Color.Magenta;
}
private void Tbx_KeyUp(object sender, KeyEventArgs e)
{
if (e.KeyCode == Keys.Enter)
{
this.DialogResult = DialogResult.OK;
}
else if (e.KeyCode == Keys.Escape)
{
this.DialogResult = DialogResult.Cancel;
}
}
public new string ShowDialog()
{
base.ShowDialog();
if (this.DialogResult == DialogResult.OK)
return tbx.Text;
return null;
}
}
public static string ShowDialog()
{
MyInputForm form = new MyInputForm();
form.StartPosition = FormStartPosition.Manual;
form.Location = Control.MousePosition;
string retValue = form.ShowDialog();
form.Close();
return retValue;
}
}
}
Selections Dictionary
Highlighted text gets edited, but in order to programatically loop through these highlighted selections, the code must be able to recognize which text exactly is highlighted.
To achieve this, I introduced a Dictionary
type variable called selections
:
private Dictionary<int, List<Tuple<int, int>>> selections =
new Dictionary<int, List<Tuple<int, int>>>();
This dictionary consists of List
objects each consisting of Tuple
objects containing pairs of int
variables – these integers represent start index
(tuple Item1
) and length
(tuple Item2
). Keys of this dictionary are integers that represent the ordinal numbers of rows in the richtextbox
(row=filename
) – so the dictionary is initialized with as many empty lists as there are filenames in richTextBox
.
The idea was to store all the selections in each line of text (each filename) as a list of tuples containing first index and length of selection, i.e.,
For the upper selection, the variable selections will contain:
0:
(6,1)
(17,1)
1:
(7,1)
(18,1)
2:
(6,1)
(17,1)
3:
(6,1)
(17,1)
4:
(6,1)
(17,1)
5:
(6,1)
(17,1)
6:
(6,1)
(17,1)
7:
(6,1)
(17,1)
8:
(6,1)
(17,1)
9:
(6,1)
(17,1)
Manual Edit
There is also the possibility to unlock the richtextbox
and do any kind of manual changes directly to the filenames in there. Of course, this would require then to do the change to each file individually – which is still better than to do it manually file by file in Windows Explorer.
The only limitation to this type of edit is that the number of lines has to be kept the same when closing the manual edit mode – because the lines are mapped to actual files, so the number of lines/files has to be maintained.
private void checkBox1_CheckedChanged(object sender, EventArgs e)
{
if (filenames == null)
return;
if (checkBox1.Checked)
{
HighlightClear();
richTextBox1.ReadOnly = false;
}
else
{
string[] tmp = richTextBox1.Text.Split('\n');
if (tmp.Length == filenames.Count)
{
filenames = new ArrayList(tmp);
deque.Add(new ArrayList(filenames));
richTextBox1.ReadOnly = true;
}
else
{
MessageBox.Show("Expected number of filenames is " +
filenames.Count.ToString(), "Invalid number of filenames",
MessageBoxButtons.OK, MessageBoxIcon.Error);
checkBox1.Checked = true;
}
}
}
Deque - Undo / Redo
The app also supports undo / redo functionality. (The undo / redo refers to all the „apply changes“ actions.)
For the undo / redo functionality to work, we need a sort-of double-sided stack structure (something where we can push and pull elements from each side of the list). Such a structure is called a „deque“ and it does not (still) exist as a built-in class in .NET Framework, so I needed to build one myself for this purpose.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
namespace FileRenamerAdvanced
{
class Deque<T>
{
private LinkedList<T> _list;
private int _index;
public int Capacity { get; set; }
public Deque(int capacity)
{
Capacity = capacity;
_list = new LinkedList<T>();
_index = -1;
}
public void Add(T elem)
{
if (_list.Count == Capacity && _index == Capacity - 1)
{
_list.RemoveFirst();
}
else if(_index < Capacity - 1)
{
ClearAllNext();
}
_list.AddLast(elem);
_index = Math.Min(_index + 1, Capacity - 1);
}
public T GetPreviousElement()
{
_index = Math.Max(_index - 1, 0);
return _list.ElementAt<T>(_index);
}
public T GetNextElement()
{
_index = Math.Min(_index + 1, _list.Count - 1);
return _list.ElementAt<T>(_index);
}
public void ClearAll()
{
_list.Clear();
_index = -1;
}
public void ClearAllNext()
{
while (_list.Count - 1 > _index && _list.Count != 0)
{
_list.RemoveLast();
}
}
public void ClearAllPrevious()
{
while (_index >= 0)
{
_list.RemoveFirst();
--_index;
}
}
}
}
The backbone of a Deque
object is a LinkedList
. The class is initialized with a generic variable type T
, and it takes as parameter the desired capacity
of the Deque
– meaning how many undo objects will it be able to remember.
Elements can be added (pushed), and taken (pulled) from the structure. The structure always remembers the current index – from which point we can go backwards (undo – GetPreviousElement
) or forward (redo – GetNextElement
). GetPreviousElement
will take elements as long as it does not reach the beginning of the list, while the same applies to GetNextElement
, only in the opposite direction.
When adding new elements, they are always added after the current index (active element) – if the list is full and the element is added to the end of the list, then the first element of the list is removed. If the new element is added somewhere in the middle of the list (meaning we backed up a couple of times with undo, then did some other change), all of the redo elements (to the right) that existed before are deleted – redo after that is not possible.
Save Changes
Changes to the filenames are always done only in the richtextbox
and other memory structures used within the code. The actual filenames (files stored on the system) are only changed when you hit the Save changes. When this is done, then whatever is at the moment inside the richtextbox
is applied to the actual files.
Before actually changing the filenames, all of the new filenames in the richtextbox
are checked for illegal characters. If any of them contain illegal characters, they will be marked red before saving. Save changes is disabled if the manual mode is on.
private void SaveChanges()
{
if (MessageBox.Show("Rename " + filenames.Count.ToString() + " files.
Are you sure?", "Save changes", MessageBoxButtons.YesNo,
MessageBoxIcon.Question) == DialogResult.No)
return;
HighlightClear();
if (checkBox1.Checked)
{
MessageBox.Show("Manual mode is on! Please turn it off before saving.",
"Save failed", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
int i = 0;
int cnt = 0;
foreach (string f in filenames)
{
if (f.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
{
richTextBox1.Select(i, f.Length);
richTextBox1.SelectionBackColor = Color.Red;
cnt++;
}
i += f.Length + 1;
}
if (cnt > 0)
{
MessageBox.Show("Filenames marked red contain invalid characters!",
"Save failed", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
try
{
for (i = 0; i < filenames.Count; i++)
File.Move(folder + "\\" + filenames_orig[i], folder + "\\" + filenames[i]);
}
catch (Exception ex)
{
MessageBox.Show("Error:\n" + ex.Message, "Save failed",
MessageBoxButtons.OK, MessageBoxIcon.Error);
}
FillRTB(folder);
}
Shortcut Key Combinations
Each functionaility in the app is also available through a keyboard shortcut – this is making this app very fast to use, once you get used to the key combinations.
Highlighting is done by using Left ctrl+Left shift+letter, while apply changes is done by using Left alt+letter.
CTRL+B = BROWSE
CTRL+Z = UNDO
CTRL+SHIFT+Z = REDO
CTRL+SHIFT+I = MARK ON INDEX
CTRL+SHIFT+S = MARK STRING ON INDEX
CTRL+SHIFT+A = MARK ALL STRINGS
CTRL+SHIFT+C = CLEAR HIGHLIGHT
ALT+R = REPLACE
ALT+I = INSERT
ALT+D = DELETE
ALT+S = SAVE
private void RichTextBox1_KeyDown(object sender, System.Windows.Forms.KeyEventArgs e)
{
if(Keyboard.IsKeyDown(Key.LeftCtrl) && Keyboard.IsKeyDown(Key.B))
{
BrowseFolder();
return;
}
if (filenames == null)
return;
if (Keyboard.IsKeyDown(Key.LeftCtrl) && Keyboard.IsKeyDown(Key.LeftShift) &&
Keyboard.IsKeyDown(Key.Z))
{
Redo();
return;
}
else if(Keyboard.IsKeyDown(Key.LeftCtrl) && Keyboard.IsKeyDown(Key.Z))
{
Undo();
return;
}
else if (Keyboard.IsKeyDown(Key.LeftCtrl) &&
Keyboard.IsKeyDown(Key.LeftShift))
{
string selection = richTextBox1.SelectedText;
int index = FindSelectionIndex();
if (selection != "")
{
if (Keyboard.IsKeyDown(Key.I))
{
HighlightClear();
HighlightTextIndex(index, selection.Length);
return;
}
else if (Keyboard.IsKeyDown(Key.S))
{
HighlightClear();
HighlightTextStringIndex(index, selection);
return;
}
else if (Keyboard.IsKeyDown(Key.A))
{
HighlightClear();
HighlightTextAll(selection);
return;
}
}
else if (Keyboard.IsKeyDown(Key.C))
{
HighlightClear();
return;
}
}
else if(Keyboard.IsKeyDown(Key.LeftAlt))
{
if (Keyboard.IsKeyDown(Key.R))
{
ReplaceSelection();
deque.Add(new ArrayList(filenames));
return;
}
else if (Keyboard.IsKeyDown(Key.D))
{
DeleteSelection();
deque.Add(new ArrayList(filenames));
return;
}
else if (Keyboard.IsKeyDown(Key.I))
{
InsertBeforeSelection();
deque.Add(new ArrayList(filenames));
return;
}
else if (Keyboard.IsKeyDown(Key.S))
{
SaveChanges();
return;
}
}
}
History
- 26th July, 2020: Initial version