From 1f2206f10b29761f2d71a89e985dcb30b9d28063 Mon Sep 17 00:00:00 2001 From: Till JS Date: Thu, 7 May 2026 13:37:01 +0200 Subject: [PATCH] feat(cards-web): PDF input for AI generator + study activity heatmap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PDF input: • lib/ai/pdf.ts wraps pdfjs-dist (Apache-2.0). Worker is bound via Vite's `?worker` suffix so the heavy parsing runs off-main-thread. • AiCardGen gains a "📄 PDF laden" button that pipes extracted text into the same textarea — the user can review/trim before generation. Reading state shows file name + page count + chars. Heatmap: • queries.useStudyHeatmap(weeks=12) fills gaps with count=0 so the grid renders without holes. • StudyHeatmap.svelte: 7 rows × N columns (Monday-anchored), 5 intensity buckets (neutral → emerald-300), tooltip per cell with date + count, legend strip. • Mounted on the dashboard between the deck list and the Anki import so the user lands on a quick visual progress receipt every visit. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/cards/apps/web/package.json | 1 + apps/cards/apps/web/src/lib/ai/pdf.ts | 56 ++++++++ .../web/src/lib/components/AiCardGen.svelte | 66 +++++++-- .../src/lib/components/StudyHeatmap.svelte | 93 +++++++++++++ apps/cards/apps/web/src/lib/queries.ts | 35 +++++ apps/cards/apps/web/src/routes/+page.svelte | 5 + pnpm-lock.yaml | 129 ++++++++++++++++++ 7 files changed, 375 insertions(+), 10 deletions(-) create mode 100644 apps/cards/apps/web/src/lib/ai/pdf.ts create mode 100644 apps/cards/apps/web/src/lib/components/StudyHeatmap.svelte diff --git a/apps/cards/apps/web/package.json b/apps/cards/apps/web/package.json index 95bdfe065..343fc0e81 100644 --- a/apps/cards/apps/web/package.json +++ b/apps/cards/apps/web/package.json @@ -41,6 +41,7 @@ "@mana/shared-utils": "workspace:*", "dexie": "^4.4.1", "jszip": "^3.10.1", + "pdfjs-dist": "^5.7.284", "sql.js": "^1.14.1" } } diff --git a/apps/cards/apps/web/src/lib/ai/pdf.ts b/apps/cards/apps/web/src/lib/ai/pdf.ts new file mode 100644 index 000000000..9cb5655a8 --- /dev/null +++ b/apps/cards/apps/web/src/lib/ai/pdf.ts @@ -0,0 +1,56 @@ +/** + * PDF text extraction using pdfjs-dist. + * + * Loads each page, walks the text layer, joins items with spaces and + * pages with double newlines so the LLM gets a structured input. We + * don't try to preserve columns / tables — the use case is "feed me + * the prose so I can make cards", not document fidelity. + * + * Worker is wired via Vite's `?worker` suffix so the heavy parsing + * happens off the main thread (PDF extraction is CPU-heavy). + */ + +import * as pdfjs from 'pdfjs-dist'; +import PdfjsWorker from 'pdfjs-dist/build/pdf.worker.mjs?worker'; + +let workerWired = false; +function ensureWorker() { + if (workerWired) return; + pdfjs.GlobalWorkerOptions.workerPort = new PdfjsWorker(); + workerWired = true; +} + +export interface PdfExtractResult { + text: string; + pageCount: number; +} + +export async function extractTextFromPdf(file: File | Blob): Promise { + ensureWorker(); + const buffer = await file.arrayBuffer(); + const doc = await pdfjs.getDocument({ data: new Uint8Array(buffer) }).promise; + + const pages: string[] = []; + for (let i = 1; i <= doc.numPages; i++) { + const page = await doc.getPage(i); + const content = await page.getTextContent(); + const pieces: string[] = []; + for (const item of content.items) { + if (typeof (item as { str?: string }).str === 'string') { + pieces.push((item as { str: string }).str); + } + } + pages.push( + pieces + .join(' ') + .replace(/[ \t]+/g, ' ') + .trim() + ); + } + + await doc.destroy(); + return { + text: pages.filter(Boolean).join('\n\n'), + pageCount: doc.numPages, + }; +} diff --git a/apps/cards/apps/web/src/lib/components/AiCardGen.svelte b/apps/cards/apps/web/src/lib/components/AiCardGen.svelte index 0084697a0..45b75dc67 100644 --- a/apps/cards/apps/web/src/lib/components/AiCardGen.svelte +++ b/apps/cards/apps/web/src/lib/components/AiCardGen.svelte @@ -1,5 +1,6 @@ @@ -97,16 +123,36 @@ {#if stage === 'error' && error}

{error}

{/if} -
- {source.length} Zeichen - +
+
+ {source.length} Zeichen + {#if pdfStatus}📄 {pdfStatus}{/if} +
+
+ + +
+ + {:else if stage === 'reading-pdf'} +
{pdfStatus ?? 'Lese PDF…'}
{:else if stage === 'generating'}
Modell denkt nach…
{:else if stage === 'preview'} diff --git a/apps/cards/apps/web/src/lib/components/StudyHeatmap.svelte b/apps/cards/apps/web/src/lib/components/StudyHeatmap.svelte new file mode 100644 index 000000000..d9a80816f --- /dev/null +++ b/apps/cards/apps/web/src/lib/components/StudyHeatmap.svelte @@ -0,0 +1,93 @@ + + +
+
+ Lernaktivität + + {total} Karten · {activeDays} aktive {activeDays === 1 ? 'Tag' : 'Tage'} · letzte {weeks} Wochen + +
+
+ {#each columns as col, ci (ci)} +
+ {#each col as cell, ri (ri)} + {#if cell.date === null} +
+ {:else} +
+ {/if} + {/each} +
+ {/each} +
+
+ weniger + + + + + + mehr +
+
diff --git a/apps/cards/apps/web/src/lib/queries.ts b/apps/cards/apps/web/src/lib/queries.ts index 139d9dbb1..acf2f4462 100644 --- a/apps/cards/apps/web/src/lib/queries.ts +++ b/apps/cards/apps/web/src/lib/queries.ts @@ -185,6 +185,41 @@ export function useDueCountByDeck() { }); } +/** + * Per-day review counts for the last `weeks * 7` days (default 12 weeks + * = 84 days). Used by the GitHub-style heatmap on the dashboard. Days + * with no row in cardStudyBlocks come back as count=0 so the renderer + * doesn't have to fill gaps itself. + */ +export function useStudyHeatmap(weeks: number = 12) { + return liveQuery(async () => { + const today = new Date(); + const localKey = (d: Date) => { + const y = d.getFullYear(); + const m = `${d.getMonth() + 1}`.padStart(2, '0'); + const day = `${d.getDate()}`.padStart(2, '0'); + return `${y}-${m}-${day}`; + }; + + const days = weeks * 7; + const rows = await cardStudyBlockTable.toArray(); + const byDate = new Map(); + for (const r of rows) { + if (r.deletedAt) continue; + byDate.set(r.date, (byDate.get(r.date) ?? 0) + r.cardsReviewed); + } + + const out: { date: string; count: number }[] = []; + for (let i = days - 1; i >= 0; i--) { + const d = new Date(today); + d.setDate(d.getDate() - i); + const key = localKey(d); + out.push({ date: key, count: byDate.get(key) ?? 0 }); + } + return out; + }); +} + /** * Days-in-a-row with at least one review. Walks back from today; the * first day with no row (or a soft-deleted/empty one) ends the count. diff --git a/apps/cards/apps/web/src/routes/+page.svelte b/apps/cards/apps/web/src/routes/+page.svelte index 86398b0b4..3cbdc2499 100644 --- a/apps/cards/apps/web/src/routes/+page.svelte +++ b/apps/cards/apps/web/src/routes/+page.svelte @@ -3,6 +3,7 @@ import { useAllDecks, useDueCountByDeck } from '$lib/queries'; import { deckStore } from '$lib/stores/decks.svelte'; import AnkiImport from '$lib/components/AnkiImport.svelte'; + import StudyHeatmap from '$lib/components/StudyHeatmap.svelte'; import type { Deck } from '@mana/cards-core'; const decksQuery = $derived(useAllDecks()); @@ -142,6 +143,10 @@ {/if}
+ +
+ +
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 890d243ef..625553070 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -232,6 +232,9 @@ importers: jszip: specifier: ^3.10.1 version: 3.10.1 + pdfjs-dist: + specifier: ^5.7.284 + version: 5.7.284 sql.js: specifier: ^1.14.1 version: 1.14.1 @@ -6215,6 +6218,76 @@ packages: cpu: [x64] os: [win32] + '@napi-rs/canvas-android-arm64@0.1.100': + resolution: {integrity: sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@0.1.100': + resolution: {integrity: sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@0.1.100': + resolution: {integrity: sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': + resolution: {integrity: sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': + resolution: {integrity: sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-arm64-musl@0.1.100': + resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': + resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + + '@napi-rs/canvas-linux-x64-gnu@0.1.100': + resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-linux-x64-musl@0.1.100': + resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': + resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@0.1.100': + resolution: {integrity: sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@0.1.100': + resolution: {integrity: sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==} + engines: {node: '>= 10'} + '@nestjs/cli@10.4.9': resolution: {integrity: sha512-s8qYd97bggqeK7Op3iD49X2MpFtW4LVNLAwXFkfbRxKME6IYT7X0muNTJ2+QfI8hpbNx9isWkrLWIp+g5FOhiA==} engines: {node: '>= 16.14'} @@ -13921,6 +13994,10 @@ packages: pdf-lib@1.17.1: resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==} + pdfjs-dist@5.7.284: + resolution: {integrity: sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw==} + engines: {node: '>=22.13.0 || >=24'} + pend@1.2.0: resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==} @@ -20733,6 +20810,54 @@ snapshots: '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': optional: true + '@napi-rs/canvas-android-arm64@0.1.100': + optional: true + + '@napi-rs/canvas-darwin-arm64@0.1.100': + optional: true + + '@napi-rs/canvas-darwin-x64@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@0.1.100': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-x64-musl@0.1.100': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@0.1.100': + optional: true + + '@napi-rs/canvas@0.1.100': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 0.1.100 + '@napi-rs/canvas-darwin-arm64': 0.1.100 + '@napi-rs/canvas-darwin-x64': 0.1.100 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.100 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.100 + '@napi-rs/canvas-linux-arm64-musl': 0.1.100 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.100 + '@napi-rs/canvas-linux-x64-gnu': 0.1.100 + '@napi-rs/canvas-linux-x64-musl': 0.1.100 + '@napi-rs/canvas-win32-arm64-msvc': 0.1.100 + '@napi-rs/canvas-win32-x64-msvc': 0.1.100 + optional: true + '@nestjs/cli@10.4.9': dependencies: '@angular-devkit/core': 17.3.11(chokidar@3.6.0) @@ -31164,6 +31289,10 @@ snapshots: pako: 1.0.11 tslib: 1.14.1 + pdfjs-dist@5.7.284: + optionalDependencies: + '@napi-rs/canvas': 0.1.100 + pend@1.2.0: {} performance-now@2.1.0: