diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-01-01 22:06:40 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-01-01 22:06:40 +0900 |
| commit | 8dbe5c2a1d8dc15bbdd6810b2582c680e1c0bb9b (patch) | |
| tree | be39e1436f83c716fc45df133106fba7dd4bc23a /src/server | |
| parent | 830b370f1b8e0f3a384b2d242ab120812e81977d (diff) | |
| download | kioku-8dbe5c2a1d8dc15bbdd6810b2582c680e1c0bb9b.tar.gz kioku-8dbe5c2a1d8dc15bbdd6810b2582c680e1c0bb9b.tar.zst kioku-8dbe5c2a1d8dc15bbdd6810b2582c680e1c0bb9b.zip | |
feat(import): add CSV bulk import for notes
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 <noreply@anthropic.com>
Diffstat (limited to 'src/server')
| -rw-r--r-- | src/server/repositories/note.test.ts | 1 | ||||
| -rw-r--r-- | src/server/repositories/note.ts | 125 | ||||
| -rw-r--r-- | src/server/repositories/types.ts | 19 | ||||
| -rw-r--r-- | src/server/routes/notes.test.ts | 1 | ||||
| -rw-r--r-- | src/server/routes/notes.ts | 32 | ||||
| -rw-r--r-- | src/server/schemas/index.ts | 11 |
6 files changed, 188 insertions, 1 deletions
diff --git a/src/server/repositories/note.test.ts b/src/server/repositories/note.test.ts index 0fe1c9f..2c4b900 100644 --- a/src/server/repositories/note.test.ts +++ b/src/server/repositories/note.test.ts @@ -109,6 +109,7 @@ function createMockNoteRepo(): NoteRepository { create: vi.fn(), update: vi.fn(), softDelete: vi.fn(), + createMany: vi.fn(), }; } 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<BulkCreateNoteResult> { + 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( diff --git a/src/server/repositories/types.ts b/src/server/repositories/types.ts index a986cad..4768d49 100644 --- a/src/server/repositories/types.ts +++ b/src/server/repositories/types.ts @@ -310,6 +310,21 @@ export interface CreateNoteResult { cards: Card[]; } +export interface BulkCreateNoteInput { + noteTypeId: string; + fields: Record<string, string>; +} + +export interface BulkCreateNoteFailure { + index: number; + error: string; +} + +export interface BulkCreateNoteResult { + created: number; + failed: BulkCreateNoteFailure[]; +} + export interface NoteRepository { findByDeckId(deckId: string): Promise<Note[]>; findById(id: string, deckId: string): Promise<Note | undefined>; @@ -330,4 +345,8 @@ export interface NoteRepository { fields: Record<string, string>, ): Promise<NoteWithFieldValues | undefined>; softDelete(id: string, deckId: string): Promise<boolean>; + createMany( + deckId: string, + notes: BulkCreateNoteInput[], + ): Promise<BulkCreateNoteResult>; } diff --git a/src/server/routes/notes.test.ts b/src/server/routes/notes.test.ts index 2ca08f9..e354fa6 100644 --- a/src/server/routes/notes.test.ts +++ b/src/server/routes/notes.test.ts @@ -23,6 +23,7 @@ function createMockNoteRepo(): NoteRepository { create: vi.fn(), update: vi.fn(), softDelete: vi.fn(), + createMany: vi.fn(), }; } diff --git a/src/server/routes/notes.ts b/src/server/routes/notes.ts index 16ffb09..ea7b0d0 100644 --- a/src/server/routes/notes.ts +++ b/src/server/routes/notes.ts @@ -8,7 +8,11 @@ import { type NoteRepository, noteRepository, } from "../repositories/index.js"; -import { createNoteSchema, updateNoteSchema } from "../schemas/index.js"; +import { + bulkCreateNotesSchema, + createNoteSchema, + updateNoteSchema, +} from "../schemas/index.js"; export interface NoteDependencies { noteRepo: NoteRepository; @@ -86,6 +90,32 @@ export function createNotesRouter(deps: NoteDependencies) { } }, ) + // Bulk import notes + .post( + "/import", + zValidator("param", deckIdParamSchema), + zValidator("json", bulkCreateNotesSchema), + async (c) => { + const user = getAuthUser(c); + const { deckId } = c.req.valid("param"); + const data = c.req.valid("json"); + + const deck = await deckRepo.findById(deckId, user.id); + if (!deck) { + throw Errors.notFound("Deck not found", "DECK_NOT_FOUND"); + } + + const result = await noteRepo.createMany(deckId, data.notes); + + return c.json( + { + created: result.created, + failed: result.failed, + }, + 201, + ); + }, + ) // Get note with field values .get("/:noteId", zValidator("param", noteIdParamSchema), async (c) => { const user = getAuthUser(c); diff --git a/src/server/schemas/index.ts b/src/server/schemas/index.ts index d2a8fb0..fc1bd77 100644 --- a/src/server/schemas/index.ts +++ b/src/server/schemas/index.ts @@ -186,6 +186,16 @@ export const updateNoteSchema = z.object({ fields: z.record(z.uuid(), z.string()), }); +// Bulk note import input schema +export const bulkCreateNotesSchema = z.object({ + notes: z.array( + z.object({ + noteTypeId: z.uuid(), + fields: z.record(z.uuid(), z.string()), + }), + ), +}); + // NoteFieldValue schema export const noteFieldValueSchema = z.object({ id: z.uuid(), @@ -244,4 +254,5 @@ export type UpdateNoteFieldTypeSchema = z.infer< export type NoteSchema = z.infer<typeof noteSchema>; export type CreateNoteSchema = z.infer<typeof createNoteSchema>; export type UpdateNoteSchema = z.infer<typeof updateNoteSchema>; +export type BulkCreateNotesSchema = z.infer<typeof bulkCreateNotesSchema>; export type NoteFieldValueSchema = z.infer<typeof noteFieldValueSchema>; |
