チュートリアル

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

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

前回まででECHOPFのサーバからデータを取得する流れができあがりました。今回は新しい記事を書くための下準備と、クラウドにアップロードするまでの流れを作ります。

新規記事のベースを作成する

新しい記事はMarkdown記法で作成します。その際、イチからファイルの内容を作るのは手間なので、前回作った記事の保存処理を流用してテンプレートを生成できるようにします。

全体の流れ

まず index.js に作成する処理の流れになります。 BlogNew オブジェクトを作成して generate メソッドで記事テンプレートを生成します。

const BlogNew = require('./libs/new');
program
  .command('new')
  .description('新しい記事のベースを作成します')
  .action(() => {
    const blogNew = new BlogNew({
      dir: '.',
    });
 
    blogNew
      .generate()
      .then(() => {
        console.log(`記事を生成しました。./${blogNew.entry.refid}.md`);
      });
  });

BlogNewの作成

BlogNew は libs/new.js として作成します。これも BlogPull と同様に BlogBase を継承します。

const BlogBase = require('./base');
 
module.exports = (() => {
  class BlogNew extends BlogBase {
    constructor(options) {
      super(options);
      this.dir = path.resolve(options.dir);
    }
 
    // ブログ記事のテンプレートを生成
    generate() {
    }
  }
  return BlogNew;
})();

generate メソッド作成

generate メソッドはまずブログのエントリーオブジェクトを作成します。

this.entry = new this.ECHOPF.Blogs.EntryObject(this.config.blogInstanceId);

そしてユニークなIDであるReference IDを作成します。これはECHOPFのルールに従って、 年月日時分秒 で作成します。デフォルトのJavaScriptでは日付を扱うのが若干手間なので、 strftime というライブラリを使うことにします。まず npm コマンドでライブラリをインストールします。

$ npm install strftime --save

このライブラリを new.js で読み込みます。

const strftime = require('strftime');

そして先ほど生成したエントリーオブジェクトに適用します。

this.entry.refid = strftime('%Y%m%d%H%M%S');

後は日付や本文、カテゴリのベースを適用します。

this.entry.put('published', new Date());
this.entry.put('categories', []);
this.entry.put('contents', { main: '', detail: '' });

これらが終わったら、ファイルとして保存します。配列にしているのは saveEntries が配列で適用する形になっているためです。

const entries = [this.entry];
return this.saveEntries(entries);

これでテンプレートの保存が完了しました。 generate メソッドの内容は次のようになります。

generate() {
  this.entry = new this.ECHOPF.Blogs.EntryObject(this.config.blogInstanceId);
  this.entry.refid = strftime('%Y%m%d%H%M%S');
  this.entry.put('published', new Date());
  this.entry.put('categories', []);
  this.entry.put('contents', { main: '', detail: '' });
  const entries = [this.entry];
  return this.saveEntries(entries);
}

コマンドの実行

ではコマンドを実行してみます。

$ echopf new
記事を生成しました。./20171126164915.md

このように出力されれば完成です。なお、このファイルの内容は次のようになります。

---
refid: 20171126164915 
title: 
description: 
keywords: 
robots: 
link_status: 
owner: 
published: Sun Nov 26 2017 16:49:15 GMT+0900 (JST)
categories: 
---
 
 
<!--more-->

後は記事を自由に書いていくだけです。 <!--more--> より前が概要、後ろが本文になります。

ブログ記事の投稿処理

ブログ記事を書き終えたら、アップロード処理を作っていきます。

全体の流れについて

先ほどと同様に、まず index.js の内容を紹介します。

const BlogPost = require('./libs/post');
program
  .command('post [filePath]')
  .description('記事を新規投稿します')
  .action((filePath) => {
    const blogPost = new BlogPost({
      dir: '.',
      filePath,
    });
    blogPost
      .post()
      .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)}`);
      });
  });

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

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

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

BlogPostについて

BlogPost の概要は次のようになります。こちらも BlogBase オブジェクトを継承します。

const path = require('path');
const BlogBase = require('./base');
 
module.exports = (() => {
  class BlogPost extends BlogBase {
    constructor(options) {
      super(options);
      this.dir = path.resolve(options.dir);
      this.filePath = path.resolve(options.filePath);
    }
 
    // ブログの投稿処理を行います
    post() {
    }
  }
  return BlogPost;
})();

post メソッドは次のようになります。アップロード処理は事前にログインを行う点がこれまでと異なります。

post() {
  return new Promise((res, rej) => {
    const me = this;
 
    // ログイン処理を行う
    this.login()
      .then((member) => {
        if (Object.keys(member).length === 0) {
          throw new Error('認証エラー');
        }
        // ファイルを読み込む
        return this.readFile(this.filePath);
      })
      // Markdownの内容をブログエントリーオブジェクトに展開
      .then(contents => me.setContents(contents))
      // ブログエントリーをアップロード
      .then((entry) => entry.push())
      // アップロード成功
      .then((success) => {
        // 完了
        res(success);
      }, (err) =>  rej(err)) // エラー
  });
}

ログイン処理について

ブログへの記事アップロードは権限が必要になりますので、事前にログイン処理を行う必要があります。これは ECHOPF.Members.login というメソッドで行います。引数はメンバーインスタンスのID、ログインIDそしてパスワードです。

以下の login メソッドは今後繰り返し使いますので base.js 内に定義します。

login() {
  return this.ECHOPF.Members.login(
    this.config.memberInstanceId,
    this.config.login_id,
    this.config.password
  );
}

認証が終わると認証された会員オブジェクトが返ってきます。

ファイルの読み込み

指定されたMarkdownファイルの読み込みはNode.jsの標準機能を使います。こちらも今後使っていきますので base.js 内に定義します。こちらは取り立てて書くことはありませんが、デフォルトのコールバック型ではなく、Promiseオブジェクトを返すようにしています。

readFile(filePath) {
  return new Promise((res, rej) => {
    fs.readFile(filePath, 'utf-8', (err, data) => {
      if (err) return rej(err);
      return res(data);
    });
  });
}

エントリーオブジェクトの作成

読み込まれたMarkdownファイルの内容からブログエントリーオブジェクトを作ります。これは難しい処理ではありませんが、若干長くなります。ヘッダー情報部と、コンテンツ部の切り出しと、ACLの設定が主な処理になります。

ECHOPFに投稿する際にはMarkdownではなくHTMLにする必要があります。そこで Showdown というライブラリを使います。まず、これを npm でインストールします。

$ npm install showdown --save

そして base.js の中で読み込みます。

const showdown = require('showdown');

また、ヘッダー部はYAML記法で定義していますので、それを読み込むための js-yaml というライブラリをインストールします。

$ npm install js-yaml --save

こちらも base.js の中で読み込みます。

const yaml = require('js-yaml');

コンテンツの設定

エントリーオブジェクトを作成する setContents ではカテゴリについても処理します。例えばカテゴリはMarkdownの中で次のように定義します。

categories: 
  - 技術系
  - バージョンアップ
  - テスト

これらの名前でECHOPFのクラウド上にすでに存在するかチェックし、なければ作成します。

カテゴリの取得

まずECHOPF上のカテゴリを取得します。これは ECHOPF.ContentsCategoriesMap オブジェクトを使います。この処理は base.js に記述します。

// 既存のカテゴリを取得します
getCategories() {
  return new Promise((res, rej) => {
    const categoriesMap = new this.ECHOPF.ContentsCategoriesMap(this.config.blogInstanceId);
    categoriesMap
      .fetch()
      .then((categories) => {
        res(categories);
      }, (err) => {
        rej(err);
      })
  });
}

カテゴリの作成

次にMarkdown内に設定されたカテゴリ名(配列。ここでは ary に入っているものとします)と既存カテゴリの比較を行います。カテゴリがすでにあればそれを使い、なければ ECHOPF.ContentsCategoryObject インスタンスを作成してカテゴリ情報をセットします。なお、新しいカテゴリの refid は cat-(マイクロセコンド) という形で作成しています。最後にまとめて Promise.all でカテゴリを作成します。

// カテゴリの存在チェック。なければ作成します
createCategories(categories, ary) {
  return new Promise((res, rej) => {
    const me = this;
    // 新規作成するカテゴリが入ります
    const promises = [];
    // 最後にカテゴリはすべてこの配列に入れます
    const echopfCategries = [];
    // Markdown上のカテゴリについて順番に確認します
    for (let i = 0; i < ary.length; i += 1) {
      let name = ary[i];
      let existCategory = null;
      // 既存のカテゴリ名との一致を確認します
      for (let j = 0; j < categories.children.length; j += 1) {
        let category = categories.children[j];
        if (category.node.get('name') === name) {
          existCategory = category;
        }
      }
      if (existCategory) {
        // 既存カテゴリにある場合
        echopfCategries.push(existCategory);
      }else{
        // 既存カテゴリにない場合
        const newCategory = new me.ECHOPF.ContentsCategoryObject(me.config.blogInstanceId);
        newCategory.put('name', name);
        newCategory.put('refid', `cat-${new Date().getTime()}`);
        promises.push(newCategory.push());
      }
    }
    // 存在しないカテゴリ名をまとめて作成します
    Promise
      .all(promises)
      .then((ary) => {
        // 作成完了した場合
        for (let i = 0; i < ary.length; i += 1) {
          ary[i].refid = ary[i].get('refid');
          echopfCategries.push(ary[i]);
        }
        res(echopfCategries);
      }, (err) => rej(err))
  });
}

setContents 処理では上記のカテゴリ処理を実行し、その後ヘッダー部の処理とブログ記事本文の処理を行います。

// ファイルからブログエントリーオブジェクトの生成
setContents(contents) {
  const me = this;
  const entry = new this.ECHOPF.Blogs.EntryObject(this.config.blogInstanceId);
  return new Promise((res, rej) => {
    const parts = contents
      .match(/---\n([\s\S]*)\n---\n([\s\S]*)$/);
    // ヘッダー部をYAMLとして読み込みます
    const headers = yaml.safeLoad(parts[1]);
    me
      // 既存のカテゴリを取得します
      .getCategories()
      .then((categories) => {
        // 既存のカテゴリとMarkdown上のカテゴリを比較します
        return me.createCategories(categories, headers.categories);
      })
      .then((categories) => {
        // Markdown上のカテゴリは削除します
        delete headers.categories;
        // 取得したカテゴリを設定します
        entry.put('categories', categories);
        // ヘッダーを設定します
        for (let header in headers) {
          let value = headers[header];
          switch (header) {
          case 'published':
            entry.put(header, new Date(value));
            break;
          default:
            entry.put(header, value);
          }
        }
        // 日付を設定します
        const d = new Date();
        entry.put('modified', d);
        entry.put('created', d);
        // 記事本文の処理です
        const bodies = parts[2].split(me.bodySplit);
        // MarkdownからHTMLに変換するコンバータを用意します
        const converter = new showdown.Converter();
        // 記事本文はmainとdetailに分けて設定します
        entry.put('contents', {
          main: converter.makeHtml(bodies[0]),
          detail: converter.makeHtml(bodies[1]),
        });
        // 権限を設定します
        const acl = new me.ECHOPF.ACL();
        // get, list, edit, deleteの順
        acl.putEntryForAll(new me.ECHOPF.ACL.Entry(true, true, false, false));
        entry.setNewACL(acl);
        res(entry);
      })
  });
}

処理が完了するとエントリーオブジェクトである entry を返しますので、これを保存する push メソッドを実行します( post.js 内)。

// ブログエントリーをアップロード
.then((entry) => entry.push())

これでアップロード処理が完了です。先ほど作成したMarkdownファイルを編集して、実際にアップロードしてみましょう。

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

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

また、新しく追加したカテゴリも作成されているのが確認できるでしょう。


今回までの流れでブログ記事の新規作成と、そのアップロード処理が完成しました。Markdownファイルをベースにすることでコンテンツの作成が容易になるでしょう。次回はブログ記事の編集と更新、そしてブログ記事の削除までの処理を作ってみます。

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