あまブログ

ドキドキ......ドキドキ2択クイ〜〜〜〜〜〜〜ズ!!

【Node.js】CLI版メモアプリを作る

この記事では、Node.jsでメモの追加・一覧・参照・削除ができるコマンドラインアプリを作成します。

データの保存先にはJSONファイルを使い、JavaScriptclass構文を使って作成します。

1. 実行環境

2. アプリの要件

以下の機能を持つメモアプリを作成します。

2-1. メモの追加

標準入力に入ってきたテキストを新しいメモとして追加する。

$ echo 'メモの内容' | app.js

2-2. メモの一覧

それぞれのメモの最初の行のみを表示する。

$ app.js -l
メモ11行目
メモ21行目
メモ31行目

2-3. メモの参照

選んだメモの全文が表示される。

$ app.js -r
Choose a note you want to see:
  メモ11行目
  メモ21行目
> メモ31行目

メモ31行目
メモ32行目
メモ33行目

2-4. メモの削除

選んだメモが削除される。

$ app.js -d
Choose a note you want to delete:
  メモ11行目
  メモ21行目
> メモ31行目

Successfully deleted !!

3. ソースコード

app.js

#!/usr/bin/env node

const MemoController = require("./memo-controller");
const argv = require("minimist")(process.argv.slice(2));

class App {
  constructor(argv) {
    this.argv = argv;
  }

  exec() {
    try {
      if (Object.keys(this.argv).length >= 3) {
        throw new Error("Only one option is available.");
      } else if (Object.keys(this.argv).some(this.#isNotOption)) {
        throw new Error("Only options -r, -l and -d are available.");
      } else if (this.argv.l) {
        MemoController.list();
      } else if (this.argv.r) {
        MemoController.select();
      } else if (this.argv.d) {
        MemoController.delete();
      } else {
        MemoController.add();
      }
    } catch (err) {
      console.log(err.message);
    }
  }

  #isNotOption(value) {
    return value != "l" && value != "r" && value != "d" && value != "_";
  }
}

const app = new App(argv);
app.exec();

memo-controller.js

const readline = require("node:readline");
const { once } = require("node:events");
const { prompt } = require("enquirer");
const FileController = require("./file-controller");
const filePath = "./memos.json";

class MemoController {
  static async add() {
    try {
      process.stdin.setEncoding("utf8");
      const lines = [];
      const rl = readline.createInterface({
        input: process.stdin,
      });

      rl.on("line", (line) => {
        lines.push(line);
      });

      await once(rl, "close");

      const fileController = new FileController(filePath);
      const memos = await fileController.read();

      const ids = Object.keys(memos).map((x) => parseInt(x));
      const id = ids.length > 0 ? Math.max(...ids) + 1 : 1;
      memos[id] = lines;

      await fileController.write(memos);
    } catch (err) {
      console.log(err.message);
    }
  }

  static async list() {
    try {
      const fileController = new FileController(filePath);
      const memos = await fileController.read();
      if (!Object.keys(memos).length) {
        throw new Error("Please create at least one note.");
      }

      for (const id in memos) {
        console.log(memos[id][0]);
      }
    } catch (err) {
      console.log(err.message);
    }
  }

  static async select() {
    try {
      const fileController = new FileController(filePath);
      const memos = await fileController.read();
      if (!Object.keys(memos).length) {
        throw new Error("Please create at least one note.");
      }

      const question = {
        type: "select",
        name: "memoId",
        message: "Choose a note you want to see:",
        choices: [],
        result() {
          return this.focused.value;
        },
      };

      const answer = await this.#getAnswer(memos, question);
      const memo = memos[answer.memoId];
      for (const line of memo) {
        console.log(line);
      }
    } catch (err) {
      console.log(err.message);
    }
  }

  static async delete() {
    try {
      const fileController = new FileController(filePath);
      const memos = await fileController.read();
      if (!Object.keys(memos).length) {
        throw new Error("Please create at least one note.");
      }

      const question = {
        type: "select",
        name: "memoId",
        message: "Choose a note you want to delete:",
        choices: [],
        result() {
          return this.focused.value;
        },
      };

      const answer = await this.#getAnswer(memos, question);
      delete memos[answer.memoId];
      await fileController.write(memos);
      console.log("Successfully deleted !!");
    } catch (err) {
      console.log(err.message);
    }
  }

  static async #getAnswer(memos, question) {
    try {
      for (const id in memos) {
        const obj = { name: memos[id][0], message: memos[id][0], value: id };
        question.choices.push(obj);
      }
      return await prompt(question);
    } catch (err) {
      console.log(err.message);
    }
  }
}

module.exports = MemoController;

file-controller.js

const fs = require("node:fs/promises");

class FileController {
  constructor(filePath) {
    this.filePath = filePath;
  }

  async read() {
    try {
      if (!(await this.#exists(this.filePath))) {
        await fs.writeFile(this.filePath, "{}");
      }
      const json = await fs.readFile(this.filePath, { encoding: "utf8" });
      return JSON.parse(json);
    } catch (err) {
      console.log(err.message);
    }
  }

  async write(obj) {
    try {
      const json = JSON.stringify(obj);
      await fs.writeFile(this.filePath, json);
    } catch (err) {
      console.log(err.message);
    }
  }

  async #exists(filePath) {
    try {
      await fs.access(filePath);
      return true;
    } catch {
      return false;
    }
  }
}

module.exports = FileController;

4. ポイント解説

4-1. fsモジュール(JSONファイルの読み書き)

ファイルの読み込み

// file-controller.js
async read() {
  try {
    if (!(await this.#exists(this.filePath))) {
      await fs.writeFile(this.filePath, "{}");
    }
    const json = await fs.readFile(this.filePath, { encoding: "utf8" });
    return JSON.parse(json);
  } catch (err) {
    console.log(err.message);
  }
}
  • ファイルが存在しなければ、await fs.writeFile(this.filePath, "{}")で空のオブジェクトを持つファイルを作成
  • await fs.readFile(this.filePath, { encoding: "utf8" });JSON文字列を取得して、JSON.parse(json);でオブジェクトを返す
// memo-controller.js
const memos = await fileController.read();
  • memosにはオブジェクトが入る

ファイルの書き込み

// file-controller.js
async write(obj) {
  try {
    const json = JSON.stringify(obj);
    await fs.writeFile(this.filePath, json);
  } catch (err) {
    console.log(err.message);
  }
}
  • JSON.stringify(obj);でオブジェクトをJSON文字列に変える
  • await fs.writeFile(this.filePath, json);でファイルを書き換える
// memo-controller.js
await fileController.write(memos);
  • memosオブジェクトを渡して実行

ファイルの存在確認

// file-controller.js
async #exists(filePath) {
  try {
    await fs.access(filePath);
    return true;
  } catch {
    return false;
  }
}

4-2. readlineモジュール

// memo-controller.js
static async add() {
  try {
    process.stdin.setEncoding("utf8");
    const lines = [];
    const rl = readline.createInterface({
      input: process.stdin,
    });

    rl.on("line", (line) => {
      lines.push(line);
    });

    await once(rl, "close");

    const fileController = new FileController(filePath);
    const memos = await fileController.read();

    const ids = Object.keys(memos).map((x) => parseInt(x));
    const id = ids.length > 0 ? Math.max(...ids) + 1 : 1;
    memos[id] = lines;

    await fileController.write(memos);
  } catch (err) {
    console.log(err.message);
  }
}
  • rlが改行(\n)を受け取りlineイベントが発行されるたびにlines.push(line);で標準入力の各行を配列に追加する
  • await once(rl, "close");closeイベントに一度だけ応答し、rlインスタンスが終了する
  • idsにはメモのid(数値)を要素に持つ配列が入る
  • ids.length > 0で配列が要素を持つか判定し、要素があればMath.max(...ids) + 1で最大値に+1をした値を、空なら1をidに格納する

4-3. enquirer

// memo-controller.js
static async select() {
  try {
    const fileController = new FileController(filePath);
    const memos = await fileController.read();
    if (!Object.keys(memos).length) {
      throw new Error("Please create at least one note.");
    }

    const question = {
      type: "select",
      name: "memoId",
      message: "Choose a note you want to see:",
      choices: [],
      result() {
        return this.focused.value;
      },
    };

    const answer = await this.#getAnswer(memos, question);
    const memo = memos[answer.memoId];
    for (const line of memo) {
      console.log(line);
    }
  } catch (err) {
    console.log(err.message);
  }
}

static async #getAnswer(memos, question) {
  try {
    for (const id in memos) {
      const obj = { name: memos[id][0], message: memos[id][0], value: id };
      question.choices.push(obj);
    }
    return await prompt(question);
  } catch (err) {
    console.log(err.message);
  }
}
  • questionオブジェクト(Prompt Options)
    • type: "select"Select Promptを指定
    • name: "memoId"→promtメソッドの戻り値であるanswerオブジェクトのプロパティのキーにmemoIdを指定(answer.memoIdのように使う)
      • promtメソッドはここでいう#getAnswerメソッド内のawait prompt(question)のこと
    • message: "Choose a note you want to see:"→ターミナルに表示されるメッセージ
    • choices: []→choiceオブジェクトを要素に持つ配列、ArrayPromptのプロパティの一つ
    • result()return this.focused.value;とすることで、answer.memoIdの値にchoiceオブジェクトのvalueプロパティの値が入る(参考:Select Prompt - Examples)
      • return this.focused.value;がないとデフォルトでanswer.memoIdの値にはchoiceオブジェクトのnameプロパティの値が入る