Googleドキュメントを、GASで編集する。

この記事は[ZOZOテクノロジーズ #1 Advent Calendar 2019](https://qiita.com/advent-calendar/2019/zozo_tech) 19日目の記事になります。

今年、ZOZOテクノロジーズでは全部で5つのAdvent Calendarが公開されています。
[ZOZOテクノロジーズ #1 Advent Calendar 2019](https://qiita.com/advent-calendar/2019/zozo_tech)
[ZOZOテクノロジーズ #2 Advent Calendar 2019](https://qiita.com/advent-calendar/2019/zozo_tech2)
[ZOZOテクノロジーズ #3 Advent Calendar 2019](https://qiita.com/advent-calendar/2019/zozo_tech3)
[ZOZOテクノロジーズ #4 Advent Calendar 2019](https://qiita.com/advent-calendar/2019/zozo_tech4)
[ZOZOテクノロジーズ #5 Advent Calendar 2019](https://qiita.com/advent-calendar/2019/zozo_tech5)
様々な記事が投稿されていますので、是非ご覧ください。

# 概要
GASを使って、Googleドキュメントの、特定の文字列が該当する見出し・箇条書きに編集を加える方法についてご紹介します。
調べても記事が少なく、正解が分からなかったり、色々と考えた部分があるので少しでも参考になれば幸いです。

## 背景
所属しているチームでは、'TGIF'と称して毎週技術的なトピックを共有する会を開催しています。
その議事録のファイルをGoogleドキュメントで毎週生成していました。

1. 先週のドキュメントファイルをコピーして、ファイル名の日時を書き換える
2. ドキュメント本文の、各メンバーの「来週やること」という見出しに続く箇条書き（listItem）を「今週やったこと」に移す

上記の作業を毎週手動で繰り返していたのですが、これを自動化することとなりました。
この記事では、そのうち2.の、特定の項目（今回はlistItem）を、旧ファイルから新ファイルにコピーする方法について記します。

### なぜこの記事を書いたのか
GASを使ったスプレッドシートの自動生成・編集を扱った記事は数多く見受けられます。
一方でドキュメントとなると自動生成の記事はあるものの、本文の編集の記事はあまり見受けられませんでした。
そのため、今回記事を執筆する運びとなりました。

# 解決方法
共通する部分のみを抜き出したテンプレートファイルを作成し、先週のファイルから必要部分を抜き出してコピペをするという方針をとりました。
（実装する上では、テンプレートを作る方法が最もやりやすかったためです。）

## ベースとして参考にした記事
[GASを利用した定例議事録作成の自動化
](https://qiita.com/takeyoshi-k/items/3c914dfdd509ecc20a7c)大いに参考にさせていただきました。ありがとうございます。

#手順
とにかくコピペして使いたい方は[こちら](#コード全体)
太字が今回の記事の対象範囲です。そのため、フォルダには**①テンプレートファイル、②先週のファイル、③テンプレートファイルをコピーして作成された新ファイル**の3つが存在する状態から始めます。
1. 議事録のテンプレートをコピーして、会が開催される日付を用いてファイル名を更新し新ファイルを作成する
**2. 先週のファイルから該当するlistItemを配列として取得する**
**3. 新ファイルの該当する行番号を取得する**
**4. 新ファイルにlistItemをコピーする**

## 用語の確認
リスト:箇条書きのかたまりのことで、複数の項目や階層を含んだ一連のかたまり
listItem:リストを構成する1つ1つの項目

## 議事録の例
各メンバーそれぞれに、「今週やったこと」と「来週やること」の見出しと、それに続くリストがあります。
今回は普通のparagraph（上記見出しの行）とlistItem（リストを構成する行）の種別の違いを利用して実装を行いました。
テンプレートファイルは、それぞれのリストが空の状態のドキュメントになります。
![スクリーンショット (36).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/146971/fcf9f93d-9f54-1f41-eab5-e1832068028d.png)


## ファイルから、該当する文字列を見出しとするリストを、配列として取得する
今回、該当する見出しが「来週やること」で、以下の関数では`target_word_from`にあたります。

```javascript
/*
*　コピー元ファイルのlistItemを取得
*/
function getCopyListItemArray(paragraph_array,  target_word_from) {
  var copy_paragraph_set = []; //target_word_fromに続くリストのセットを格納する配列

  // 旧ファイルのパラグラフを格納した配列から、target_word_fromに該当するパラグラフに続くlistItemを抜き出す
  paragraph_array.forEach(function(paragraph) {
    if(paragraph.getText() == target_word_from){
      var target_paragraph = paragraph.getNextSibling();
      var target_paragraph_array = [];
      while(1){
        target_paragraph_array.push(target_paragraph);
        target_paragraph = target_paragraph.getNextSibling();

        // listItemではなくparagraph（＝見出しや空行など）が出たことを、リストの終わりとして認識
        if(target_paragraph.getType() == 'PARAGRAPH'){
          break;
        }
      }
      // 各メンバーのリストにあるlistItemをひとまとめにする
      copy_paragraph_set.push(target_paragraph_array);
    }
  });
  return copy_paragraph_set;
}
```
## 新ファイルの、該当する文字列が位置する行番号を取得する
テンプレートファイルをコピーして生成された新ファイルにおける、コピー先に該当する行番号を探します。
今回、該当する文字列は「今週やったこと」で、以下の関数では`target_word_to`にあたります。

```javascript
/*
* コピー先新ファイルのlistItemの場所を取得
*/
function getPositionArray(paragraph_array,target_word_to){
  var position_array = [];
  paragraph_array.forEach(function(paragraph, index) {
    if(paragraph.getText() == target_word_to){
      position_array.push(index + 1);
    };
  });
  return position_array;
}
```

## 取得したlistItemと行番号を用いて、新ファイルにコピーする
上記2つの関数を利用して、新ファイルに必要な項目をコピーしていきます。
ここで、2つのポイントがあります。
1. ファイルの下から挿入しています（先頭から挿入すると、それに続く行番号が影響を受けるため）
2. 箇条書きの記号（行頭文字）は、1つのリストのかたまりごとにlistItemを全て挿入し終えてから設定します（そのように設定しないとリストの1番上のみ設定が反映することとなるため）

```javascript
/*
* 新ファイルにlistItemを挿入
*/
function insertListItemsSets(copy_paragraph_set, position_array, content_new){
  var position_array_reverse = position_array.reverse(); // コピー先の位置を行末からに並べ替える
  
  // コピーするリスト1つずつ処理を行う
  copy_paragraph_set.reverse().forEach(function(paragraph_array, index_paragraph_array){
    var append_array = [];
    var position = position_array_reverse[index_paragraph_array];

    // コピーするlistItem1つずつ処理を行う
    paragraph_array.forEach(function(paragraph, index_paragraph){
      var append_listItem = content_new.insertListItem(position + index_paragraph, paragraph.getText()).setNestingLevel(paragraph.getNestingLevel());
      append_array.push(append_listItem);
    });
    
    // 箇条書きの記号をリストごとに設定する
    append_array.forEach(function(list_item, index_list_item){
      list_item.setGlyphType(paragraph_array[index_list_item].getGlyphType());
    });
  });
}
```

## コード全体
```js
var folder = DriveApp.getFolderById('議事録を保存しているフォルダID');
var template_file = folder.getFilesByName('テンプレートドキュメントのファイル名').next();
var last_file = folder.getFilesByName('旧ファイル名').next();
var new_file = folder.getFilesByName('新ファイル名').next();


/*
* 新ファイルに特定のlistItemをコピー
*/
function copyContent() {
  // 元のファイルからコピーする部分のリストを取得
  var content = DocumentApp.openById(last_file.getId()).getBody();  
  var paragraph_array_old = content.getParagraphs();
  var target_word_from = '来週やること'; 
  var copy_paragraph_set = getCopyListItemArray(paragraph_array_old, target_word_from);
  
  // 新ファイルにおけるコピー先の位置を取得
  var content_new = DocumentApp.openById(new_file.getId()).getBody();
  var paragraph_array_new = content_new.getParagraphs();
  var target_word_to = '今週やったこと';
  var position_list_array = getPositionArray(paragraph_array_new, target_word_to);
  
  // 上2つの結果を用いて、新ファイルにコピー
  insertListItemsSets(copy_paragraph_set, position_list_array, content_new);
}


/*
*　コピー元ファイルのlistItemを取得
*/
function getCopyListItemArray(paragraph_array,  target_word_from) {
  var copy_paragraph_set = []; //target_word_fromに続くリストのセットを格納する配列

  // 旧ファイルのパラグラフを格納した配列から、target_word_fromに該当するパラグラフに続くlistItemを抜き出す
  paragraph_array.forEach(function(paragraph) {
    if(paragraph.getText() == target_word_from){
      var target_paragraph = paragraph.getNextSibling();
      var target_paragraph_array = [];
      while(1){
        target_paragraph_array.push(target_paragraph);
        target_paragraph = target_paragraph.getNextSibling();
        if(target_paragraph.getType() == 'PARAGRAPH'){
          break;
        }
      }
      // 各メンバーのリストにあるlistItemをひとまとめにする
      copy_paragraph_set.push(target_paragraph_array);
    }
  });
  return copy_paragraph_set;
}

/*
* コピー先新ファイルのlistItemの場所を取得
*/
function getPsitionArray(paragraph_array,target_word_to){
  var position_array = [];
  paragraph_array.forEach(function(paragraph, index) {
    if(paragraph.getText() == target_word_to){
      position_array.push(index + 1);
    };
  });
  return position_array;
}

/*
* 新ファイルにlistItemを挿入
*/
function insertListItemsSets(copy_paragraph_set, position_array, content_new){
  var position_array_reverse = position_array.reverse(); // コピー先の位置を行末からに並べ替える
  
  // コピーするリスト1つずつ処理を行う
  copy_paragraph_set.reverse().forEach(function(paragraph_array, index_paragraph_array){
    var append_array = [];
    var position = position_array_reverse[index_paragraph_array];

    // コピーするlistItem1つずつ処理を行う
    paragraph_array.forEach(function(paragraph, index_paragraph){
      var append_listItem = content_new.insertListItem(position + index_paragraph, paragraph.getText()).setNestingLevel(paragraph.getNestingLevel());
      append_array.push(append_listItem);
    });
    
    // 箇条書きの記号をリストごとに設定する
    append_array.forEach(function(list_item, index_list_item){
      list_item.setGlyphType(paragraph_array[index_list_item].getGlyphType());
    });
  });
}
```

# 試行錯誤した点
## `getPositionArray`を普通のjavascriptで処理している理由
- GASはAPI呼び出しを多く行うと処理速度が遅くなるため、一度に全体を取得した後javascriptで該当箇所を検索しています
- GASに[`findText(searchPattern, from) `](https://developers.google.com/apps-script/reference/document/list-item#findtextsearchpattern,-from)という関数があるのですが、最初の1つしか検索することができず、また正しい使い方も見つからなかったため（どなたか使い方が分かったら教えてください）

## テンプレートファイルをコピーして新ファイルを作成している理由
これは、手動の場合と同様に旧ファイルをコピーした上でそのファイルを編集するのではなく、テンプレートから新ファイルを生成した理由になります。

- GASで行数不定、さらに様々な該当箇所削除をするのが困難なため
 

## `insertListItemsSets`において、ドキュメントの後ろからlistItemを挿入している理由
- `getListHeaderPositionArray`で取得した行番号ですが、listItemを先頭から挿入していくとそれに合わせて続く行番号も変更するため

## `insertListItemsSets`において、リストごとにlistItemの箇条書き記号を設定している理由
- 箇条書き記号をリストごとに一括して設定する必要があるのは仕様のようでした。
（参考：[Googleドキュメントのリスト項目のグリフが制御できない](https://www.subthread.co.jp/blog/20190227/)）

# まとめ
この記事では、定期的に生成する議事録などのドキュメントの特定の箇条書き（リスト）を、旧ファイルから新ファイルにコピーする方法について説明しました。
結構大変だったし、メンバーが4人しかいないので手動でコピーを続けた方が楽だったかもしれません。(笑)（20人ぐらいなれば効率化になりそうですが）
しかしながら、ドキュメントも思い通りにGASで操作できること、またその方法をしっかりとまとめることができて良い経験になりました。この記事を読んでくださったみなさまが、簡単に実装できれば何よりです。

他にも、「こういうことできないの？」や、「こういうことしてみたいんだけど調べてみてよ！」ということがあれば、どしどしコメントをよろしくお願いいたします！


## 前後のアドベントカレンダーのお知らせ
昨日は、@katsuyanさんの「[Googleスプレッドシートの縦横変換をSQLでおこなう](https://qiita.com/katsuyan/items/2abb92507012cc0636ce)」でした。
スプレッドシートは本当に様々な機能に対応していて、改めて便利な機能があると感じました。
（ちなみに私は最近Bigqueryと連携して使っています。）


明日は、@awsmgsさんの「VBScriptのfor文でcontinue的なことを実現する」です。


# （おまけ）GASを推す理由
GASそのものはどこか地味だし、記法も古くてイケてないと思っています。
（もちろん[clasp](https://github.com/google/clasp)などもあるので工夫は可能です。）

しかしながら、Googleドライブの様々なサービスに対応していること、時間などのトリガーを使った定期実行ができること、その他にも外部のサービスを利用したり外部に公開することもできるなど、使い道が幅広いです。
そういった特徴を持つGASを使って、今後とも自動化を心がけ、莫大なる効率化を生み出していきたいと思います。
楽しーーーーーーい！！！