使用 Nodejs 编写命令行工具

最近在做个人文件整理,整理时遇到一个小问题,就是要如何做到同一个文件夹下的同类型文档批量重命名并按指定格式编号?系统自带的批量重命名肯定不行,因为格式定死了,不符合我作为一个强迫症晚期患者的审美,这样一来貌似就只能靠批处理或 shell 脚本来解决了。然而仔细一想,发现事情并不简单,单纯使用批处理或者 shell 来编写这样一个脚本,复杂度还是不低的。正当我犹豫是花时间去写这样一个脚本还是老老实实一个一个改的时候,突然记起来 Nodejs 也可以用来写脚本,而且用 javascript 来写的话,复杂度也能降低不少。经过一番尝试,发现的确可行,但是在参数处理这一块还是有很大问题,格式规定、错误处理等等,秉承着准完美主义者的优良作风,我肯定不能善罢甘休,于是便在网上查找相关内容,最终找到一个叫做 commander 的插件,折腾半天后终于做出了我理想中的效果

commander 安装和使用

commander 项目地址:commander.js

通过npm安装后直接require引用:

$ npm install commander
const program = require('commander');

我觉得插件的官方文档内容编排有点乱,总结下来其实最关键的用法就两个:

用法1

第一个是单纯使用option然后给定一个或者多个参数,然后通过program.optionprogram.args来判断参数并执行命令。

program
  .version('1.0.0')
  .option('-a, --option-a <required>|[optional] [argu2] [argu3] [argu4]', 'Description a', format, default)
  .option('-b, --option-b', 'Description b')
  .option('-c, --option-c', 'Description c')
  .parse(process.argv);

每个option可以有4个参数:

  • 第一个参数指定命令的选项,包含一个简化和一个完整形式,其后可以接一个或多个选项参数,注意的是这些参数必须要写在<>(必须)和[](可选)中,才能通过命令获取这些参数;
  • 第二个参数是关于这个选项的描述;
  • 第三个参数是对选项参数的格式化,是一个可选的函数,比如使用parseInt就是把选项的参数转为整数形式;
  • 最后一个参数是选项参数的默认值。

然后可以通过program.optionAprogram.optionBprogram.optionC获取这些选项,如果没有指定选项参数,那么这个值就是一个布尔值,即是否使用了这个选项;如果指定了选项参数,那么这个值就是第一个选项参数,额外的选项参数会被传入program.args数组。考虑到复杂性,建议设计选项时,每个选项最多一个选项参数,方便后续操作。

插件会默认帮我们生成命令的帮助信息,通过-h--help调用,在代码里面可以通过program.help()调用,这个帮助信息是可以自定义的,不过一般没特殊需求的话,使用默认的就好了,记得在代码最后面加上:

if (!program.args.length) program.help();

这样写的好处是可以在直接输入命令不指定选项的情况下,直接显示帮助信息,从而更接近本地原生命令的用法。

用法2

第二个是使用command实现子命令:

program.version('1.0.0');
program
  .command('command <argu>|[argu]')
  .alias('cmd')
  .description('Command description')
  .option('-a, --option-a <required>|[optional] [argu2] [argu3] [argu4]', 'Description a', format, default)
  .option('-b, --option-b', 'Description b')
  .option('-c, --option-c', 'Description c')
  .action((argu, opts) => {
    // argu: 命令的直接参数
    // opts: 通过 opts.optionA、opts.optionB 等来获取选项
    if (!opts.optionA) {
      console.warn('Please specify...\n');
      opts.help();
    }
    // ...
  });
program.parse(process.argv);

通过command用来实现命令的子命令,类似于git add这种,方便于对复杂命令的统一管理,需要注意的东西基本和用法1一致,不过这里建议在.action()里面指定每个选项的处理,比如在上述代码中,通过判断选项是否存在输出提示信息和子命令的帮助信息,这样做也是为了更接近本地原生命令。

其他功能

掌握上面两种用法,基本就能满足脚本命令编写的大部分需求了,至于插件的其他功能,这里仅仅通过列表展示出来,详细的内容请阅览插件文档。

  • 可以通过.command()使用相同目录下的脚本文件作为子命令;
  • 可以使用正则表达式作为optionformat
  • 可以使用[argu...]作为可变参数;
  • 可以自定义选项参数的语法;
  • 可以自定义命令帮助内容;
  • 自定义命令的版本信息;

实例

下面的代码就是我最开始所需要的批量重命名的脚本:

const fs      = require('fs');
const path    = require('path');
const colors  = require('colors');
const program = require('commander');

function file_traverse(dir, cb, recur) {
  fs.readdir(dir, (err, files) => {
    if (err) throw err;
    let counter = 0;
    for (let file of files) {
      if (fs.statSync(path.resolve(dir, file)).isDirectory()) {
        recur && file_traverse(path.resolve(dir, file), cb);
      } else {
        cb(path.resolve(dir, file), counter++);
      }
    }
  });
}

function rename_number(dir, opts) {
  let new_number = new_filename = new_file = '';
  file_traverse(dir, (file, counter) => {
    new_number = opts.before + (counter + (+opts.number)) + opts.after;
    new_filename = opts.fileName.replace('[n]', new_number);
    new_file = path.join(path.dirname(file), new_filename);
    try {
      fs.renameSync(file, new_file);
      console.log(("处理结果: " + file + " => " + new_file).green);
    } catch(err) {
      console.log(err.red);
    }
  }, opts.recursive);
}

function rename_replace(dir, opts) {
  let new_filename = new_file = '';
  file_traverse(dir, (file) => {
    new_filename = path.basename(file).replace(new RegExp(opts.target, 'g'), opts.substitute);
    new_file = path.join(path.dirname(file), new_filename);
    try {
      fs.renameSync(file, new_file);
      console.log(("处理结果: " + file + " => " + new_file).green);
    } catch(err) {
      console.log(err.red);
    }
  }, opts.recursive);
}

function handle_error(message) {
  console.warn(message.red);
  process.exit(1);
}

function string2num(str) {
  return +str;
}

program.version('1.0.0');

program
  .command('number <dir>')
  .alias('num')
  .description('rename and add serial number to files')
  .option('-f, --file-name <name>', 'specify a new filename which contains both suffix and a flag "[n]"')
  .option('-r, --recursive', 'whether rename files recursively', false)
  .option('-n, --number [number]', 'specify a serial number to replace flag, default from "1"', string2num, 1)
  .option('-b, --before <before>', 'specify content before the number', '')
  .option('-a, --after <after>', 'specify content after the number', '')
  .action((dir, opts) => {
    if (!opts.fileName) {
      console.warn('Please specify a filename\n'.yellow);
      opts.help();
    } else if (opts.fileName.indexOf('[n]') < 0) {
      handle_error('Please specify the flag "[n]"');
    }
    rename_number(dir, opts);
  });

program
  .command('replace <dir>')
  .alias('rep')
  .description('replace target string in files')
  .option('-r, --recursive', 'whether rename files recursively', false)
  .option('-t, --target <target>', 'specify the target string to replace in the filename')
  .option('-s, --substitute <substitute>', 'specify the substitute')
  .action((dir, opts) => {
    if (!opts.target) {
      console.warn('Please specify target string\n'.yellow);
      opts.help();
    } else if (opts.substitute === 'undefined') {
      console.warn('Please specify substitute\n'.yellow);
      opts.help();
    }
    rename_replace(dir, opts);
  });

program.parse(process.argv);
if (!program.args.length) program.help();

脚本写好之后还不能直接调用,必须通过node rename.js xxx这种方式调用,想要像本地命令那样直接调用,还需要做一些工作。

首先运行命令找到本地npm全局安装位置:

$ npm root -g

比如我的在“C:\Users\NickHopps\AppData\Roaming\npm\node_modules”,则把写好的脚本项目复制到这个文件夹里面,然后返回上一层,新建两个文件:renamerename.bat,对应 Linux 的 bash 和 Windows 的 cmd,文件名即是想要调用的命令的名称,文件的具体内容如下:

#!/bin/sh
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")

case `uname` in
    *CYGWIN*) basedir=`cygpath -w "$basedir"`;;
esac

if [ -x "$basedir/node" ]; then
  "$basedir/node"  "$basedir/node_modules/rename/rename.js" "$@"
  ret=$?
else
  node  "$basedir/node_modules/rename/rename.js" "$@"
  ret=$?
fi
exit $ret
@IF EXIST "%~dp0\node.exe" (
  "%~dp0\node.exe"  "%~dp0\node_modules\rename\rename.js" %*
) ELSE (
  @SETLOCAL
  @SET PATHEXT=%PATHEXT:;.JS;=;%
  node  "%~dp0\node_modules\rename\rename.js" %*
)

最后就可以和运行本地命令一样,运行自己的脚本了。

点赞

发表评论

电子邮件地址不会被公开。