CATEGORIES:JAVA KINTONE
kintoneで同じビル名の過去レコードから添付ファイルをコピーする

kintoneの編集画面にボタンを置き、同じビル名の過去レコードから添付ファイルをコピーする方法。申請書のひな形のように、同じビルなら毎回使うファイルを、ダウンロードして添付し直す手間を省く。

前回の記事では、同じビルの過去レコードから項目をコピーした。

ただし、添付ファイルフィールドは kintone.app.record.set() では変更できない。また、添付ファイルは「ファイルキー」という値で管理されていて、コピー元のファイルキーをそのまま別のレコードに入れてもコピーにはならない。そこで、ファイルを一度ダウンロードしてアップロードし直し、APIで直接保存する。

使い方は次のとおり。

  1. ビル名を入れて保存したレコードを編集画面で開く
  2. 「同じビルの添付資料を検索」を押す
  3. 同じビル名のレコードが、添付ファイルの件数と一緒に一覧で表示される
  4. 使いたいレコードの「このファイルのコピーを取得」を押す
  5. ファイルがコピーされて保存され、画面が再読み込みされる

項目のコピーと違い、画面に入れるのではなくその場で保存まで行うのが大きな違い。

検索と一覧表示は前回と同じで、同じアプリ内をビル名の部分一致で探す。違うのはコピーの部分で、次の3ステップを添付ファイル1つずつ繰り返す。

ステップ使うAPIやっていること
1. ダウンロードGET /k/v1/file.jsonコピー元のファイルキーを指定して、ファイルの中身を取得する
2. アップロードPOST /k/v1/file.json取得した中身を同じファイル名でアップロードし、新しいファイルキーをもらう
3. 保存PUT /k/v1/record.json新しいファイルキーを、開いているレコードの添付フィールドに保存する

ポイントは次のとおり。

ポイント内容
ファイルのやり取りは fetch を使うkintone.api() はJSONしか扱えないので、ファイルの中身(バイナリ)のダウンロードとアップロードは fetch で行う
ヘッダーとトークンを付けるfetch でkintoneのAPIを呼ぶときは、X-Requested-With: XMLHttpRequest ヘッダーが必要。アップロードでは kintone.getRequestToken() の値も一緒に送る
URLは kintone.api.url() で作る/k/v1/file.json と直書きすると、ゲストスペースのアプリでは動かない。kintone.api.url() なら環境に合わせたURLを返してくれる
保存後に画面を再読み込みするAPIで保存した内容は、開いている編集画面には反映されない。location.reload() で読み込み直して表示する
再読み込みの前に確認する再読み込みすると、編集画面で入力中の未保存の内容は消える。コピー前に確認ダイアログで知らせている
ファイルがないレコードは押せないようにする一覧に添付ファイルの件数を出し、0件ならボタンを無効にする

JavaScript全体(アプリの設定 →「JavaScript / CSSでカスタマイズ」でアップロードする。フォームには要素ID copy_file_space のスペースを置く)

/*
 * 同じビルの過去レコードから添付ファイルをコピーする
 *
 * 編集画面にボタンを置き、同じビル名のレコードを添付ファイルの件数と一緒に一覧表示する。
 * 一覧のボタンを押すと、そのレコードの添付ファイルを開いているレコードにコピーして保存する。
 */
(function () {
  "use strict";

  // ------------------------------------------------------------
  // 設定
  // ------------------------------------------------------------
  const FIELD_BUILDING = "ビル名";          // 検索に使うフィールド
  const FIELD_ATTACHMENT = "添付資料";       // コピーする添付ファイルフィールド
  const SPACE_ID = "copy_file_space";       // ボタンを置くスペースの要素ID
  const ALLOW_USERS = ["user_a", "user_b", "user_c"];   // ボタンを使えるユーザー(ログイン名)

  // ------------------------------------------------------------
  // 1. 編集画面を開いたときに、検索ボタンを置く
  // ------------------------------------------------------------
  kintone.events.on("app.record.edit.show", function (event) {

    // 許可したユーザー以外には何も表示しない
    const loginUser = kintone.getLoginUser().code;
    if (!ALLOW_USERS.includes(loginUser)) {
      return event;
    }

    const spaceEl = kintone.app.record.getSpaceElement(SPACE_ID);
    if (!spaceEl) {
      return event;
    }

    // 二重に表示されないよう、いったん空にする
    spaceEl.innerHTML = "";

    const button = document.createElement("button");
    button.textContent = "同じビルの添付資料を検索";
    button.style.marginBottom = "10px";

    const listArea = document.createElement("div");   // 検索結果を表示する場所

    spaceEl.appendChild(button);
    spaceEl.appendChild(listArea);

    // ----------------------------------------------------------
    // 2. 検索ボタンを押したとき
    // ----------------------------------------------------------
    button.onclick = function () {

      // ボタンを押した時点の画面の値を取る
      const current = kintone.app.record.get();
      const buildingName = current.record[FIELD_BUILDING].value;
      const currentRecordId = current.record.$id.value;

      if (!buildingName) {
        alert("ビル名が未入力です");
        return;
      }

      // ビル名に " が含まれていてもクエリが壊れないようにする
      const safeValue = buildingName.replace(/"/g, '\\"');

      // 同じアプリ内で、ビル名が部分一致し、自分以外のレコード
      const query = `${FIELD_BUILDING} like "${safeValue}" and $id != ${currentRecordId}`;

      kintone.api(kintone.api.url("/k/v1/records", true), "GET", {
        app: kintone.app.getId(),
        query: query
      }, function (resp) {

        listArea.innerHTML = "";

        // ビル名が「・」で始まるレコードは対象外
        const records = (resp.records || []).filter(function (rec) {
          return !(rec[FIELD_BUILDING].value || "").trim().startsWith("・");
        });

        if (records.length === 0) {
          listArea.textContent = "該当ビルはありません";
          return;
        }

        // --------------------------------------------------------
        // 3. 見つかったレコードを、添付ファイルの件数と一緒に一覧表示する
        // --------------------------------------------------------
        records.forEach(function (rec) {

          const files = rec[FIELD_ATTACHMENT].value || [];

          const row = document.createElement("div");
          row.style.backgroundColor = "#f9faf7";
          row.style.border = "1px solid #ccc";
          row.style.padding = "8px";
          row.style.margin = "5px";

          const title = document.createElement("div");
          title.textContent = `レコード番号: ${rec.$id.value} / ビル名: ${rec[FIELD_BUILDING].value}(添付ファイル: ${files.length}件)`;

          const copyButton = document.createElement("button");
          copyButton.style.marginTop = "5px";

          // 添付ファイルがないレコードはボタンを押せないようにする
          if (files.length === 0) {
            copyButton.textContent = "ファイルなし";
            copyButton.disabled = true;
          } else {
            copyButton.textContent = "このファイルのコピーを取得";
          }

          // ------------------------------------------------------
          // 4. コピーボタンを押したとき
          // ------------------------------------------------------
          copyButton.onclick = async function () {

            // 保存後に画面を再読み込みするので、未保存の入力が消えることを先に知らせる
            if (!confirm("ファイルをコピーして保存し、画面を再読み込みします。\n入力中の未保存の内容は消えます。よろしいですか?\n(他の項目を入力中の場合は「キャンセル」し、先に保存してください)")) {
              return;
            }

            copyButton.textContent = "コピー中...";
            copyButton.disabled = true;

            try {
              await copyAttachment(files, currentRecordId);
              alert("ファイルのコピーが完了しました。画面を再読み込みします。");
              location.reload();
            } catch (error) {
              console.error(error);
              alert("ファイルのコピー中にエラーが発生しました。");
              copyButton.textContent = "エラー";
            }
          };

          row.appendChild(title);
          row.appendChild(copyButton);
          listArea.appendChild(row);
        });

      }, function (error) {
        console.error(error);
        alert("検索エラー");
      });
    };

    return event;
  });

  // ------------------------------------------------------------
  // 添付ファイルのコピー(ダウンロード → アップロード → 保存)
  // ------------------------------------------------------------
  async function copyAttachment(files, targetRecordId) {

    // kintone.api.url() を使うと、ゲストスペースでも正しいURLになる
    const fileUrl = kintone.api.url("/k/v1/file", true);
    const newFileKeys = [];

    for (const file of files) {

      // ① ダウンロード:コピー元のファイルキーでファイルの中身を取得する
      const dlResp = await fetch(fileUrl + "?fileKey=" + encodeURIComponent(file.fileKey), {
        headers: { "X-Requested-With": "XMLHttpRequest" }
      });
      if (!dlResp.ok) {
        throw new Error("ダウンロード失敗: " + file.name);
      }
      const blob = await dlResp.blob();

      // ② アップロード:同じファイル名でアップロードし、新しいファイルキーを受け取る
      const formData = new FormData();
      formData.append("__REQUEST_TOKEN__", kintone.getRequestToken());
      formData.append("file", blob, file.name);

      const upResp = await fetch(fileUrl, {
        method: "POST",
        headers: { "X-Requested-With": "XMLHttpRequest" },
        body: formData
      });
      if (!upResp.ok) {
        throw new Error("アップロード失敗: " + file.name);
      }
      const upData = await upResp.json();
      newFileKeys.push({ fileKey: upData.fileKey });
    }

    // ③ 保存:新しいファイルキーを、開いているレコードの添付フィールドに保存する
    //    ※ 添付フィールドは丸ごと置き換わる(もともとあったファイルは残らない)
    await kintone.api(kintone.api.url("/k/v1/record", true), "PUT", {
      app: kintone.app.getId(),
      id: targetRecordId,
      record: {
        [FIELD_ATTACHMENT]: { value: newFileKeys }
      }
    });
  }
})();

自分の環境に合わせるときの変更箇所は次のとおり。コード先頭の「設定」だけ変えれば動く。

変えたいもの変更する場所
検索に使うフィールドFIELD_BUILDING
コピーする添付ファイルフィールドFIELD_ATTACHMENT
ボタンの置き場所SPACE_ID(フォームのスペースの要素ID)
ボタンを使える人ALLOW_USERS(ログイン名)
対象外にする目印startsWith("・") の ・

フィールドコードは記事用に置き換えている。

  • 添付フィールドは丸ごと置き換わる。開いているレコードにすでにファイルがあると、そのファイルは消えてコピー元のファイルだけになる。添付ファイルが空のレコードで使う
  • コピーするとその場で保存される。項目のコピーのように「保存せずにキャンセル」はできないので、間違えたときは手でファイルを削除する
  • 再読み込みで、編集画面で入力中の未保存の内容は消える。項目のコピーと組み合わせるときは、添付ファイルを先にコピーしてから項目をコピーして保存する
  • 新規作成画面では動かない。保存先のレコードがまだないため。ビル名だけ入れて一度保存してから使う
  • コピー元とは別のファイルとして保存されるので、コピー元のファイルを削除してもコピー先には影響しない。その分、kintoneのディスク容量も使う
  • 検索は最大100件まで。ビル名の検索の注意点は前回の記事と同じ

本番に反映する前に、テスト用のアプリで動作を確認するのを推奨。

NOTICES

  • 記事内容は実装させたものがほとんどですが自己責任で参考にしてください。

TO HEADER