feat: retire Learning Hub
Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 45s
Forgejo Android APK / Build signed APK (push) Successful in 2m1s
Forgejo Docker Build / Build Docker image (push) Successful in 9s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s

My Resources generates better slides than Learning Hub ever did — a typed deck
the model fills in, rendered by python-pptx with fit-to-slide text, figures, a
vision review and themes, against Learning Hub's markdown-through-pandoc — and
the articles and quizzes now live in the quiz app. Keeping a second, weaker
generator and a whole CMS beside it was not earning its maintenance.

Removed: three routers, the Learning Hub and Content Manager tabs, their
components and frontend modules, the five database tables, the WebDAV browser,
the content embedding column and its vector index.

Content was exported first — every article as markdown plus a full SQL dump of
all five tables — to ops-backups/learning-hub-export-*. That export is the
restore path; the migration's down() can recreate the shape but never the rows,
and says so.

Two things this simplifies rather than merely deletes:

generated_image_links existed only to record which published content an image
appeared in, and it was the sole reason a generated image could be read by
someone who did not make it. Images are now owner-only — the visibility rule is
one WHERE clause instead of a join across two tables and a published flag.

embeddings.js keeps the model discovery the admin panel uses and loses
searchSimilar and generateContentEmbedding, which queried a table that no longer
exists.

Kept deliberately: Nextcloud connect, disconnect and export, which are how a
generated note reaches a real filesystem and have nothing to do with Learning
Hub; learningRetrieval, which despite its name is the clinical corpus search My
Resources depends on; and the pandoc reference deck, still the fallback when the
python renderer fails, moved from assets/learning to assets/deck now that the
old name misleads.

Tests: four Learning-Hub-only files removed, and the individual cases inside
shared files that asserted its behaviour. Where a test used a Learning endpoint
only as a convenient example — the account-boundary token test, the policy
matrix — it now uses one that still exists, so the property it proves is
unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-12 20:14:20 +02:00
parent 59226f2109
commit 025290d64a
59 changed files with 105 additions and 5967 deletions

View file

@ -1,6 +1,6 @@
# Ped-AI
Ped-AI is a pediatric clinical documentation, education, and bedside decision-support app. This fork has moved well beyond the original scribe app: it now combines encounter documentation, clinical workflows, Learning Hub CMS, admin controls, MCP-backed clinical assistant integration, Redis-backed operational state, and hardened deployment defaults.
Ped-AI is a pediatric clinical documentation, education, and bedside decision-support app. This fork has moved well beyond the original scribe app: it now combines encounter documentation, clinical workflows, private teaching material, admin controls, MCP-backed clinical assistant integration, Redis-backed operational state, and hardened deployment defaults.
The app runs as an authenticated Express/Postgres service with a browser frontend and optional integrations for LiteLLM, AWS, OpenAI-compatible APIs, Nextcloud WebDAV, S3-compatible storage, OpenBao, Redis, OIDC, TOTP, and Cloudflare Turnstile.
@ -24,14 +24,6 @@ The app runs as an authenticated Express/Postgres service with a browser fronten
- Mobile-friendly PWA layout for bedside use.
- Per-user phone extension and pager directory with soft-delete, search, ZIP export, and JSON/ZIP import for handoff between users.
### Learning Hub
- CMS for articles, clinical pearls, quizzes, and presentations.
- Tiptap article editor, quiz builder, category management, and draft/publish flow.
- AI-assisted content generation from topic text, uploaded files, or connected Nextcloud WebDAV files.
- Marp slide editing with preview and PPTX export.
- Keyword, semantic, and hybrid search using Postgres/pgvector where configured.
### My Resources
- Private teaching material any signed-in user can generate for themselves — nobody else sees it.
@ -180,7 +172,6 @@ Primary references:
- `docs/embeddings-setup.md` — embedding model configuration.
- `docs/global-prompt-administration.md` — prompt overrides and the conversation budget.
- `docs/speech.md` — STT, TTS, recording, and audio backups.
- `docs/learning-hub.md` — the CMS and education workflow.
- `docs/my-resources.md` — private teaching material, the slide renderer, and search sources.
- `docs/deployment.md` — production deployment.
- `docs/scaling.md` — scaling priorities and readiness work.

View file

@ -180,11 +180,6 @@ Metadata for files in S3-compatible object storage. File bytes stay in S3.
| description | TEXT | |
| created_at | TIMESTAMPTZ DEFAULT NOW() | |
### `learning_categories`, `learning_content`, `learning_questions`, `learning_options`, `learning_progress`
Learning Hub CMS tables. `learning_content.embedding` is `VECTOR(768)` for
semantic search (pgvector IVFFLAT index). See `docs/learning-hub.md`.
### `developmental_milestones`
AAP-aligned pediatric milestone reference data. Age group + domain keyed.

View file

@ -26,19 +26,9 @@ Browser Whisper and browser-local model workers are removed. Do not expect a pre
The voice preview button calls LiteLLM TTS and plays the returned audio in the browser. If preview is silent, check that a LiteLLM voice is selected, the gateway is configured, the user is authenticated, and browser autoplay has not blocked playback.
## Learning Hub
Learning Hub is both a learner-facing content area and an admin/moderator CMS.
- Articles and pearls render sanitized content.
- Quizzes support single-answer, multi-select, and true/false questions.
- Presentations use Marp-style markdown with preview and PPTX export.
- AI generation can use topic text, uploaded source files, or connected Nextcloud WebDAV files.
- Categories can organize content without deleting the content when category assignments change.
## Nextcloud WebDAV
Users can connect a Nextcloud account with an app password. Learning Hub AI generation can browse files from the connected WebDAV account, and users can set a default browse path to avoid repeatedly navigating to the same clinical content folder.
Users can connect a Nextcloud account with an app password and export generated notes to it.
## Documents And S3
@ -79,7 +69,7 @@ deliberately never cached. Full detail in
## Admin Panel
Admins can manage users, roles, registration, security settings, model defaults, prompts, logs, and Learning Hub content. Production deployments should enable SSO/2FA and restrict admin access.
Admins can manage users, roles, registration, security settings, model defaults, prompts and logs. Production deployments should enable SSO/2FA and restrict admin access.
## Feature Status
@ -89,7 +79,6 @@ Admins can manage users, roles, registration, security settings, model defaults,
| Server transcription | Active | Routed through LiteLLM; the upstream vendor is a gateway choice. |
| Browser Web Speech preview | Optional | Explicit opt-in only. |
| Browser Whisper | Removed | No public worker or model download path. |
| Learning Hub CMS | Active | Articles, pearls, quizzes, presentations. |
| Nextcloud WebDAV | Active | Used for file browsing/content import. |
| Patient handouts | Active | Parent-facing, note-derived, preferred-language draft. |
| Extension transfer | Active | ZIP export plus JSON/ZIP import preview. |
@ -107,4 +96,4 @@ Admins can manage users, roles, registration, security settings, model defaults,
- Check `docker logs pediatric-ai-scribe -f` for backend errors.
- Check `/api/health` for service status.
- Check provider credentials and model names before debugging UI state.
- For Learning Hub file import failures, verify Nextcloud URL, username, app password, and folder path.
- For Nextcloud export failures, verify the URL, username, app password, and folder path.

View file

@ -1,101 +0,0 @@
# Learning Hub
A CMS + content-delivery module for clinical education material inside the
app. Supports articles, clinical pearls, quizzes, and Marp-rendered
presentations with PPTX export. Quiz questions are stored alongside article
content and can optionally be generated by AI from uploaded source material.
## Content types
| Type | Description |
|---|---|
| `article` | Rich HTML body with an optional attached quiz |
| `pearl` | Short clinical snippet (no quiz, no heavy media) |
| `quiz` | Standalone quiz (no article body) |
| `presentation` | Marp markdown rendered as slides; PPTX export supported |
## User-facing features
- Browse by category.
- Three search modes:
- **Keyword** — Postgres full-text.
- **Semantic** — pgvector cosine similarity on the embedding column.
- **Hybrid** — weighted merge of both result sets.
- Articles render with sanitized HTML (DOMPurify, loaded via SRI-pinned cdnjs).
- Quizzes: multiple-choice, multi-select, true/false. Score computed on submit,
per-question explanations revealed after.
- Presentation viewer: modal with keyboard / swipe navigation.
- Progress: `learning_progress` stores per-attempt score + total.
## CMS (moderator / admin)
- Tiptap rich-text editor for article body.
- Draft / published toggle.
- Category assignment.
- Quiz builder: add/remove questions, add/remove options, mark correct, enter
explanation.
- Marp editor for presentations with live preview.
## AI content generation
`POST /api/admin/learning/generate` takes one of:
| Input | Notes |
|---|---|
| `topic` | Plain-text description of the topic |
| Uploaded files | PDF / DOCX / PPTX / ODT / EPUB / TXT / MD / HTML / CSV / JSON, ≤ 10 MB each, max 10 files. The declared type must be in the allowlist *and* match the extension, and the bytes are sniffed before anything parses them. |
| WebDAV path | Pulled from the user's connected Nextcloud instance |
Parameters: `model` (from the provider whitelist), `slideCount` for
presentations, `wordCount` for articles.
File uploads pass the `src/utils/fileType.js` magic-byte check so a
mismatched extension is rejected before it reaches the parser.
## Marp → PPTX export
`POST /api/admin/learning/generate-pptx` writes the markdown to a temp
directory and runs `pandoc` against
`assets/learning/slides-reference.pptx`. The reference deck carries the fonts,
palette and slide layouts, so restyling the export means editing that file in
PowerPoint — not changing code.
Images are handled before pandoc sees the markdown. Each
`/api/generated-images/{id}` link is resolved through the ownership check and
written beside the deck under a name this route chooses; any link that does not
resolve to one of those is dropped. Pandoc resolves image links against the
filesystem, so passing an arbitrary local path through would embed that file
into the deck.
This is the Learning Hub's own path and is separate from My Resources, which
renders decks with python-pptx from a typed deck rather than from markdown —
see [`my-resources.md`](my-resources.md).
## Semantic search
| | |
|---|---|
| Store | `pgvector` on `learning_content.embedding VECTOR(768)` |
| Index | IVFFLAT, cosine distance |
| Primary model | `vertex/text-embedding-005` (768 dims), served through LiteLLM |
| Fallback model | OpenAI `text-embedding-3-small` (truncated to 768 to match the column) |
Embeddings are generated on content publish + on every edit. If the embedding
provider is unreachable, the content still saves — keyword search remains
available.
## Tables
| Table | Purpose |
|---|---|
| `learning_categories` | Top-level groupings |
| `learning_content` | Articles / pearls / quizzes / presentations. Body + `embedding` vector. |
| `learning_questions` | Quiz question prompts (FK to content) |
| `learning_options` | Answer options (FK to question) |
| `learning_progress` | Per-user attempt history |
## Retrieval sizing
How many corpus excerpts the Clinical Assistant, the Learning Hub and My
Resources each receive, and the reranker cap that overrides all three:
[retrieval-tuning.md](retrieval-tuning.md).

View file

@ -43,7 +43,7 @@ generation can use topic text, uploaded files, or connected Nextcloud/WebDAV
sources. Semantic search uses pgvector embeddings on `learning_content` when an
embedding provider is configured.
See [`../learning-hub.md`](../learning-hub.md) and
See
[`../embeddings-setup.md`](../embeddings-setup.md).
## Security Rules

View file

@ -0,0 +1,39 @@
// Learning Hub is retired. My Resources generates better slides from a typed
// deck, and the articles and quizzes live in the quiz app.
//
// The content was exported before this ran — every article as markdown plus a
// full SQL dump of all five tables — to ops-backups/learning-hub-export-*.
// That export is the restore path; this migration is not reversible in any
// useful sense, because down() can recreate the shape but never the rows.
//
// generated_image_links goes with it: it existed only to say which Learning Hub
// content an image was published in, and it was the sole reason a generated
// image could ever be read by someone who did not make it. Images are now
// owner-only, which is both simpler and stricter.
exports.up = async function (pgm) {
// Order matters only for readability — CASCADE handles the dependencies.
pgm.sql('DROP TABLE IF EXISTS generated_image_links CASCADE');
pgm.sql('DROP TABLE IF EXISTS learning_progress CASCADE');
pgm.sql('DROP TABLE IF EXISTS learning_options CASCADE');
pgm.sql('DROP TABLE IF EXISTS learning_questions CASCADE');
pgm.sql('DROP TABLE IF EXISTS learning_content CASCADE');
pgm.sql('DROP TABLE IF EXISTS learning_categories CASCADE');
// The workflow enum on image jobs. Any rows for the retired workflow go with
// it: those images belonged to Learning Hub content that no longer exists.
pgm.sql("DELETE FROM generated_image_jobs WHERE workflow = 'learning_hub'");
pgm.sql('ALTER TABLE generated_image_jobs DROP CONSTRAINT IF EXISTS generated_image_jobs_workflow_check');
pgm.sql("ALTER TABLE generated_image_jobs ADD CONSTRAINT generated_image_jobs_workflow_check " +
"CHECK (workflow = ANY (ARRAY['clinical_assistant'::text, 'my_resources'::text]))");
// Settings that only ever addressed Learning Hub.
pgm.sql("DELETE FROM app_settings WHERE key LIKE 'learning_hub.%'");
};
exports.down = async function (pgm) {
// The shape, not the content. Restoring the rows means loading the export.
pgm.sql('ALTER TABLE generated_image_jobs DROP CONSTRAINT IF EXISTS generated_image_jobs_workflow_check');
pgm.sql("ALTER TABLE generated_image_jobs ADD CONSTRAINT generated_image_jobs_workflow_check " +
"CHECK (workflow = ANY (ARRAY['clinical_assistant'::text, 'learning_hub'::text, 'my_resources'::text]))");
};

View file

@ -1,327 +0,0 @@
<div class="module-header">
<h2><i class="fas fa-pen-to-square"></i> Content Manager</h2>
<p>Create and manage Learning Hub content, quizzes, and categories</p>
</div>
<!-- Stats bar -->
<div class="cms-stats-bar" id="lh-cms-stats-bar">
<div class="cms-stat"><span class="cms-stat-value" id="cms-stat-published">-</span><span class="cms-stat-label">Published</span></div>
<div class="cms-stat"><span class="cms-stat-value" id="cms-stat-drafts">-</span><span class="cms-stat-label">Drafts</span></div>
<div class="cms-stat"><span class="cms-stat-value" id="cms-stat-categories">-</span><span class="cms-stat-label">Categories</span></div>
<div class="cms-stat"><span class="cms-stat-value" id="cms-stat-quizzes">-</span><span class="cms-stat-label">Quizzes</span></div>
<div class="cms-stat"><span class="cms-stat-value" id="cms-stat-attempts">-</span><span class="cms-stat-label">Attempts</span></div>
</div>
<!-- CMS Layout: sidebar + main -->
<div class="cms-layout">
<!-- Sidebar: Categories + filters -->
<aside class="cms-sidebar">
<div class="cms-sidebar-section">
<h4><i class="fas fa-folder-tree"></i> Categories</h4>
<div id="lh-cms-categories" class="cms-cat-list"></div>
<form id="lh-cms-add-category" class="cms-add-cat">
<input type="text" id="lh-cms-cat-name" placeholder="New category..." aria-label="New category name" class="cms-input-sm">
<button type="submit" id="btn-lh-add-cat" class="cms-btn-add" title="Add category">Add</button>
</form>
</div>
<div class="cms-sidebar-section">
<h4><i class="fas fa-filter"></i> Filter</h4>
<select id="cms-filter-status" class="cms-input-sm" style="width:100%;margin-bottom:6px;">
<option value="all">All Status</option>
<option value="published">Published</option>
<option value="draft">Drafts</option>
</select>
<select id="cms-filter-category" class="cms-input-sm" style="width:100%;">
<option value="all">All Categories</option>
</select>
</div>
</aside>
<!-- Main: Content list / Editor -->
<div class="cms-main">
<!-- Content list view -->
<div id="cms-list-view">
<div class="cms-toolbar">
<div style="display:flex;gap:8px;">
<button type="button" id="btn-lh-new-content" class="btn-sm btn-primary" data-type="article"><i class="fas fa-file-alt"></i> New Article</button>
<button type="button" id="btn-lh-new-quiz" class="btn-sm" style="background:var(--amber-light);color:#92400e;border:1px solid var(--amber);border-radius:6px;padding:6px 12px;font-size:13px;cursor:pointer;"><i class="fas fa-clipboard-question"></i> New Quiz</button>
<button type="button" id="btn-lh-new-pearl" class="btn-sm" style="background:var(--purple-light);color:var(--purple);border:1px solid var(--purple);border-radius:6px;padding:6px 12px;font-size:13px;cursor:pointer;"><i class="fas fa-gem"></i> New Pearl</button>
<button type="button" id="btn-lh-new-presentation" class="btn-sm" style="background:#ecfdf5;color:#065f46;border:1px solid #6ee7b7;border-radius:6px;padding:6px 12px;font-size:13px;cursor:pointer;"><i class="fas fa-presentation-screen"></i> New Presentation</button>
</div>
<div style="display:flex;gap:8px;align-items:center;">
<button type="button" id="btn-lh-refresh-content" class="btn-sm btn-ghost" title="Refresh content list"><i class="fas fa-rotate"></i></button>
<div class="cms-search">
<i class="fas fa-search"></i>
<input type="text" id="cms-search" placeholder="Search content..." class="cms-input-sm">
</div>
</div>
</div>
<div class="cms-content-table">
<div class="cms-table-header">
<span class="cms-col-title">Title</span>
<span class="cms-col-cat">Category</span>
<span class="cms-col-type">Type</span>
<span class="cms-col-status">Status</span>
<span class="cms-col-date">Updated</span>
<span class="cms-col-actions"></span>
</div>
<div id="lh-cms-content-list" class="cms-table-body">
<div style="text-align:center;padding:40px;color:var(--g400);">Loading...</div>
</div>
</div>
</div>
<!-- Editor view (hidden until edit/new) -->
<div id="lh-cms-editor" class="cms-editor hidden">
<div class="cms-editor-header">
<button type="button" id="btn-lh-close-editor" class="btn-sm btn-ghost"><i class="fas fa-arrow-left"></i> Back to List</button>
<div class="cms-editor-actions">
<button type="button" id="btn-lh-ai-open" class="btn-sm" style="background:linear-gradient(135deg,#7c3aed,#2563eb);color:white;border:none;"><i class="fas fa-wand-magic-sparkles"></i> AI Generate</button>
<button type="button" id="btn-lh-delete-content" class="btn-sm hidden" style="background:var(--red-light);color:var(--red);"><i class="fas fa-trash"></i> Delete</button>
<select id="lh-cms-edit-published" class="cms-input-sm">
<option value="false">Draft</option>
<option value="true">Published</option>
</select>
<button type="button" id="btn-lh-save-content" class="btn-sm btn-primary"><i class="fas fa-save"></i> Save</button>
</div>
</div>
<!-- Inline delete confirmation bar — message built dynamically in JS -->
<div id="lh-delete-confirm-bar" class="lh-delete-confirm-bar hidden">
<i class="fas fa-triangle-exclamation"></i>
<span id="lh-delete-confirm-msg"></span>
<div style="display:flex;gap:8px;margin-left:auto;flex-shrink:0;">
<button type="button" id="btn-lh-delete-cancel" class="btn-sm btn-ghost">Cancel</button>
<button type="button" id="btn-lh-delete-yes" class="btn-sm" style="background:var(--red);color:white;">Yes, Delete</button>
</div>
</div>
<input type="hidden" id="lh-cms-edit-id" value="">
<!-- Title -->
<input type="text" id="lh-cms-edit-title" class="cms-title-input" placeholder="Enter title...">
<!-- Meta row -->
<div class="cms-meta-row">
<div class="cms-meta-field">
<label>Category</label>
<select id="lh-cms-edit-category" class="cms-input-sm">
<option value="">Uncategorized</option>
</select>
</div>
<div class="cms-meta-field">
<label>Subject</label>
<input type="text" id="lh-cms-edit-subject" class="cms-input-sm" placeholder="e.g., Development">
</div>
<div class="cms-meta-field">
<label>Type</label>
<select id="lh-cms-edit-type" class="cms-input-sm">
<option value="article">Article</option>
<option value="quiz">Quiz Only</option>
<option value="pearl">Clinical Pearl</option>
<option value="presentation">Presentation</option>
</select>
</div>
</div>
<!-- AI Generate panel -->
<div id="lh-ai-panel" class="lh-ai-panel hidden">
<div class="lh-ai-panel-header">
<div style="display:flex;align-items:center;gap:8px;">
<span style="font-size:18px;"></span>
<strong style="font-size:14px;color:var(--g800);">AI Content Generator</strong>
</div>
<button id="btn-lh-ai-close" class="btn-sm btn-ghost" style="padding:2px 8px;">✕ Close</button>
</div>
<!-- Source tabs -->
<div class="lh-ai-tabs">
<button class="lh-ai-tab active" data-aitab="topic"><i class="fas fa-lightbulb"></i> Describe Content</button>
<button class="lh-ai-tab" data-aitab="upload"><i class="fas fa-file-upload"></i> Upload File</button>
<button class="lh-ai-tab" data-aitab="webdav" data-feature="nextcloud" id="lh-ai-tab-webdav"><i class="fas fa-cloud"></i> Nextcloud</button>
</div>
<!-- Topic tab -->
<div class="lh-ai-tabpanel" id="lh-ai-tp-topic">
<div class="form-group" style="margin:0;">
<label style="font-size:12px;font-weight:600;color:var(--g600);">Tell AI what to create</label>
<textarea id="lh-ai-topic" class="cms-input-sm" style="width:100%;min-height:72px;resize:vertical;font-family:inherit;line-height:1.5;" placeholder="Describe the content you want AI to generate, e.g.:&#10;&#10;Write an article on febrile seizures in children covering causes, evaluation, and management.&#10;&#10;Create a clinical pearl on neonatal jaundice with key decision points.&#10;&#10;Build a quiz on pediatric asthma classification and treatment ladder."></textarea>
</div>
</div>
<!-- Upload tab -->
<div class="lh-ai-tabpanel hidden" id="lh-ai-tp-upload">
<label class="lh-ai-dropzone" id="lh-ai-dropzone">
<i class="fas fa-cloud-upload-alt" style="font-size:28px;color:var(--blue);margin-bottom:8px;display:block;"></i>
<span id="lh-ai-file-label">Drop files here or click to browse</span>
<small style="color:var(--g400);display:block;margin-top:4px;">PDF, DOCX, PPTX, ODT, EPUB, TXT, MD, HTML, CSV, JSON — max 10 MB each, up to 10 files</small>
<input type="file" id="lh-ai-file" accept=".pdf,.txt,.md,.html,.htm,.csv,.json" multiple style="display:none;">
</label>
<div id="lh-ai-files-list" style="margin-top:8px;display:none;"></div>
<div class="form-group" style="margin:8px 0 0;">
<label style="font-size:12px;font-weight:600;color:var(--g600);">Topic / context <span style="color:var(--g400);font-weight:400;">(optional — helps AI focus)</span></label>
<input type="text" id="lh-ai-upload-context" class="cms-input-sm" style="width:100%;" placeholder="e.g., Pediatric asthma management, focus on treatment ladder">
</div>
</div>
<!-- WebDAV tab -->
<div class="lh-ai-tabpanel hidden" data-feature="nextcloud" id="lh-ai-tp-webdav">
<!-- File browser — hidden once a file is selected -->
<div id="lh-ai-webdav-browser">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<span id="lh-ai-webdav-path-label" style="font-size:12px;color:var(--g500);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">/</span>
<button id="btn-lh-webdav-refresh" class="btn-sm btn-ghost" style="padding:3px 8px;font-size:12px;"><i class="fas fa-sync"></i></button>
</div>
<div id="lh-ai-webdav-list" style="max-height:200px;overflow-y:auto;border:1px solid var(--g200);border-radius:8px;background:white;">
<div style="padding:12px;text-align:center;color:var(--g400);font-size:13px;">Click to browse your Nextcloud files</div>
</div>
</div>
<!-- Selected file indicator — shown after selection, replaces browser -->
<div id="lh-ai-webdav-selected" style="display:none;flex-direction:column;gap:8px;">
<div style="display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--blue-light);border-radius:6px;font-size:13px;color:var(--blue);">
<i class="fas fa-file-check"></i>
<span id="lh-ai-webdav-selected-name" style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></span>
<button id="btn-lh-webdav-deselect" style="background:none;border:none;cursor:pointer;color:var(--blue);padding:0;font-size:16px;line-height:1;" title="Remove selection"></button>
</div>
<div class="form-group" style="margin:0;">
<label style="font-size:12px;font-weight:600;color:var(--g600);">Topic / context <span style="color:var(--g400);font-weight:400;">(optional — helps AI focus)</span></label>
<input type="text" id="lh-ai-webdav-context" class="cms-input-sm" style="width:100%;" placeholder="e.g., Focus on management guidelines for pediatric residents">
</div>
</div>
</div>
<!-- Generation options -->
<div class="lh-ai-options">
<!-- Row 1: type + model -->
<div class="lh-ai-opt-row">
<div class="lh-ai-opt-field">
<label>Content type</label>
<select id="lh-ai-ctype" class="cms-input-sm">
<option value="article">Article</option>
<option value="quiz">Quiz</option>
<option value="pearl">Clinical Pearl</option>
<option value="presentation">Presentation</option>
</select>
</div>
<div class="lh-ai-opt-field" style="flex:2;">
<label>Model</label>
<select id="lh-ai-model" class="cms-input-sm tab-model-select" style="width:100%;"></select>
</div>
</div>
<!-- Row 2: context-aware size options (shown/hidden via style.display in JS) -->
<div class="lh-ai-opt-row" id="lh-ai-size-row">
<div id="lh-ai-words-field" style="display:flex;flex-direction:column;gap:3px;">
<label style="font-size:11px;font-weight:600;color:var(--g500);text-transform:uppercase;letter-spacing:0.3px;">Approx. word count <span style="color:var(--g400);font-weight:400;">(optional)</span></label>
<input type="number" id="lh-ai-word-count" value="" min="100" max="3000" step="50" class="cms-input-sm" style="width:110px;" placeholder="e.g. 600">
</div>
<div id="lh-ai-slides-field" style="display:none;flex-direction:column;gap:3px;">
<label style="font-size:11px;font-weight:600;color:var(--g500);text-transform:uppercase;letter-spacing:0.3px;">Number of slides <span style="color:var(--g400);font-weight:400;">(optional)</span></label>
<input type="number" id="lh-ai-slide-count" value="" min="3" max="30" class="cms-input-sm" style="width:90px;" placeholder="e.g. 10">
</div>
</div>
<!-- Row 3: quiz questions card -->
<div id="lh-ai-quiz-row" class="lh-ai-quiz-card">
<div class="lh-ai-quiz-card-header" id="lh-ai-quiz-card-header">
<i class="fas fa-clipboard-question" style="color:var(--blue);font-size:14px;"></i>
<span id="lh-ai-quiz-card-title" style="font-weight:600;font-size:13px;color:var(--g800);">Quiz Questions</span>
</div>
<div class="lh-ai-quiz-card-body">
<label id="lh-ai-q-toggle-label" style="display:none;align-items:center;gap:8px;cursor:pointer;font-size:13px;color:var(--g700);margin:0;">
<input type="checkbox" id="lh-ai-q-toggle" style="accent-color:var(--blue);width:15px;height:15px;flex-shrink:0;">
Also generate quiz questions for this content
</label>
<div id="lh-ai-q-count-wrap" style="display:flex;align-items:center;gap:8px;">
<label style="font-size:12px;color:var(--g600);margin:0;">Number of questions</label>
<input type="number" id="lh-ai-q-count" value="5" min="1" max="20" class="cms-input-sm" style="width:70px;">
</div>
</div>
</div>
<!-- Row 4: grounding -->
<div class="lh-ai-opt-field" style="width:100%;">
<label style="display:flex;align-items:center;gap:8px;font-weight:600;">
<input type="checkbox" id="lh-ai-use-corpus" checked>
Use the clinical library
</label>
<p style="margin:4px 0 0;font-size:12px;color:var(--g500);">
Searches the indexed clinical corpus for this topic and writes from those
excerpts, preferring them over the model's own recall. The resource ends with
a References section listing what it drew on. Turn this off for a topic the
library does not cover &mdash; grounding on the nearest unrelated material is
worse than not grounding at all.
</p>
<p id="lh-ai-grounding-result" role="status" style="margin:6px 0 0;font-size:12px;color:var(--g600);"></p>
</div>
<!-- Row 5: instructions -->
<div class="lh-ai-opt-field" style="width:100%;">
<label>Special instructions <span style="color:var(--g400);font-weight:400;">(optional)</span></label>
<textarea id="lh-ai-refinement" class="cms-input-sm" style="width:100%;min-height:90px;resize:vertical;font-family:inherit;line-height:1.5;" placeholder="e.g., Focus on ER management, suitable for residents, case-based format"></textarea>
</div>
</div>
<div style="display:flex;gap:8px;align-items:center;">
<button type="button" id="btn-lh-ai-generate" class="btn-sm btn-primary" style="flex:1;padding:10px;">
<i class="fas fa-wand-magic-sparkles"></i> Generate Content
</button>
<button type="button" id="btn-lh-ai-refine-body" class="btn-sm btn-ghost" style="padding:10px;">
<i class="fas fa-pen-to-square"></i> Refine Body
</button>
</div>
<!-- Inline refine input bar — shown when Refine Body is clicked -->
<div id="lh-ai-refine-bar" style="display:none;gap:6px;align-items:center;padding:10px 12px;background:#fffbeb;border:1.5px solid var(--amber);border-radius:8px;margin-top:4px;">
<input type="text" id="lh-ai-refine-input" class="cms-input-sm" style="flex:1;" placeholder='e.g. "Make it more concise" or "Add a clinical case example"'>
<button id="btn-lh-refine-submit" class="btn-sm btn-primary" style="padding:5px 14px;white-space:nowrap;">Apply</button>
<button id="btn-lh-refine-cancel" class="btn-sm btn-ghost" style="padding:5px 10px;"></button>
</div>
</div>
<!-- Body editor (Tiptap rich text) — hidden for presentations -->
<div id="lh-body-section" class="cms-body-section">
<label class="cms-label">Body</label>
<div id="lh-body-editor" class="cms-quill-body"></div>
</div>
<!-- Marp markdown editor — shown only for presentations -->
<div id="lh-marp-section" class="cms-body-section hidden">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px;">
<label class="cms-label" style="margin:0;">Slides <span style="font-size:11px;color:var(--g400);font-weight:400;">(Marp markdown — separate slides with <code>---</code>)</span></label>
<div style="display:flex;gap:6px;">
<button type="button" id="btn-lh-preview-slides" class="btn-sm btn-ghost" style="font-size:12px;"><i class="fas fa-eye"></i> Preview</button>
<button type="button" id="btn-lh-download-pptx" class="btn-sm" style="background:#065f46;color:white;font-size:12px;border:none;border-radius:6px;padding:5px 12px;cursor:pointer;"><i class="fas fa-file-powerpoint"></i> Download PPTX</button>
</div>
</div>
<textarea id="lh-marp-editor" class="lh-marp-textarea" placeholder="✨ Click 'AI Generate' above to create your slides automatically.
After generation, you can edit the Marp markdown here if needed.
Each slide is separated by ---
Example structure:
# Title Slide
Subtitle here
---
# Topic Slide
- Key point one
- Key point two"></textarea>
</div>
<!-- Shared image controls stay visible for both body and presentation editors. -->
<div id="lh-images-section"></div>
<!-- Quiz builder -->
<div class="cms-quiz-section">
<div class="cms-quiz-header">
<h3><i class="fas fa-clipboard-question"></i> Quiz Questions</h3>
<button type="button" id="btn-lh-add-question" class="btn-sm btn-ghost"><i class="fas fa-plus"></i> Add Question</button>
</div>
<div id="lh-cms-questions" class="cms-questions-list"></div>
</div>
</div>
</div>
</div>

View file

@ -1,64 +0,0 @@
<div class="module-header">
<h2><i class="fas fa-graduation-cap"></i> Learning Hub</h2>
<p>Pediatric education, clinical pearls, and self-assessment quizzes</p>
</div>
<!-- Search bar -->
<div class="card" style="padding:12px 16px;">
<div style="display:flex;gap:8px;align-items:center;">
<i class="fas fa-search" style="color:var(--g400);"></i>
<input type="text" id="lh-search" class="full-input" placeholder="Search topics, subjects..." style="border:none;flex:1;">
</div>
</div>
<!-- Category pills -->
<div id="lh-categories" class="lh-category-bar"></div>
<!-- Content feed -->
<div id="lh-feed" class="lh-feed"></div>
<!-- Content viewer (hidden until item clicked) -->
<div id="lh-viewer" class="lh-viewer hidden">
<button id="lh-back" class="btn-sm btn-ghost" style="margin-bottom:12px;"><i class="fas fa-arrow-left"></i> Back to Feed</button>
<!-- Presentation viewer — shown instead of body card for slide content -->
<div id="lh-presentation-viewer" class="hidden" style="margin-bottom:12px;"></div>
<div class="card">
<div class="card-header">
<h3 id="lh-viewer-title"></h3>
<span id="lh-viewer-meta" style="font-size:12px;color:var(--g500);"></span>
</div>
<div id="lh-viewer-body" class="lh-content-body" style="padding:16px;"></div>
</div>
<!-- Quiz section (if content has questions) -->
<div id="lh-quiz-section" class="hidden" style="margin-top:16px;">
<div class="card">
<div class="card-header">
<h3><i class="fas fa-clipboard-question"></i> Quiz</h3>
<span id="lh-quiz-count" style="font-size:12px;color:var(--g500);"></span>
</div>
<div id="lh-quiz-questions" style="padding:16px;"></div>
<div style="padding:0 16px 16px;">
<button id="lh-submit-quiz" class="btn-generate"><i class="fas fa-check"></i> Submit Answers</button>
</div>
</div>
<!-- Results -->
<div id="lh-quiz-results" class="hidden" style="margin-top:12px;">
<div class="card">
<div class="card-header">
<h3><i class="fas fa-chart-bar"></i> Results</h3>
<span id="lh-quiz-score" class="model-tag"></span>
</div>
<div id="lh-quiz-explanations" style="padding:16px;"></div>
</div>
</div>
</div>
<!-- Past attempts -->
<div id="lh-progress-section" class="hidden" style="margin-top:12px;">
<div class="card" style="padding:12px 16px;">
<h4 style="margin:0 0 8px;font-size:13px;color:var(--g600);"><i class="fas fa-history"></i> Your Past Attempts</h4>
<div id="lh-progress-list" style="font-size:13px;"></div>
</div>
</div>
</div>

View file

@ -288,14 +288,6 @@
<i class="fas fa-folder-open"></i>
<span>My Resources</span>
</button>
<button class="tab-btn" data-tab="learning">
<i class="fas fa-graduation-cap"></i>
<span>Learning Hub</span>
</button>
<button class="tab-btn hidden" data-tab="cms" id="cms-tab-btn">
<i class="fas fa-pen-to-square"></i>
<span>Content Manager</span>
</button>
<button class="tab-btn hidden" data-tab="docs" id="docs-tab-btn">
<i class="fas fa-book"></i>
<span>Docs</span>
@ -361,8 +353,6 @@
<section id="myresources-tab" class="tab-content" data-component="my-resources"></section>
<section id="diagrams-tab" class="tab-content" data-component="diagrams"></section>
<section id="assistant-tab" class="tab-content" data-component="assistant"></section>
<section id="learning-tab" class="tab-content" data-component="learning"></section>
<section id="cms-tab" class="tab-content" data-component="cms"></section>
<section id="docs-tab" class="tab-content" data-component="admin-docs"></section>
<section id="admin-tab" class="tab-content" data-component="admin"></section>
<section id="wellvisit-tab" class="tab-content" data-component="wellvisit"></section>
@ -526,7 +516,6 @@
<script type="module" src="/js/drugs-loader.js"></script>
<script type="module" src="/js/calculators.js"></script>
<script type="module" src="/js/bedside/index.js"></script>
<script type="module" src="/js/learningHub.js"></script>
<script type="module" src="/js/admin.js?v=7.1.3"></script>
<!-- ═══════════ IMAGE LIGHTBOX (global overlay — triggered by any

File diff suppressed because it is too large Load diff

View file

@ -1,103 +0,0 @@
import { getJson } from './api.js';
export function createAiPanelController(deps) {
function open() {
var panel = document.getElementById('lh-ai-panel');
if (!panel) return;
panel.classList.remove('hidden');
populateModelSelect();
var editorType = deps.getEditorType() || 'article';
var ctype = document.getElementById('lh-ai-ctype');
if (ctype) ctype.value = editorType;
updateOptions(editorType);
getJson('/api/auth/me')
.then(function(d) {
var tab = document.getElementById('lh-ai-tab-webdav');
if (tab) tab.style.display = (d.user && d.user.nextcloud_url) ? '' : 'none';
if (d.user && d.user.webdav_learning_path) deps.setWebdavPath(d.user.webdav_learning_path || '/');
});
wireOptionControls();
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function close() {
var panel = document.getElementById('lh-ai-panel');
if (panel) panel.classList.add('hidden');
}
function populateModelSelect() {
var sel = document.getElementById('lh-ai-model');
if (!sel || sel.options.length > 1) return;
if (typeof window._buildModelOptions === 'function') window._buildModelOptions(sel);
}
function switchTab(tabName) {
document.querySelectorAll('.lh-ai-tab').forEach(function(t) {
t.classList.toggle('active', t.dataset.aitab === tabName);
});
document.querySelectorAll('.lh-ai-tabpanel').forEach(function(p) { p.classList.add('hidden'); });
var tp = document.getElementById('lh-ai-tp-' + tabName);
if (tp) tp.classList.remove('hidden');
if (tabName === 'webdav' && !document.querySelector('#lh-ai-webdav-list .lh-webdav-item')) {
deps.browseWebdav(deps.getWebdavPath());
}
}
function updateOptions(type) {
applyAiOptionState(type, {
wordsField: document.getElementById('lh-ai-words-field'),
slidesField: document.getElementById('lh-ai-slides-field'),
quizRow: document.getElementById('lh-ai-quiz-row'),
cardTitle: document.getElementById('lh-ai-quiz-card-title'),
toggleLabel: document.getElementById('lh-ai-q-toggle-label'),
toggleCb: document.getElementById('lh-ai-q-toggle'),
countWrap: document.getElementById('lh-ai-q-count-wrap')
});
}
function wireOptionControls() {
var ctypeEl = document.getElementById('lh-ai-ctype');
var toggleEl = document.getElementById('lh-ai-q-toggle');
if (ctypeEl && !ctypeEl._wired) {
ctypeEl.addEventListener('change', function() { updateOptions(ctypeEl.value); });
ctypeEl._wired = true;
}
if (toggleEl && !toggleEl._wired) {
toggleEl.addEventListener('change', function() {
updateOptions((document.getElementById('lh-ai-ctype') || {}).value);
});
toggleEl._wired = true;
}
}
return {
close: close,
open: open,
switchTab: switchTab,
updateOptions: updateOptions
};
}
export function applyAiOptionState(type, els) {
if (els.wordsField) els.wordsField.style.display = (type === 'quiz' || type === 'presentation') ? 'none' : 'flex';
if (els.slidesField) els.slidesField.style.display = type === 'presentation' ? 'flex' : 'none';
if (type === 'quiz') {
if (els.quizRow) els.quizRow.style.display = 'block';
if (els.cardTitle) els.cardTitle.textContent = 'Quiz Questions';
if (els.toggleLabel) els.toggleLabel.style.display = 'none';
if (els.toggleCb) { els.toggleCb.checked = true; els.toggleCb.disabled = true; }
if (els.countWrap) els.countWrap.style.display = 'flex';
return;
}
if (els.quizRow) els.quizRow.style.display = 'block';
if (els.cardTitle) els.cardTitle.textContent = 'Quiz Questions (optional)';
if (els.toggleLabel) els.toggleLabel.style.display = 'flex';
if (els.toggleCb) els.toggleCb.disabled = false;
var checked = els.toggleCb ? els.toggleCb.checked : false;
if (els.countWrap) els.countWrap.style.display = checked ? 'flex' : 'none';
}

View file

@ -1,26 +0,0 @@
export function getJson(url) {
return fetch(url, { headers: getAuthHeaders() }).then(function(r) { return r.json(); });
}
export function sendJson(url, method, payload) {
return fetch(url, {
method: method,
headers: getAuthHeaders(),
body: JSON.stringify(payload || {})
}).then(function(r) { return r.json(); });
}
export function sendJsonBlob(url, method, payload) {
return fetch(url, {
method: method,
headers: getAuthHeaders(),
body: JSON.stringify(payload || {})
}).then(function(r) {
if (!r.ok) return r.json().then(function(e) { throw new Error(e.error || 'Request failed'); });
return r.blob();
});
}
export function deleteJson(url) {
return fetch(url, { method: 'DELETE', headers: getAuthHeaders() }).then(function(r) { return r.json(); });
}

View file

@ -1,190 +0,0 @@
import { deleteJson, getJson, sendJson } from './api.js';
import { renderCmsCategoryList, renderCmsContentEmpty, renderCmsContentRow } from './cmsRenderer.js';
import { esc } from './sanitize.js';
export function createCmsController(deps) {
var contentData = [];
function loadStats() {
getJson('/api/admin/learning/stats')
.then(function(data) {
if (!data.success) return;
var s = data.stats;
setText('cms-stat-published', s.publishedContent);
setText('cms-stat-drafts', s.totalContent - s.publishedContent);
setText('cms-stat-categories', s.totalCategories);
setText('cms-stat-quizzes', s.totalQuizzes);
setText('cms-stat-attempts', s.totalAttempts);
});
}
function loadCategories(editorCategoryId, filterCategoryId) {
getJson('/api/admin/learning/categories')
.then(function(data) {
if (!data.success) return;
var container = document.getElementById('lh-cms-categories');
if (!container) return;
container.innerHTML = renderCmsCategoryList(data.categories);
updateCategorySelects(data.categories, editorCategoryId, filterCategoryId);
setActiveCategory((document.getElementById('cms-filter-category') || {}).value || 'all');
});
}
function addCategory() {
var input = document.getElementById('lh-cms-cat-name');
if (!input || !input.value.trim()) { deps.showToast('Enter category name', 'error'); return; }
sendJson('/api/admin/learning/categories', 'POST', { name: input.value.trim() })
.then(function(data) {
if (data.success) {
input.value = '';
loadCategories();
deps.refreshUserCategories();
deps.showToast('Category created', 'success');
} else deps.showToast(data.error || 'Failed', 'error');
})
.catch(function() { deps.showToast('Request failed', 'error'); });
}
function deleteCategory(id) {
if (!window.__confirmDeleteCat) {
window.__confirmDeleteCat = id;
deps.showToast('Click delete again to confirm removing this category', 'info');
setTimeout(function() { window.__confirmDeleteCat = null; }, 4000);
return;
}
window.__confirmDeleteCat = null;
deleteJson('/api/admin/learning/categories/' + id)
.then(function(data) {
if (data.success) {
loadCategories();
deps.refreshUserCategories();
deps.showToast('Category deleted', 'info');
} else deps.showToast(data.error || 'Failed', 'error');
});
}
function loadContent() {
var container = document.getElementById('lh-cms-content-list');
if (!container) return;
container.innerHTML = '<p style="text-align:center;color:var(--g400);padding:12px;">Loading...</p>';
getJson('/api/admin/learning/content')
.then(function(data) {
if (!data.success) return;
contentData = data.content || [];
if (contentData.length === 0) {
container.innerHTML = '<p style="text-align:center;color:var(--g400);padding:12px;">No content yet. Click "New Content" to create.</p>';
return;
}
renderContentList(contentData);
});
}
function handleFilterInput(e) {
if (e.target.id === 'cms-search') filterContent();
}
function handleFilterChange(e) {
if (e.target.id !== 'cms-filter-status' && e.target.id !== 'cms-filter-category') return;
if (e.target.id === 'cms-filter-category') setActiveCategory(e.target.value || 'all');
filterContent();
}
function filterByCategory(categoryId) {
var filterSel = document.getElementById('cms-filter-category');
if (filterSel) filterSel.value = String(categoryId);
setActiveCategory(categoryId);
filterContent();
}
function refreshAll() {
loadContent();
loadCategories();
loadStats();
}
function renderContentList(items) {
var container = document.getElementById('lh-cms-content-list');
if (!container) return;
if (!items || items.length === 0) {
container.innerHTML = renderCmsContentEmpty();
return;
}
container.innerHTML = items.map(renderCmsContentRow).join('');
}
function filterContent() {
var search = (document.getElementById('cms-search') || {}).value || '';
var statusFilter = (document.getElementById('cms-filter-status') || {}).value || 'all';
var catFilter = (document.getElementById('cms-filter-category') || {}).value || 'all';
search = search.toLowerCase().trim();
var filtered = contentData.filter(function(item) {
var title = (item.title || '').toLowerCase();
var subject = (item.subject || '').toLowerCase();
if (search && title.indexOf(search) === -1 && subject.indexOf(search) === -1) return false;
if (statusFilter === 'published' && !item.published) return false;
if (statusFilter === 'draft' && item.published) return false;
if (catFilter !== 'all' && String(item.category_id) !== catFilter) return false;
return true;
});
renderContentList(filtered);
}
return {
addCategory: addCategory,
deleteCategory: deleteCategory,
filterByCategory: filterByCategory,
handleFilterChange: handleFilterChange,
handleFilterInput: handleFilterInput,
loadCategories: loadCategories,
loadContent: loadContent,
loadStats: loadStats,
refreshAll: refreshAll
};
}
function updateCategorySelects(categories, editorCategoryId, filterCategoryId) {
var sel = document.getElementById('lh-cms-edit-category');
if (sel) {
var selectedEditorId = editorCategoryId !== undefined ? editorCategoryId : sel.value;
sel.innerHTML = '<option value="">Uncategorized</option>';
categories.forEach(function(c) {
sel.innerHTML += '<option value="' + c.id + '">' + esc(c.name) + '</option>';
});
if (selectedEditorId && hasSelectValue(sel, selectedEditorId)) sel.value = selectedEditorId;
}
var filterSel = document.getElementById('cms-filter-category');
if (filterSel) {
var selectedFilterId = filterCategoryId !== undefined ? filterCategoryId : filterSel.value;
filterSel.innerHTML = '<option value="all">All Categories</option>';
categories.forEach(function(c) {
filterSel.innerHTML += '<option value="' + c.id + '">' + esc(c.name) + '</option>';
});
if (selectedFilterId && hasSelectValue(filterSel, selectedFilterId)) filterSel.value = selectedFilterId;
}
}
function setText(id, val) {
var el = document.getElementById(id);
if (el) el.textContent = val;
}
function setActiveCategory(categoryId) {
categoryId = String(categoryId || 'all');
document.querySelectorAll('.lh-cms-cat-filter').forEach(function(item) {
item.classList.toggle('active', categoryId !== 'all' && item.dataset.id === categoryId);
});
}
function hasSelectValue(sel, value) {
value = String(value);
for (var i = 0; i < sel.options.length; i++) {
if (sel.options[i].value === value) return true;
}
return false;
}

View file

@ -1,109 +0,0 @@
import { esc } from './sanitize.js';
export function renderCmsCategoryList(categories) {
if (!categories || categories.length === 0) {
return '<div style="font-size:12px;color:var(--g400);">No categories yet</div>';
}
return categories.map(function(c) {
return '<div class="cms-cat-item lh-cms-cat-filter" data-id="' + c.id + '">' +
'<span class="cms-cat-name">' +
'<i class="fas fa-folder" style="color:var(--amber);margin-right:6px;font-size:11px;"></i>' +
esc(c.name) +
'</span>' +
'<span class="cms-cat-count">' + c.content_count + '</span>' +
'<button class="lh-cms-del-cat" data-id="' + c.id + '" ' +
'style="border:none;background:none;cursor:pointer;color:var(--g400);font-size:11px;padding:2px;" title="Delete">' +
'<i class="fas fa-times"></i>' +
'</button>' +
'</div>';
}).join('');
}
export function renderCmsContentEmpty() {
return '<div style="text-align:center;padding:40px;color:var(--g400);">' +
'<i class="fas fa-file-circle-plus" style="font-size:24px;display:block;margin-bottom:8px;"></i>' +
'No content yet. Click "New Content" to get started.' +
'</div>';
}
export function renderCmsContentRow(item) {
var statusBadge = item.published
? '<span class="cms-badge cms-badge-pub">Published</span>'
: '<span class="cms-badge cms-badge-draft">Draft</span>';
var typeLabel = item.content_type === 'quiz' ? 'Quiz'
: item.content_type === 'pearl' ? 'Pearl'
: item.content_type === 'presentation' ? 'Slides'
: 'Article';
var qBadge = item.question_count > 0
? ' <span style="color:var(--amber);">' + item.question_count + 'Q</span>'
: '';
var date = item.updated_at ? new Date(item.updated_at).toLocaleDateString() : '';
return '<div class="cms-table-row lh-cms-content-item" data-id="' + item.id + '">' +
'<span class="cms-col-title">' + esc(item.title) + qBadge +
'<br><span class="cms-title-sub">' + esc(item.subject || '') + '</span>' +
'</span>' +
'<span class="cms-col-cat">' + esc(item.category_name || 'Uncategorized') + '</span>' +
'<span class="cms-col-type">' + typeLabel + '</span>' +
'<span class="cms-col-status">' + statusBadge + '</span>' +
'<span class="cms-col-date">' + date + '</span>' +
'<span class="cms-col-actions"><i class="fas fa-pen" style="color:var(--g400);"></i></span>' +
'</div>';
}
export function renderQuestionBlockShell(existingQ, qNum) {
var qBadgeStyle = 'background:linear-gradient(135deg,var(--blue),var(--purple));color:white;' +
'font-size:12px;font-weight:700;padding:3px 10px;border-radius:6px;';
var qTextStyle = 'width:100%;font-size:14px;font-weight:500;padding:10px;' +
'border:1.5px solid var(--g300);border-radius:8px;box-sizing:border-box;' +
'margin-bottom:8px;resize:vertical;font-family:inherit;';
var qExplStyle = 'width:100%;font-size:12px;padding:6px 10px;border:1px solid var(--g300);' +
'border-radius:6px;box-sizing:border-box;resize:vertical;font-family:inherit;';
return '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;padding-bottom:8px;border-bottom:1px solid var(--g200);">' +
'<div style="display:flex;align-items:center;gap:8px;">' +
'<span style="' + qBadgeStyle + '">Q' + qNum + '</span>' +
'<select class="lh-q-type" style="font-size:12px;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;">' +
'<option value="mcq"' + selectedQuestionType(existingQ, 'mcq') + '>Single Choice</option>' +
'<option value="multi"' + selectedQuestionType(existingQ, 'multi') + '>Multiple Select</option>' +
'<option value="true_false"' + selectedQuestionType(existingQ, 'true_false') + '>True / False</option>' +
'</select>' +
'<button type="button" class="lh-richtext-btn" data-target="question">Rich Text</button>' +
'</div>' +
'<button class="btn-sm btn-ghost lh-rm-question" style="padding:2px 8px;font-size:12px;color:var(--red);"><i class="fas fa-trash"></i></button>' +
'</div>' +
'<textarea class="lh-q-text" placeholder="Enter question text..." rows="3" style="' + qTextStyle + '"></textarea>' +
'<div style="margin-bottom:8px;">' +
'<label style="font-size:11px;font-weight:600;color:var(--g500);text-transform:uppercase;letter-spacing:0.3px;display:block;margin-bottom:4px;">' +
'Answer Options <span style="color:var(--green);">(check the correct answer)</span>' +
'</label>' +
'</div>' +
'<div class="lh-options-list"></div>' +
'<button class="btn-sm btn-ghost lh-add-option" style="margin-top:6px;font-size:12px;"><i class="fas fa-plus"></i> Add Option</button>' +
'<div style="margin-top:8px;">' +
'<label style="font-size:11px;font-weight:600;color:var(--g500);display:block;margin-bottom:3px;">Explanation (shown after answering)</label>' +
'<textarea class="lh-q-explanation" placeholder="Explain the correct answer..." rows="2" style="' + qExplStyle + '"></textarea>' +
'</div>';
}
export function renderOptionRowShell(opt) {
var optTextStyle = 'width:100%;font-size:13px;padding:6px 10px;border:1.5px solid var(--g300);' +
'border-radius:6px;box-sizing:border-box;';
var optExplStyle = 'flex:1;font-size:12px;padding:6px 10px;border:1px solid var(--g300);' +
'border-radius:6px;color:var(--g600);';
return '<label style="display:flex;align-items:center;gap:4px;cursor:pointer;min-width:28px;padding-top:6px;" title="Mark as correct answer">' +
'<input type="checkbox" class="lh-opt-correct"' + (opt && opt.is_correct ? ' checked' : '') + ' style="accent-color:var(--green);width:16px;height:16px;">' +
'</label>' +
'<div style="flex:2;display:flex;flex-direction:column;gap:3px;">' +
'<input type="text" class="lh-opt-text" placeholder="Option text" style="' + optTextStyle + '">' +
'<button type="button" class="lh-richtext-btn" data-target="option" style="align-self:flex-start;">Rich</button>' +
'</div>' +
'<input type="text" class="lh-opt-expl" placeholder="Explanation if wrong" value="' + esc(opt ? opt.explanation || '' : '') + '" style="' + optExplStyle + '">' +
'<button class="btn-sm btn-ghost lh-rm-option" style="padding:2px 6px;font-size:12px;color:var(--g400);margin-top:4px;"><i class="fas fa-times"></i></button>';
}
function selectedQuestionType(existingQ, type) {
return existingQ && existingQ.question_type === type ? ' selected' : '';
}

View file

@ -1,69 +0,0 @@
import { esc } from './sanitize.js';
export function renderCategoryPills(categories) {
var html = '<button class="lh-cat-pill active" data-slug="all">All</button>';
(categories || []).forEach(function(c) {
html += '<button class="lh-cat-pill" data-slug="' + esc(c.slug) + '">' + esc(c.name) + '</button>';
});
return html;
}
export function renderFeed(items, feedEl) {
if (!feedEl) return;
if (!items || items.length === 0) {
feedEl.innerHTML = '<div style="text-align:center;padding:40px;color:var(--g400);">' +
'<i class="fas fa-book-open" style="font-size:32px;margin-bottom:12px;display:block;"></i>' +
'No content yet. Check back soon!' +
'</div>';
return;
}
feedEl.innerHTML = items.map(renderFeedItem).join('');
}
export function renderSearchHeader(count, query) {
return '<div style="padding:8px 0 12px;display:flex;align-items:center;justify-content:space-between;">' +
'<span style="font-size:13px;color:var(--g600);">' +
(count > 0
? '<strong>' + count + '</strong> result' + (count !== 1 ? 's' : '') + ' for <strong>"' + esc(query) + '"</strong>'
: 'No results for <strong>"' + esc(query) + '"</strong>') +
'</span>' +
'<button id="lh-search-clear" style="background:none;border:none;font-size:12px;color:var(--blue);cursor:pointer;padding:0;">Clear search</button>' +
'</div>';
}
export function renderEmptySearchMessage() {
return '<div style="text-align:center;padding:40px;color:var(--g400);">' +
'<i class="fas fa-search" style="font-size:32px;margin-bottom:12px;display:block;opacity:0.4;"></i>' +
'Try different keywords or browse by category' +
'</div>';
}
function renderFeedItem(item) {
var typeIcon = item.content_type === 'quiz' ? 'fa-clipboard-question'
: item.content_type === 'pearl' ? 'fa-gem'
: item.content_type === 'presentation' ? 'fa-presentation-screen'
: 'fa-file-alt';
var typeColor = item.content_type === 'quiz' ? 'var(--amber)'
: item.content_type === 'pearl' ? 'var(--purple, #8b5cf6)'
: item.content_type === 'presentation' ? '#065f46'
: 'var(--blue)';
var quizBadge = item.question_count > 0 ? '<span class="lh-badge lh-badge-quiz"><i class="fas fa-clipboard-question"></i> ' + item.question_count + ' Q</span>' : '';
var catBadge = item.category_name ? '<span class="lh-badge">' + esc(item.category_name) + '</span>' : '<span class="lh-badge" style="opacity:0.5;">Uncategorized</span>';
var date = item.created_at ? new Date(item.created_at).toLocaleDateString() : '';
return '<div class="lh-feed-item card" data-slug="' + esc(item.slug) + '">' +
'<div class="lh-feed-item-header">' +
'<i class="fas ' + typeIcon + '" style="color:' + typeColor + ';font-size:16px;"></i>' +
'<div style="flex:1;min-width:0;">' +
'<div class="lh-feed-title">' + esc(item.title) + '</div>' +
'<div class="lh-feed-meta">' +
(item.subject ? '<span>' + esc(item.subject) + '</span>' : '') +
(item.author_name ? '<span>by ' + esc(item.author_name) + '</span>' : '') +
'<span>' + date + '</span>' +
'</div>' +
'</div>' +
'<div class="lh-feed-badges">' + catBadge + quizBadge + '</div>' +
'</div>' +
'</div>';
}

View file

@ -1,76 +0,0 @@
import { renderImageJobs, imageJson, hydrateImage, imageDataUrl, captureImageOwner, assertImageOwner, transientImageUrl, revokeImageUrl } from '../generatedImages.js';
import { sanitizeHtml } from './sanitize.js';
import { validSharingOwner } from '../assistant/sharing.js';
export function generatedImageExtension(T) {
// Reuse the Node class already shipped with StarterKit, without a second Tiptap bundle.
const Node = T.StarterKit.config.addExtensions.call(T.StarterKit).find(e => e.type === 'node').constructor;
return Node.create({ name: 'generatedImage', group: 'block', atom: true, draggable: true,
addAttributes() { return { src: { default: '' }, alt: { default: 'Generated teaching visual' } }; },
parseHTML() { return [{ tag: 'img[src^="/api/generated-images/"]' }]; },
renderHTML({ HTMLAttributes }) { return ['img', HTMLAttributes]; },
addNodeView() { return ({ node }) => {
const img = document.createElement('img'); img.alt = node.attrs.alt; img.style.maxWidth = '100%';
// NodeViews hydrate after attachment, leaving the editor's persistent attributes untouched.
queueMicrotask(() => hydrateImage(img, node.attrs.src).catch(() => { img.alt = 'Private image unavailable'; }));
return { dom: img, ignoreMutation: () => true, destroy() { if (img.src.startsWith('blob:')) revokeImageUrl(img.src); } };
}; }
});
}
export function createLearningImages(getEditor, getMarkdown) {
let panel, list, draft;
function mount() {
if (panel?.isConnected) return;
const parent = document.getElementById('lh-images-section');
if (!parent) return;
panel = document.createElement('section'); panel.setAttribute('aria-label', 'Learning Hub images');
panel.innerHTML = '<h3>Learning Hub images</h3><p>Uses the separately administered Learning image model and instructions. Maximum assembled input: 32,000 UTF-16 code units (or the lower admin budget). The full image request is retained; whole recent authoring-context turns are selected to fit, with counts shown below. Existing content is unchanged. Images remain private until attached to saved content.</p><label>Image prompt<textarea rows="4"></textarea></label><button type="button" data-generate>Generate image</button><button type="button" data-history>Reopen image history</button><button type="button" data-export>Export content with images (HTML)</button><p role="status"></p><div data-jobs></div>';
parent.append(panel); list = panel.querySelector('[data-jobs]');
const status = panel.querySelector('[role=status]');
panel.querySelector('[data-generate]').onclick = async function() {
if (this.disabled) return;
const prompt = panel.querySelector('textarea').value;
const content = getMarkdown()?.value ?? getEditor()?.getHTML() ?? '';
if (!draft || draft.prompt !== prompt || draft.content !== content) draft = { prompt, content, idempotencyKey: crypto.randomUUID() };
this.disabled = true;
try { show([await imageJson('/api/admin/learning/image/jobs', { method: 'POST', body: JSON.stringify(draft) })]); status.textContent = 'Image queued; your draft is preserved.'; }
catch (e) { status.textContent = e.message; } finally { this.disabled = false; }
};
panel.querySelector('[data-history]').onclick = async () => {
try { list.replaceChildren(); show((await imageJson('/api/image-jobs/learning_hub')).jobs); } catch (e) { status.textContent = e.message; }
};
panel.querySelector('[data-export]').onclick = async () => {
let ticket;
try {
ticket = captureImageOwner(); const template = document.createElement('template');
const markdown = getMarkdown();
if (markdown) throw new Error('For presentations use the existing PowerPoint export, which includes attached generated images.');
template.innerHTML = sanitizeHtml(getEditor()?.getHTML() || '');
for (const img of template.content.querySelectorAll('img')) img.src = await imageDataUrl(img.getAttribute('src'), ticket);
assertImageOwner(ticket);
const url = transientImageUrl(new Blob(['<!doctype html><meta charset="utf-8"><title>Learning content</title>' + template.innerHTML], { type: 'text/html' }), ticket);
const a = document.createElement('a'); a.href = url; a.download = 'learning-content.html'; a.click(); setTimeout(() => revokeImageUrl(url), 60000);
} catch (e) { if (validSharingOwner(ticket) && e.name !== 'AbortError') status.textContent = e.message; }
};
}
function show(jobs) {
mount(); if (!list) return;
renderImageJobs(list, jobs, 'learning_hub', (card, data) => {
const img = document.createElement('img'); img.alt = 'Generated teaching visual'; img.style.maxWidth = '100%'; card.append(img);
hydrateImage(img, data.imageUrl).catch(() => { img.alt = 'Private image unavailable'; });
const insert = document.createElement('button'); insert.type = 'button'; insert.textContent = 'Insert image at end of content';
insert.onclick = () => {
captureImageOwner();
const markdown = getMarkdown();
if (markdown) markdown.value += '\n---\n# Generated teaching visual\n![Generated teaching visual](' + data.imageUrl + ')\n';
else {
const editor = getEditor();
if (!editor || editor.isDestroyed) return;
editor.commands.insertContentAt(editor.state.doc.content.size, { type: 'generatedImage', attrs: { src: data.imageUrl, alt: 'Generated teaching visual' } });
}
insert.disabled = true; insert.textContent = 'Inserted — save content to attach';
};
card.append(insert);
});
}
return { mount, show };
}

View file

@ -1,100 +0,0 @@
import { sendJson } from './api.js';
import { renderQuizQuestions, renderQuizResultExplanations } from './quizRenderer.js';
export function createQuizController(deps) {
function renderQuiz(questions) {
var container = document.getElementById('lh-quiz-questions');
var countEl = document.getElementById('lh-quiz-count');
if (!container) return;
if (countEl) countEl.textContent = questions.length + ' question' + (questions.length !== 1 ? 's' : '');
container.innerHTML = renderQuizQuestions(questions);
}
function submitQuiz() {
var currentContent = deps.getCurrentContent();
if (!currentContent || !currentContent.questions) return;
var answers = buildQuizAnswers(currentContent.questions);
deps.showLoading('Submitting quiz...');
sendJson('/api/learning/submit-quiz', 'POST', { contentId: currentContent.id, answers: answers })
.then(function(data) {
deps.hideLoading();
if (!data.success) { deps.showToast(data.error || 'Submit failed', 'error'); return; }
showQuizResults(data);
})
.catch(function() { deps.hideLoading(); deps.showToast('Submit failed', 'error'); });
}
function showQuizResults(data) {
var resultsEl = document.getElementById('lh-quiz-results');
var scoreEl = document.getElementById('lh-quiz-score');
var explEl = document.getElementById('lh-quiz-explanations');
if (!resultsEl) return;
resultsEl.classList.remove('hidden');
var pct = data.percentage;
var color = pct >= 80 ? 'var(--green)' : pct >= 50 ? 'var(--amber)' : 'var(--red)';
if (scoreEl) {
scoreEl.textContent = data.score + '/' + data.total + ' (' + pct + '%)';
scoreEl.style.background = color;
scoreEl.style.color = 'white';
}
if (explEl) explEl.innerHTML = renderQuizResultExplanations(data.results);
applyQuizResultHighlights(data.results || []);
var submitBtn = document.getElementById('lh-submit-quiz');
if (submitBtn) { submitBtn.disabled = true; submitBtn.innerHTML = '<i class="fas fa-check"></i> Submitted'; }
resultsEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
return {
renderQuiz: renderQuiz,
submitQuiz: submitQuiz
};
}
export function buildQuizAnswers(questions, root) {
root = root || document;
var answers = [];
(questions || []).forEach(function(q) {
if (q.question_type === 'multi') {
var checkedEls = root.querySelectorAll('input[name="lh-qm-' + q.id + '"]:checked');
var optionIds = [];
checkedEls.forEach(function(el) { optionIds.push(parseInt(el.value)); });
answers.push({ questionId: q.id, optionIds: optionIds });
} else {
var selected = root.querySelector('input[name="lh-q-' + q.id + '"]:checked');
answers.push({ questionId: q.id, optionId: selected ? parseInt(selected.value) : null });
}
});
return answers;
}
function applyQuizResultHighlights(results) {
results.forEach(function(r) {
var qDiv = document.querySelector('.lh-quiz-q[data-qid="' + r.questionId + '"]');
if (!qDiv) return;
var isMulti = r.questionType === 'multi';
qDiv.querySelectorAll('.lh-quiz-option').forEach(function(label) {
var input = label.querySelector('input');
if (!input) return;
var optId = parseInt(input.value);
if (isMulti) {
var correctIds = r.correctOptionIds || [];
var selectedIds = r.selectedOptionIds || [];
if (correctIds.indexOf(optId) !== -1) label.classList.add('lh-opt-correct');
if (selectedIds.indexOf(optId) !== -1 && correctIds.indexOf(optId) === -1) label.classList.add('lh-opt-wrong');
} else {
if (optId === r.correctOptionId) label.classList.add('lh-opt-correct');
if (optId === r.selectedOptionId && !r.isCorrect) label.classList.add('lh-opt-wrong');
}
input.disabled = true;
});
});
}

View file

@ -1,63 +0,0 @@
import { esc, sanitizeHtml } from './sanitize.js';
export function renderQuizQuestions(questions) {
return (questions || []).map(function(q, idx) {
var isMulti = q.question_type === 'multi';
var typeLabel = q.question_type === 'true_false' ? 'True / False'
: isMulti ? 'Multiple Select'
: 'Single Choice';
var inputType = isMulti ? 'checkbox' : 'radio';
var nameAttr = isMulti ? 'lh-qm-' + q.id : 'lh-q-' + q.id;
var hint = isMulti ? '<div class="lh-quiz-multi-hint">Select all that apply</div>' : '';
return '<div class="lh-quiz-q" data-qid="' + q.id + '" data-qtype="' + esc(q.question_type) + '">' +
'<div class="lh-quiz-q-header">' +
'<span class="lh-quiz-q-num">Q' + (idx + 1) + '</span>' +
'<span class="lh-quiz-q-type">' + typeLabel + '</span>' +
'</div>' +
'<p class="lh-quiz-q-text">' + sanitizeHtml(q.question_text) + '</p>' +
hint +
'<div class="lh-quiz-options">' + renderQuizOptions(q.options, inputType, nameAttr) + '</div>' +
'</div>';
}).join('');
}
export function renderQuizResultExplanations(results) {
return (results || []).map(function(r, idx) {
var icon = r.isCorrect
? '<i class="fas fa-check-circle" style="color:var(--green);"></i>'
: '<i class="fas fa-times-circle" style="color:var(--red);"></i>';
var explHtml = '';
if (!r.isCorrect && r.selectedExplanation) {
explHtml += '<div class="lh-expl lh-expl-wrong"><strong>Why incorrect:</strong> ' +
esc(r.selectedExplanation) +
'</div>';
}
if (!r.isCorrect) {
explHtml += '<div class="lh-expl lh-expl-correct"><strong>Correct answer:</strong> ' +
esc(r.correctOptionText) +
'</div>';
}
if (r.generalExplanation) {
explHtml += '<div class="lh-expl lh-expl-general"><strong>Explanation:</strong> ' +
sanitizeHtml(r.generalExplanation) +
'</div>';
}
return '<div class="lh-result-item">' +
'<div class="lh-result-header">' +
icon + ' <strong>Q' + (idx + 1) + ':</strong> ' + sanitizeHtml(r.questionText) +
'</div>' +
explHtml +
'</div>';
}).join('');
}
function renderQuizOptions(options, inputType, nameAttr) {
return (options || []).map(function(opt) {
return '<label class="lh-quiz-option">' +
'<input type="' + inputType + '" name="' + nameAttr + '" value="' + opt.id + '">' +
'<span>' + sanitizeHtml(opt.option_text) + '</span>' +
'</label>';
}).join('');
}

View file

@ -1,24 +0,0 @@
export function esc(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
export function sanitizeHtml(html) {
// DOMPurify is loaded globally from index.html. If it is unavailable, render
// as plain text rather than attempting partial sanitization.
if (!window.DOMPurify || typeof window.DOMPurify.sanitize !== 'function') {
console.warn('[learningHub] DOMPurify unavailable - rendering as plain text.');
var d = document.createElement('div');
d.textContent = String(html == null ? '' : html);
return d.innerHTML;
}
return window.DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['p','br','b','strong','i','em','u','s','h1','h2','h3','h4','h5','h6',
'ul','ol','li','a','blockquote','code','pre','table','thead','tbody','tr','th','td',
'hr','div','span','sub','sup','dl','dt','dd','img'],
ALLOWED_ATTR: ['src','alt','href','colspan','rowspan','class','target','rel'],
ADD_ATTR: ['target'],
FORBID_ATTR: ['style','onerror','onload','onclick','onmouseover'],
ALLOW_DATA_ATTR: false
});
}

View file

@ -1,149 +0,0 @@
import { getJson, sendJson, sendJsonBlob } from './api.js';
export function createSlideController(deps) {
var previewSlides = [];
var previewIdx = 0;
function openSlidesFromSlug(slug) {
deps.showBusy('Loading slides...');
getJson('/api/learning/content/' + encodeURIComponent(slug) + '/slides')
.then(function(data) {
deps.hideBusy();
if (!data.success) { deps.showToast(data.error || 'Could not load slides', 'error'); return; }
openSlideModal(data.slides, data.css);
})
.catch(function(err) { deps.hideBusy(); deps.showToast(err.message, 'error'); });
}
function previewCurrentSlides() {
var md = deps.getMarkdown();
if (!md) { deps.showToast('No slide content yet. Use AI Generate first.', 'info'); return; }
deps.showBusy('Rendering slides...');
sendJson('/api/admin/learning/preview-slides', 'POST', { markdown: md })
.then(function(data) {
deps.hideBusy();
if (!data.success) { deps.showToast(data.error || 'Preview failed', 'error'); return; }
openSlideModal(data.slides, data.css);
})
.catch(function(err) { deps.hideBusy(); deps.showToast(err.message, 'error'); });
}
function openSlideModal(slides, css) {
closeSlideModal();
previewSlides = slides || [];
previewIdx = 0;
var modal = document.getElementById('slide-preview-modal');
var cssEl = document.getElementById('slide-preview-css');
var dotsEl = document.getElementById('slide-preview-dots');
if (!modal || !previewSlides.length) { deps.showToast('No slides to display', 'error'); return; }
if (cssEl) cssEl.textContent = css || '';
if (dotsEl) dotsEl.innerHTML = renderDots(previewSlides.length);
modal.classList.remove('hidden');
document.body.style.overflow = 'hidden';
renderSlide(0);
wireModalInput(modal);
}
function renderSlide(idx) {
var content = document.getElementById('slide-preview-content');
var counter = document.getElementById('slide-preview-counter');
var dots = document.querySelectorAll('.slide-dot');
var prev = document.getElementById('slide-nav-prev');
var next = document.getElementById('slide-nav-next');
if (!content || !previewSlides.length) return;
previewIdx = Math.max(0, Math.min(idx, previewSlides.length - 1));
content.innerHTML = previewSlides[previewIdx];
if (counter) counter.textContent = (previewIdx + 1) + ' / ' + previewSlides.length;
if (prev) prev.style.opacity = previewIdx === 0 ? '0.3' : '1';
if (next) next.style.opacity = previewIdx === previewSlides.length - 1 ? '0.3' : '1';
dots.forEach(function(d, i) { d.classList.toggle('active', i === previewIdx); });
}
function slideStep(dir) { renderSlide(previewIdx + dir); }
function closeSlideModal() {
var modal = document.getElementById('slide-preview-modal');
if (modal) modal.classList.add('hidden');
document.body.style.overflow = '';
if (document._slideKeyHandler) {
document.removeEventListener('keydown', document._slideKeyHandler);
document._slideKeyHandler = null;
}
if (modal && modal._touchStart) {
modal.removeEventListener('touchstart', modal._touchStart);
modal._touchStart = null;
}
if (modal && modal._touchEnd) {
modal.removeEventListener('touchend', modal._touchEnd);
modal._touchEnd = null;
}
}
function downloadPptx() {
var md = deps.getMarkdown();
if (!md) { deps.showToast('No slide content yet', 'error'); return; }
var title = deps.getTitle() || 'presentation';
var btn = document.getElementById('btn-lh-download-pptx');
if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Building...'; }
sendJsonBlob('/api/admin/learning/generate-pptx', 'POST', { markdown: md, title: title })
.then(function(blob) {
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = makePptxFilename(title);
a.click();
URL.revokeObjectURL(url);
deps.showToast('PPTX downloaded!', 'success');
})
.catch(function(err) { deps.showToast(err.message, 'error'); })
.finally(function() {
if (btn) { btn.disabled = false; btn.innerHTML = '<i class="fas fa-file-powerpoint"></i> Download PPTX'; }
});
}
function wireModalInput(modal) {
document._slideKeyHandler = function(e) {
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') slideStep(1);
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') slideStep(-1);
else if (e.key === 'Escape') closeSlideModal();
};
document.addEventListener('keydown', document._slideKeyHandler);
var touchStartX = 0;
modal._touchStart = function(e) { touchStartX = e.touches[0].clientX; };
modal._touchEnd = function(e) {
var dx = e.changedTouches[0].clientX - touchStartX;
if (Math.abs(dx) > 40) slideStep(dx < 0 ? 1 : -1);
};
modal.addEventListener('touchstart', modal._touchStart);
modal.addEventListener('touchend', modal._touchEnd);
}
return {
closeSlideModal: closeSlideModal,
downloadPptx: downloadPptx,
openSlidesFromSlug: openSlidesFromSlug,
previewCurrentSlides: previewCurrentSlides,
renderSlide: renderSlide,
slideStep: slideStep
};
}
export function makePptxFilename(title) {
return String(title || 'presentation').replace(/\s+/g, '-').toLowerCase() + '.pptx';
}
function renderDots(count) {
var html = '';
for (var i = 0; i < count; i++) {
html += '<button class="slide-dot" data-idx="' + i + '" aria-label="Go to slide ' + (i + 1) + '"></button>';
}
return html;
}

View file

@ -1,165 +0,0 @@
import { generatedImageExtension } from './images.js';
function getTiptap() {
return window.Tiptap || {};
}
function buildTpToolbar(mini, isOption) {
var btns;
if (isOption) {
btns = '<button type="button" class="tp-btn" data-cmd="bold" title="Bold"><i class="fas fa-bold"></i></button>' +
'<button type="button" class="tp-btn" data-cmd="italic" title="Italic"><i class="fas fa-italic"></i></button>' +
'<button type="button" class="tp-btn" data-cmd="link" title="Link"><i class="fas fa-link"></i></button>';
} else if (mini) {
btns = '<button type="button" class="tp-btn" data-cmd="bold" title="Bold"><i class="fas fa-bold"></i></button>' +
'<button type="button" class="tp-btn" data-cmd="italic" title="Italic"><i class="fas fa-italic"></i></button>' +
'<button type="button" class="tp-btn" data-cmd="bulletList" title="List"><i class="fas fa-list-ul"></i></button>' +
'<button type="button" class="tp-btn" data-cmd="link" title="Link"><i class="fas fa-link"></i></button>';
} else {
btns = '<button type="button" class="tp-btn" data-cmd="bold" title="Bold"><i class="fas fa-bold"></i></button>' +
'<button type="button" class="tp-btn" data-cmd="italic" title="Italic"><i class="fas fa-italic"></i></button>' +
'<button type="button" class="tp-btn" data-cmd="underline" title="Underline"><i class="fas fa-underline"></i></button>' +
'<button type="button" class="tp-btn" data-cmd="strike" title="Strike"><i class="fas fa-strikethrough"></i></button>' +
'<span class="tp-sep"></span>' +
'<button type="button" class="tp-btn" data-cmd="h2" title="Heading 2">H2</button>' +
'<button type="button" class="tp-btn" data-cmd="h3" title="Heading 3">H3</button>' +
'<span class="tp-sep"></span>' +
'<button type="button" class="tp-btn" data-cmd="bulletList" title="Bullet List"><i class="fas fa-list-ul"></i></button>' +
'<button type="button" class="tp-btn" data-cmd="orderedList" title="Numbered List"><i class="fas fa-list-ol"></i></button>' +
'<button type="button" class="tp-btn" data-cmd="blockquote" title="Blockquote"><i class="fas fa-quote-left"></i></button>' +
'<button type="button" class="tp-btn" data-cmd="codeBlock" title="Code"><i class="fas fa-code"></i></button>' +
'<span class="tp-sep"></span>' +
'<button type="button" class="tp-btn" data-cmd="link" title="Link"><i class="fas fa-link"></i></button>' +
'<span class="tp-sep"></span>' +
'<button type="button" class="tp-btn" data-cmd="clear" title="Clear formatting"><i class="fas fa-remove-format"></i></button>';
}
return '<div class="tp-toolbar' + (mini ? ' tp-toolbar-mini' : '') + '">' + btns + '</div>' +
'<div class="tp-link-bar" style="display:none;">' +
'<input type="url" class="tp-link-input" placeholder="https://">' +
'<button type="button" class="tp-link-apply">Apply</button>' +
'<button type="button" class="tp-link-remove">Remove</button>' +
'<button type="button" class="tp-link-cancel">&times;</button>' +
'</div>';
}
function wireTpToolbar(wrap, editor) {
var linkBar = wrap.querySelector('.tp-link-bar');
var linkInput = wrap.querySelector('.tp-link-input');
wrap.querySelector('.tp-toolbar').addEventListener('mousedown', function(e) {
var btn = e.target.closest('.tp-btn[data-cmd]');
if (!btn) return;
e.preventDefault();
var cmd = btn.dataset.cmd;
switch (cmd) {
case 'bold': editor.chain().focus().toggleBold().run(); break;
case 'italic': editor.chain().focus().toggleItalic().run(); break;
case 'underline': editor.chain().focus().toggleUnderline().run(); break;
case 'strike': editor.chain().focus().toggleStrike().run(); break;
case 'h2': editor.chain().focus().toggleHeading({ level: 2 }).run(); break;
case 'h3': editor.chain().focus().toggleHeading({ level: 3 }).run(); break;
case 'bulletList': editor.chain().focus().toggleBulletList().run(); break;
case 'orderedList': editor.chain().focus().toggleOrderedList().run(); break;
case 'blockquote': editor.chain().focus().toggleBlockquote().run(); break;
case 'codeBlock': editor.chain().focus().toggleCodeBlock().run(); break;
case 'clear': editor.chain().focus().unsetAllMarks().clearNodes().run(); break;
case 'link':
if (linkBar.style.display === 'none') {
linkInput.value = editor.getAttributes('link').href || '';
linkBar.style.display = 'flex';
setTimeout(function() { linkInput.focus(); }, 0);
} else {
linkBar.style.display = 'none';
}
break;
}
updateTpState(wrap, editor);
});
wrap.querySelector('.tp-link-apply').addEventListener('mousedown', function(e) {
e.preventDefault();
var url = linkInput.value.trim();
if (url) editor.chain().focus().setLink({ href: url }).run();
linkBar.style.display = 'none';
});
wrap.querySelector('.tp-link-remove').addEventListener('mousedown', function(e) {
e.preventDefault();
editor.chain().focus().unsetLink().run();
linkBar.style.display = 'none';
});
wrap.querySelector('.tp-link-cancel').addEventListener('mousedown', function(e) {
e.preventDefault();
linkBar.style.display = 'none';
});
linkInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { e.preventDefault(); wrap.querySelector('.tp-link-apply').dispatchEvent(new MouseEvent('mousedown')); }
if (e.key === 'Escape') linkBar.style.display = 'none';
});
}
function updateTpState(wrap, editor) {
wrap.querySelectorAll('.tp-btn[data-cmd]').forEach(function(btn) {
var a = false;
switch (btn.dataset.cmd) {
case 'bold': a = editor.isActive('bold'); break;
case 'italic': a = editor.isActive('italic'); break;
case 'underline': a = editor.isActive('underline'); break;
case 'strike': a = editor.isActive('strike'); break;
case 'h2': a = editor.isActive('heading', { level: 2 }); break;
case 'h3': a = editor.isActive('heading', { level: 3 }); break;
case 'bulletList': a = editor.isActive('bulletList'); break;
case 'orderedList': a = editor.isActive('orderedList'); break;
case 'blockquote': a = editor.isActive('blockquote'); break;
case 'codeBlock': a = editor.isActive('codeBlock'); break;
case 'link': a = editor.isActive('link'); break;
}
btn.classList.toggle('active', a);
});
}
export function makeTpEditor(wrap, existingHtml, mini, isOption) {
var T = getTiptap();
wrap.innerHTML = buildTpToolbar(mini, isOption) + '<div class="tp-content"></div>';
var ed = new T.Editor({
element: wrap.querySelector('.tp-content'),
extensions: [
T.StarterKit,
T.Link.configure({ openOnClick: false, autolink: true }),
T.Underline,
generatedImageExtension(T)
],
content: existingHtml || '',
onUpdate: function() { updateTpState(wrap, ed); },
onSelectionUpdate: function() { updateTpState(wrap, ed); }
});
wireTpToolbar(wrap, ed);
return ed;
}
export function getTpHTML(editor) {
if (!editor) return '';
var html = editor.getHTML();
return (html === '<p></p>' || html === '') ? '' : html;
}
export function destroyTpEditor(editor) {
if (editor && typeof editor.destroy === 'function') editor.destroy();
}
export function destroyOptionEditor(row) {
if (!row || !row._optEditor) return;
destroyTpEditor(row._optEditor);
row._optEditor = null;
}
export function destroyQuestionBlockEditors(block) {
if (!block) return;
destroyTpEditor(block._questionEditor);
block._questionEditor = null;
block.querySelectorAll('.lh-option-row').forEach(destroyOptionEditor);
}
export function clearQuestionBlocks(container) {
if (!container) return;
container.querySelectorAll('.lh-question-block').forEach(destroyQuestionBlockEditors);
container.innerHTML = '';
}

View file

@ -1,42 +0,0 @@
import { esc } from './sanitize.js';
export function buildViewerMeta(item) {
var parts = [];
if (item.category_name) parts.push(item.category_name);
if (item.subject) parts.push(item.subject);
if (item.author_name) parts.push('by ' + item.author_name);
if (item.created_at) parts.push(new Date(item.created_at).toLocaleDateString());
return parts.join(' | ');
}
export function renderPresentationCard(item) {
var slideCount = (item.body || '').split(/\n---\n/).length;
var slideButtonStyle = 'padding:11px 28px;font-size:14px;border-radius:10px;border:none;' +
'cursor:pointer;background:var(--blue);color:white;font-weight:600;';
return '<div class="card" style="text-align:center;padding:32px 24px;">' +
'<div style="font-size:42px;margin-bottom:12px;">📊</div>' +
'<h3 style="margin:0 0 4px;color:var(--g800);">' + esc(item.title) + '</h3>' +
'<p style="font-size:13px;color:var(--g500);margin:0 0 20px;">' +
slideCount + ' slides' + (item.subject ? ' · ' + esc(item.subject) : '') +
'</p>' +
'<button id="btn-lh-view-slides" class="btn-primary" style="' + slideButtonStyle + '">' +
'<i class="fas fa-play-circle"></i> View Slides' +
'</button>' +
'</div>';
}
export function renderProgressList(progress) {
return (progress || []).map(function(p) {
var pct = p.total > 0 ? Math.round((p.score / p.total) * 100) : 0;
var date = new Date(p.completed_at).toLocaleDateString();
var color = pct >= 70 ? 'var(--green)' : 'var(--amber)';
return '<div style="display:flex;justify-content:space-between;padding:4px 0;border-bottom:1px solid var(--g100);">' +
'<span>' + date + '</span>' +
'<span style="font-weight:600;color:' + color + ';">' +
p.score + '/' + p.total + ' (' + pct + '%)' +
'</span>' +
'</div>';
}).join('');
}

View file

@ -1,116 +0,0 @@
import { getJson } from './api.js';
import { esc } from './sanitize.js';
export function createWebdavController(deps) {
var currentPath = '/';
var selectedPath = '';
var selectedName = '';
function browse(path) {
currentPath = path || '/';
var list = document.getElementById('lh-ai-webdav-list');
var pathLabel = document.getElementById('lh-ai-webdav-path-label');
if (list) list.innerHTML = '<div style="padding:12px;text-align:center;color:var(--g400);font-size:13px;"><i class="fas fa-spinner fa-spin"></i> Loading...</div>';
if (pathLabel) pathLabel.textContent = currentPath;
getJson('/api/admin/learning/webdav-browse?path=' + encodeURIComponent(currentPath))
.then(function(data) {
if (!data.success) {
if (list) list.innerHTML = '<div style="padding:12px;color:var(--red);font-size:13px;">' + esc(data.error) + '</div>';
return;
}
if (pathLabel) pathLabel.textContent = data.path;
renderList(data);
})
.catch(function(err) {
if (list) list.innerHTML = '<div style="padding:12px;color:var(--red);font-size:13px;">' + esc(err.message) + '</div>';
});
}
function renderList(data) {
var list = document.getElementById('lh-ai-webdav-list');
if (!list) return;
list.innerHTML = renderWebdavListHtml(data);
}
function openItem(el) {
if (!el) return;
if (el.dataset.isdir === '1') browse(el.dataset.path);
else selectFile(el.dataset.path, el.dataset.name);
}
function selectFile(path, name) {
selectedPath = path;
selectedName = name;
var browser = document.getElementById('lh-ai-webdav-browser');
var selDiv = document.getElementById('lh-ai-webdav-selected');
var selName = document.getElementById('lh-ai-webdav-selected-name');
if (browser) browser.style.display = 'none';
if (selDiv) selDiv.style.display = 'flex';
if (selName) selName.textContent = name;
}
function deselectFile() {
selectedPath = '';
selectedName = '';
var browser = document.getElementById('lh-ai-webdav-browser');
var selDiv = document.getElementById('lh-ai-webdav-selected');
if (selDiv) selDiv.style.display = 'none';
if (browser) browser.style.display = '';
}
return {
browse: browse,
deselectFile: deselectFile,
getCurrentPath: function() { return currentPath; },
getSelectedName: function() { return selectedName; },
getSelectedPath: function() { return selectedPath; },
openItem: openItem,
selectFile: selectFile,
setCurrentPath: function(path) { currentPath = path || '/'; }
};
}
export function renderWebdavListHtml(data) {
var html = '';
if (data.path !== '/' && data.path !== data.parentPath) {
html += '<div class="lh-webdav-item lh-webdav-dir" data-path="' + esc(data.parentPath) + '" data-isdir="1">' +
'<i class="fas fa-arrow-left" style="color:var(--g400);"></i> <span>..</span></div>';
}
if (data.items.length === 0 && html === '') {
return '<div style="padding:12px;text-align:center;color:var(--g400);font-size:13px;">Empty folder</div>';
}
data.items.forEach(function(item) {
var icon = item.isDir ? 'fa-folder' : getFileIcon(item.contentType, item.name);
var iconColor = item.isDir ? 'var(--amber)' : 'var(--blue)';
var sizeStr = (!item.isDir && item.size)
? ' <span style="color:var(--g400);font-size:11px;">(' + formatBytes(item.size) + ')</span>'
: '';
html += '<div class="lh-webdav-item' + (item.isDir ? ' lh-webdav-dir' : ' lh-webdav-file') + '"' +
' data-path="' + esc(item.path) + '" data-name="' + esc(item.name) + '" data-isdir="' + (item.isDir ? '1' : '0') + '">' +
'<i class="fas ' + icon + '" style="color:' + iconColor + ';width:16px;flex-shrink:0;"></i>' +
'<span class="lh-webdav-name">' + esc(item.name) + '</span>' + sizeStr +
'</div>';
});
return html;
}
export function getFileIcon(mime, name) {
if (!name) name = '';
var ext = name.split('.').pop().toLowerCase();
if (mime && mime.includes('pdf') || ext === 'pdf') return 'fa-file-pdf';
if (['doc', 'docx'].includes(ext)) return 'fa-file-word';
if (['txt', 'md'].includes(ext)) return 'fa-file-lines';
if (['html', 'htm'].includes(ext)) return 'fa-file-code';
return 'fa-file';
}
export function formatBytes(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + ' KB';
return (bytes / 1024 / 1024).toFixed(1) + ' MB';
}

View file

@ -2,10 +2,9 @@
// MY RESOURCES
// A person's own generated teaching material.
//
// Separate from the Learning Hub on purpose: that is moderator-owned content
// published into categories for everyone, this is private and needs no role
// beyond being signed in. The server enforces that independently — every query
// there filters on the owner — so this only has to be an honest interface to it.
// Private to whoever made it, and needing no role beyond being signed in. The
// server enforces that independently — every query there filters on the owner —
// so this only has to be an honest interface to it.
// ============================================================
(function () {

View file

@ -22,7 +22,7 @@ export function formatWhen(iso) {
}
// Sanitize via DOMPurify (loaded from cdnjs in index.html, also used by
// learningHub.js). If DOMPurify fails to load, render as plain text.
// the assistant). If DOMPurify fails to load, render as plain text.
export function sanitizeHtml(html) {
if (!window.DOMPurify || typeof window.DOMPurify.sanitize !== 'function') {
console.warn('[notes] DOMPurify unavailable - rendering as plain text.');

View file

@ -267,12 +267,6 @@ app.use(express.static(path.join(__dirname, 'public'), {
app.use('/api/auth', require('./src/routes/auth'));
app.use('/api/auth', require('./src/routes/oidc'));
// Learning Hub CMS — must come BEFORE general /api/admin to avoid adminMiddleware conflict
// (moderators need access to /api/admin/learning but not other /api/admin routes)
app.use('/api/admin/learning/image', require('./src/routes/generatedImages').learningRouter);
app.use('/api/admin/learning', require('./src/routes/learningAdmin'));
app.use('/api/admin/learning', require('./src/routes/learningAI'));
// Config exposes only its authenticated announcement before its own admin guard.
app.use('/api/admin', require('./src/routes/adminConfig'));
app.use('/api/admin', require('./src/routes/admin'));
@ -318,7 +312,6 @@ app.get('/api/health/detailed', _hcAuth, _hcAdmin, (req, res) => {
});
// Learning Hub routes (all authenticated users can read content & take quizzes)
app.use('/api/learning', require('./src/routes/learningHub'));
// A person's own generated teaching material. Separate from Learning, which is
// moderator-owned and published; this is private to whoever made it.
app.use('/api', require('./src/routes/myResources'));
@ -355,7 +348,7 @@ app.use('/api', require('./src/routes/dontMiss'));
app.use('/api', require('./src/routes/patientEducation'));
app.use('/api/user', require('./src/routes/userPreferences'));
// User-level preference: save WebDAV learning path (auth only, not moderator-only)
// User-level preference: the Nextcloud folder this account browses from.
(function() {
var { authMiddleware } = require('./src/middleware/auth');
var db = require('./src/db/database');

View file

@ -207,62 +207,6 @@ async function initDatabase() {
CREATE INDEX IF NOT EXISTS idx_ext_user_active ON user_phone_extensions(user_id) WHERE trashed_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_ext_user_trash ON user_phone_extensions(user_id) WHERE trashed_at IS NOT NULL;
-- Learning Hub tables
CREATE TABLE IF NOT EXISTS learning_categories (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
description TEXT DEFAULT '',
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS learning_content (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
slug TEXT UNIQUE NOT NULL,
body TEXT NOT NULL DEFAULT '',
category_id INTEGER REFERENCES learning_categories(id) ON DELETE SET NULL,
subject TEXT DEFAULT '',
content_type TEXT DEFAULT 'article',
published BOOLEAN DEFAULT false,
author_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE IF NOT EXISTS learning_questions (
id SERIAL PRIMARY KEY,
content_id INTEGER REFERENCES learning_content(id) ON DELETE CASCADE,
question_text TEXT NOT NULL,
question_type TEXT NOT NULL DEFAULT 'mcq',
explanation TEXT DEFAULT '',
sort_order INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS learning_options (
id SERIAL PRIMARY KEY,
question_id INTEGER REFERENCES learning_questions(id) ON DELETE CASCADE,
option_text TEXT NOT NULL,
is_correct BOOLEAN DEFAULT false,
explanation TEXT DEFAULT '',
sort_order INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS learning_progress (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
content_id INTEGER REFERENCES learning_content(id) ON DELETE CASCADE,
score INTEGER,
total INTEGER,
completed_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_learning_content_cat ON learning_content(category_id);
CREATE INDEX IF NOT EXISTS idx_learning_content_pub ON learning_content(published);
CREATE INDEX IF NOT EXISTS idx_learning_questions_content ON learning_questions(content_id);
CREATE INDEX IF NOT EXISTS idx_learning_options_question ON learning_options(question_id);
CREATE INDEX IF NOT EXISTS idx_learning_progress_user ON learning_progress(user_id);
`);
// Add columns if upgrading
@ -275,16 +219,6 @@ async function initDatabase() {
"CREATE INDEX IF NOT EXISTS idx_saved_enc_user ON saved_encounters(user_id)",
"CREATE INDEX IF NOT EXISTS idx_saved_enc_expires ON saved_encounters(expires_at)",
"CREATE INDEX IF NOT EXISTS idx_memories_user ON user_memories(user_id)",
"CREATE TABLE IF NOT EXISTS learning_categories (id SERIAL PRIMARY KEY, name TEXT NOT NULL, slug TEXT UNIQUE NOT NULL, description TEXT DEFAULT '', sort_order INTEGER DEFAULT 0, created_at TIMESTAMPTZ DEFAULT NOW())",
"CREATE TABLE IF NOT EXISTS learning_content (id SERIAL PRIMARY KEY, title TEXT NOT NULL, slug TEXT UNIQUE NOT NULL, body TEXT NOT NULL DEFAULT '', category_id INTEGER REFERENCES learning_categories(id) ON DELETE SET NULL, subject TEXT DEFAULT '', content_type TEXT DEFAULT 'article', published BOOLEAN DEFAULT false, author_id INTEGER REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW())",
"CREATE TABLE IF NOT EXISTS learning_questions (id SERIAL PRIMARY KEY, content_id INTEGER REFERENCES learning_content(id) ON DELETE CASCADE, question_text TEXT NOT NULL, question_type TEXT NOT NULL DEFAULT 'mcq', explanation TEXT DEFAULT '', sort_order INTEGER DEFAULT 0)",
"CREATE TABLE IF NOT EXISTS learning_options (id SERIAL PRIMARY KEY, question_id INTEGER REFERENCES learning_questions(id) ON DELETE CASCADE, option_text TEXT NOT NULL, is_correct BOOLEAN DEFAULT false, explanation TEXT DEFAULT '', sort_order INTEGER DEFAULT 0)",
"CREATE TABLE IF NOT EXISTS learning_progress (id SERIAL PRIMARY KEY, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE, content_id INTEGER REFERENCES learning_content(id) ON DELETE CASCADE, score INTEGER, total INTEGER, completed_at TIMESTAMPTZ DEFAULT NOW())",
"CREATE INDEX IF NOT EXISTS idx_learning_content_cat ON learning_content(category_id)",
"CREATE INDEX IF NOT EXISTS idx_learning_content_pub ON learning_content(published)",
"CREATE INDEX IF NOT EXISTS idx_learning_questions_content ON learning_questions(content_id)",
"CREATE INDEX IF NOT EXISTS idx_learning_options_question ON learning_options(question_id)",
"CREATE INDEX IF NOT EXISTS idx_learning_progress_user ON learning_progress(user_id)"
];
for (var i = 0; i < migrations.length; i++) {
try { await client.query(migrations[i]); } catch(e) {}
@ -292,7 +226,8 @@ async function initDatabase() {
// Add updated_by column to app_settings if upgrading
try { await client.query("ALTER TABLE app_settings ADD COLUMN IF NOT EXISTS updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL"); } catch(e) {}
// Add webdav_learning_path to users for Learning Hub file browser
// The Nextcloud folder this account browses from. Named for the Learning
// Hub it was added for; kept because Settings still uses it to pick a folder.
try { await client.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS webdav_learning_path TEXT DEFAULT NULL"); } catch(e) {}
// Add user preferences for STT model and TTS voice
try { await client.query("ALTER TABLE users ADD COLUMN IF NOT EXISTS stt_model TEXT DEFAULT NULL"); } catch(e) {}
@ -352,14 +287,6 @@ async function initDatabase() {
CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON user_sessions(token_hash);
`); } catch(e) {}
// Add embedding column to learning_content for vector search (768 dims = Vertex AI text-embedding-005)
try {
await client.query('ALTER TABLE learning_content ADD COLUMN IF NOT EXISTS embedding vector(768)');
console.log('✅ learning_content.embedding: added');
} catch(e) {
console.warn('⚠️ Could not add embedding column:', e.message);
}
// Developmental milestones table
try { await client.query(`
CREATE TABLE IF NOT EXISTS developmental_milestones (
@ -398,27 +325,6 @@ async function initDatabase() {
console.warn('⚠️ Could not create clinical prompt pool snapshots table:', e.message);
}
// Create IVFFLAT index for fast similarity search (after data is populated)
try {
var indexExists = await client.query(
"SELECT 1 FROM pg_indexes WHERE indexname = 'idx_learning_content_embedding'"
);
if (indexExists.rows.length === 0) {
// Check if we have at least some embeddings before creating index
var embCount = await client.query('SELECT COUNT(*) as count FROM learning_content WHERE embedding IS NOT NULL');
if (parseInt(embCount.rows[0]?.count || 0) >= 10) {
// IVFFLAT requires lists parameter — use sqrt(rows) as heuristic
var lists = Math.max(10, Math.floor(Math.sqrt(embCount.rows[0].count)));
await client.query(
`CREATE INDEX idx_learning_content_embedding ON learning_content
USING ivfflat (embedding vector_cosine_ops) WITH (lists = ${lists})`
);
console.log('✅ Vector search index: created');
}
}
} catch(e) {
// Index creation can fail if not enough data or pgvector not installed — safe to ignore
}
// Seed all default config values (ON CONFLICT DO NOTHING — never overwrites admin changes)
var defaults = [

View file

@ -1,5 +1,5 @@
const router = require('express').Router();
const { authMiddleware, moderatorMiddleware, adminMiddleware } = require('../middleware/auth');
const { authMiddleware, adminMiddleware } = require('../middleware/auth');
const images = require('../utils/generatedImages');
const db = require('../db/database');
// Scoped to this router's own prefixes. Mounted on /api, a bare
@ -51,7 +51,6 @@ async function sendAsset(req, res, download) {
// there is no filename and no title.
const WORKFLOW_LABELS = {
clinical_assistant: 'Clinical Assistant',
learning_hub: 'Learning Hub',
my_resources: 'My Resources'
};
@ -132,22 +131,13 @@ router.get('/generated-images/:id', async (req, res) => {
});
router.get('/image-jobs/:workflow', async (req, res) => {
try {
if (!['clinical_assistant', 'learning_hub', 'my_resources'].includes(req.params.workflow)) throw images.failure(404, 'Workflow not found');
if (!IMAGE_WORKFLOWS.includes(req.params.workflow)) throw images.failure(404, 'Workflow not found');
const result = await db.query('SELECT id,stage,model,error_code,context_included,context_total,prompt_units,budget FROM generated_image_jobs WHERE owner_id=$1 AND workflow=$2 ORDER BY created_at DESC LIMIT 100', [req.user.id, req.params.workflow]);
res.json({ success: true, jobs: result.rows.map(images.publicJob) });
} catch (e) { fail(res, e); }
});
// Mounted narrowly before the server's blanket /api/admin guards.
const learningRouter = require('express').Router();
learningRouter.use(authMiddleware, moderatorMiddleware);
learningRouter.post('/jobs', async (req, res) => {
try { res.json(await images.service().enqueue(req.user.id, 'learning_hub', { prompt: req.body.prompt, ...(req.body.layout === undefined ? {} : { layout: req.body.layout }) }, images.requestKey(req.body), false, req.body.content === undefined ? undefined : images.imageContext(req.body.prompt, [{ role: 'user', content: req.body.content }]))); }
catch (e) { fail(res, e); }
});
learningRouter.get('/jobs/:id', async (req, res) => {
try { res.json(await images.service().get(req.params.id, req.user.id, 'learning_hub')); } catch (e) { fail(res, e); }
});
const IMAGE_WORKFLOWS = ['clinical_assistant', 'learning_hub', 'my_resources'];
const IMAGE_WORKFLOWS = ['clinical_assistant', 'my_resources'];
// The old single key, still read so an existing deployment keeps its fallback
// without anyone re-entering it. Writing through the new key supersedes it.
@ -176,7 +166,6 @@ router.put('/admin/image-settings/:workflow', adminMiddleware, async (req, res)
const workflow = req.params.workflow;
if (!IMAGE_WORKFLOWS.includes(workflow)) throw images.failure(404, 'Workflow not found');
const budget = images.budgetLimit(req.body.budget);
if (workflow === 'learning_hub' && (typeof req.body.model !== 'string' || !MODEL_ID.test(req.body.model))) throw images.failure(400, 'Enter an image model ID enabled at the configured image gateway');
// Fallbacks are optional, and only sent when the caller means to change
// them: an absent field leaves the saved chain alone rather than clearing it.
@ -190,8 +179,7 @@ router.put('/admin/image-settings/:workflow', adminMiddleware, async (req, res)
}
// The primary counts toward the cap, and naming it again would pay twice
// for the same refusal.
const primary = workflow === 'learning_hub' ? req.body.model
: String(await db.getSetting(workflow + '.image_model') || '');
const primary = String(await db.getSetting(workflow + '.image_model') || '');
fallbacks = fallbacks.filter((id, i) => id !== primary && fallbacks.indexOf(id) === i);
if (fallbacks.length > images.MAX_IMAGE_MODELS - 1) {
throw images.failure(400, 'At most ' + (images.MAX_IMAGE_MODELS - 1) +
@ -202,9 +190,7 @@ router.put('/admin/image-settings/:workflow', adminMiddleware, async (req, res)
const client = await db.pool.connect();
try {
await client.query('BEGIN');
const changes = workflow === 'learning_hub'
? { image_model: req.body.model, image_budget: budget }
: { image_budget: budget };
const changes = { image_budget: budget };
if (fallbacks !== null) changes.fallback_image_models = fallbacks.join(',');
for (const [key, value] of Object.entries(changes)) {
await client.query('INSERT INTO app_settings(key,value) VALUES($1,$2) ON CONFLICT(key) DO UPDATE SET value=$2,updated_at=NOW()', [workflow + '.' + key, String(value)]);
@ -216,4 +202,3 @@ router.put('/admin/image-settings/:workflow', adminMiddleware, async (req, res)
});
module.exports = router;
module.exports.sendAsset = sendAsset;
module.exports.learningRouter = learningRouter;

View file

@ -1,726 +0,0 @@
// ============================================================
// LEARNING AI ROUTES — AI-assisted content generation for Learning Hub
// ============================================================
var express = require('express');
var router = express.Router();
var multer = require('multer');
var axios = require('axios');
var path = require('path');
var { callAI } = require('../utils/ai');
var imageTool = require('../utils/imageTool');
var { authMiddleware, moderatorMiddleware } = require('../middleware/auth');
var db = require('../db/database');
var cryptoUtil = require('../utils/crypto');
var { assertSafeHttpsUrl } = require('../utils/urlSafety');
var learningRetrieval = require('../utils/learningRetrieval');
router.use(authMiddleware);
router.use(moderatorMiddleware);
// 10 MiB, down from 100. The whole file is held in memory to be parsed, so the
// old ceiling meant ten concurrent uploads could ask for a gigabyte of heap —
// and a source article that large is not a thing anyone uploads here.
var MAX_SOURCE_BYTES = 10 * 1024 * 1024;
var ALLOWED_SOURCE_TYPES = [
'application/pdf',
'text/plain',
'text/markdown',
'text/html',
'text/csv',
'application/json',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.oasis.opendocument.text',
'application/epub+zip'
];
var ALLOWED_SOURCE_EXTENSIONS = /\.(pdf|txt|md|html|htm|csv|json|docx|pptx|odt|epub)$/i;
var upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: MAX_SOURCE_BYTES,
files: 10 // max 10 files at once
},
fileFilter: function(req, file, cb) {
// Both, not either. This was `mime OR extension`, so naming a file .pdf was
// enough on its own whatever it actually declared — and the extension is
// chosen by whoever uploads. The bytes are checked after the buffer exists,
// in verifySources() below; a filter only sees the headers.
if (ALLOWED_SOURCE_TYPES.includes(file.mimetype) && ALLOWED_SOURCE_EXTENSIONS.test(file.originalname)) {
cb(null, true);
} else {
cb(new Error('File type not allowed. Supported: PDF, DOCX, PPTX, ODT, EPUB, TXT, MD, HTML, CSV, JSON.'));
}
}
});
// What multer cannot do: it filters on the declared type, before any bytes have
// arrived. This runs once the buffer exists and refuses a file whose contents
// are not what its type claims — the same check documents, S3 uploads and
// assistant attachments already make, through the same helper.
function verifySources(files) {
var fileType = require('../utils/fileType');
(files || []).forEach(function (file) {
if (!file || !Buffer.isBuffer(file.buffer)) return;
if (file.size > MAX_SOURCE_BYTES) {
throw new Error('"' + file.originalname + '" is larger than 10 MB.');
}
if (!fileType.matches(file.mimetype, file.buffer)) {
throw new Error('"' + file.originalname + '" is not the file type it claims to be.');
}
});
}
// ── Text extraction helpers ──────────────────────────────────
async function extractText(buffer, mimetype, filename) {
var ext = (filename || '').split('.').pop().toLowerCase();
// PDF
if (mimetype === 'application/pdf' || ext === 'pdf') {
try {
var pdfParse = require('pdf-parse');
var data = await pdfParse(buffer);
return data.text || '';
} catch (e) {
throw new Error('Could not parse PDF: ' + e.message);
}
}
// Plain text / markdown / HTML
if (mimetype.startsWith('text/') || ['txt', 'md', 'html', 'htm', 'csv'].includes(ext)) {
return buffer.toString('utf8');
}
// JSON
if (mimetype === 'application/json' || ext === 'json') {
return buffer.toString('utf8');
}
// DOCX
if (mimetype === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' || ext === 'docx') {
try {
var mammoth = require('mammoth');
var result = await mammoth.extractRawText({ buffer: buffer });
return result.value || '';
} catch (e) {
throw new Error('Could not parse DOCX: ' + e.message);
}
}
// PPTX — extract text from slide XML inside the zip
if (mimetype === 'application/vnd.openxmlformats-officedocument.presentationml.presentation' || ext === 'pptx') {
try {
var JSZip = require('jszip');
var zip = await JSZip.loadAsync(buffer);
var slides = Object.keys(zip.files).filter(function(f) { return /^ppt\/slides\/slide\d+\.xml$/.test(f); }).sort();
var texts = [];
for (var i = 0; i < slides.length; i++) {
var xml = await zip.files[slides[i]].async('string');
var slideText = xml.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
if (slideText) texts.push('--- Slide ' + (i + 1) + ' ---\n' + slideText);
}
return texts.join('\n\n');
} catch (e) {
throw new Error('Could not parse PPTX: ' + e.message);
}
}
// ODT — extract text from content.xml inside the zip
if (mimetype === 'application/vnd.oasis.opendocument.text' || ext === 'odt') {
try {
var JSZip = require('jszip');
var zip = await JSZip.loadAsync(buffer);
var contentXml = await zip.files['content.xml'].async('string');
return contentXml.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
} catch (e) {
throw new Error('Could not parse ODT: ' + e.message);
}
}
// EPUB — extract text from XHTML chapters inside the zip
if (mimetype === 'application/epub+zip' || ext === 'epub') {
try {
var JSZip = require('jszip');
var zip = await JSZip.loadAsync(buffer);
var chapters = Object.keys(zip.files).filter(function(f) { return /\.(xhtml|html|htm)$/i.test(f) && !zip.files[f].dir; }).sort();
var texts = [];
for (var i = 0; i < chapters.length; i++) {
var html = await zip.files[chapters[i]].async('string');
var chapterText = html.replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
if (chapterText) texts.push(chapterText);
}
return texts.join('\n\n');
} catch (e) {
throw new Error('Could not parse EPUB: ' + e.message);
}
}
// Fallback — try utf8
return buffer.toString('utf8');
}
// ── Build AI prompt ──────────────────────────────────────────
function buildGeneratePrompt(opts) {
var { topic, docText, contentType, questionCount, refinement, wordCount, slideCount, existingCategories, corpusContext } = opts;
var source = docText
? 'Based on the following document/resource text, generate educational content.\n\nDOCUMENT:\n"""\n' + docText.substring(0, 50000) + '\n"""\n'
: 'Generate educational content on the following topic for a medical professional audience (pediatrics / primary care).\n\nTOPIC: ' + topic + '\n';
// Material from this institution's own indexed documents, when the author
// asked for it. It goes before the instructions so the model reads it as the
// ground to work from, and it is explicitly preferred over recall: the point
// of grounding is that local guidance wins where the two disagree.
if (corpusContext) {
source += '\nThe following excerpts come from this institution\'s indexed clinical library. ' +
'Prefer them over your own recall wherever they disagree, and do not contradict them. ' +
'They are reference material, not a template: write the resource in your own words.\n\n' +
'Do NOT cite in the body: no [1] markers, no bracketed numbers, no parenthetical ' +
'"(Nelson, p. 2604)" inside sentences. Teaching material reads as prose, and a slide ' +
'carrying citation markers is unreadable from the back of a room.\n\n' +
'Instead, end the resource with a short References section listing only the excerpts you ' +
'actually drew on, by title and page. In a presentation this is the final slide, titled ' +
'References. Do not invent references, and do not list an excerpt you did not use.\n\n' +
'LIBRARY EXCERPTS:\n"""\n' + corpusContext + '\n"""\n';
}
var refineInstr = refinement ? '\n\nAdditional instructions for tone/style/focus: ' + refinement : '';
var categoryInstr = buildCategoryInstruction(existingCategories);
// ── Presentation ──
if (contentType === 'presentation') {
var slideHint = slideCount ? slideCount + ' slides' : '8-12 slides';
var hasQuestions = parseInt(questionCount) > 0;
if (!hasQuestions) {
// No questions — return raw Marp markdown only
return source + '\n' +
'Create a professional Marp presentation (' + slideHint + ') suitable for medical education. ' +
'Each slide should be focused and readable.' + refineInstr + `
Return ONLY valid Marp markdown (no JSON, no code fences). Start with frontmatter:
---
marp: true
theme: default
---
Then each slide separated by ---. Guidelines:
- First slide: title slide with presentation name and brief subtitle
- Use # for slide titles
- Use bullet points (- ) for lists, keep them concise (max 5 bullets per slide)
- A slide that contains a table must contain ONLY that table and its heading.
Anything after a table starts a new, untitled slide when the deck is built.
- Leave a blank line before and after every table, or it is not read as a table
at all and appears as literal pipe characters on the slide.
- Do not nest lists more than one level deep, and do not put an ordered list
inside a bullet: it overfills the slide.
- Prefer more slides with less on each. A slide should hold one idea.
- A slide heading is the slide's subject. Do not number it or prefix it with
"Slide 3:" the deck numbers itself.
- Include a summary/key takeaways slide at the end
- Do NOT include HTML tags or inline styles`;
}
// With questions — return JSON containing both Marp markdown and questions
return source + '\n' +
'Create a professional Marp presentation (' + slideHint + ') suitable for medical education, ' +
'AND ' + questionCount + ' quiz questions based on the content.' + categoryInstr + refineInstr + `
Return ONLY a valid JSON object (no markdown, no code fences) with this exact structure:
{
"title": "string",
"category_name": "string (best existing category name, or a concise new category if none fit)",
"marpMarkdown": "string (the complete Marp markdown starting with frontmatter ---\\nmarp: true\\ntheme: default\\n---)",
"questions": [
{
"question_text": "string",
"question_type": "mcq",
"explanation": "string",
"options": [
{ "option_text": "string", "is_correct": false, "explanation": "string" }
]
}
]
}
Marp guidelines inside marpMarkdown:
- Start with: ---\\nmarp: true\\ntheme: default\\n---
- Separate slides with \\n---\\n
- First slide: title + subtitle. Use # for titles, bullets for content.
- Each MCQ must have exactly 4 options, exactly 1 marked is_correct: true`;
}
var wordHint = wordCount ? ' Target approximately ' + wordCount + ' words for the body.' : '';
var qInstr = parseInt(questionCount) > 0
? 'then generate ' + questionCount + ' quiz questions.'
: 'Do NOT include quiz questions (questions array should be empty []).';
var typeInstr = '';
if (contentType === 'quiz') {
typeInstr = 'This is a quiz-only resource. Write a brief introductory body (1-2 paragraphs),' + wordHint + ' ' + qInstr;
} else if (contentType === 'pearl') {
typeInstr = 'This is a clinical pearl. Write a concise, high-impact body (2-4 paragraphs focusing on key takeaways).' + wordHint + ' ' + qInstr;
} else {
typeInstr = 'This is an article. Write a comprehensive, well-structured body.' + wordHint + ' ' + qInstr;
}
return source + '\n' + typeInstr + categoryInstr + refineInstr + `
Return ONLY a valid JSON object (no markdown, no code fences) with this exact structure:
{
"title": "string",
"category_name": "string (best existing category name, or a concise new category if none fit)",
"subject": "string (1-3 word sub-topic label)",
"body": "string (valid HTML using only: p, h2, h3, ul, ol, li, strong, em, blockquote, code — no inline styles)",
"questions": [
{
"question_text": "string",
"question_type": "mcq",
"explanation": "string (general explanation shown after answering)",
"options": [
{ "option_text": "string", "is_correct": false, "explanation": "string (shown if this wrong option chosen)" }
]
}
]
}
Rules:
- Each MCQ must have exactly 4 options, exactly 1 marked is_correct: true
- Prefer an existing category_name when it fits; create a short broad category_name only when no existing category fits
- question_type must be "mcq" or "true_false" (true_false has exactly 2 options: "True" and "False")
- body must be clean HTML, no raw markdown
- Do not include any text outside the JSON object`;
}
function buildCategoryInstruction(existingCategories) {
var names = (existingCategories || []).map(function(c) { return c.name; }).filter(Boolean);
if (!names.length) {
return '\nAssign a short, broad category_name for this content.';
}
return '\nChoose the best category_name from this existing list when appropriate: ' + names.join(', ') + '. If none fit, create one short broad category_name.';
}
// ── POST /api/admin/learning/ai-generate ────────────────────
// Accepts: multipart/form-data OR application/json
router.post('/ai-generate', upload.array('files', 10), async function(req, res) {
try {
var topic = req.body.topic || '';
var contentType = req.body.contentType || 'article';
var questionCount = Math.min(parseInt(req.body.questionCount) || 0, 20);
var model = req.body.model || null;
var refinement = req.body.refinement || '';
var webdavPath = req.body.webdavPath || '';
var wordCount = parseInt(req.body.wordCount) || 0;
var slideCount = parseInt(req.body.slideCount) || 0;
// Opt in. Grounding is the right default for clinical teaching, but a
// resource on something the library does not cover is better written
// without it than padded with the nearest unrelated excerpts.
var useCorpus = String(req.body.useCorpus) === 'true' || req.body.useCorpus === true;
if (typeof topic !== 'string' || typeof refinement !== 'string') return res.status(400).json({ error: 'topic and refinement must be text' });
var docText = '';
var fileCount = 0;
// 1 — Uploaded files (multiple)
if (req.files && req.files.length > 0) {
// Before a single byte is parsed. An extractor handed a file that is not
// what it claims is the place a malformed input does its damage.
try { verifySources(req.files); }
catch (e) { return res.status(400).json({ error: e.message }); }
var allTexts = [];
for (var i = 0; i < req.files.length; i++) {
var file = req.files[i];
try {
var text = await extractText(file.buffer, file.mimetype, file.originalname);
allTexts.push('### Source File: ' + file.originalname + '\n\n' + text);
fileCount++;
} catch (e) {
console.error('[LearningAI] Failed to extract uploaded file:', e.message);
// Continue with other files even if one fails
}
}
docText = allTexts.join('\n\n---\n\n');
}
// 2 — Nextcloud WebDAV path
else if (webdavPath) {
if (!await require('../utils/policy').isFeatureEnabled('nextcloud')) return res.status(403).json({ error: 'Feature disabled' });
var user = await db.get(
'SELECT nextcloud_url, nextcloud_user, nextcloud_token FROM users WHERE id = ?',
[req.user.id]
);
if (!user || !user.nextcloud_url) {
return res.status(400).json({ error: 'Nextcloud not connected. Go to Settings first.' });
}
await assertSafeHttpsUrl(user.nextcloud_url, 'Nextcloud URL');
var ncPassword;
try { ncPassword = cryptoUtil.decryptString(user.nextcloud_token); }
catch (decErr) { return res.status(400).json({ error: 'Nextcloud credentials invalid. Please reconnect.' }); }
if (!webdavPath.startsWith('/')) webdavPath = '/' + webdavPath;
var fileUrl = user.nextcloud_url + '/remote.php/dav/files/' + encodeURIComponent(user.nextcloud_user) + webdavPath;
var response = await axios.get(fileUrl, {
auth: { username: user.nextcloud_user, password: ncPassword },
responseType: 'arraybuffer',
timeout: 30000,
maxRedirects: 0
});
var mimeType = response.headers['content-type'] || 'text/plain';
var fname = path.basename(webdavPath);
docText = await extractText(Buffer.from(response.data), mimeType, fname);
}
// 3 — Topic only (no file)
else if (!topic.trim()) {
return res.status(400).json({ error: 'Provide a topic or upload a file.' });
}
var existingCategories = await db.all('SELECT name FROM learning_categories ORDER BY sort_order ASC, name ASC', []);
// Retrieval never fails a generation: without it the resource is written
// from the model alone, which is exactly what happened before this
// existed. The reason is surfaced so the author is told rather than
// quietly handed ungrounded material.
var corpus = { sources: [], context: '', reason: 'not requested' };
if (useCorpus) {
corpus = await learningRetrieval.retrieve(topic || docText || '', db.getSetting);
if (corpus.reason) console.warn('[learning] corpus not used:', corpus.reason);
else console.info('[learning] grounded on', corpus.sources.length, 'excerpts');
}
var prompt = buildGeneratePrompt({ topic, docText, contentType, questionCount, refinement, wordCount, slideCount, existingCategories, corpusContext: corpus.context });
var aiMessages = [
{ role: 'system', content: 'You are a medical education content generator. Return ONLY the requested JSON or Marp markdown — no preamble, no commentary, no code fences, no thinking. Start your response with { or --- as appropriate.' },
{ role: 'user', content: prompt }
];
var aiOptions = { model: model, temperature: 0.4, maxTokens: 8000, tools: imageTool.tools };
var result = await callAI(aiMessages, aiOptions);
result = await imageTool.dispatch(result, { owner: req.user.id, workflow: 'learning_hub', body: { ...req.body, docText }, imageContext: require('../utils/generatedImages').imageContext(
[topic, refinement].filter(Boolean).join('\n\n') || 'Generate educational content from the supplied document.', docText ? [{ role: 'user', content: docText }] : []), messages: aiMessages, options: aiOptions, callAI });
var raw = result.content.trim();
// Strip any leading text before the first { or --- (models sometimes add preamble)
if (contentType !== 'presentation' || parseInt(questionCount) > 0) {
var jsonStart = raw.indexOf('{');
if (jsonStart > 0) raw = raw.substring(jsonStart);
}
// ── Presentation ──
if (contentType === 'presentation') {
if (questionCount > 0) {
// JSON response with marpMarkdown + questions
var cleanRaw = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '');
var parsedPres;
try { parsedPres = JSON.parse(cleanRaw); }
catch(e) {
var m = cleanRaw.match(/\{[\s\S]*\}/);
try { parsedPres = m ? JSON.parse(m[0]) : null; } catch(e2) { parsedPres = null; }
}
if (parsedPres && parsedPres.marpMarkdown) {
return res.json({ success: true, grounding: { used: Boolean(corpus.context), count: corpus.sources.length, reason: corpus.reason || null }, contentType: 'presentation', marpMarkdown: parsedPres.marpMarkdown, category_name: parsedPres.category_name || '', questions: parsedPres.questions || [], imageJobs: result.imageJobs || [], model: result.model });
}
}
// Plain Marp markdown (no questions requested, or parse failed)
var marpMd = raw.replace(/^```(?:markdown|marp)?\s*/i, '').replace(/\s*```\s*$/, '');
return res.json({ success: true, grounding: { used: Boolean(corpus.context), count: corpus.sources.length, reason: corpus.reason || null }, contentType: 'presentation', marpMarkdown: marpMd, questions: [], imageJobs: result.imageJobs || [], model: result.model, docLength: docText.length });
}
// Strip code fences and any trailing text after JSON
raw = raw.replace(/^```(?:json)?\s*/i, '').replace(/\s*```\s*$/, '');
// Trim trailing non-JSON text (e.g. "Here is the JSON..." after closing brace)
var lastBrace = raw.lastIndexOf('}');
if (lastBrace !== -1 && lastBrace < raw.length - 1) raw = raw.substring(0, lastBrace + 1);
// Fix unescaped control characters inside JSON string values.
// Models sometimes output literal newlines/tabs inside strings instead of \n \t.
function sanitizeJsonString(s) {
var inStr = false, escaped = false, out = '';
for (var i = 0; i < s.length; i++) {
var c = s[i];
if (escaped) { out += c; escaped = false; continue; }
if (c === '\\' && inStr) { out += c; escaped = true; continue; }
if (c === '"') { inStr = !inStr; out += c; continue; }
if (inStr && c === '\n') { out += '\\n'; continue; }
if (inStr && c === '\r') { continue; }
if (inStr && c === '\t') { out += '\\t'; continue; }
out += c;
}
return out;
}
var parsed;
try {
parsed = JSON.parse(raw);
} catch (e) {
console.error('[LearningAI] Direct parse failed:', e.message, '| Pos:', e.message.match(/position (\d+)/)?.[1]);
// Attempt 2: sanitize control chars and retry
try { parsed = JSON.parse(sanitizeJsonString(raw)); }
catch (e2) {
// Attempt 3: extract first { to last }
var firstBrace = raw.indexOf('{');
var extractedJson = firstBrace >= 0 ? raw.substring(firstBrace, lastBrace + 1) : null;
if (extractedJson) {
try { parsed = JSON.parse(sanitizeJsonString(extractedJson)); }
catch (e3) {
console.error('[LearningAI] All parse attempts failed:', e3.message);
return res.status(500).json({ error: 'AI returned invalid JSON. Try again.' });
}
} else {
console.error('[LearningAI] No JSON braces found');
return res.status(500).json({ error: 'AI returned invalid JSON. Try again.' });
}
}
}
res.json({
success: true,
grounding: { used: Boolean(corpus.context), count: corpus.sources.length, reason: corpus.reason || null },
content: parsed,
imageJobs: result.imageJobs || [], model: result.model,
docLength: docText.length,
fileCount: fileCount || (webdavPath ? 1 : 0)
});
} catch (err) {
console.error('[LearningAI]', err.message);
res.status(err.statusCode || 500).json({ error: err.statusCode ? err.message : 'Generation failed' });
}
});
// ── POST /api/admin/learning/ai-refine ───────────────────────
// Refine an existing body or specific question
router.post('/ai-refine', async function(req, res) {
try {
var { content, instructions, model } = req.body;
if (typeof content !== 'string' || typeof instructions !== 'string' || !content || !instructions.trim()) return res.status(400).json({ error: 'content and instructions required' });
var prompt = `You are editing educational medical content. Refine the following HTML body according to the instructions below.
INSTRUCTIONS: ${instructions}
CURRENT CONTENT:
${content}
For a text-only refinement, return ONLY the refined HTML body (same structure, no JSON wrapper, no markdown fences). Keep all HTML tags intact. If the instruction calls for an image, invoke generate_image instead; the existing body will be preserved regardless of any accompanying text. Do not combine image insertion with a body rewrite.`;
var aiMessages = [{ role: 'user', content: prompt }];
var aiOptions = { model: model, temperature: 0.3, maxTokens: 4000, tools: imageTool.tools };
var result = await callAI(aiMessages, aiOptions);
// A tool call never authorizes a text rewrite, even if the model also emits HTML.
var imageOnly = Boolean(result.toolCalls?.length);
if (imageOnly) result = { ...result, content };
result = await imageTool.dispatch(result, { owner: req.user.id, workflow: 'learning_hub', body: req.body, imageContext: require('../utils/generatedImages').imageContext(instructions, [{ role: 'user', content }]), messages: aiMessages, options: aiOptions, callAI });
var refined = imageOnly ? content : result.content.trim().replace(/^```(?:html)?\s*/i, '').replace(/\s*```\s*$/, '');
res.json({ success: true, refined, bodyPreserved: imageOnly, imageJobs: result.imageJobs || [], model: result.model });
} catch (err) {
console.error('[LearningAI]', err.message);
res.status(err.statusCode || 500).json({ error: err.statusCode ? err.message : 'Request failed' });
}
});
// ── GET /api/admin/learning/webdav-browse ────────────────────
// Browse user's Nextcloud folder
router.get('/webdav-browse', require('../utils/policy').requireFeature('nextcloud'), async function(req, res) {
try {
var user = await db.get(
'SELECT nextcloud_url, nextcloud_user, nextcloud_token, nextcloud_folder, webdav_learning_path FROM users WHERE id = ?',
[req.user.id]
);
if (!user || !user.nextcloud_url) {
return res.status(400).json({ error: 'Nextcloud not connected' });
}
await assertSafeHttpsUrl(user.nextcloud_url, 'Nextcloud URL');
var ncPassword;
try { ncPassword = cryptoUtil.decryptString(user.nextcloud_token); }
catch (decErr) { return res.status(400).json({ error: 'Nextcloud credentials invalid. Please reconnect.' }); }
var browsePath = req.query.path || user.webdav_learning_path || user.nextcloud_folder || '/';
// Ensure it starts with /
if (!browsePath.startsWith('/')) browsePath = '/' + browsePath;
var davUrl = user.nextcloud_url + '/remote.php/dav/files/' + encodeURIComponent(user.nextcloud_user) + browsePath;
var davResponse = await axios({
method: 'PROPFIND',
url: davUrl,
auth: { username: user.nextcloud_user, password: ncPassword },
headers: { Depth: '1', 'Content-Type': 'application/xml' },
data: `<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:displayname/><d:getcontenttype/><d:getcontentlength/><d:resourcetype/></d:prop></d:propfind>`,
timeout: 15000,
maxRedirects: 0
});
// Parse WebDAV XML response
var xml = davResponse.data;
var items = [];
var responseRegex = /<d:response>([\s\S]*?)<\/d:response>/g;
var match;
while ((match = responseRegex.exec(xml)) !== null) {
var block = match[1];
var hrefMatch = block.match(/<d:href>([^<]+)<\/d:href>/);
var nameMatch = block.match(/<d:displayname>([^<]*)<\/d:displayname>/);
var typeMatch = block.match(/<d:getcontenttype>([^<]*)<\/d:getcontenttype>/);
var sizeMatch = block.match(/<d:getcontentlength>([^<]*)<\/d:getcontentlength>/);
var isCollMatch = block.includes('<d:collection');
if (!hrefMatch) continue;
var href = decodeURIComponent(hrefMatch[1]);
// Strip the /remote.php/dav/files/username prefix to get the relative path
var relPath = href.replace(/^.*\/remote\.php\/dav\/files\/[^/]+/, '') || '/';
items.push({
path: relPath,
name: nameMatch ? nameMatch[1] : path.basename(relPath) || relPath,
isDir: !!isCollMatch,
contentType: typeMatch ? typeMatch[1] : '',
size: sizeMatch ? parseInt(sizeMatch[1]) : 0
});
}
// Filter: skip the parent directory entry (same path as requested)
var normBrowse = browsePath.replace(/\/$/, '');
items = items.filter(function(item) {
var normPath = item.path.replace(/\/$/, '');
return normPath !== normBrowse;
});
// Sort: directories first, then files
items.sort(function(a, b) {
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
return a.name.localeCompare(b.name);
});
// Compute parent path
var parent = browsePath.replace(/\/$/, '');
var parentPath = parent.includes('/') ? parent.substring(0, parent.lastIndexOf('/')) || '/' : '/';
res.json({ success: true, path: browsePath, parentPath, items });
} catch (err) {
console.error('[WebDAV]', err.message);
res.status(500).json({ error: 'WebDAV browse failed' });
}
});
// ── POST /api/admin/learning/webdav-path ─────────────────────
// Save user's preferred WebDAV learning path
router.post('/webdav-path', require('../utils/policy').requireFeature('nextcloud'), async function(req, res) {
try {
var { path: wPath } = req.body;
await db.run('UPDATE users SET webdav_learning_path = ? WHERE id = ?', [wPath || null, req.user.id]);
res.json({ success: true });
} catch (err) {
res.status(500).json({ error: 'Request failed' });
}
});
// ── POST /api/admin/learning/generate-pptx ──────────────────
// pandoc measures images itself and emits an extent with the right shape, so
// nothing here needs to size them.
router.post('/generate-pptx', async function(req, res) {
var workdir = null;
try {
var { markdown, title } = req.body;
if (!markdown) return res.status(400).json({ error: 'markdown required' });
var fsp = require('fs/promises');
var os = require('os');
var pathMod = require('path');
var { execFile } = require('child_process');
workdir = await fsp.mkdtemp(pathMod.join(os.tmpdir(), 'pptx-'));
// Only images this user owns, written beside the markdown under names we
// choose. Everything else is stripped: pandoc resolves an image link
// against the filesystem, so a link naming any local path would embed that
// file into the deck. The allow-list is the set we just fetched by id.
var allowed = Object.create(null);
var refs = require('../utils/generatedImageLinks').references(markdown);
for (var r = 0; r < refs.length; r++) {
var id = refs[r];
var image = await require('../utils/generatedImages').service().asset(id, req.user);
var ext = String(image.mime || '').indexOf('png') !== -1 ? 'png' : 'jpg';
var file = 'img-' + r + '.' + ext;
await fsp.writeFile(pathMod.join(workdir, file), image.bytes);
allowed['/api/generated-images/' + id] = file;
}
var source = String(markdown).replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function (whole, alt, src) {
var local = allowed[String(src).trim()];
// A link we did not resolve is dropped rather than passed through, so a
// deck can never be made to read a path off this host.
return local ? '![' + alt + '](' + local + ')' : '';
});
await fsp.writeFile(pathMod.join(workdir, 'deck.md'), source, 'utf8');
// The reference deck carries the fonts, palette and slide layouts. Design
// lives there, not here: restyling means editing that file in PowerPoint.
var reference = pathMod.join(__dirname, '..', '..', 'assets', 'learning', 'slides-reference.pptx');
await new Promise(function (resolve, reject) {
execFile('pandoc', ['deck.md', '--reference-doc=' + reference, '-o', 'deck.pptx'],
{ cwd: workdir, timeout: 60000, maxBuffer: 1024 * 1024 },
function (err, stdout, stderr) {
if (err) return reject(new Error(String(stderr || err.message).slice(0, 400)));
resolve();
});
});
var pptxBuffer = await fsp.readFile(pathMod.join(workdir, 'deck.pptx'));
var safeTitle = (title || 'presentation').replace(/[^a-zA-Z0-9-_\s]/g, '').replace(/\s+/g, '-').toLowerCase();
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.presentationml.presentation');
res.setHeader('Content-Disposition', 'attachment; filename="' + safeTitle + '.pptx"');
res.send(pptxBuffer);
} catch (err) {
console.error('[PPTX]', err.message);
res.status(500).json({ error: 'Request failed' });
} finally {
if (workdir) {
try { await require('fs/promises').rm(workdir, { recursive: true, force: true }); }
catch (e) { console.warn('[PPTX] could not clean', workdir, e.message); }
}
}
});
// ── POST /api/admin/learning/preview-slides ──────────────────
// Render Marp markdown to HTML for in-browser preview
router.post('/preview-slides', async function(req, res) {
try {
var { markdown } = req.body;
if (!markdown) return res.status(400).json({ error: 'markdown required' });
var { Marp } = require('@marp-team/marp-core');
var marp = new Marp({ html: false });
var { html, css } = marp.render(markdown);
// Extract individual <section> elements for page-by-page navigation
var slides = [];
var sectionReg = /<section[^>]*>[\s\S]*?<\/section>/g;
var match;
while ((match = sectionReg.exec(html)) !== null) {
slides.push(match[0]);
}
if (slides.length === 0) slides.push(html); // fallback
res.json({ success: true, css: css, slides: slides });
} catch (err) {
res.status(500).json({ error: 'Request failed' });
}
});
module.exports = router;

View file

@ -1,444 +0,0 @@
// ============================================================
// LEARNING ADMIN ROUTES — CMS for categories, content, quizzes
// ============================================================
var express = require('express');
var router = express.Router();
var db = require('../db/database');
var imageLinks = require('../utils/generatedImageLinks');
var { authMiddleware, moderatorMiddleware } = require('../middleware/auth');
var { generateContentEmbedding, isEmbeddingsAvailable } = require('../utils/embeddings');
router.use(authMiddleware);
router.use(moderatorMiddleware);
// ── Helpers ──────────────────────────────────────────────────
function slugify(text) {
return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').substring(0, 100);
}
var ALLOWED_SLUG_TABLES = ['learning_categories', 'learning_content'];
var ALLOWED_QUESTION_TYPES = ['mcq', 'true_false', 'multi'];
async function uniqueSlug(table, base) {
if (ALLOWED_SLUG_TABLES.indexOf(table) === -1) throw new Error('Invalid table for slug generation');
var slug = slugify(base);
var exists = await db.get('SELECT id FROM ' + table + ' WHERE slug = ?', [slug]);
if (!exists) return slug;
var i = 2;
while (true) {
var attempt = slug + '-' + i;
var exists2 = await db.get('SELECT id FROM ' + table + ' WHERE slug = ?', [attempt]);
if (!exists2) return attempt;
i++;
if (i > 100) return slug + '-' + Date.now();
}
}
// ============================================================
// CATEGORIES — CRUD
// ============================================================
router.get('/categories', async function(req, res) {
try {
var categories = await db.all(
`SELECT c.*, (SELECT COUNT(*) FROM learning_content lc WHERE lc.category_id = c.id) as content_count
FROM learning_categories c ORDER BY c.sort_order ASC, c.name ASC`,
[]
);
res.json({ success: true, categories: categories });
} catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
router.post('/categories', async function(req, res) {
try {
var { name, description, sort_order } = req.body;
if (!name || !name.trim()) return res.status(400).json({ error: 'Name required' });
var existing = await db.get('SELECT id, slug FROM learning_categories WHERE LOWER(name) = LOWER(?)', [name.trim()]);
if (existing) return res.json({ success: true, id: existing.id, slug: existing.slug, existing: true });
var slug = await uniqueSlug('learning_categories', name.trim());
var result = await db.run(
'INSERT INTO learning_categories (name, slug, description, sort_order) VALUES (?, ?, ?, ?)',
[name.trim(), slug, description || '', sort_order || 0]
);
res.json({ success: true, id: result.lastInsertRowid, slug: slug });
} catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
router.put('/categories/:id', async function(req, res) {
try {
var { name, description, sort_order } = req.body;
var cat = await db.get('SELECT * FROM learning_categories WHERE id = ?', [req.params.id]);
if (!cat) return res.status(404).json({ error: 'Category not found' });
await db.run(
'UPDATE learning_categories SET name = ?, description = ?, sort_order = ? WHERE id = ?',
[name || cat.name, description !== undefined ? description : cat.description, sort_order !== undefined ? sort_order : cat.sort_order, cat.id]
);
res.json({ success: true });
} catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
router.delete('/categories/:id', async function(req, res) {
try {
// Set content in this category to uncategorized (NULL)
await db.run('UPDATE learning_content SET category_id = NULL WHERE category_id = ?', [req.params.id]);
await db.run('DELETE FROM learning_categories WHERE id = ?', [req.params.id]);
res.json({ success: true });
} catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
// ============================================================
// CONTENT — CRUD
// ============================================================
router.get('/content', async function(req, res) {
try {
var content = await db.all(
`SELECT c.id, c.title, c.slug, c.subject, c.content_type, c.published, c.category_id,
c.created_at, c.updated_at,
cat.name as category_name,
u.name as author_name,
(SELECT COUNT(*) FROM learning_questions q WHERE q.content_id = c.id) as question_count
FROM learning_content c
LEFT JOIN learning_categories cat ON c.category_id = cat.id
LEFT JOIN users u ON c.author_id = u.id
ORDER BY c.updated_at DESC`,
[]
);
res.json({ success: true, content: content });
} catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
router.get('/content/:id', async function(req, res) {
try {
var item = await db.get(
`SELECT c.*, cat.name as category_name
FROM learning_content c
LEFT JOIN learning_categories cat ON c.category_id = cat.id
WHERE c.id = ?`,
[req.params.id]
);
if (!item) return res.status(404).json({ error: 'Content not found' });
// Get questions + options
var questions = await db.all(
'SELECT * FROM learning_questions WHERE content_id = ? ORDER BY sort_order ASC',
[item.id]
);
for (var i = 0; i < questions.length; i++) {
questions[i].options = await db.all(
'SELECT * FROM learning_options WHERE question_id = ? ORDER BY sort_order ASC',
[questions[i].id]
);
}
item.questions = questions;
res.json({ success: true, content: item });
} catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
router.post('/content', async function(req, res) {
try {
var { title, body, category_id, subject, content_type, published } = req.body;
if (!title || !title.trim()) return res.status(400).json({ error: 'Title required' });
var slug = await uniqueSlug('learning_content', title.trim());
var client = await db.pool.connect();
var contentId;
try {
await client.query('BEGIN');
var imageIds = await imageLinks.validateLearning(client, body, req.user.id);
var result = await client.query(
'INSERT INTO learning_content (title, slug, body, category_id, subject, content_type, published, author_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8) RETURNING id',
[title.trim(), slug, body || '', category_id || null, subject || '', content_type || 'article', published ? true : false, req.user.id]);
contentId = result.rows[0].id;
await imageLinks.setLinks(client, contentId, imageIds);
await client.query('COMMIT');
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; } finally { client.release(); }
// Generate embedding asynchronously (don't block response)
if (isEmbeddingsAvailable() && body && body.trim()) {
generateContentEmbedding({ title: title.trim(), subject: subject || '', body: body })
.then(function(embedding) {
return db.query(
'UPDATE learning_content SET embedding = $1 WHERE id = $2',
[JSON.stringify(embedding), contentId]
);
})
.then(function() { console.log('[Embeddings] Generated for content ID:', contentId); })
.catch(function(err) { console.error('[Embeddings] Failed for content ID ' + contentId + ':', err.message); });
}
res.json({ success: true, id: contentId, slug: slug });
} catch (err) { console.error('[LearningAdmin]', err.message); res.status(err.statusCode || 500).json({ error: err.statusCode ? err.message : 'Internal server error' }); }
});
router.put('/content/:id', async function(req, res) {
try {
var { title, body, category_id, subject, content_type, published } = req.body;
var client = await db.pool.connect();
try {
await client.query('BEGIN');
var item = (await client.query('SELECT * FROM learning_content WHERE id=$1 FOR UPDATE', [req.params.id])).rows[0];
if (!item) {
await client.query('ROLLBACK');
return res.status(404).json({ error: 'Content not found' });
}
// All omitted fields must come from the locked row, including publication state.
var newTitle = title !== undefined ? title : item.title;
var newBody = body !== undefined ? body : item.body;
var newSubject = subject !== undefined ? subject : item.subject;
var imageIds = await imageLinks.validateLearning(client, newBody, req.user.id, item.id);
await client.query(
'UPDATE learning_content SET title = $1, body = $2, category_id = $3, subject = $4, content_type = $5, published = $6, updated_at = NOW() WHERE id = $7',
[
newTitle,
newBody,
category_id !== undefined ? (category_id || null) : item.category_id,
newSubject,
content_type !== undefined ? content_type : item.content_type,
published !== undefined ? published : item.published,
item.id
]
);
await imageLinks.setLinks(client, item.id, imageIds);
await client.query('COMMIT');
} catch (error) { await client.query('ROLLBACK').catch(() => {}); throw error; } finally { client.release(); }
// Regenerate embedding if title/body/subject changed (async, don't block)
if (isEmbeddingsAvailable() && (title !== undefined || body !== undefined || subject !== undefined)) {
if (newBody && newBody.trim()) {
generateContentEmbedding({ title: newTitle, subject: newSubject, body: newBody })
.then(function(embedding) {
return db.query(
'UPDATE learning_content SET embedding = $1 WHERE id = $2',
[JSON.stringify(embedding), item.id]
);
})
.then(function() { console.log('[Embeddings] Regenerated for content ID:', item.id); })
.catch(function(err) { console.error('[Embeddings] Failed to regenerate for content ID ' + item.id + ':', err.message); });
}
}
res.json({ success: true });
} catch (err) { console.error('[LearningAdmin]', err.message); res.status(err.statusCode || 500).json({ error: err.statusCode ? err.message : 'Internal server error' }); }
});
router.delete('/content/:id', async function(req, res) {
try {
await db.run('DELETE FROM learning_content WHERE id = ?', [req.params.id]);
res.json({ success: true });
} catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
// ============================================================
// QUESTIONS — CRUD (nested under content)
// ============================================================
router.post('/content/:contentId/questions', async function(req, res) {
try {
var { question_text, question_type, explanation, options } = req.body;
if (!question_text) return res.status(400).json({ error: 'Question text required' });
if (question_type === undefined) question_type = 'mcq';
if (!ALLOWED_QUESTION_TYPES.includes(question_type)) return res.status(400).json({ error: 'Invalid question type' });
// Get max sort_order
var maxOrder = await db.get('SELECT COALESCE(MAX(sort_order), -1) as mx FROM learning_questions WHERE content_id = ?', [req.params.contentId]);
var sortOrder = (maxOrder ? maxOrder.mx : -1) + 1;
var result = await db.run(
'INSERT INTO learning_questions (content_id, question_text, question_type, explanation, sort_order) VALUES (?, ?, ?, ?, ?)',
[req.params.contentId, question_text, question_type, explanation || '', sortOrder]
);
var questionId = result.lastInsertRowid;
// Insert options if provided
if (options && Array.isArray(options)) {
for (var i = 0; i < options.length; i++) {
var opt = options[i];
await db.run(
'INSERT INTO learning_options (question_id, option_text, is_correct, explanation, sort_order) VALUES (?, ?, ?, ?, ?)',
[questionId, opt.option_text, opt.is_correct ? true : false, opt.explanation || '', i]
);
}
}
res.json({ success: true, id: questionId });
} catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
router.put('/questions/:id', async function(req, res) {
try {
var q = await db.get('SELECT * FROM learning_questions WHERE id = ?', [req.params.id]);
if (!q) return res.status(404).json({ error: 'Question not found' });
var { question_text, question_type, explanation, options } = req.body;
if (question_type === undefined) question_type = q.question_type;
if (!ALLOWED_QUESTION_TYPES.includes(question_type)) return res.status(400).json({ error: 'Invalid question type' });
await db.run(
'UPDATE learning_questions SET question_text = ?, question_type = ?, explanation = ? WHERE id = ?',
[question_text || q.question_text, question_type, explanation !== undefined ? explanation : q.explanation, q.id]
);
// Replace options if provided
if (options && Array.isArray(options)) {
await db.run('DELETE FROM learning_options WHERE question_id = ?', [q.id]);
for (var i = 0; i < options.length; i++) {
var opt = options[i];
await db.run(
'INSERT INTO learning_options (question_id, option_text, is_correct, explanation, sort_order) VALUES (?, ?, ?, ?, ?)',
[q.id, opt.option_text, opt.is_correct ? true : false, opt.explanation || '', i]
);
}
}
res.json({ success: true });
} catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
router.delete('/questions/:id', async function(req, res) {
try {
await db.run('DELETE FROM learning_questions WHERE id = ?', [req.params.id]);
res.json({ success: true });
} catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
// ============================================================
// STATS
// ============================================================
router.get('/stats', async function(req, res) {
try {
var totalContent = await db.get('SELECT COUNT(*) as count FROM learning_content', []);
var published = await db.get('SELECT COUNT(*) as count FROM learning_content WHERE published = true', []);
var totalCategories = await db.get('SELECT COUNT(*) as count FROM learning_categories', []);
var totalQuizzes = await db.get("SELECT COUNT(DISTINCT content_id) as count FROM learning_questions", []);
var totalAttempts = await db.get('SELECT COUNT(*) as count FROM learning_progress', []);
var withEmbeddings = await db.get('SELECT COUNT(*) as count FROM learning_content WHERE embedding IS NOT NULL', []);
res.json({
success: true,
stats: {
totalContent: parseInt(totalContent.count),
publishedContent: parseInt(published.count),
totalCategories: parseInt(totalCategories.count),
totalQuizzes: parseInt(totalQuizzes.count),
totalAttempts: parseInt(totalAttempts.count),
withEmbeddings: parseInt(withEmbeddings.count),
embeddingsEnabled: isEmbeddingsAvailable()
}
});
} catch (err) { console.error('[LearningAdmin]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
// ============================================================
// EMBEDDINGS — Backfill & Management
// ============================================================
// Generate embeddings for all content (or just missing ones)
router.post('/embeddings/generate', async function(req, res) {
try {
if (!isEmbeddingsAvailable()) {
return res.status(400).json({ error: 'Embeddings not configured. Set LITELLM_API_BASE, VERTEX_PROJECT, or OPENAI_API_KEY' });
}
var { regenerateAll } = req.body;
// Get content without embeddings (or all if regenerateAll=true)
var whereClause = regenerateAll ? '' : 'WHERE embedding IS NULL';
var content = await db.all(
'SELECT id, title, subject, body FROM learning_content ' + whereClause + ' ORDER BY id ASC',
[]
);
if (content.length === 0) {
return res.json({ success: true, message: 'All content already has embeddings', processed: 0 });
}
// Process in background
var processed = 0;
var failed = 0;
console.log('[Embeddings] Starting batch generation for ' + content.length + ' items...');
// Don't await — run in background
async function generateEmbeddingsInBackground() {
for (var i = 0; i < content.length; i++) {
var item = content[i];
try {
if (!item.body || !item.body.trim()) {
console.log('[Embeddings] Skipping empty content ID:', item.id);
continue;
}
var embedding = await generateContentEmbedding(item);
await db.query(
'UPDATE learning_content SET embedding = $1 WHERE id = $2',
[JSON.stringify(embedding), item.id]
);
processed++;
console.log('[Embeddings] Generated ' + processed + '/' + content.length + ' (ID: ' + item.id + ')');
} catch (err) {
failed++;
console.error('[Embeddings] Failed for content ID ' + item.id + ':', err.message);
}
}
console.log('[Embeddings] Batch complete: ' + processed + ' succeeded, ' + failed + ' failed');
// Create index if we have enough embeddings now
if (processed >= 10) {
try {
var lists = Math.max(10, Math.floor(Math.sqrt(processed)));
await db.query(
'CREATE INDEX IF NOT EXISTS idx_learning_content_embedding ON learning_content USING ivfflat (embedding vector_cosine_ops) WITH (lists = ' + lists + ')'
);
console.log('[Embeddings] Vector index created/updated');
} catch (e) {
console.error('[Embeddings] Index creation failed:', e.message);
}
}
}
generateEmbeddingsInBackground();
res.json({
success: true,
message: 'Embedding generation started in background',
total: content.length
});
} catch (err) {
console.error('[LearningAdmin]', err.message);
res.status(500).json({ error: 'Request failed' });
}
});
// Check embedding status
router.get('/embeddings/status', async function(req, res) {
try {
var total = await db.get('SELECT COUNT(*) as count FROM learning_content', []);
var withEmbeddings = await db.get('SELECT COUNT(*) as count FROM learning_content WHERE embedding IS NOT NULL', []);
var missing = parseInt(total.count) - parseInt(withEmbeddings.count);
res.json({
success: true,
enabled: isEmbeddingsAvailable(),
total: parseInt(total.count),
withEmbeddings: parseInt(withEmbeddings.count),
missing: missing,
model: process.env.EMBEDDING_MODEL || 'vertex_ai/text-embedding-005',
dimensions: parseInt(process.env.EMBEDDING_DIMENSIONS) || 768
});
} catch (err) {
res.status(500).json({ error: 'Request failed' });
}
});
module.exports = router;

View file

@ -1,369 +0,0 @@
// ============================================================
// LEARNING HUB ROUTES — User-facing content & quizzes
// ============================================================
var express = require('express');
var router = express.Router();
var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth');
var { searchSimilar, isEmbeddingsAvailable } = require('../utils/embeddings');
router.use(authMiddleware);
// ============================================================
// GET CATEGORIES
// ============================================================
router.get('/categories', async function(req, res) {
try {
var categories = await db.all(
'SELECT id, name, slug, description FROM learning_categories ORDER BY sort_order ASC, name ASC',
[]
);
res.json({ success: true, categories: categories });
} catch (err) { console.error('[LearningHub]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
// ============================================================
// GET LATEST CONTENT (feed)
// ============================================================
router.get('/feed', async function(req, res) {
try {
var limit = Math.min(parseInt(req.query.limit) || 20, 50);
var offset = parseInt(req.query.offset) || 0;
var content = await db.all(
`SELECT c.id, c.title, c.slug, c.subject, c.content_type, c.created_at, c.updated_at,
cat.name as category_name, cat.slug as category_slug,
u.name as author_name,
(SELECT COUNT(*) FROM learning_questions q WHERE q.content_id = c.id) as question_count
FROM learning_content c
LEFT JOIN learning_categories cat ON c.category_id = cat.id
LEFT JOIN users u ON c.author_id = u.id
WHERE c.published = true
ORDER BY c.created_at DESC
LIMIT ? OFFSET ?`,
[limit, offset]
);
var total = await db.get('SELECT COUNT(*) as count FROM learning_content WHERE published = true', []);
res.json({ success: true, content: content, total: total ? parseInt(total.count) : 0 });
} catch (err) { console.error('[LearningHub]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
// ============================================================
// GET CONTENT BY CATEGORY
// ============================================================
router.get('/category/:slug', async function(req, res) {
try {
var cat = await db.get('SELECT * FROM learning_categories WHERE slug = ?', [req.params.slug]);
if (!cat) return res.status(404).json({ error: 'Category not found' });
var content = await db.all(
`SELECT c.id, c.title, c.slug, c.subject, c.content_type, c.created_at, c.updated_at,
u.name as author_name,
(SELECT COUNT(*) FROM learning_questions q WHERE q.content_id = c.id) as question_count
FROM learning_content c
LEFT JOIN users u ON c.author_id = u.id
WHERE c.category_id = ? AND c.published = true
ORDER BY c.created_at DESC`,
[cat.id]
);
res.json({ success: true, category: cat, content: content });
} catch (err) { console.error('[LearningHub]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
// ============================================================
// GET SINGLE CONTENT (with questions)
// ============================================================
router.get('/content/:slug', async function(req, res) {
try {
var item = await db.get(
`SELECT c.*, cat.name as category_name, cat.slug as category_slug, u.name as author_name
FROM learning_content c
LEFT JOIN learning_categories cat ON c.category_id = cat.id
LEFT JOIN users u ON c.author_id = u.id
WHERE c.slug = ? AND c.published = true`,
[req.params.slug]
);
if (!item) return res.status(404).json({ error: 'Content not found' });
// Get questions + options
var questions = await db.all(
'SELECT * FROM learning_questions WHERE content_id = ? ORDER BY sort_order ASC',
[item.id]
);
for (var i = 0; i < questions.length; i++) {
var options = await db.all(
'SELECT id, option_text, sort_order FROM learning_options WHERE question_id = ? ORDER BY sort_order ASC',
[questions[i].id]
);
questions[i].options = options;
}
// Get user's past attempts
var progress = await db.all(
'SELECT score, total, completed_at FROM learning_progress WHERE user_id = ? AND content_id = ? ORDER BY completed_at DESC LIMIT 5',
[req.user.id, item.id]
);
item.questions = questions;
item.progress = progress;
res.json({ success: true, content: item });
} catch (err) { console.error('[LearningHub]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
// ============================================================
// RENDER PRESENTATION SLIDES (user-accessible)
// ============================================================
router.get('/content/:slug/slides', async function(req, res) {
try {
var item = await db.get(
'SELECT body, content_type FROM learning_content WHERE slug = ? AND published = true',
[req.params.slug]
);
if (!item) return res.status(404).json({ error: 'Content not found' });
if (item.content_type !== 'presentation') return res.status(400).json({ error: 'Not a presentation' });
var { Marp } = require('@marp-team/marp-core');
var marp = new Marp({ html: false });
var { html, css } = marp.render(item.body || '');
var slides = [];
var sectionReg = /<section[^>]*>[\s\S]*?<\/section>/g;
var match;
while ((match = sectionReg.exec(html)) !== null) slides.push(match[0]);
if (slides.length === 0) slides.push(html);
res.json({ success: true, css: css, slides: slides });
} catch (err) { res.status(500).json({ error: 'Request failed' }); }
});
// ============================================================
// SUBMIT QUIZ ANSWERS
// ============================================================
router.post('/submit-quiz', async function(req, res) {
try {
var { contentId, answers } = req.body;
// answers = [ { questionId: 1, optionId: 5 }, ... ]
if (!contentId || !answers || !Array.isArray(answers)) {
return res.status(400).json({ error: 'contentId and answers array required' });
}
// Get all questions for this content (include question_type)
var questions = await db.all(
'SELECT id, question_text, question_type, explanation as general_explanation FROM learning_questions WHERE content_id = ? ORDER BY sort_order ASC',
[contentId]
);
var results = [];
var score = 0;
for (var i = 0; i < questions.length; i++) {
var q = questions[i];
var userAnswer = answers.find(function(a) { return a.questionId === q.id; });
// Get all options with correct flag
var options = await db.all(
'SELECT id, option_text, is_correct, explanation FROM learning_options WHERE question_id = ? ORDER BY sort_order ASC',
[q.id]
);
var isCorrect, resultEntry;
if (q.question_type === 'multi') {
var selectedIds = (userAnswer && Array.isArray(userAnswer.optionIds)) ? userAnswer.optionIds.map(Number) : [];
var correctIds = options.filter(function(o) { return o.is_correct; }).map(function(o) { return o.id; });
var allCorrectChosen = correctIds.length > 0 && correctIds.every(function(id) { return selectedIds.indexOf(id) !== -1; });
var noWrongChosen = selectedIds.every(function(id) { return correctIds.indexOf(id) !== -1; });
isCorrect = allCorrectChosen && noWrongChosen && selectedIds.length > 0;
if (isCorrect) score++;
resultEntry = {
questionId: q.id,
questionType: 'multi',
questionText: q.question_text,
selectedOptionIds: selectedIds,
correctOptionIds: correctIds,
isCorrect: isCorrect,
generalExplanation: q.general_explanation || ''
};
} else {
var selectedOptionId = userAnswer ? userAnswer.optionId : null;
var correctOption = options.find(function(o) { return o.is_correct; });
var selectedOption = selectedOptionId ? options.find(function(o) { return o.id === selectedOptionId; }) : null;
isCorrect = selectedOption ? selectedOption.is_correct : false;
if (isCorrect) score++;
resultEntry = {
questionId: q.id,
questionType: q.question_type,
questionText: q.question_text,
selectedOptionId: selectedOptionId,
isCorrect: isCorrect,
correctOptionId: correctOption ? correctOption.id : null,
correctOptionText: correctOption ? correctOption.option_text : '',
selectedExplanation: selectedOption && !isCorrect ? selectedOption.explanation : '',
generalExplanation: q.general_explanation || ''
};
}
results.push(resultEntry);
}
// Save progress
await db.run(
'INSERT INTO learning_progress (user_id, content_id, score, total) VALUES (?, ?, ?, ?)',
[req.user.id, contentId, score, questions.length]
);
res.json({
success: true,
score: score,
total: questions.length,
percentage: questions.length > 0 ? Math.round((score / questions.length) * 100) : 0,
results: results
});
} catch (err) { console.error('[LearningHub]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
// ============================================================
// SEARCH CONTENT (keyword-based)
// ============================================================
router.get('/search', async function(req, res) {
try {
var q = (req.query.q || '').trim();
if (!q) return res.json({ success: true, content: [] });
var pattern = '%' + q + '%';
var content = await db.all(
`SELECT c.id, c.title, c.slug, c.subject, c.content_type, c.created_at,
cat.name as category_name, cat.slug as category_slug,
(SELECT COUNT(*) FROM learning_questions lq WHERE lq.content_id = c.id) as question_count
FROM learning_content c
LEFT JOIN learning_categories cat ON c.category_id = cat.id
WHERE c.published = true AND (c.title ILIKE ? OR c.subject ILIKE ? OR c.body ILIKE ?)
ORDER BY c.created_at DESC LIMIT 30`,
[pattern, pattern, pattern]
);
res.json({ success: true, content: content, method: 'keyword' });
} catch (err) { console.error('[LearningHub]', err.message); res.status(500).json({ error: 'Internal server error' }); }
});
// ============================================================
// SEMANTIC SEARCH (vector-based)
// ============================================================
router.get('/search/semantic', async function(req, res) {
try {
if (!isEmbeddingsAvailable()) {
return res.status(400).json({ error: 'Semantic search not available. Embeddings not configured.' });
}
var q = (req.query.q || '').trim();
if (!q) return res.json({ success: true, content: [], method: 'semantic' });
var limit = Math.min(parseInt(req.query.limit) || 10, 50);
var threshold = parseFloat(req.query.threshold) || 0.5;
var results = await searchSimilar(q, {
limit: limit,
threshold: threshold,
contentType: req.query.contentType || null
});
res.json({
success: true,
content: results,
method: 'semantic',
query: q
});
} catch (err) {
console.error('[LearningHub] Semantic search error:', err.message);
res.status(500).json({ error: 'Request failed' });
}
});
// ============================================================
// HYBRID SEARCH (keyword + semantic combined)
// ============================================================
router.get('/search/hybrid', async function(req, res) {
try {
var q = (req.query.q || '').trim();
if (!q) return res.json({ success: true, content: [], method: 'hybrid' });
var limit = Math.min(parseInt(req.query.limit) || 20, 50);
// 1. Get keyword matches
var pattern = '%' + q + '%';
var keywordResults = await db.all(
`SELECT c.id, c.title, c.slug, c.subject, c.content_type, c.created_at,
cat.name as category_name, cat.slug as category_slug,
(SELECT COUNT(*) FROM learning_questions lq WHERE lq.content_id = c.id) as question_count,
1.0 as score, 'keyword' as match_type
FROM learning_content c
LEFT JOIN learning_categories cat ON c.category_id = cat.id
WHERE c.published = true AND (c.title ILIKE ? OR c.subject ILIKE ? OR c.body ILIKE ?)
LIMIT 15`,
[pattern, pattern, pattern]
);
// 2. Get semantic matches (if available)
var semanticResults = [];
if (isEmbeddingsAvailable()) {
try {
semanticResults = await searchSimilar(q, {
limit: 15,
threshold: 0.4
});
// Add match_type
semanticResults = semanticResults.map(function(r) {
return Object.assign(r, { score: r.similarity, match_type: 'semantic' });
});
} catch (err) {
console.error('[LearningHub] Semantic search in hybrid failed:', err.message);
}
}
// 3. Merge and deduplicate (favor semantic if both match)
var seen = {};
var combined = [];
// Add semantic results first (higher quality)
semanticResults.forEach(function(r) {
if (!seen[r.id]) {
seen[r.id] = true;
combined.push(r);
}
});
// Add keyword results if not already included
keywordResults.forEach(function(r) {
if (!seen[r.id]) {
seen[r.id] = true;
combined.push(r);
}
});
// Sort by score descending, limit results
combined.sort(function(a, b) { return (b.score || 0) - (a.score || 0); });
combined = combined.slice(0, limit);
res.json({
success: true,
content: combined,
method: 'hybrid',
query: q,
keywordCount: keywordResults.length,
semanticCount: semanticResults.length
});
} catch (err) {
console.error('[LearningHub] Hybrid search error:', err.message);
res.status(500).json({ error: 'Request failed' });
}
});
module.exports = router;

View file

@ -38,7 +38,7 @@ var AI_CONTEXT_CATEGORIES = [
// ── GET all memories for current user ───────────────────────────────────
router.get('/memories', async function(req, res) {
try {
// Legacy correction_* rows from the removed Dragon-style learning feature
// Legacy correction_* rows from the removed Dragon-style correction feature
// are filtered out — invisible to UI and to the AI context endpoint, but
// not dropped from the table.
var rows = await db.all(

View file

@ -2,11 +2,13 @@
// MY RESOURCES
// A person's own generated teaching material.
//
// Deliberately a separate pathway from Learning. Learning is moderator-owned:
// content published into categories that everyone sees. This is the other
// thing — somewhere any signed-in user can generate a deck for tomorrow's
// session, keep it, refine it and export it, without it becoming institutional
// content and without needing to be a moderator to do it at all.
// Private to whoever made it. Somewhere any signed-in user can generate a deck
// for tomorrow's session, keep it, refine it and export it, without it becoming
// institutional content and without needing a role to do it at all.
//
// It began as the counterpart to a moderator-owned Learning Hub; that was
// retired once this generated better slides, and this is now the only place
// teaching material is made.
//
// Nothing here is shared. Every statement filters on the owner, and there is no
// route that returns another person's work. Sharing, if it is ever wanted,

View file

@ -20,7 +20,7 @@ var JSZip = require('jszip');
var slideSpec = require('./slideSpec');
var docSpec = require('./docSpec');
var REFERENCE_DECK = pathMod.join(__dirname, '..', '..', 'assets', 'learning', 'slides-reference.pptx');
var REFERENCE_DECK = pathMod.join(__dirname, '..', '..', 'assets', 'deck', 'slides-reference.pptx');
var DECK_RENDERER = pathMod.join(__dirname, '..', '..', 'scripts', 'render_pptx.py');
var DOC_RENDERER = pathMod.join(__dirname, '..', '..', 'scripts', 'render_docx.py');
var GOTENBERG = process.env.GOTENBERG_URL || 'http://gotenberg:3000';

View file

@ -78,76 +78,6 @@ async function generateEmbeddingLiteLLM(text, model, dimensions) {
* @param {object} opts - Options: { limit, threshold, contentType }
* @returns {Promise<Array>} - Matching content with similarity scores
*/
async function searchSimilar(queryText, opts) {
opts = opts || {};
var limit = opts.limit || 10;
var threshold = opts.threshold || 0.5; // Cosine similarity threshold (0-1)
var db = require('../db/database');
// Generate embedding for query
var queryEmbedding = await generateEmbedding(queryText);
// Build WHERE clause for filtering
var whereClause = 'WHERE c.published = true AND c.embedding IS NOT NULL';
var params = [JSON.stringify(queryEmbedding), threshold, limit];
var paramIdx = 4;
if (opts.contentType) {
whereClause += ' AND c.content_type = $' + paramIdx;
params.push(opts.contentType);
paramIdx++;
}
if (opts.categoryId) {
whereClause += ' AND c.category_id = $' + paramIdx;
params.push(opts.categoryId);
}
// Query with cosine similarity using pgvector
// 1 - (a <=> b) converts distance to similarity (higher = more similar)
var sql = `
SELECT
c.id, c.title, c.slug, c.subject, c.content_type, c.created_at,
cat.name as category_name, cat.slug as category_slug,
1 - (c.embedding <=> $1::vector) as similarity,
(SELECT COUNT(*) FROM learning_questions q WHERE q.content_id = c.id) as question_count
FROM learning_content c
LEFT JOIN learning_categories cat ON c.category_id = cat.id
${whereClause}
AND 1 - (c.embedding <=> $1::vector) >= $2
ORDER BY c.embedding <=> $1::vector
LIMIT $3
`;
var results = await db.all(sql, params);
return results;
}
/**
* Generate embedding for learning content (combines title + subject + body)
* @param {object} content - { title, subject, body }
* @returns {Promise<number[]>} - Embedding vector
*/
async function generateContentEmbedding(content) {
// Combine title, subject, and body (weighted toward title)
var text = [
content.title || '',
content.title || '', // Title twice for emphasis
content.subject || '',
stripHtml(content.body || '').substring(0, 6000)
].filter(Boolean).join('\n\n');
return await generateEmbedding(text);
}
/**
* Strip HTML tags from string
*/
function stripHtml(html) {
return html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
}
function getLiteLLMModelId(model) {
if (!model) return '';
if (typeof model === 'string') return model;
@ -185,9 +115,7 @@ function isEmbeddingsAvailable() {
module.exports = {
generateEmbedding,
generateContentEmbedding,
getLiteLLMEmbeddingModels,
searchSimilar,
isEmbeddingsAvailable,
isLiteLLMEmbeddingModel,
DEFAULT_MODEL,

View file

@ -18,17 +18,4 @@ async function validateChat(db, payload, owner) {
const rows = await db.query("SELECT id FROM generated_image_jobs WHERE id=ANY($1::uuid[]) AND owner_id=$2 AND workflow='clinical_assistant'", [[...ids], owner]);
if (rows.rows.length !== ids.size) throw failure(403, 'Saved image references must belong to this account and Clinical Assistant');
}
// Called INSIDE the content write transaction: publication and grants commit together.
async function validateLearning(client, body, owner, contentId) {
const ids = references(body);
if (!ids.length) return ids;
const rows = await client.query(`SELECT j.id FROM generated_image_jobs j WHERE j.id=ANY($1::uuid[]) AND j.workflow='learning_hub' AND j.stage='done'
AND (j.owner_id=$2 OR EXISTS (SELECT 1 FROM generated_image_links l WHERE l.asset_id=j.id AND l.content_id=$3)) FOR SHARE`, [ids, owner, contentId || null]);
if (rows.rows.length !== ids.length) throw failure(403, 'Only your Learning Hub assets, or assets already attached to this content, may be attached. Clinical/private-chat images cannot be published.');
return ids;
}
async function setLinks(client, contentId, ids) {
await client.query('DELETE FROM generated_image_links WHERE content_id=$1', [contentId]);
for (const id of ids) await client.query('INSERT INTO generated_image_links(asset_id,content_id) VALUES($1,$2)', [id, contentId]);
}
module.exports = { references, validateChat, validateLearning, setLinks };
module.exports = { references, validateChat };

View file

@ -9,7 +9,7 @@ const fileLog = require('./fileLog');
const MAX_IMAGE_MODELS = 3;
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const failure = (statusCode, message) => Object.assign(new Error(message), { statusCode });
const workflows = ['clinical_assistant', 'learning_hub', 'my_resources'];
const workflows = ['clinical_assistant', 'my_resources'];
function budgetLimit(value) {
const n = value == null || value === '' ? 32000 : Number(value);
if ((value != null && !['string', 'number'].includes(typeof value)) || !Number.isInteger(n) || n < 1000 || n > 32000) throw failure(400, 'Image budget must be 1000..32000 UTF-16 code units');
@ -137,11 +137,13 @@ function createImageService({ db, storage, generate = provider, encryption = req
if (generate === provider && !env.LITELLM_API_BASE) throw failure(503, 'Image gateway is not configured; no image provider request was sent');
if (!encryption.hasKey()) throw failure(503, 'Generated images require encryption configuration');
try {
// A readiness probe: every column the service touches, selected once so a
// missing migration fails here rather than mid-job with a paid request in
// flight. LIMIT 0 returns no rows and does no work.
await db.query(`SELECT j.id,j.owner_id,j.workflow,j.idempotency_key,j.input_hash,j.prompt_cipher,j.model,j.prompt_revision,
j.budget,j.prompt_units,j.context_included,j.context_total,j.stage,j.staged_bytes,j.lease_token,j.lease_until,
j.mime,j.checksum,j.byte_length,j.error_code,j.created_at,j.updated_at,l.asset_id,l.content_id,c.id,c.published
FROM generated_image_jobs j LEFT JOIN generated_image_links l ON l.asset_id=j.id
LEFT JOIN learning_content c ON c.id=l.content_id LIMIT 0`);
j.mime,j.checksum,j.byte_length,j.error_code,j.created_at,j.updated_at
FROM generated_image_jobs j LIMIT 0`);
await getStorage().ready();
} catch (_) { throw failure(503, 'Generated image storage or migration unavailable; no image provider request was sent'); }
}
@ -153,7 +155,7 @@ function createImageService({ db, storage, generate = provider, encryption = req
const settings = Object.fromEntries(result.rows.map(r => [r.key, r.value]));
const model = modelOverride || settings[workflow + '.image_model'] || (workflow === 'clinical_assistant' ? env.CLINICAL_ASSISTANT_IMAGE_MODEL || 'openai-gpt-image-1' : '');
const fallback = workflow === 'clinical_assistant' ? String(settings[workflow + '.fallback_image_model'] || '') : '';
if (!model) throw failure(503, 'Configure the Learning Hub image model in administration first');
if (!model) throw failure(503, 'Configure an image model for this workflow in administration first');
const budget = budgetLimit(settings[workflow + '.image_budget']);
const bound = context ? imageContext(context.request, context.history) : imageContext(parsed.prompt);
const request = 'Original image request:\n' + bound.request + (context && parsed.prompt !== bound.request ? '\n\nImage tool description:\n' + parsed.prompt : '');
@ -359,9 +361,12 @@ async function warmThumbs(id) {
}
async function asset(id, user) {
if (!UUID.test(id)) throw failure(404, 'Image not found');
const result = await db.query(`SELECT j.checksum,j.byte_length,j.mime FROM generated_image_jobs j WHERE j.id=$1 AND j.stage='done' AND
(j.owner_id=$2 OR (j.workflow='learning_hub' AND EXISTS(SELECT 1 FROM generated_image_links l JOIN learning_content c ON c.id=l.content_id
WHERE l.asset_id=j.id AND (c.published=true OR $3::boolean))))`, [id, user.id, ['admin','moderator'].includes(user.role)]);
// Owner only. There used to be a second way in: an image attached to
// published Learning Hub content was readable by anyone. That feature is
// gone, and with it the only case where a generated image was ever visible
// to someone who did not make it.
const result = await db.query(`SELECT j.checksum,j.byte_length,j.mime FROM generated_image_jobs j
WHERE j.id=$1 AND j.stage='done' AND j.owner_id=$2`, [id, user.id]);
if (!result.rows[0]) throw failure(404, 'Image not found');
const image = await getStorage().get(id);
if (image.checksum !== result.rows[0].checksum || image.bytes.length !== result.rows[0].byte_length || image.mime !== result.rows[0].mime) throw failure(503, 'Image integrity check failed');

View file

@ -3,7 +3,7 @@
// Multiple figures for one generated resource.
// ============================================================
// Deliberately not imageTool.dispatch. That one is shared with the clinical
// assistant and the Learning Hub and permits exactly one image per request —
// assistant and permits exactly one image per request —
// "Only one image tool invocation is permitted per request" — which is the
// right rule for a chat reply and the wrong one for a twelve-slide deck where
// an author has asked for three figures.

View file

@ -590,7 +590,7 @@ test('actual cookie-authenticated B callers never send a valid leftover A token'
b.c.showToast = b.c.showLoading = b.c.hideLoading = b.c.showBusy = b.c.hideBusy = () => {};
b.c.copyText = () => {};
fakeIndexedDB(b.c, new Map()); b.module('public/js/audioBackup.js');
b.module('public/js/documents.js'); b.module('public/js/learningHub.js');
b.module('public/js/documents.js'); b.module('public/js/myResources.js');
b.calls.length = 0;
// Synthetic server uses the production Bearer-first precedence. A remains valid.
b.c.api = async (url, init) => {
@ -600,7 +600,6 @@ test('actual cookie-authenticated B callers never send a valid leftover A token'
if (init.body instanceof b.c.FormData) assert.equal(init.headers?.['Content-Type'], undefined, url);
if (url.endsWith('/audio')) return { ...response({}), blob: async () => new b.c.Blob(['B audio']) };
if (url === '/api/transcribe/status') return response({ available: true });
if (url === '/api/admin/learning/ai-generate') return response({ success: false });
return response({ success: true, id: 9, backups: [{ id: 9 }], text: 'B transcript', s3_configured: true, documents: [] });
};
await b.c.getAudioBackups();
@ -611,15 +610,14 @@ test('actual cookie-authenticated B callers never send a valid leftover A token'
assert.equal(b.c._transcribeAvailable, true);
await b.c.transcribeAudio(new b.c.Blob(['B']));
const controls = b.w.document.createElement('div');
controls.innerHTML = '<button data-action="copy" data-target="fixture-copy">Copy</button><input id="doc-file-input" type="file"><button id="btn-doc-upload">Upload</button><div id="documents-list"></div><input id="lh-ai-model" value="fake"><input id="lh-ai-topic" value="Synthetic topic"><button id="btn-lh-ai-generate">Generate</button>';
controls.innerHTML = '<button data-action="copy" data-target="fixture-copy">Copy</button><input id="doc-file-input" type="file"><button id="btn-doc-upload">Upload</button><div id="documents-list"></div>';
b.w.document.body.append(controls);
Object.defineProperty(controls.querySelector('#doc-file-input'), 'files', { value: [new b.w.File(['B'], 'fixture.txt')] });
controls.querySelector('[data-action]').click();
controls.querySelector('#btn-doc-upload').click();
controls.querySelector('#btn-lh-ai-generate').click();
await tick();
const reached = b.calls.map(call => call.url);
for (const url of ['/api/audio-backups', '/api/audio-backups/9/audio', '/api/audio-backups/9', '/api/transcribe/status', '/api/transcribe', '/api/logs/client-event', '/api/documents/upload', '/api/admin/learning/ai-generate']) assert.ok(reached.includes(url), url);
for (const url of ['/api/audio-backups', '/api/audio-backups/9/audio', '/api/audio-backups/9', '/api/transcribe/status', '/api/transcribe', '/api/logs/client-event', '/api/documents/upload']) assert.ok(reached.includes(url), url);
// Also assert recorded headers: callers may intentionally catch transport rejection.
for (const { url, init } of b.calls) {
assert.equal(init.headers?.Authorization, undefined, url);

View file

@ -92,20 +92,6 @@ test('opening the assistant replaces the main menu with the saved chats', async
assert.ok(!app.document.body.classList.contains('assistant-workspace'), 'leaving the assistant restores the app chrome');
});
test('Learning Hub is a top-level main-menu entry, not part of the assistant workspace', () => {
const indexHtml = read('public/index.html');
assert.match(indexHtml, /data-tab="learning">[\s\S]*?<span>Learning Hub<\/span>/, 'Learning Hub button in the sidebar');
assert.match(indexHtml, /<section id="learning-tab" class="tab-content" data-component="learning"><\/section>/, 'learning tab section exists');
assert.match(indexHtml, /<section id="assistant-tab" class="tab-content" data-component="assistant"><\/section>/, 'assistant tab is a clean standalone section');
});
test('the assistant tab keeps its clean structure next to the restored learning tab', () => {
const indexHtml = read('public/index.html');
assert.match(indexHtml, /<script src="\/vendor\/katex\/katex\.min\.js" defer><\/script>[\s\S]*<script src="\/vendor\/katex\/contrib\/mhchem\.min\.js" defer><\/script>/, 'mhchem loads right after katex');
assert.match(indexHtml, /<section id="assistant-tab" class="tab-content" data-component="assistant"><\/section>/);
assert.match(indexHtml, /<section id="learning-tab" class="tab-content" data-component="learning"><\/section>/);
});
test('one menu toggle serves both the app sidebar and the assistant rail', () => {
const index = read('public/index.html');
const rail = read('public/components/assistant.html');
@ -219,16 +205,6 @@ test('Go back is gone: Workspace is how you leave the assistant', () => {
assert.doesNotMatch(js, /goBackToMainMenu/, 'and no dead handler left behind');
});
test('Learning Hub sits with the content tools, not above the clinical ones', () => {
const fs = require('node:fs');
const path = require('node:path');
const index = fs.readFileSync(path.join(__dirname, '..', 'public/index.html'), 'utf8');
const order = [...index.matchAll(/data-tab="([a-z]+)"/g)].map(m => m[1]);
assert.ok(order.indexOf('learning') > order.indexOf('encounter'), 'moved down past the clinical tabs');
assert.equal(order[order.indexOf('learning') + 1], 'cms', 'and sits immediately before Content Manager');
});
test('the Assistant/Workspace switch is the same control in both places', () => {
const root = path.join(__dirname, '..');
const index = read('public/index.html');

View file

@ -36,23 +36,20 @@ test('URL safety helper blocks private network targets', () => {
});
test('Nextcloud/WebDAV routes enforce SSRF guard and redirect blocking', () => {
// One route reaches Nextcloud now. The second caller was Learning Hub's
// WebDAV browser, retired with it.
const nextcloud = read('src/routes/nextcloud.js');
const learningAI = read('src/routes/learningAI.js');
assert.match(nextcloud, /assertSafeHttpsUrl\(cleanUrl, 'Nextcloud URL'\)/);
assert.match(nextcloud, /assertSafeHttpsUrl\(user\.nextcloud_url, 'Nextcloud URL'\)/);
assert.match(nextcloud, /maxRedirects: 0/);
assert.match(nextcloud, /encodeURIComponent\(username\)/);
assert.match(learningAI, /assertSafeHttpsUrl\(user\.nextcloud_url, 'Nextcloud URL'\)/);
assert.match(learningAI, /maxRedirects: 0/);
assert.match(learningAI, /encodeURIComponent\(user\.nextcloud_user\)/);
});
test('logs and audits avoid unbounded limits and PHI-prone details', () => {
const logs = read('src/routes/logs.js');
const encounters = read('src/routes/encounters.js');
const documents = read('src/routes/documents.js');
const learningAI = read('src/routes/learningAI.js');
assert.match(logs, /function clampLimit/);
assert.match(logs, /clampLimit\(req\.query\.limit, 50, 200\)/);
@ -63,7 +60,6 @@ test('logs and audits avoid unbounded limits and PHI-prone details', () => {
assert.doesNotMatch(encounters, /Saved encounter: ' \+ \(label/);
assert.doesNotMatch(encounters, /Loaded encounter: ' \+ \(row\.label/);
assert.doesNotMatch(documents, /Uploaded: ' \+ \(req\.file/);
assert.doesNotMatch(learningAI, /Char context/);
});
// adminMiddleware only checks req.user.role; without authMiddleware having run,
@ -316,61 +312,3 @@ test('citation quality is measured on the server, where answer and sources both
assert.match(route, /tracker\.store\(req\.user\.id, question, result, sources\);/);
});
test('slides are built by pandoc, from markdown, with a reference template', () => {
// pptxgenjs is gone. It stretched every image — reading the slide XML it
// emitted showed the target box verbatim with <a:stretch/>, so a 200x800
// image in an 11.8x3.9 box came out 1:4 squashed to 3:1 — and it could not
// do better, because it never measures an image; its own getSizeFromImage is
// commented out as unused. pandoc measures them: rendered and inspected, a
// 300x175 source produced aspect 1.714 and a 160x360 produced 0.445.
//
// Removing it also took image-size with it, and with that both high-severity
// advisories (GHSA-w3rx-r6r6-pgpr, GHSA-5p2g-fcmc-qvqq), which had no fixed
// release to upgrade to.
const src = read('src/routes/learningAI.js');
assert.doesNotMatch(src, /pptxgenjs|PptxGenJS/, 'nothing builds slides by hand any more');
assert.match(src, /execFile\('pandoc', \['deck\.md', '--reference-doc=' \+ reference, '-o', 'deck\.pptx'\]/);
assert.match(src, /timeout: 60000/, 'a conversion cannot hang the request');
// Design lives in the template, so restyling is editing a file in PowerPoint.
assert.match(src, /assets', 'learning', 'slides-reference\.pptx'/);
assert.ok(require('node:fs').existsSync(
require('node:path').join(__dirname, '..', 'assets', 'learning', 'slides-reference.pptx')),
'and that template ships with the app');
// pandoc must be in the image, or every export fails at runtime.
assert.match(read('Dockerfile'), /apk add --no-cache ffmpeg curl jq pandoc-cli/);
const pkg = JSON.parse(read('package.json'));
assert.ok(!pkg.dependencies['pptxgenjs'], 'and the library is no longer a dependency');
assert.ok(!pkg.dependencies['image-size'], 'nor the parser it dragged in');
});
test('a deck can only embed images the requester owns', () => {
// pandoc resolves an image link against the filesystem, so a markdown link
// naming any local path would read that file into the deck. Only the images
// fetched by id for this user are written into the working directory, and
// every other image link is dropped rather than passed through.
const src = read('src/routes/learningAI.js');
assert.match(src, /var image = await require\('\.\.\/utils\/generatedImages'\)\.service\(\)\.asset\(id, req\.user\);/,
'ownership is checked when fetching');
assert.match(src, /return local \? '!\[' \+ alt \+ '\]\(' \+ local \+ '\)' : '';/,
'an unresolved link is removed, not passed to pandoc');
// The working directory is per request and always cleaned up.
assert.match(src, /workdir = await fsp\.mkdtemp\(/);
assert.match(src, /\} finally \{[\s\S]{0,200}rm\(workdir, \{ recursive: true, force: true \}\)/);
});
test('the slide prompt carries the rules pandoc actually enforces', () => {
// Found by generating a deck with ds-deepseek-v4-flash and rendering it: the
// model produced exactly the 6 headings asked for, but the deck came out with
// 8 slides. pandoc splits a slide after a table, and the remainder becomes an
// untitled orphan. A table with no blank line before it is not parsed as a
// table at all — it renders as literal pipe characters.
const src = read('src/routes/learningAI.js');
assert.match(src, /A slide that contains a table must contain ONLY that table/);
assert.match(src, /Leave a blank line before and after every table/);
// And the overfull slide in that same test: a nested ordered list inside a
// bullet ran past the bottom of the slide.
assert.match(src, /do not put an ordered list\s*\n\s*inside a bullet/);
});

View file

@ -29,7 +29,7 @@ const passthrough = {
}
};
test('Learning retrieves with its own budget, not the assistants', async () => {
test('My Resources retrieves with its own budget, not the assistants', async () => {
let captured = null;
const lib = load({
...passthrough,
@ -106,45 +106,3 @@ test('excerpts are numbered the way the assistant numbers them', async () => {
assert.match(out.context, /\[2\] RSV guidance\nPeak 3-6 months\./);
assert.equal(out.reason, null);
});
test('the corpus block tells the model to prefer it over recall', () => {
const route = read('src/routes/learningAI.js');
assert.match(route, /Prefer them over your own recall wherever they disagree/);
assert.match(route, /reference material, not a template/, 'so it writes rather than copies');
// Provenance a clinician can check, on the deck itself rather than only in a
// log. Restricted to excerpts actually used, and explicitly not invented.
// References belong at the end, never in the body: a slide carrying [1]
// markers is unreadable from the back of a room, and an article reads as
// prose, not as a paper.
assert.match(route, /Do NOT cite in the body: no \[1\] markers, no bracketed numbers/);
assert.match(route, /In a presentation this is the final slide, titled/);
assert.match(route, /'References\. Do not invent references/);
assert.match(route, /Do not invent references, and do not list an excerpt you did not use/);
// Opt in: a resource on something the library does not cover is better
// written without it than padded with the nearest unrelated excerpts.
assert.match(route, /var useCorpus\s+= String\(req\.body\.useCorpus\) === 'true'/);
assert.match(route, /if \(useCorpus\) \{/);
// And every success response says what it was grounded on.
assert.equal((route.match(/grounding: \{ used: Boolean\(corpus\.context\)/g) || []).length, 3);
});
test('the Learning screen can ask for grounding and says what it got', () => {
const html = read('public/components/cms.html');
const js = read('public/js/learningHub.js');
// Opt-out in the UI rather than opt-in: for clinical teaching the library is
// nearly always the right source, so forgetting the checkbox should give the
// grounded version, not the ungrounded one.
assert.match(html, /<input type="checkbox" id="lh-ai-use-corpus" checked>/);
assert.match(js, /formData\.append\('useCorpus', corpusBox && corpusBox\.checked === false \? 'false' : 'true'\)/);
// The checkbox explains when to turn it OFF, which is the non-obvious case.
assert.match(html, /Turn this off for a topic the\s*\n?\s*library does not cover/);
// And the result is reported. Ungrounded material presented as grounded is
// the failure worth preventing, so the wording must never imply the library
// was used when it was not.
assert.match(js, /'Written from ' \+ g\.count \+ ' library excerpt'/);
assert.match(js, /'Not grounded' \+ \(g\.reason \? ' \\u2014 ' \+ g\.reason : ''\) \+ '\. Written from the model alone\.'/);
assert.match(html, /id="lh-ai-grounding-result"/);
});

View file

@ -120,26 +120,6 @@ test('actual Clinical chat + fragmented stream send real tools to SDK, dispatch
assert.equal(result.imageJobs[0].jobId,id);assert.equal(jobs.length,1);assert.equal(jobs[0][5].request,' Create a clinical diagram for the precise current clinical findings 😀\n');assert.equal(jobs[0][5].history[0].content,'Prior table [3].');assert.equal(requests.length,1);assert.equal(requests[0].tools[0].function.name,'generate_image');
}
});
test('actual Learning generate/refine use callable tools and bind their own workflow (not sidebar heuristics)', async()=>{
for (const [path,content,input] of [
['/ai-generate','{"title":"Teaching","body":"<p>Exact body.</p>","questions":[]}',{topic:'Create a diagram',idempotencyKey:'gen'}],
['/ai-refine','<p>Exact refined body.</p>',{content:'<p>Prior body.</p>',instructions:'Include an image',idempotencyKey:'ref'}]
]) {
const {ai,requests}=loadAI('litellm',undefined,content);const jobs=[];
const request=route('src/routes/learningAI.js',ai,jobs);const response=await request(path,input);
assert.equal(response.statusCode,200,JSON.stringify(response.data));assert.equal(response.data.success,true);assert.equal(response.data.imageJobs[0].jobId,id);
assert.equal(requests.length,1);assert.equal(requests[0].tools[0].function.name,'generate_image');assert.equal(jobs[0][1],'learning_hub');
assert.equal(path==='/ai-refine'?response.data.refined:response.data.content.body,path==='/ai-refine'?input.content:'<p>Exact body.</p>');
}
});
test('Learning tool-only refinement retains every existing body/citation/page byte, including edge whitespace', async () => {
const content = '\n <p>Exact [3, 1].</p>\n<table><tr><td>5 mg</td><td>page 19 [3]</td></tr></table> \n';
const { ai, requests } = loadAI('litellm', undefined, null);
const jobs = []; const request = route('src/routes/learningAI.js', ai, jobs);
const response = await request('/ai-refine', { content, instructions: 'Create a matching diagram', idempotencyKey: 'exact-refine' });
assert.equal(response.statusCode, 200); assert.equal(response.data.refined, content);
assert.equal(requests.length, 1); assert.equal(jobs.length, 1);
});
test('all compatible providers capture tool_choice/parallel cap, fragmented IDs, and unchanged ordinary streams', async () => {
const chunks = [
{ choices: [{ delta: { tool_calls: [{ index: 0, id: 'to', type: 'function', function: { name: 'generate_', arguments: '{"prompt":' } }] } }] },
@ -216,13 +196,3 @@ test('actual OpenAI SDK HTTP capture from Clinical routes carries tools and disp
}
} finally { await new Promise(resolve => server.close(resolve)); }
});
test('Learning image refinement ignores accompanying rewritten HTML/citations/table; text-only refinement still works', async () => {
const original='\n <p>Original [3, 1].</p><table><tr><td>5 mg</td><td>19 [3]</td></tr></table> \n';
for (const withTool of [true,false]) {
const jobs=[];const replacement='<p>ALTERED [9].</p>';
const request=route('src/routes/learningAI.js',{callAI:async()=>({content:replacement,...(withTool?{toolCalls:[{id:'x',type:'function',function:{name:'generate_image',arguments:'{"prompt":"diagram"}'}}]}:{})})},jobs);
const response=await request('/ai-refine',{content:original,instructions:withTool?'Include an image':'Shorten this text'});
assert.equal(response.statusCode,200);assert.equal(response.data.refined,withTool?original:replacement);
assert.equal(response.data.bodyPreserved,withTool);assert.equal(jobs.length,withTool?1:0);
}
});

View file

@ -43,19 +43,6 @@ test('inline image jobs append DOM without altering answer/citation nodes; reope
ui.context.renderImageJobs(root,[{jobId:id}],'clinical_assistant',(card,data)=>{const img=ui.dom.window.document.createElement('img');img.setAttribute('src',data.imageUrl);card.append(img);});
await new Promise(r=>setImmediate(r));assert.equal(root.firstChild,original);assert.equal(original.outerHTML,html);assert.equal(root.querySelector('img').getAttribute('src'),src);ui.dom.window.close();
});
test('actual bundled Tiptap persists/reopens stable generated image attributes without modifying educational text',async()=>{
const dom=new JSDOM('<div id="editor"></div>',{url:'https://synthetic.test',runScripts:'outside-only'});
dom.window.eval(fs.readFileSync('public/vendor/tiptap.bundle.js','utf8'));
const context={document:dom.window.document,queueMicrotask(){},hydrateImage(){},revokeImageUrl(){}};
vm.createContext(context);
const source=fs.readFileSync('public/js/learningHub/images.js','utf8').replace(/^import .*;\n/gm,'').replace(/^export /gm,'');
vm.runInContext(source,context);
const T=dom.window.Tiptap;const extension=context.generatedImageExtension(T);
const editor=new T.Editor({element:dom.window.document.getElementById('editor'),extensions:[T.StarterKit,extension],content:'<p>Exact <strong>teaching</strong> body.</p>'});
const before=editor.getHTML();editor.commands.insertContentAt(editor.state.doc.content.size,{type:'generatedImage',attrs:{src,alt:'Generated teaching visual'}});
const saved=editor.getHTML();assert.ok(saved.startsWith(before));assert.ok(saved.includes(src));assert.ok(!saved.includes('blob:'));
editor.commands.setContent(saved);assert.equal(editor.getHTML(),saved);editor.destroy();dom.window.close();
});
test('clinical interception is removed and export path embeds verified images rather than rewriting answer text',()=>{
const source=fs.readFileSync('public/js/clinicalAssistant.js','utf8');assert.doesNotMatch(source,/if \(isImageRequest|prepareSidebarImagePrompt/);assert.match(source,/attachImageJobs\(loading/);
const exporter=fs.readFileSync('public/js/assistant/export.js','utf8');assert.match(exporter,/images.push\(await imageDataUrl/);assert.match(exporter,/messages.push\(\{ \.\.\.message, images \}\)/);
@ -142,33 +129,6 @@ test('selected sidebar B survives A-done -> B-queued -> save/reopen/export, with
assert.equal(exported.messages[0].images[0],'data-for:'+src);assert.equal(exported.messages[0].content,transcript[0].content);
ui.context.performAutosave();assert.equal(saved.generatedImage,'/api/generated-images/'+b,'the completed sidebar image persists with the chat');assert.equal(saved.generatedImageJobs[0].jobId,b);ui.dom.window.close();
});
test('actual CMS presentation image controls are visible and insertion/save/reopen preserve Markdown; preserved refinement never resets editor', async () => {
const ui=client();ui.dom.window.document.body.innerHTML='<style>.hidden{display:none!important}</style>'+fs.readFileSync('public/components/cms.html','utf8');
ui.dom.window.HTMLElement.prototype.scrollIntoView=()=>{};
let saved,resets=0,html='<p>Original [3].</p>';
const editor={isDestroyed:false,getHTML:()=>html,commands:{setContent(value){resets++;html=value;},clearContent(){html='';}}};
Object.assign(ui.context,{getTpHTML:e=>e.getHTML(),makeTpEditor:()=>editor,clearQuestionBlocks:el=>el.replaceChildren(),showToast(){},showLoading(){},hideLoading(){},showBusy(){},hideBusy(){},getSelectedModel:()=>'',
createSlideController:()=>({}),createCmsController:()=>({refreshAll(){}}),createQuizController:()=>({}),createWebdavController:()=>({}),createAiPanelController:()=>({}),
getJson:async()=>({success:true,content:{...saved,id:1,questions:[]}}),
sendJson:async(url,method,payload)=>{if(url.endsWith('ai-refine'))return {success:true,bodyPreserved:true,refined:'SHOULD NOT RESET',imageJobs:[]};saved=payload;return {success:true,id:1};}
});
vm.runInContext(fs.readFileSync('public/js/learningHub/images.js','utf8').replace(/^import .*;\n/gm,'').replace(/^export /gm,''),ui.context);
vm.runInContext(fs.readFileSync('public/js/learningHub.js','utf8').replace(/^import[\s\S]*?;\n/gm,''),ui.context);
ui.context.openEditor(null,'presentation');const doc=ui.dom.window.document;
const panel=doc.querySelector('[aria-label="Learning Hub images"]');assert.ok(panel);
for(let el=panel;el;el=el.parentElement) assert.notEqual(ui.dom.window.getComputedStyle(el).display,'none',el.id);
const markdown='---\nmarp: true\n---\n# Original [3]\n| Dose | Page |\n| 5 mg | 19 [3] |\n';
doc.getElementById('lh-marp-editor').value=markdown;doc.getElementById('lh-cms-edit-title').value='Presentation';
ui.context.learningImages.show([{jobId:id}]);await new Promise(r=>setImmediate(r));
const insert=[...panel.querySelectorAll('button')].find(b=>b.textContent==='Insert image at end of content');assert.ok(insert);insert.click();
const inserted=doc.getElementById('lh-marp-editor').value;assert.ok(inserted.startsWith(markdown));assert.ok(inserted.includes(']('+src+')'));
ui.context.saveContent();await new Promise(r=>setImmediate(r));assert.equal(saved.body,inserted);assert.equal(saved.content_type,'presentation');
ui.context.openEditor(1,'presentation');await new Promise(r=>setImmediate(r));assert.equal(doc.getElementById('lh-marp-editor').value,inserted);
// Same real frontend refine handler: no setContent on bodyPreserved.
html='<p>Original [3].</p>';doc.getElementById('lh-ai-refine-input').value='Add an image';const before=resets;
ui.context.submitRefineBody();await new Promise(r=>setImmediate(r));assert.equal(resets,before);assert.equal(html,'<p>Original [3].</p>');
ui.dom.window.close();
});
test('same-owner legacy sharing still supports NativeFiles, Filesystem, Share-only, Web Share and browser', async () => {
for(const mode of ['native','filesystem','share-only','web','browser']) {
const ui=client();let effects=0;
@ -187,26 +147,3 @@ test('same-owner legacy sharing still supports NativeFiles, Filesystem, Share-on
}
});
test('Learning private export keeps the initial owner between images and has no late UI or download after same-ID replacement', async () => {
const ui = client(); let downloads = 0, conversions = 0;
ui.dom.window.document.body.innerHTML = '<div id="lh-images-section"></div>';
ui.context.sanitizeHtml = value => value;
ui.dom.window.HTMLAnchorElement.prototype.click = () => { downloads++; };
vm.runInContext(fs.readFileSync('public/js/learningHub/images.js', 'utf8').replace(/^import .*;\n/gm, '').replace(/^export /gm, ''), ui.context);
const images = ui.context.createLearningImages(() => ({ getHTML: () => '<p>Exact body [1].</p><img src="' + src + '"><img src="' + src + '">' }), () => null);
images.mount();
const original = ui.context.imageDataUrl;
ui.context.imageDataUrl = async (...args) => {
if (++conversions === 1) {
ui.dom.window.AccountBoundary = { ...ui.dom.window.AccountBoundary };
return 'data:image/png;base64,' + png.toString('base64');
}
return original(...args);
};
const status = ui.dom.window.document.querySelector('[role=status]'); const before = status.textContent;
await ui.dom.window.document.querySelector('[data-export]').onclick();
assert.equal(conversions, 2, 'exercise the continuation between images');
assert.equal(ui.calls.length, 0, 'do not recapture same-ID B to fetch another image');
assert.equal(downloads, 0); assert.equal(status.textContent, before);
ui.dom.window.close();
});

View file

@ -1,416 +0,0 @@
// Run only with scripts/test-generated-images.sh: disposable internal-network PG + private S3.
const test = require('node:test');
const assert = require('node:assert/strict');
const { Pool } = require('pg');
const { createImageService, requestKey } = require('../src/utils/generatedImages');
const { createStorage, inspect } = require('../src/utils/generatedImageStorage');
const links = require('../src/utils/generatedImageLinks');
const { savedChatPayload } = require('../src/utils/clinicalConversation');
const revisions = require('../src/utils/promptRevisions');
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aL9sAAAAASUVORK5CYII=', 'base64');
if (!process.env.GENERATED_IMAGES_TEST_DB || new URL(process.env.GENERATED_IMAGES_TEST_DB).hostname !== 'test-pg' || new URL(process.env.GENERATED_IMAGES_TEST_DB).pathname !== '/image_lane') throw Error('Use the disposable test script, never an application database');
const pool = new Pool({ connectionString: process.env.GENERATED_IMAGES_TEST_DB });
const db = { pool, query: (sql, params) => pool.query(sql, params), async all(sql, params) { return (await pool.query(sql, params)).rows; }, async get(sql, params) { return (await pool.query(sql, params)).rows[0]; } };
let paid = 0;
const generate = async () => { paid++; return inspect(png); };
const storage = createStorage();
const service = () => createImageService({ db, storage, generate });
let image;
test.before(async () => {
await pool.query('CREATE TABLE users(id INTEGER PRIMARY KEY); INSERT INTO users VALUES(101),(102),(103); CREATE TABLE app_settings(key TEXT PRIMARY KEY,value TEXT,updated_at TIMESTAMPTZ DEFAULT NOW()); CREATE TABLE learning_content(id SERIAL PRIMARY KEY,body TEXT,published BOOLEAN DEFAULT false);');
for (const migration of ['1777700000000_add-prompt-revisions','1777800000000_generated-images','1777900000000_image-context']) {
let sql; require('../migrations/' + migration).up({ sql: value => { sql = value; } }); await pool.query(sql);
}
await pool.query("INSERT INTO app_settings(key,value) VALUES('learning_hub.image_model','synthetic-learning-image'),('clinical_assistant.image_model','synthetic-clinical-image')");
});
test.after(async () => { storage.close(); await pool.end(); });
test('private S3 asset, encrypted durable snapshot, idempotency and owner/workflow isolation', async () => {
const jobs = service(); const before = paid;
const input = { prompt: 'Synthetic flowchart comparison', layout: 'portrait' };
image = await jobs.enqueue(101, 'clinical_assistant', input, 'first');
const dup = await jobs.enqueue(101, 'clinical_assistant', input, 'first'); assert.equal(dup.jobId, image.jobId);
await assert.rejects(jobs.enqueue(101, 'clinical_assistant', { prompt: 'different' }, 'first'), e => e.statusCode === 409);
const row = await db.get('SELECT * FROM generated_image_jobs WHERE id=$1', [image.jobId]);
assert.match(row.prompt_cipher, /^enc1:/); assert.ok(!row.prompt_cipher.includes(input.prompt)); assert.equal(row.budget, 32000); assert.equal(row.model, 'synthetic-clinical-image');
await jobs.tick(); assert.equal(paid, before + 1);
const done = await jobs.get(image.jobId, 101, 'clinical_assistant'); assert.equal(done.status, 'done'); assert.equal(done.imageUrl, '/api/generated-images/' + image.jobId);
assert.deepEqual((await jobs.asset(image.jobId, { id: 101 })).bytes, png);
await assert.rejects(jobs.get(image.jobId, 102, 'clinical_assistant'), e => e.statusCode === 404);
await assert.rejects(jobs.get(image.jobId, 101, 'learning_hub'), e => e.statusCode === 404);
await assert.rejects(jobs.asset(image.jobId, { id: 102, role: 'admin' }), e => e.statusCode === 404);
assert.equal((await db.get('SELECT staged_bytes FROM generated_image_jobs WHERE id=$1', [image.jobId])).staged_bytes, null);
const anonymous = await fetch('http://test-s3:9000/generated-images/assets/' + image.jobId); assert.equal(anonymous.status, 403);
});
test('saved chat keeps exact body/citations, validates owned asset and durable job references', async () => {
const body = '## Exact\nDose [3, 1].\n| A | Source |\n| --- | --- |\n| 5 mg | [1] |';
const sources = [{ number: 3, page: 19, title: 'Synthetic three' }, { number: 1, page: 4, title: 'Synthetic one' }];
const payload = savedChatPayload({ messages: [{ role: 'assistant', content: body, sources, imageJobs: [image] }], lastAnswer: body, sources, generatedImage: '/api/generated-images/' + image.jobId });
await links.validateChat(db, payload, 101);
assert.equal(payload.messages[0].content, body); assert.deepEqual(payload.sources, sources);
assert.deepEqual(JSON.parse(JSON.stringify(payload)), payload);
await assert.rejects(links.validateChat(db, payload, 102), e => e.statusCode === 403);
});
test('Learning has independent model/prompt revision and authenticated current-publication grants; clinical UUID cannot be published', async () => {
const jobs = service();
const revision = await revisions.mutate(db, 'learning_hub.image_behavior', { action: 'save', value: 'Synthetic learning instructions.', expectedRevision: 0, actor: 101 });
const job = await jobs.enqueue(101, 'learning_hub', { prompt: 'Learning diagram' }, 'learning');
const row = await db.get('SELECT * FROM generated_image_jobs WHERE id=$1', [job.jobId]);
assert.equal(row.prompt_revision, revision.revision); assert.equal(row.model, 'synthetic-learning-image');
assert.match(require('../src/utils/crypto').decryptString(row.prompt_cipher), /Synthetic learning instructions/);
const changed = await revisions.mutate(db, 'learning_hub.image_behavior', { action: 'save', value: 'Second synthetic Learning instructions.', expectedRevision: revision.revision, actor: 101 });
const restored = await revisions.mutate(db, 'learning_hub.image_behavior', { action: 'restore', revisionId: revision.revision, expectedRevision: changed.revision, actor: 101 });
assert.equal(restored.value, revision.value);
assert.equal((await revisions.read(db, 'learning_hub.image_behavior', restored.revision)).restoredFrom, revision.revision);
assert.equal((await db.get('SELECT prompt_revision FROM generated_image_jobs WHERE id=$1', [job.jobId])).prompt_revision, revision.revision);
await jobs.tick();
const id = (await db.query("INSERT INTO learning_content(body,published) VALUES('',false) RETURNING id")).rows[0].id;
const client = await pool.connect();
try {
await client.query('BEGIN');
await assert.rejects(links.validateLearning(client, '<img src="/api/generated-images/' + image.jobId + '">', 101, id), e => e.statusCode === 403);
const ids = await links.validateLearning(client, '<img src="/api/generated-images/' + job.jobId + '">', 101, id);
await links.setLinks(client, id, ids); await client.query('COMMIT');
} finally { client.release(); }
await assert.rejects(jobs.asset(job.jobId, { id: 102, role: 'user' }), e => e.statusCode === 404);
assert.deepEqual((await jobs.asset(job.jobId, { id: 102, role: 'moderator' })).bytes, png);
await db.query('UPDATE learning_content SET published=true WHERE id=$1', [id]);
assert.deepEqual((await jobs.asset(job.jobId, { id: 102, role: 'user' })).bytes, png);
await db.query('UPDATE learning_content SET published=false WHERE id=$1', [id]);
await assert.rejects(jobs.asset(job.jobId, { id: 102, role: 'user' }), e => e.statusCode === 404);
await db.query('DELETE FROM generated_image_links WHERE content_id=$1', [id]);
await assert.rejects(jobs.asset(job.jobId, { id: 102, role: 'moderator' }), e => e.statusCode === 404);
await assert.rejects(links.validateLearning(db, '/api/generated-images/' + job.jobId, 102, id), e => e.statusCode === 403);
await assert.rejects(db.query('INSERT INTO generated_image_links VALUES($1,$2)', [image.jobId, id]), /Only Learning assets/);
await assert.rejects(db.query("UPDATE generated_image_jobs SET workflow='learning_hub' WHERE id=$1", [image.jobId]), /immutable/);
});
test('restart resumes queued/storage stages, but never retries ambiguous paid stages; lease fencing and SKIP LOCKED are native', async () => {
const jobs = service(); const before = paid;
const queued = await jobs.enqueue(101, 'clinical_assistant', { prompt: 'Queued across restart' }, 'restart');
await service().tick(); assert.equal((await jobs.get(queued.jobId,101,'clinical_assistant')).status, 'done');
const unknown = await jobs.enqueue(101, 'clinical_assistant', { prompt: 'Crash during provider request' }, 'unknown');
const old = await jobs.claim(); assert.equal(old.id, unknown.jobId);
await db.query("UPDATE generated_image_jobs SET lease_until=NOW()-interval '1 second' WHERE id=$1", [unknown.jobId]);
await service().tick(); assert.equal((await jobs.get(unknown.jobId,101,'clinical_assistant')).outcome, 'unknown');
assert.equal(paid, before + 1);
const fenced = await db.query("UPDATE generated_image_jobs SET stage='storing' WHERE id=$1 AND lease_token=$2 AND stage='generating' RETURNING id", [old.id,old.lease_token]); assert.equal(fenced.rows.length, 0);
const storing = await jobs.enqueue(101,'clinical_assistant',{ prompt: 'Storage crash' },'storage');
await createImageService({ db, generate, storage: { ...storage, put: async () => { throw Error('synthetic unavailable'); } } }).tick();
const staged = await db.get('SELECT stage,staged_bytes FROM generated_image_jobs WHERE id=$1', [storing.jobId]);
assert.equal(staged.stage, 'storing'); assert.notDeepEqual(staged.staged_bytes, png);
assert.deepEqual(require('../src/utils/crypto').decryptBuffer(staged.staged_bytes), png);
await db.query("UPDATE generated_image_jobs SET lease_until=NOW()-interval '1 second' WHERE id=$1",[storing.jobId]);
await service().tick(); assert.equal((await jobs.get(storing.jobId,101,'clinical_assistant')).status, 'done'); assert.equal(paid,before+2);
const first = await jobs.enqueue(101,'clinical_assistant',{ prompt:'lock one' },'lock-one');
const second = await jobs.enqueue(102,'clinical_assistant',{ prompt:'lock two' },'lock-two');
const client = await pool.connect();
try {
await client.query('BEGIN'); await client.query('SELECT id FROM generated_image_jobs WHERE id=$1 FOR UPDATE',[first.jobId]);
const claim = await service().claim(); assert.equal(claim.id, second.jobId);
await client.query('COMMIT');
} finally { client.release(); }
await db.query("UPDATE generated_image_jobs SET stage='interrupted' WHERE id=ANY($1::uuid[])", [[first.jobId,second.jobId]]);
});
test('budget exact UTF16 assembly, malformed input and missing storage prevent paid calls; provider timeout is explicit unknown', async () => {
const jobs = service(); const before = paid;
await db.query("INSERT INTO app_settings(key,value) VALUES('clinical_assistant.image_behavior','X'),('clinical_assistant.image_budget','1000')");
const base = await jobs.snapshot('clinical_assistant',{ prompt:'x',layout:'square' });
const prompt = '😀'.repeat(Math.floor((1001-base.rendered.length)/2)) + ('x'.repeat((1001-base.rendered.length)%2));
const exact = await jobs.snapshot('clinical_assistant',{prompt,layout:'square'}); assert.equal(exact.rendered.length, 1000);
await assert.rejects(jobs.enqueue(101,'clinical_assistant',{prompt:prompt+'x',layout:'square'},'large'), e => e.statusCode === 413);
for (const input of [{prompt:''},{prompt:'x',model:'forbidden'},{prompt:'x',layout:'url'},{prompt:'x'.repeat(32001)}]) await assert.rejects(jobs.enqueue(101,'clinical_assistant',input,'bad'));
const unavailable = createImageService({ db, generate, storage: { ready: async () => { throw Error('synthetic storage outage'); } } });
await assert.rejects(unavailable.enqueue(101,'clinical_assistant',{prompt:'no call'},'offline'),e => e.statusCode === 503);
assert.equal(paid,before);
const unknown = await jobs.enqueue(101,'clinical_assistant',{prompt:'ambiguous timeout'},'timeout');
await createImageService({ db, storage, generate: async () => { paid++; throw Error('synthetic timeout after possible billing'); } }).tick();
await jobs.tick(); assert.equal(paid,before+1); assert.equal((await jobs.get(unknown.jobId,101,'clinical_assistant')).outcome,'unknown');
});
test('actual authenticated asset/settings and Learning content write routes enforce grants and publication atomically', async () => {
const express = require('express'); const fs = require('fs'); const vm = require('vm'); const jwt = require('jsonwebtoken');
const jobs = service();
await pool.query("ALTER TABLE learning_content ADD title TEXT, ADD slug TEXT, ADD category_id INTEGER, ADD subject TEXT, ADD content_type TEXT, ADD author_id INTEGER, ADD updated_at TIMESTAMPTZ DEFAULT NOW()");
const convert = sql => { let n=0; return sql.replace(/\?/g,()=>'$'+(++n)); };
let lockNotice;
const routeDb = { ...db, pool: { async connect() {
const client=await pool.connect(); return { release:()=>client.release(), query(sql,params) {
if(sql.includes('FOR UPDATE') && lockNotice) { lockNotice(); lockNotice=null; }
return client.query(sql,params);
} };
} }, getSetting: async key => (await db.get('SELECT value FROM app_settings WHERE key=$1',[key]))?.value,
async get(sql,params) { return db.get(convert(sql),params); },
async all(sql,params) { return db.all(convert(sql),params); },
async run(sql,params) { const r=await db.query(convert(sql),params);return {lastInsertRowid:r.rows[0]?.id,changes:r.rowCount}; }
};
function load(file,mocks) {
const module={exports:{}};
vm.runInNewContext(fs.readFileSync(file,'utf8'),{module,Buffer,console:{warn(){},error(){}},process:{env:{JWT_SECRET:'synthetic-signing-only',CLINICAL_ASSISTANT_MCP_WARMUP:'false'}},setTimeout(){},require:n=>{assert.ok(n in mocks,n);return mocks[n];}});
return module.exports;
}
const roles={101:'admin',102:'user',103:'moderator'};
const auth = load('src/middleware/auth.js',{'jsonwebtoken':jwt,'../db/database':{get:async(sql,params)=>sql.includes('user_sessions')?{id:1,last_activity:new Date()}:roles[params[0]]?{id:params[0],role:roles[params[0]]}:null},'../utils/sessions':{hashToken:()=> 'synthetic-hash'},'../utils/platform':{isMobileClient:()=>false}});
const imageRoutes=load('src/routes/generatedImages.js',{'express':express,'../middleware/auth':auth,'../utils/generatedImages':{...require('../src/utils/generatedImages'),service:()=>jobs},'../db/database':routeDb});
const learningRoutes=load('src/routes/learningAdmin.js',{'express':express,'../db/database':routeDb,'../middleware/auth':auth,'../utils/embeddings':{isEmbeddingsAvailable:()=>false},'../utils/generatedImageLinks':links});
await db.query('CREATE TABLE clinical_assistant_chats(id SERIAL PRIMARY KEY,user_id INTEGER,title TEXT,payload TEXT,created_at TIMESTAMPTZ DEFAULT NOW(),updated_at TIMESTAMPTZ DEFAULT NOW())');
const learningAI=load('src/routes/learningAI.js',{
express,multer:require('multer'),axios:{},path:require('path'),'../utils/ai':{},'../utils/imageTool':require('../src/utils/imageTool'),
'../middleware/auth':auth,'../db/database':routeDb,'../utils/crypto':require('../src/utils/crypto'),'../utils/urlSafety':require('../src/utils/urlSafety'),
'../utils/policy':{requireFeature:()=>()=>{}},'../utils/generatedImageLinks':links,'../utils/generatedImages':{service:()=>jobs}
});
await db.query('CREATE TABLE learning_categories(id SERIAL PRIMARY KEY,name TEXT);CREATE TABLE learning_questions(id SERIAL PRIMARY KEY,content_id INTEGER,sort_order INTEGER)');
const clinicalRoutes = load('src/routes/clinicalAssistant.js', {
express, axios: {}, crypto: require('crypto'), '../db/database': routeDb, '../middleware/auth': auth,
'../utils/ai': {}, '../utils/generatedImages': { ...require('../src/utils/generatedImages'), service: () => jobs },
'../utils/visionTool': require('../src/utils/visionTool'),
'../utils/modelVision': { supportsVision: async () => null },
'../utils/imageTool': require('../src/utils/imageTool'), '../utils/generatedImageLinks': links,
'../utils/logger': { audit() {}, error() {} }, '../utils/crypto': require('../src/utils/crypto'), '../utils/redis': {},
'../utils/clinicalPromptPool': { createClinicalPromptPool: () => ({}) }, '../utils/clinicalMcpClient': {}, '../utils/clinicalRetrieval': {},
'../utils/clinicalPrompts': require('../src/utils/clinicalPrompts'), '../utils/clinicalConversation': require('../src/utils/clinicalConversation'), '../utils/clinicalAnswer': require('../src/utils/clinicalAnswer')
});
// Execute REAL server registrations in order, including the actual blanket adminConfig guard.
const configRoutes=load('src/routes/adminConfig.js',{
express,'../db/database':routeDb,'../middleware/auth':auth,'../utils/prompts':{getAllPrompts:()=>[]},
'../utils/promptCatalog':{},'../utils/promptRevisions':{},'../utils/clinicalConversation':require('../src/utils/clinicalConversation'),
'../utils/logger':{},'../utils/errors':{},'../utils/ttsProvider':{},'../utils/litellm':{},'../utils/sttProvider':{},'../utils/embeddings':{}
});
const app=express();app.use(express.json());
const composition=fs.readFileSync('server.js','utf8');
vm.runInNewContext(composition.slice(composition.indexOf('// Routes\n'),composition.indexOf('// User-level preference:')),{
app,APP_VERSION:'synthetic',process:{env:{}},require(name){
if(name==='./src/routes/generatedImages') return imageRoutes;
if(name==='./src/routes/learningAdmin') return learningRoutes;
if(name==='./src/routes/learningAI') return learningAI;
if(name==='./src/routes/clinicalAssistant') return clinicalRoutes;
if(name==='./src/routes/adminConfig') return configRoutes;
if(name==='./src/middleware/auth') return auth;
if(name==='./src/db/database') return routeDb;
if(name==='./src/utils/models') return {activeProvider:'synthetic',getAvailableModelsWithOverrides:async()=>[],getEffectiveDefaultModel:async()=>''};
if(name==='./src/utils/ai') return {};
if(name==='./src/utils/generatedImages') return {service:()=>({start(){}})};
assert.match(name,/^\.\/src\/routes\//);return express.Router(); // no unrelated application modules/services
}
});
const server=app.listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));
const base='http://127.0.0.1:'+server.address().port;
const request=(path,owner,method='GET',body)=>fetch(base+path,{method,headers:{'Content-Type':'application/json',...(owner?{Authorization:'Bearer '+jwt.sign({userId:owner},'synthetic-signing-only')}: {})},body:body?JSON.stringify(body):undefined});
try {
assert.equal((await request('/api/health')).status,200);
assert.equal((await request('/api/models')).status,200);
assert.equal((await request('/api/admin/learning/image/jobs',null,'POST',{prompt:'denied'})).status,401);
assert.equal((await request('/api/admin/learning/image/jobs',102,'POST',{prompt:'denied'})).status,403);
assert.equal((await request('/api/admin/config',103)).status,403);
const moderatorJob=await request('/api/admin/learning/image/jobs',103,'POST',{prompt:'Moderator image',idempotencyKey:'moderator-image'});
assert.equal(moderatorJob.status,200,await moderatorJob.clone().text());
const moderatorId=(await moderatorJob.json()).jobId;
assert.equal((await request('/api/admin/learning/image/jobs/'+moderatorId,103)).status,200);await jobs.tick();
assert.equal((await request('/api/generated-images/'+image.jobId)).status,401);
assert.equal((await request('/api/generated-images/'+image.jobId,102)).status,404);
const bytes=await request('/api/generated-images/'+image.jobId+'?download=1',101);
assert.equal(bytes.status,200);assert.equal(bytes.headers.get('x-image-owner'),'101');assert.equal(bytes.headers.get('cache-control'),'private, no-store');assert.equal(bytes.headers.get('x-content-type-options'),'nosniff');assert.deepEqual(Buffer.from(await bytes.arrayBuffer()),png);
const denied=await request('/api/admin/learning/content',101,'POST',{title:'Forbidden',body:'<img src="/api/generated-images/'+image.jobId+'">',published:true});assert.equal(denied.status,403);
assert.equal((await db.get("SELECT COUNT(*)::int AS n FROM learning_content WHERE title='Forbidden'")).n,0);
const job=await jobs.enqueue(101,'learning_hub',{prompt:'Attach through actual CMS'},'route-attach');await jobs.tick();
const draft=await request('/api/admin/learning/content',101,'POST',{title:'Teaching',body:'<p>Exact body.</p><img src="/api/generated-images/'+job.jobId+'">',published:false});assert.equal(draft.status,200);const contentId=(await draft.json()).id;
assert.equal((await request('/api/generated-images/'+job.jobId,102)).status,404);
assert.equal((await request('/api/generated-images/'+job.jobId,103)).status,200);
assert.equal((await request('/api/admin/learning/content/'+contentId,103,'PUT',{published:true})).status,200);
assert.equal((await request('/api/generated-images/'+job.jobId,102)).status,200);
assert.equal((await request('/api/admin/learning/content/'+contentId,103,'PUT',{published:false})).status,200);
assert.equal((await request('/api/generated-images/'+job.jobId,102)).status,404);
// Concurrent unpublish holds the row while a body-only request reaches its lock.
await db.query('UPDATE learning_content SET published=true WHERE id=$1',[contentId]);
const unpublish=await pool.connect();
try {
await unpublish.query('BEGIN');await unpublish.query('UPDATE learning_content SET published=false WHERE id=$1',[contentId]);
let lockReached;const atLock=new Promise(r=>{lockReached=r;});lockNotice=lockReached;
const update=request('/api/admin/learning/content/'+contentId,103,'PUT',{body:'<p>Concurrent body [3].</p><img src="/api/generated-images/'+job.jobId+'">'});
await atLock;await unpublish.query('COMMIT');assert.equal((await update).status,200);
assert.equal((await db.get('SELECT published FROM learning_content WHERE id=$1',[contentId])).published,false,'body edit must not restore stale publication');
assert.equal((await request('/api/generated-images/'+job.jobId,102)).status,404);
assert.equal((await db.get('SELECT COUNT(*)::int AS n FROM generated_image_links WHERE content_id=$1',[contentId])).n,1);
} finally { await unpublish.query('ROLLBACK');unpublish.release(); }
const markdown='---\nmarp: true\n---\n# Original slide [3]\nDose 5 mg, page 19 [3].\n---\n# Image\n![Generated teaching visual](/api/generated-images/'+job.jobId+')\n';
const presentation=await request('/api/admin/learning/content',101,'POST',{title:'Presentation',content_type:'presentation',body:markdown,published:false});
assert.equal(presentation.status,200);const presentationId=(await presentation.json()).id;
const reopenedPresentation=await request('/api/admin/learning/content/'+presentationId,103);
assert.equal(reopenedPresentation.status,200);assert.equal((await reopenedPresentation.json()).content.body,markdown);
const pptx=await request('/api/admin/learning/generate-pptx',103,'POST',{markdown,title:'Synthetic presentation'});
assert.equal(pptx.status,200,await pptx.clone().text());
const zip=await require('jszip').loadAsync(Buffer.from(await pptx.arrayBuffer()));
const media=Object.keys(zip.files).filter(f=>/^ppt\/media\/.+\.png$/.test(f));assert.equal(media.length,1);
assert.deepEqual(await zip.files[media[0]].async('nodebuffer'),png);
assert.match(await zip.files['ppt/slides/slide1.xml'].async('string'),/Original slide \[3\]/);
assert.equal((await request('/api/admin/learning/content',102,'POST',{title:'No permission'})).status,403);
assert.equal((await request('/api/admin/image-settings/learning_hub',103,'PUT',{model:'synthetic',budget:32000})).status,403);
assert.equal((await request('/api/admin/image-settings/learning_hub',101,'PUT',{model:'synthetic-own-learning',budget:1500})).status,200);
assert.equal((await request('/api/admin/image-settings/learning_hub',101,'PUT',{model:'synthetic',budget:32001})).status,400);
const listing=await (await request('/api/image-jobs/clinical_assistant',101)).text();assert.ok(!listing.includes('prompt_cipher'));assert.ok(!listing.includes('staged_bytes'));
const body = ' Exact [3, 1].\n| Dose | Page |\n| 5 mg | 19 [3] |\n';
const payload = { lastAnswer: body, messages: [{ role: 'assistant', content: body, sources: [{ number: 3, page: 19 }], imageJobs: [{ jobId: image.jobId, status: 'forged', imageUrl: 'https://invalid.test' }] }], generatedImage: '/api/generated-images/' + image.jobId };
const saved = await request('/api/clinical-assistant/chats', 101, 'POST', payload); assert.equal(saved.status, 200); const savedId = (await saved.json()).id;
const reopened = await (await request('/api/clinical-assistant/chats/' + savedId, 101)).json();
assert.equal(reopened.chat.payload.lastAnswer, body); assert.equal(reopened.chat.payload.messages[0].content, body);
assert.deepEqual(reopened.chat.payload.messages[0].sources, [{ number: 3, page: 19 }]);
assert.deepEqual(reopened.chat.payload.messages[0].imageJobs, [{ jobId: image.jobId }]);
assert.equal(reopened.chat.payload.generatedImage, undefined, 'offbox image is session-only and never stored');
assert.match((await db.get('SELECT payload FROM clinical_assistant_chats WHERE id=$1', [savedId])).payload, /^enc1:/);
assert.equal((await request('/api/clinical-assistant/chats/' + savedId, 102)).status, 404);
assert.equal((await request('/api/clinical-assistant/chats', 102, 'POST', payload)).status, 403);
assert.equal((await request('/api/clinical-assistant/chats', 102, 'POST', { messages: [], lastAnswer: '![image](' + payload.generatedImage + ')' })).status, 403);
assert.equal((await request('/api/clinical-assistant/chats', 101, 'POST', { messages: [], generatedImageJobs: [{ jobId: 'bad' }] })).status, 400);
} finally { await new Promise(r=>server.close(r)); }
});
test('both workflows enforce exact 32000 UTF16 assembly and durable snapshots cannot change with admin settings', async () => {
const jobs = service(); const before = paid;
for (const workflow of ['clinical_assistant', 'learning_hub']) {
await db.query("INSERT INTO app_settings(key,value) VALUES($1,'32000') ON CONFLICT(key) DO UPDATE SET value='32000'", [workflow + '.image_budget']);
const base = await jobs.snapshot(workflow, { prompt: 'x', layout: 'square' });
const units = 32001 - base.rendered.length;
const prompt = '😀'.repeat(Math.floor(units / 2)) + 'x'.repeat(units % 2);
assert.equal((await jobs.snapshot(workflow, { prompt, layout: 'square' })).rendered.length, 32000);
await assert.rejects(jobs.enqueue(101, workflow, { prompt: prompt + 'x', layout: 'square' }, 'over-32000'), e => e.statusCode === 413);
const job = await jobs.enqueue(101, workflow, { prompt, layout: 'square' }, 'exact-32000');
const original = await db.get('SELECT * FROM generated_image_jobs WHERE id=$1', [job.jobId]);
assert.equal(original.prompt_units, 32000); assert.match(original.prompt_cipher, /^enc1:/);
for (const [field, value] of [['owner_id', 102], ['model', 'replacement'], ['budget', 1000], ['prompt_cipher', 'enc1:replacement'], ['prompt_revision', original.prompt_revision + 1], ['context_total', 9], ['context_included', 1]]) {
await assert.rejects(db.query(`UPDATE generated_image_jobs SET ${field}=$2 WHERE id=$1`, [job.jobId, value]), /immutable/);
}
await db.query("UPDATE app_settings SET value='1000' WHERE key=$1", [workflow + '.image_budget']);
let sent;
await createImageService({ db, storage, generate: async (snapshot, text) => { sent = { snapshot, text }; paid++; return inspect(png); } }).tick();
assert.equal(sent.snapshot.id, job.jobId); assert.equal(sent.snapshot.budget, 32000); assert.equal(sent.snapshot.model, original.model);
assert.equal(sent.text, require('../src/utils/crypto').decryptString(original.prompt_cipher));
}
assert.equal(paid, before + 2);
});
test('concurrent tool replay, owner/workflow identity and preflight failures never create duplicate paid jobs', async () => {
const jobs = service(); const before = paid; const tool = require('../src/utils/imageTool');
const opts = { owner: 101, workflow: 'clinical_assistant', body: { idempotencyKey: 'concurrent-tool' }, imageContext:{request:'Concurrent original request',history:[]}, images: jobs };
const result = prompt => ({ content: 'Exact body [3].', toolCalls: [{ id: 'one', type: 'function', function: { name: 'generate_image', arguments: JSON.stringify({ prompt }) } }] });
const [a, b] = await Promise.all([tool.dispatch(result('First replay diagram'), opts), tool.dispatch(result('Second replay diagram'), opts)]);
assert.equal(a.imageJobs[0].jobId, b.imageJobs[0].jobId);
const other = await jobs.enqueue(102, 'clinical_assistant', { prompt: 'Other owner' }, 'tool:concurrent-tool');
const learning = await jobs.enqueue(101, 'learning_hub', { prompt: 'Other workflow' }, 'tool:concurrent-tool');
assert.notEqual(other.jobId, a.imageJobs[0].jobId); assert.notEqual(learning.jobId, a.imageJobs[0].jobId);
await jobs.tick(); await jobs.tick(); await jobs.tick(); await jobs.tick(); assert.equal(paid, before + 3);
const noMigration = createImageService({ db: { query: async () => { throw Error('synthetic missing migration'); } }, storage, generate });
await assert.rejects(noMigration.ready(), e => e.statusCode === 503);
const noEncryption = createImageService({ db, storage, generate, encryption: { hasKey: () => false } });
await assert.rejects(noEncryption.enqueue(101, 'clinical_assistant', { prompt: 'No encryption' }, 'no-key'), e => e.statusCode === 503);
const noGateway = createImageService({ db, storage, env: {}, encryption: require('../src/utils/crypto') });
await assert.rejects(noGateway.ready(), e => e.statusCode === 503);
assert.equal(paid, before + 3);
});
test('worker stop during preflight or claim never starts a new paid request; queued work resumes safely', async () => {
for (const pauseAt of ['preflight', 'claim']) {
const jobs = service(); const before = paid;
const job = await jobs.enqueue(101, 'clinical_assistant', { prompt: 'Stop before payment' }, 'stop-' + pauseAt);
let reached, release;
const paused = new Promise(resolve => { reached = resolve; });
const gate = new Promise(resolve => { release = resolve; });
const worker = createImageService({ generate,
storage: { ...storage, ready: async () => { await storage.ready(); if (pauseAt === 'preflight') { reached(); await gate; } } },
db: { ...db, pool: { async connect() {
const client = await pool.connect();
return { release: () => client.release(), async query(...args) {
const result = await client.query(...args);
if (pauseAt === 'claim' && args[0] === 'COMMIT') { reached(); await gate; }
return result;
} };
} } }
});
worker.start(); await paused;
const stopped = worker.stop(); release(); await stopped;
assert.equal(paid, before, pauseAt); assert.equal((await jobs.get(job.jobId, 101, 'clinical_assistant')).status, 'pending');
await service().tick(); assert.equal(paid, before + 1); assert.equal((await jobs.get(job.jobId, 101, 'clinical_assistant')).status, 'done');
}
});
test('exact IMAGE HTTP input binds original request and contiguous whole recent turns; snapshots, UTF16 metadata and replay remain honest', async () => {
const http = require('node:http'); const captured = [];
const server = http.createServer(async (req,res) => {
const chunks=[]; for await (const c of req) chunks.push(c);
captured.push({path:req.url,body:JSON.parse(Buffer.concat(chunks))});
res.writeHead(200,{'Content-Type':'application/json'}); res.end(JSON.stringify({data:[{b64_json:png.toString('base64')}]}));
});
server.listen(0,'127.0.0.1'); await new Promise(r=>server.once('listening',r));
const old = process.env.LITELLM_API_BASE; process.env.LITELLM_API_BASE='http://127.0.0.1:'+server.address().port+'/v1';
try {
await db.query("UPDATE app_settings SET value='12000' WHERE key='clinical_assistant.image_budget'");
const jobs=createImageService({db,storage});
const context={request:' ORIGINAL request: draw the latest corrected dose 😀 [3].\n',history:[
{role:'user',content:'Old tiny turn must not jump a gap.'},
{role:'assistant',content:'too large boundary '+ '😀'.repeat(7000)},
{role:'user',content:'Recent correction '+ '😀'.repeat(1600)},
{role:'assistant',content:' Exact table [3, 1].\n| Dose | Page |\n| 5 mg | 19 [3] |\n'}]};
const original=JSON.stringify(context); const input={prompt:'MODEL DESCRIPTION ONLY',layout:'portrait'};
const tool=require('../src/utils/imageTool');
const opts={owner:101,workflow:'clinical_assistant',body:{idempotencyKey:'exact-image-input'},imageContext:context,images:jobs};
const ai={content:'Unchanged [3, 1].',toolCalls:[{id:'ctx',type:'function',function:{name:'generate_image',arguments:JSON.stringify(input)}}]};
const job=(await tool.dispatch(ai,opts)).imageJobs[0];
await jobs.tick(); assert.equal(captured.length,1);
const sent=captured[0].body.prompt;
assert.ok(sent.includes(context.request),'ORIGINAL request must reach IMAGE provider');
assert.ok(sent.includes(input.prompt));
assert.ok(sent.includes(context.history[2].content)); assert.ok(sent.includes(context.history[3].content));
assert.ok(sent.indexOf(context.history[2].content)<sent.indexOf(context.history[3].content));
assert.ok(!sent.includes(context.history[0].content)); assert.ok(!sent.includes(context.history[1].content));
assert.match(sent,/image only/i); assert.match(sent,/citations, reference numbers, footnotes, bibliography, or source lists/i);
assert.equal(captured[0].path,'/v1/images/generations'); assert.equal(captured[0].body.model,'synthetic-clinical-image');
assert.deepEqual(job.context,{includedTurns:2,totalTurns:4,used:sent.length,limit:12000,unit:'UTF-16 code units'});
assert.deepEqual((await jobs.get(job.jobId,101,'clinical_assistant')).context,job.context);
const row=await db.get('SELECT * FROM generated_image_jobs WHERE id=$1',[job.jobId]);
assert.equal(require('../src/utils/crypto').decryptString(row.prompt_cipher),sent); assert.equal(row.prompt_units,sent.length);
assert.ok(!row.prompt_cipher.includes(context.request)); assert.equal(JSON.stringify(context),original);
const replay=await tool.dispatch({...ai,toolCalls:[{...ai.toolCalls[0],function:{name:'generate_image',arguments:'{"prompt":"changed model wording"}'}}]},opts);
assert.equal(replay.imageJobs[0].jobId,job.jobId);
await assert.rejects(tool.dispatch(ai,{...opts,imageContext:{...context,history:context.history.concat({role:'user',content:'new'})}}),e=>e.statusCode===409);
for (const workflow of ['clinical_assistant','learning_hub']) {
await db.query("UPDATE app_settings SET value='32000' WHERE key=$1",[workflow+'.image_budget']);
const base=await jobs.snapshot(workflow,{prompt:'x'});
const text='😀'.repeat(Math.floor((32001-base.rendered.length)/2))+'x'.repeat((32001-base.rendered.length)%2);
const exact=await jobs.enqueue(101,workflow,{prompt:text},'http-exact-'+workflow);
await jobs.tick(); assert.equal(captured.at(-1).body.prompt.length,32000);
assert.match(captured.at(-1).body.prompt,/image only/i); assert.equal(exact.context.totalTurns,0);
await assert.rejects(jobs.enqueue(101,workflow,{prompt:text+'x'},'http-over-'+workflow),e=>e.statusCode===413);
}
assert.equal(captured.length,3,'mandatory overflow never calls IMAGE provider');
} finally { if(old===undefined) delete process.env.LITELLM_API_BASE; else process.env.LITELLM_API_BASE=old; await new Promise(r=>server.close(r)); }
});
test('expired PAID lease becomes explicit unknown even while storage and gateway readiness fail', async () => {
const jobs=service(),before=paid;
const job=await jobs.enqueue(101,'clinical_assistant',{prompt:'Crash plus outage'},'combined-outage');
const claim=await jobs.claim();assert.equal(claim.id,job.jobId);
await db.query("UPDATE generated_image_jobs SET lease_until=NOW()-interval '1 second' WHERE id=$1",[job.jobId]);
const down=createImageService({db,env:{},storage:{ready:async()=>{throw Error('synthetic outage');}}});
await down.tick().catch(()=>{});
assert.equal((await jobs.get(job.jobId,101,'clinical_assistant')).outcome,'unknown');assert.equal(paid,before);
await jobs.tick();assert.equal(paid,before);
});
test('partial schema without links/asset-read relation fails readiness and queued payment against real PG', async () => {
const jobs=service(),before=paid;
const job=await jobs.enqueue(101,'clinical_assistant',{prompt:'Do not pay with missing links'},'partial-schema');
await db.query('ALTER TABLE generated_image_links RENAME TO unavailable_links');
try {
await assert.rejects(jobs.ready(),e=>e.statusCode===503);
await assert.rejects(jobs.tick(),e=>e.statusCode===503);
await assert.rejects(jobs.enqueue(101,'clinical_assistant',{prompt:'blocked too'},'partial-new'),e=>e.statusCode===503);
assert.equal(paid,before);
} finally { await db.query('ALTER TABLE unavailable_links RENAME TO generated_image_links');await db.query("UPDATE generated_image_jobs SET stage='interrupted' WHERE id=$1",[job.jobId]); }
});
test('whole-turn context exactly fills the cap or is omitted in full; mandatory original request overflow never pays', async () => {
const jobs=service(),before=paid;
for(const workflow of ['clinical_assistant','learning_hub']) {
await db.query("UPDATE app_settings SET value='32000' WHERE key=$1",[workflow+'.image_budget']);
const input={prompt:'Image description',layout:'square'},context={request:' Original image request 😀\n',history:[{role:'user',content:'x'}]};
const base=await jobs.snapshot(workflow,input,context);const remaining=32001-base.rendered.length;
context.history[0].content='😀'.repeat(Math.floor(remaining/2))+'x'.repeat(remaining%2);
const original=JSON.stringify(context),exact=await jobs.snapshot(workflow,input,context);
assert.equal(exact.rendered.length,32000);assert.equal(exact.included,1);assert.ok(exact.rendered.includes(context.history[0].content));
context.history[0].content+='x';const over=await jobs.snapshot(workflow,input,context);
assert.equal(over.included,0);assert.equal(over.total,1);assert.ok(!over.rendered.includes(context.history[0].content));
assert.ok(over.rendered.includes(context.request));assert.ok(over.rendered.endsWith(require('../src/utils/generatedImages').IMAGE_OUTPUT_RULE));
context.history[0].content=context.history[0].content.slice(0,-1);assert.equal(JSON.stringify(context),original);
await assert.rejects(jobs.enqueue(101,workflow,input,'mandatory-original-overflow',false,{request:'😀'.repeat(16000),history:[]}),e=>e.statusCode===413);
}
assert.equal(paid,before);
});

View file

@ -1,37 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
function read(relativePath) {
return fs.readFileSync(path.join(__dirname, '..', relativePath), 'utf8');
}
const frontend = read('public/js/learningHub.js');
const learningAI = read('src/routes/learningAI.js');
const learningAdmin = read('src/routes/learningAdmin.js');
test('Learning AI prompts request category_name using existing categories', () => {
assert.match(learningAI, /function buildCategoryInstruction\(existingCategories\)/);
assert.match(learningAI, /SELECT name FROM learning_categories ORDER BY sort_order ASC, name ASC/);
assert.match(learningAI, /"category_name": "string \(best existing category name/);
assert.match(learningAI, /Prefer an existing category_name when it fits/);
});
test('Learning AI presentation JSON preserves generated category_name', () => {
assert.match(learningAI, /category_name: parsedPres\.category_name \|\| ''/);
});
test('Learning Hub applies AI-generated category names to the editor', () => {
assert.match(frontend, /return applyAiContent\(payload, contentType\)\.then/);
assert.match(frontend, /function applyAiCategory\(categoryName\)/);
assert.match(frontend, /sendJson\('\/api\/admin\/learning\/categories', 'POST', \{ name: categoryName \}\)/);
assert.match(frontend, /findCategoryOption\(sel, categoryName\)/);
assert.match(frontend, /cms\.loadCategories\(String\(data\.id\)\)/);
assert.match(frontend, /loadCategories\(\);/);
});
test('Learning category creation is idempotent by name', () => {
assert.match(learningAdmin, /WHERE LOWER\(name\) = LOWER\(\?\)/);
assert.match(learningAdmin, /existing: true/);
});

View file

@ -1,156 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { pathToFileURL } = require('node:url');
const { JSDOM } = require('jsdom');
async function loadAiPanelController() {
return import(pathToFileURL(path.join(__dirname, '..', 'public/js/learningHub/aiPanelController.js')).href);
}
function makeDom() {
return new JSDOM(`<!doctype html>
<div id="lh-ai-panel" class="hidden"></div>
<select id="lh-ai-model"><option value="">Default</option></select>
<select id="lh-ai-ctype"><option value="article">Article</option><option value="quiz">Quiz</option><option value="presentation">Presentation</option></select>
<div id="lh-ai-words-field"></div>
<div id="lh-ai-slides-field"></div>
<div id="lh-ai-quiz-row"></div>
<div id="lh-ai-quiz-card-title"></div>
<label id="lh-ai-q-toggle-label"><input id="lh-ai-q-toggle" type="checkbox"></label>
<div id="lh-ai-q-count-wrap"></div>
<button id="lh-ai-tab-prompt" class="lh-ai-tab active" data-aitab="prompt"></button>
<button id="lh-ai-tab-webdav" class="lh-ai-tab" data-aitab="webdav"></button>
<div id="lh-ai-tp-prompt" class="lh-ai-tabpanel"></div>
<div id="lh-ai-tp-webdav" class="lh-ai-tabpanel hidden"></div>
<div id="lh-ai-webdav-list"></div>`);
}
function withGlobals(dom, fn) {
const previousDocument = global.document;
const previousWindow = global.window;
const previousFetch = global.fetch;
const previousGetAuthHeaders = global.getAuthHeaders;
global.document = dom.window.document;
global.window = dom.window;
global.getAuthHeaders = function() { return {}; };
return Promise.resolve()
.then(fn)
.finally(function() {
global.document = previousDocument;
global.window = previousWindow;
global.fetch = previousFetch;
global.getAuthHeaders = previousGetAuthHeaders;
});
}
test('Learning Hub AI panel option state matches content type semantics', async () => {
const { applyAiOptionState } = await loadAiPanelController();
const els = {
wordsField: { style: {} },
slidesField: { style: {} },
quizRow: { style: {} },
cardTitle: { textContent: '' },
toggleLabel: { style: {} },
toggleCb: { checked: false, disabled: false },
countWrap: { style: {} }
};
applyAiOptionState('article', els);
assert.equal(els.wordsField.style.display, 'flex');
assert.equal(els.slidesField.style.display, 'none');
assert.equal(els.cardTitle.textContent, 'Quiz Questions (optional)');
assert.equal(els.countWrap.style.display, 'none');
els.toggleCb.checked = true;
applyAiOptionState('presentation', els);
assert.equal(els.wordsField.style.display, 'none');
assert.equal(els.slidesField.style.display, 'flex');
assert.equal(els.toggleCb.disabled, false);
assert.equal(els.countWrap.style.display, 'flex');
els.toggleCb.checked = false;
applyAiOptionState('quiz', els);
assert.equal(els.wordsField.style.display, 'none');
assert.equal(els.slidesField.style.display, 'none');
assert.equal(els.cardTitle.textContent, 'Quiz Questions');
assert.equal(els.toggleLabel.style.display, 'none');
assert.equal(els.toggleCb.checked, true);
assert.equal(els.toggleCb.disabled, true);
assert.equal(els.countWrap.style.display, 'flex');
});
test('Learning Hub AI panel opens with defaults, models, auth state, and stable handlers', async () => {
const dom = makeDom();
await withGlobals(dom, async function() {
const { createAiPanelController } = await loadAiPanelController();
let modelBuilds = 0;
let fetches = 0;
const paths = [];
const panel = document.getElementById('lh-ai-panel');
panel.scrollIntoView = function() {};
window._buildModelOptions = function(sel) {
modelBuilds += 1;
const opt = document.createElement('option');
opt.value = 'model-a';
opt.textContent = 'Model A';
sel.appendChild(opt);
};
global.fetch = function(url) {
fetches += 1;
assert.equal(url, '/api/auth/me');
return Promise.resolve({ json: function() { return Promise.resolve({ user: { nextcloud_url: 'https://cloud.example', webdav_learning_path: '/Learning' } }); } });
};
const controller = createAiPanelController({
browseWebdav: function() {},
getEditorType: function() { return 'presentation'; },
getWebdavPath: function() { return '/Learning'; },
setWebdavPath: function(pathValue) { paths.push(pathValue); }
});
controller.open();
controller.open();
await new Promise(function(resolve) { setImmediate(resolve); });
assert.equal(panel.classList.contains('hidden'), false);
assert.equal(document.getElementById('lh-ai-ctype').value, 'presentation');
assert.equal(document.getElementById('lh-ai-words-field').style.display, 'none');
assert.equal(document.getElementById('lh-ai-slides-field').style.display, 'flex');
assert.equal(document.getElementById('lh-ai-tab-webdav').style.display, '');
assert.deepEqual(paths, ['/Learning', '/Learning']);
assert.equal(modelBuilds, 1);
assert.equal(fetches, 2);
const ctype = document.getElementById('lh-ai-ctype');
ctype.value = 'quiz';
ctype.dispatchEvent(new window.Event('change'));
assert.equal(document.getElementById('lh-ai-q-toggle').disabled, true);
assert.equal(document.getElementById('lh-ai-quiz-card-title').textContent, 'Quiz Questions');
});
});
test('Learning Hub AI panel tab switch lazily browses empty WebDAV panel', async () => {
const dom = makeDom();
await withGlobals(dom, async function() {
const { createAiPanelController } = await loadAiPanelController();
const browsedPaths = [];
const controller = createAiPanelController({
browseWebdav: function(pathValue) { browsedPaths.push(pathValue); },
getEditorType: function() { return 'article'; },
getWebdavPath: function() { return '/Learning/Sub'; },
setWebdavPath: function() {}
});
controller.switchTab('webdav');
assert.equal(document.getElementById('lh-ai-tab-webdav').classList.contains('active'), true);
assert.equal(document.getElementById('lh-ai-tp-webdav').classList.contains('hidden'), false);
assert.deepEqual(browsedPaths, ['/Learning/Sub']);
document.getElementById('lh-ai-webdav-list').innerHTML = '<button class="lh-webdav-item"></button>';
controller.switchTab('webdav');
assert.deepEqual(browsedPaths, ['/Learning/Sub']);
});
});

View file

@ -1,190 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
function read(relativePath) {
return fs.readFileSync(path.join(__dirname, '..', relativePath), 'utf8');
}
const source = read('public/js/learningHub.js');
const apiModule = read('public/js/learningHub/api.js');
const aiPanelControllerModule = read('public/js/learningHub/aiPanelController.js');
const cmsControllerModule = read('public/js/learningHub/cmsController.js');
const cmsRendererModule = read('public/js/learningHub/cmsRenderer.js');
const editorModule = read('public/js/learningHub/tiptapEditor.js');
const feedRendererModule = read('public/js/learningHub/feedRenderer.js');
const quizControllerModule = read('public/js/learningHub/quizController.js');
const quizRendererModule = read('public/js/learningHub/quizRenderer.js');
const sanitizeModule = read('public/js/learningHub/sanitize.js');
const slideControllerModule = read('public/js/learningHub/slideController.js');
const viewerRendererModule = read('public/js/learningHub/viewerRenderer.js');
const webdavControllerModule = read('public/js/learningHub/webdavController.js');
test('Learning Hub destroys embedded Tiptap editors before removing CMS rows', () => {
assert.match(source, /from '\.\/learningHub\/tiptapEditor\.js'/);
assert.match(editorModule, /export function destroyTpEditor\(editor\)/);
assert.match(editorModule, /export function destroyQuestionBlockEditors\(block\)/);
assert.match(editorModule, /export function clearQuestionBlocks\(container\)/);
assert.match(source, /destroyQuestionBlockEditors\(qb\); qb\.remove\(\)/);
assert.match(source, /destroyOptionEditor\(row\); row\.remove\(\)/);
assert.doesNotMatch(source, /document\.getElementById\('lh-cms-questions'\)\.innerHTML = ''/);
assert.doesNotMatch(source, /qContainer\.innerHTML = ''/);
});
test('Learning Hub sanitization helpers are isolated for renderer reuse', () => {
assert.match(source, /from '\.\/learningHub\/sanitize\.js'/);
assert.match(sanitizeModule, /export function esc\(str\)/);
assert.match(sanitizeModule, /export function sanitizeHtml\(html\)/);
assert.match(sanitizeModule, /DOMPurify\.sanitize/);
assert.doesNotMatch(sanitizeModule, /ai-generate|ai-refine|FormData|\/api\/admin\/learning/);
assert.doesNotMatch(source, /function sanitizeHtml\(html\)/);
});
test('Learning Hub API helpers centralize auth JSON fetches', () => {
assert.match(source, /from '\.\/learningHub\/api\.js'/);
assert.match(apiModule, /export function getJson\(url\)/);
assert.match(apiModule, /getAuthHeaders\(\)/);
assert.match(apiModule, /export function sendJson\(url, method, payload\)/);
assert.match(apiModule, /export function sendJsonBlob\(url, method, payload\)/);
assert.match(apiModule, /export function deleteJson\(url\)/);
});
test('Learning Hub Tiptap helpers are isolated from content generation flow', () => {
assert.match(editorModule, /export function makeTpEditor/);
assert.match(editorModule, /window\.Tiptap/);
assert.doesNotMatch(editorModule, /ai-generate|ai-refine|FormData|\/api\/admin\/learning/);
});
test('Learning Hub feed renderers are isolated from API and editor flows', () => {
assert.match(source, /from '\.\/learningHub\/feedRenderer\.js'/);
assert.match(feedRendererModule, /export function renderCategoryPills\(categories\)/);
assert.match(feedRendererModule, /export function renderFeed\(items, feedEl\)/);
assert.match(feedRendererModule, /export function renderSearchHeader\(count, query\)/);
assert.match(feedRendererModule, /export function renderEmptySearchMessage\(\)/);
assert.match(feedRendererModule, /fa-presentation-screen/);
assert.doesNotMatch(feedRendererModule, /fetch\(|getJson|sendJson|deleteJson|ai-generate|FormData|window\.Tiptap/);
assert.doesNotMatch(source, /function renderFeed\(items, feedEl\)/);
});
test('Learning Hub CMS renderers are isolated from API and editor flows', () => {
assert.match(source, /from '\.\/learningHub\/cmsRenderer\.js'/);
assert.match(cmsRendererModule, /export function renderCmsCategoryList\(categories\)/);
assert.match(cmsRendererModule, /export function renderCmsContentRow\(item\)/);
assert.match(cmsRendererModule, /export function renderQuestionBlockShell\(existingQ, qNum\)/);
assert.match(cmsRendererModule, /export function renderOptionRowShell\(opt\)/);
assert.match(cmsRendererModule, /lh-cms-cat-filter/);
assert.match(cmsRendererModule, /selectedQuestionType\(existingQ, type\)/);
assert.doesNotMatch(cmsRendererModule, /fetch\(|getJson|sendJson|deleteJson|ai-generate|FormData|window\.Tiptap/);
});
test('Learning Hub preserves AI-created category selection before save', () => {
assert.match(cmsControllerModule, /function loadCategories\(editorCategoryId, filterCategoryId\)/);
assert.match(cmsControllerModule, /var selectedEditorId = editorCategoryId !== undefined \? editorCategoryId : sel\.value/);
assert.match(cmsControllerModule, /if \(selectedEditorId && hasSelectValue\(sel, selectedEditorId\)\) sel\.value = selectedEditorId/);
assert.match(source, /cms\.loadCategories\(String\(data\.id\)\)/);
});
test('Learning Hub CMS sidebar categories filter content list', () => {
assert.match(source, /cms\.filterByCategory\(cmsCat\.dataset\.id\)/);
assert.match(cmsControllerModule, /function filterByCategory\(categoryId\)/);
assert.match(cmsControllerModule, /function setActiveCategory\(categoryId\)/);
assert.match(cmsControllerModule, /item\.classList\.toggle\('active'/);
});
test('Learning Hub refreshes CMS category counts after content changes', () => {
assert.match(source, /saveQuestions\(contentId, questions, 0, function\(\) \{[\s\S]*?cms\.refreshAll\(\);/);
assert.match(source, /showToast\('Content deleted', 'info'\);[\s\S]*?cms\.refreshAll\(\);/);
assert.match(cmsControllerModule, /function refreshAll\(\) \{[\s\S]*?loadContent\(\);[\s\S]*?loadCategories\(\);[\s\S]*?loadStats\(\);/);
});
test('Learning Hub CMS controller owns admin list operations', () => {
assert.match(source, /from '\.\/learningHub\/cmsController\.js'/);
assert.match(source, /var cms = createCmsController\(/);
assert.match(source, /cms\.loadContent\(\); cms\.loadStats\(\)/);
assert.match(source, /cms\.addCategory\(\)/);
assert.match(source, /cms\.deleteCategory\(delCat\.dataset\.id\)/);
assert.match(cmsControllerModule, /export function createCmsController\(deps\)/);
assert.match(cmsControllerModule, /function loadStats\(\)/);
assert.match(cmsControllerModule, /function loadCategories\(editorCategoryId, filterCategoryId\)/);
assert.match(cmsControllerModule, /function loadContent\(\)/);
assert.match(cmsControllerModule, /function handleFilterInput\(e\)/);
assert.doesNotMatch(cmsControllerModule, /window\.Tiptap|ai-generate|FormData/);
assert.doesNotMatch(source, /function loadCmsStats\(\)|function loadCmsCategories\(|function loadCmsContent\(\)/);
});
test('Learning Hub slide controller owns preview and PPTX flow', () => {
assert.match(source, /from '\.\/learningHub\/slideController\.js'/);
assert.match(source, /var slides = createSlideController\(/);
assert.match(source, /slides\.openSlidesFromSlug\(item\.slug\)/);
assert.match(source, /slides\.previewCurrentSlides\(\)/);
assert.match(source, /slides\.downloadPptx\(\)/);
assert.match(slideControllerModule, /export function createSlideController\(deps\)/);
assert.match(slideControllerModule, /function openSlideModal\(slides, css\) \{\s*closeSlideModal\(\);/);
assert.match(slideControllerModule, /if \(!modal \|\| !previewSlides\.length\) \{ deps\.showToast\('No slides to display', 'error'\); return; \}/);
assert.match(slideControllerModule, /modal\.removeEventListener\('touchstart', modal\._touchStart\)/);
assert.match(slideControllerModule, /modal\.removeEventListener\('touchend', modal\._touchEnd\)/);
assert.match(slideControllerModule, /sendJsonBlob\('\/api\/admin\/learning\/generate-pptx'/);
assert.doesNotMatch(slideControllerModule, /window\.Tiptap|ai-generate|FormData/);
assert.doesNotMatch(source, /function openSlideModal\(slides, css\)/);
});
test('Learning Hub quiz renderers are isolated from API and submit flow', () => {
assert.match(quizControllerModule, /from '\.\/quizRenderer\.js'/);
assert.match(quizRendererModule, /export function renderQuizQuestions\(questions\)/);
assert.match(quizRendererModule, /export function renderQuizResultExplanations\(results\)/);
assert.match(quizRendererModule, /sanitizeHtml\(q\.question_text\)/);
assert.match(quizRendererModule, /sanitizeHtml\(r\.generalExplanation\)/);
assert.doesNotMatch(quizRendererModule, /fetch\(|getJson|sendJson|deleteJson|submitQuiz|showLoading|currentContent/);
});
test('Learning Hub quiz controller owns submission and answer tracking', () => {
assert.match(source, /from '\.\/learningHub\/quizController\.js'/);
assert.match(source, /var quiz = createQuizController\(/);
assert.match(source, /quiz\.submitQuiz\(\)/);
assert.match(source, /quiz\.renderQuiz\(item\.questions\)/);
assert.match(quizControllerModule, /export function createQuizController\(deps\)/);
assert.match(quizControllerModule, /export function buildQuizAnswers\(questions, root\)/);
assert.match(quizControllerModule, /sendJson\('\/api\/learning\/submit-quiz', 'POST', \{ contentId: currentContent\.id, answers: answers \}\)/);
assert.match(quizControllerModule, /optionIds: optionIds/);
assert.match(quizControllerModule, /optionId: selected \? parseInt\(selected\.value\) : null/);
assert.doesNotMatch(source, /function submitQuiz\(\)|function showQuizResults\(|function renderQuiz\(questions\)/);
});
test('Learning Hub WebDAV controller owns browser state and rendering', () => {
assert.match(source, /from '\.\/learningHub\/webdavController\.js'/);
assert.match(source, /var webdav = createWebdavController\(/);
assert.match(source, /webdav\.browse\(webdav\.getCurrentPath\(\)\)/);
assert.match(source, /webdav\.openItem\(wdItem\)/);
assert.match(source, /var webdavPath = webdav\.getSelectedPath\(\)/);
assert.match(webdavControllerModule, /export function createWebdavController\(deps\)/);
assert.match(webdavControllerModule, /export function renderWebdavListHtml\(data\)/);
assert.match(webdavControllerModule, /export function getFileIcon\(mime, name\)/);
assert.match(webdavControllerModule, /export function formatBytes\(bytes\)/);
assert.doesNotMatch(source, /var _aiWebdav|function browseWebdav\(|function renderWebdavList\(|function selectWebdavFile\(/);
assert.doesNotMatch(webdavControllerModule, /window\.Tiptap|ai-generate|FormData/);
});
test('Learning Hub AI panel controller owns panel options and source tabs', () => {
assert.match(source, /from '\.\/learningHub\/aiPanelController\.js'/);
assert.match(source, /var aiPanel = createAiPanelController\(/);
assert.match(source, /aiPanel\.open\(\)/);
assert.match(source, /aiPanel\.close\(\)/);
assert.match(source, /aiPanel\.switchTab\(aiTab\.dataset\.aitab\)/);
assert.match(aiPanelControllerModule, /export function createAiPanelController\(deps\)/);
assert.match(aiPanelControllerModule, /export function applyAiOptionState\(type, els\)/);
assert.match(aiPanelControllerModule, /window\._buildModelOptions\(sel\)/);
assert.match(aiPanelControllerModule, /getJson\('\/api\/auth\/me'\)/);
assert.match(aiPanelControllerModule, /deps\.browseWebdav\(deps\.getWebdavPath\(\)\)/);
assert.doesNotMatch(source, /function openAiPanel\(\)|function updateAiOptions\(|function switchAiTab\(/);
assert.doesNotMatch(aiPanelControllerModule, /window\.Tiptap|ai-generate|FormData|generate-pptx/);
});
test('Learning Hub viewer renderers are isolated from API and slide loading', () => {
assert.match(source, /from '\.\/learningHub\/viewerRenderer\.js'/);
assert.match(viewerRendererModule, /export function buildViewerMeta\(item\)/);
assert.match(viewerRendererModule, /export function renderPresentationCard\(item\)/);
assert.match(viewerRendererModule, /export function renderProgressList\(progress\)/);
assert.match(viewerRendererModule, /btn-lh-view-slides/);
assert.doesNotMatch(viewerRendererModule, /fetch\(|getJson|sendJson|deleteJson|openSlidesFromSlug|showViewer/);
});

View file

@ -1,81 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { pathToFileURL } = require('node:url');
const { JSDOM } = require('jsdom');
const root = process.env.PEDAI_CMS_TEST_ROOT || path.join(__dirname, '..');
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
const tick = () => new Promise(resolve => setImmediate(resolve));
test('actual CMS category form creates manually once, supports native submit and preserves failed drafts', async () => {
const dom = new JSDOM('<!doctype html><style>' + read('public/css/styles.css') + '</style><body>' + read('public/components/cms.html') + '</body>');
const keys = ['window', 'document', 'fetch', 'getAuthHeaders', 'showToast'];
const previous = Object.fromEntries(keys.map(key => [key, global[key]]));
const calls = [], categories = [], notices = [];
let fail = false;
global.window = dom.window;
global.document = dom.window.document;
global.getAuthHeaders = () => ({ 'Content-Type': 'application/json' });
global.showToast = (message, type) => notices.push({ message, type });
global.fetch = async (url, options = {}) => {
calls.push({ url, options });
assert.ok(!url.includes('ai-generate'), 'Manual categories must not call AI');
if (options.method === 'POST') {
assert.equal(url, '/api/admin/learning/categories');
const payload = JSON.parse(options.body);
if (fail) return { json: async () => ({ success: false, error: 'Synthetic rejection' }) };
categories.push({ id: categories.length + 1, name: payload.name, slug: 'category-' + (categories.length + 1), content_count: 0 });
return { json: async () => ({ success: true, id: categories.length }) };
}
assert.ok(['/api/admin/learning/categories', '/api/learning/categories'].includes(url), url);
return { json: async () => ({ success: true, categories }) };
};
try {
const input = document.getElementById('lh-cms-cat-name');
const button = document.getElementById('btn-lh-add-cat');
const form = input.closest('form');
assert.ok(form, 'Category entry needs native form submission for Enter');
assert.equal(form.id, 'lh-cms-add-category');
assert.equal(button.type, 'submit');
assert.equal(button.textContent.trim(), 'Add', 'Creation remains discoverable without icon fonts');
assert.equal(input.getAttribute('aria-label'), 'New category name');
assert.match(window.getComputedStyle(input).minWidth, /^0(px)?$/, 'Input must shrink inside the sidebar');
assert.equal(window.getComputedStyle(button).flexShrink, '0', 'The Add control must remain visible');
await import(pathToFileURL(path.join(root, 'public/js/learningHub.js')).href);
const prevented = [];
document.addEventListener('submit', event => prevented.push(event.defaultPrevented));
input.value = ' Manual category ';
button.click();
await tick(); await tick();
assert.equal(calls.filter(c => c.options.method === 'POST').length, 1);
assert.equal(categories[0].name, 'Manual category');
assert.equal(input.value, '');
assert.match(document.getElementById('lh-cms-categories').textContent, /Manual category/);
input.value = 'Keyboard category';
form.requestSubmit(); // Same submit path as the browser's implicit Enter action.
await tick(); await tick();
assert.equal(categories.length, 2);
assert.equal(calls.filter(c => c.options.method === 'POST').length, 2);
input.value = ' ';
form.requestSubmit();
await tick();
assert.equal(calls.filter(c => c.options.method === 'POST').length, 2);
assert.equal(notices.at(-1).message, 'Enter category name');
fail = true;
input.value = 'Keep this draft';
button.click();
await tick(); await tick();
assert.equal(input.value, 'Keep this draft');
assert.equal(notices.at(-1).message, 'Synthetic rejection');
assert.deepEqual(prevented, [true, true, true, true], 'Every path prevents native page navigation');
} finally {
for (const key of keys) global[key] = previous[key];
dom.window.close();
}
});

View file

@ -1,55 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { pathToFileURL } = require('node:url');
async function loadQuizController() {
return import(pathToFileURL(path.join(__dirname, '..', 'public/js/learningHub/quizController.js')).href);
}
function makeRoot(singleSelections, multiSelections) {
return {
querySelector(selector) {
var match = selector.match(/input\[name="lh-q-(\d+)"\]:checked/);
if (!match) return null;
var value = singleSelections[match[1]];
return value == null ? null : { value: String(value) };
},
querySelectorAll(selector) {
var match = selector.match(/input\[name="lh-qm-(\d+)"\]:checked/);
if (!match) return [];
return (multiSelections[match[1]] || []).map(function(value) { return { value: String(value) }; });
}
};
}
test('Learning Hub quiz answers preserve single, unanswered, and multi-select payloads', async () => {
const { buildQuizAnswers } = await loadQuizController();
const questions = [
{ id: 11, question_type: 'mcq' },
{ id: 12, question_type: 'true_false' },
{ id: 13, question_type: 'multi' },
{ id: 14, question_type: 'multi' }
];
const root = makeRoot({ 11: 101 }, { 13: [301, 303] });
assert.deepEqual(buildQuizAnswers(questions, root), [
{ questionId: 11, optionId: 101 },
{ questionId: 12, optionId: null },
{ questionId: 13, optionIds: [301, 303] },
{ questionId: 14, optionIds: [] }
]);
});
test('Learning Hub quiz controller keeps result highlighting semantics', async () => {
const fs = require('node:fs');
const source = fs.readFileSync(path.join(__dirname, '..', 'public/js/learningHub/quizController.js'), 'utf8');
assert.match(source, /function applyQuizResultHighlights\(results\)/);
assert.match(source, /var correctIds = r\.correctOptionIds \|\| \[\]/);
assert.match(source, /var selectedIds = r\.selectedOptionIds \|\| \[\]/);
assert.match(source, /selectedIds\.indexOf\(optId\) !== -1 && correctIds\.indexOf\(optId\) === -1/);
assert.match(source, /optId === r\.correctOptionId/);
assert.match(source, /optId === r\.selectedOptionId && !r\.isCorrect/);
assert.match(source, /input\.disabled = true/);
});

View file

@ -1,72 +0,0 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const { pathToFileURL } = require('node:url');
async function loadWebdavController() {
return import(pathToFileURL(path.join(__dirname, '..', 'public/js/learningHub/webdavController.js')).href);
}
test('Learning Hub WebDAV helpers preserve icons and byte labels', async () => {
const { formatBytes, getFileIcon } = await loadWebdavController();
assert.equal(getFileIcon('application/pdf', 'scan.bin'), 'fa-file-pdf');
assert.equal(getFileIcon('', 'note.md'), 'fa-file-lines');
assert.equal(getFileIcon('', 'page.html'), 'fa-file-code');
assert.equal(getFileIcon('', 'letter.docx'), 'fa-file-word');
assert.equal(getFileIcon('', 'archive.zip'), 'fa-file');
assert.equal(formatBytes(512), '512 B');
assert.equal(formatBytes(2048), '2 KB');
assert.equal(formatBytes(1572864), '1.5 MB');
});
test('Learning Hub WebDAV list rendering escapes paths and names', async () => {
const { renderWebdavListHtml } = await loadWebdavController();
const html = renderWebdavListHtml({
path: '/Learning/Sub',
parentPath: '/Learning?<bad>',
items: [
{ isDir: true, path: '/Learning/<script>', name: 'Peds <Cases>', contentType: '', size: 0 },
{ isDir: false, path: '/Learning/a.pdf', name: 'A&B.pdf', contentType: 'application/pdf', size: 2048 }
]
});
assert.match(html, /lh-webdav-dir/);
assert.match(html, /data-path="\/Learning\?&lt;bad&gt;"/);
assert.match(html, /Peds &lt;Cases&gt;/);
assert.match(html, /A&amp;B\.pdf/);
assert.match(html, /\(2 KB\)/);
assert.doesNotMatch(html, /<script>/);
});
test('Learning Hub WebDAV controller tracks current and selected paths', async () => {
const previousDocument = global.document;
const elements = {
'lh-ai-webdav-browser': { style: {} },
'lh-ai-webdav-selected': { style: {} },
'lh-ai-webdav-selected-name': { textContent: '' }
};
global.document = { getElementById: function(id) { return elements[id] || null; } };
try {
const { createWebdavController } = await loadWebdavController();
const webdav = createWebdavController({});
webdav.setCurrentPath('/Learning');
webdav.selectFile('/Learning/case.pdf', 'case.pdf');
assert.equal(webdav.getCurrentPath(), '/Learning');
assert.equal(webdav.getSelectedPath(), '/Learning/case.pdf');
assert.equal(webdav.getSelectedName(), 'case.pdf');
assert.equal(elements['lh-ai-webdav-browser'].style.display, 'none');
assert.equal(elements['lh-ai-webdav-selected'].style.display, 'flex');
assert.equal(elements['lh-ai-webdav-selected-name'].textContent, 'case.pdf');
webdav.deselectFile();
assert.equal(webdav.getSelectedPath(), '');
assert.equal(webdav.getSelectedName(), '');
assert.equal(elements['lh-ai-webdav-selected'].style.display, 'none');
assert.equal(elements['lh-ai-webdav-browser'].style.display, '');
} finally {
global.document = previousDocument;
}
});

View file

@ -1,42 +0,0 @@
// Source articles for AI generation are held whole in memory to be parsed, so
// the ceiling matters, and the declared type is chosen by whoever uploads.
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const route = fs.readFileSync(path.join(__dirname, '..', 'src/routes/learningAI.js'), 'utf8');
test('the per-file ceiling is 10 MB, in one place', () => {
assert.match(route, /var MAX_SOURCE_BYTES = 10 \* 1024 \* 1024;/);
assert.match(route, /fileSize: MAX_SOURCE_BYTES/);
assert.doesNotMatch(route, /100 \* 1024 \* 1024/, 'the old 100 MB ceiling is gone');
});
test('the type and the extension must both be allowed, not either', () => {
// It was `mime OR extension`, so naming a file .pdf was enough on its own.
assert.match(route, /ALLOWED_SOURCE_TYPES\.includes\(file\.mimetype\) && ALLOWED_SOURCE_EXTENSIONS\.test\(file\.originalname\)/);
assert.doesNotMatch(route, /allowed\.includes\(file\.mimetype\) \|\| file\.originalname\.match/);
});
test('the bytes are sniffed once the buffer exists, before anything parses them', () => {
// multer filters on headers alone, before any byte has arrived.
assert.match(route, /function verifySources\(files\)/);
assert.match(route, /fileType\.matches\(file\.mimetype, file\.buffer\)/);
assert.match(route, /is not the file type it claims to be/);
// And it runs ahead of extraction, not after.
const call = route.indexOf('verifySources(req.files)');
const extract = route.indexOf('await extractText(');
assert.ok(call > -1 && call < extract, 'verification must precede extraction');
});
test('the size is re-checked on the buffer, not trusted from the header', () => {
assert.match(route, /file\.size > MAX_SOURCE_BYTES/);
assert.match(route, /is larger than 10 MB/);
});
test('what the screen promises matches what the server accepts', () => {
const cms = fs.readFileSync(path.join(__dirname, '..', 'public/components/cms.html'), 'utf8');
assert.match(cms, /max 10 MB each, up to 10 files/);
assert.doesNotMatch(cms, /100 MB/);
});

View file

@ -16,17 +16,3 @@ test('top bar model selector is removed and omitted selections use backend defau
assert.match(selector, /return undefined/);
});
test('learning hub model selector uses shared tab model options', () => {
const learningHub = read('public/js/learningHub.js');
const aiPanelController = read('public/js/learningHub/aiPanelController.js');
assert.match(learningHub, /createAiPanelController\(/);
assert.match(aiPanelController, /window\._buildModelOptions\(sel\)/);
assert.doesNotMatch(learningHub, /global-model-select|globalSel\.innerHTML/);
assert.match(learningHub, /if \(model\) formData\.append\('model', model\)/);
});
test('personal notes voice generation sends selected model only when populated', () => {
const recorder = read('public/js/notes/recorder.js');
assert.match(recorder, /var modelEl = document\.getElementById\('notes-model-select'\)/);
assert.match(recorder, /options\.noteFromVoice\(\{ transcript: resp\.text, model: selectedModel \|\| undefined \}\)/);
});

View file

@ -16,7 +16,6 @@ const moduleEntrypoints = [
'public/js/ed-encounters.js',
'public/js/extensions.js',
'public/js/hospitalCourse.js',
'public/js/learningHub.js',
'public/js/memories.js',
'public/js/milestones.js',
'public/js/nextcloud.js',

View file

@ -6,14 +6,13 @@ const path = require('node:path');
const root = path.join(__dirname, '..');
const read = file => fs.readFileSync(path.join(root, file), 'utf8');
test('a persons own resources are a separate pathway from Learning', () => {
test('anyone signed in can generate here — no moderator gate', () => {
const route = read('src/routes/myResources.js');
const learning = read('src/routes/learningAI.js');
// Learning stays moderator-owned. This exists so that not being a moderator
// no longer means not being able to generate anything at all.
assert.match(learning, /router\.use\(moderatorMiddleware\)/, 'Learning is unchanged');
assert.doesNotMatch(route, /moderatorMiddleware/, 'and this one never mentions it');
// This existed alongside Learning Hub, which was moderator-owned, so that not
// being a moderator did not mean not being able to generate anything at all.
// Learning Hub is retired and this is the only generator left; the absence of
// a role gate still matters, and is still worth pinning.
assert.doesNotMatch(route, /moderatorMiddleware/);
assert.match(route, /router\.use\('\/my-resources', authMiddleware\)/,
'signed in is the only requirement, and the gate names its own prefix');
});
@ -170,11 +169,12 @@ test('illustration is opt-in, with its own dispatcher rather than the assistant
assert.match(route, /is about the written resource; a tool call is not a violation of it/);
assert.match(route, /wantsImages: Boolean\(wantsImages && imageModel\)/);
// Its own workflow, not a reuse of learning_hub: generated_image_links only
// accepts learning_hub assets, and that is exactly the barrier keeping a
// private illustration out of published content.
assert.match(read('src/utils/generatedImages.js'), /const workflows = \['clinical_assistant', 'learning_hub', 'my_resources'\];/);
assert.match(read('migrations/1780400000000_my-resources-images.js'), /CHECK \(workflow IN \('clinical_assistant', 'learning_hub', 'my_resources'\)\)/);
// Its own workflow, and the only two left now Learning Hub is retired. The
// barrier this used to describe — keeping a private illustration out of
// published content — is now absolute: there is no published content, and an
// image is readable by its owner alone.
assert.match(read('src/utils/generatedImages.js'), /const workflows = \['clinical_assistant', 'my_resources'\];/);
assert.match(read('migrations/1780800000000_retire-learning-hub.js'), /'clinical_assistant'::text, 'my_resources'::text/);
// Status polling is owner-scoped and workflow-scoped, so it can only report
// on an image the caller made here.
assert.match(route, /service\(\)\.get\(req\.params\.id, req\.user\.id, 'my_resources'\)/);

View file

@ -356,7 +356,7 @@ test('feature routes deny before data/provider access; user status is nonsensiti
const endpoints = [
['POST', '/api/text-to-speech'], ['POST', '/api/nextcloud/connect'], ['POST', '/api/nextcloud/export'], ['POST', '/api/nextcloud/disconnect'],
['GET', '/api/memories'], ['GET', '/api/memories/context'], ['POST', '/api/memories'], ['PUT', '/api/memories/1'], ['DELETE', '/api/memories/1'],
['GET', '/api/admin/learning/webdav-browse'], ['POST', '/api/admin/learning/ai-generate'], ['POST', '/api/user/webdav-path'], ['POST', '/api/admin/learning/webdav-path']
['POST', '/api/user/webdav-path']
];
for (const [method, route] of endpoints) {
const response = await request(route, { method, authenticated: true, body: method === 'GET' ? undefined : { webdavPath: '/synthetic.txt', text: 'Synthetic' } });
@ -375,10 +375,6 @@ test('feature routes deny before data/provider access; user status is nonsensiti
assert.equal((await request('/api/admin/config/announcement')).status, 401);
assert.equal((await request('/api/admin/config', { role: 'user' })).status, 403);
assert.equal((await request('/api/admin/config', { role: 'moderator' })).status, 403);
f.state.aiContent = '{"title":"Synthetic","body":"<p>Synthetic</p>","category_name":"General"}';
const generated = await request('/api/admin/learning/ai-generate', { method: 'POST', role: 'moderator', body: { topic: 'Synthetic educational topic' } });
assert.equal(generated.status, 200); assert.equal(generated.data.success, true);
assert.equal((await request('/api/admin/learning/ai-generate', { method: 'POST', role: 'user', body: { topic: 'Synthetic' } })).status, 403);
f.state.settings['feature.memories'] = 'true';
assert.equal((await request('/api/memories', { role: 'user' })).data.memories.length, 1);
assert.match((await request('/api/memories/context', { role: 'user' })).data.context, /MEMORY_SENTINEL/);
@ -467,8 +463,10 @@ test('enabled personal Nextcloud and memory CRUD remain usable through actual ha
const response = await request(route, { method: 'POST', role: 'user', body });
assert.equal(response.status, 200, route); assert.equal(response.data.success, true);
}
const browse = await request('/api/admin/learning/webdav-browse', { role: 'moderator' });
assert.equal(browse.status, 200); assert.equal(browse.data.success, true);
// Export is the one thing that still reaches Nextcloud; the WebDAV browser
// went with Learning Hub.
const exported = await request('/api/nextcloud/export', { method: 'POST', role: 'user', body: { text: 'Synthetic note', docType: 'note' } });
assert.equal(exported.status, 200); assert.equal(exported.data.success, true);
assert.ok(f.state.requests.some(r => r.dav && r.dav.method === 'PUT'));
for (const [method, route] of [['POST', '/api/memories'], ['PUT', '/api/memories/1'], ['DELETE', '/api/memories/1']]) {
const response = await request(route, { method, role: 'user', body: { name: 'Synthetic template', category: 'physical_exam', content: 'Synthetic content' } });
@ -476,32 +474,6 @@ test('enabled personal Nextcloud and memory CRUD remain usable through actual ha
}
});
test('LearningAI saved WebDAV path requires enabled Nextcloud for admins and moderators, without writes on denial/outage', async t => {
const f = fixture(); const request = await f.serve(t, true);
for (const role of ['admin', 'moderator']) {
for (const unavailable of [false, true]) {
f.state.settings['feature.nextcloud'] = 'false';
f.state.settingsError = unavailable;
const before = f.state.writes.length;
const response = await request('/api/admin/learning/webdav-path', { method: 'POST', role, body: { path: '/Synthetic' } });
assert.equal(response.status, unavailable ? 503 : 403);
assert.equal(f.state.writes.length, before);
assert.equal(f.state.requests.length, 0);
}
f.state.settingsError = false;
f.state.settings['feature.nextcloud'] = 'true';
const before = f.state.writes.length;
const response = await request('/api/admin/learning/webdav-path', { method: 'POST', role, body: { path: '/Synthetic' } });
assert.equal(response.status, 200); assert.equal(response.data.success, true);
assert.equal(f.state.writes.length, before + 1);
assert.match(f.state.writes.at(-1).sql, /UPDATE users SET webdav_learning_path/);
assert.deepEqual(Array.from(f.state.writes.at(-1).params), ['/Synthetic', 7]);
}
const before = f.state.writes.length;
assert.equal((await request('/api/admin/learning/webdav-path', { method: 'POST', role: 'user', body: { path: '/Synthetic' } })).status, 403);
assert.equal(f.state.writes.length, before);
});
test('actual native admin script disables selected default, displays backend replacement, and saves it; discovery excludes disabled options', async t => {
const { JSDOM } = require('jsdom');
const { pathToFileURL } = require('node:url');

View file

@ -34,9 +34,9 @@ test('actual app hides disabled/lazy-loaded feature UI, uses only advertised mod
window._defaultModelId = 'removed'; window._buildModelOptions(select);
assert.equal(select.options.length, 1); assert.equal(select.options[0].value, 'allowed');
await window.loadUserFeatures();
window.document.getElementById('lazy').innerHTML = read('public/components/settings.html') + read('public/components/cms.html');
window.document.getElementById('lazy').innerHTML = read('public/components/settings.html');
const hidden = selector => assert.equal(window.getComputedStyle(window.document.querySelector(selector)).display, 'none', selector);
hidden('#read'); hidden('#export'); hidden('[data-feature="read_aloud"]'); hidden('[data-feature="memories"]'); hidden('[data-feature="nextcloud"]'); hidden('#lh-ai-tab-webdav'); hidden('#lh-ai-tp-webdav');
hidden('#read'); hidden('#export'); hidden('[data-feature="read_aloud"]'); hidden('[data-feature="memories"]'); hidden('[data-feature="nextcloud"]');
window.speakText('synthetic-note'); assert.equal(ttsCalls, 0);
features = { read_aloud: true, nextcloud: true, memories: true };
await window.loadUserFeatures();

View file

@ -89,76 +89,6 @@ test('auth rejects missing last session and session-store errors, preserving val
}
});
test('actual quiz write endpoints reject invalid types before all mutations and preserve valid defaults/types', async () => {
let storedType = 'multi';
const writes = [];
const router = load('src/routes/learningAdmin.js', {
express, '../middleware/auth': authStub, '../utils/embeddings': {},
'../utils/generatedImageLinks': require('../src/utils/generatedImageLinks'),
'../db/database': {
async get(sql) {
return sql.includes('MAX(') ? { mx: 0 } : { id: 9, question_text: 'Question', question_type: storedType };
},
async run(sql, params) { writes.push({ sql, params }); return { lastInsertRowid: 9 }; }
}
});
const create = endpoint(router, 'post', '/content/:contentId/questions');
const update = endpoint(router, 'put', '/questions/:id');
const badTypes = ['', null, false, 0, [], {}, 'MCQ', 'unknown', '\"><img src=x onerror=alert(1)>'];
for (const handler of [create, update]) {
for (const question_type of badTypes) {
writes.length = 0;
const res = response();
await handler({ params: { id: 9, contentId: 1 }, body: {
question_text: 'Q', question_type, options: [{ option_text: 'x' }]
} }, res);
assert.equal(res.code, 400, JSON.stringify(question_type));
assert.equal(writes.length, 0, 'Neither questions nor options may mutate');
}
for (const type of ['mcq', 'true_false', 'multi', undefined]) {
writes.length = 0;
const res = response();
await handler({ params: { id: 9, contentId: 1 }, body: {
question_text: 'Q', ...(type === undefined ? {} : { question_type: type })
} }, res);
assert.equal(res.code, 200);
assert.equal(writes[0].params[handler === create ? 2 : 1], type || (handler === create ? 'mcq' : storedType));
}
}
storedType = 'legacy-invalid';
writes.length = 0;
const res = response();
await update({ params: { id: 9 }, body: { question_text: 'Q' } }, res);
assert.equal(res.code, 400, 'Invalid legacy type must be explicitly corrected');
assert.equal(writes.length, 0);
});
test('actual quiz renderer contains malicious legacy metadata and preserves quiz input semantics', async () => {
const dom = new JSDOM('<div id="root"></div>', { virtualConsole: pageConsole() });
global.window = dom.window;
global.document = dom.window.document;
window.DOMPurify = require('dompurify')(window);
try {
const { renderQuizQuestions } = await import(pathToFileURL(path.join(root, 'public/js/learningHub/quizRenderer.js')).href);
const malicious = '\"><span id="injected"></span><div onmouseover="alert(1)';
const types = ['mcq', 'true_false', 'multi', malicious];
document.getElementById('root').innerHTML = renderQuizQuestions(types.map((question_type, id) => ({
id, question_type, question_text: '<strong>Question</strong>', options: [{ id: id + 10, option_text: 'Answer' }]
})));
const questions = [...document.querySelectorAll('.lh-quiz-q')];
assert.equal(questions.length, 4);
assert.equal(document.querySelector('#injected, [onmouseover]'), null);
questions.forEach((el, i) => {
assert.equal(el.dataset.qtype, types[i]);
assert.equal(el.querySelector('input').type, i === 2 ? 'checkbox' : 'radio');
assert.equal(el.querySelector('input').name, (i === 2 ? 'lh-qm-' : 'lh-q-') + i);
assert.equal(el.querySelector('strong').textContent, 'Question');
});
} finally {
dom.window.close(); delete global.window; delete global.document;
}
});
function encounterServer() {
const rows = [];
const db = {