改行コードを含む文章の置換をシェルスクリプトで行う

# 概要
Google App Script やシェルスクリプトを酷使して、セル内改行を含むスプレッドシートをもとに、なるべく体裁の崩れない置換を行う。
 `awk` や `sed` のセパレータに ASCII 制御コードを利用することで実現可能となった。


### 経緯
XML のメールテンプレートの置換作業をする必要に迫られた。
置換作業用スプレッドシートが存在するが、セル内改行を含むため何かと面倒。
それでも、量が量だからスクリプト化したい。
行単位でなく、任意の文章ブロック単位で置換を行うにはどうすればよいだろうか...


# 手順
置換作業のおおまかな流れは以下。

1. スプレッドシートをGASでエクスポート
1. 置換対象ファイルをリストアップ
1. 置換対象ファイルごとにレコードを抽出
1. 抽出したレコードごとに置換


## スプレッドシート側（Google Apps Script） の事前処理

### 対象のスプレッドシートの概要
おおよそ、このようなデータを想定している。
1列目は Github などのリモートのURLを記載して、シェルに渡した後に `sed` などでローカルのパスに置換をするといった運用もアリ。

|ファイルパス|置換前|置換後|
|---|---|---|
|/User/hoge/fuga/1.xml|タイトル|Title|
|/User/hoge/fuga/1.xml|本文<br><br>改行含む<br>文章|body<br><br>include new line<br>sentence|
|/User/hoge/fuga/1.xml|1ファイルで<br>複数箇所にわたる<br>埋め込みも想定|embedding at<br>multiple locations<br>in one file is also assumed|
|/User/hoge/fuga/2.xml|2つ目のファイル 文章1|second file 1st sentence|
|/User/hoge/fuga/2.xml|2つ目のファイル<br>文章2|second file<br>2nd sentence|
|/User/hoge/fuga/3.xml|3つ目のファイル|third file|
|...|...|...|


### スプレッドシートをエクスポート
CSV や TSV でエクスポートすると、改行が空白に置換されてしまう。
そこで、 Google Apps Script を使ってエクスポートする。

```js:gas
function main() {
  var data = importData();
  exportToDriveFolder(data);
​
  return;
}
​
function importData() {
  var spreadSheet = SpreadsheetApp.openById('対象スプレッドシートのID');
  var targetSheet = spreadSheet.getSheetByName('抽出対象シート名');
  var cells = targetSheet.getRange('A1:C327'); // 置換対象範囲を指定
  var values = cells.getValues();
  var rowsCount = values.length;
  var data = '';
​
  for(var i = 0; i < rowsCount; i++) {
    values[i] = values[i].join('___EOF___');
  }
​
  data = values.join('___EOR___');
​
  return data;
}
​
function exportToDriveFolder(data) {
  var driveFolderId = 'Google Driveにある任意のフォルダのID';
  var driveFolder = DriveApp.getFolderById(driveFolderId);
  var fileName = 'replace.txt';
  var contentType = 'text/plain';
  var charset = 'utf-8';
  var blob = Utilities.newBlob('', contentType, fileName).setDataFromString(data, charset);
​
  driveFolder.createFile(blob);
​
  return;
}
```

Google Driveにある任意のフォルダ内に `replace.txt` というファイル名でエクスポートする。
列の区切りは `___EOF___` 、行の区切りは `___EOR___` とする。スプレッドシート内に存在しない文字列であれば、任意のものでよい。
なお、通常のエクスポートのカンマやタブに相当するのが `___EOF___` で、 `___EOR___` は改行に相当する。


## シェルスクリプトでの置換作業

### 各種セパレータ用として ASCII 制御コードを変数に格納

まず、後の処理を行うための変数を定義する。

 `awk` や `sed` で用いるセパレータは通常、改行 `\n` や空白 `\s` 、 スラッシュ `/` などがデフォルト値として設定されている。しかし、それらを含むような文字列に対しては、エスケープ処理や代替文字への置換を施す必要がある。毎回エスケープ処理を行うのは煩雑で、通常の表記文字を代替文字とするのも文字の選定が難しい。

そこで、以下のように変数の定義を行い、その変数を `awk` や `sed` のセパレータとして利用する。

```bash:shell
FS=$( echo @ | tr @ '\034' ) # File Separator
GS=$( echo @ | tr @ '\035' ) # Group Separator
RS=$( echo @ | tr @ '\036' ) # Record Separator
US=$( echo @ | tr @ '\037' ) # Unit Separator
```

`@` 1文字を `echo` で出力した後に `tr` で制御コード（8進数コードを使用）に置換するという処理を、変数に格納している。これらの制御コードは改行コードなどと違い、通常の文章中には出現しない。

`@` は任意の表記文字でよい。置換する制御コードも、動作に影響のないものであれば、上記以外のものでも可。`\` によるエスケープコードが存在しないものがよさそう。

#### 2020-01-10 追記
[ printf だけで事足りるようです。(コメント欄)](https://qiita.com/soysource8850/items/a5631a39d8c7c0fb812e#comment-134782d7394c8c4bad05)

```bash:shell
FS=$( printf '\034' ) # File Separator
GS=$( printf '\035' ) # Group Separator
RS=$( printf '\036' ) # Record Separator
US=$( printf '\037' ) # Unit Separator
```

### 置換対象ファイルをリストアップ

```bash:shell

cat $IMPORT_DATA | # GASでエクスポートしたデータを読み込む
sed -e "s${GS}___EOF___${GS}${FS}${GS}g" \
    -e "s${GS}___EOR___${GS}${RS}${GS}g" |
awk -v FS=${FS} -v RS=${RS} 'NR>1 {print $1}' |
sort | uniq | # 重複の削除
# grep -e "必要に応じて抽出条件を指定" |
# sed -e "必要に応じてローカルのパスに置換" |
```

- `sed` で `___EOF___ ` を `FS` に、 `___EOR___ ` を `RS` にそれぞれ置換
- `awk` の `-v` オプションで `FS` および `RS` をセパレータとして設定し、1列目（URLやファイルパス）を抽出
- スプレッドシートのヘッダー行は `NR>1` をアクションの条件とすることで除外できる
-  置換対象の絞り込みやパスの置換作業などはこの時点で行う


### 置換対象ファイルごとにレコードを抽出

置換対象ファイルリストを `while` で回す。

```bash:shell
while read target_file #  置換対象ファイルリストを while で回す
do
    cat $IMPORT_DATA | # GASでエクスポートしたデータを読み込む
    tr $'\n' ${US} | # awkの変数に渡せるように改行を Unit Separator に置換
    sed -e "s${GS}___EOF___${GS}${FS}${GS}g" \
        -e "s${GS}___EOR___${GS}${RS}${GS}g" |
    awk -v FS=${FS} -v RS=${RS} -v OFS=${FS} -v ORS=${RS} \
        -v url_checker=$target_file '
        { url = $1; default = $2; replace = $3; }
        ( url == url_checker ) { print default,replace }
    ' | 
    tr ${RS} $'\n' # whileで回すためRecordSeparatorを改行に変換
    # | while IFS=${FS} read default_text replace_text ... (次項)
done
```

- 1番目のフィールドをチェックして、置換対象のファイルに対応するレコードだけ抽出
- 置換前に相当する列と置換後に相当する列の値を出力
- `awk` の `-v` オプションで、シェルから `awk` に変数を渡すことができるが、改行が含まれていると正常に渡せないため、あらかじめフィールド内の改行を Unit Separator に置換


### 抽出したレコードごとに置換
抽出したレコードを更に `while` で回し、レコード単位で置換作業を行う。

```bash:shell
while IFS=${FS} read default_text replace_text
do
    temporary_file=$(mktemp -t "temporary.xml.tmp") # 一時ファイルの作成
    cat $target_file | # 置換対象のXMLを読み込み

    # セパレータを仕込む
    sed -e "s${GS}<titleText>${GS}<titleText>${FS}${GS}g" \
        -e "s${GS}\(<plain><!\[CDATA\[\)${GS}\1${FS}${GS}g" \
        -e "s${GS}\(<content><!\[CDATA\[\)${GS}\1${FS}${GS}g" \
        -e "s${GS}</titleText>${GS}${RS}</titleText>${GS}g" \
        -e "s${GS}\]\]>${GS}${RS}\]\]>${GS}g" |

    awk -v FS=${FS} -v RS=${RS} -v OFS= -v ORS= \ # OFSとORSにはNULLを設定しておく
        -v default_text="$default_text" -v replace_text="$replace_text" '
        {
            non_target_text = $1
            target_text = $2
            length_checker = sqrt( ( length(target_text) - length(default_text) ) ^ 2 )
            length_checker_buffer = 4
            print non_target_text
        }
        ( length_checker > length_checker_buffer ) { print target_text }
        ( length_checker <= length_checker_buffer ) { print replace_text }
    ' |
    tr ${US} $'\n' > $temporary_file # Unit separator を改行に戻して、一時ファイルに出力
    cat $temporary_file > $target_file # 一時ファイルの内容を対象ファイルに出力
done
rm -f $temporary_file
```

- `read` のIFSに File Separator を設定し、置換前に相当するフィールドを `default_text` 、 変更後に相当するフィールドを `replace_text` に格納
- `sed` で置換対象の直前に File Separator を、置換対象の直後に Record Separator をうまいこと埋め込む
- `awk` のlength関数などを用いて `default_text` と `target_text` の文字数を比較
- 文字数の差が `length_checker_buffer` を超過する場合は、置換前の文章 `target_text` を出力
- 文字数の差が `length_checker_buffer` 以下であれば、置換適用箇所とみなして、置換後の文章 `replace_text` を出力

### AWKの組み込み変数

当記事で扱ったものは以下。

|変数名|説明|デフォルト値|
|---|---|---|
|FS|入力フィールドのセパレータ<br>入力フィールドの分割に使用する文字<br> -F オプションでも指定可能|空白|
|RS|入力レコードのセパレータ<br>入力レコードの分割に使用する文字|改行|
|OFS|出力フィールドのセパレータ<br>出力フィールドの分割に使用する文字|空白|
|ORS|出力レコードのセパレータ<br>出力レコードの分割に使用する文字|改行|
|NR|現在までに読み込んだレコードの合計||


## シェルスクリプト全体

実際に使う際は適度に関数化した方が良い。

```bash:replace.sh
#!/bin/bash

IMPORT_DATA="GASでエクスポートしたデータのパス"

FS=$( echo @ | tr @ '\034' ) # File Separator
GS=$( echo @ | tr @ '\035' ) # Group Separator
RS=$( echo @ | tr @ '\036' ) # Record Separator
US=$( echo @ | tr @ '\037' ) # Unit Separator

cat $IMPORT_DATA |
sed -e "s${GS}___EOF___${GS}${FS}${GS}g" \
    -e "s${GS}___EOR___${GS}${RS}${GS}g" |
awk -v FS=${FS} -v RS=${RS} 'NR>1 {print $1}' |
sort | uniq | # 重複の削除
# grep -e "必要に応じて抽出条件を指定" |
# sed -e "必要に応じてローカルのパスに置換"

while read target_file #  置換対象ファイルリストを while で回す
do
    cat $IMPORT_DATA |
    tr $'\n' ${US} | # awkの変数に渡せるように改行を Unit Separator に置換
    sed -e "s${GS}___EOF___${GS}${FS}${GS}g" \
        -e "s${GS}___EOR___${GS}${RS}${GS}g" |
    awk -v FS=${FS} -v RS=${RS} -v OFS=${FS} -v ORS=${RS} \
        -v url_checker=$target_file '
        { url = $1; default = $2; replace = $3; }
        ( url == url_checker ) { print default,replace }
    ' | 
    tr ${RS} $'\n' | # whileで回すためRecordSeparatorを改行に変換

    while IFS=${FS} read default_text replace_text
    do
        temporary_file=$(mktemp -t "temporary.xml.tmp") # 一時ファイルの作成
        cat $target_file | # 置換対象のXMLを読み込み

        # セパレータを仕込む
        sed -e "s${GS}<titleText>${GS}<titleText>${FS}${GS}g" \
            -e "s${GS}\(<plain>!\[CDATA\[\)${GS}\1${FS}${GS}g" \
            -e "s${GS}\(<content><!\[CDATA\[\)${GS}\1${FS}${GS}g" \
            -e "s${GS}</titleText>${GS}${RS}</titleText>${GS}g" \
            -e "s${GS}\]\]>${GS}${RS}\]\]>${GS}g" |

        awk -v FS=${FS} -v RS=${RS} -v OFS= -v ORS= \ # OFSとORSにはNULLを設定しておく
            -v default_text="$default_text" -v replace_text="$replace_text" '
            {
                non_target_text = $1
                target_text = $2
                length_checker = sqrt( ( length(target_text) - length(default_text) ) ^ 2 )
                length_checker_buffer = 4
                print non_target_text
            }
            ( length_checker > length_checker_buffer ) { print target_text }
            ( length_checker <= length_checker_buffer ) { print replace_text }
        ' |
        tr ${US} $'\n' > $temporary_file # Unit Separator を改行に戻して、一時ファイルに出力
        cat $temporary_file > $target_file # 一時ファイルの内容を対象ファイルに出力
    done

    rm -f $temporary_file
done
```


# 参考

### AWK
- [awk(1) FreeBSDドキュメントJMan](https://kaworu.jpn.org/doc/FreeBSD/jman/man1/awk.1.php)
- [AWK リファレンス | UNIX & Linux コマンド・シェルスクリプト リファレンス](https://shellscript.sunone.me/awk.html)
- [ＡＷＫのまとめ](http://www.osaka-kyoiku.ac.jp/~kokugo/nonami/awk/awkmini.html)
- [「シェル芸」に効く！AWK処方箋](https://www.shoeisha.co.jp/book/detail/9784798150918)

### GAWK
- [Man page of GAWK](http://linuxjm.osdn.jp/html/GNU_gawk/man1/gawk.1.html)
- [The GNU Awk User's Guide](http://www.kt.rim.or.jp/~kbk/gawk-30/gawk_toc.html)

### ASCIIコード
- [ASCIIコードの秘密 - ザリガニが見ていた...。](https://zariganitosh.hatenablog.jp/entry/20150209/ascii_code_secrets)
- [How can I parse a YAML file from a Linux shell script?](https://stackoverflow.com/questions/5014632/how-can-i-parse-a-yaml-file-from-a-linux-shell-script)

### Google Apps Script
- [Spreadsheet Service  |  Apps Script  |  Google Developers](https://developers.google.com/apps-script/reference/spreadsheet/)
- [【Google Apps Script入門】セルの取得・変更をする | UX MILK](https://uxmilk.jp/25841)