Files
flashcards/packages/backend/src/services/import.ts
Bert Hausmans f5000d3c58 fix(import): set owner on auto-created lessons + nest lesson_path under started lesson
Two bugs surfaced by Excel import on a lesson with a lesson_path column:
1. resolveLesson created lessons without owner_id, so after the ownership
   model (sub-project B) they never appeared in getLessonTree — import
   reported success but nothing was visible.
2. lesson_path was resolved at the root; cards landed in new root lessons
   instead of under the lesson the import was started from.

Now: auto-created lessons get ownerId + visibility 'private'; lesson_path is
resolved relative to the started lesson (each segment a sublesson). Also drop
the stale eager card_progress insert (progress is per-user and lazy since B).
2026-05-21 07:45:49 +02:00

139 lines
5.3 KiB
TypeScript

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<ImportRow>(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<number | null> {
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<ImportResult> {
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<Buffer> {
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<number, string> {
const byId = new Map(allLessons.map((l) => [l.id, l]));
const cache = new Map<number, string>();
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;
}