チュートリアル

Markdownでブログコンテンツが書けるCLIクライアントを作ろう!(その3)

ECHOPFのJavaScript SDKを使ってMarkdown記法で書いたドキュメントをブログにアップロードできるソフトウェアを作ります。JavaScript SDKはNode.jsで利用します。

前回まででECHOPFのサーバからデータを取得し、さらに新規記事をアップロードする流れができあがりました。今回はさらに記事の編集と削除、画像アップロード機能を実装します。

記事の編集について

これまでの流れで記事はすべて entries ディレクトリ以下に入るようになっています。例えば次のようになっているはずです。

$ tree entries/
entries/
├── 20171027063553.md
├── 20171126143940.md
└── 20171126143959.md

0 directories, 3 files

このMarkdownファイルの内容を新規登録した時と同様に編集します。

BlogUpdate処理の作成

まず全体の流れを紹介します。これは index.js に記述します。

const BlogUpdate = require('./libs/update');
program
  .command('update [filePath]')
  .description('記事を更新します')
  .action((filePath) => {
    const blogUpdate = new BlogUpdate({
      dir: '.',
      filePath,
    });
    blogUpdate
      .update()
      .then((entry) => {
        console.log(`記事を更新しました。 ${entry.get('url')}`);
        const blogPull = new BlogPull({
          dir: '.',
        });
        console.log('記事一覧を更新します。');
        return blogPull.pull();
      })
      .then(() => {
        console.log('記事を取得しました。');
      })
      .catch((err) => {
        console.log(`エラーが発生しました。${JSON.stringify(err)}`);
      });
  });

処理の流れは BlogUpdate オブジェクトを作成して update メソッドでアップロードを行います。次に BlogPull オブジェクトを生成して更新した記事を含め、全記事をECHOPFのクラウドよりダウンロードします。

コマンドは次のようになります。指定したファイルパスは filePath という変数で取得できます。

$ echopf update [Markdownファイルのパス]

BlogUpdateについて

ブログ記事の更新を行うのは BlogUpdate クラスになります。まず libs/update.js を作成します。基本的な形は新規投稿時と変わりません。

const path = require('path');
const BlogBase = require('./base');

module.exports = (() => {
  class BlogUpdate extends BlogBase {
    constructor(options) {
      super(options);
      this.dir = path.resolve(options.dir);
      this.filePath = path.resolve(`${options.dir}/${options.filePath}`);
    }
    
    // 更新処理を実行します
    update() {
    }
  }
  return BlogUpdate;
})();

BlogUpdate.update 処理について

基本的には記事の新規投稿処理と変わりません。ただし、 entry.refid に対して既存のrefidを適用する必要があります。これによって新規投稿処理ではなく、更新処理になります。

update() {
  return new Promise((res, rej) => {
    const me = this;
    // 認証処理
    this.login()
      .then((member) => {
        me.currentMember = member;
        // 認証処理結果の確認
        if (Object.keys(member).length === 0) {
          throw new Error('認証エラー');
        }
        // ファイルを読み込む
        return this.readFile(this.filePath);
      })
      // ブログのエントリーにします
      .then(contents => me.setContents(contents))
      // refid を適用すると更新処理になります
      .then((entry) => {
        entry.refid = entry.get('refid');
        return entry.push()
      })
      // 処理がうまくいった場合です
      .then((success) => {
        // 完了
        res(success);
      }, (err) =>  rej(err))
  });
}

これで更新処理が完了です。 entries フォルダの中にあるMarkdownファイルを編集して、実際にアップロードしてみましょう。

$ echopf update entries/20171126164915.md
記事をアップロードしました。 http://blogcli.echopf.com/information/entry/20171126164915
記事一覧を更新します。
記事を取得しました。

管理画面でもその内容が確認できるはずです。

記事の削除について

続いて記事の削除です。これは次のようなコマンドで実現します。 delete オプションを使い、削除したい記事のMarkdownファイルを指定します。

$ echopf delete entries/20171126164915.md

全体の処理について

まず index.js の内容です。削除処理を実行して終わります。処理を行うクラスは BlogDelete で、処理は blogDelete.delete にて行います。

const BlogDelete = require('./libs/delete');
program
  .command('delete [filePath]')
  .description('記事を削除します')
  .action((filePath) => {
    const blogDelete = new BlogDelete({
      dir: '.',
      filePath,
    });
    blogDelete
      .delete()
      .then((entry) => {
        console.log(`記事を除しました。`);
      })
      .catch((err) => {
        console.log(`エラーが発生しました。${JSON.stringify(err)}`);
      });
  });

blogDelete クラスについて

全体像は次のようになります。他のクラスと大きく変わりませんが、削除対象のMarkdownファイルを削除するために fs ライブラリを読み込んでおきます。

const path = require('path');
const BlogBase = require('./base');
const fs = require('fs');

module.exports = (() => {
  class BlogDelete extends BlogBase {
    constructor(options) {
      super(options);
      this.dir = path.resolve(options.dir);
      this.filePath = path.resolve(`${options.dir}/${options.filePath}`);
    }

    delete() {
    }
  }
  return BlogDelete;
})();

deleteメソッドについて

そして削除処理を行うdeleteメソッドを紹介します。更新時に実行していたのは entry.put() ですが、削除の際には entry.delete() になります。基本的な認証、ファイルの読み込みとエントリーオブジェクトの作成といった流れは変わりません。

そして entry.delete() がうまくいった後、Markdownファイルを削除 fs.unlink(me.filePath) します。 これで処理は完了です。

delete() {
  return new Promise((res, rej) => {
    const me = this;
    this.login()
      .then((member) => {
        me.currentMember = member;
        // 保存処理
        if (Object.keys(member).length === 0) {
          throw new Error('認証エラー');
        }
        // ファイルを読み込む
        return this.readFile(this.filePath);
      })
      .then(contents => me.setContents(contents))
      .then((entry) => {
        // 削除を行います
        entry.refid = entry.get('refid');
        return entry.delete()
      })
      // 元ファイルを削除
      .then((success) => fs.unlink(me.filePath))
      .then((success) => {
        // 完了
        res(success);
      }, (err) =>  rej(err))
  });
}

実際に記事ファイルを指定して削除処理を実行してみます。

$ echopf delete entries/20171126143940.md
記事を削除しました。

管理画面でも記事がなくなっているのが確認できれば完了です。

画像アップロードについて

ブログで必要なのが画像コンテンツです。ここからはMarkdownの本文を読み込んで、画像指定されている部分についてアップロードを行う処理について解説します。

まず画像アップロードの仕組みについて紹介します。

  1. 専用のデータベースインスタンスを作成する
  2. 画像用のフィールドを作成する
  3. 画像のアップロードとともにレコードを作成する
  4. 作成したレコードの画像フィールドにアクセスするURLを取得する

データベースインスタンスの作成

ECHOPFの管理画面にログインしてデータベースインスタンスを新規作成します。インスタンスIDは image とします。

また、ファイルをアップロードできるフィールドを追加します。データ型をファイルデータとし、名前を file とします。

外部レコード登録設定において、新規登録時ステータスを有効としておいてください。これでデータ追加時にステータスが有効になり、公開設定になります。

全体の流れ

まず index.js に記述する処理全体の流れについて解説します。 BlogImage クラスを作成し、 upload メソッドを使って画像をアップロードします。返ってくるのは画像のURLになります。

const BlogImage = require('./libs/image');
program
  .command('upload [filePath]')
  .description('画像をアップロードします')
  .action((filePath) => {
    const blogImage = new BlogImage({
      dir: '.',
      filePath,
    });
    blogImage
      .upload()
      .then((url) => {
        console.log(`画像をアップロードしました。 ${url}`);
      })
      .catch((err) => {
        console.log(`エラーが発生しました。${JSON.stringify(err)}`);
      });
  });

コマンドラインからでは次のように使います。

$ echopf upload /path/to/image.png

BlogImageクラスについて

BlogImageクラスの概要です。 upload メソッドで画像アップロード処理を実行します。

const path = require('path');
const BlogBase = require('./base');

module.exports = (() => {
  class BlogImage extends BlogBase {
    constructor(options) {
      super(options);
      this.dir = path.resolve(options.dir);
      this.filePath = path.resolve(options.filePath);
    }

    upload() {
    }
  }
  return BlogImage;
})();

uploadメソッドについて

uploadメソッドは次のように実行します。

  1. 認証処理の後、画像ファイルを読み込みます( readBinaryFile メソッド)
  2. 読み込んだらデータベースレコードオブジェクトを作成します( setImage メソッド)
  3. データベースレコードオブジェクトを保存します
  4. レスポンスから画像のURLを取得して返す
upload() {
  return new Promise((res, rej) => {
    const me = this;
    // ログイン処理を行う
    this.login()
      .then((member) => {
        // 保存処理
        if (Object.keys(member).length === 0) {
          throw new Error('認証エラー');
        }
        // ファイルを読み込む
        return this.readBinaryFile(this.filePath);
      })
      // バイナリからレコードを作成
      .then(binary => this.setImage(path.basename(me.filePath), binary))
      // データベースレコードをアップロード
      .then((record) => {
        this.record = record;
        return this.record.push();
      })
      // アップロード成功
      .then((success) => {
        // 完了
        res(`${this.record.get('url')}?get_file=file&array_key=0`);
      }, (err) => rej(err)) // エラー
  });
}

バイナリファイルの読み込み

バイナリファイルは fs ライブラリを使って読み込みます。これは base.js の中に定義します。

// ファイルを読み込みます
readBinaryFile(filePath) {
  return new Promise((res, rej) => {
    fs.readFile(filePath, (err, data) => {
      if (err) return rej(err);
      return res(data);
    });
  });
}

データベースレコードオブジェクトの作成

読み込んだファイルデータとファイル名を引数に、データベースレコードオブジェクトを作成します。 this.config.imageInstanceId という設定を追加しています。これは echopf.config.json の中に imageInstanceId というキーで追加してください。

バイナリファイルのアップロードは ECHOPF.File クラスで行います。そして、そのインスタンスをデータベースレコードオブジェクトの contents に対してセットします。

// バイナリファイルからエントリー作成
setImage(fileName, binary) {
  return new Promise((res, rej) => {
    const record = new this.ECHOPF.Databases.RecordObject(this.config.imageInstanceId);
    record.put('refid', `image-${new Date().getTime()}-${path.extname(fileName).replace('.', '')}`);
    record.put('title', fileName);
    const file = new this.ECHOPF.File(fileName, Buffer.from(binary));
    record.put('contents', {file: file});
    res(record);
  });
}

データベースレコードオブジェクトの保存

保存処理は簡単です。作成したデータベースレコードオブジェクトで push メソッドを実行します。

record.push();

画像のURLを取得する

保存後、画像のURLを取得するのは若干特殊です。以下のようなURLになります。

`record.get('url')}?get_file=file&array_key=0`
// 例
// http://blogcli.echopf.com/image/record/image-1514352233261-png?get_file=file&array_key=0

これで画像アップロード処理が完了します。

新規作成、編集時に画像を自動アップロードする

最後に、Markdown中に書かれた画像ファイル(この時点ではローカルにあるとします)をアップロードする仕組みを作ります。新規作成、編集時両方があると思いますので、どちらも利用する setContents メソッドの中に定義します。

一部抜粋ですが、次のようになります。 uploadImage メソッドを使って、本文中から画像タグ部分を抽出して画像をアップロードします。データベースレコードオブジェクトの保存処理はネットワークを使うので非同期処理であり、概要と本文の2つがありますので Promise.all を使って並列処理としています。そして返ってきた本文は画像がECHOPFのものと置き換わっています。

// ファイルからブログエントリーオブジェクトの生成
setContents(contents) {
  // 省略
  return new Promise((res, rej) => {
      // 省略
    me
      // 省略
      .then((categories) => {
        // Markdown上のカテゴリは削除します
        delete headers.categories;
        // 取得したカテゴリを設定します
        entry.put('categories', categories);
        // 本文から画像部分を抽出してアップロードします
        const bodies = parts[2].split(me.bodySplit);
        const promises = [];
        promises.push(me.uploadImage(bodies[0]));
        promises.push(me.uploadImage(bodies[1]));
        return Promise.all(promises);
      })
      .then((bodies) => {
        // MarkdownからHTMLに変換するコンバータを用意します
        const converter = new showdown.Converter();
        // 記事本文はmainとdetailに分けて設定します
        entry.put('contents', {
          main: converter.makeHtml(bodies[0]),
          detail: converter.makeHtml(bodies[1]),
        });
        
        // 省略
      })
  });
}

uploadImage メソッドです。行単位で画像タグを抽出してBlogImageでアップロード処理を行った後、結果として得られるURLで既存のタグを置き換えます。今回は行頭から画像タグが書かれている前提となっています。注意点として画像アップロード処理はネットワークを使うので非同期になります。そのため for ループで処理してしまうと通信処理が終わる前に関数が終了してしまう可能性があります。そうならないために関数(ここでは loop)で囲み、非同期処理が終わるごとに次のループ処理を呼び出す仕組みになっています。

uploadImage(content) {
  const me = this;
  return new Promise((res, rej) => {
    // コンテンツがない場合は処理なし
    if (!content) return res();
    // 改行で区切って処理します
    const ary = content.split(/\r\n|\r|\n/);
    // ループ処理を関数化します
    const loop = (ary, index) => {
      // 該当行を取得します
      // データがなければ終了です
      const line = ary[index];
      if (typeof line == 'undefined') {
        return res(ary.join("\n"));
      }
      // 行から画像タグを取り出します
      const matches = line.match(/^!\[(.*?)\]\((.*?)\)$/)
      if (!matches) {
        return loop(ary, index + 1);
      }
      // 正規表現にマッチした情報を取り出します
      const alt = matches[1];
      const fileName = matches[2];
      const filePath = `${path.resolve(fileName)}`;
      // ファイルの存在チェックをします
      fs.stat(filePath, (err, stats) => {
        if (err) return rej(err);
        // ファイルがあれば BlogImageクラスのインスタンスを作ります
        const blogImage = new me.BlogImage({
          dir: '.',
          filePath
        })
        // アップロード処理を行います
        .upload()
        .then((url) => {
          // 結果のURLを使って画像タグを作ります
          ary[index] = `![${alt}](${url})`;
          // 次の行を処理します
          loop(ary, index + 1);
        });
      });
    }
    // 最初のループ(1行目)です。
    loop(ary, 0);
  });
}

さらに BlogImageクラスを BlogPostBlogUpdate クラスの中で呼び出すようにします。

constructor(options) {
  super(options);
  this.dir = path.resolve(options.dir);
  this.filePath = path.resolve(`${options.dir}/${options.filePath}`);
  this.BlogImage = options.BlogImage;
}

index.js からオプションとして渡します。以下は BlogUpdateの例です(BlogPostも同じように追加します)。

const BlogUpdate = require('./libs/update');
program
  .command('update [filePath]')
  .description('記事を更新します')
  .action((filePath) => {
    const blogUpdate = new BlogUpdate({
      dir: '.',
      filePath,
      BlogImage // 追加
    });
  });

ここまでの処理で、もしMarkdownファイルの中に ![説明](画像のパス) があったら、ECHOPFのレコードオブジェクトとして画像をアップロードしてMarkdownファイルの中に差し込むという処理が完成しました。画像アップロード処理は総じて面倒なものですが、コマンドラインから意識せずにできるのは便利です。


今回までの流れでブログクライアントとしての機能(新規作成、編集、削除、既存記事の取得)がすべて出来上がりました。Markdownファイルをベースにすることでコンテンツの作成や編集が容易になり、さらにCLIコマンドで作業が完了できるようになります。次回はメールマガジン機能を使ってみたいと思います。

ここまでのコードは echopfcom/Echopf_Blog_CLI at v4 にアップロードしてあります。実装時の参考にしてください。