はじめに
遅くても 2008 年ごろから使っている個人の Wiki があって、調べたことや頻繁に探すことをメモしたりして使ってきた。当時職場で使っていたのが PukiWiki で使い慣れていたという理由だけで使い始めた PukiWiki をそろそろさすがに別のものにしたいなと感じていたので esa に移行をした。そのときに考えたことやこうやって移行をしたという記録。移行対象となった PukiWiki のバージョンは 1.5.0 のため、以後の各仕様は v1.5.0 のものになっている。
最初に考えたこと
まずなにはなくとも PukiWiki 記法を Markdown にする必要がある。それから自分の用途としてダウンロードした各種スライドを添付していて、中にはすでに WWW 上では見つけられないものもあるので添付ファイルを完全に移行したい。そこまでどうにかできればあとは互換性のない移行をあきらめることでどうにかなるだろう。
と、まあ、あまり細かいことは考えずに始めた。
PukiWiki 記法を解釈して Markdown に変換する
検索すると正規表現を使ってごりごりと変換していくサンプルはいくつか見つかったのだが、自分でこれを修正しながら互換性を上げていく土台としてはきついなというものが多かった。そこで、パーサジェネレータを使ってみたかったという興味もあり、Parslet で PEG スタイルのパーサを書いてみることにした。
Parslet のパース結果はツリーで Array として返され、Hash が入ってる。Hash は Symbol と Parslet::Slice でできてる。Parslet::Slice は出自の位置をもった String みたいなもの。to_s をすると文字列を取り出せる。
{:toc=>"#contents"@0}
#=> [Symbol, :toc]
[Parslet::Slice, "#contents"@0]
実装進めるにあたっては、他の実装を参考にするのが一番イメージがつかみやすかった。
できあがった実装が pukiwiki2md として公開されている。この Gem を使うと、Parslet に則って、Parser#parse でパースし、その結果を Transform#apply で Markdown へと変換することができる。
parser = Pukiwiki2md::Parser.new
transform = Pukiwiki2md::Transform.new
tree = parser.parse(wiki_text)
markdown_text = transform.apply(tree)
テストを書きながら、ある程度できあがったところで実際の自分の Wiki のテキストに対して適用して、正しく変換ができなかったものを修正しつつ、という形で開発をしていった。
添付ファイルをどうするか?
添付ファイル名を解決する
PukiWiki では添付ファイルをそのままファイルとして attach ディレクトリに置いている。基本的にはこのファイルたちとページ群との対応関係を正しく解決してやればいい (PukiWiki では「ページに対してファイルを添付する」という挙動になっている)。
どのファイルがどのページに添付されているかについてはマッピングテーブルのようなものは存在せず、ファイル名で表現されている。そのファイル名は 68746D6C35_313637E58FB75F74332E706466
といった謎の文字列になっているので、まずはこのファイル名を読み解かなければならない。
検索するとちらほら関係する情報が出てくるが、ファイル名を変換できるというスクリプトはすでにリンク切れでファイル名の変換ができなかったりしたため、ここは PukiWiki のコードを読んで探すことにした。
とっかかりとしては、plugin/attach.inc.php
に実装がある。AttachFile クラスに、
$this->page = $page;
$this->file = preg_replace('#^.*/#','',$file);
$this->age = is_numeric($age) ? $age : 0;
$this->basename = UPLOAD_DIR . encode($page) . '_' . encode($this->file);
$this->filename = $this->basename . ($age ? '.' . $age : '');
$this->logname = $this->basename . '.log';
となっている。encode/decode については lib/func.php
に実装があり、
// Encode page-name
function encode($key)
{
return ($key == '') ? '' : strtoupper(bin2hex($key));
// Equal to strtoupper(join('', unpack('H*0', $key)));
// But PHP 4.3.10 says 'Warning: unpack(): Type H: outside of string in ...'
}
// Decode page name
function decode($key)
{
return pkwk_hex2bin($key);
}
// Inversion of bin2hex()
function pkwk_hex2bin($hex_string)
{
// preg_match : Avoid warning : pack(): Type H: illegal hex digit ...
// (string) : Always treat as string (not int etc). See BugTrack2/31
return preg_match('/^[0-9a-f]+$/i', $hex_string) ?
pack('H*', (string)$hex_string) : $hex_string;
}
といった形になっている。
Ruby では CGI クラスを使って CGI.unescape してやることで、求める実装ができそう。
ファイル名が {ページ名}_{添付ファイル名}
となっていて、decode される前に _
での区切りで分割してやるのがポイント。
require 'cgi'
page, file = '68746D6C35_313637E58FB75F74332E706466'.split('_')
CGI.unescape(page.chomp.scan(/../).map {|s| "%#{s}" }.join) #=> "html5"
CGI.unescape(file.chomp.scan(/../).map {|s| "%#{s}" }.join) #=> "167号_t3.pdf"
といったかんじになる。
ファイルを esa にアップロードする
さて、ファイルと Wiki ページの対応は解決できそうな目途が立ったので、次は esa へのアップロードを考える。
esa へのアップロードと言いつつ、esa は各ページへのファイルアップロードというよりもファイルは S3 上にあり、esa からはそのファイルへのリンクや画像へのリンクとして実現されているので、次のステップで添付ファイルを実現することにした。
- ファイルを S3 へアップロード
- ページ名、ファイル名、アップロード先 URL の組で記録する
- 添付ファイルの置き場所は記録されたデータから URL を引っ張って transform する
ファイルのアップロードは自分の S3 bucket を使ったほうが都合がよさそうだったので、esa 上で「チーム独自のS3バケットに添付ファイルを保存する」のオプションを使い、自分の S3 bucket を使うことにした。
bucket の下のパスは esa の仕様にならって、s3/buckets/{bucket_name}/uploads/production
の配下に pukiwiki/files
を作って置いていくことにした。添付ファイルは基本的に文中でリンクされている esa と違い、PukiWiki では文中では触れないがページにファイルは添付されているということがありえるため、各ページの末尾に添付ファイルの一覧を追記してやることにする。
ここでは方針の検討のために小さなコードを書きつつ、それで実現ができるなら実際に利用するコードとしても使っていくという形で進めていった。
「もし、ページ名から添付ファイルの URL を引ける辞書があればリンクを解決できるか」というのを確認するためにこんなコードを書いて pukiwiki2md での Markdown の変換に割り込んでファイル名を解決するのを確認していった。
require 'pukiwiki2md'
Esanize = ->(page_name, transform) {
transform.module_eval do
rule(:block_image => simple(:image), :l => simple(:l), :r => simple(:r)) {
retriever = FileRetriever.new(page_name)
"![#{image}](#{retriever.find(image)})" + ::Pukiwiki2md::Transform::EOL
}
rule(:block_file => simple(:file), :l => simple(:l), :r => simple(:r)) {
retriever = FileRetriever.new(page_name)
"[#{file}](#{retriever.find(file)})" + ::Pukiwiki2md::Transform::EOL
}
rule(:image => simple(:image), :l => simple(:l), :r => simple(:r)) {
retriever = FileRetriever.new(page_name)
"![#{image}](#{retriever.find(image)})"
}
rule(:file => simple(:file), :l => simple(:l), :r => simple(:r)) {
retriever = FileRetriever.new(page_name)
"[#{file}](#{retriever.find(file)})"
}
end
transform
}
class FileRetriever
def initialize(page_name)
@index = database.fetch(page_name)
end
def find(filename)
@index.fetch(filename.to_s)
end
def database
{
'並カン' => {
'image.png' => 'https://example.com/image.png',
'namikan_1_goyoki.pdf' => 'https://example.com/namikan_1_goyoki.pdf',
'parallel-programing-brief.pptx' => 'https://example.com/parallel-programing-brief.pptx',
'そろそろvolatileについて一言いっておくか.ppt' => 'https://example.com/そろそろvolatileについて一言いっておくか.ppt',
'マルチコア時代のLock-free入門.ppt' => 'https://example.com/マルチコア時代のLock-free入門.ppt'
}
}
end
end
class Sample
def initialize
@parser = ::Pukiwiki2md::Parser.new
esa = Esanize.call('並カン', Class.new(::Pukiwiki2md::Transform))
@trans = esa.new
end
def run(text)
result = @parser.parse(text)
@trans.apply(result)
end
end
if $0 == __FILE__
puts Sample.new.run(<<-TEXT)
[[並行カンファレンス:https://atnd.org/events/2092]]の資料。とくに「並列プログラミングの入門&おさらい的な話(主に並列プログラミングにおける注意事項など) : wraith13」の資料である &ref(parallel-programing-brief.pptx); と「lock freeとかmemory barrier : yamasa」の資料 &ref(マルチコア時代のLock-free入門.ppt); &ref(そろそろvolatileについて一言いっておくか.ppt); は当時非常に新鮮でその後の糧と指針となった。
- &ref(namikan_1_goyoki.pdf);
- &ref(マルチコア時代のLock-free入門.ppt);
- &ref(そろそろvolatileについて一言いっておくか.ppt);
- &ref(parallel-programing-brief.pptx);
TEXT
end
意図した形の Markdown が返ってくるので大丈夫そう。あとはこの database の部分を正しくアップロードされた結果で置き換えられればいい。
なにもかもうまくやる大きなプログラムは好きじゃないので、この後もちょっとずつ上手にやってくれるスクリプトをいくつか書いていく方針で進むことにする。
少しずつ進むときに問題になるのは、何度も小さな問題を発見しその都度やりなおしをすることになることだ。これは再試行がかんたんにできることで不安に対処することができる。
そこで、esa や S3 というやりなおしのしにくいポイントは抱えつつも、なるべく気軽に失敗ができる形で進められるような構成にしていこう。
S3 へのアップロードをやりなおしできるようにするためには dry-run を実装するのがいいだろう。アップロード自体も目的だが、今回はページ名から添付ファイルを探す索引をつくることも大きな目的だ。アップロード先のパスが一意に定まるなら、それを繰り返し実行可能にすることで再試行は気軽にできるようになりそうだ。
最終的にアップロードのスクリプトはこんなものにした。実行すると files_index
として索引が出力されるので、実行した結果はいくらでも確認できる。満足がいったら DRYRUN = false
で実行をすると実際に S3 へのアップロードが行われる。DryrunUploader をつくるのでなくメソッドでの分岐にしているのはちょっとした手抜き。
# usage: bundle exec ruby -Ilib lib/upload.rb
# https://docs.aws.amazon.com/ja_jp/sdk-for-ruby/v3/developer-guide/setup-config.html
# https://docs.aws.amazon.com/sdk-for-ruby/v3/api/Aws/S3.html
require 'aws-sdk-s3'
require 'attachment_file'
require 'yaml'
require 'config'
srand(256) # DRYRUN 実行で URI を固定させる
UPLOAD_PATH = 'production/pukiwiki' # : production/pukiwiki, test/pukiwiki
DRYRUN = true
class Uploader
def initialize(dryrun: false)
Aws.config.update(credentials: Aws::SharedCredentials.new(profile_name: 'esa'))
@s3 = Aws::S3::Resource.new(region:'ap-northeast-1')
@dryrun = dryrun
end
def upload(filename, source)
n = rand(0..1024)
unless @dryrun
upload_real(n, filename, source)
else
upload_dryrun(n, filename, source)
end
end
private
def upload_dryrun(n, filename, source)
$stdout.puts filename
base_url = "https://#{Config.bucket}.s3.ap-northeast-1.amazonaws.com/"
base_url + "uploads/#{UPLOAD_PATH}/#{n}/#{filename}"
end
def upload_real(n, filename, source)
obj = @s3.bucket(Config.bucket).object("uploads/#{UPLOAD_PATH}/#{n}/#{filename}")
if obj.upload_file("#{Config.wiki_path}/attach/#{source}", acl: 'public-read')
$stdout.puts filename
return obj.public_url
else
$stderr.puts "Error (Upload failure): #{filename}(#{source})"
return nil
end
end
end
reverse_index = {}
attachment_file = AttachmentFile.new
uploader = Uploader.new(dryrun: DRYRUN)
attachment_file.filenames.each do |file|
# upload
url = uploader.upload(file[:file], file[:source])
# index construction
page = reverse_index[file[:page]] || {}
page.update(file[:file] => url)
reverse_index.update(file[:page] => page)
end
File.open('files_index', 'w') {|f| f.write(reverse_index.to_yaml) }
AttachmentFile は PukiWiki の添付ファイルにまつわる実装のあれこれを扱ってくれている。
require 'cgi'
require 'pathname'
require 'config'
class AttachmentFile
attr_reader :filenames
def initialize(root_path: Config.wiki_path)
@filenames = scan_dir(root_path)
end
private
def decode(name)
page, file = name.split('_')
{
source: name,
page: CGI.unescape(page.chomp.scan(/../).map {|s| "%#{s}" }.join),
file: CGI.unescape(file.chomp.scan(/../).map {|s| "%#{s}" }.join)
}
end
def scan_dir(root_path)
Pathname.glob(root_path+'/attach/[0-9A-Z]*_[0-9A-Z]*[^.log]').inject([]) do |result, path|
result << decode(path.basename.to_s)
end
end
end
if $0 == __FILE__
pp AttachmentFile.new.filenames
end
問題を分割する
どうやら添付ファイルの問題は解決できそうだ。あとは記事を変換してやって、esa へアップロードをしていけばいい。ここでも問題はなるべく小さく分割してやりなおしをできるようにしていこう。
- ローカルの PukiWiki 記法のファイルを入力に、esa 用 Markdown で書かれたファイルを出力する
- esa 用 Markdown で書かれたファイルを esa に投稿する
というステップを分けてやると、一番試行錯誤の多い Markdown の解釈の部分をやりなおすことが楽になりそうだ。PukiWiki の wiki
ディレクトリからファイルを受けとって、PukiWiki 記法を esa 用の Markdown へ変換し、実行ディレクトリの esa
ディレクトリにファイルを書き出していくことにしよう。
初回は esa へ新規投稿として投稿し、 2 回目からは対象の記事を指定で更新をかけられるようにする。esa
ディレクトリは Git で管理して、生成されたファイルに差分が出たら気付けるようにする。後述するが [[WikiName]]
などの Wiki 内リンクを解決するために esa の記事番号が何になるか知りたいので、初回と二回目の最低 2 回は esa へ API 経由で記事をつくっていくことになる (今回 WikiName
の自動リンクは対応していない)。
esa 用 Markdown で書かれたファイルを出力する
esa 用の Markdown を出力するには先ほど試しに Transform を上書きした方法を使って、Pukiwiki2md::Transform の挙動を一部上書きして esa 上で期待する動きをするようにしてやる。
$LOAD_PATH.unshift File.expand_path('../../lib', __FILE__)
require 'pukiwiki2md'
require 'page_file'
require 'page_retriever'
require 'file_retriever'
require 'cgi'
require 'config'
Esanize = ->(page_name, transform) {
transform.module_eval do
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
rule(:child_pages => simple(:s)) do
name = page_name.delete_suffix('/README')
"[#{name}の子ページ](/#path=%2F#{CGI.escape(name)})" + ::Pukiwiki2md::Transform::EOL
end
rule(:block_image => simple(:image), :l => simple(:l), :r => simple(:r)) {
retriever = FileRetriever.new(page_name)
"![#{image}](#{retriever.find(image)})" + ::Pukiwiki2md::Transform::EOL
}
rule(:block_file => simple(:file), :l => simple(:l), :r => simple(:r)) {
retriever = FileRetriever.new(page_name)
"[#{file}](#{retriever.find(file)})" + ::Pukiwiki2md::Transform::EOL
}
rule(:image => simple(:image), :l => simple(:l), :r => simple(:r)) {
retriever = FileRetriever.new(page_name)
"![#{image}](#{retriever.find(image)})"
}
rule(:file => simple(:file), :l => simple(:l), :r => simple(:r)) {
retriever = FileRetriever.new(page_name)
"[#{file}](#{retriever.find(file)})"
}
end
transform
}
class EsaFile
def initialize(page_name)
@parser = ::Pukiwiki2md::Parser.new
esa = Esanize.call(page_name, Class.new(::Pukiwiki2md::Transform))
@trans = esa.new
end
def run(text)
result = @parser.parse(text)
@trans.apply(result)
end
end
if $0 == __FILE__
page_file = PageFile.new
page_file.pages.each do |name, page|
puts "==========================="
puts " name: #{name}"
puts " source: #{page[:source]}"
puts "==========================="
esa = EsaFile.new(name)
wiki_text = File.read("#{Config.wiki_path}/wiki/#{page[:source]}")
File.open("esa/#{page[:source]}", 'w') do |f|
f.puts esa.run(wiki_text)
f.puts FileRetriever.new(name).markdown_links
end
end
end
PageFile
PageFile は PukiWiki のページをディレクトリから走査して、ページ名やエンコードされたファイル名を返してくれる。
require 'cgi'
require 'pathname'
require 'config'
class PageFile
attr_reader :database
def initialize(root_path: Config.wiki_path)
@database = scan_dir(root_path)
end
def names
@database.keys
end
def pages
@database
end
def self.decode(name)
decoded = name.delete_suffix('.txt')
CGI.unescape decoded.chomp.scan(/../).map {|s| "%#{s}" }.join
end
def scan_dir(root_path)
Pathname.glob(root_path+'/wiki/[0-9A-Z]*.txt').inject({}) do |result, path|
name = PageFile.decode(path.basename.to_s)
result[name] = { page: name, source: path.basename.to_s }
result
end
end
end
if $0 == __FILE__
pp PageFile.new.pages
end
PageRetriever
PageRetriever は WikiName の内部リンクを解決するためのもので 2 回目以降でないとリンクを解決できない。
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
PukiWiki は Category/ChildCategory/PageName
と区切ったときにカテゴリであると同時に Category
というページや Category/ChildCategory
というページでもあったが、esa ではカテゴリはカテゴリでしかなく同名のページはそのカテゴリには所属しない一つ上の階層の同名のページとなってしまう。PukiWiki 上でもカテゴリのページはインデックスとなる大事なページであることも多いので、一律 Category
なページは Category/README
となるように PukiWiki 側でページ名の変更をしていった (PukiWiki 側では #ls
などが効かなくなるので捨てる前提じゃないとやらないほうがいい)。これはプログラム使わず PukiWiki 側の名前を変更していったけれど、今思えばファイル名の一覧を出して自動で esa への変換時にやってもよかったかもしれない。
esa_posts ファイルは esa にアップロードされた記事の一覧を持つ YAML ファイル。たとえばこんなかんじ。
---
- :number: 1977
:full_name: '20090222'
- :number: 1976
:full_name: ruby/SIGALRM
- :number: 1975
:full_name: ruby/mocha
FileRetriever
FileRetriever は添付ファイルを扱うためのクラスで索引から添付ファイルの URL を返す。また、ページの末尾に記載するファイル一覧の Markdown を返すための仕事もしている。
require 'yaml'
class FileRetriever
def initialize(page_name)
@index = database.fetch(page_name, Hash.new)
end
def find(filename)
@index.fetch(filename.to_s)
end
# view
def markdown_links
return if @index.empty?
links = @index.map do |filename, url|
"* [#{filename}](#{url})"
end
[ '', '# Attachment files', *links ].join("\n")
end
private
def database
YAML.load_file('files_index')
end
end
Config
環境依存の性質のものを外部の設定ファイルから読み込むだけ。
require 'yaml'
class Config
@config = YAML::load_file('.config/file')
def self.wiki_path
@config['wiki_path']
end
def self.bucket
@config['bucket']
end
end
if $0 == __FILE__
pp Config.wiki_path
pp Config.bucket
end
.config/file
はこんなかんじ。
---
wiki_path: /home/sunaot/wiki.example.com
bucket: esa-bucket
Markdown ファイルを esa に投稿する
ここまでで大体準備ができた。あとは esa へ投稿をしていくだけだ。 次の記事ではできあがった Markdown ファイルを esa へ投稿していく。