Ruby で自分のための問題を解決していく話 (個人の Wiki を esa に移行した話) の続き。前回は文章とコードでの説明ばかりになってしまったので全体的にやっていることを図にしてみた。

PukiWikiをesaへ移行する流れ

作戦としては、

  • 一連の PukiWiki のファイルはローカルに置いて失敗があれば最初からやりなおせるようにする
    • esa の記事作成以外は繰り返し実行可能にする
  • Markdown の変換で PukiWiki と esa の仕様の差分を吸収できるようにがんばる
  • 最終結果を esa 上で確認し、意図しない表示になっているものは Markdown の変換からやりなおして差分が出た記事について esa へ更新をかける

という流れで、実行制限のある esa の API 呼び出しを最小限にしつつ、Wiki 内のページリンクなど PukiWiki 資産を最大限に活かせる形を考えた。実際はここまで全部最初から考えてやったわけではなくて、やりながらやっぱり Wiki 内のページへのリンクも有効にしたいなとか #ls もただ機能をつぶすのじゃなくて活用したいなとか考えては実装し、そして再度手順を実行し、という繰り返しだったのでやりなおせる構成にしたのは正解だった。

ここからは esa への新規投稿による記事 URI 確定と、二度目に Wiki 内リンクを解消した更新をかける流れを説明し、最後に繰り返し修正と更新を繰り返す中でどんな PukiWiki の仕様で引っかかったかとその対処を説明する。

esa への新規投稿で記事 URI を確定させる

ページ名を日本語でもつけていることもあり、WikiName による Wiki 内のページのリンク機能を前提として書いている文章はさほどないが、[[Page name]] と囲むことでページ名を指定してのリンクは多用しているのでこれは活かしたい。

実際は記事の URI を確定させたいというだけなのだけど、esa 上に記事をつくるしかその手段がないので全記事を投稿して記事番号を採番してもらい、URI を確定させる。

投稿するスクリプトはこんなかんじ。

require 'esa'
require 'page_file'
require 'yaml'
require 'esa_helper'

DRYRUN = true
CONTINUE = false

root_dir = 'esa'
class DryRunClient
  def create_post(params)
    pp params
  end
end

client = if DRYRUN
  DryRunClient.new
else
  credentials = EsaCredentials.credentials
  Esa::Client.new(access_token: credentials['access_token'], current_team: credentials['current_team'])
end

pages = if CONTINUE
  YAML.load_file('esa_pages')
else
  PageFile.new.pages
end

pages.each do |name, page|
  puts "start: #{name}"
  body = File.read("#{root_dir}/#{page[:source]}")
  m = name.match(%r{(.*)/(.*)})
  if m
    category, title = m[1], m[2]
  else
    category, title = '', name
  end

  client.create_post({
    name: title,
    body_md: body,
    category: category,
    wip: false,
    message: 'Convert from PukiWiki',
    user: 'sunaot'
  })
  puts "  end: #{name}"
end

esa API 仕様で 15 分に 75 呼出に制限されているが、その対処は esa gem が待機してくれるため、特別な考慮はしない。ただ、全量の記事を投稿するには記事数によって長時間かかるため中断した箇所から続けるために外部ファイルから実行対象の記事を取得できるようにしている。より安定して動作させたければ途中でコネクションが切断されたときのリトライなど API 呼出時の異常終了へ丁寧にケアしてやるとよさそうだが、繰り返し使うものでもないのでこのくらいの実装にした。

esa_helper.rb は esa の認証情報を扱うもので、こんな実装になっている。

require 'yaml'
class EsaCredentials
  def self.credentials(group_name = 'default')
    config = YAML::load_file('.esa/credentials')
    config.fetch(group_name)
  end
end

if $0 == __FILE__
  pp EsaCredentials.credentials
end

credentials のサンプルはこんなかんじになっている。

---
default:
  access_token: <access_token>
  current_team: <team_name>

確定した記事 URI のリストを得る

これで初回の投稿が終わり、esa 上では記事の URI が確定した。一連の記事の URI データベースをつくるのはかんたんだ。

require 'esa'
require 'yaml'
require 'esa_helper'

credentials = EsaCredentials.credentials
client = Esa::Client.new(access_token: credentials['access_token'], current_team: credentials['current_team'])
page = 1
while(true)
  response = client.posts('per_page' => 100, 'page' => page)

  posts = response.body['posts']
  File.open('esa_posts', 'a') do |f|
    f.write(posts.map {|post| { number: post['number'], full_name: post['full_name'] } }.to_yaml)
  end
  break if response.body['next_page'].nil?
  page += 1
end

これで esa_posts という名前のファイルにページ名と esa の記事番号の組み合わせが記録される。

---
- :number: 1986
  :full_name: Screen/256colors
- :number: 1984
  :full_name: VirtualBox/README
- :number: 1985
  :full_name: '20130425'
- :number: 1983
  :full_name: '20120912'
- :number: 1981

...

確定した URI で Wiki 内リンクを実現する

Wiki 内リンクを URI へのリンクへと変換するのは Markdown の生成時に対応していて、こんなかんじで Transform の動作を上書きしている (すべてのコードを見たいときは前回の記事の「esa 用 Markdown で書かれたファイルを出力する」に書いてます)。

    rule(:inner_link => simple(:name), :l => simple(:l), :r => simple(:r)) do
      if name == 'FrontPage'
        '<!-- FrontPage -->'
      else
        retriever = PageRetriever.new(page_name)
        link = retriever.resolve_link(name.to_s)
        !link.empty? ? "[#{name}](#{link})" : "[#{name}]"
      end
    end

PageRetriever がリンクの解決を担当しており、こんなかんじで都度リンク先を探すようにしている。

require 'yaml'
require 'pathname'
require 'cgi'

class PageRetriever
  def initialize(current_page)
    @current_page = current_page
    @posts = YAML::load_file('esa_posts')
    @categories = categories(@posts).sort.uniq
  end

  def find(page_name)
    @posts.find do |item|
      item[:full_name] == page_name
    end
  end

  def resolve_link(target_page)
    page = find_page(target_page)
    category = @categories.find {|name| name == target_page }

    generate_link(page, category, target_page)
  end

  def find_page(target_page)
    @posts.find do |item|
      if target_page.start_with?('./', '../')
        path_match?(item, target_page, @current_page)
      else
        same_page_name?(target_page, item[:full_name])
      end
    end
  end

  private
  def path_match?(item, target_page, current_page)
    path = Pathname.new(current_page) + target_page
    no_readme_path = Pathname.new(current_page.delete_suffix('README')) + target_page
    same_page_name?(path.to_s, item[:full_name]) or
    same_page_name?(no_readme_path.to_s, item[:full_name])
  end

  def generate_link(page, category, original_name)
    if page
      "/posts/#{page[:number]}"
    elsif category
      "/#path=#{CGI.escape('/'+category)}"
    else
      $stderr.puts original_name
      ''
    end
  end

  def same_page_name?(target_name, page_name)
    page_name == target_name ||
    page_name.delete_suffix('/README') == target_name
  end

  def categories(posts)
    posts.reduce([]) do |result, item|
      category, title = category_name(item[:full_name])
      unless category == ''
        result.push category
      end
      result
    end
  end

  def category_name(page_name)
    m = page_name.match(%r{(.*)/(.*)})
    if m
      category, title = m[1], m[2]
    else
      category, title = '', page_name
    end
  end
end

更新した記事内容で esa 上の記事を更新する

ここまでで Wiki の内部リンクも esa 記事へのリンクに書き換えられた Markdown ができたので、この内容で esa へ更新かける。 esa というディレクトリに変換後ファイルを出力して Git リポジトリとして管理をしているので差分が出たファイルの確認は

git diff --name-status | grep -P '^M' | awk '{print $2}' > ../update_pages

というようなかんじで更新対象のファイルリストをつくっていた。

そうして作った update_pages ファイルから対象ファイルの名前を取得して更新をかけるスクリプトはこうなっている。

require 'esa'
require 'page_file'
require 'yaml'
require 'esa_helper'

DRYRUN = true

root_dir = 'esa'
class DryRunClient
  def update_post(number, params)
    pp(number, params)
  end
end

client = if DRYRUN
  DryRunClient.new
else
  credentials = EsaCredentials.credentials
  Esa::Client.new(access_token: credentials['access_token'], current_team: credentials['current_team'])
end

pages = File.read('update_pages')
esa_pages = YAML.load_file('esa_posts')

pages.each_line(chomp: true) do |page_name|
  name = PageFile.decode(page_name)
  puts "start: #{name}"
  body = File.read("#{root_dir}/#{page_name}")

  page = esa_pages.find {|item| item[:full_name] == name }
  raise("cannot find page: [#{name}]") unless page

  client.update_post(page[:number], {
    body_md: body
  })
  puts "  end: #{name}"
end

PukiWiki 記法で移行に困ったものとその対処

さて、これで自分で使いたいものは大体全部 esa へ移行することができた。今では PukiWiki はさわることなく、このまま VPS も解約ができそうだ。

最後にプログラムで解消するのが面倒だったものとその回避の対応についてまとめておく。

表組みがむずかしすぎる

PukiWiki の表はあまりに強力だ。謎に充実している。一方、Markdown は必要最低限の表についての機能サポートしかない。このため、まじめに表を移行しようとすると PukiWiki の記法を解釈して HTML として Markdown へ表を埋め込み、見た目の再現をしていくということになりそうだった。

今回はそこまで複雑な表 (セルのマージやカラーや) を使っていたわけではなかったので、PukiWiki 側で複雑な表を使っていそうなページを検索して、レンダリング結果を画像で保存し、esa のほうへは画像のインライン表示をして済ますことにした。

複雑な表を探すには PukiWiki のファイル群を |~|>|LEFT:CENTER:, RIGHT:, COLOR(, BGCOLOR(, SIZE( あたりで検索をかけていった。

別ページのファイル参照を解決する

esa には別ページに添付されているファイルを参照する機能がある。データベースからのファイル探索を改善すれば対応できるのだが、この機能を利用していたのが1箇所だけだったので直接索引のファイルを書き換えて探索できるようにしてしまった。

添付ファイル (&ref();#ref()) のオプション引数に対処したい

PukiWiki の &ref();#ref() はオプション引数をとることができ、それによって挙動を変える。ただ、これも数が少ないので対象を grep で検索して元の PukiWiki の内容を書き換え、引数のない形に修正していって丁寧な対応はしないで済ませた。

#ls を実現する

これは PukiWiki としては重要機能だったので対応をした。esa には #ls 相当の機能はないので、/#path=%2F{Category Name} へのリンクへと置き換えをすることにした。

    rule(:child_pages => simple(:s)) do
      name = page_name.delete_suffix('/README')
      "[#{name}の子ページ](/#path=%2F#{CGI.escape(name)})" + ::Pukiwiki2md::Transform::EOL
    end

#peg_error をつぶす

変換結果の esa ディレクトリで grep page_components * すると、parse に失敗している対象が見つかるので原因をつぶしていった。

末尾改行を無視する

Markdown ではパラグラフ中の改行は普通に改行してくれるので PukiWiki 上で明示的に末尾改行を入れている場合は無視したい。これも数が少なかったため PukiWiki 側で grep をかけて対象を探し、元の PukiWiki の文章から ~ を消してしまった。

おわりに

Ruby を使って自分の困りごとを解決していった過程と、そのときに考えたこと、その中で使ったコードについて書いてきた。スクリプトのあれこれはそのときの自分に役に立てばいいという程度のものなので丁寧さが足りない部分もあるが、それでも 10 年近く貯めてきた自分の Wiki を esa へ移すことができる程度には役に立つので、同じ問題で困っている人がいれば適宜修正して使ってほしい。