From 8dbe5c2a1d8dc15bbdd6810b2582c680e1c0bb9b Mon Sep 17 00:00:00 2001 From: nsfisis Date: Thu, 1 Jan 2026 22:06:40 +0900 Subject: feat(import): add CSV bulk import for notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add client-side CSV parsing and bulk import API endpoint for importing notes from CSV files. Supports quoted fields, newlines in values, and escaped quotes. - New POST /api/decks/{deckId}/notes/import endpoint for bulk creation - CSV parser with RFC 4180 compliance - Multi-phase import modal (upload → validate → preview → import) - Client-side validation with per-row error reporting 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/server/repositories/note.ts | 125 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) (limited to 'src/server/repositories/note.ts') diff --git a/src/server/repositories/note.ts b/src/server/repositories/note.ts index eb1aa28..6f607cf 100644 --- a/src/server/repositories/note.ts +++ b/src/server/repositories/note.ts @@ -9,6 +9,8 @@ import { noteTypes, } from "../db/schema.js"; import type { + BulkCreateNoteInput, + BulkCreateNoteResult, Card, CreateNoteResult, Note, @@ -265,6 +267,129 @@ export const noteRepository: NoteRepository = { return result.length > 0; }, + + async createMany( + deckId: string, + notesInput: BulkCreateNoteInput[], + ): Promise { + const failed: { index: number; error: string }[] = []; + let created = 0; + + // Pre-fetch all note types and their field types for validation + const noteTypeCache = new Map< + string, + { + noteType: { + frontTemplate: string; + backTemplate: string; + isReversible: boolean; + }; + fieldTypes: { id: string; name: string }[]; + } + >(); + + for (let i = 0; i < notesInput.length; i++) { + const input = notesInput[i]; + if (!input) continue; + + try { + // Get note type from cache or fetch + let cached = noteTypeCache.get(input.noteTypeId); + if (!cached) { + const noteType = await db + .select() + .from(noteTypes) + .where( + and( + eq(noteTypes.id, input.noteTypeId), + isNull(noteTypes.deletedAt), + ), + ); + + if (!noteType[0]) { + failed.push({ index: i, error: "Note type not found" }); + continue; + } + + const fieldTypes = await db + .select() + .from(noteFieldTypes) + .where( + and( + eq(noteFieldTypes.noteTypeId, input.noteTypeId), + isNull(noteFieldTypes.deletedAt), + ), + ) + .orderBy(noteFieldTypes.order); + + cached = { noteType: noteType[0], fieldTypes }; + noteTypeCache.set(input.noteTypeId, cached); + } + + // Create note + const [note] = await db + .insert(notes) + .values({ + deckId, + noteTypeId: input.noteTypeId, + }) + .returning(); + + if (!note) { + failed.push({ index: i, error: "Failed to create note" }); + continue; + } + + // Create field values + const fieldValuesResult: NoteFieldValue[] = []; + for (const fieldType of cached.fieldTypes) { + const value = input.fields[fieldType.id] ?? ""; + const [fieldValue] = await db + .insert(noteFieldValues) + .values({ + noteId: note.id, + noteFieldTypeId: fieldType.id, + value, + }) + .returning(); + if (fieldValue) { + fieldValuesResult.push(fieldValue); + } + } + + // Create normal card + await createCardForNote( + deckId, + note.id, + cached.noteType, + fieldValuesResult, + cached.fieldTypes, + false, + ); + + // Create reversed card if reversible + if (cached.noteType.isReversible) { + await createCardForNote( + deckId, + note.id, + cached.noteType, + fieldValuesResult, + cached.fieldTypes, + true, + ); + } + + created++; + } catch (error) { + failed.push({ + index: i, + error: error instanceof Error ? error.message : "Unknown error", + }); + } + } + + return { created, failed }; + }, }; async function createCardForNote( -- cgit v1.2.3-70-g09d2