watermint.org - Takayuki Okazaki's note

Jekyllサイトにタグとミニマイズを

このサイトをJekyllに移行したことでメンテナンスはかなり簡単になりました。Github Pagesのホスティングを使っているのでJekyllで構築したサイトも自動生成してくれます。

ただ、Github Pagesで利用できるJekyllプラグインには制限があり、利用したかったタグごとのページ生成プラグインや、ミニマイズのプラグインは利用できませんでした。仕方なしとは思っていたのですが、タグごとに記事がまとまっているURLがあったほうが記事を紹介する際にも便利ですからGithub Pages側でHTML生成してもらうのではなく、あらかじめ手元でHTMLを生成することにしました。

Docker環境

HTML生成のためにプラグインなどを導入済みのDocker環境を作っておきます。

FROM jekyll/jekyll:stable

RUN apk add --no-cache --virtual build-dependencies build-base
RUN apk add --no-cache libxml2-dev libxslt-dev
RUN apk add --no-cache ruby-dev curl-dev zlib-dev yaml-dev
RUN gem install nokogiri
RUN gem install minima
RUN gem install jekyll-import
RUN gem install jekyll-minifier
RUN gem install jekyll-tagging
RUN gem install jekyll-paginate

ENTRYPOINT ["jekyll", "build", "--config", "/srv/jekyll/source/_config.yml,/srv/jekyll/source/_config_prd.yml", "--destination", "/srv/jekyll/release", "--source", "/srv/jekyll/source"]

なお、ディレクトリ構成は次のようなイメージです。必要に応じて読み替えてください。

  • サイトルート
    • source … Jekyllのソース一式
    • release … Github PagesにアップロードするHTML一式
    • staging … 表示確認用HTML生成先

表示確認用にjekyllをserveで起動するには最後の行を下記のように変えたイメージを準備しておきます。

ENTRYPOINT ["jekyll", "serve", "--config", "/srv/jekyll/source/_config.yml,/srv/jekyll/source/_config_dev.yml", "--watch", "--destination", "/srv/jekyll/staging", "--source", "/srv/jekyll/source"]

設定ファイル_config.ymlは共通のものと、表示確認用のもので分離しています。これはMinifierは実行にそれなりに時間がかかるプラグインを外したり、デバッグ用にURLを切り替えるなどしています。 ちなみにMinifierを有効化した際、本サイトのデータを手元のMacBook Pro (Late 2016、Core i7)で実行してMinifierがない場合と比べ1分程度の追加時間が必要となります。

タグの有効化

jekyll-taggingプラグインを利用します。プラグインの設定とjekyll-taggingへの設定は下記のようなイメージです。ハマりどころとして、jekyll-taggingは jekyll/taggingとスラッシュで区切ります。他のプラグインはだいたいjekyll-paginateのようにハイフン区切りなのでそれに倣ってしまいがちです。。

plugins:
  - jekyll-paginate
  - jekyll-sitemap
  - jekyll/tagging

tag_page_layout:          tagpage
tag_page_dir:             tags
tag_permalink_style:      pretty

あとはjekyll-taggingの説明にあるように_plugins/ext.rbへプラグイン指定追加、_layouts/tag_page.htmlを作成して完成です。

Dropbox API: チーム全体のフォルダ・ファイル一覧を取得する

Dropbox BusinessではAPIを通じてチーム内のユーザーについてファイル・フォルダ一覧を取得したり、更新することができます。これは、マルウェア対策システムがファイルを検査・隔離するために利用したり、ワークフローや生産性向上ツールが自動的にファイルを仕分けするといった用途を想定していると考えられます。なお便利な一方、非常に強力な権限となるため取り扱い上の注意は管理者パスワードなどと同様厳重に管理する必要があることは言うまでもありません。

このようにチーム全体のファイルを扱う場合にはDropbox APIの概要のBusiness Endpointsでご紹介したTeam member file access権限を用います。この権限を使うアプリケーションを作成する手順などは該当記事をご参照ください。

チームメンバーのファイルやフォルダを操作するには大きく分けて二種類の方法があります。

  1. チームメンバーの代理として実行
  2. 管理者として実行

チームメンバーの代理として実行する方法では、指定したユーザーが所有しているファイルやフォルダ、参加している共有フォルダ・チームフォルダなどへアクセスすることができます。 管理者として実行する場合にはチーム内すべてのファイル・フォルダへアクセスすることができます。

使い分けとして、たとえばワークフローアプリケーションなどで承認されたドキュメントを所定フォルダに移動するといった用途であれば、承認者の権限で承認者が所有フォルダに移動するといった場合には代理での実行が適切でしょう。マルウェアのスキャンや監査といったチーム全体を対象としたい場合には管理者としての実行が適切でしょう。

チームメンバーの代理として実行

チームメンバーの代理として実行する場合にはまずteams/members/listでアカウント一覧を取得します。User Endpointに対してAPIコールする際にHTTPヘッダDropbox-API-Select-User: チームメンバーIDを追加すると、その該当アカウントとして処理が行われます。

たとえばfiles/list_folderを代理として実行する場合には次のように呼び出します。

curl -X POST https://api.dropboxapi.com/2/files/list_folder \
  --header 'Authorization: Bearer 認証トークン' \
  --header 'Content-Type: application/json' \
  --header 'Dropbox-Api-Select-User: チームメンバーID' \
  --data '{"path":""}'

チーム管理者として実行

チーム管理者として実行する場合にはDropbox-API-Select-Userヘッダの代わりにDropbox-API-Select-Adminヘッダを用います。また、チームメンバーIDにはチーム管理者のメンバーIDを指定します。 Dropbox-API-Select-Adminヘッダを用いた場合、チーム内のネームスペースすべてにアクセス可能となりますがAPIによって二つのモードがあり可視範囲が変わります。

一つ目はWhole Teamと呼ばれるモードです。主に参照系APIが該当します。該当APIはAPIドキュメントにてAUTHENTICATIONにDropbox-API-Select-Admin (Whole Team)と記載されています。Whole Teamモードではメンバーのプライベートファイルを含むすべてのファイルにアクセスすることができます。

もう一つはTeam Adminと呼ばれるモードです。主に更新系APIが該当します。該当APIはAPIドキュメントにてAUTHENTICATIONにDropbox-API-Select-Admin (Team Admin)と記載されています。Team Adminモードではメンバーのプライベートファイルにはアクセスできません。

チーム管理者として実行する際の流れは大まかに次のようになると思います。

  1. 実行するチーム管理者のメンバーIDを取得
  2. チームフォルダ一覧やネームスペース一覧を取得
  3. 各ネームスペースについて処理

まずチームメンバーの代理として実行する場合と同様にteams/members/listでアカウント一覧を取得しチーム管理者のメンバーIDを取得します。アプリケーション用に専用のアカウントが用意できるようであればアプリケーションにあらかじめメンバーIDを設定しておくといった運用も可能でしょう。

次にチームフォルダ一覧やネームスペース一覧を取得します。チームフォルダ一覧はteam/team_folder/list、ネームスペース一覧はteam/namespaces/listで取得できます。ワークフロー処理などチームフォルダ内で処理が完結する場合はteam/team_folder/list、チーム全体のファイルを監査するといった処理の場合はネームスペース一覧から処理を始めるとよいでしょう。

処理したいチームフォルダやネームスペースのネームスペースIDを取得します。なお、チームフォルダIDはそのままネームスペースIDとして利用できます。たとえば、team/team_folder/listのレスポンスが次のような場合、ネームスペースIDは123456789となります。

{
    "team_folders": [
        {
            "team_folder_id": "123456789",
            "name": "Marketing",
            "status": {
                ".tag": "active"
            },
            "is_team_shared_dropbox": false
        }
    ],
    "cursor": "ZtkX9_EHj3x7PMkVuFIhwKYXEpwpLwyxp9vMKomUhllil9q7eWiAu",
    "has_more": false
}

ネームスペースを使ってファイル一覧を取得する

パスとネームスペースの扱いについては以前の記事Dropbox API: ネームスペースとパスの表現にてご紹介いたしました。こちらでご紹介したとおり、ns:ネームスペースID/パスといった形式でパス指定すれば通常のファイル一覧取得と同じようにファイル一覧を取得することができます。

たとえばチームフォルダ(ネームスペースID = 123456789)のトップフォルダからファイル一覧を取得するには次のように実行します。

curl -X POST https://api.dropboxapi.com/2/files/list_folder \
  --header 'Authorization: Bearer 認証トークン' \
  --header 'Content-Type: application/json' \
  --header 'Dropbox-Api-Select-Admin: チーム管理者のメンバーID' \
  --data '{"path":"ns:123456789"}'

mruby and go: value mapping library

I’m recently working on my hobby project that requires DSL on Go language. From my little research of DSL on Go; go-mruby looks attractive to implement DSL. mruby is the lightweight version of Ruby. That is embeddable into an app. With go language, an app can be compilable into a single binary.

go-mruby

go-mruby have set of functions that enable essential integration with mruby code and Go. For example, go-mruby can define mruby class or method from Go. But current functions are it’s too primitive for dealing with real-world applications.

Like mapping values from mruby to Go, or vice versa. It’s a bit tiresome for getting/putting each struct field. For example below, just for two fields of struct require 9 lines of code.

mrb := mruby.NewMrb()
defer mrb.Close()

type Planet struct {
    Id   int
    Name string
}

rv, err := mrb.LoadString(`
class Planet
  attr_accessor :id
  attr_accessor :name
end

p = Planet.new
p.id   = 3
p.name = "Earth"
p
`)
if err != nil {
    panic(err.Error())
}

p := Planet{}
if rId, err := rv.Call("id"); err != nil {
    panic(err.Error())
} else {
    p.Id = rId.Fixnum()
}
if rName, err := rv.Call("name"); err != nil {
    panic(err.Error())
} else {
    p.Name = rName.String()
}
fmt.Printf("Planet: %v\n", p)

For more productivity, I wrote mapping functions. Functions are open sourced on GitHub in MIT license. grb.

grb

Unmarshal Ruby values into Go struct data

Func Unmarshal fetch values for every field in Ruby object that defined by mruby:"METHOD_NAME" tag.

type Planet struct {
    Id         int            `mruby:"id"`
    Name       string         `mruby:"name"`
    Radius     float64        `mruby:"radius"`
    HasMoon    bool           `mruby:"has_moon"`
    Moon       []string       `mruby:"moon"`
    MoonRadius map[string]int `mruby:"moon_radius"`
}

Define each value accessor in Ruby class.

class Planet
  attr_accessor :id
  attr_accessor :name
  attr_accessor :radius
  attr_accessor :has_moon
  attr_accessor :moon
  attr_accessor :moon_radius
end

Retrieve ruby value *ruby.MrbValue, then unmarshal it into the Go struct.

rv, _ := mrb.LoadString(`
p = Planet.new
p.id          = 3
p.name        = "Earth"
p.radius      = 6371.0
p.has_moon    = true
p.moon        = ["Moon"]
p.moon_radius = {"Moon" => 1737}
p
`)

p := Planet{}
Unmarshal(rv, &p)

fmt.Printf("Planet: %v\n", p)
// Planet: {3 Earth 6371 true [Moon] map[Moon:1737]}

Decode/encode between Ruby values and Go values

Similar to func Unmarshal. Func DecodeMrbValue and EncodeMrbValue enable mapping values between mruby and Go. DecodeMrbValue/EncodeMrbValue have limitation for a type of value. This supports JSON equivalent types like below.

  • nil
  • bool
  • int
  • float
  • string
  • Array / slice
  • Hash / map (Hash/map key must be a string)
mrb := mruby.NewMrb()
defer mrb.Close()

// Mapping to mruby value
rv, err := EncodeMrbValue(mrb, map[string]interface{}{"Earth": 6371, "Moon": 1737})

// Mapping from mruby value
gv, err := DecodeMrbValue(rv)

GolangでBOM付きCSVファイルを読み込む

先行事例・解決策は山ほどあるかと思いますが、はまったのでメモがてら解決方法を。BOM付きUTF16などでエンコードされたテキストを扱うに当たって、ライブラリなどが対応してくれているといいのですが、GolangのCSVReaderは対応していないようです。このため、次のような関数を準備してエンコーディングを変換してやります。

func NewBomAwareCsvReader(r io.Reader) *csv.Reader {
  var (
    bomUtf8    = []byte{0xef, 0xbb, 0xbf}
    bomUtf16BE = []byte{0xfe, 0xff}
    bomUtf16LE = []byte{0xff, 0xfe}
  )
  br := bufio.NewReader(r)
  mark, err := br.Peek(3)
  if err != nil {
    panic(err)
  }

  // decoder
  d := unicode.UTF8.NewDecoder()

  if bytes.HasPrefix(mark, bomUtf8) {
    br.Discard(len(bomUtf8))
  } else if bytes.HasPrefix(mark, bomUtf16BE) {
    d = unicode.UTF16(unicode.BigEndian, 
      unicode.UseBOM).NewDecoder()
  } else if bytes.HasPrefix(mark, bomUtf16LE) {
    d = unicode.UTF16(unicode.LittleEndian,
     unicode.UseBOM).NewDecoder()
  }

  return csv.NewReader(transform.NewReader(br, d))
}

あとはファイルを読み込めば変換できます。ひとまず必要が無かったので対応しませんでしたが、utf32パッケージを使って同様処理を追加してやればUTF32も対応できます。

Googleマップのスターが消える問題と、Day One 2に移行した話

Google Mapsのスターとは、お気に入りのレストランなどにスターをつけてメモしておけるというものです。旅行先で行こうと考えている観光スポット、知人から教わったおすすめレストランなど今まで記録はすべてGoogle Mapsでスターをつけることでメモしていました。 ただこの便利な機能にも不満が2点。一つはスターが勝手に消えてしまうことで。二つ目があまり追加情報が書き込めないことです。

Google Mapsでのスター

今回はGoogle Mapsでスターをつけた位置をDay One 2の記事として移行した際のお話です。なお各種仕様は変わるかもしれませんので試される際には自己責任で。

Google Mapsでの2つの課題

課題1: スターが消える問題

まず一つ目のスターが勝手に消えてしまう問題について。少し検索してみると下記のように詳細に調査されている記事が見つかりました。

いろいろ丁寧に検証されているので大変参考になりました。結局、保存はされているが、どうやら表示数に制限があり、古いものから表示されなくなるのではとのこと。データが保存されていても簡単に取り出せないとするとあまり保存している意味もありません。

課題2: 追加情報が書き込めない

この中華料理屋さんの担々麺がおいしいとか、この焼き鳥屋さんは塩よりタレがおすすめとか些細ではあるものの、教えてもらった情報は書き留めておきたいものです。Google Mapsでは最近スターの種類が増えたのでカテゴリごとにスターを分けたりできますが、それでも追加情報を書き込むという段階まではたどり着きません。

Google+などSNSを使って管理する方法もあるのですが、各所に情報が分散しても使いづらいですし、自分で投稿したものにもかかわらず検索が難しいので数年前からこの目的のためにSNSは利用していません。

Day One 2へ移行

Day One 2はもともと日記などライフログをつけるためのアプリです。写真やメモに対して位置情報やタグなどを追加して情報を管理することができます。

Day Oneは2013年頃から使い始めていて、ようやく最近Day One 2へ移行を完了しました。Day One 2では基本的な使い勝手はそのままにかなり機能が追加されています。よく見ると、Day One 2ではJSON Zip Fileという形式でデータをエクスポートしたり、インポートすることができます。これを使えば、Google Mapsから一括してデータを読み込みできそうです。

Day One 2のJSON Zip File形式

JSON Zip File形式のドキュメントは見つかりませんでしたが、データ形式としてはごく単純なものです。ZIPファイルの中にJournalごとのJSONファイルと、写真データが格納されています。Day One 2にデータをインポートするにはこのJSONファイルをインポートすれば良さそうです。

JSONファイルは次のようなフォーマットです。Day One 2 version 2.5.10にて幾つかテストをしてみたところ、インポートの際には記事ごとのuuidは振らなくても自動的に発番されるので問題ありません。locationデータについても緯度(longitude)・経度(latitude)のみあればよく他は省略してもインポートは成功します。

{
"metadata" : {
  "version" : "1.0"
},
"entries" : [
{
  "starred" : false,
  "location" : {
    "region" : {
      "center" : {
        "longitude" : 120.1973037719727,
        "latitude" : 22.99810981750488
      },
      "radius" : 75
    },
    "longitude" : 120.1973,
    "placeName" : "有方公寓 Your Fun Apartment",
    "latitude" : 22.99811
  },
  "creationDate" : "2015-03-14T11:35:05Z",
  "text" : "[有方公寓 Your Fun Apartment](http:\/\/maps.google.com\/?cid=1337703935590192274)\n\nNo. 9, Lane 269, Section 2, Hai'an Road, West Central District, Tainan City, Taiwan 700",
  "timeZone" : "Asia\/Tokyo",
  "uuid" : "345585B421AF47DEA98B497C66CFBF8F",
  "duration" : 0
}
]}

Google Takeoutでスター一覧を取得

Google TakeoutでスターをつけたデータをGeoJSONとして取り出します。手順については先ほど掲載させていただいたGoogleマップの「スター」(お気に入りの場所)が、古いものから消えて表示されなくなる問題に詳しく書かれています。

GeoJSONの中身をみてみると以下のように緯度、経度、タイトル、Google Maps上でのURLなどが得られることがわかりました。

{
  "type" : "FeatureCollection",
  "features" : [ {
    "geometry" : {
      "coordinates" : [ 120.1972925, 22.9981111 ],
      "type" : "Point"
    },
    "properties" : {
      "Google Maps URL" : "http://maps.google.com/?cid=1337703935590192274",
      "Location" : {
        "Address" : "No. 9, Lane 269, Section 2, Hai'an Road, West Central District, Tainan City, Taiwan 700",
        "Business Name" : "有方公寓 Your Fun Apartment",
        "Country Code" : "TW",
        "Geo Coordinates" : {
          "Latitude" : "22.9981111",
          "Longitude" : "120.1972925"
        }
      },
      "Published" : "2015-03-14T11:35:05Z",
      "Title" : "有方公寓 Your Fun Apartment",
      "Updated" : "2017-02-22T12:03:22Z"
    },
    "type" : "Feature"
  },
]}

Day One 2のJSON Zip File形式に変換

あとは形式を変更するだけですが、JSON同士の変換となるので簡単です。今回はRubyで書きました。ライセンスはMITライセンスとしておきます。

# MIT License
# 
# Copyright (c) 2018 Takayuki Okazaki
# 
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
# 
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
# 
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

require 'json'

def feature2entry(feature)
  def geo(f, l)
    if f.key?('Geo Coordinates')
      f['Geo Coordinates'][l]
    elsif f.key?(l)
      f[l]
    else
      p feature
      exit
    end
  end
  if feature['Location'].key?('Address')
    address = "\n\n#{feature['Location']['Address']}"
  else
    address = ''
  end
  if feature['Location'].key?('Business Name')
    place_name = feature['Location']['Business Name']
  else
    place_name = feature['Title']
  end

  {
    :location => {
      :placeName => place_name,
      :longitude => geo(feature['Location'], 'Longitude').to_f,
      :latitude  => geo(feature['Location'], 'Latitude').to_f,
    },
    :text => "[#{feature['Title']}](#{feature['Google Maps URL']})#{address}",
    :creationDate => feature['Published'],
  }
end

def dayone(entries)
  journal = {
    :metadata => {
      :version => "1.0"
    },
    :entries => entries
  }
  JSON.generate(journal)
end

geojson = JSON.load(open('Saved Places.json'))
entries = Hash[geojson['features'].map { |f| [f['properties']['Google Maps URL'], f] }]
journal = dayone(entries.values.map { |f| feature2entry(f['properties']) })

puts journal

あとはこの出力を Location.json など新しいジャーナル名として保存し、zipファイルにしてインポートします。これで無事にDay One 2に読み込み完了です。あとは、ゴミデータを削除したり、中華料理とか和食とかタグをつけて整理すれば完了です。

記事として読み込まれた位置情報

地図表示に切り替えればGoogle Mapsと同じように周辺の地図といままでメモした場所を一覧して確認することができます。

地図表示