古い形式のブログ記事を変換するおしごと
これまでの遷移
blog以前 | Qiita flavored markdownでQiitaに投稿 |
一番初めの時期(2020/08/02~2021/01/19) | markdown+pandoc拡張(メタデータをyamlで管理) |
org移行期(2021/01/31~ 2021-05-19 | Org format(メタデータをyamlで管理 |
現在のフォーマット(2021/07/24~) | Org format(メタデータをPropertyで管理) |
なぜ移行するのか
複数のフォーマットに対応したHakyllコードをかくのが そろそろ辛くなってきたからです。 特に、メタデータの取り扱い周りが厄介なのです。
Pandocでは、独自の構文としてメタデータを YAML フォーマットのヘッダーに保存することができます。 昔はこれを使っていたのですが、 Emacs+Org mode の構成で投稿を作成する際、それらをプロパティとして 扱えたら便利だなと感じるようになりました。
プロパティとして扱える利点としては、例えば現在の投稿にある
BLOG_POST_PROGRESS
を org-agenda
から見ることによって、
作成中の投稿の一覧を容易に作成することができます。
これは、org modeの機能の一つであるプロパティを用いているからできること
であって、pandocのYAML構文を用いようとしたらまた新しいプログラムを
書く必要がでてきます。それは面倒だし非効率なので、
今迄YAMLメタデータとして仕舞っていたデータも
プロパティとして保存するように変更しました。
それに伴い、タイトルの取得の方法等も変わって昔の方法は使えなくなりました。 どちらにも対応することは不可能ではないでしょうが、 正直もう昔の方法を利用することは無いであろうこと・今後のメンテナンス性を 考えると非効率であることから、完全な移行をするに至りました。
移行スクリプト
技術選定
今回使っていくのは
です
以下のコード内で、便宜上コードブロックの言語を sh
にしていますが、
一部は実際はnushellのスクリプトです。
(Emacs上でOrg babelで実行する際は、 :shbang #!/bin/nu
の効果で
nushellのスクリプトとして実行されます。)
pandoc
いわずと知れたフォーマット変換(等)ツール。 今回、markdown→org formatの変換を必要とするファイルもあるため必須です。 又、整形にも使います。
nushell
言わずと知れた‥というにはまだ早いので紹介します。 nushellは、色々な新しい面を持ちますが、今回の目的にそった紹介をする のであればデータ型を持つシェルです。 内部にリストやテーブル・日付型・サイズ型等色々な型を持つほか、 CVSやINI, TOML, ICS, XML, そして今回の本命YAML等のデータを 取り込むんことができます。
今回はこのYAMLを取り込めるという機能を目的に採用しました。 YAML形式で保存されているメタデータの取り出しから、それを新しいHeadingに 整形する部分までかnushellの役割です。
経緯
とりあえずpandocを使うことは決定していましたが、 実はpandoc単体(フィルターなし)では「その内容を参照して あれこれする」ことができません。 というか、そういうニーズを満たすためのものがフィルターなんですが...
でもフィルター書くのはちょっと面倒なんだよね... 今はYAML形式のメタデータの情報を使いたいだけなので、 テキスト処理ならawkなりsedなりあるし わざわざフィルター書く程のものでもないように感じます。
なので、手軽にYAMLを使えるnushellを使ってみることにしました。
awk
文字列処理ならsedかawkでしょ、ということで使用。 今回は、記事からYAML形式のメタデータ部分を取得するために使用します。
bash
nushellで全てを書こうとしたのですが、いかんせん新しいプロジェクトなので 未だ使い勝手が整ってなかったり出来無いことがあったりします。
私がnushellに慣れていない、というのもありますが、 テキストを読み込んだ時に謎にTableにされてしまい、それを頑張ってテキストに戻して... みたいなことをやるのに疲れてしまいました。 ということで、nushellの得意な部分はnushellに任せて、他の処理は手慣れたbashで 行おうということになりました。
1. YAML部分を取り出す
ここは単純なテキスト処理なのでawkを使います。(もっと良い方法があれば教えてください)
メタデータは ---
と ---
で囲まれているので、
「 ---
の次の行から次に ---
がくるまで」を出力します。
BEGIN { inside=0; }
/---/ {if (inside == 0) { inside=1; }
else { inside=0; };
next;
}
{ if (inside == 1) { print $0; }; }
title: ブログの見た目を整える
author: Cj-bc
tags:
- hakyll
- ブログ
- haskell
date: Jan 03, 2021
2. Yaml部分からメタデータを取得する
先程のawkスクリプトを適用してあげて...
let target = (if ($nu.env | select TARGET | empty?) {""} {$nu.env.TARGET})
if ($target | empty?) {
^echo "usage: TARGET=<TARGET_FILENAME> blog-migration-script--createHeader.nu"
exit
} {}
let metadata = (awk '
<<extract-yaml-metadata>>
' $target | from yaml)
<<migration-script>>
echo $metadata
# title author tags date ─────────────────────────────────────────────────────────────────────── 0 ブログの見た目を整える Cj-bc [table 3 rows] Jan 03, 2021
3. メタデータを加工して新しいヘッダーを作成する
さて、これで投稿のタイトルと諸々のデータは取れるようになりました。 あとはこれを加工して、新しいヘッダーを作成します。
<<nu-getAuthor>>
<<nu-formatTags>>
<<nu-formatDate>>
echo $"* ($metadata.title)
:PROPERTIES:
:DATE: (formatDate $metadata)
:TAGS: ($metadata.tags | reduce -f ':' { $acc + $it + ':' })
:AUTHOR: (getAuthor $metadata)
:BLOG_POST_KIND: Memo
:BLOG_POST_PROGRESS: Published
:BLOG_POST_STATUS: Normal
:END:
"
<<migration-script>>
echo $newHeading
ブログの見た目を整える
タグをOrg形式に変換する
org形式のタグはタグ名を :
で囲んだものになります。
タグ名はメタデータ内にリストとして持っているので、nushellの reduce
コマンドで整形します。
def formatTags [tags: table] {
$tags | reduce -f ':' { $acc + $it + ':' }
}
<<nu-formatTags>>
formatTags $metadata.tags
:hakyll:ブログ:haskell:
記事の日付を変換する
昔のフォーマットでは 月 日, 年
となっているので、これを
org形式の [年-月-日 曜日]
に変換します。
最初は nushell の parse
コマンドでパースしてうんたら...って
考えていたけれど、曜日を出す方法や月番号周りの変換に悩んでいました。
で、その間に GNU coreutils の date
コマンド(nushellは組込みで date
コマンド持っているが、そっちではない)が全ての仕事を出来そうだとわかったので
こちらでやることにしました。
GNU coreutilsの date
コマンドはデフォルトでは現在時刻を吐きますが、
--date
オプションに文字列を渡してあげることで別の日付にすることが可能です。
このオプションに元の文字列をセットして、それをorg形式にフォーマットしなおします。
nushellでは、前述の通りそれ自体が提供している date
コマンドが存在し、
GNU coreutilsの date
コマンドはそのままでは使用することができません。
そのため、nushell bookの"Escaping to the System"を参考にコマンド名の前に
^
を付けることでnushell独自のコマンドを呼び出さず、GNU coreutilsの date
コマンドを呼びだします。
尚、GNU coreutilsの date
コマンドは環境変数 LANG
に応じて曜日名の
出力などを変化させます。ここでは英語表記になってほしいので LANG=C
にしています。
def formatDate [metadata] {
LANG=C ^date --date $"($metadata.date)" +[%Y-%m-%d %a] | tr -d '\n'
}
"
"デフォルトの筆者を設定する
いくつかの記事はAUTHORが記載されていないので、 その場合はデフォルト値を使うようにしてあげます。
def getAuthor [metadata] {
if ($metadata | select author | empty?) {
"Cj-bc"
} { $metadata.author }
}
4. 元の記事を一段階下げる
さて、今迄作ってきたheadingを、元の記事と組合せる前段階をします。 トップレベル(level1)のheadingは一つだけであってほしいので、 元の記事のレベルを一段階下げます。これはpandocを用いて行うことができます。
但し、その前にYAMLヘッダーを取り除いてあげます。
BEGIN { inside=0; }
/---/ {if (inside == 0) { inside=1; }
else { inside=2; };
next;
}
{ if (inside == 2) { print $0; }; }
* ~Ixed~ とは
数学的解説はわかりませんごめんなさい。誰か補足があれば [[https://github.com/Cj-bc/blog][blogのレポジトリ]] にissueでも残してください()
Haskellなのでとりあえず hoogleを参照します。
~Ixed~ の定義は以下の通りです
#+begin_src haskell
class Ixed m where
-- |
-- /NB:/ Setting the value of this 'Traversal' will only set the value in
-- 'at' if it is already present.
--
-- If you want to be able to insert /missing/ values, you want 'at'.
--
-- >>> Seq.fromList [a,b,c,d] & ix 2 %~ f
-- fromList [a,b,f c,d]
--
-- >>> Seq.fromList [a,b,c,d] & ix 2 .~ e
-- fromList [a,b,e,d]
--
-- >>> Seq.fromList [a,b,c,d] ^? ix 2
-- Just c
--
-- >>> Seq.fromList [] ^? ix 2
-- Nothing
ix :: Index m -> Traversal' m (IxValue m)
default ix :: At m => Index m -> Traversal' m (IxValue m)
ix = ixAt
{-# INLINE ix #-}
#+end_src
~Ixed~ は =Lens= の提供する型の一つで、 ~Map~ のような型の値に対して
値を ~traverse~ するシンプルな ~Traversal~ を提供するものです。
簡潔に言うと、
*リスト等の要素にLensでアクセスできるようにするやつ*
みたいなざっくりとした理解をしています。
また、これに関連するオープンな型ファミリーとして ~Index~ と ~IxValue~ があります
#+begin_src haskell
type family Index (s :: *) :: *
-- | This provides a common notion of a value at an index that is shared by both 'Ixed' and 'At'.
type family IxValue (m :: *) :: *
#+end_src
~Ixed~ において、 ~Index~ はインデックスの型、 ~IxValue~ はそこに格納されている
値の型です。
* 作る
とりあえず作り始めます。
前提として、今回~Ixed~のインスタンスを作るのは以下の型です。
元のファイルは [[https://github.com/Cj-bc/playground/blob/0fb982f28f7ab0444ffd2ad59eacc3cd904b99ba/haskell/hit-n-blow/src/HitNBlow/Type.hs#L15-20][Cj-bc/playground -- hit-n-blow]] で使われているものです。
#+begin_src haskell
-- | Represents each Pin
data Pin = Red | Blue | Green | White | Purple deriving (Show)
-- | One Set of Pins that user will guess
data Lane = Lane (Maybe Pin) (Maybe Pin) (Maybe Pin) (Maybe Pin) (Maybe Pin)
deriving (Show)
#+end_src
~Ixed~ の定義に特に制限がかかれていないので、 ~ix~ を定義することにします。
そのために、 ~ix~ で使用される ~Index~ と ~IxValue~ を定義することにします。
** Index
~Index~ はあまり説明がありませんが、型の情報からすると恐らく「添字に使う型」
の定義であろうと推測が出来ます。
(名前が ~Index~ であること、 ~ix~ において最初に取ること等。又、
既にあるインスタンスを確認するのも良い方法だと思います。)
~Lane~ において添字は ~Int~ です。
#+begin_src haskell
type instance Index Lane = Int
#+end_src
** IxValue
同様ですが、今度はそれぞれの中身の型を定義します。
#+begin_src haskell
type instance IxValue Lane = Maybe Pin
#+end_src
** Ixed
~Ixed~ 本体に行きます!!
~ix~ の型は
#+begin_src haskell
ix :: Index m -> Traversal' m (IxValue m)
#+end_src
で、今回は ~m~ が ~Lane~ なので具体的な型にすると
#+begin_src haskell
ix :: Int -> Traversal' Lane (Maybe Pin)
#+end_src
ということになります。
で、 ~Lens~ 少ししか分からんので一つ疑問が浮かびます
*>>>>> ~Traversal'~ ってナニよ!!!!! <<<<<*
** Traversal' ってナニよ!
はい。名前は知ってるけど使い方良く分からずに放置してた子ですね。
定義によると
#+begin_src haskell
type Traversal' s a = Traversal s s a a
type Traversal s t a b = forall f. Applicative f => (a -> f b) -> s -> f t
#+end_src
ついでなので [[https://hackage.haskell.org/package/lens-5.0.1/docs/Control-Lens-Type.html#t:Traversal][~Traversal~]] の定義も載せておきました。
~Lens~ と同じように、実体はただの関数ですね。
~Lens~ よりも制限の緩い型で ~Traversable~ の型関数である ~traverse~ の一般化らしいです。
しっかりと理解はしていないが、まぁ型を考えれば作れてしまうのでとりあえずは
ふんわりと掴んだ状態で作ってみます。
あ、ちなみに ~Traversal'~ は単純に、値の更新等した時に型が変化しないものですね。
参考:
- [[https://fumieval.hatenablog.com/entry/2015/07/14/223329][lensパッケージのオプティクス(弱い順) -- モナドとわたしとコモナド]]
** ~ix~ を作る
さて、 ~Traversal'~ がわかったので ~ix~ を作れ(る気がし)ます。
~Traversal'~ を置き換えてみると:
#+begin_src haskell
ix :: Int -> Traversa' Lane (Maybe Pin)
ix :: Int -> Traversal Lane Lane (Maybe Pin) (Maybe Pin)
ix :: Int -> (forall f. Applicative f => (Maybe Pin -> f (Maybe Pin) -> Lane -> f Lane
#+end_src
となります(forallの位置は少し自信がないけど多分あってる)
~Int~ は元々 ~Index m~ だった部分なので、今興味のあるインデックス(に該当する数字)が来るのがわかります。
又、元の ~Traversal'~ の部分も要は「中身( ~Maybe Pin~ )に作用する関数を受け取り、作用させた
結果を返す」わけなので、その通りに実装します。
#+begin_src haskell
instance Ixed Lane where
ix 1 = \g l@(Lane a b c d e) -> Lane <$> g a <*> b <*> c <*> d <*> e
ix 2 = \g l@(Lane a b c d e) -> Lane a <$> g b <*> c <*> d <*> e
ix 3 = \g l@(Lane a b c d e) -> Lane a b <$> g c <*> d <*> e
ix 4 = \g l@(Lane a b c d e) -> Lane a b c <$> g d <*> e
ix 5 = \g l@(Lane a b c d e) -> Lane a b c d <$> g e
ix _ = \_ l -> pure l
#+end_src
多分動いた!!
pandoc -f org --shift-heading-level-by=1 -t org <(echo "
* Leve1 header example
hello!
** Inner level2 header
")
** Leve1 header example
hello!
* Inner level2 header
5. 3.と4.を組み合わせる
ここからはbashを使います。
[[ -z $1 ]] && { echo "usage: migration-script.sh TARGETFILE"; exit; }
target=$1
extensions=(emoji task_lists
backtick_code_blocks fenced_code_attributes
header_attributes raw_html
)
function formatExtension() {
echo "+${extensions[@]}" | tr ' ' '+'
}
format=$(filename=($(echo $target | tr -s '.' ' '));
case "${filename[-1]}" in
"md") echo "markdown$(formatExtension)";;
*) echo "org";;
esac
)
cat <(TARGET=$target ./blog-migration-script--createHeader.nu) <(awk '
<<awk-trim-yaml-header>>
' $target | pandoc -f "${format}" --shift-heading-level-by=1 -t org)
usage: migration-script.sh TARGETFILE
ここでnushellを使わなかった理由
nushellの echo
はListで出力してくるので、
GNU coreutilsの echo
を使います。
又、 ^echo $newHeading (pandoc...)
だと $newHeading
と
(pandoc...)
の間に改行が作成されず、 $newHeading
の後ろに空行を追加して
おいてもなんか消されてしまうので以下のような方法を取っています。
7. 全ファイルに対して実行する
あとはこれを全てのファイルに対して実行してあげれば良いわけです! これまでの遷移から、 2021/05/19以前の投稿が古いフォーマットを使用している ことがわかるので、それ以前のファイルのみを探し出します。
この取得はちょっと面倒で、昔のファイルでも最近変更していたりするので
find
コマンドが使えません( -newer
系を使いたいが、一定の日付より前のみに
変更しているという根拠はない)。
しかし、私のブログは(ありがたいことに)ファイル名が日付で始まっているので、それをglob展開して取得することにします。
そしてその各ファイルに先程のスクリプトを適用してあげて、変形します。
まずはGit管理されているファイルに実行する
ただ、念の為にまずはGitにコミットされているファイルのみを対象にします。コミットさえされていれば失敗しても大丈夫なので...
Gitに認識されていないファイルは srcsh{git status --short FILENAME} が ?? <ファイル名>
となるので、それを使って
判定します。
function filter {
local filterFunction=$1
while read candidate; do
$filterFunction "$candidate" \
&& echo "$candidate"
done < <(cat /dev/stdin)
}
function map {
local f=$1
while read target; do
$f "$target"
done < <(cat /dev/stdin)
}
function filterGitKnownFile {
local filename=$1
local gitStatusResult="$(git status --short $filename)"
[[ ! "$gitStatusResult" ]]
}
function convertFile {
local target=$1
./migration-script.sh $target > ${target}.new
mv ${target}.new $target
}
set -e
ls 2020-* 2021-0{1,2,3,4}* | grep "\(md\|org\)$" | filter "filterGitKnownFile" | map convertFile
convertFile 2021-05-04-xmonad-use-stack-for-compile.org
convertFile 2021-05-10-xmonad-list-of-layouts.org
echo "Finished!"
Finished!