はじめに

遅くても 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 からはそのファイルへのリンク画像へのリンクとして実現されているので、次のステップで添付ファイルを実現することにした。

  1. ファイルを S3 へアップロード
  2. ページ名、ファイル名、アップロード先 URL の組で記録する
  3. 添付ファイルの置き場所は記録されたデータから 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 へアップロードをしていけばいい。ここでも問題はなるべく小さく分割してやりなおしをできるようにしていこう。

  1. ローカルの PukiWiki 記法のファイルを入力に、esa 用 Markdown で書かれたファイルを出力する
  2. 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 へ投稿していく。