家計簿アプリを作る #43:CSVインポート機能

家計簿アプリ作成シリーズの第43回です。以前実装したCSVエクスポートの逆で、CSVファイルから収支データを一括登録できるインポート機能を作ります。

実装方針

「登録」ボタンが /transactions/new への別ページリンクになっているのと同じパターンで、「CSVインポート」ボタンも /import という別ページへのリンクにしました。一覧ページに直接アップロード用の <form> を埋め込むことも技術的には可能でした(削除用フォームが複数存在するのと同様、1ページに複数の <form> を置くこと自体は問題ない)が、ファイル選択のUIをボタン風に見せかける工夫が必要になり煩雑になるため、専用ページに分けました。

Fileuploadコンポーネントの利用

flowbite-svelteの Fileupload コンポーネントは内部的に通常の <input type="file"> をレンダリングするので、name 属性を指定すれば FormData で受け取れます。<form> には enctype="multipart/form-data" を明示する必要があります。

<form method="POST" action="?/import" enctype="multipart/form-data" use:enhance>
  <Fileupload id="file" name="file" accept=".csv" required />
  <Button type="submit">インポート</Button>
</form>

CSVパースとバリデーション

CSVエクスポート時のフォーマット(日付,種類,カテゴリ,メモ,金額)に合わせてパースします。

const text = await file.text();
const lines = text.split('\n').filter(line => line.trim() !== '');
const [header, ...rows] = lines;

const [header, ...rows] = lines; は配列の分割代入で、先頭の1行(ヘッダ)と、残りの行(rest構文でまとめた配列)を1回の記述で分離しています。

不正な行を黙って捨てない

最初は不正な行(日付や金額が空、種類が「収入」「支出」以外など)を null にしてフィルタで除外するだけの実装でしたが、これだとユーザーに何も知らされないまま一部の行だけが登録される、という分かりにくい挙動になってしまいます。

そこで、不正な行があった場合はDB登録を一切行わず、エラーとして返す(all-or-nothing)方針にしました。map の中で正常な行はデータを組み立て、不正な行は行番号を errorRows に積んでおき、map の後に errorRows.length > 0 かどうかで分岐します。

const errorRows: number[] = [];
const newTransactions = rows.map((row, index) => {
  const [date, typeLabel, category, memo, amount] = row.split(',');

  if (!date || !typeLabel || !category || !amount) {
    errorRows.push(index + 2); // ヘッダ行(1行目)を考慮して+2
    return null;
  }
  if (!['収入', '支出'].includes(typeLabel)) {
    errorRows.push(index + 2);
    return null;
  }
  if (isNaN(Number(amount)) || Number(amount) <= 0) {
    errorRows.push(index + 2);
    return null;
  }

  return {
    userId: String(userId),
    date: String(date),
    type: typeLabel === '収入' ? 'income' : 'expense',
    category: String(category),
    memo: String(memo),
    amount: Number(amount),
  };
}).filter((v): v is NonNullable<typeof v> => !!v);

if (errorRows.length > 0) {
  return fail(400, {
    errors: { file: `CSVの${errorRows.join(', ')}行目に不正なデータがあります` }
  });
}

await db.insert(transactions).values(newTransactions);

「エラー行があるかどうか」のチェックを、「新規登録データが1件もないかどうか」のチェックより先に置くことで、全行不正でも一部不正でも同じ具体的なメッセージ(何行目が不正か)でユーザーに案内できます。

ハマりポイント:fail() にFileオブジェクトを渡すとエラーになる

バリデーションエラー時に fail(400, { errors, values: { file } }) のように選択されたファイルをそのまま返そうとしたところ、次のエラーになりました。

Error: Data returned from action inside /(auth)/import is not serializable:
Cannot stringify arbitrary non-POJOs (data..values.file)

fail() はJSONにシリアライズできる値しか返せませんが、File オブジェクトはプレーンオブジェクトではないためシリアライズできません。ファイル選択はブラウザ側でクリアされるため、そもそも values として保持する必要が無く、単純に削除して解決しました。

// ❌ Fileオブジェクトはシリアライズできない
return fail(400, { errors, values: { file } });

// ✅ valuesを返さない
return fail(400, { errors });

ハマりポイント:file: クラスが効かない

flowbite-svelteの公式サンプルのようにボタン部分に色を付けたく、Tailwindの file: バリアント(::file-selector-button 疑似要素用)を指定しましたが、最初は反映されませんでした。

原因は単純なタイプミスで、class と書くつもりが cclass になっていました。

<!-- ❌ タイプミスで別属性として扱われる -->
<Fileupload cclass="file:bg-gray-800! ..." />

<!-- ✅ classに修正 -->
<Fileupload class="file:bg-gray-800! file:text-white! file:border-0! file:py-2.5! file:px-6! file:rounded-s-lg! dark:file:bg-gray-700!" />

修正後、クラス自体は要素に正しく付与されていたものの、リロード直後は一瞬色が反映されてすぐ元に戻る現象がありました。これはVite・TailwindのCSSキャッシュが原因で、dev serverを再起動したら解消しました。新しいルート追加時に svelte-kit sync が必要だったのと似た、開発環境特有のキャッシュ絡みの問題でした。

まとめ

ポイント 内容
ページ構成 一覧ページに埋め込まず、/import を別ページとして用意する
ファイルアップロード enctype="multipart/form-data" を明示し、FileuploadnameFormData から取得する
不正行の扱い 黙って捨てず、行番号を集めてall-or-nothingでエラーを返す
fail() の制約 File オブジェクトなど非POJOはシリアライズできないので渡さない
file: バリアント ::file-selector-button 用のTailwindクラスでボタンの見た目を調整する
開発時のキャッシュ 反映されない・一瞬だけ反映される場合はdev serverの再起動を試す
← トップページに戻る