🫥

react-adminでCSVエクスポートの文字コードを変える

2024/11/16に公開

文字コードの扱い

一覧画面にはexporterという機能があり、一覧データをCSV形式で簡単にエクスポートが可能です。
しかし、日本での開発時には少し注意が必要です。

それは「文字コード」の問題です。

日本の管理ツールではしばしば、SJISなどの文字コードが採用されている場合があり、このwebアプリのCSVエクスポートの目的がそう言ったSJISなどの文字コードを前提としていた際に困ったことになります。
それは、react-adminのCSVエクスポート機能は文字コードを特に考慮していないため、常にutf-8で出力されます。
utf-8SJISは当然別物なので、特に気にせずに使うと急に「文字化けした」という不具合報告を受けることになり、かなり肝を冷やします。

考慮していない」ということは、「カスタムもできない」ということなので、文字コードをカスタムしたい場合は自作して組み込むことになります。

自作したよ

そこで、今回はその自作したものを紹介します。
私と同じ轍を踏む方が少しでも減ることを願っています、、

必要なライブラリのインストール

まずは、使用するライブラリを導入します。
それぞれの用途とインストールコマンドは下記です。
jsonexport: オブジェクトをCSV形式に変換するために使用しています。
encoding-japanese: 文字コードの変換(UTF-8からSJIS、またはその逆)に使用しています。
Papa: CSVのパース(解析)に使用しています。ヘッダーを自動で検出し、空行をスキップします。

npm install jsonexport encoding-japanese papaparse
# or
yarn add jsonexport encoding-japanese papaparse

マッピング定数の用意

次にいい感じにマッピング定数を用意しておきます。
これは「各データのヘッダーも日本語にしてほしい!」という要望に対応するためのものです。
この後用意するcsvHandler関数の中にべた書きすると管理の段階で死んでしまうので、定数ファイルへの切り分けを推奨します。

interface CsvResourceMapping {
  [key: string]: {
    key: number;
    data: string;
    label: string;
  }[];
}
export const csvExportMapping: CsvResourceMapping = {
  posts: [
    { key: 1, data: "id", label: "ID" },
    { key: 2, data: "name", label: "投稿者氏名" },
    { key: 3, data: "content", label: "投稿内容" },
  ],
  books: [
    { key: 1, data: "id", label: "ID" },
    { key: 2, data: "title", label: "書籍名" },
    { key: 3, data: "price", label: 価格" },
  ],
// などなど

関数の作成

用意できたら次は、こんな感じのcsvHandler.tsを作成します。
(こちらのコードは私が魔改造を続けて汚くなったコードでもあるので、きれいにした方がいたら教えてください。)

import jsonExport from "jsonexport/dist";
import { convert } from "encoding-japanese";
import Papa from "papaparse";
import {
  csvExportMapping,
  csvFileNameMapping,
  csvImportMapping,
} from "../const/csvMapping";
import { titleMapping } from "../const/dataConst";

/**
 * オブジェクトから特定のキーを再帰的に削除する関数
 * @param obj - 対象のオブジェクト
 * @param keysToRemove - 削除するキーの配列
 * @returns {any} - 指定したキーが削除されたオブジェクト
 */
const removeKeysRecursively = (obj: any, keysToRemove: string[]): any => {
  if (Array.isArray(obj)) {
    return obj.map((item) => removeKeysRecursively(item, keysToRemove));
  } else if (obj !== null && typeof obj === "object") {
    return Object.fromEntries(
      Object.entries(obj)
        .filter(([key]) => !keysToRemove.includes(key))
        .map(([key, value]) => [
          key,
          removeKeysRecursively(value, keysToRemove),
        ])
    );
  }
  return obj;
};

/**
 * オブジェクトからネストされたプロパティの値を取得するユーティリティ関数
 * @param obj - 対象のオブジェクト
 * @param path - プロパティのパス
 * @returns {*} - プロパティの値
 */
const getNestedValue = (obj: any, path: string) => {
  return path
    .split(".")
    .reduce(
      (acc, key) => (acc && acc[key] !== undefined ? acc[key] : null),
      obj
    );
};

/**
 * データをSJISエンコードのCSVとしてエクスポートする関数
 * @param data エクスポートするデータ
 * @param filename ダウンロードするCSVのファイル名
 */
export const exportToSjisCsv = (data: any[], resource: string) => {
  const mapping = csvExportMapping[resource];
  const fileNameLabel = csvFileNameMapping[resource] || "エクスポート";

  if (!mapping) {
    console.error(`Mapping for resource ${resource} not found`);
    return;
  }

  // カラムをフィルタリングし、ヘッダーを日本語に差し替える
  const sanitizedData = data.map((record) => {
    const filteredRecord: any = {};
    mapping.forEach(({ data: dataKey, label }) => {
        filteredRecord[label] = getNestedValue(record, dataKey); // 日本語ラベルをキーとして使用
    });
    return filteredRecord;
  });

  // CSVとしてエクスポート
  jsonExport(sanitizedData, (err, csv) => {
    if (err) {
      console.error("Export error:", err);
      return;
    }
    // UTF-8からSJISにエンコード
    const sjisArray = convert(csv, {
      from: "UNICODE",
      to: "SJIS",
      type: "array",
    });
    const sjisCsv = new Uint8Array(sjisArray);
    const blob = new Blob([sjisCsv], { type: "text/csv;charset=Shift_JIS" });
    const url = window.URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.style.display = "none";
    a.href = url;
    a.download = `${fileNameLabel}.csv`;
    document.body.appendChild(a);
    a.click();
    window.URL.revokeObjectURL(url);
  });
};

/**
 * 逆マッピングを作成する関数
 * @param mapping - csvResourceMapping のマッピングデータ
 * @returns {Record<string, string>} - 論理名から物理名へのマッピングオブジェクト
 */
const createReverseMapping = (mapping: { data: string; label: string }[]) => {
  const reverseMapping: Record<string, string> = {};
  mapping.forEach(({ data, label }) => {
    reverseMapping[label] = data;
  });
  return reverseMapping;
};

/**
 * オブジェクトにネストされたキーの値をセットする関数
 * @param obj - 対象のオブジェクト
 * @param path - キーのパス(例: 'restaurant.id')
 * @param value - セットする値
 */
const setNestedValue = (obj: any, path: string, value: any) => {
  const keys = path.split(".");
  let current = obj;
  keys.forEach((key, index) => {
    if (index === keys.length - 1) {
      current[key] = value;
    } else {
      if (!current[key]) {
        current[key] = {};
      }
      current = current[key];
    }
  });
};

/**
 * SJISエンコードのCSVファイルをUTF-8に変換して解析する関数
 * @param file CSVファイル
 * @param onComplete 完了時のコールバック関数
 * @param onError エラー時のコールバック関数
 */
export const importFromSjisCsv = (
  file: File,
  resource: string,
  onComplete: (data: any[]) => void,
  onError: (error: Error) => void
) => {
  const reader = new FileReader();

  reader.onload = (e) => {
    try {
      // ファイルの内容をArrayBufferとして読み込む
      const arrayBuffer = e.target?.result as ArrayBuffer;
      // SJISからUTF-8に変換
      const sjisArray = new Uint8Array(arrayBuffer);
      const utf8Text = convert(sjisArray, {
        from: "SJIS",
        to: "UNICODE",
        type: "string",
      });

      // パースしてデータを取得
      Papa.parse(utf8Text, {
        header: true,
        skipEmptyLines: true,
        complete: (results) => {
          const mapping = csvImportMapping[resource];
          if (!mapping) {
            throw new Error(`Mapping for resource ${resource} not found`);
          }

          // 逆マッピングを作成
          const reverseMapping = createReverseMapping(mapping);

          // ヘッダーのみで中身が空の場合のエラーチェック
          if (results.data.length === 0) {
            onError(
              new Error(
                "CSVの中身が空です。ヘッダーのみではインポートできません。"
              )
            );
            return;
          }

          // データのキーを論理名から物理名に変換
          const transformedData = results.data.map((record: any) => {
            const newRecord: any = {};
            Object.entries(record).forEach(([key, value]) => {
              const physicalKey = reverseMapping[key];
              if (physicalKey) {
                setNestedValue(newRecord, physicalKey, value);
              } else {
                // マッピングに存在しないキーは無視するか、そのまま使用
                newRecord[key] = value;
              }
            });

            return newRecord;
          });

          onComplete(transformedData);
        },
        error: (error: any) => {
          onError(error);
        },
      });
    } catch (error) {
      onError(error as Error);
    }
  };
  reader.onerror = (error) => {
    onError(new Error("ファイルの読み込みに失敗しました"));
  };

  reader.readAsArrayBuffer(file);
};

やりたかったこと

やりたいことはこれだけ
エクスポート処理:
オブジェクトデータをCSV形式に変換し、日本語ラベルに置き換えてSJISエンコードでファイルをダウンロードします。
インポート処理:
SJISエンコードのCSVファイルをUTF-8に変換し、データを解析してオブジェクトに変換。マッピングを使って正しいキーに戻します。

やったこと

なのですが、そのためになんやかんやする必要があり、結果的には

  1. キーの削除とプロパティの取得・設定
    removeKeysRecursively: オブジェクトから指定されたキーを再帰的に削除する関数です。オブジェクトがネストされている場合でも、すべての階層で指定されたキーを削除します。
    getNestedValue: ネストされたオブジェクトの特定のプロパティ値を取得するためのユーティリティ関数です。
    setNestedValue: ネストされたオブジェクトにプロパティ値を設定するための関数です。
  2. データのエクスポート(SJISエンコード)
    exportToSjisCsv: データをSJISエンコードのCSVファイルとしてエクスポートします。データを日本語のラベルに変換した後、UTF-8からSJISに変換してダウンロードできる形式にします。
  3. データのインポート(SJISからUTF-8への変換)
    importFromSjisCsv: SJISエンコードのCSVファイルを読み込み、UTF-8に変換してデータを解析します。解析後に、CSVのラベルを論理名から物理名に変換し、コールバック関数に渡します。
  4. 逆マッピングの作成
    createReverseMapping: CSVの論理名(日本語ラベル)から物理名へのマッピングを作成する関数です。これにより、CSVのヘッダーをインポート時に元のキーに戻します。

を組み合わせた形になりました。

つまり

要は、

  1. オブジェクトデータをCSVに変換
  2. Mapping定数に基づいてラベルを置き換え
  3. 文字コードをエンコード/デコード

の手順をエクスポート時とインポート時で逆順で行っているわけです。

まとめ

これで基本的な機能を備えたCSVインポートとエクスポート機能が実装できると思います。
あとは適切なボタンから適切に呼び出してあげてください。

あと、かなり雑なコードになっているので、きれいにした方がいましたら教えてください。まじで。

Discussion