コメントのバックアップ&リストアのコツ

目次

はじめに

コメントの取得や投稿、削除ができるAPIを使えば、コメントのバックアップとリストアが可能です。

万が一の事態でもkintoneのアプリを元の状態に復元できるように、kintone上のレコード情報とコメント情報を手元のPCに保存(バックアップ)して、それを別のアプリに復元(リストア)します。

完成イメージ

バックアップ元のアプリ

レコード一覧画面

レコード詳細画面

リストア先のアプリ

レコード一覧画面

レコード詳細画面

コメントリストアのコツ

ポイント1:リストア先のレコードIDを事前に知る必要がある

APIからリストアされたレコードには新しいIDが割り振られるため、投稿先のレコードIDが変わってしまいます。

このような場合、レコードのリストア完了時にレコード投稿APIが返してくれるレコードIDの一覧と、バックアップしたコメントデータを対応させ、新レコードIDの方にコメントを投稿します。

ポイント2:メンション情報付きコメントの再現

レコードコメントを取得するAPIで取得したコメントデータには、メンション情報(comments[].mentions)が含まれています。
このデータをそのまま レコードコメントを投稿するAPIのリクエストパラメーターに渡してリストアすると、宛先ユーザーに通知が送られてしまいます。

リストア時に宛先ユーザーへの通知を避けたい場合は、コメントデータのメンション情報(comments[].mentions)を削除してレコードコメントを投稿するAPIを実行する必要があります。
メンション先の宛先ユーザーの情報は、コメントの本文に@なしの文字列として含まれているため、誰にメンションしたかの情報は把握できます。

ポイント3:コメント投稿者名の再現

APIで投稿したコメントの投稿者は、APIログインに使ったユーザーです。
コメント投稿者名を復元するには、コメントの本文中に投稿者名を加えるなどの対応が必要です。

ポイント4:投稿時刻の再現

APIによるコメントの投稿日時は再投稿時の日時になります。
これは変更できないので、本文中に投稿時刻を埋め込んでリストアすることで回避します。

バックアップ時、時刻はUTCの文字列で届くので、日本時間に直してリストアします。

例:バックアップ元のコメント

例:リストア先のコメント

サンプルコード

sample_1.js

バックアップ元のアプリに適用するサンプルコードです。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
/*
 * comment backup sample (backup)
 * Copyright (c) 2020 Cybozu
 *
 * Licensed under the MIT License
 * https://opensource.org/license/mit/
 */

(function() {
  'use strict';
  function getCommentsData(record_id, opt_offset, opt_comments) {
    const offset = opt_offset || 0;
    const body = {
      app: kintone.app.getId(),
      record: record_id,
      offset: offset,
      order: 'asc' // ポイント(5)
    };
    let comments = opt_comments || [];
    return kintone.api(kintone.api.url('/k/v1/record/comments', true), 'GET', body).then((resp) => {
      comments = comments.concat(resp.comments);
      if (resp.older === true) {
        return getCommentsData(record_id, offset + 10, comments);
      }
      return comments;
    });
  }

  function downloadFile(blob, fileName) {
    if (window.navigator.msSaveOrOpenBlob) {
      // ブラウザがIEの場合
      window.navigator.msSaveOrOpenBlob(blob, fileName);
    } else {
      // ブラウザがIE以外の場合
      const link = document.createElement('a');
      const e = document.createEvent('MouseEvents');
      const url = (window.uRL || window.webkitURL).createObjectURL(blob);
      e.initMouseEvent('click', true, false, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null);
      link.download = fileName;
      link.href = url;
      link.dispatchEvent(e);
    }
  }

  // *** 本文[fetch-records] ここから ***
  function fetchRecords(appId, opt_offset, opt_limit, opt_records) {
    const offset = opt_offset || 0;
    const limit = opt_limit || 100;
    let allRecords = opt_records || [];
    const params = {app: appId, query: 'order by レコード番号 asc limit ' + limit + ' offset ' + offset};
    return kintone.api('/k/v1/records', 'GET', params).then((resp) => {
      allRecords = allRecords.concat(resp.records);
      if (resp.records.length === limit) {
        return fetchRecords(appId, offset + limit, limit, allRecords);
      }
      return allRecords;
    });
  }
  // *** 本文[fetch-records] ここまで ***

  // レコード一覧画面
  kintone.events.on(['app.record.index.show'], (event) => {
    // ヘッダの要素にボタンを作成
    const header_element = kintone.app.getHeaderMenuSpaceElement();
    const csv_button = document.createElement('button');
    csv_button.id = 'bulk-download-comment-csv';
    csv_button.innerText = 'レコードとコメントをバックアップ';

    csv_button.onclick = function() {
      fetchRecords(kintone.app.getId()).then((records) => {
        // 全レコードを取得したらJSON形式でダウンロード
        const recordBlob = new Blob([JSON.stringify(records)]);
        const recordFileName = kintone.app.getId() + '.json';
        downloadFile(recordBlob, recordFileName);

        // *** 本文[save-records-and-comments] ここから ***
        const comments = [];
        let done = 0;
        // レコードごとにコメントを取得し、配列に入れていく
        records.forEach((elem, i, original) => {
          getCommentsData(elem.$id.value).then((comments_for_record) => {
            // 取得したコメントを、レコードIDをキーとする配列に保存(ポイント(1))
            if (comments_for_record.length) {
              comments[elem.$id.value] = comments_for_record;
            }

            done++;
            // 最後のコメント取得が終わったら、JSON形式でダウンロード
            if (done === original.length) {
              const blob = new Blob([JSON.stringify(comments)]);
              const fileName = kintone.app.getId() + '_comments.json';
              downloadFile(blob, fileName);
            }
          });
        });
        // *** 本文[save-records-and-comments] ここまで ***
      });
    };

    header_element.appendChild(csv_button);
    return event;
  });
})();

sample_2.js

リストア先のアプリに適用するサンプルコードです。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
/*
 * comment backup sample (restore)
 * Copyright (c) 2020 Cybozu
 *
 * Licensed under the MIT License
 * https://opensource.org/license/mit/
 */

(function() {
  'use strict';

  // *** 本文[post-comments] ここから ***
  function postComment(app_id, record_id, comment) {
    return kintone.api(
      kintone.api.url('/k/v1/record/comment', true), 'POST',
      {app: app_id, record: record_id, comment: comment}
    );
  }

  // 古いコメントから順にリストア
  function postComments(app_id, record_id, comments) {
    postComment(app_id, record_id, comments[0]).then(() => {
      comments.shift();
      postComments(app_id, record_id, comments);
    });
  }
  // *** 本文[post-comments] ここまで ***

  const keys_to_remove = [
    'レコード番号',
  ];

  function sanitizeRecords(records) {
    return records.map((record, index, array) => {
      keys_to_remove.forEach((key) => {
        delete record[key];
      });

      return record;
    });
  }

  function buildTimeString(time) {
    const offset = 9 * 3600 * 1000; // (日本時間 - UTC): 9[時間] * 3600[秒/時] * 1000[ミリ秒/秒]
    const timeUTC = new Date(time).getTime();
    const date_string = new Date(timeUTC + offset).toISOString();
    return date_string.slice(0, 10) + ' ' + date_string.slice(11, 19);
  }

  // *** 本文[build-comments] ここから ***
  function buildComments(comments) {
    return comments.map((comment, index, array) => {
      // ポイント(2)
      delete comment.mentions;
      // ポイント(3)
      comment.text += '\n投稿者: ' + comment.creator.name;
      // ポイント(4)
      comment.text += '\nコメント日時: ' + buildTimeString(comment.createdAt);

      return comment;
    });
  }
  // *** 本文[build-comments] ここまで ***

  // レコード一覧画面
  kintone.events.on(['app.record.index.show'], (event) => {

    const appId = event.appId;

    // *** 本文[read-data] ここから ***
    // 読み込んだファイルをテキストエリアに表示
    function inputFileContent(evt, dest_element) {
      const reader = new FileReader();
      reader.readAsText(evt.target.files[0]);
      reader.onload = function(ev) {
        dest_element.val(reader.result);
      };
    }

    // ダイアログでファイルが選択されたらテキストボックスに内容を表示
    $('#record-data').bind('change', (evt) => {
      inputFileContent(evt, $('textarea[name="record-data-txt"]'));
    });
    $('#comment-data').bind('change', (evt) => {
      inputFileContent(evt, $('textarea[name="comment-data-txt"]'));
    });
    // *** 本文[read-data] ここまで ***

    // リストアボタンが押された時の処理
    $('#post_btn').bind('click', () => {
      // *** 本文[load-data] ここから ***
      const records = JSON.parse($('textarea[name="record-data-txt"]').val());
      const comments = JSON.parse($('textarea[name="comment-data-txt"]').val());
      // *** 本文[load-data] ここまで ***

      const old_ids = records.map((record, index, array) => {
        return record.$id.value;
      });

      if (window.confirm('リストアを実行します。よろしいですか?')) {
        // まずレコードのリストアを実行
        // *** 本文[restore-comment] ここから ***

        // *** 本文[restore-record] ここから ***
        kintone.api(kintone.api.url('/k/v1/records', true), 'POST', {app: appId, records: sanitizeRecords(records)}, (resp) => {
          // *** 本文[restore-record] ここまで ***

          // *** 本文[find-comments] ここから ***
          const new_ids = resp.ids;

          for (let i = 0; i < old_ids.length; i++) {
            const old_id = old_ids[i];
            const new_id = new_ids[i];

            // リストアされたレコードに対応するコメントデータが存在する場合、リストアする
            if (comments[old_id] !== null && comments[old_id].length) {
              // 旧IDのコメントを新IDのレコードに対してリストア (ポイント(1))
              postComments(appId, new_id, buildComments(comments[old_id]));
            }
          }
          // *** 本文[find-comments] ここまで ***
        });
        // *** 本文[restore-comment] ここまで ***
      }
    });

    return event;
  });
})();

サンプル実行方法

バックアップ元アプリの設定

サンプルコード sample_1.jsの内容でファイルを保存し、バックアップするアプリに読み込ませます。

レコードのバックアップ

一覧画面のバックアップボタンをクリックして、レコードとコメント両方のバックアップデータをダウンロードします。

リストア先アプリの作成

バックアップ元のアプリをコピーして、同じアプリを作成します。

ビュー作成

バックアップファイルを読み込むためのフォームを作成します。
kintoneのレコードの一覧の表示形式を「カスタマイズ」にして、以下のHTMLを記述します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<!--
 * comment backup sample
 * Copyright (c) 2020 Cybozu
 *
 * Licensed under the MIT License
 * https://opensource.org/license/mit/
-->
<form name="test">
  <p>リストアするレコードデータ</p>
    <input type="file" id="record-data"><br>
    <textarea name="record-data-txt" rows="10" cols="50"></textarea><br>
  <p>リストアするコメントデータ</p>
    <input type="file" id="comment-data"><br>
    <textarea name="comment-data-txt" rows="10" cols="50"></textarea>
</form>
<button id="post_btn">リストア!</button>

カスタマイズJS導入

次のJavaScriptファイルをリストア先のアプリに読み込ませます。

リストア

リストア先のアプリの一覧画面に移動して、先ほど作成したカスタマイズビューを開きます。

レコードとコメントそれぞれの、先ほどダウンロードしたバックアップデータを読み込ませます。
読み込みに成功すると、ファイル選択部分の下のテキストボックスに、読み込んだファイルの内容が表示されます。

レコードとコメントの両方を読み込んだら、リストアボタンを押せば、レコードとコメントの両方がリストアされます。
一覧画面に戻って、リストアに成功したことを確認します。

サンプルコードの解説

レコードのバックアップ

「sample_1.js」のfetchRecords関数では、kintone APIを利用して、バックアップ元からレコードデータを取得します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
function fetchRecords(appId, opt_offset, opt_limit, opt_records) {
  const offset = opt_offset || 0;
  const limit = opt_limit || 100;
  let allRecords = opt_records || [];
  const params = {app: appId, query: 'order by レコード番号 asc limit ' + limit + ' offset ' + offset};
  return kintone.api('/k/v1/records', 'GET', params).then((resp) => {
    allRecords = allRecords.concat(resp.records);
    if (resp.records.length === limit) {
      return fetchRecords(appId, offset + limit, limit, allRecords);
    }
    return allRecords;
  });
}

コメントのバックアップ

コメントデータの一括取得

コメント取得のAPIでは一度に10個までしか取得できないので、再帰処理で全件取得します。
また、コメント取得順に「昇順(asc)」を指定します(sample_1.js)。

取得したデータの加工

取得したコメントデータを、プログラム内で使う配列のままファイルに書き込んで、JSON形式で保存します(sample_1.js)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
const comments = [];
let done = 0;
// レコードごとにコメントを取得し、配列に入れていく
records.forEach((elem, i, original) => {
  getCommentsData(elem.$id.value).then((comments_for_record) => {
    // 取得したコメントを、レコードIDをキーとする配列に保存(ポイント(1))
    if (comments_for_record.length) {
      comments[elem.$id.value] = comments_for_record;
    }

    done++;
    // 最後のコメント取得が終わったら、JSON形式でダウンロード
    if (done === original.length) {
      const blob = new Blob([JSON.stringify(comments)]);
      const fileName = kintone.app.getId() + '_comments.json';
      downloadFile(blob, fileName);
    }
  });
});

バックアップされたデータの読み込み

ユーザーが指定したバックアップファイルを「リストア」ボタンが押されたタイミングに読み込みます(sample_2.js)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 読み込んだファイルをテキストエリアに表示
function inputFileContent(evt, dest_element) {
  const reader = new FileReader();
  reader.readAsText(evt.target.files[0]);
  reader.onload = function(ev) {
    dest_element.val(reader.result);
  };
}

// ダイアログでファイルが選択されたらテキストボックスに内容を表示
$('#record-data').bind('change', (evt) => {
  inputFileContent(evt, $('textarea[name="record-data-txt"]'));
});
$('#comment-data').bind('change', (evt) => {
  inputFileContent(evt, $('textarea[name="comment-data-txt"]'));
});

レコードのリストア

レコードのリストアに成功すると、登録されたレコードIDの配列などを保持したオブジェクトが返ってきます。
この新IDの配列を利用して、コメントをリストアします(sample_2.js)。

1
2
3
kintone.api(kintone.api.url('/k/v1/records', true), 'POST', {app: appId, records: sanitizeRecords(records)}, (resp) => {
  // ...
});

コメントのリストア(sample_2.js)

リストアするコメントの整形

コメントリストアのコツの2〜4に対応する処理を実施します。
コメントをリストアするためにメンションを削除し、日付を追加して、元の投稿者名を入れる処理をしています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
function buildComments(comments) {
  return comments.map((comment, index, array) => {
    // ポイント(2)
    delete comment.mentions;
    // ポイント(3)
    comment.text += '\n投稿者: ' + comment.creator.name;
    // ポイント(4)
    comment.text += '\nコメント日時: ' + buildTimeString(comment.createdAt);

    return comment;
  });
}
リストアするコメントデータを探す

バックアップデータの中身は、元のレコードIDをインデックス、そのレコードの全コメントを値とする配列になっていました。
レコードのリストア後に新しいレコードIDを受け取ることができるので、「旧ID」「新ID」「旧IDに対応するコメントデータ」を使って、新IDに対してコメントをリストアします。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
const new_ids = resp.ids;

for (let i = 0; i < old_ids.length; i++) {
  const old_id = old_ids[i];
  const new_id = new_ids[i];

  // リストアされたレコードに対応するコメントデータが存在する場合、リストアする
  if (comments[old_id] !== null && comments[old_id].length) {
    // 旧IDのコメントを新IDのレコードに対してリストア (ポイント(1))
    postComments(appId, new_id, buildComments(comments[old_id]));
  }
}
コメントの投稿

コメントのリストアは、レコードのリストア完了後に処理します。
レコード投稿API(非同期処理)のコールバックに、コメントのリストアのコードを記述しましょう。
postCommentsは、レコードに対して複数コメントをリストアするための関数です。
コメント投稿APIでは一度にひとつしか投稿できないため、ループ処理しています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
function postComment(app_id, record_id, comment) {
  return kintone.api(
    kintone.api.url('/k/v1/record/comment', true), 'POST',
    {app: app_id, record: record_id, comment: comment}
  );
}

// 古いコメントから順にリストア
function postComments(app_id, record_id, comments) {
  postComment(app_id, record_id, comments[0]).then(() => {
    comments.shift();
    postComments(app_id, record_id, comments);
  });
}

最後に

以上でレコードとコメントのバックアップとリストアができました。
新しいAPIを使えば、これまでできなかったコメントの操作がいろいろと便利になりそうです!

サンプルコードの制限事項

  • 「ファイル」や「計算」のフィールドがあるデータでは動作しません。
    リストア側の計算フィールドにもバックアップ元のアプリに使われた数値フィールドと計算式を入れれば使用可能です。
    文字列の計算フィールドも同様です。
  • 今回のサンプルでは、バックアップ時とリストア時のどちらでも、すべてのデータを一度にメモリへ読み込んでいます。
    そのため、レコード数が多いアプリでは動作しないことがあります。

参考記事