Phase 8c: Anki-Import via portiertem Parser
Strategie-B-Ausnahme: parse.ts (Anki-Format-Parser via JSZip + sql.js) und AnkiImport.svelte (UI-Stages) sind aus mana-monorepo portiert, mit Source-Comment-Header dokumentiert. Anki-Format ist standalone Parser-Logik, kein Architektur-Schmuggel. Neuer server-authoritative import.ts schreibt direkt gegen die cards-api ($lib/api/decks + cards) — keine Stores, keine Dexie. Anki "::"-Hierarchie wird zu " / "-Strings flach. Fallback-Deck "Anki-Import" für Karten ohne explizites Deck. Cloze-Karten kommen first-class durch (Sub-Index pro Cluster, Sprint 8a/8b). Phase-8-MVP-Scope: Bilder + Audio werden gedroppt (Option A) — der sanitizeAnkiHtml entfernt <img> und [sound:…] ersatzlos. Späterer Media-Pfad (lokaler Cards-Upload oder mana-media nach Phase 2) ist additiv. Neue Route /import + Top-Nav-Link. Hermetic Vitest (5 Cases): baut zur Laufzeit ein Mini-.apkg via sql.js + JSZip und prüft den Parser-Output (basic, basic-reverse, cloze, sanitize, dedupe auf Note-Ebene). svelte-check 0 errors, prod-Build sauber. sql-wasm.wasm liegt in static/ (660kB) — fix für sql.js 1.14.1, vom Browser einmal geladen. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0b609c46fd
commit
2ca09fe0c3
9 changed files with 916 additions and 3 deletions
|
|
@ -17,7 +17,9 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cards/domain": "workspace:*",
|
"@cards/domain": "workspace:*",
|
||||||
"dompurify": "^3.4.2",
|
"dompurify": "^3.4.2",
|
||||||
"marked": "^18.0.3"
|
"jszip": "^3.10.1",
|
||||||
|
"marked": "^18.0.3",
|
||||||
|
"sql.js": "^1.14.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-node": "^5.2.0",
|
"@sveltejs/adapter-node": "^5.2.0",
|
||||||
|
|
@ -25,6 +27,8 @@
|
||||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||||
"@tailwindcss/vite": "^4.2.4",
|
"@tailwindcss/vite": "^4.2.4",
|
||||||
"@types/dompurify": "^3.2.0",
|
"@types/dompurify": "^3.2.0",
|
||||||
|
"@types/jszip": "^3.4.1",
|
||||||
|
"@types/sql.js": "^1.4.11",
|
||||||
"svelte": "^5.0.0",
|
"svelte": "^5.0.0",
|
||||||
"svelte-check": "^4.0.0",
|
"svelte-check": "^4.0.0",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4.2.4",
|
||||||
|
|
|
||||||
120
apps/web/src/lib/anki/import.ts
Normal file
120
apps/web/src/lib/anki/import.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
/**
|
||||||
|
* Server-authoritative Anki-Import.
|
||||||
|
*
|
||||||
|
* Schreibt gegen die cards-api HTTP-Endpoints — keine Dexie, keine
|
||||||
|
* lokalen Stores. Anki-Decks werden 1:1 in cards-Decks gemappt
|
||||||
|
* (Anki-`::` zu ` / ` flacht die Hierarchie aus, wie im Original).
|
||||||
|
* Karten werden mit sanitisiertem Markdown angelegt.
|
||||||
|
*
|
||||||
|
* Phase-8-MVP: Bilder + Audio werden gedroppt (siehe parse.ts
|
||||||
|
* `sanitizeAnkiHtml`). Ein späterer Media-Pfad ist additiv.
|
||||||
|
*
|
||||||
|
* No de-dupe: Re-Import derselben .apkg legt doppelte Decks an.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createDeck } from '$lib/api/decks.ts';
|
||||||
|
import { createCard } from '$lib/api/cards.ts';
|
||||||
|
import { sanitizeAnkiHtml, type ParsedAnki } from './parse.ts';
|
||||||
|
|
||||||
|
export interface ImportResult {
|
||||||
|
decksCreated: number;
|
||||||
|
cardsCreated: number;
|
||||||
|
failed: number;
|
||||||
|
failures: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImportProgress {
|
||||||
|
stage: 'decks' | 'cards' | 'done';
|
||||||
|
current: number;
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importParsedAnki(
|
||||||
|
parsed: ParsedAnki,
|
||||||
|
opts: { onProgress?: (p: ImportProgress) => void } = {}
|
||||||
|
): Promise<ImportResult> {
|
||||||
|
const result: ImportResult = {
|
||||||
|
decksCreated: 0,
|
||||||
|
cardsCreated: 0,
|
||||||
|
failed: 0,
|
||||||
|
failures: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1) Decks — Anki "::"-Hierarchie zu " / "-Strings flach machen.
|
||||||
|
const ankiIdToDeckId = new Map<string, string>();
|
||||||
|
let deckIdx = 0;
|
||||||
|
for (const ankiDeck of parsed.decks) {
|
||||||
|
opts.onProgress?.({ stage: 'decks', current: deckIdx++, total: parsed.decks.length });
|
||||||
|
const name = ankiDeck.name.replace(/::/g, ' / ');
|
||||||
|
try {
|
||||||
|
const created = await createDeck({ name });
|
||||||
|
ankiIdToDeckId.set(ankiDeck.ankiId, created.id);
|
||||||
|
result.decksCreated++;
|
||||||
|
} catch (e) {
|
||||||
|
result.failed++;
|
||||||
|
result.failures.push(`deck "${name}": ${errMessage(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback-Deck für Karten ohne explizit referenziertes Anki-Deck.
|
||||||
|
let fallbackDeckId: string | null = null;
|
||||||
|
const ensureFallbackDeck = async (): Promise<string | null> => {
|
||||||
|
if (fallbackDeckId) return fallbackDeckId;
|
||||||
|
try {
|
||||||
|
const created = await createDeck({ name: 'Anki-Import' });
|
||||||
|
fallbackDeckId = created.id;
|
||||||
|
result.decksCreated++;
|
||||||
|
return fallbackDeckId;
|
||||||
|
} catch (e) {
|
||||||
|
result.failures.push(`fallback deck: ${errMessage(e)}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2) Cards — Felder sanitizen (Media-Refs werden gedroppt).
|
||||||
|
for (let i = 0; i < parsed.cards.length; i++) {
|
||||||
|
opts.onProgress?.({ stage: 'cards', current: i, total: parsed.cards.length });
|
||||||
|
const card = parsed.cards[i];
|
||||||
|
|
||||||
|
let targetDeckId = ankiIdToDeckId.get(card.ankiDeckId);
|
||||||
|
if (!targetDeckId) {
|
||||||
|
const fallback = await ensureFallbackDeck();
|
||||||
|
if (!fallback) {
|
||||||
|
result.failed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
targetDeckId = fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanFields: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(card.fields)) {
|
||||||
|
cleanFields[key] = sanitizeAnkiHtml(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await createCard({
|
||||||
|
deck_id: targetDeckId,
|
||||||
|
type: card.type,
|
||||||
|
fields: cleanFields,
|
||||||
|
});
|
||||||
|
result.cardsCreated++;
|
||||||
|
} catch (e) {
|
||||||
|
result.failed++;
|
||||||
|
result.failures.push(`card "${preview(cleanFields)}": ${errMessage(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
opts.onProgress?.({ stage: 'done', current: parsed.cards.length, total: parsed.cards.length });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errMessage(e: unknown): string {
|
||||||
|
if (e instanceof Error) return e.message;
|
||||||
|
return String(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
function preview(fields: Record<string, string>): string {
|
||||||
|
const first = Object.values(fields)[0] ?? '';
|
||||||
|
const trimmed = first.length > 40 ? first.slice(0, 40) + '…' : first;
|
||||||
|
return trimmed.replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
241
apps/web/src/lib/anki/parse.ts
Normal file
241
apps/web/src/lib/anki/parse.ts
Normal file
|
|
@ -0,0 +1,241 @@
|
||||||
|
/**
|
||||||
|
* Parse an Anki .apkg / .colpkg file in the browser.
|
||||||
|
*
|
||||||
|
* .apkg = ZIP archive containing a SQLite collection (`collection.anki2`
|
||||||
|
* or `collection.anki21`) plus media files. We open the SQLite blob with
|
||||||
|
* sql.js (WASM-backed in-browser SQLite) and walk Anki's three core
|
||||||
|
* tables: `col` (collection meta with JSON-encoded models + decks),
|
||||||
|
* `notes` (the user-typed content), and `cards` (one row per learnable
|
||||||
|
* unit — basic = 1, basic-reverse = 2, cloze = N).
|
||||||
|
*
|
||||||
|
* MVP scope (Cards Phase 8): basic + basic-reverse + cloze. Media is
|
||||||
|
* collected but not uploaded — Image/audio refs are stripped from the
|
||||||
|
* sanitized text. Review history is skipped — FSRS state will be
|
||||||
|
* regenerated on first sight.
|
||||||
|
*
|
||||||
|
* --------------------------------------------------------------------
|
||||||
|
* STRATEGIE-B-AUSNAHME: Diese Datei ist ein bewusst portierter Lift aus
|
||||||
|
* mana-monorepo/apps/cards/apps/web/src/lib/anki/parse.ts (commit
|
||||||
|
* ~Mai 2026). Anki-Format-Logik ist standalone Parser-Code ohne
|
||||||
|
* Architektur-Übernahme — die Kopie spart 2-3 Tage Re-Implementierung
|
||||||
|
* bei null Strategy-Risiko. CardType-Import auf @cards/domain
|
||||||
|
* umgestellt, Doc-Kommentar an Phase-8-Scope angepasst.
|
||||||
|
* --------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
import JSZip, { type JSZipObject } from 'jszip';
|
||||||
|
import initSqlJs, { type Database } from 'sql.js';
|
||||||
|
import type { CardType } from '@cards/domain';
|
||||||
|
|
||||||
|
export interface ParsedDeck {
|
||||||
|
ankiId: string; // Anki's numeric deck id, stringified
|
||||||
|
name: string; // "Studies::Spanish" — Anki uses :: as separator
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedCard {
|
||||||
|
ankiDeckId: string;
|
||||||
|
type: CardType;
|
||||||
|
fields: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedAnki {
|
||||||
|
decks: ParsedDeck[];
|
||||||
|
cards: ParsedCard[];
|
||||||
|
skipped: number;
|
||||||
|
warnings: string[];
|
||||||
|
/**
|
||||||
|
* Mapping from the original media filename (as referenced in card
|
||||||
|
* fields, e.g. `paris.jpg` or `audio_001.mp3`) to its ZIP entry. Anki
|
||||||
|
* stores files numerically (`0`, `1`, …) and the JSON manifest
|
||||||
|
* (`media`) maps numbers → original names; we flip that here so the
|
||||||
|
* importer can look up by the name it sees in the field text.
|
||||||
|
*/
|
||||||
|
mediaByFilename: Map<string, JSZipObject>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnkiModel {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: number; // 0 = standard, 1 = cloze
|
||||||
|
flds: { name: string }[];
|
||||||
|
tmpls: { name: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AnkiDeckJson {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let SQL: Awaited<ReturnType<typeof initSqlJs>> | null = null;
|
||||||
|
async function getSql() {
|
||||||
|
if (SQL) return SQL;
|
||||||
|
SQL = await initSqlJs({ locateFile: (file) => `/${file}` });
|
||||||
|
return SQL;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseApkg(file: File | Blob): Promise<ParsedAnki> {
|
||||||
|
const zip = await JSZip.loadAsync(await file.arrayBuffer());
|
||||||
|
|
||||||
|
const collectionEntry = zip.file('collection.anki21') ?? zip.file('collection.anki2');
|
||||||
|
if (!collectionEntry) {
|
||||||
|
throw new Error(
|
||||||
|
'Keine Anki-Collection-Datei in der .apkg gefunden (erwartet: collection.anki21 oder collection.anki2).'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqliteBytes = await collectionEntry.async('uint8array');
|
||||||
|
const sql = await getSql();
|
||||||
|
const db: Database = new sql.Database(sqliteBytes);
|
||||||
|
|
||||||
|
const mediaByFilename = await extractMediaManifest(zip);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = extract(db);
|
||||||
|
return { ...result, mediaByFilename };
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function extractMediaManifest(zip: JSZip): Promise<Map<string, JSZipObject>> {
|
||||||
|
const out = new Map<string, JSZipObject>();
|
||||||
|
const manifestEntry = zip.file('media');
|
||||||
|
if (!manifestEntry) return out;
|
||||||
|
let manifest: Record<string, string>;
|
||||||
|
try {
|
||||||
|
manifest = JSON.parse(await manifestEntry.async('string'));
|
||||||
|
} catch {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
for (const [numericKey, originalName] of Object.entries(manifest)) {
|
||||||
|
const entry = zip.file(numericKey);
|
||||||
|
if (entry) out.set(originalName, entry);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal extract returns everything except media — that's plumbed in
|
||||||
|
// at the parseApkg layer so the SQLite-only path stays focused.
|
||||||
|
type ExtractResult = Omit<ParsedAnki, 'mediaByFilename'>;
|
||||||
|
function extract(db: Database): ExtractResult {
|
||||||
|
const colRow = db.exec('SELECT models, decks FROM col LIMIT 1');
|
||||||
|
if (colRow.length === 0 || colRow[0].values.length === 0) {
|
||||||
|
throw new Error('Anki-Collection ist leer.');
|
||||||
|
}
|
||||||
|
const [modelsJson, decksJson] = colRow[0].values[0] as [string, string];
|
||||||
|
const models: Record<string, AnkiModel> = JSON.parse(modelsJson);
|
||||||
|
const decksMap: Record<string, AnkiDeckJson> = JSON.parse(decksJson);
|
||||||
|
|
||||||
|
const decks: ParsedDeck[] = Object.values(decksMap)
|
||||||
|
.filter((d) => d.id !== 1) // Anki's "Default" deck has id 1; skip if empty later
|
||||||
|
.map((d) => ({ ankiId: String(d.id), name: d.name }));
|
||||||
|
|
||||||
|
// Pre-load notes into a Map so we don't hit SQLite per card.
|
||||||
|
type NoteRow = { id: string; mid: string; flds: string };
|
||||||
|
const notesById = new Map<string, NoteRow>();
|
||||||
|
const notesRes = db.exec('SELECT id, mid, flds FROM notes');
|
||||||
|
if (notesRes.length > 0) {
|
||||||
|
for (const row of notesRes[0].values) {
|
||||||
|
const [id, mid, flds] = row as [number, number, string];
|
||||||
|
notesById.set(String(id), { id: String(id), mid: String(mid), flds });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const warnings: string[] = [];
|
||||||
|
const cards: ParsedCard[] = [];
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
const cardsRes = db.exec('SELECT nid, did, ord FROM cards');
|
||||||
|
if (cardsRes.length === 0)
|
||||||
|
return { decks, cards: [], skipped: 0, warnings: ['Keine Karten gefunden.'] };
|
||||||
|
|
||||||
|
// We dedupe at the note level — Anki stores one DB-row per generated
|
||||||
|
// card (basic-reverse = 2 rows, cloze cluster c1+c2 = 2 rows). Our
|
||||||
|
// model regenerates these from `type` + `fields` automatically, so
|
||||||
|
// pulling each note once is enough.
|
||||||
|
const seenNotes = new Set<string>();
|
||||||
|
for (const row of cardsRes[0].values) {
|
||||||
|
const [nid, did] = row as [number, number, number];
|
||||||
|
const noteKey = String(nid);
|
||||||
|
if (seenNotes.has(noteKey)) continue;
|
||||||
|
seenNotes.add(noteKey);
|
||||||
|
|
||||||
|
const note = notesById.get(noteKey);
|
||||||
|
if (!note) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const model = models[note.mid];
|
||||||
|
if (!model) {
|
||||||
|
skipped++;
|
||||||
|
warnings.push(`Note ${nid}: unknown model ${note.mid}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldValues = note.flds.split('\x1f');
|
||||||
|
const result = mapNoteToCard(model, fieldValues);
|
||||||
|
if (!result) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
cards.push({ ankiDeckId: String(did), ...result });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skipped > 0) warnings.unshift(`${skipped} Karten übersprungen (unbekannter Typ).`);
|
||||||
|
return { decks, cards, skipped, warnings };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapNoteToCard(
|
||||||
|
model: AnkiModel,
|
||||||
|
fields: string[]
|
||||||
|
): { type: CardType; fields: Record<string, string> } | null {
|
||||||
|
// Cloze: exactly one input field with {{cN::...}} markup.
|
||||||
|
if (model.type === 1) {
|
||||||
|
const text = fields[0] ?? '';
|
||||||
|
return { type: 'cloze', fields: { text, ...(fields[1] ? { extra: fields[1] } : {}) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard: one or two templates → basic / basic-reverse.
|
||||||
|
if (model.type === 0) {
|
||||||
|
const front = fields[0] ?? '';
|
||||||
|
const back = fields[1] ?? '';
|
||||||
|
if (model.tmpls.length === 2) {
|
||||||
|
return { type: 'basic-reverse', fields: { front, back } };
|
||||||
|
}
|
||||||
|
// 1 (or unusual N) → treat as basic. Custom multi-card templates
|
||||||
|
// lose their extra surfaces; the user-typed content survives.
|
||||||
|
return { type: 'basic', fields: { front, back } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Anki's HTML / image / sound markup to plain text + Markdown.
|
||||||
|
*
|
||||||
|
* Phase-8-MVP: Bilder + Audio werden ersatzlos gedroppt (Option A).
|
||||||
|
* Ein späterer Media-Pfad (lokaler Cards-Upload-Endpunkt oder mana-media
|
||||||
|
* via Phase 2 Auth-Föderation) kann hier eine Filename→URL-Map einsetzen,
|
||||||
|
* die dann zu `<img>` / `<audio>`-Tags expandiert.
|
||||||
|
*/
|
||||||
|
export function sanitizeAnkiHtml(html: string): string {
|
||||||
|
// Bilder + Audio-Refs vollständig entfernen.
|
||||||
|
const imgStripped = html.replace(/<img\b[^>]*>/gi, '');
|
||||||
|
const soundStripped = imgStripped.replace(/\[sound:[^\]]+\]/g, '');
|
||||||
|
|
||||||
|
return soundStripped
|
||||||
|
.replace(/<br\s*\/?>/gi, '\n')
|
||||||
|
.replace(/<\/?(?:b|strong)>/gi, '**')
|
||||||
|
.replace(/<\/?(?:i|em)>/gi, '*')
|
||||||
|
.replace(/<\/?p>/gi, '\n')
|
||||||
|
.replace(/<\/?div>/gi, '\n')
|
||||||
|
.replace(/<[^>]+>/gi, '')
|
||||||
|
.replace(/ /g, ' ')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/\n{3,}/g, '\n\n')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
209
apps/web/src/lib/components/AnkiImport.svelte
Normal file
209
apps/web/src/lib/components/AnkiImport.svelte
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
<!--
|
||||||
|
Anki-Import-UI: Datei wählen → Preview → Importieren → Done.
|
||||||
|
|
||||||
|
STRATEGIE-B-AUSNAHME: portiert aus
|
||||||
|
mana-monorepo/apps/cards/apps/web/src/lib/components/AnkiImport.svelte.
|
||||||
|
Original-Layout angepasst auf das oklch-Theme im neuen Repo
|
||||||
|
(var(--color-*)) und auf den server-authoritative Import-Pfad
|
||||||
|
ohne Media-Upload-Stage. Cloze-Skipped-Anzeige ergänzt — der neue
|
||||||
|
Importer reicht Cloze direkt durch.
|
||||||
|
-->
|
||||||
|
<script lang="ts">
|
||||||
|
import { parseApkg, type ParsedAnki } from '$lib/anki/parse.ts';
|
||||||
|
import { importParsedAnki, type ImportResult, type ImportProgress } from '$lib/anki/import.ts';
|
||||||
|
|
||||||
|
let fileInput = $state<HTMLInputElement | null>(null);
|
||||||
|
let stage = $state<'idle' | 'parsing' | 'preview' | 'importing' | 'done' | 'error'>('idle');
|
||||||
|
let parsed = $state<ParsedAnki | null>(null);
|
||||||
|
let result = $state<ImportResult | null>(null);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let fileName = $state<string>('');
|
||||||
|
let progress = $state<ImportProgress>({ stage: 'decks', current: 0, total: 0 });
|
||||||
|
|
||||||
|
const typeBreakdown = $derived.by(() => {
|
||||||
|
if (!parsed) return { basic: 0, basicReverse: 0, cloze: 0 };
|
||||||
|
const counts = { basic: 0, basicReverse: 0, cloze: 0 };
|
||||||
|
for (const c of parsed.cards) {
|
||||||
|
if (c.type === 'basic') counts.basic++;
|
||||||
|
else if (c.type === 'basic-reverse') counts.basicReverse++;
|
||||||
|
else if (c.type === 'cloze') counts.cloze++;
|
||||||
|
}
|
||||||
|
return counts;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleFile(file: File) {
|
||||||
|
error = null;
|
||||||
|
fileName = file.name;
|
||||||
|
stage = 'parsing';
|
||||||
|
try {
|
||||||
|
parsed = await parseApkg(file);
|
||||||
|
stage = 'preview';
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error = e instanceof Error ? e.message : 'Datei konnte nicht gelesen werden.';
|
||||||
|
stage = 'error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPick(e: Event) {
|
||||||
|
const input = e.currentTarget as HTMLInputElement;
|
||||||
|
const f = input.files?.[0];
|
||||||
|
if (f) handleFile(f);
|
||||||
|
input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDrop(e: DragEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const f = e.dataTransfer?.files?.[0];
|
||||||
|
if (f) handleFile(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmImport() {
|
||||||
|
if (!parsed) return;
|
||||||
|
stage = 'importing';
|
||||||
|
progress = { stage: 'decks', current: 0, total: parsed.decks.length };
|
||||||
|
try {
|
||||||
|
result = await importParsedAnki(parsed, {
|
||||||
|
onProgress: (p) => {
|
||||||
|
progress = p;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
stage = 'done';
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error = e instanceof Error ? e.message : 'Import fehlgeschlagen.';
|
||||||
|
stage = 'error';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
stage = 'idle';
|
||||||
|
parsed = null;
|
||||||
|
result = null;
|
||||||
|
error = null;
|
||||||
|
fileName = '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-[var(--color-border)] bg-[var(--color-card)] p-4">
|
||||||
|
<div class="mb-2 text-sm font-medium">Aus Anki importieren</div>
|
||||||
|
|
||||||
|
{#if stage === 'idle'}
|
||||||
|
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||||
|
<div
|
||||||
|
class="cursor-pointer rounded-lg border-2 border-dashed border-[var(--color-border)] px-4 py-6 text-center text-sm text-[var(--color-muted)] transition-colors hover:border-[var(--color-primary)] hover:text-[var(--color-fg)]"
|
||||||
|
ondragover={(e) => e.preventDefault()}
|
||||||
|
ondrop={onDrop}
|
||||||
|
onclick={() => fileInput?.click()}
|
||||||
|
>
|
||||||
|
<div class="mb-1">📦 .apkg-Datei hier ablegen oder klicken</div>
|
||||||
|
<div class="text-xs">
|
||||||
|
Basic, Basic + Reverse, Cloze · Bilder + Audio werden in dieser Phase nicht übernommen.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
bind:this={fileInput}
|
||||||
|
type="file"
|
||||||
|
accept=".apkg,.colpkg"
|
||||||
|
class="hidden"
|
||||||
|
onchange={onPick}
|
||||||
|
/>
|
||||||
|
{:else if stage === 'parsing'}
|
||||||
|
<div class="py-6 text-center text-sm text-[var(--color-muted)]">Lese {fileName}…</div>
|
||||||
|
{:else if stage === 'preview' && parsed}
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<span class="text-[var(--color-muted)]">Gefunden in</span>
|
||||||
|
<code class="rounded bg-[var(--color-border)]/40 px-1 text-xs">{fileName}</code>:
|
||||||
|
</div>
|
||||||
|
<ul class="ml-4 list-disc">
|
||||||
|
<li>{parsed.decks.length} {parsed.decks.length === 1 ? 'Deck' : 'Decks'}</li>
|
||||||
|
<li>
|
||||||
|
{parsed.cards.length} {parsed.cards.length === 1 ? 'Karte' : 'Karten'}
|
||||||
|
{#if parsed.cards.length > 0}
|
||||||
|
<span class="text-[var(--color-muted)]">
|
||||||
|
({typeBreakdown.basic} basic, {typeBreakdown.basicReverse} basic-reverse,
|
||||||
|
{typeBreakdown.cloze} cloze)
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{#if parsed.mediaByFilename.size > 0}
|
||||||
|
<li class="text-[var(--color-muted)]">
|
||||||
|
{parsed.mediaByFilename.size} Medien (werden in dieser Phase NICHT übernommen)
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
|
{#if parsed.skipped > 0}
|
||||||
|
<li>{parsed.skipped} übersprungen (unbekannter Typ)</li>
|
||||||
|
{/if}
|
||||||
|
</ul>
|
||||||
|
{#if parsed.warnings.length > 0}
|
||||||
|
<details class="text-xs text-[var(--color-muted)]">
|
||||||
|
<summary class="cursor-pointer">Hinweise ({parsed.warnings.length})</summary>
|
||||||
|
<ul class="mt-1 list-disc pl-4">
|
||||||
|
{#each parsed.warnings.slice(0, 10) as w (w)}<li>{w}</li>{/each}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
<div class="flex justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
class="rounded px-3 py-1.5 text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]"
|
||||||
|
onclick={reset}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded bg-[var(--color-primary)] px-4 py-1.5 text-sm text-[var(--color-primary-fg)] hover:opacity-90"
|
||||||
|
onclick={confirmImport}
|
||||||
|
>
|
||||||
|
Importieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if stage === 'importing'}
|
||||||
|
<div class="py-6 text-center text-sm text-[var(--color-muted)]">
|
||||||
|
{#if progress.stage === 'decks'}
|
||||||
|
Lege Decks an · {progress.current} / {progress.total}
|
||||||
|
{:else if progress.stage === 'cards'}
|
||||||
|
Importiere Karten · {progress.current} / {progress.total}
|
||||||
|
{:else}
|
||||||
|
Fertig.
|
||||||
|
{/if}
|
||||||
|
<div class="mx-auto mt-3 h-1 w-48 overflow-hidden rounded-full bg-[var(--color-border)]/40">
|
||||||
|
<div
|
||||||
|
class="h-full bg-[var(--color-primary)] transition-all"
|
||||||
|
style="width: {progress.total === 0 ? 0 : (progress.current / progress.total) * 100}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if stage === 'done' && result}
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div class="text-[var(--color-success,#16a34a)]">
|
||||||
|
✓ {result.cardsCreated} Karten in {result.decksCreated}
|
||||||
|
{result.decksCreated === 1 ? 'Deck' : 'Decks'} angelegt.
|
||||||
|
</div>
|
||||||
|
{#if result.failed > 0}
|
||||||
|
<details class="text-[var(--color-danger)]">
|
||||||
|
<summary class="cursor-pointer">{result.failed} Fehler</summary>
|
||||||
|
<ul class="mt-1 list-disc pl-4 text-xs">
|
||||||
|
{#each result.failures.slice(0, 20) as msg (msg)}<li>{msg}</li>{/each}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="rounded px-3 py-1.5 text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]"
|
||||||
|
onclick={reset}
|
||||||
|
>
|
||||||
|
Weitere Datei
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else if stage === 'error'}
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<div class="text-[var(--color-danger)]">Fehler: {error}</div>
|
||||||
|
<button
|
||||||
|
class="rounded px-3 py-1.5 text-sm text-[var(--color-muted)] hover:text-[var(--color-fg)]"
|
||||||
|
onclick={reset}
|
||||||
|
>
|
||||||
|
Erneut versuchen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
@ -27,6 +27,11 @@
|
||||||
class="hover:text-[var(--color-primary)]"
|
class="hover:text-[var(--color-primary)]"
|
||||||
class:font-medium={page.url.pathname.startsWith('/study')}>Lernen</a
|
class:font-medium={page.url.pathname.startsWith('/study')}>Lernen</a
|
||||||
>
|
>
|
||||||
|
<a
|
||||||
|
href="/import"
|
||||||
|
class="hover:text-[var(--color-primary)]"
|
||||||
|
class:font-medium={page.url.pathname.startsWith('/import')}>Import</a
|
||||||
|
>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="flex items-center gap-3 text-sm">
|
<div class="flex items-center gap-3 text-sm">
|
||||||
|
|
|
||||||
43
apps/web/src/routes/import/+page.svelte
Normal file
43
apps/web/src/routes/import/+page.svelte
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { devUser } from '$lib/auth/dev-stub.svelte.ts';
|
||||||
|
import AnkiImport from '$lib/components/AnkiImport.svelte';
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (!devUser.id) {
|
||||||
|
goto('/');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Import · Cards</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="mx-auto max-w-2xl px-4 py-8">
|
||||||
|
<h1 class="text-2xl font-semibold">Importieren</h1>
|
||||||
|
<p class="mt-2 text-sm text-[var(--color-muted)]">
|
||||||
|
Übernimm Decks und Karten aus einer Anki-Datei (<code>.apkg</code> oder <code>.colpkg</code>).
|
||||||
|
FSRS-Verlauf wird nicht übernommen — alle Karten starten als „neu".
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<AnkiImport />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<aside class="mt-8 rounded-lg border border-dashed border-[var(--color-border)] p-4 text-xs text-[var(--color-muted)]">
|
||||||
|
<div class="mb-1 font-medium text-[var(--color-fg)]">Was wird übernommen</div>
|
||||||
|
<ul class="list-disc pl-4">
|
||||||
|
<li>Decks (Anki-Hierarchie <code>Foo::Bar</code> wird zu <code>Foo / Bar</code>).</li>
|
||||||
|
<li>Basic + Basic-Reverse: Front/Back direkt.</li>
|
||||||
|
<li>Cloze: <code>{'{{c1::…}}'}</code> wird mit Sub-Index pro Cluster angelegt.</li>
|
||||||
|
</ul>
|
||||||
|
<div class="mt-2 mb-1 font-medium text-[var(--color-fg)]">Was nicht übernommen wird</div>
|
||||||
|
<ul class="list-disc pl-4">
|
||||||
|
<li>Bilder + Audio (kommen mit der Plattform-Anbindung in einer späteren Phase).</li>
|
||||||
|
<li>FSRS-Lernverlauf (Anki-Reviews werden bewusst neu aufgesetzt).</li>
|
||||||
|
<li>Add-on-spezifische Card-Types (image-occlusion etc.).</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
BIN
apps/web/static/sql-wasm.wasm
Executable file
BIN
apps/web/static/sql-wasm.wasm
Executable file
Binary file not shown.
169
apps/web/tests/anki-parse.test.ts
Normal file
169
apps/web/tests/anki-parse.test.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
/**
|
||||||
|
* Hermetic Anki-Parser-Test. Wir bauen zur Laufzeit eine minimale
|
||||||
|
* .apkg (sql.js + JSZip), füttern sie in `parseApkg` und prüfen das
|
||||||
|
* ParsedAnki-Output. Keine Binär-Fixture im Repo — die Tests sind
|
||||||
|
* vollständig reproducible aus reiner JS-Logik.
|
||||||
|
*
|
||||||
|
* Anki-Mindest-Schema: `col` (collection), `notes`, `cards` plus
|
||||||
|
* JSON-encoded `models` und `decks` im einzigen `col`-Row.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import JSZip from 'jszip';
|
||||||
|
import initSqlJs from 'sql.js';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
import { parseApkg, sanitizeAnkiHtml } from '../src/lib/anki/parse.ts';
|
||||||
|
|
||||||
|
// In Node-Tests muss locateFile auf den lokalen WASM-Pfad zeigen.
|
||||||
|
// Wir patchen das einmal pro Test-Run — die Library cached SQL global.
|
||||||
|
const WASM_BUFFER = readFileSync(
|
||||||
|
new URL('../node_modules/sql.js/dist/sql-wasm.wasm', import.meta.url)
|
||||||
|
);
|
||||||
|
|
||||||
|
async function buildSampleApkg(): Promise<Blob> {
|
||||||
|
const SQL = await initSqlJs({ wasmBinary: WASM_BUFFER as unknown as ArrayBuffer });
|
||||||
|
const db = new SQL.Database();
|
||||||
|
|
||||||
|
// Anki erwartet diese Tabellen auch wenn nur ein Subset benutzt wird.
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE col (
|
||||||
|
id INTEGER PRIMARY KEY, crt INTEGER, mod INTEGER, scm INTEGER, ver INTEGER,
|
||||||
|
dty INTEGER, usn INTEGER, ls INTEGER,
|
||||||
|
conf TEXT, models TEXT, decks TEXT, dconf TEXT, tags TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE notes (
|
||||||
|
id INTEGER PRIMARY KEY, guid TEXT, mid INTEGER, mod INTEGER, usn INTEGER,
|
||||||
|
tags TEXT, flds TEXT, sfld TEXT, csum INTEGER, flags INTEGER, data TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE cards (
|
||||||
|
id INTEGER PRIMARY KEY, nid INTEGER, did INTEGER, ord INTEGER, mod INTEGER,
|
||||||
|
usn INTEGER, type INTEGER, queue INTEGER, due INTEGER, ivl INTEGER,
|
||||||
|
factor INTEGER, reps INTEGER, lapses INTEGER, left INTEGER, odue INTEGER,
|
||||||
|
odid INTEGER, flags INTEGER, data TEXT
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const models = {
|
||||||
|
// model-id "100" = Standard (1 template = basic)
|
||||||
|
'100': {
|
||||||
|
id: 100,
|
||||||
|
name: 'Basic',
|
||||||
|
type: 0,
|
||||||
|
flds: [{ name: 'Front' }, { name: 'Back' }],
|
||||||
|
tmpls: [{ name: 'Card 1' }],
|
||||||
|
},
|
||||||
|
// model-id "101" = Standard mit 2 templates = basic-reverse
|
||||||
|
'101': {
|
||||||
|
id: 101,
|
||||||
|
name: 'Basic + Reverse',
|
||||||
|
type: 0,
|
||||||
|
flds: [{ name: 'Front' }, { name: 'Back' }],
|
||||||
|
tmpls: [{ name: 'Card 1' }, { name: 'Card 2' }],
|
||||||
|
},
|
||||||
|
// model-id "102" = Cloze
|
||||||
|
'102': {
|
||||||
|
id: 102,
|
||||||
|
name: 'Cloze',
|
||||||
|
type: 1,
|
||||||
|
flds: [{ name: 'Text' }, { name: 'Extra' }],
|
||||||
|
tmpls: [{ name: 'Cloze' }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const decks = {
|
||||||
|
'1': { id: 1, name: 'Default' },
|
||||||
|
'200': { id: 200, name: 'Geographie::Europa' },
|
||||||
|
'201': { id: 201, name: 'Vokabeln' },
|
||||||
|
};
|
||||||
|
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO col VALUES (1,0,0,0,0,0,0,0,'{}',?,?,'{}','{}')`,
|
||||||
|
[JSON.stringify(models), JSON.stringify(decks)]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drei Notes (basic, basic-reverse, cloze).
|
||||||
|
const FS = '\x1f'; // Anki-Field-Separator
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO notes VALUES (?,?,?,0,0,'',?,'',0,0,'')`,
|
||||||
|
[1001, 'g1', 100, `Was ist 2+2?${FS}4`]
|
||||||
|
);
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO notes VALUES (?,?,?,0,0,'',?,'',0,0,'')`,
|
||||||
|
[1002, 'g2', 101, `Hauptstadt Frankreich${FS}Paris`]
|
||||||
|
);
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO notes VALUES (?,?,?,0,0,'',?,'',0,0,'')`,
|
||||||
|
[1003, 'g3', 102, `Die {{c1::Hauptstadt}} von {{c2::Frankreich}}.${FS}Geo-Quiz`]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cards: 1 für basic (ord=0), 2 für basic-reverse (ord=0,1),
|
||||||
|
// 2 für cloze (ord=0,1 — Anki erzeugt eine Card pro Cluster).
|
||||||
|
db.run(`INSERT INTO cards VALUES (?,?,?,0,0,0,0,0,0,0,2500,0,0,0,0,0,0,'')`, [9001, 1001, 200]);
|
||||||
|
db.run(`INSERT INTO cards VALUES (?,?,?,0,0,0,0,0,0,0,2500,0,0,0,0,0,0,'')`, [9002, 1002, 201]);
|
||||||
|
db.run(`INSERT INTO cards VALUES (?,?,?,1,0,0,0,0,0,0,2500,0,0,0,0,0,0,'')`, [9003, 1002, 201]);
|
||||||
|
db.run(`INSERT INTO cards VALUES (?,?,?,0,0,0,0,0,0,0,2500,0,0,0,0,0,0,'')`, [9004, 1003, 200]);
|
||||||
|
db.run(`INSERT INTO cards VALUES (?,?,?,1,0,0,0,0,0,0,2500,0,0,0,0,0,0,'')`, [9005, 1003, 200]);
|
||||||
|
|
||||||
|
const sqliteBytes = db.export();
|
||||||
|
db.close();
|
||||||
|
|
||||||
|
const zip = new JSZip();
|
||||||
|
zip.file('collection.anki21', sqliteBytes);
|
||||||
|
// Empty media manifest — kein extra File.
|
||||||
|
zip.file('media', '{}');
|
||||||
|
const blob = await zip.generateAsync({ type: 'arraybuffer' });
|
||||||
|
return new Blob([blob]);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('parseApkg', () => {
|
||||||
|
it('extrahiert Decks, Cards (basic, basic-reverse, cloze) und de-dupes Note-Rows', async () => {
|
||||||
|
const apkg = await buildSampleApkg();
|
||||||
|
const result = await parseApkg(apkg);
|
||||||
|
|
||||||
|
// Default-Deck (id=1) wird gefiltert.
|
||||||
|
expect(result.decks).toHaveLength(2);
|
||||||
|
expect(result.decks.map((d) => d.name).sort()).toEqual([
|
||||||
|
'Geographie::Europa',
|
||||||
|
'Vokabeln',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 3 Notes → 3 Karten (NICHT 5, weil basic-reverse + cloze auf Note-Ebene dedupliziert werden).
|
||||||
|
expect(result.cards).toHaveLength(3);
|
||||||
|
|
||||||
|
const types = result.cards.map((c) => c.type).sort();
|
||||||
|
expect(types).toEqual(['basic', 'basic-reverse', 'cloze']);
|
||||||
|
|
||||||
|
const cloze = result.cards.find((c) => c.type === 'cloze');
|
||||||
|
expect(cloze?.fields.text).toBe('Die {{c1::Hauptstadt}} von {{c2::Frankreich}}.');
|
||||||
|
expect(cloze?.fields.extra).toBe('Geo-Quiz');
|
||||||
|
|
||||||
|
const basic = result.cards.find((c) => c.type === 'basic');
|
||||||
|
expect(basic?.fields).toEqual({ front: 'Was ist 2+2?', back: '4' });
|
||||||
|
|
||||||
|
const reverse = result.cards.find((c) => c.type === 'basic-reverse');
|
||||||
|
expect(reverse?.fields).toEqual({ front: 'Hauptstadt Frankreich', back: 'Paris' });
|
||||||
|
|
||||||
|
expect(result.skipped).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sanitizeAnkiHtml', () => {
|
||||||
|
it('strippt Bilder und Audio-Markup', () => {
|
||||||
|
const out = sanitizeAnkiHtml('Vorne <img src="paris.jpg"> Hinten [sound:audio.mp3] fertig.');
|
||||||
|
expect(out).toBe('Vorne Hinten fertig.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('konvertiert Bold/Italic zu Markdown', () => {
|
||||||
|
expect(sanitizeAnkiHtml('Das <b>ist</b> <i>wichtig</i>')).toBe('Das **ist** *wichtig*');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('decodiert HTML-Entities', () => {
|
||||||
|
expect(sanitizeAnkiHtml('Q&A <tag>')).toBe('Q&A <tag>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt Cloze-Markup intakt', () => {
|
||||||
|
const out = sanitizeAnkiHtml('Die {{c1::Hauptstadt}} ist <b>Paris</b>.');
|
||||||
|
expect(out).toBe('Die {{c1::Hauptstadt}} ist **Paris**.');
|
||||||
|
});
|
||||||
|
});
|
||||||
126
pnpm-lock.yaml
generated
126
pnpm-lock.yaml
generated
|
|
@ -34,7 +34,7 @@ importers:
|
||||||
version: link:../../packages/cards-domain
|
version: link:../../packages/cards-domain
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: '0.38'
|
specifier: '0.38'
|
||||||
version: 0.38.4(bun-types@1.3.13)(postgres@3.4.9)
|
version: 0.38.4(@types/sql.js@1.4.11)(bun-types@1.3.13)(postgres@3.4.9)(sql.js@1.14.1)
|
||||||
hono:
|
hono:
|
||||||
specifier: ^4.6.0
|
specifier: ^4.6.0
|
||||||
version: 4.12.18
|
version: 4.12.18
|
||||||
|
|
@ -63,9 +63,15 @@ importers:
|
||||||
dompurify:
|
dompurify:
|
||||||
specifier: ^3.4.2
|
specifier: ^3.4.2
|
||||||
version: 3.4.2
|
version: 3.4.2
|
||||||
|
jszip:
|
||||||
|
specifier: ^3.10.1
|
||||||
|
version: 3.10.1
|
||||||
marked:
|
marked:
|
||||||
specifier: ^18.0.3
|
specifier: ^18.0.3
|
||||||
version: 18.0.3
|
version: 18.0.3
|
||||||
|
sql.js:
|
||||||
|
specifier: ^1.14.1
|
||||||
|
version: 1.14.1
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@sveltejs/adapter-node':
|
'@sveltejs/adapter-node':
|
||||||
specifier: ^5.2.0
|
specifier: ^5.2.0
|
||||||
|
|
@ -82,6 +88,12 @@ importers:
|
||||||
'@types/dompurify':
|
'@types/dompurify':
|
||||||
specifier: ^3.2.0
|
specifier: ^3.2.0
|
||||||
version: 3.2.0
|
version: 3.2.0
|
||||||
|
'@types/jszip':
|
||||||
|
specifier: ^3.4.1
|
||||||
|
version: 3.4.1
|
||||||
|
'@types/sql.js':
|
||||||
|
specifier: ^1.4.11
|
||||||
|
version: 1.4.11
|
||||||
svelte:
|
svelte:
|
||||||
specifier: ^5.0.0
|
specifier: ^5.0.0
|
||||||
version: 5.55.5
|
version: 5.55.5
|
||||||
|
|
@ -889,18 +901,28 @@ packages:
|
||||||
resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==}
|
resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==}
|
||||||
deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.
|
deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.
|
||||||
|
|
||||||
|
'@types/emscripten@1.41.5':
|
||||||
|
resolution: {integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==}
|
||||||
|
|
||||||
'@types/estree@1.0.8':
|
'@types/estree@1.0.8':
|
||||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||||
|
|
||||||
'@types/estree@1.0.9':
|
'@types/estree@1.0.9':
|
||||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||||
|
|
||||||
|
'@types/jszip@3.4.1':
|
||||||
|
resolution: {integrity: sha512-TezXjmf3lj+zQ651r6hPqvSScqBLvyPI9FxdXBqpEwBijNGQ2NXpaFW/7joGzveYkKQUil7iiDHLo6LV71Pc0A==}
|
||||||
|
deprecated: This is a stub types definition. jszip provides its own type definitions, so you do not need this installed.
|
||||||
|
|
||||||
'@types/node@22.19.18':
|
'@types/node@22.19.18':
|
||||||
resolution: {integrity: sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ==}
|
resolution: {integrity: sha512-9v00a+dn2yWVsYDEunWC4g/TcRKVq3r8N5FuZp7u0SGrPvdN9c2yXI9bBuf5Fl0hNCb+QTIePTn5pJs2pwBOQQ==}
|
||||||
|
|
||||||
'@types/resolve@1.20.2':
|
'@types/resolve@1.20.2':
|
||||||
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
|
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
|
||||||
|
|
||||||
|
'@types/sql.js@1.4.11':
|
||||||
|
resolution: {integrity: sha512-QXIx38p2ZThJaK9vP5ZdqdlRe1FG9I8SmCZOS7FHfB/2qPAjZwkL7/vlfPg6N/oWHuuOaGg/P/IRwfP2W0kWVQ==}
|
||||||
|
|
||||||
'@types/trusted-types@2.0.7':
|
'@types/trusted-types@2.0.7':
|
||||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||||
|
|
||||||
|
|
@ -983,6 +1005,9 @@ packages:
|
||||||
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
|
resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
core-util-is@1.0.3:
|
||||||
|
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
|
|
@ -1198,6 +1223,12 @@ packages:
|
||||||
resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==}
|
resolution: {integrity: sha512-RWzP96k/yv0PQfyXnWjs6zot20TqfpfsNXhOnev8d1InAxubW93L11/oNUc3tQqn2G0bSdAOBpX+2uDFHV7kdQ==}
|
||||||
engines: {node: '>=16.9.0'}
|
engines: {node: '>=16.9.0'}
|
||||||
|
|
||||||
|
immediate@3.0.6:
|
||||||
|
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||||
|
|
||||||
|
inherits@2.0.4:
|
||||||
|
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||||
|
|
||||||
is-core-module@2.16.2:
|
is-core-module@2.16.2:
|
||||||
resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==}
|
resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
@ -1211,6 +1242,9 @@ packages:
|
||||||
is-reference@3.0.3:
|
is-reference@3.0.3:
|
||||||
resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==}
|
resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==}
|
||||||
|
|
||||||
|
isarray@1.0.0:
|
||||||
|
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||||
|
|
||||||
isexe@3.1.5:
|
isexe@3.1.5:
|
||||||
resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==}
|
resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
@ -1219,10 +1253,16 @@ packages:
|
||||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
jszip@3.10.1:
|
||||||
|
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
||||||
|
|
||||||
kleur@4.1.5:
|
kleur@4.1.5:
|
||||||
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
|
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
lie@3.3.0:
|
||||||
|
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
|
||||||
|
|
||||||
lightningcss-android-arm64@1.32.0:
|
lightningcss-android-arm64@1.32.0:
|
||||||
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
|
|
@ -1323,6 +1363,9 @@ packages:
|
||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
pako@1.0.11:
|
||||||
|
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||||
|
|
||||||
path-parse@1.0.7:
|
path-parse@1.0.7:
|
||||||
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
|
||||||
|
|
||||||
|
|
@ -1359,6 +1402,12 @@ packages:
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
process-nextick-args@2.0.1:
|
||||||
|
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||||
|
|
||||||
|
readable-stream@2.3.8:
|
||||||
|
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
||||||
|
|
||||||
readdirp@4.1.2:
|
readdirp@4.1.2:
|
||||||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
||||||
engines: {node: '>= 14.18.0'}
|
engines: {node: '>= 14.18.0'}
|
||||||
|
|
@ -1380,6 +1429,9 @@ packages:
|
||||||
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
|
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
safe-buffer@5.1.2:
|
||||||
|
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
|
||||||
|
|
||||||
semver@7.7.4:
|
semver@7.7.4:
|
||||||
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
|
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
@ -1388,6 +1440,9 @@ packages:
|
||||||
set-cookie-parser@3.1.0:
|
set-cookie-parser@3.1.0:
|
||||||
resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==}
|
resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==}
|
||||||
|
|
||||||
|
setimmediate@1.0.5:
|
||||||
|
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
||||||
|
|
||||||
shell-quote@1.8.3:
|
shell-quote@1.8.3:
|
||||||
resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
|
resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
@ -1410,12 +1465,18 @@ packages:
|
||||||
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
sql.js@1.14.1:
|
||||||
|
resolution: {integrity: sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==}
|
||||||
|
|
||||||
stackback@0.0.2:
|
stackback@0.0.2:
|
||||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||||
|
|
||||||
std-env@3.10.0:
|
std-env@3.10.0:
|
||||||
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||||
|
|
||||||
|
string_decoder@1.1.1:
|
||||||
|
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
|
||||||
|
|
||||||
supports-preserve-symlinks-flag@1.0.0:
|
supports-preserve-symlinks-flag@1.0.0:
|
||||||
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
@ -1477,6 +1538,9 @@ packages:
|
||||||
undici-types@6.21.0:
|
undici-types@6.21.0:
|
||||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||||
|
|
||||||
|
util-deprecate@1.0.2:
|
||||||
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
vite-node@2.1.9:
|
vite-node@2.1.9:
|
||||||
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
|
resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==}
|
||||||
engines: {node: ^18.0.0 || >=20.0.0}
|
engines: {node: ^18.0.0 || >=20.0.0}
|
||||||
|
|
@ -2071,16 +2135,27 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
dompurify: 3.4.2
|
dompurify: 3.4.2
|
||||||
|
|
||||||
|
'@types/emscripten@1.41.5': {}
|
||||||
|
|
||||||
'@types/estree@1.0.8': {}
|
'@types/estree@1.0.8': {}
|
||||||
|
|
||||||
'@types/estree@1.0.9': {}
|
'@types/estree@1.0.9': {}
|
||||||
|
|
||||||
|
'@types/jszip@3.4.1':
|
||||||
|
dependencies:
|
||||||
|
jszip: 3.10.1
|
||||||
|
|
||||||
'@types/node@22.19.18':
|
'@types/node@22.19.18':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
|
|
||||||
'@types/resolve@1.20.2': {}
|
'@types/resolve@1.20.2': {}
|
||||||
|
|
||||||
|
'@types/sql.js@1.4.11':
|
||||||
|
dependencies:
|
||||||
|
'@types/emscripten': 1.41.5
|
||||||
|
'@types/node': 22.19.18
|
||||||
|
|
||||||
'@types/trusted-types@2.0.7': {}
|
'@types/trusted-types@2.0.7': {}
|
||||||
|
|
||||||
'@vitest/expect@2.1.9':
|
'@vitest/expect@2.1.9':
|
||||||
|
|
@ -2159,6 +2234,8 @@ snapshots:
|
||||||
|
|
||||||
cookie@0.6.0: {}
|
cookie@0.6.0: {}
|
||||||
|
|
||||||
|
core-util-is@1.0.3: {}
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
@ -2185,10 +2262,12 @@ snapshots:
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
drizzle-orm@0.38.4(bun-types@1.3.13)(postgres@3.4.9):
|
drizzle-orm@0.38.4(@types/sql.js@1.4.11)(bun-types@1.3.13)(postgres@3.4.9)(sql.js@1.14.1):
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
|
'@types/sql.js': 1.4.11
|
||||||
bun-types: 1.3.13
|
bun-types: 1.3.13
|
||||||
postgres: 3.4.9
|
postgres: 3.4.9
|
||||||
|
sql.js: 1.14.1
|
||||||
|
|
||||||
enhanced-resolve@5.21.2:
|
enhanced-resolve@5.21.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|
@ -2331,6 +2410,10 @@ snapshots:
|
||||||
|
|
||||||
hono@4.12.18: {}
|
hono@4.12.18: {}
|
||||||
|
|
||||||
|
immediate@3.0.6: {}
|
||||||
|
|
||||||
|
inherits@2.0.4: {}
|
||||||
|
|
||||||
is-core-module@2.16.2:
|
is-core-module@2.16.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
hasown: 2.0.3
|
hasown: 2.0.3
|
||||||
|
|
@ -2345,12 +2428,25 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/estree': 1.0.9
|
'@types/estree': 1.0.9
|
||||||
|
|
||||||
|
isarray@1.0.0: {}
|
||||||
|
|
||||||
isexe@3.1.5: {}
|
isexe@3.1.5: {}
|
||||||
|
|
||||||
jiti@2.7.0: {}
|
jiti@2.7.0: {}
|
||||||
|
|
||||||
|
jszip@3.10.1:
|
||||||
|
dependencies:
|
||||||
|
lie: 3.3.0
|
||||||
|
pako: 1.0.11
|
||||||
|
readable-stream: 2.3.8
|
||||||
|
setimmediate: 1.0.5
|
||||||
|
|
||||||
kleur@4.1.5: {}
|
kleur@4.1.5: {}
|
||||||
|
|
||||||
|
lie@3.3.0:
|
||||||
|
dependencies:
|
||||||
|
immediate: 3.0.6
|
||||||
|
|
||||||
lightningcss-android-arm64@1.32.0:
|
lightningcss-android-arm64@1.32.0:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -2418,6 +2514,8 @@ snapshots:
|
||||||
|
|
||||||
nanoid@3.3.12: {}
|
nanoid@3.3.12: {}
|
||||||
|
|
||||||
|
pako@1.0.11: {}
|
||||||
|
|
||||||
path-parse@1.0.7: {}
|
path-parse@1.0.7: {}
|
||||||
|
|
||||||
pathe@1.1.2: {}
|
pathe@1.1.2: {}
|
||||||
|
|
@ -2443,6 +2541,18 @@ snapshots:
|
||||||
|
|
||||||
prettier@3.8.3: {}
|
prettier@3.8.3: {}
|
||||||
|
|
||||||
|
process-nextick-args@2.0.1: {}
|
||||||
|
|
||||||
|
readable-stream@2.3.8:
|
||||||
|
dependencies:
|
||||||
|
core-util-is: 1.0.3
|
||||||
|
inherits: 2.0.4
|
||||||
|
isarray: 1.0.0
|
||||||
|
process-nextick-args: 2.0.1
|
||||||
|
safe-buffer: 5.1.2
|
||||||
|
string_decoder: 1.1.1
|
||||||
|
util-deprecate: 1.0.2
|
||||||
|
|
||||||
readdirp@4.1.2: {}
|
readdirp@4.1.2: {}
|
||||||
|
|
||||||
resolve-pkg-maps@1.0.0: {}
|
resolve-pkg-maps@1.0.0: {}
|
||||||
|
|
@ -2489,10 +2599,14 @@ snapshots:
|
||||||
dependencies:
|
dependencies:
|
||||||
mri: 1.2.0
|
mri: 1.2.0
|
||||||
|
|
||||||
|
safe-buffer@5.1.2: {}
|
||||||
|
|
||||||
semver@7.7.4: {}
|
semver@7.7.4: {}
|
||||||
|
|
||||||
set-cookie-parser@3.1.0: {}
|
set-cookie-parser@3.1.0: {}
|
||||||
|
|
||||||
|
setimmediate@1.0.5: {}
|
||||||
|
|
||||||
shell-quote@1.8.3: {}
|
shell-quote@1.8.3: {}
|
||||||
|
|
||||||
siginfo@2.0.0: {}
|
siginfo@2.0.0: {}
|
||||||
|
|
@ -2512,10 +2626,16 @@ snapshots:
|
||||||
|
|
||||||
source-map@0.6.1: {}
|
source-map@0.6.1: {}
|
||||||
|
|
||||||
|
sql.js@1.14.1: {}
|
||||||
|
|
||||||
stackback@0.0.2: {}
|
stackback@0.0.2: {}
|
||||||
|
|
||||||
std-env@3.10.0: {}
|
std-env@3.10.0: {}
|
||||||
|
|
||||||
|
string_decoder@1.1.1:
|
||||||
|
dependencies:
|
||||||
|
safe-buffer: 5.1.2
|
||||||
|
|
||||||
supports-preserve-symlinks-flag@1.0.0: {}
|
supports-preserve-symlinks-flag@1.0.0: {}
|
||||||
|
|
||||||
svelte-check@4.4.8(picomatch@4.0.4)(svelte@5.55.5)(typescript@5.9.3):
|
svelte-check@4.4.8(picomatch@4.0.4)(svelte@5.55.5)(typescript@5.9.3):
|
||||||
|
|
@ -2582,6 +2702,8 @@ snapshots:
|
||||||
|
|
||||||
undici-types@6.21.0: {}
|
undici-types@6.21.0: {}
|
||||||
|
|
||||||
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
vite-node@2.1.9(@types/node@22.19.18)(lightningcss@1.32.0):
|
vite-node@2.1.9(@types/node@22.19.18)(lightningcss@1.32.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
cac: 6.7.14
|
cac: 6.7.14
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue