GrowiのCLIクライアントを書いた

これは琉大 Advent Calendar 2020の10日目の記事です。

昨日はid:unimarimoさんの天穂のサクナヒメをプレイした感想 でした。

まだまだ琉大アドベントカレンダーは空きがあるので皆さんの参加お待ちしております!!!

growi

さて僕が在籍している研究室では主に僕が使いたかったのでmarkdownで投稿できるwikiサービスのGrowiを使っています。

growi.cr.ie.u-ryukyu.ac.jp

毎週のゼミ資料などもgrowiで公開していて、後輩や自分が過去ログを手軽に参照しやすくしています。

growiの投稿

growiへの投稿はデフォルトでwebエディタがついていて、webブラウザで完結するようになっています。 emacs/vimキーバインドで編集出来るのでまぁ便利なので使っています。

他にもHackMDのOSSバージョンのCodiMDとの連携機能もありこれも便利です。

..........ですが、たまにコンパイルのログや、書いたプログラムをGrowiの記事に書くときに、手持ちのvimから編集したくなるときがあります。 また、書いた資料をローカルにも残しておきたいというときがあります。

当然markdownをローカルでvimで書いて、$cat hoge.md | pbcopyして貼り付ければ終わりなのですが、直接Growiを書き込みに言ってほしい...!!

幸いGrowiにはwebAPIが存在していて、投稿や記事の取得もAPI経由で行うことが可能なので、ここは自分でwebAPIを叩くCLIクライアントを作ってみます。

webクライアントをgolangで書く

ではgrowiのAPIを叩いてローカルからエントリを編集できるクライアントを実装します。 普段ならPerlで書いてしまいますが、他の研究室メンバが利用する可能性があることを考えて配布しやすいgolangを選択しました。

growiはもともとCrowiというmarkdownで書けるwikiをforkしたものなので、growiのAPIはだいたいcrowiのAPIに準じています。 実際にGitHubでcrowi, growiのgolangのライブラリを見てみると、本家crowiに取り込まれたgo-crowi、go-crowiをforkしてgrowiに対応したgo-growiが既に存在しています。

当初はgo-growiを利用していました。 しかし実装を進めるにつれて、pageのcreateかgetで帰ってくるjsonの形式が、go-growiで定義されているものと異なっていることが発覚しました。これはGrowi側で、go-growiが実装された当時からAPIのレスポンスのJSONが変更された為です。

そのため、新しくgo-crowi/go-growiの様なGrowiのクライアントライブラリを実装する必要が出てきました。機能を全てカバーするクライアントライブラリを作成するべきではありますが、今回はgrowiのエントリをcliで編集するという目的を早めに達成したいので、必要最低限のエンドポイントをカバーする方針にします。

ということでクライアントもついでに実装したものがこちらになります。(研究室はmercurialを利用しています)

www.cr.ie.u-ryukyu.ac.jp

コマンド名growsyncははてなブログCLIツールのblogsyncをもじっています。 (当初はgrowiのフルバックアップもCLIで行う予定だった)

growsyncの使い方

$ growsync edit 
# 日報ページを作成し、 $EDITOR に指定しているエディタが立ち上がる
# 保存すると自動でpush

$ growsync Gears
# growiの /Gears に相当するページを編集に行く

一応pullもありますがまだ実装してないです.....

CLIクライアントの設定

作成したgrowsyncはいくつかの初期設定を与える必要があります。 今回は、~/.config/growsync/config.yamlにすべて書く方針にしています。

growi_url: https://growi.cr.ie.u-ryukyu.ac.jp
user_name: AnaTofuZ
token: 
local_root: /Users/anatofuz/Documents/ie-univ-ryukyu/cr-growi
daily_path: /user/anatofuz/note

大体雰囲気を見ればなにを設定していればわかるかと思いますが、local_rootmarkdownをローカルに保存する際のルートディレクトリになります。

daily_pathは、日報をGrowiで管理する場合、ユーザーの名前空間下にページを生成するのが多い為、日報ページのURLをここで指定します。

例えば僕はhttps://growi.cr.ie.u-ryukyu.ac.jp/user/anatofuz/note/2020/12/10の用に/user/anatofuz/note + /y/m/dで日報を書くことが多い為、この/y/m/d以前の部分をdaily_pathに指定しています。

実装に関して

ここからはgolangで実装したやつに関する話です。

growiをAPI経由で操作する

さて投稿に関するAPIを見てみます。 CLIクライアントならgrowi側の最新の情報をpullする機能もいれるべきですが、今回作ったgrowsyncはまだその機能は入っていない為、エントリの投稿に関するものを見てみます。

growiのAPI経由の投稿

growiにAPI経由で投稿する場合は/pages.createエンドポイントにPOSTでコンテンツを投げれば投稿できます。

投稿したいmarkdownjsonpathの値として設定し、ページのパスをpathの値として投げることでGrowiに投稿可能です。

growiのAPI経由のエントリのアップデート

投稿はcreateにPOSTですが、updateの場合は/pages.updateにPOSTする必要があります。

含めるべきJSONは、先程と同様にmarkdownコンテンツ自体のbodyと、作成したページに振られるpage_id、そして各変更に振られるrevision_idを選択します。revison_idは選択したIDの子供としてページが更新される仕様のようです。

作りたいページのパス(タイトル)と、コンテンツのみ知っていれば良かった投稿と違い、アップデートにはpage_idrevision_idをGrowi側から入手する必要があります。 page_idrevison_idをローカルのmarkdownに埋め込んでしまう方法も考えられますが、markdownにメタ情報を埋め込むのはあまり好きではない為、今回は一度GETリクエストでページの情報を入手し、ここからpage_id等を抜き出す方針にします。

ローカルにgrowiのmarkdownファイルを作成する

さて次に問題になるのは、ローカルでgrowiのmarkdownファイルをどう保存するかです。 markdownファイルなのでデータベースなどを使わずにフラットにファイルを置くのはそうなのですが、Growiのタイトルは階層構造を持っているので、これをファイルシステムディレクトリとマッピングしたい気がします。

これもAPI経由で行おうとしましたが、ユーザーの権限によっては見れないページがあることや、ぱぱっと実装してしまいたかったので、以前の記事で紹介した方法を使いフラットなmarkdownファイルを作成しました。

editサブコマンドで投稿/アップデートを両方する

$growi editコマンドは、利便性の問題で新規作成も編集も同じインターフェイスで出来るようになっています。 これは前述のpage_idなどを何れにせよGETして取ってこないといけないためです。

純粋なエラーの場合は動きを止める必要がありますが、ページがない場合は次に叩くAPIupdateからcreateに変えれば良い為、GETした際にまだページがないエラーは個別に見る必要があります。 その為今回は、まずページのチェックとしてGETでページの詳細を入手しに行き、ページがない場合は独自に作成したエラー型を返すようにしています。

editを読んだ場合はまずこれが叩かれます。 page==nilの場合は、ページがなかった場合なのでCreateNewPage、それ以外のケースはUpdateをしに行きます。

func (gClient *growClient) UpdatePage(path string, mdPATH string) error {
  ctx := context.Background()
  markdown, err := ioutil.ReadFile(mdPATH)
  if err != nil {
    return xerrors.Errorf("failed read %s file %+w", mdPATH, err)
  }

  page, err := gClient.IsExistsPageOnGrowi(path)

  if err != nil {
    return err
  }

  if page == nil {
    fmt.Println("[info] create new page", mdPATH)
    return gClient.CreateNewPage(path, mdPATH)
  }

  _, err = gClient.client.Pages.Update(ctx, page.ID, page.Revision.ID, string(markdown))
  return err
}

pages.getに該当する処理の中では、GETレスポンスによって返すエラーを変えています。

  res, err := p.client.newRequest(ctx, http.MethodGet, GET_ENDPOINT, &params)
  if err != nil {
    return nil, xerrors.Errorf("failed get page %+w", err)
  }
  pagesGet := PagesGet{}
  if err := json.Unmarshal(res, &pagesGet); err != nil {
    return nil, err
  }

  if !pagesGet.Ok {
    return nil, ErrorPageNotFOund
  }

  return &pagesGet.Page, nil

実際にエラーを判定しています。ページがない場合は止めずに続行します。

func (gClient *growClient) IsExistsPageOnGrowi(path string) (*client.Page, error) {
  ctx := context.Background()
  page, err := gClient.client.Pages.Get(ctx, path)

  if errors.Is(err, client.ErrorPageNotFOund) {
    return nil, nil
  }

  if err != nil {
    return nil, xerrors.Errorf("failed isExistsPageOnGrowi at %+w", err)
  }
  return page, nil
}

ほか

地味にgrowsync edit Gearsと来た場合はGearsをgrowiのパス表記である/Gearsに変更するなどの機能が入っています。あと拡張子の.mdを取り除くも入ってる。

おもしろポイントとしては編集する前にsha256を計算して、編集後のハッシュと比較を行い、特に違いがなければAPIを叩かずに終了する方針になっています。

コマンドの中からエディタを呼び出す方法は、素朴にexec.Commandした構造体にos.Stdinとかを代入する方法がありましたが、今回はmmvの処理を参考に、ttyを代入する方法でやりました。(どっちが良いかはよくわからない........)

Growi側で更新したファイルを持ってくるpull機能は搭載していないので、そこは早めに搭載したいですね。。。

はい

ということでCLI書いてみました。なかなか便利なのでもう少し完成度を高めてGitHubで公開しようと思います。

明日のアドベントカレンダーid:anatofuzです。