import { and, eq, inArray } from 'drizzle-orm'; import * as XLSX from 'xlsx'; import type { Db } from '../db/client.js'; import { cards, lessons } from '../db/schema.js'; import { getDescendantLessonIds } from './lessons.js'; import { canEditLesson, canReadLesson } from './permissions.js'; import { ApiError } from '../lib/errors.js'; export interface ImportRow { question: string; answer: string; hint?: string; lesson_path?: string; } export interface ImportOptions { updateExisting: boolean; createMissingLessons: boolean; } export interface ImportResult { inserted: number; updated: number; skipped: number; errors: { row: number; message: string }[]; } function parseSheet(buf: Buffer): ImportRow[] { const wb = XLSX.read(buf, { type: 'buffer' }); const firstName = wb.SheetNames[0]; if (!firstName) return []; const sheet = wb.Sheets[firstName]; if (!sheet) return []; return XLSX.utils.sheet_to_json(sheet, { defval: '' }); } function nowSec() { return Math.floor(Date.now() / 1000); } async function resolveLesson( db: Db, userId: number, defaultLessonId: number, lessonPath: string | undefined, createMissing: boolean ): Promise { if (!lessonPath || lessonPath.trim() === '') return defaultLessonId; const parts = lessonPath.split('/').map((s) => s.trim()).filter(Boolean); if (parts.length === 0) return defaultLessonId; // lesson_path is resolved RELATIVE to the lesson the import was started from: // each segment becomes a sublesson under it (created on demand, owned by the user). let parentId: number = defaultLessonId; for (const name of parts) { const found = db.select().from(lessons) .where(and(eq(lessons.name, name), eq(lessons.parentId, parentId), eq(lessons.ownerId, userId))) .get(); if (found) { parentId = found.id; continue; } if (!createMissing) return null; const [row] = db.insert(lessons).values({ name, parentId, position: 0, ownerId: userId, visibility: 'private', isCurated: false, }).returning().all(); parentId = row!.id; } return parentId; } export async function importCardsFromBuffer( db: Db, userId: number, defaultLessonId: number, buffer: Buffer, opts: ImportOptions ): Promise { if (!(await canEditLesson(db, userId, defaultLessonId))) { throw new ApiError(403, 'FORBIDDEN_LESSON', 'Not your lesson'); } const rows = parseSheet(buffer); const result: ImportResult = { inserted: 0, updated: 0, skipped: 0, errors: [] }; for (let i = 0; i < rows.length; i++) { const row = rows[i]!; const rowNum = i + 2; const q = String(row.question ?? '').trim(); const a = String(row.answer ?? '').trim(); if (!q || !a) { result.errors.push({ row: rowNum, message: 'question and answer are required' }); continue; } const targetLessonId = await resolveLesson(db, userId, defaultLessonId, row.lesson_path, opts.createMissingLessons); if (targetLessonId === null) { result.errors.push({ row: rowNum, message: `lesson_path not found: ${row.lesson_path}` }); continue; } const existing = db.select().from(cards).where(and(eq(cards.lessonId, targetLessonId), eq(cards.question, q))).get(); const hint = row.hint && String(row.hint).trim() !== '' ? String(row.hint) : null; if (existing) { if (!opts.updateExisting) { result.skipped += 1; continue; } db.update(cards).set({ answer: a, hint, updatedAt: nowSec() }).where(eq(cards.id, existing.id)).run(); result.updated += 1; } else { const positions = db.select({ pos: cards.position }).from(cards).where(eq(cards.lessonId, targetLessonId)).all(); const position = positions.length === 0 ? 0 : Math.max(...positions.map((p) => p.pos)) + 1; db.insert(cards).values({ lessonId: targetLessonId, question: q, answer: a, hint, position }).run(); result.inserted += 1; } } return result; } export async function exportCardsToBuffer( db: Db, userId: number, lessonId: number, includeDescendants: boolean ): Promise { if (!(await canReadLesson(db, userId, lessonId))) { throw new ApiError(403, 'FORBIDDEN_LESSON', 'Cannot read this lesson'); } const ids = includeDescendants ? await getDescendantLessonIds(db, lessonId) : [lessonId]; const lessonRows = db.select().from(lessons).where(inArray(lessons.id, ids)).all(); const pathById = buildPathMap(lessonRows); const cardRows = db.select().from(cards).where(inArray(cards.lessonId, ids)).all(); const data = cardRows.map((c) => ({ question: c.question, answer: c.answer, hint: c.hint ?? '', lesson_path: pathById.get(c.lessonId) ?? '', })); const ws = XLSX.utils.json_to_sheet(data); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, 'cards'); return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }) as Buffer; } function buildPathMap(allLessons: { id: number; name: string; parentId: number | null }[]): Map { const byId = new Map(allLessons.map((l) => [l.id, l])); const cache = new Map(); function path(id: number): string { if (cache.has(id)) return cache.get(id)!; const l = byId.get(id); if (!l) return ''; const p = l.parentId === null ? l.name : `${path(l.parentId)}/${l.name}`; cache.set(id, p); return p; } for (const l of allLessons) path(l.id); return cache; }