需求:一个文件夹被复制,要求新文件夹的名字合适且不重复。
在 nodejs后端,我在实现文件夹复制的功能时,发现简单的给原名子添加 “的副本” 作为新名字,复制多次的话得到的“...的副本的副本...”特别长,且没有什么意义,于是决定模仿 mac 的文件复制的命名的表现,写了个命名算法。 这个算法的代码实现很简单,难的是理解需求和详细规则(即为什么要这么做)。
关于mac文件重命名的规则
- 每次复制文件,如果不是“的副本”结尾的文件,复制的新文件会加上“的副本( n)”,这里的n是自然数字,不含前导零。括号是指可能有,也可能没有空格和数字
- 如果原文件名是“的副本”结尾,复制出的新文件的名字会加上“ n”。
- 如果原文件名是“的副本 n”,复制出的新文件名结尾的数字会比原名的索引大且向上最接近原名且不和其他文件重名。
- 复制时,如果原文件夹是“的副本”加上有前导0时,会去掉前导0,并应用上一条规则。
- 文件夹视为文件,即创建的新文件名不能和当前文件夹下的文件重名
只要明确了重命名的详细规则,我们就很容易明确如何算法的实现细节。
2019.3.25更新
优化 重写了代码,包装成一个方法,可以自定义后缀名。
/**
* 文件复制的命名算法
* @param {string} oName 被复制的文件的名称
* @param {Array} filenames 目录下的所有文件名数组
* @param {string} suffix 后缀(默认为'的副本')
*/
const getCopyedName = (oName, filenames, suffix = '的副本') => {
let index;
let root; // 词根
let match = new RegExp(`${suffix}( \\d+)?$`).exec(oName);
console.log(match);
// 1. 求出 oName 的 索引值 和 词根(不含后缀和索引的源文件名 )
if (match) {
if (match[1]) index = parseInt(match[1]);
else index = 0;
root = oName.slice(0, match.index);
} else {
index = 0;
root = oName;
}
console.log('被复制文件的词根:', root);
console.log('被复制文件的索引:', index);
// 2. 根据“词根”,获得当前目录下的相同词根 索引列表。纯词根(没有后缀)不在范围内,因为复制的新名字必然有后缀。
const reg = new RegExp(`^${root}${suffix}( [1-9][0-9]*)?$`); // 注意这里要求为非0开始的数字
let indexs = [];
filenames.forEach((item) => {
match = reg.exec(item);
if (match) {
const i = match[1] ? parseInt(match[1]) : 1;
indexs.push(i);
}
})
// indexs 理论上不会有重复的值。如果你的目录可能会有重名的文件,请做“去重”处理。
// 3. 寻找可用的 index
// 从 indexs 找出与 index + 1 相等的值,如果不存在,新的文件名即为 root + suffix + (index+1)
// 如果存在,继续找出 index + 2 的值,直到发现一个 index + n 在 数组中不存在。
indexs.sort((a,b)=> a - b);
console.log('目录下的同词根的文件index(排序):', indexs);
index++;
for (let i = 0, len = indexs.length; i < len; i++) {
if (indexs[i] == index) index++;
else if (indexs[i] > index) break;
}
console.log('复制后的文件名:', root + suffix + ' ' + index);
return root + suffix + ' ' + index;
}
// 测试
const filenames = [
'aa的副本 23', 'aa的副本 003', 'bb', 'aa', 'aa的副本', '的副本', 'aa的副本 12',
'aa的副本 13', 'aa的副本 14'
];
getCopyedName('aa的副本 12', filenames);
下面的内容是很久以前写的,看思路就行了,旧版的代码(不优雅)不要太过在意。
实现
下面是具体的实现,不过是针对文件夹的,其实换成文件也完全没问题。
确定原文件夹的索引
首先获取原文件夹名字,然后定义一个 index,用于确定原文件“xx的副本 n”的 n 的取值。
let regExpName = oFolder.name;
let index;
接着我们确定原文件夹名称的 n 到底是哪个,n 的获取方式如下表。
原文件夹名 | n |
---|---|
xx | 0 |
xx的副本 | 1 |
xx的副本 2 | 2 |
xx的副本 002 | 2 |
... | ... |
注:不会复制出名为“xx的副本 1”的文件夹。
为了读取名字结尾的数字,使用了正则表达式。
if (/的副本( \d+)?$/g.test(oFolder.name)) {
let r = / \d+$/g.exec(oFolder.name);
if (r == null) {
// 文件夹名格式: xx的副本
index = 1;
} else {
// 格式:xx的副本 n (n可以包含前导0)
index = parseInt(r[0]);
regExpName = oFolder.name.slice(0, r.index);
}
} else {
// 格式:xx
regExpName = regExpName;
index = 0;
}
通过正则表达式,已经知道了原文件夹的名字符合的是哪一种情况,并给 index 赋予了正确的值。这里还对 regExpName 进行操作,使其为“的副本”结尾,以方便接下来的查询其他文件夹的操作作为正则表达式的一部分。
得到符合“xx的副本 n”的所有文件夹名字
接下来是找出原文件夹所在文件夹系的所有文件夹中,符合 /^${regExpName}的副本( [1-9][0-9]*)?$/
的所有名字,正则表达式的意思是要求符合“xx的副本”或者“xx的副本 n”(注意这个 n 是不含前导0的数字)。
let checkedNameFolders = await models.Folder.findAll({
where: {
parentId: oFolder.parentId,
name: {
$regexp: `^${regExpName}的副本( [1-9][0-9]*)?$`
}
}
});
let checkedName = checkedNameFolders.map(item => {
let name = item.name;
let e = /\d+$/g.exec( name );
if(e == null) return 1;
return parseInt( e[0] );
});
// 去重并排序
checkedName = [...new Set(checkedName)].sort((a, b) => a - b);
这里我是用了 sequelize 获取了 数据库中所有名字符合该正则表达式的文件夹对象,取得了文件夹对象的 name,并判断 name 的结尾是否有数字,有数字的话,就提取这个成数字,放到 checkedName 数组内,没有的话就返回1。最后我们对这个数组进行去重和升序,理论上去重操作是不需要的,但谁知道数据库里面会发生什么事情呢。不管怎样,稳妥起见做个去重。
循环找出可以使用的索引
let newIndex = 1;
for (let i = index + 1; ;i++) {
if (!checkedName.includes(i)) {
newIndex = i;
break;
}
}
我们会从原文件夹的索引+1后,进行递增并判断该数组里时候含有这个值,一旦发现没有,就确定了我们的复制文件夹索引,停止循环。(理论上这里的算法是可以优化的,因为我们之前已经给数组排序了,而inclues方法每次都要遍历数组效率并不高,在文件数量很多的情况下可能不好使了。)
根据确定后的可用索引映射回文件名
根据我之前给出的表格,从索引得出最终的名字。
let newName;
switch (newIndex) {
case 1:
newName = `${regExpName}的副本`;
break;
default:
newName = `${regExpName}的副本 ${newIndex}`;
break;
}
到了这里,我们就获得了想要的新文件夹的名字了。