Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 47s
Forgejo Docker Build / Root app tests (push) Successful in 48s
Forgejo Android APK / Build signed APK (push) Successful in 2m38s
Forgejo Docker Build / Build Docker image (push) Successful in 12s
Forgejo Docker Build / Deploy to the host (push) Failing after 2s
Three pairs of docs described the same thing twice, and the copies had drifted
apart. Merged each into one file, keeping the unique content from both:
- ARCHITECTURE.md -> architecture.md (its operational map: ownership, request
flow, runtime boundaries, source of truth, deployment shape)
- DEVELOPMENT.md -> developer-guide.md (change workflow, Clinical Assistant
high-risk areas, frontend rendering rules, deployment checks)
- transcription-options.md -> speech.md (the clinic setup table, and the list
of browser-Whisper paths that must stay removed)
Then audited what remained against the code and the live database rather than
against the previous docs. Corrected:
- Google Vertex was still documented as a provider across nine files. The SDK
is gone; AI_PROVIDER=vertex now logs an advisory and falls back to
OpenRouter, and Gemini is reached through LiteLLM. Fixed the provider
selection order to match src/utils/ai.js, which starts from LITELLM_API_BASE.
- promptSafe was documented on 8 routes; it is on 13.
- Node 20 -> 24, "24 vanilla JS modules" -> no fixed count, and
transcribe.js/tts.js -> sttProvider.js/ttsProvider.js, which is what exists.
- STT/TTS are LiteLLM-only; README listed direct Google, AWS Transcribe and
ElevenLabs paths that are not in the runtime.
- Learning Hub PPTX export was documented as pptxgenjs, which is not a
dependency. It is pandoc against a reference deck.
- POST /api/admin/milestones/seed does not exist; it is /bulk-import.
- NEXTCLOUD_URL and NTFY_TOPIC are not read anywhere. Nextcloud is per-user in
the users table, and the ntfy topic is derived as pedscribe-{userId}.
- A prose paragraph sat inside the Clinical Assistant settings table, so half
the rows rendered as text.
Filled the gaps the audit exposed:
- database.md was missing 12 of 29 tables, including user_resources,
personal_notes, login_codes, registration_invites and generated_image_jobs.
- developer-guide.md was missing 11 routers and 10 frontend modules.
- api-reference.md detailed 121 of 244 endpoints and said so, but whole
features were absent. Added an endpoint index covering Clinical Assistant,
My Resources, Notes, Diagrams, ED Encounters, invites and sign-in codes.
- configuration.md was missing METRICS_TOKEN, REDIS_URL, API_RATE_LIMIT_MAX,
the LITELLM_* model variables, the DB_* ones maintenance.js reads, and the
per-purpose S3 resolution scheme.
- clinical-assistant.md documented 2 of its 17 environment variables.
- features-explained.md had no entry for My Resources or Clinical Assistant.
Renamed the three remaining SHOUTING filenames to kebab-case, which is what the
docs viewer's prettyName() was working around, and rewrote README's index,
which listed architecture.md twice and omitted nine files.
Noted but not changed: the Turnstile site key is hardcoded in index.html rather
than read from TURNSTILE_SITE_KEY, and /api/health/detailed can report
tts: 'elevenlabs' though no ElevenLabs path exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
378 lines
14 KiB
JavaScript
378 lines
14 KiB
JavaScript
// ============================================================
|
|
// admin-docs.js — admin-only docs viewer.
|
|
//
|
|
// Lazy-loads /api/admin/docs/tree once on first tab activation,
|
|
// renders a collapsible file tree in the sidebar. Clicking a .md
|
|
// node fetches /api/admin/docs/file?path=X and renders the
|
|
// pre-sanitised HTML returned by the server (server uses `marked`).
|
|
//
|
|
// The route is gated to role=admin server-side. The sidebar tab
|
|
// button (#docs-tab-btn) is hidden by default and revealed by
|
|
// auth.js after login when user.role === 'admin'.
|
|
// ============================================================
|
|
|
|
var _inited = false;
|
|
var _treeLoaded = false;
|
|
var _tree = [];
|
|
var _flatFiles = []; // flat list of {name, path, parent} for filter
|
|
var _expanded = {}; // map of dir-path → bool, persisted in UIState
|
|
var _currentPath = '';
|
|
|
|
function $(id) { return document.getElementById(id); }
|
|
function escHtml(s) { return String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); }
|
|
|
|
// Pretty label from a file/dir basename. README is shown as "Index" so the
|
|
// entry-point doc is more obvious in the tree.
|
|
//
|
|
// Both separators, and the case is normalised rather than only capitalised:
|
|
// uppercasing the first letter of each word leaves a SHOUTING_FILENAME
|
|
// shouting. The docs tree has since been renamed to kebab-case throughout,
|
|
// but the normalisation stays: a new doc added in that style still renders
|
|
// as a title rather than as a constant next to "Learning Hub".
|
|
var ACRONYMS = { ai: 'AI', api: 'API', ui: 'UI', id: 'ID', oidc: 'OIDC', sso: 'SSO',
|
|
stt: 'STT', tts: 'TTS', pdf: 'PDF', faq: 'FAQ', mcp: 'MCP', ped: 'Ped',
|
|
openid: 'OpenID', litellm: 'LiteLLM', milvus: 'Milvus' };
|
|
var MINOR = { and: 1, or: 1, the: 1, a: 1, an: 1, of: 1, to: 1, in: 1, for: 1, with: 1 };
|
|
|
|
function prettyName(name, isDir) {
|
|
if (!isDir && /^readme\.md$/i.test(name)) return 'Index';
|
|
var base = name.replace(/\.md$/i, '');
|
|
var words = base.replace(/[-_]+/g, ' ').trim().split(/\s+/);
|
|
return words.map(function (word, i) {
|
|
var lower = word.toLowerCase();
|
|
if (ACRONYMS[lower]) return ACRONYMS[lower];
|
|
// Small joining words stay lowercase unless they open the title.
|
|
if (i > 0 && MINOR[lower]) return lower;
|
|
return lower.charAt(0).toUpperCase() + lower.slice(1);
|
|
}).join(' ');
|
|
}
|
|
|
|
// Recursively render the tree into HTML.
|
|
function renderNode(node, depth) {
|
|
if (node.type === 'dir') {
|
|
var open = _expanded[node.path] !== false; // default open
|
|
var arrow = open ? 'fa-chevron-down' : 'fa-chevron-right';
|
|
var label = prettyName(node.name, true);
|
|
var html =
|
|
'<div class="docs-node docs-dir" data-path="' + escHtml(node.path) + '" style="--depth:' + depth + ';">' +
|
|
'<button type="button" class="docs-dir-toggle" data-toggle="' + escHtml(node.path) + '">' +
|
|
'<i class="fas ' + arrow + ' docs-arrow"></i>' +
|
|
'<i class="fas fa-folder docs-icon"></i>' +
|
|
'<span class="docs-label">' + escHtml(label) + '</span>' +
|
|
'</button>' +
|
|
'<div class="docs-children" ' + (open ? '' : 'hidden') + '>' +
|
|
(node.children || []).map(function (c) { return renderNode(c, depth + 1); }).join('') +
|
|
'</div>' +
|
|
'</div>';
|
|
return html;
|
|
}
|
|
// File node
|
|
return '<div class="docs-node docs-file" data-path="' + escHtml(node.path) + '" style="--depth:' + depth + ';">' +
|
|
'<button type="button" class="docs-file-btn" data-file="' + escHtml(node.path) + '" title="' + escHtml(node.path) + '">' +
|
|
'<i class="fas fa-file-lines docs-icon"></i>' +
|
|
'<span class="docs-label">' + escHtml(prettyName(node.name, false)) + '</span>' +
|
|
'</button>' +
|
|
'</div>';
|
|
}
|
|
|
|
// Flatten the tree into _flatFiles for the filter to search across.
|
|
function flatten(nodes, parentLabel) {
|
|
nodes.forEach(function (n) {
|
|
if (n.type === 'dir') {
|
|
flatten(n.children || [], (parentLabel ? parentLabel + ' / ' : '') + prettyName(n.name, true));
|
|
} else {
|
|
_flatFiles.push({
|
|
name: prettyName(n.name, false),
|
|
path: n.path,
|
|
parent: parentLabel || ''
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderTree() {
|
|
var host = $('docs-tree');
|
|
if (!host) return;
|
|
if (!_tree.length) {
|
|
host.innerHTML = '<div class="docs-empty">No docs found.</div>';
|
|
return;
|
|
}
|
|
host.innerHTML = _tree.map(function (n) { return renderNode(n, 0); }).join('');
|
|
}
|
|
|
|
function renderFilterResults(q) {
|
|
var host = $('docs-tree');
|
|
if (!host) return;
|
|
var lc = q.toLowerCase();
|
|
var matches = _flatFiles.filter(function (f) {
|
|
return f.name.toLowerCase().indexOf(lc) !== -1
|
|
|| f.path.toLowerCase().indexOf(lc) !== -1
|
|
|| f.parent.toLowerCase().indexOf(lc) !== -1;
|
|
});
|
|
if (matches.length === 0) {
|
|
host.innerHTML = '<div class="docs-empty">No matches for "' + escHtml(q) + '".</div>';
|
|
return;
|
|
}
|
|
host.innerHTML = matches.map(function (f) {
|
|
var parent = f.parent ? '<span class="docs-file-parent">' + escHtml(f.parent) + '</span>' : '';
|
|
return '<div class="docs-node docs-file" data-path="' + escHtml(f.path) + '" style="--depth:0;">' +
|
|
'<button type="button" class="docs-file-btn" data-file="' + escHtml(f.path) + '" title="' + escHtml(f.path) + '">' +
|
|
'<i class="fas fa-file-lines docs-icon"></i>' +
|
|
'<span class="docs-label">' + escHtml(f.name) + '</span>' +
|
|
parent +
|
|
'</button>' +
|
|
'</div>';
|
|
}).join('');
|
|
}
|
|
|
|
// marked no longer guarantees heading ids across versions, and the docs
|
|
// reader is an internal scroll container. Add stable GitHub-style ids and
|
|
// handle same-page #toc links by scrolling the reader, not the window.
|
|
function slugHeading(text) {
|
|
return String(text || '')
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/\s/g, '-')
|
|
.replace(/[^a-z0-9_-]/g, '');
|
|
}
|
|
|
|
function prepareDocAnchors(body) {
|
|
if (!body) return;
|
|
var seen = {};
|
|
body.querySelectorAll('h1,h2,h3,h4,h5,h6').forEach(function (h) {
|
|
var base = slugHeading(h.textContent || '') || h.id;
|
|
if (!base) return;
|
|
var id = base;
|
|
var n = seen[base] || 0;
|
|
if (n) id = base + '-' + n;
|
|
seen[base] = n + 1;
|
|
h.id = id;
|
|
});
|
|
}
|
|
|
|
function prepareDocLinks(body) {
|
|
if (!body) return;
|
|
body.querySelectorAll('a[href]').forEach(function (link) {
|
|
var href = link.getAttribute('href') || '';
|
|
if (href.charAt(0) === '#') {
|
|
link.dataset.docHash = href;
|
|
link.classList.add('docs-anchor-link');
|
|
return;
|
|
}
|
|
if (/\.md(#.*)?$/i.test(href)) {
|
|
var parts = href.split('#');
|
|
link.dataset.docFile = resolveDocPath(parts[0] || _currentPath);
|
|
if (parts[1]) link.dataset.docHash = '#' + parts[1];
|
|
link.classList.add('docs-anchor-link');
|
|
return;
|
|
}
|
|
if (link.hash) {
|
|
try {
|
|
var u = new URL(link.href, window.location.href);
|
|
if (u.origin !== window.location.origin || u.pathname !== window.location.pathname) return;
|
|
link.dataset.docHash = u.hash;
|
|
link.classList.add('docs-anchor-link');
|
|
} catch (_) { return; }
|
|
}
|
|
});
|
|
}
|
|
|
|
function resolveDocPath(href) {
|
|
if (!href) return _currentPath;
|
|
if (href.charAt(0) === '/') return href.replace(/^\/+/, '');
|
|
var base = _currentPath.split('/');
|
|
base.pop();
|
|
href.split('/').forEach(function (part) {
|
|
if (!part || part === '.') return;
|
|
if (part === '..') base.pop();
|
|
else base.push(part);
|
|
});
|
|
return base.join('/');
|
|
}
|
|
|
|
function scrollReaderToHash(hash) {
|
|
var body = $('docs-reader-body');
|
|
var reader = $('docs-reader');
|
|
if (!body || !reader || !hash) return false;
|
|
var rawId = String(hash).replace(/^#/, '');
|
|
var id;
|
|
try { id = decodeURIComponent(rawId); } catch (_) { id = rawId; }
|
|
if (!id) return false;
|
|
var target = document.getElementById(id);
|
|
if (!target || !body.contains(target)) {
|
|
target = Array.prototype.slice.call(body.querySelectorAll('h1,h2,h3,h4,h5,h6')).find(function (h) {
|
|
return slugHeading(h.textContent || '') === id;
|
|
});
|
|
}
|
|
if (!target || !body.contains(target)) return false;
|
|
var readerBox = reader.getBoundingClientRect();
|
|
var targetBox = target.getBoundingClientRect();
|
|
reader.scrollTop += targetBox.top - readerBox.top - 8;
|
|
return true;
|
|
}
|
|
|
|
function handleDocAnchorClick(e) {
|
|
var body = $('docs-reader-body');
|
|
var link = e.target.closest('a, [data-doc-hash], [data-doc-file]');
|
|
if (!link || !body || !body.contains(link)) return false;
|
|
var href = link.dataset.docHash || link.getAttribute('href') || '';
|
|
var file = link.dataset.docFile || '';
|
|
if (!file && href && /\.md(#.*)?$/i.test(href)) {
|
|
var parts = href.split('#');
|
|
file = resolveDocPath(parts[0] || _currentPath);
|
|
href = parts[1] ? '#' + parts[1] : '';
|
|
}
|
|
if (!file && (!href || href.charAt(0) !== '#')) return false;
|
|
e.preventDefault();
|
|
if (file && file !== _currentPath) {
|
|
loadFile(file, href);
|
|
markActive(file);
|
|
return true;
|
|
}
|
|
requestAnimationFrame(function () {
|
|
if (scrollReaderToHash(href)) {
|
|
try { history.replaceState(null, '', href); } catch (_) {}
|
|
}
|
|
});
|
|
return true;
|
|
}
|
|
|
|
function handleDocAnchorKeydown(e) {
|
|
if (e.key !== 'Enter' && e.key !== ' ') return;
|
|
if (handleDocAnchorClick(e)) e.preventDefault();
|
|
}
|
|
|
|
function loadTree() {
|
|
if (_treeLoaded) return;
|
|
fetch('/api/admin/docs/tree', { headers: getAuthHeaders() })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) {
|
|
if (!data || !data.success) {
|
|
$('docs-tree').innerHTML = '<div class="docs-empty">' + escHtml((data && data.error) || 'Tree load failed') + '</div>';
|
|
return;
|
|
}
|
|
_tree = data.tree || [];
|
|
_flatFiles = [];
|
|
flatten(_tree, '');
|
|
// Restore expanded state from UIState before render
|
|
try {
|
|
var saved = window.UIState && window.UIState.get('docs.expanded');
|
|
if (saved) _expanded = JSON.parse(saved);
|
|
} catch (e) {}
|
|
renderTree();
|
|
_treeLoaded = true;
|
|
// Auto-load the top README if available, else the first file
|
|
var first = _flatFiles[0];
|
|
if (first) {
|
|
var savedPath = (window.UIState && window.UIState.get('docs.lastPath')) || first.path;
|
|
loadFile(savedPath);
|
|
markActive(savedPath);
|
|
}
|
|
})
|
|
.catch(function (err) {
|
|
$('docs-tree').innerHTML = '<div class="docs-empty">Tree load failed: ' + escHtml(err.message || String(err)) + '</div>';
|
|
});
|
|
}
|
|
|
|
function loadFile(relPath, hash) {
|
|
var body = $('docs-reader-body');
|
|
var meta = $('docs-reader-meta');
|
|
if (!body) return;
|
|
_currentPath = relPath;
|
|
body.innerHTML = '<div class="docs-loading">Loading…</div>';
|
|
fetch('/api/admin/docs/file?path=' + encodeURIComponent(relPath), { headers: getAuthHeaders() })
|
|
.then(function (r) { return r.json(); })
|
|
.then(function (data) {
|
|
if (!data || !data.success) {
|
|
body.innerHTML = '<p style="color:var(--red);">' + escHtml((data && data.error) || 'Load failed') + '</p>';
|
|
return;
|
|
}
|
|
body.innerHTML = data.html || '';
|
|
prepareDocAnchors(body);
|
|
prepareDocLinks(body);
|
|
if (meta) {
|
|
meta.textContent = relPath + ' • ' + (data.bytes != null ? (data.bytes + ' bytes') : '');
|
|
}
|
|
// Persist last opened
|
|
if (window.UIState) window.UIState.set('docs.lastPath', relPath);
|
|
// Scroll content area to top so deep-link readers don't land mid-doc
|
|
var reader = $('docs-reader');
|
|
if (reader) reader.scrollTop = 0;
|
|
var targetHash = hash || window.location.hash;
|
|
if (targetHash) setTimeout(function () { scrollReaderToHash(targetHash); }, 0);
|
|
})
|
|
.catch(function (err) {
|
|
body.innerHTML = '<p style="color:var(--red);">' + escHtml(err.message || String(err)) + '</p>';
|
|
});
|
|
}
|
|
|
|
function markActive(relPath) {
|
|
document.querySelectorAll('.docs-file-btn').forEach(function (b) {
|
|
b.classList.toggle('active', b.dataset.file === relPath);
|
|
});
|
|
}
|
|
|
|
function persistExpanded() {
|
|
if (!window.UIState) return;
|
|
try { window.UIState.set('docs.expanded', JSON.stringify(_expanded)); } catch (e) {}
|
|
}
|
|
|
|
// ── Wire events ────────────────────────────────────────────────────
|
|
function init() {
|
|
document.addEventListener('click', function (e) {
|
|
if (handleDocAnchorClick(e)) return;
|
|
var fileBtn = e.target.closest('.docs-file-btn');
|
|
if (fileBtn) {
|
|
var p = fileBtn.dataset.file;
|
|
if (p) { loadFile(p); markActive(p); }
|
|
return;
|
|
}
|
|
var dirToggle = e.target.closest('.docs-dir-toggle');
|
|
if (dirToggle) {
|
|
var dir = dirToggle.dataset.toggle;
|
|
var node = dirToggle.closest('.docs-dir');
|
|
var children = node && node.querySelector('.docs-children');
|
|
var arrow = dirToggle.querySelector('.docs-arrow');
|
|
var willOpen = !(_expanded[dir] !== false); // toggle
|
|
_expanded[dir] = willOpen;
|
|
if (children) {
|
|
if (willOpen) children.removeAttribute('hidden');
|
|
else children.setAttribute('hidden', '');
|
|
}
|
|
if (arrow) {
|
|
arrow.classList.toggle('fa-chevron-down', willOpen);
|
|
arrow.classList.toggle('fa-chevron-right', !willOpen);
|
|
}
|
|
persistExpanded();
|
|
return;
|
|
}
|
|
});
|
|
|
|
var body = $('docs-reader-body');
|
|
if (body && !body.dataset.anchorsWired) {
|
|
body.dataset.anchorsWired = '1';
|
|
body.addEventListener('click', handleDocAnchorClick);
|
|
body.addEventListener('keydown', handleDocAnchorKeydown);
|
|
}
|
|
|
|
var filter = $('docs-filter');
|
|
if (filter) {
|
|
var t = null;
|
|
filter.addEventListener('input', function () {
|
|
clearTimeout(t);
|
|
t = setTimeout(function () {
|
|
var q = filter.value.trim();
|
|
if (!q) renderTree();
|
|
else renderFilterResults(q);
|
|
}, 120);
|
|
});
|
|
}
|
|
}
|
|
|
|
document.addEventListener('tabChanged', function (e) {
|
|
if (!e.detail || e.detail.tab !== 'docs') return;
|
|
if (!_inited) { _inited = true; init(); }
|
|
loadTree();
|
|
});
|
|
|
|
console.log('Admin docs viewer loaded');
|