家計簿アプリを作る #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" を明示し、Fileupload の name で FormData から取得する |
| 不正行の扱い | 黙って捨てず、行番号を集めてall-or-nothingでエラーを返す |
fail() の制約 |
File オブジェクトなど非POJOはシリアライズできないので渡さない |
file: バリアント |
::file-selector-button 用のTailwindクラスでボタンの見た目を調整する |
| 開発時のキャッシュ | 反映されない・一瞬だけ反映される場合はdev serverの再起動を試す |