Ruby で自分のための問題を解決していく話 (個人の Wiki を 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 へ移すことができる程度には役に立つので、同じ問題で困っている人がいれば適宜修正して使ってほしい。