使用 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" %*
)

最後就可以和運行本地命令一樣,運行自己的腳本了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章