feat: implement CPP08 C++20 card

This commit is contained in:
user
2026-07-21 20:11:42 +02:00
commit f99e7fc5dc
36 changed files with 7614 additions and 0 deletions
+75
View File
@@ -0,0 +1,75 @@
import { watch } from 'node:fs';
import { access } from 'node:fs/promises';
import path from 'node:path';
import { spawn } from 'node:child_process';
const repositoryRoot = path.resolve(process.cwd());
const layoutsRoot = path.resolve(process.env.CARD_LAYOUTS_ROOT ?? path.join(repositoryRoot, '../../../tools/card-layouts'));
const renderScript = path.join(repositoryRoot, 'scripts/render_card_layouts.sh');
const watched = [
[path.join(repositoryRoot, 'json'), name => name === 'card_source.json'],
[path.join(repositoryRoot, 'doc/assets'), () => true],
[path.join(layoutsRoot, 'react/src'), name => /\.(tsx?|css)$/.test(name)],
[path.join(layoutsRoot, 'react'), name => /^(build\.mjs|tsconfig\.json)$/.test(name)],
[path.join(layoutsRoot, 'tools'), name => name === 'render_card.py'],
[path.join(layoutsRoot, 'schemas'), name => name === 'card-source.schema.json']
];
let build = null;
let queued = false;
let debounce = null;
function render() {
if (build) {
queued = true;
return;
}
process.stdout.write('[card-dev] generowanie karty…\n');
build = spawn(renderScript, {
cwd: repositoryRoot,
env: { ...process.env, CARD_LAYOUTS_ROOT: layoutsRoot },
stdio: 'inherit'
});
build.once('exit', code => {
build = null;
process.stdout.write(code === 0
? '[card-dev] karta gotowa; przeglądarka odświeży się automatycznie.\n'
: `[card-dev] generator zakończył się kodem ${code}.\n`);
if (queued) {
queued = false;
render();
}
});
}
function scheduleRender() {
clearTimeout(debounce);
debounce = setTimeout(render, 180);
}
await access(renderScript);
for (const [directory, accepts] of watched) {
try {
await access(directory);
watch(directory, (_event, filename) => {
if (filename && accepts(String(filename))) scheduleRender();
});
process.stdout.write(`[card-dev] obserwuję ${directory}\n`);
} catch {
process.stderr.write(`[card-dev] pomijam brakujący katalog ${directory}\n`);
}
}
const server = spawn(process.execPath, ['--watch', 'scripts/card_server.mjs'], {
cwd: repositoryRoot,
stdio: 'inherit'
});
const stop = signal => {
if (build) build.kill(signal);
server.kill(signal);
process.exit(0);
};
process.on('SIGINT', () => stop('SIGINT'));
process.on('SIGTERM', () => stop('SIGTERM'));
render();
+918
View File
@@ -0,0 +1,918 @@
import { createHash, randomUUID } from 'node:crypto';
import { execFile } from 'node:child_process';
import { createServer } from 'node:http';
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { promisify } from 'node:util';
import Fastify from 'fastify';
import websocket from '@fastify/websocket';
import {
STATUSES,
catalogFromCard,
normaliseProgress,
progressSummary,
readJson,
statusLabel,
teamsMarkdown,
updateItem,
writeJsonAtomically
} from './lib/card_progress.mjs';
import {
attachNvimUi,
callSelectedNvim,
resizeSelectedNvimUi,
selectedNvimTarget
} from './lib/nvim_ui_bridge.mjs';
import { activateCheckpoint, checkpointStatus } from './lib/checkpoint_controller.mjs';
import { CardStateDatabase } from './lib/card_state_db.mjs';
import {
activateNavigation,
KEYBOARD_SHORTCUTS,
moveNavigation,
navigationCatalog,
normalizeViewerState,
patchViewerState,
selectNavigation
} from './lib/card_control.mjs';
const repositoryRoot = path.resolve(process.cwd());
const cardFile = path.join(repositoryRoot, 'json/card_source.json');
const progressFile = path.resolve(process.env.CARD_PROGRESS_FILE ?? path.join(repositoryRoot, 'json/lesson_progress.json'));
const stateDatabaseFile = path.resolve(
process.env.CARD_STATE_DATABASE ?? path.join(repositoryRoot, '.stem/card-state.sqlite3')
);
const apiSocket = path.resolve(
process.env.CARD_API_SOCKET ?? path.join(repositoryRoot, '.stem/card-api.sock')
);
const evidenceRoot = path.resolve(
process.env.CARD_EVIDENCE_ROOT ?? path.join(repositoryRoot, '.stem/evidence')
);
const webRoot = path.join(repositoryRoot, 'web');
const host = process.env.CARD_HOST ?? '127.0.0.1';
const port = Number(process.env.CARD_PORT ?? 8080);
const app = Fastify({ logger: true });
const execFileAsync = promisify(execFile);
const stateDatabase = new CardStateDatabase(stateDatabaseFile);
await app.register(websocket, {
options: {
maxPayload: 64 * 1024
}
});
const contentTypes = {
'.css': 'text/css; charset=utf-8',
'.html': 'text/html; charset=utf-8',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.js': 'text/javascript; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.puml': 'text/plain; charset=utf-8',
'.svg': 'image/svg+xml; charset=utf-8',
'.webp': 'image/webp'
};
let writeQueue = Promise.resolve();
let currentNavigation = stateDatabase.read('navigation');
let navigationRevision = Number.isSafeInteger(Number(currentNavigation?.revision))
? Number(currentNavigation.revision)
: 0;
const persistedViewer = stateDatabase.read('viewer');
let currentViewer = normalizeViewerState(persistedViewer);
if (persistedViewer && JSON.stringify(persistedViewer) !== JSON.stringify(currentViewer)) {
stateDatabase.write('viewer', currentViewer);
}
let viewerRevision = Number.isSafeInteger(Number(currentViewer?.revision))
? Number(currentViewer.revision)
: 0;
function actor(value, fallback = 'system') {
return typeof value === 'string' && value.trim()
? value.trim().slice(0, 160)
: fallback;
}
async function zoomSelectedHostPane() {
const selected = await selectedNvimTarget();
const instance = String(selected.metadata?.instance ?? '');
const result = await execFileAsync('tmux', [
'list-panes', '-a', '-F',
'#{pane_id}\t#{@stem_instance}\t#{@stem_role}\t#{window_zoomed_flag}'
]);
const panes = result.stdout.trim().split('\n').filter(Boolean).map(line => {
const [id, stemInstance, role, zoomed] = line.split('\t');
return { id, stemInstance, role, zoomed };
});
const exact = panes.find(pane => pane.stemInstance === instance && pane.role === 'debugger');
const debuggerPanes = panes.filter(pane => pane.role === 'debugger');
const pane = exact ?? (debuggerPanes.length === 1 ? debuggerPanes[0] : null);
if (!pane) return { found: false, instance };
if (pane.zoomed !== '1') {
await execFileAsync('tmux', ['resize-pane', '-Z', '-t', pane.id]);
}
const dimensions = await execFileAsync('tmux', [
'display-message', '-p', '-t', pane.id,
'#{pane_width}x#{pane_height}\t#{window_zoomed_flag}'
]);
const [size, zoomed] = dimensions.stdout.trim().split('\t');
return { found: true, pane: pane.id, instance, size, zoomed: zoomed === '1' };
}
function expectedNvimGrid(hostPane) {
const match = hostPane?.found && typeof hostPane.size === 'string'
? /^(\d+)x(\d+)$/.exec(hostPane.size)
: null;
if (!match) return null;
return {
width: Number(match[1]),
// The nested tmux status line occupies one row below the Neovim pane.
height: Math.max(1, Number(match[2]) - 1)
};
}
async function settleSelectedNvimTerminalUi(hostPane) {
const target = expectedNvimGrid(hostPane);
let innerTmux = null;
try {
innerTmux = await callSelectedNvim('nvim_exec_lua', [`
if not vim.env.TMUX or vim.env.TMUX == '' then
return { attempted = false }
end
local result = vim.system(
{ 'tmux', 'resize-window', '-A' },
{ text = true }
):wait()
return {
attempted = true,
code = result.code,
stderr = vim.trim(result.stderr or '')
}
`, []]);
} catch (error) {
innerTmux = { attempted: true, error: error instanceof Error ? error.message : String(error) };
}
const deadline = Date.now() + 1800;
let terminal = null;
do {
const uis = await callSelectedNvim('nvim_list_uis', []);
terminal = Array.isArray(uis)
? uis.find(ui => ui?.stdin_tty && ui?.stdout_tty) ?? null
: null;
if (
terminal
&& (!target || (terminal.width >= target.width && terminal.height >= target.height))
) break;
await new Promise(resolve => setTimeout(resolve, 80));
} while (Date.now() < deadline);
return {
target,
terminal: terminal
? { width: terminal.width, height: terminal.height }
: null,
inner_tmux: innerTmux,
settled: Boolean(
terminal
&& (!target || (terminal.width >= target.width && terminal.height >= target.height))
)
};
}
function audit(eventType, eventActor, payload = {}, options = {}) {
return stateDatabase.appendEvent({
eventType,
actor: actor(eventActor),
navigation: options.navigation === undefined ? currentNavigation : options.navigation,
evidenceRef: options.evidenceRef ?? null,
payload
});
}
function html(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
function auditReportHtml(events) {
const rows = events.map(event => {
const position = [event.task_id, event.block_id, event.phase_id, event.step_id]
.filter(Boolean)
.join(' / ');
const evidence = event.evidence_ref
? `<figure><img src="${html(event.evidence_ref)}" alt="${html(event.payload?.caption ?? event.event_type)}"><figcaption>${html(event.payload?.caption ?? '')}</figcaption></figure>`
: '';
return `<article><time>${html(event.occurred_at)}</time><strong>${html(event.event_type)}</strong><span>${html(event.actor)}</span><p>${html(position)}</p>${evidence}</article>`;
}).join('\n');
return `<!doctype html><html lang="pl"><meta charset="utf-8"><title>Ślad lekcji</title><style>body{max-width:1100px;margin:2rem auto;font:16px/1.45 system-ui;color:#222}header{border-bottom:2px solid;padding-bottom:1rem}article{display:grid;grid-template-columns:14rem 13rem 1fr;gap:.35rem 1rem;padding:1rem 0;border-bottom:1px solid #bbb}article p{grid-column:1/-1;margin:0;color:#555}figure{grid-column:1/-1;margin:.7rem 0}img{max-width:100%;border:1px solid #777}figcaption{font-size:.9rem;color:#555}@media print{body{max-width:none;margin:12mm}}</style><header><h1>Chronologiczny ślad lekcji</h1><p>Wpisy i timestampy pochodzą z backendu; zrzuty są dowodami przypiętymi do zdarzeń.</p></header><main>${rows}</main></html>`;
}
async function restoreNavigation() {
if (!currentNavigation) return;
try {
const catalog = await currentNavigationCatalog();
currentNavigation = selectNavigation(
catalog,
currentNavigation,
navigationRevision,
currentNavigation.selected_at
);
} catch {
currentNavigation = null;
navigationRevision = 0;
}
}
async function currentNavigationCatalog() {
return navigationCatalog(await readJson(cardFile));
}
async function currentCardIdentity() {
const source = await readFile(cardFile);
const card = JSON.parse(source.toString('utf8')).card ?? {};
return {
schema: 'stem-card-identity.v1',
id: String(card.id ?? ''),
uuid: String(card.uuid ?? ''),
version: String(card.version ?? ''),
source_sha256: createHash('sha256').update(source).digest('hex')
};
}
async function selectedNvimState() {
return callSelectedNvim('nvim_exec_lua', [`
local win = vim.api.nvim_get_current_win()
local buf = vim.api.nvim_win_get_buf(win)
local cursor = vim.api.nvim_win_get_cursor(win)
local mode = vim.api.nvim_get_mode()
return {
window = win,
buffer = buf,
path = vim.api.nvim_buf_get_name(buf),
cursor = { row = cursor[1], column = cursor[2] },
mode = mode.mode,
blocking = mode.blocking,
changedtick = vim.api.nvim_buf_get_changedtick(buf),
columns = vim.o.columns,
lines = vim.o.lines
}
`, []]);
}
async function webRevision() {
const files = ['index.html', 'app.js', 'app.css', 'style.css', 'card-data.json'];
for (const directory of ['figures', 'debug-snapshots']) {
try {
for (const name of await readdir(path.join(webRoot, directory))) {
files.push(path.join(directory, name));
}
} catch {
// A card without optional generated assets is valid.
}
}
const revisions = await Promise.all(files.sort().map(async name => {
const info = await stat(path.join(webRoot, name));
return `${name}:${info.mtimeMs}:${info.size}`;
}));
return revisions.join('|');
}
async function cardAndProgress() {
const card = await readJson(cardFile);
let saved;
try {
saved = await readJson(progressFile);
} catch (error) {
if (error.code !== 'ENOENT') throw error;
saved = {};
}
return {
card,
progress: normaliseProgress(card, saved, {
repository: process.env.CARD_SOURCE_REPOSITORY,
revision: process.env.CARD_SOURCE_REVISION
})
};
}
async function updateProgress(itemId, change) {
const operation = writeQueue.then(async () => {
const { card, progress } = await cardAndProgress();
updateItem(progress, itemId, change);
await writeJsonAtomically(progressFile, progress);
return { card, progress };
});
writeQueue = operation.catch(() => undefined);
return operation;
}
function trackerAssets() {
const css = `<style id="lesson-progress-style">
.lesson-progress-control{display:inline-flex;align-items:center;gap:.35rem;margin:0 .45rem .25rem 0;padding:.18rem .48rem;border:1px solid #8c8c8c;border-radius:1rem;background:#fff;color:#222;font:600 .76rem/1.25 system-ui,sans-serif;cursor:pointer}.lesson-progress-control:hover{border-color:#005a9c}.lesson-progress-control[data-status="approved"]{border-color:#18794e;background:#e7f6ed}.step-row.lesson-progress-completed{border-left:4px solid #18794e;padding-left:.45rem}.lesson-progress-dock{position:fixed;z-index:20;right:1rem;bottom:1rem;width:min(24rem,calc(100vw - 2rem));padding:.85rem 1rem;border:1px solid #555;border-radius:.6rem;background:#fff;box-shadow:0 5px 24px #0004;font:14px/1.35 system-ui,sans-serif}.lesson-progress-dock strong{display:block}.lesson-progress-dock p{margin:.35rem 0}.lesson-progress-dock a{color:#005a9c}.lesson-progress-dock button{margin-top:.25rem;padding:.3rem .55rem;border:1px solid #555;border-radius:.25rem;background:#f6f6f6;cursor:pointer}@media print{.lesson-progress-control,.lesson-progress-dock{display:none!important}}</style>`;
const script = `<script id="lesson-progress-script">
(() => {
const next = {pending:'approved', approved:'pending'};
const labels = {pending:'○ niezatwierdzony', approved:'● zatwierdzony'};
let catalog = [];
let progress = null;
const api = async (url, options) => {
const response = await fetch(url, options);
if (!response.ok) throw new Error(await response.text());
return response.json();
};
const render = () => {
const rows = [...document.querySelectorAll('.step-row')];
rows.forEach((row, index) => {
const item = catalog[index];
if (!item) return;
let control = row.querySelector('.lesson-progress-control');
if (!control) {
control = document.createElement('button');
control.type = 'button'; control.className = 'lesson-progress-control';
row.querySelector('.step-title')?.before(control);
control.addEventListener('click', async () => {
const current = progress.items[item.id].status;
control.disabled = true;
try {
const data = await api('/api/progress/' + encodeURIComponent(item.id), {method:'PUT', headers:{'content-type':'application/json'}, body:JSON.stringify({status:next[current]})});
progress = data.progress; render();
} catch (error) { window.alert('Nie zapisano postępu: ' + error.message); }
finally { control.disabled = false; }
});
}
const state = progress.items[item.id];
control.dataset.status = state.status;
control.textContent = labels[state.status];
control.title = 'Kliknij, aby zatwierdzić lub cofnąć zatwierdzenie. Zapis zawiera timestamp.';
row.classList.toggle('lesson-progress-completed', state.status === 'approved');
});
const summary = progress.summary;
document.getElementById('lesson-progress-summary').textContent = 'Zatwierdzone: ' + summary.counts.approved + '/' + summary.total;
};
const start = async () => {
const data = await api('/api/progress');
catalog = data.catalog; progress = data.progress; render();
const dock = document.createElement('aside'); dock.className = 'lesson-progress-dock';
dock.innerHTML = '<strong>Przebieg zajęć</strong><p id="lesson-progress-summary"></p><p>Kliknij znacznik przy kroku, aby zatwierdzić lub cofnąć zatwierdzenie. Każda zmiana trafia do wersjonowanego JSON-a z czasem.</p><a href="/api/report/teams.md" target="_blank" rel="noopener">Otwórz raport do Teams (Markdown)</a>';
document.body.append(dock); render();
};
start().catch(error => console.error('Nie można wczytać postępu zajęć:', error));
})();
</script>`;
return { css, script };
}
function injectTracker(html, revision) {
const usesReactRenderer = html.includes('<meta name="card-renderer" content="react">');
const { css, script } = usesReactRenderer ? { css: '', script: '' } : trackerAssets();
const reload = `<script id="card-live-reload">
(() => {
let revision = ${JSON.stringify(revision)};
const check = async () => {
try {
const response = await fetch('/api/card-revision', { cache: 'no-store' });
if (!response.ok) return;
const current = (await response.json()).revision;
if (current !== revision) window.location.reload();
} catch {
// Keep the current page usable while the local server is restarting.
}
};
window.setInterval(check, 1200);
})();
</script>`;
return html
.replace('</head>', `${css}</head>`)
.replace('</body>', `${script}${reload}</body>`);
}
app.get('/api/progress', async () => {
const { card, progress } = await cardAndProgress();
return {
card: progress.card,
session: progress.session,
catalog: catalogFromCard(card),
progress: { ...progress, summary: progressSummary(progress) },
statuses: Object.fromEntries(STATUSES.map(status => [status, statusLabel[status]]))
};
});
app.put('/api/progress/:itemId', async (request, reply) => {
const body = request.body && typeof request.body === 'object' ? request.body : {};
if (!STATUSES.includes(body.status)) {
return reply.code(400).send({ error: `status musi być jednym z: ${STATUSES.join(', ')}` });
}
try {
const progressActor = actor(body.actor, 'teacher');
const { progress } = await updateProgress(request.params.itemId, {
status: body.status,
note: body.note,
actor: progressActor
});
audit('progress.status', progressActor, {
item_id: request.params.itemId,
status: body.status,
note: typeof body.note === 'string' ? body.note.slice(0, 2000) : null
});
return { progress: { ...progress, summary: progressSummary(progress) } };
} catch (error) {
return reply.code(error.message.startsWith('Nieznany element') ? 404 : 500).send({ error: error.message });
}
});
app.get('/api/report/teams.md', async (_request, reply) => {
const { card, progress } = await cardAndProgress();
return reply
.type('text/markdown; charset=utf-8')
.header('content-disposition', `inline; filename="${progress.session.id}-teams.md"`)
.send(teamsMarkdown(card, progress));
});
app.get('/api/report/lesson.html', async (_request, reply) => reply
.type('text/html; charset=utf-8')
.send(auditReportHtml(stateDatabase.listEvents({ limit: 5000 }))));
app.get('/api/events', async request => {
const query = request.query && typeof request.query === 'object' ? request.query : {};
const afterId = Math.max(0, Number.isSafeInteger(Number(query.after_id)) ? Number(query.after_id) : 0);
const limit = Math.max(1, Math.min(1000, Number.isSafeInteger(Number(query.limit)) ? Number(query.limit) : 200));
return {
schema: 'stem-card-event-log.v1',
events: stateDatabase.listEvents({ afterId, limit })
};
});
app.post('/api/events', async (request, reply) => {
const body = request.body && typeof request.body === 'object' ? request.body : {};
const eventType = typeof body.event_type === 'string' ? body.event_type.trim() : '';
if (!/^[a-z][a-z0-9_.-]{1,79}$/.test(eventType)) {
return reply.code(400).send({ error: 'event_type ma niepoprawny format.' });
}
const payload = body.payload && typeof body.payload === 'object' ? body.payload : {};
if (JSON.stringify(payload).length > 16 * 1024) {
return reply.code(413).send({ error: 'payload zdarzenia jest zbyt duży.' });
}
return {
schema: 'stem-card-event.v1',
event: audit(eventType, actor(body.actor, 'api'), payload)
};
});
app.post('/api/evidence', { bodyLimit: 12 * 1024 * 1024 }, async (request, reply) => {
const body = request.body && typeof request.body === 'object' ? request.body : {};
const match = typeof body.data_url === 'string'
? /^data:image\/(png|jpeg|webp);base64,([A-Za-z0-9+/=]+)$/.exec(body.data_url)
: null;
if (!match) return reply.code(400).send({ error: 'Wymagany jest data_url obrazu PNG, JPEG albo WebP.' });
const bytes = Buffer.from(match[2], 'base64');
if (!bytes.length || bytes.length > 8 * 1024 * 1024) {
return reply.code(413).send({ error: 'Zrzut musi mieć od 1 B do 8 MiB.' });
}
const extension = match[1] === 'jpeg' ? 'jpg' : match[1];
const name = `${new Date().toISOString().replaceAll(':', '-')}-${randomUUID()}.${extension}`;
await mkdir(evidenceRoot, { recursive: true });
await writeFile(path.join(evidenceRoot, name), bytes, { mode: 0o600 });
const evidenceRef = `/api/evidence/${name}`;
const event = stateDatabase.appendEvent({
eventType: 'checkpoint.screenshot',
actor: actor(body.actor, 'browser'),
navigation: currentNavigation,
evidenceRef,
payload: {
caption: typeof body.caption === 'string' ? body.caption.slice(0, 500) : '',
mime: `image/${match[1]}`,
size_bytes: bytes.length,
snapshot_ref: typeof body.snapshot_ref === 'string' ? body.snapshot_ref : currentNavigation?.snapshot_ref
}
});
return reply.code(201).send({ schema: 'stem-card-evidence.v1', event });
});
app.get('/api/evidence/:name', async (request, reply) => {
const name = String(request.params.name ?? '');
if (!/^[A-Za-z0-9_.+-]+\.(png|jpg|webp)$/.test(name)) return reply.code(404).send('Not found');
try {
const file = path.join(evidenceRoot, name);
const info = await stat(file);
if (!info.isFile()) return reply.code(404).send('Not found');
return reply
.type(contentTypes[path.extname(file).toLowerCase()] ?? 'application/octet-stream')
.send(await readFile(file));
} catch {
return reply.code(404).send('Not found');
}
});
app.get('/api/card-revision', async () => ({ revision: await webRevision() }));
app.get('/api/identity', async () => currentCardIdentity());
app.get('/api/checkpoint/status', async () => checkpointStatus());
app.get('/api/keyboard', async () => ({
schema: 'stem-card-keyboard.v1',
hierarchy: ['card', 'task', 'block', 'phase', 'step', 'snapshot'],
shortcuts: KEYBOARD_SHORTCUTS
}));
app.get('/api/navigation/catalog', async () => currentNavigationCatalog());
app.get('/api/navigation/current', async () => ({
current: currentNavigation,
checkpoint: checkpointStatus()
}));
app.put('/api/navigation/current', async (request, reply) => {
const body = request.body && typeof request.body === 'object' ? request.body : {};
try {
const catalog = await currentNavigationCatalog();
navigationRevision += 1;
currentNavigation = selectNavigation(catalog, body, navigationRevision);
stateDatabase.write('navigation', currentNavigation);
audit('navigation.select', currentNavigation.actor, {
focus_level: currentNavigation.focus_level,
sync_requested: currentNavigation.sync_requested
});
return { current: currentNavigation };
} catch (error) {
return reply.code(400).send({
error: error instanceof Error ? error.message : String(error)
});
}
});
app.post('/api/navigation/move', async (request, reply) => {
const body = request.body && typeof request.body === 'object' ? request.body : {};
try {
const catalog = await currentNavigationCatalog();
navigationRevision += 1;
currentNavigation = moveNavigation(
catalog,
currentNavigation,
body,
navigationRevision
);
stateDatabase.write('navigation', currentNavigation);
audit('navigation.move', currentNavigation.actor, {
level: body.level ?? currentNavigation.focus_level,
delta: Number(body.delta),
sync_requested: false
});
return { current: currentNavigation };
} catch (error) {
return reply.code(400).send({
error: error instanceof Error ? error.message : String(error)
});
}
});
app.get('/api/viewer/state', async () => ({ viewer: currentViewer }));
app.put('/api/viewer/state', async (request, reply) => {
const body = request.body && typeof request.body === 'object' ? request.body : {};
try {
const requestedViewer = body.viewer && typeof body.viewer === 'object' ? body.viewer : body;
const requestedNvim = requestedViewer.nvim && typeof requestedViewer.nvim === 'object'
? requestedViewer.nvim
: null;
viewerRevision += 1;
currentViewer = patchViewerState(currentViewer, body, viewerRevision);
stateDatabase.write('viewer', currentViewer);
if (requestedNvim && ['visible', 'sync', 'control', 'expanded', 'fit'].some(key => key in requestedNvim)) {
audit('viewer.nvim', actor(body.actor, 'browser'), {
visible: currentViewer.nvim.visible,
sync: currentViewer.nvim.sync,
control: currentViewer.nvim.control,
expanded: currentViewer.nvim.expanded,
fit: currentViewer.nvim.fit
});
}
if (requestedViewer.allocator && typeof requestedViewer.allocator === 'object'
&& 'visible' in requestedViewer.allocator) {
audit('viewer.allocator', actor(body.actor, 'browser'), {
visible: currentViewer.allocator.visible
});
}
return { viewer: currentViewer };
} catch (error) {
return reply.code(400).send({
error: error instanceof Error ? error.message : String(error)
});
}
});
app.get('/api/control/state', async () => ({
schema: 'stem-card-control-state.v1',
revision: `${navigationRevision}:${viewerRevision}`,
navigation: currentNavigation,
viewer: currentViewer,
checkpoint: checkpointStatus()
}));
app.get('/api/nvim/state', async (_request, reply) => {
try {
return {
schema: 'stem-card-nvim-state.v1',
state: await selectedNvimState()
};
} catch (error) {
return reply.code(503).send({
error: error instanceof Error ? error.message : String(error)
});
}
});
app.put('/api/nvim/cursor', async (request, reply) => {
const body = request.body && typeof request.body === 'object' ? request.body : {};
const row = Number(body.row);
const column = Number(body.column);
const requestedWindow = body.window == null ? null : Number(body.window);
if (!Number.isSafeInteger(row) || row < 1 || !Number.isSafeInteger(column) || column < 0) {
return reply.code(400).send({ error: 'Kursor wymaga row >= 1 i column >= 0.' });
}
if (requestedWindow != null && (!Number.isSafeInteger(requestedWindow) || requestedWindow < 1)) {
return reply.code(400).send({ error: 'window musi być poprawnym identyfikatorem Neovima.' });
}
try {
const window = requestedWindow ?? await callSelectedNvim('nvim_get_current_win');
await callSelectedNvim('nvim_win_set_cursor', [window, [row, column]]);
return {
schema: 'stem-card-nvim-state.v1',
state: await selectedNvimState()
};
} catch (error) {
return reply.code(503).send({
error: error instanceof Error ? error.message : String(error)
});
}
});
app.post('/api/nvim/input', async (request, reply) => {
const body = request.body && typeof request.body === 'object' ? request.body : {};
const keys = typeof body.keys === 'string' ? body.keys : '';
if (!keys || keys.length > 256) {
return reply.code(400).send({ error: 'keys musi zawierać od 1 do 256 znaków.' });
}
try {
const accepted = await callSelectedNvim('nvim_input', [keys]);
return { accepted, state: await selectedNvimState() };
} catch (error) {
return reply.code(503).send({
error: error instanceof Error ? error.message : String(error)
});
}
});
app.get('/api/state', async () => {
let nvim;
try {
nvim = { available: true, state: await selectedNvimState() };
} catch (error) {
nvim = { available: false, error: error instanceof Error ? error.message : String(error) };
}
return {
schema: 'stem-card-state.v1',
navigation: currentNavigation,
viewer: currentViewer,
checkpoint: checkpointStatus(),
nvim,
endpoints: {
keyboard: '/api/keyboard',
navigation_catalog: '/api/navigation/catalog',
navigation_current: '/api/navigation/current',
navigation_move: '/api/navigation/move',
navigation_sync: '/api/navigation/sync',
events: '/api/events',
evidence: '/api/evidence',
lesson_report: '/api/report/lesson.html',
identity: '/api/identity',
viewer: '/api/viewer/state',
nvim_reset: '/api/nvim/reset',
nvim_redraw: '/api/nvim/redraw',
nvim_cursor: '/api/nvim/cursor',
nvim_input: '/api/nvim/input'
}
};
});
app.post('/api/checkpoint/activate', async (request, reply) => {
const body = request.body && typeof request.body === 'object' ? request.body : {};
const eventActor = actor(body.actor, 'browser');
audit('checkpoint.request', eventActor, {
snapshot_ref: body.snapshot_ref,
generation: Number(body.generation)
});
try {
const result = await activateCheckpoint({
repositoryRoot,
cardFile,
snapshotRef: body.snapshot_ref,
generation: Number(body.generation),
expectedBinding: body.expected_binding,
expectedIdentity: body.expected_identity
});
audit('checkpoint.ready', eventActor, {
snapshot_ref: body.snapshot_ref,
generation: Number(body.generation),
status: result.status,
message: result.message
});
return result;
} catch (error) {
audit('checkpoint.failed', eventActor, {
snapshot_ref: body.snapshot_ref,
generation: Number(body.generation),
message: error instanceof Error ? error.message : String(error)
});
return reply.code(409).send({
status: 'failed',
snapshot_ref: body.snapshot_ref,
message: error instanceof Error ? error.message : String(error)
});
}
});
app.post('/api/nvim/reset', async (request, reply) => {
const body = request.body && typeof request.body === 'object' ? request.body : {};
const eventActor = actor(body.actor, 'browser');
audit('nvim.reset.request', eventActor, { source: 'web.F1' });
try {
await callSelectedNvim('nvim_command', ['CpuReset']);
return {
schema: 'stem-card-nvim-reset.v1',
status: 'accepted',
actor: eventActor,
state: await selectedNvimState()
};
} catch (error) {
audit('nvim.reset.failed', eventActor, {
source: 'web.F1',
message: error instanceof Error ? error.message : String(error)
});
return reply.code(503).send({
status: 'failed',
message: error instanceof Error ? error.message : String(error)
});
}
});
app.post('/api/nvim/redraw', async (request, reply) => {
const body = request.body && typeof request.body === 'object' ? request.body : {};
const eventActor = actor(body.actor, 'nvim');
let hostPane;
try {
hostPane = await zoomSelectedHostPane();
} catch (error) {
hostPane = { found: false, error: error instanceof Error ? error.message : String(error) };
}
await new Promise(resolve => setTimeout(resolve, 120));
try {
const terminalUi = await settleSelectedNvimTerminalUi(hostPane);
const externalUi = await resizeSelectedNvimUi();
await callSelectedNvim('nvim_command', ['silent! StudentLayoutFit | redraw!']);
const state = await selectedNvimState();
audit('nvim.redraw', eventActor, {
source: 'api',
host_pane: hostPane,
terminal_ui: terminalUi,
external_ui: externalUi,
columns: state.columns,
lines: state.lines
});
return {
schema: 'stem-card-nvim-redraw.v1',
status: 'ready',
host_pane: hostPane,
terminal_ui: terminalUi,
external_ui: externalUi,
state
};
} catch (error) {
return reply.code(503).send({
status: 'failed',
host_pane: hostPane,
message: error instanceof Error ? error.message : String(error)
});
}
});
app.post('/api/navigation/sync', async (request, reply) => {
const body = request.body && typeof request.body === 'object' ? request.body : {};
const eventActor = actor(body.actor, 'nvim');
const resolvedFrom = currentNavigation?.focus_level ?? null;
let activatedNavigation;
try {
const catalog = await currentNavigationCatalog();
activatedNavigation = activateNavigation(
catalog,
currentNavigation,
eventActor,
navigationRevision + 1
);
} catch (error) {
return reply.code(409).send({
status: 'failed',
message: error instanceof Error ? error.message : String(error)
});
}
navigationRevision += 1;
currentNavigation = activatedNavigation;
stateDatabase.write('navigation', currentNavigation);
audit('navigation.activate', eventActor, {
source: 'navigation.sync',
resolved_from: resolvedFrom,
focus_level: currentNavigation.focus_level,
snapshot_ref: currentNavigation.snapshot_ref
});
const snapshotRef = currentNavigation.snapshot_ref;
audit('checkpoint.request', eventActor, {
source: 'navigation.sync',
snapshot_ref: snapshotRef
});
try {
const checkpoint = await activateCheckpoint({
repositoryRoot,
cardFile,
snapshotRef,
generation: Date.now()
});
audit('checkpoint.ready', eventActor, {
source: 'navigation.sync',
snapshot_ref: snapshotRef,
status: checkpoint.status,
message: checkpoint.message
});
return {
schema: 'stem-card-navigation-sync.v1',
actor: eventActor,
current: currentNavigation,
checkpoint
};
} catch (error) {
audit('checkpoint.failed', eventActor, {
source: 'navigation.sync',
snapshot_ref: snapshotRef,
message: error instanceof Error ? error.message : String(error)
});
return reply.code(409).send({
status: 'failed',
snapshot_ref: snapshotRef,
message: error instanceof Error ? error.message : String(error)
});
}
});
app.get('/api/nvim-ui', { websocket: true }, (socket, request) => {
const origin = request.headers.origin;
const expectedOrigin = `http://${request.headers.host}`;
if (origin && origin !== expectedOrigin) {
socket.close(1008, 'Origin not allowed');
return;
}
attachNvimUi(socket);
});
app.get('/*', async (request, reply) => {
const requested = request.params['*'] || 'index.html';
const relative = requested === '' ? 'index.html' : requested;
const target = path.resolve(webRoot, relative);
if (target !== webRoot && !target.startsWith(`${webRoot}${path.sep}`)) return reply.code(403).send('Forbidden');
try {
const info = await stat(target);
if (!info.isFile()) return reply.code(404).send('Not found');
if (path.basename(target) === 'index.html') {
return reply
.type('text/html; charset=utf-8')
.send(injectTracker(await readFile(target, 'utf8'), await webRevision()));
}
return reply
.type(contentTypes[path.extname(target).toLowerCase()] ?? 'application/octet-stream')
.send(await readFile(target));
} catch {
return reply.code(404).send('Not found');
}
});
let socketServer = null;
app.addHook('onClose', async () => {
if (socketServer) {
await new Promise(resolve => socketServer.close(resolve));
}
await rm(apiSocket, { force: true });
stateDatabase.close();
});
await restoreNavigation();
await app.listen({ host, port });
await rm(apiSocket, { force: true });
socketServer = createServer(app.routing);
await new Promise((resolve, reject) => {
socketServer.once('error', reject);
socketServer.listen(apiSocket, () => {
socketServer.off('error', reject);
resolve();
});
});
+107
View File
@@ -0,0 +1,107 @@
import { mkdir, writeFile } from 'node:fs/promises';
import { spawn } from 'node:child_process';
import path from 'node:path';
import {
catalogFromCard,
normaliseProgress,
readJson,
statusLabel,
teamsMarkdown
} from './lib/card_progress.mjs';
const root = path.resolve(process.cwd());
const card = await readJson(path.join(root, 'json/card_source.json'));
const progressFile = path.resolve(process.env.CARD_PROGRESS_FILE ?? path.join(root, 'json/lesson_progress.json'));
let savedProgress;
try {
savedProgress = await readJson(progressFile);
} catch (error) {
if (error.code !== 'ENOENT') throw error;
savedProgress = {};
}
const progress = normaliseProgress(card, savedProgress, {
repository: process.env.CARD_SOURCE_REPOSITORY,
revision: process.env.CARD_SOURCE_REVISION
});
const reportDir = path.join(root, 'doc/session-reports');
const basename = `${card.card.id}-${progress.session.id}`;
const markdownFile = path.join(reportDir, `${basename}.md`);
const texFile = path.join(reportDir, `${basename}.tex`);
const pdfFile = path.join(reportDir, `${basename}.pdf`);
function tex(value) {
return String(value ?? '')
.replaceAll('\\', '\\textbackslash{}')
.replaceAll('&', '\\&').replaceAll('%', '\\%').replaceAll('$', '\\$')
.replaceAll('#', '\\#').replaceAll('_', '\\_').replaceAll('{', '\\{')
.replaceAll('}', '\\}').replaceAll('~', '\\textasciitilde{}')
.replaceAll('^', '\\textasciicircum{}');
}
function reportTex() {
const catalog = catalogFromCard(card);
const rows = catalog.map(item => {
const current = progress.items[item.id];
return `${tex(item.id)} & ${tex(statusLabel[current.status])} & ${tex(current.updated_at || '—')} & ${tex(item.title)} \\\\ \\hline`;
}).join('\n');
const events = progress.events.length
? progress.events.map(event => `${tex(event.at)} & ${tex(event.item_id)} & ${tex(`${statusLabel[event.from]}${statusLabel[event.to]}`)} & ${tex(event.note || '—')} \\\\ \\hline`).join('\n')
: `— & — & Brak zmian & — \\\\ \\hline`;
return `\\documentclass[11pt,a4paper]{article}
\\usepackage[utf8]{inputenc}
\\usepackage[T1]{fontenc}
\\usepackage[polish]{babel}
\\usepackage[margin=18mm]{geometry}
\\usepackage{longtable}
\\usepackage{array}
\\usepackage{booktabs}
\\begin{document}
\\section*{${tex(card.card.title)} — zapis zajęć}
\\begin{tabular}{ll}
Karta & ${tex(card.card.id)} (${tex(card.card.version)}) \\\\
Materiały & ${tex(progress.card.repository || 'nieuzupełniono')} \\\\
Rewizja & ${tex(progress.card.revision || 'nieuzupełniono')} \\\\
Sesja & ${tex(progress.session.id)} \\\\
Prowadzący & ${tex(progress.session.teacher || 'nieuzupełniono')} \\\\
Rozpoczęcie & ${tex(progress.session.started_at || 'brak')} \\\\
\\end{tabular}
\\subsection*{Zakres i status}
\\begin{longtable}{p{0.13\\textwidth}p{0.18\\textwidth}p{0.22\\textwidth}p{0.38\\textwidth}}
\\toprule Element & Status & Ostatnia zmiana & Zakres \\\\ \\midrule
${rows}
\\bottomrule
\\end{longtable}
\\subsection*{Historia zatwierdzeń}
\\begin{longtable}{p{0.22\\textwidth}p{0.13\\textwidth}p{0.25\\textwidth}p{0.30\\textwidth}}
\\toprule Czas & Element & Zmiana & Notatka \\\\ \\midrule
${events}
\\bottomrule
\\end{longtable}
\\end{document}
`;
}
function run(command, args, options) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, options);
child.on('error', reject);
child.on('exit', code => code === 0 ? resolve() : reject(new Error(`${command} zakończył się kodem ${code}`)));
});
}
await mkdir(reportDir, { recursive: true });
await writeFile(markdownFile, teamsMarkdown(card, progress), 'utf8');
await writeFile(texFile, reportTex(), 'utf8');
console.log(`Markdown: ${path.relative(root, markdownFile)}`);
console.log(`TeX: ${path.relative(root, texFile)}`);
if (!process.argv.includes('--no-pdf')) {
try {
await run('latexmk', ['-pdf', '-interaction=nonstopmode', '-outdir=' + reportDir, texFile], { cwd: root, stdio: 'inherit' });
console.log(`PDF: ${path.relative(root, pdfFile)}`);
} catch (error) {
console.warn(`Nie utworzono PDF (${error.message}). Pliki Markdown i TeX są gotowe.`);
}
}
+376
View File
@@ -0,0 +1,376 @@
const NAVIGATION_LEVELS = ['card', 'task', 'block', 'phase', 'step', 'snapshot'];
export const KEYBOARD_SHORTCUTS = [
{ keys: 'Alt+ArrowUp/Alt+ArrowDown', action: 'navigation.move', level: 'task', description: 'poprzedni/następny TASK' },
{ keys: 'Ctrl+ArrowUp/Ctrl+ArrowDown', action: 'navigation.move', level: 'block', description: 'poprzedni/następny BLOCK' },
{ keys: 'Shift+ArrowUp/Shift+ArrowDown', action: 'navigation.move', level: 'phase', description: 'poprzednia/następna PHASE' },
{ keys: 'Ctrl+Shift+ArrowUp/Ctrl+Shift+ArrowDown', action: 'navigation.move', level: 'step', description: 'poprzedni/następny STEP' },
{ keys: 'Alt+Shift+ArrowUp/Alt+Shift+ArrowDown', action: 'navigation.move', level: 'snapshot', description: 'poprzedni/następny unikalny SNAPSHOT' },
{ keys: 'ArrowUp/ArrowDown', action: 'navigation.move', level: 'current', description: 'poprzedni/następny element aktywnego poziomu' },
{ keys: 'ArrowRight', action: 'navigation.level', direction: 'child', description: 'zejdź poziom niżej' },
{ keys: 'ArrowLeft', action: 'navigation.level', direction: 'parent', description: 'wróć poziom wyżej' },
{ keys: 'Enter', action: 'navigation.activate', description: 'wybierz element i przy SYNC ON odtwórz jego stan' },
{ keys: 'F1', action: 'nvim.reset', description: 'zresetuj aktualny cel i odśwież debugger' },
{ keys: 'F2', action: 'navigation.activate', description: 'zsynchronizuj cel z kursorem UML; BLOCK wybiera pierwszy krok' },
{ keys: 'Ctrl+Backquote', action: 'nvim.redraw', description: 'maksymalizuj pane, dopasuj siatkę i odśwież Neovima' },
{ keys: 'Ctrl+Enter', action: 'progress.toggle', description: 'zatwierdź lub cofnij zatwierdzenie bieżącego kroku' },
{ keys: 'Escape', action: 'navigation.level', direction: 'parent', description: 'anuluj albo wróć poziom wyżej' },
{ keys: 'Alt+Digit1', action: 'viewer.nvim.visible.toggle', description: 'pokaż/ukryj panel Neovima' },
{ keys: 'Alt+Digit2', action: 'viewer.nvim.sync.toggle', description: 'przełącz SYNC OFF/SYNC ON' },
{ keys: 'Alt+Digit3', action: 'viewer.nvim.control.toggle', description: 'uzbrój/rozbrój sterowanie Neovimem' },
{ keys: 'Alt+Digit4', action: 'viewer.nvim.expanded.toggle', description: 'przełącz normalną/wysoką wysokość panelu' },
{ keys: 'Alt+Digit5', action: 'viewer.nvim.fit.toggle', description: 'dopasuj cały ekran Neovima' },
{ keys: 'Alt+Digit6', action: 'viewer.allocator.visible.toggle', description: 'przypnij/ukryj model alokatora' },
{ keys: 'F12', action: 'viewer.keyboard.toggle', description: 'pokaż/ukryj skorowidz klawiatury' }
];
function text(value, fallback = '') {
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
}
function node(value, fallbackId, fallbackLabel) {
return {
id: text(value?.id, fallbackId),
label: text(value?.label, fallbackLabel)
};
}
export function navigationCatalog(card) {
const entries = [];
const taskIds = [];
const blockIds = new Map();
const globalSnapshots = [];
for (const [sectionIndex, section] of (card.sections ?? []).entries()) {
for (const [assetIndex, asset] of (section.assets ?? []).entries()) {
const interactive = asset.interactive;
if (!interactive?.phases?.length) continue;
const task = node(
interactive.task,
`section-${sectionIndex + 1}`,
text(section.title, `Sekcja ${sectionIndex + 1}`)
);
const block = node(
interactive.block,
text(asset.label, `asset-${assetIndex + 1}`).replace(/^fig:/, ''),
text(interactive.title, text(asset.caption, `Blok ${assetIndex + 1}`))
);
if (!taskIds.includes(task.id)) taskIds.push(task.id);
const taskBlocks = blockIds.get(task.id) ?? [];
if (!taskBlocks.includes(block.id)) taskBlocks.push(block.id);
blockIds.set(task.id, taskBlocks);
const blockPhases = interactive.phases;
const blockSnapshots = [];
for (const [phaseIndex, phaseValue] of blockPhases.entries()) {
const phase = node(phaseValue, `phase-${phaseIndex + 1}`, `Faza ${phaseIndex + 1}`);
const phaseSnapshots = [];
for (const [stepIndex, stepValue] of (phaseValue.steps ?? []).entries()) {
const step = node(stepValue, `step-${stepIndex + 1}`, `Krok ${stepIndex + 1}`);
const snapshotRef = text(stepValue.snapshot_ref) || null;
if (snapshotRef && !phaseSnapshots.includes(snapshotRef)) phaseSnapshots.push(snapshotRef);
if (snapshotRef && !blockSnapshots.includes(snapshotRef)) blockSnapshots.push(snapshotRef);
if (snapshotRef && !globalSnapshots.includes(snapshotRef)) globalSnapshots.push(snapshotRef);
entries.push({
task: { ...task, index: taskIds.indexOf(task.id) },
block: { ...block, index: taskBlocks.indexOf(block.id) },
phase: { ...phase, index: phaseIndex },
step: {
...step,
number: Number.isSafeInteger(Number(stepValue.number)) ? Number(stepValue.number) : stepIndex + 1,
index: stepIndex,
global_index: entries.length
},
snapshot: snapshotRef ? {
ref: snapshotRef,
index: phaseSnapshots.indexOf(snapshotRef),
block_index: blockSnapshots.indexOf(snapshotRef),
global_index: globalSnapshots.indexOf(snapshotRef)
} : null,
asset: {
label: text(asset.label),
section_index: sectionIndex,
asset_index: assetIndex
}
});
}
}
}
}
return {
schema: 'stem-card-navigation-catalog.v1',
levels: NAVIGATION_LEVELS,
entries
};
}
function integer(value) {
const parsed = Number(value);
return Number.isSafeInteger(parsed) ? parsed : null;
}
function entryMatches(entry, request) {
const ids = {
task: text(request.task?.id, text(request.task_id)),
block: text(request.block?.id, text(request.block_id)),
phase: text(request.phase?.id, text(request.phase_id)),
step: text(request.step?.id, text(request.step_id))
};
for (const [level, id] of Object.entries(ids)) {
if (id && entry[level].id !== id) return false;
}
const snapshotRef = text(request.snapshot_ref, text(request.snapshot?.ref));
return !snapshotRef || entry.snapshot?.ref === snapshotRef;
}
function findNavigationEntry(catalog, request) {
const globalStep = integer(request.position?.global_step_index);
if (globalStep != null) return catalog.entries[globalStep] ?? null;
const matches = catalog.entries.filter(entry => entryMatches(entry, request));
if (!matches.length) return null;
const phaseIndex = integer(request.position?.phase_index);
const stepIndex = integer(request.position?.step_index);
const snapshotIndex = integer(request.position?.snapshot_index);
return matches.find(entry =>
(phaseIndex == null || entry.phase.index === phaseIndex)
&& (stepIndex == null || entry.step.index === stepIndex)
&& (snapshotIndex == null || entry.snapshot?.index === snapshotIndex)
) ?? matches[0];
}
export function selectNavigation(catalog, request, revision = 1, now = new Date().toISOString()) {
if (!request || typeof request !== 'object') throw new Error('Stan nawigacji musi być obiektem.');
const entry = findNavigationEntry(catalog, request);
if (!entry) throw new Error('Nie znaleziono wskazanej pozycji w katalogu karty.');
const focusLevel = text(request.focus_level, 'step');
if (!NAVIGATION_LEVELS.includes(focusLevel)) {
throw new Error(`focus_level musi być jednym z: ${NAVIGATION_LEVELS.join(', ')}.`);
}
return {
schema: 'stem-card-navigation.v2',
revision,
selected_at: now,
actor: text(request.actor, 'browser'),
focus_level: focusLevel,
cursor_visible: focusLevel !== 'card',
task: entry.task,
block: entry.block,
phase: entry.phase,
step: entry.step,
snapshot: entry.snapshot,
snapshot_ref: entry.snapshot?.ref ?? null,
position: {
task_index: entry.task.index,
block_index: entry.block.index,
phase_index: entry.phase.index,
step_index: entry.step.index,
global_step_index: entry.step.global_index,
snapshot_index: entry.snapshot?.index ?? null,
global_snapshot_index: entry.snapshot?.global_index ?? null
},
sync_requested: request.sync_requested === true
};
}
function uniqueAtLevel(entries, level, current) {
const seen = new Set();
return entries.filter(entry => {
let key;
if (level === 'task') key = entry.task.id;
else if (level === 'block') key = `${entry.task.id}/${entry.block.id}`;
else if (level === 'phase') key = `${entry.task.id}/${entry.block.id}/${entry.phase.id}`;
else if (level === 'snapshot') key = entry.snapshot?.ref;
else key = `${entry.task.id}/${entry.block.id}/${entry.phase.id}/${entry.step.id}`;
if (!key || seen.has(key)) return false;
seen.add(key);
if (level === 'phase') return entry.task.id === current.task.id && entry.block.id === current.block.id;
if (level === 'step' || level === 'snapshot') {
return entry.task.id === current.task.id && entry.block.id === current.block.id;
}
return true;
});
}
export function moveNavigation(catalog, current, command, revision = 1, now = new Date().toISOString()) {
if (!current) throw new Error('Najpierw ustaw bieżącą pozycję nawigacji.');
const requestedLevel = text(command?.level, current.focus_level);
const level = requestedLevel === 'current' ? current.focus_level : requestedLevel;
if (!NAVIGATION_LEVELS.includes(level)) throw new Error('Niepoprawny poziom nawigacji.');
if (level === 'card') throw new Error('Poziom CARD nie ma elementów UML do przewijania.');
const delta = integer(command?.delta);
if (delta == null || delta === 0 || Math.abs(delta) > 100) throw new Error('delta musi być niezerową liczbą całkowitą.');
const candidates = uniqueAtLevel(catalog.entries, level, current);
const currentIndex = candidates.findIndex(entry => {
if (level === 'task') return entry.task.id === current.task.id;
if (level === 'block') return entry.block.id === current.block.id && entry.task.id === current.task.id;
if (level === 'phase') return entry.phase.id === current.phase.id;
if (level === 'snapshot') return entry.snapshot?.ref === current.snapshot_ref;
return entry.step.id === current.step.id && entry.phase.id === current.phase.id;
});
const targetIndex = Math.max(0, Math.min(candidates.length - 1, Math.max(0, currentIndex) + delta));
const target = candidates[targetIndex];
if (!target) throw new Error('Brak elementu na wskazanym poziomie.');
return selectNavigation(catalog, {
task_id: target.task.id,
block_id: target.block.id,
phase_id: target.phase.id,
step_id: target.step.id,
snapshot_ref: target.snapshot?.ref,
focus_level: level,
// Moving the teaching cursor never mutates the debugger. Synchronisation
// is a separate, explicit activation (Enter in WWW or F2 in Neovim).
sync_requested: false,
actor: text(command.actor, 'api')
}, revision, now);
}
export function activateNavigation(catalog, current, actor = 'nvim', revision = 1, now = new Date().toISOString()) {
if (!current) throw new Error('Najpierw wybierz BLOCK zawierający diagram UML.');
if (current.focus_level === 'card' || current.focus_level === 'task') {
throw new Error('F2 wymaga zaznaczonego BLOCK, PHASE, STEP albo SNAPSHOT.');
}
const target = catalog.entries.find(entry => {
if (entry.task.id !== current.task.id || entry.block.id !== current.block.id) return false;
if (current.focus_level === 'block') return true;
if (entry.phase.id !== current.phase.id) return false;
if (current.focus_level === 'phase') return true;
if (current.focus_level === 'snapshot') return entry.snapshot?.ref === current.snapshot_ref;
return entry.step.id === current.step.id;
});
if (!target) throw new Error('Nie znaleziono kroku UML, który można zsynchronizować.');
if (!target.snapshot?.ref) throw new Error('Wybrany krok UML nie ma snapshotu do synchronizacji.');
return selectNavigation(catalog, {
task_id: target.task.id,
block_id: target.block.id,
phase_id: target.phase.id,
step_id: target.step.id,
snapshot_ref: target.snapshot.ref,
focus_level: 'step',
sync_requested: true,
actor: text(actor, 'nvim')
}, revision, now);
}
export function initialViewerState() {
return {
schema: 'stem-card-viewer-state.v1',
revision: 0,
updated_at: null,
actor: null,
scale: 2.18,
scroll: { x: 0, y: 0, page_index: 0, sheet_scroll_top: 0 },
viewport: { width: 0, height: 0 },
allocator: { visible: true },
nvim: {
visible: true,
sync: false,
control: false,
expanded: false,
fit: false,
connection: 'unknown',
screen_cursor: null,
grid: null
}
};
}
function finite(value, minimum, maximum, name) {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < minimum || parsed > maximum) {
throw new Error(`${name} jest poza zakresem ${minimum}..${maximum}.`);
}
return parsed;
}
function boolean(value, fallback) {
return typeof value === 'boolean' ? value : fallback;
}
export function patchViewerState(current, request, revision = current.revision + 1, now = new Date().toISOString()) {
if (!request || typeof request !== 'object') throw new Error('Stan viewer musi być obiektem.');
const patch = request.viewer && typeof request.viewer === 'object' ? request.viewer : request;
const scroll = patch.scroll && typeof patch.scroll === 'object' ? patch.scroll : {};
const viewport = patch.viewport && typeof patch.viewport === 'object' ? patch.viewport : {};
const allocator = patch.allocator && typeof patch.allocator === 'object' ? patch.allocator : {};
const nvim = patch.nvim && typeof patch.nvim === 'object' ? patch.nvim : {};
const cursor = nvim.screen_cursor && typeof nvim.screen_cursor === 'object'
? {
row: Math.trunc(finite(nvim.screen_cursor.row, 0, 100000, 'nvim.screen_cursor.row')),
column: Math.trunc(finite(nvim.screen_cursor.column, 0, 100000, 'nvim.screen_cursor.column'))
}
: current.nvim.screen_cursor;
const grid = nvim.grid && typeof nvim.grid === 'object'
? {
columns: Math.trunc(finite(nvim.grid.columns, 1, 10000, 'nvim.grid.columns')),
rows: Math.trunc(finite(nvim.grid.rows, 1, 10000, 'nvim.grid.rows'))
}
: current.nvim.grid;
const sync = boolean(nvim.sync, current.nvim.sync);
const control = sync && boolean(nvim.control, current.nvim.control);
const visible = boolean(nvim.visible, current.nvim.visible);
const fit = visible && boolean(nvim.fit, current.nvim.fit);
const expanded = visible && !fit && boolean(nvim.expanded, current.nvim.expanded);
const pageIndex = scroll.page_index == null
? current.scroll.page_index
: Math.trunc(finite(scroll.page_index, 0, 100000, 'scroll.page_index'));
const pageChanged = scroll.page_index != null && pageIndex !== current.scroll.page_index;
return {
schema: 'stem-card-viewer-state.v1',
revision,
updated_at: now,
actor: text(request.actor, 'browser'),
scale: patch.scale == null ? current.scale : finite(patch.scale, 0.25, 3, 'scale'),
scroll: {
x: scroll.x == null ? current.scroll.x : finite(scroll.x, 0, 10000000, 'scroll.x'),
y: scroll.y == null ? current.scroll.y : finite(scroll.y, 0, 10000000, 'scroll.y'),
page_index: pageIndex,
sheet_scroll_top: scroll.sheet_scroll_top == null
? (pageChanged ? 0 : current.scroll.sheet_scroll_top ?? 0)
: finite(scroll.sheet_scroll_top, 0, 10000000, 'scroll.sheet_scroll_top')
},
viewport: {
width: viewport.width == null ? current.viewport.width : finite(viewport.width, 0, 100000, 'viewport.width'),
height: viewport.height == null ? current.viewport.height : finite(viewport.height, 0, 100000, 'viewport.height')
},
allocator: {
visible: boolean(allocator.visible, current.allocator?.visible ?? true)
},
nvim: {
visible,
sync,
control,
expanded,
fit,
connection: text(nvim.connection, current.nvim.connection),
screen_cursor: cursor,
grid
}
};
}
export function normalizeViewerState(value) {
const initial = initialViewerState();
if (!value || typeof value !== 'object') return initial;
const revisionValue = Number(value.revision);
const revision = Number.isSafeInteger(revisionValue) && revisionValue >= 0
? revisionValue
: 0;
const updatedAt = typeof value.updated_at === 'string' ? value.updated_at : null;
const actor = typeof value.actor === 'string' && value.actor.trim()
? value.actor.trim()
: null;
try {
const normalized = patchViewerState(
initial,
{ actor: actor ?? 'browser', viewer: value },
revision,
updatedAt
);
return { ...normalized, actor };
} catch {
return { ...initial, revision, updated_at: updatedAt, actor };
}
}
+193
View File
@@ -0,0 +1,193 @@
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
import path from 'node:path';
export const STATUSES = ['pending', 'approved'];
export const statusLabel = {
pending: 'niezatwierdzony',
approved: 'zatwierdzony'
};
function normaliseStatus(status) {
if (status === 'approved' || status === 'completed' || status === 'discussed') return 'approved';
return 'pending';
}
export async function readJson(file) {
return JSON.parse(await readFile(file, 'utf8'));
}
export async function writeJsonAtomically(file, value) {
await mkdir(path.dirname(file), { recursive: true });
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
await rename(temporary, file);
}
export function catalogFromCard(card) {
return card.sections.flatMap((section, sectionIndex) => {
const sectionSteps = (section.steps ?? []).map((step, stepIndex) => ({
id: step.id,
title: step.title,
kind: 'section-step',
section_index: sectionIndex + 1,
section_title: section.title,
step_index: stepIndex + 1
}));
const interactiveItems = (section.assets ?? []).flatMap(asset => {
const phases = asset.interactive?.phases ?? [];
if (phases.length) {
return phases.flatMap(phase =>
(phase.steps ?? [])
.filter(step => typeof step.progress_id === 'string' && step.progress_id)
.map(step => ({
id: step.progress_id,
title: `${String(asset.label || 'interaktywny UML').replace(/^fig:/, '')}: ${phase.label} / ${String(step.number).padStart(2, '0')} ${step.label}`,
kind: 'interactive-step',
section_index: sectionIndex + 1,
section_title: section.title,
asset_label: asset.label,
phase_id: phase.id,
step_id: step.id
}))
);
}
return (asset.interactive?.stages ?? [])
.filter(stage => typeof stage.progress_id === 'string' && stage.progress_id)
.map(stage => ({
id: stage.progress_id,
title: `${String(asset.label || 'interaktywny UML').replace(/^fig:/, '')}: ${stage.label}`,
kind: 'interactive-stage',
section_index: sectionIndex + 1,
section_title: section.title,
asset_label: asset.label,
stage_id: stage.id
}));
}).map((item, itemIndex) => ({
...item,
step_index: sectionSteps.length + itemIndex + 1
}));
return [...sectionSteps, ...interactiveItems];
});
}
export function normaliseProgress(card, progress, material = {}) {
const catalog = catalogFromCard(card);
const knownIds = new Set(catalog.map(item => item.id));
const items = {};
for (const item of catalog) {
const previous = progress.items?.[item.id] ?? {};
items[item.id] = {
status: normaliseStatus(previous.status),
updated_at: previous.updated_at ?? null,
note: typeof previous.note === 'string' ? previous.note : ''
};
}
return {
schema: 'stem-card-progress.v2',
card: {
id: card.card.id,
source: progress.card?.source || 'json/card_source.json',
version: card.card.version,
repository: progress.card?.repository || material.repository || '',
revision: progress.card?.revision || material.revision || ''
},
session: {
id: progress.session?.id || `${new Date().toISOString().slice(0, 10)}-${card.card.slug}`,
title: progress.session?.title || card.card.title,
teacher: progress.session?.teacher || '',
started_at: progress.session?.started_at ?? null
},
items,
events: Array.isArray(progress.events)
? progress.events
.filter(event => knownIds.has(event.item_id))
.map(event => ({
...event,
from: normaliseStatus(event.from),
to: normaliseStatus(event.to)
}))
: []
};
}
export function progressSummary(progress) {
const counts = Object.fromEntries(STATUSES.map(status => [status, 0]));
for (const item of Object.values(progress.items)) counts[item.status] += 1;
return { total: Object.keys(progress.items).length, counts };
}
export function updateItem(progress, itemId, { status, note, actor = 'teacher', at = new Date().toISOString() }) {
if (!STATUSES.includes(status)) throw new Error(`Nieznany status: ${status}`);
const item = progress.items[itemId];
if (!item) throw new Error(`Nieznany element karty: ${itemId}`);
const nextNote = typeof note === 'string' ? note.trim() : item.note;
const changed = item.status !== status || item.note !== nextNote;
if (!changed) return false;
const from = item.status;
item.status = status;
item.updated_at = at;
item.note = nextNote;
if (!progress.session.started_at) progress.session.started_at = at;
progress.events.push({ at, item_id: itemId, from, to: status, actor, note: nextNote });
return true;
}
function escapeMarkdown(value) {
return String(value ?? '').replaceAll('|', '\\|').replaceAll('\n', '<br>');
}
export function teamsMarkdown(card, progress) {
const catalog = catalogFromCard(card);
const byStatus = status => catalog.filter(item => progress.items[item.id]?.status === status);
const summary = progressSummary(progress);
const heading = `# ${card.card.title} — zapis zajęć`;
const metadata = [
`- Karta: \`${card.card.id}\` (${card.card.version})`,
`- Materiał Edu: ${progress.card.repository || 'nieuzupełniono'}`,
`- Rewizja materiału: \`${progress.card.revision || 'nieuzupełniono'}\``,
`- Sesja: \`${progress.session.id}\``,
`- Prowadzący: ${progress.session.teacher || 'nieuzupełniono'}`,
`- Rozpoczęcie: ${progress.session.started_at || 'brak'}`,
`- Stan: ${summary.counts.approved}/${summary.total} zatwierdzone`
];
const list = (title, status) => {
const rows = byStatus(status);
const body = rows.length
? rows.map(item => {
const state = progress.items[item.id];
const timestamp = state.updated_at ? `${state.updated_at}` : '';
const note = state.note ? `${state.note}` : '';
return `- [${status === 'approved' ? 'x' : ' '}] \`${item.id}\` ${item.title}${timestamp}${note}`;
}).join('\n')
: '- Brak.';
return `## ${title}\n\n${body}`;
};
const timeline = progress.events.length
? progress.events.map(event => {
const item = catalog.find(candidate => candidate.id === event.item_id);
return `| ${event.at} | \`${event.item_id}\` | ${statusLabel[event.from]}${statusLabel[event.to]} | ${escapeMarkdown(event.note)} |`;
}).join('\n')
: '| — | — | Brak zmian | — |';
return [
heading,
'',
...metadata,
'',
list('Zatwierdzone', 'approved'),
'',
list('Pozostało do zatwierdzenia', 'pending'),
'',
'## Historia zatwierdzeń',
'',
'| Czas | Element | Zmiana | Notatka |',
'| --- | --- | --- | --- |',
timeline,
''
].join('\n');
}
+161
View File
@@ -0,0 +1,161 @@
import { mkdirSync } from 'node:fs';
import path from 'node:path';
import { DatabaseSync } from 'node:sqlite';
const STATE_SCHEMA = 'stem-card-backend-state.v1';
export class CardStateDatabase {
constructor(file) {
this.file = path.resolve(file);
mkdirSync(path.dirname(this.file), { recursive: true });
this.database = new DatabaseSync(this.file);
this.database.exec(`
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
CREATE TABLE IF NOT EXISTS card_state (
key TEXT PRIMARY KEY,
schema_name TEXT NOT NULL,
revision INTEGER NOT NULL,
updated_at TEXT NOT NULL,
value_json TEXT NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS card_event (
id INTEGER PRIMARY KEY AUTOINCREMENT,
occurred_at TEXT NOT NULL,
event_type TEXT NOT NULL,
actor TEXT NOT NULL,
task_id TEXT,
block_id TEXT,
phase_id TEXT,
step_id TEXT,
snapshot_ref TEXT,
evidence_ref TEXT,
payload_json TEXT NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS card_event_occurred_at
ON card_event(occurred_at, id);
`);
this.readStatement = this.database.prepare(`
SELECT value_json
FROM card_state
WHERE key = ?
`);
this.writeStatement = this.database.prepare(`
INSERT INTO card_state (key, schema_name, revision, updated_at, value_json)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(key) DO UPDATE SET
schema_name = excluded.schema_name,
revision = excluded.revision,
updated_at = excluded.updated_at,
value_json = excluded.value_json
`);
this.appendEventStatement = this.database.prepare(`
INSERT INTO card_event (
occurred_at, event_type, actor,
task_id, block_id, phase_id, step_id, snapshot_ref,
evidence_ref, payload_json
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id
`);
this.listEventsStatement = this.database.prepare(`
SELECT
id, occurred_at, event_type, actor,
task_id, block_id, phase_id, step_id, snapshot_ref,
evidence_ref, payload_json
FROM card_event
WHERE id > ?
ORDER BY id ASC
LIMIT ?
`);
}
read(key) {
const row = this.readStatement.get(key);
if (!row) return null;
try {
return JSON.parse(row.value_json);
} catch {
return null;
}
}
write(key, value) {
const revision = Number.isSafeInteger(Number(value?.revision))
? Number(value.revision)
: 0;
const updatedAt = String(value?.updated_at ?? value?.selected_at ?? new Date().toISOString());
this.writeStatement.run(
key,
STATE_SCHEMA,
revision,
updatedAt,
JSON.stringify(value)
);
return value;
}
appendEvent({
eventType,
actor = 'system',
navigation = null,
evidenceRef = null,
payload = {},
occurredAt = new Date().toISOString()
}) {
const row = this.appendEventStatement.get(
String(occurredAt),
String(eventType),
String(actor),
navigation?.task?.id ?? null,
navigation?.block?.id ?? null,
navigation?.phase?.id ?? null,
navigation?.step?.id ?? null,
navigation?.snapshot_ref ?? null,
evidenceRef == null ? null : String(evidenceRef),
JSON.stringify(payload ?? {})
);
return {
id: Number(row.id),
occurred_at: String(occurredAt),
event_type: String(eventType),
actor: String(actor),
task_id: navigation?.task?.id ?? null,
block_id: navigation?.block?.id ?? null,
phase_id: navigation?.phase?.id ?? null,
step_id: navigation?.step?.id ?? null,
snapshot_ref: navigation?.snapshot_ref ?? null,
evidence_ref: evidenceRef == null ? null : String(evidenceRef),
payload: payload ?? {}
};
}
listEvents({ afterId = 0, limit = 200 } = {}) {
return this.listEventsStatement.all(Number(afterId), Number(limit)).map(row => {
let payload = {};
try {
payload = JSON.parse(row.payload_json);
} catch {
// Preserve the audit row even if an old payload cannot be decoded.
}
return {
id: Number(row.id),
occurred_at: row.occurred_at,
event_type: row.event_type,
actor: row.actor,
task_id: row.task_id,
block_id: row.block_id,
phase_id: row.phase_id,
step_id: row.step_id,
snapshot_ref: row.snapshot_ref,
evidence_ref: row.evidence_ref,
payload
};
});
}
close() {
this.database.close();
}
}
export { STATE_SCHEMA };
+423
View File
@@ -0,0 +1,423 @@
import { createHash, randomUUID } from 'node:crypto';
import { readFile, realpath } from 'node:fs/promises';
import path from 'node:path';
import { callSelectedNvim } from './nvim_ui_bridge.mjs';
const POLL_MS = 100;
const state = {
wantedGeneration: 0,
active: null,
activeControl: null,
last: null,
queue: Promise.resolve()
};
function delay(milliseconds) {
return new Promise(resolve => setTimeout(resolve, milliseconds));
}
function errorText(error) {
return error instanceof Error ? error.message : String(error);
}
async function readJson(file) {
return JSON.parse(await readFile(file, 'utf8'));
}
function runtimeRoot() {
return process.env.XDG_RUNTIME_DIR ?? '/run/user/1000';
}
function selectionRoot() {
return process.env.MCP_CONTAINER_SELECTION_ROOT
?? path.join(runtimeRoot(), 'stem/mcp-selected');
}
async function selectedBinding() {
const selectedDirectory = await realpath(path.join(selectionRoot(), 'current'));
const [metadata, socketPath] = await Promise.all([
readJson(path.join(selectedDirectory, 'selection.json')),
realpath(path.join(selectedDirectory, 'n.sock'))
]);
return {
metadata,
socketPath,
epoch: `${metadata.container_id ?? 'unknown'}:${socketPath}`
};
}
async function assertBindingCurrent(expected) {
const current = await selectedBinding();
if (current.epoch !== expected.epoch) {
throw new Error('Cel MCP zmienił się podczas replay; wynik starego kontenera został odrzucony.');
}
}
export function assertExpectedBinding(binding, expected) {
// Browser activation follows the currently selected teaching session and
// remains backward compatible. CLI/orchestrator callers pin all fields.
if (expected == null) return;
if (typeof expected !== 'object') throw new Error('expected_binding musi być obiektem.');
for (const field of ['container_id', 'instance', 'profile', 'target']) {
const wanted = typeof expected[field] === 'string' ? expected[field] : '';
const actual = typeof binding.metadata[field] === 'string' ? binding.metadata[field] : '';
if (!wanted || wanted !== actual) {
throw new Error(`Cel MCP zmienił się przed replay (${field}: ${actual || '?'} != ${wanted || '?'}).`);
}
}
}
export function assertExpectedIdentity(actual, expected) {
// Browser-only calls may omit the pin. Orchestrators send all fields so a
// card hot reload cannot silently replay a recipe from another revision.
if (expected == null) return;
if (typeof expected !== 'object') throw new Error('expected_identity musi być obiektem.');
for (const field of ['id', 'uuid', 'version', 'source_sha256']) {
const wanted = typeof expected[field] === 'string' ? expected[field] : '';
const observed = typeof actual[field] === 'string' ? actual[field] : '';
if (!wanted || wanted !== observed) {
throw new Error(`Karta zmieniła się przed replay (${field}: ${observed || '?'} != ${wanted || '?'}).`);
}
}
}
async function readCardSnapshot(cardFile) {
const source = await readFile(cardFile);
const document = JSON.parse(source.toString('utf8'));
const card = document.card ?? {};
return {
document,
identity: {
id: String(card.id ?? ''),
uuid: String(card.uuid ?? ''),
version: String(card.version ?? ''),
source_sha256: createHash('sha256').update(source).digest('hex')
}
};
}
async function sha256(file) {
const hash = createHash('sha256');
hash.update(await readFile(file));
return hash.digest('hex');
}
async function gitBlobSha1(file) {
const content = await readFile(file);
const hash = createHash('sha1');
hash.update(`blob ${content.length}\0`);
hash.update(content);
return hash.digest('hex');
}
function hostPath(repositoryRoot, containerPath) {
if (!path.isAbsolute(containerPath)) {
const resolved = path.resolve(repositoryRoot, containerPath);
if (resolved === repositoryRoot || resolved.startsWith(`${repositoryRoot}${path.sep}`)) {
return resolved;
}
}
if (containerPath === '/workspace') return repositoryRoot;
if (containerPath.startsWith('/workspace/')) {
return path.join(repositoryRoot, containerPath.slice('/workspace/'.length));
}
throw new Error(`Ścieżka spoza /workspace nie jest dozwolona: ${containerPath}`);
}
async function nvimLua(source, args = [], binding = null) {
return callSelectedNvim(
'nvim_exec_lua',
[source, args],
binding ? { socketPath: binding.socketPath } : {}
);
}
async function callPinnedNvim(binding, method, parameters = []) {
return callSelectedNvim(method, parameters, { socketPath: binding.socketPath });
}
async function sendGdb(command, binding) {
const available = await callPinnedNvim(binding, 'nvim_call_function', [
'exists',
['*TermDebugSendCommand']
]);
if (available !== 1) {
throw new Error('Termdebug nie udostępnia TermDebugSendCommand');
}
return callPinnedNvim(binding, 'nvim_call_function', [
'TermDebugSendCommand',
[command]
]);
}
async function interruptGdb(binding) {
return nvimLua(`
for _, buffer in ipairs(vim.api.nvim_list_bufs()) do
local name = vim.api.nvim_buf_get_name(buffer)
if vim.bo[buffer].buftype == 'terminal' and name:find('gdb%-multiarch') then
local channel = vim.bo[buffer].channel
if channel and channel > 0 then
vim.api.nvim_chan_send(channel, string.char(3))
return true
end
end
end
return false
`, [], binding);
}
async function gdbTerminalRunning(binding) {
return nvimLua(`
for _, buffer in ipairs(vim.api.nvim_list_bufs()) do
local name = vim.api.nvim_buf_get_name(buffer)
if vim.bo[buffer].buftype == 'terminal' and name:find('gdb%-multiarch') then
local channel = vim.bo[buffer].channel
if channel and channel > 0 and vim.fn.jobwait({ channel }, 0)[1] == -1 then
return true
end
end
end
return false
`, [], binding);
}
async function cleanupDeadGdbBuffers(binding) {
return nvimLua(`
local dead = {}
for _, buffer in ipairs(vim.api.nvim_list_bufs()) do
local name = vim.api.nvim_buf_get_name(buffer)
if vim.bo[buffer].buftype == 'terminal' and name:find('gdb%-multiarch') then
local channel = vim.bo[buffer].channel
if not channel or channel <= 0 or vim.fn.jobwait({ channel }, 0)[1] ~= -1 then
table.insert(dead, buffer)
end
end
end
for _, buffer in ipairs(dead) do
for _, window in ipairs(vim.fn.win_findbuf(buffer)) do
if vim.api.nvim_win_is_valid(window) and #vim.api.nvim_list_wins() > 1 then
pcall(vim.api.nvim_win_close, window, true)
end
end
if vim.api.nvim_buf_is_valid(buffer) then
pcall(vim.api.nvim_buf_delete, buffer, { force = true })
end
end
return #dead
`, [], binding);
}
async function ensureTermdebug(binding) {
const probe = gdbTerminalRunning(binding);
let running;
try {
running = await Promise.race([
probe,
delay(500).then(() => { throw new Error('timeout'); })
]);
} catch {
// Termdebug cleanup sometimes leaves a hit-enter prompt which blocks an
// RPC expression while Neovim still accepts input events.
await callPinnedNvim(binding, 'nvim_input', ['<CR>']).catch(() => undefined);
running = await probe;
}
if (running) {
await cleanupDeadGdbBuffers(binding);
await callPinnedNvim(binding, 'nvim_command', ['silent! StudentLayoutFit']).catch(() => undefined);
return;
}
// A failed Termdebug job can leave Neovim at a hit-enter prompt. Feeding
// Enter only in the dead-GDB branch clears it before the recovery command.
await callPinnedNvim(binding, 'nvim_input', ['<CR>']).catch(() => undefined);
await cleanupDeadGdbBuffers(binding);
await callPinnedNvim(binding, 'nvim_command', ['silent! StudentTermdebug']);
const deadline = Date.now() + 8000;
while (Date.now() < deadline) {
if (await gdbTerminalRunning(binding)) {
// TermdebugStartPost queues target/directory/dashboard setup.
await delay(1200);
await cleanupDeadGdbBuffers(binding);
await callPinnedNvim(binding, 'nvim_command', ['silent! StudentLayoutFit']).catch(() => undefined);
return;
}
await delay(100);
}
throw new Error('Nie udało się automatycznie odtworzyć procesu GDB w Termdebug.');
}
async function assertEditorReady(repositoryRoot, artifact, binding) {
const modified = await nvimLua(`
local result = {}
for _, buffer in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_is_loaded(buffer) and vim.bo[buffer].modified then
table.insert(result, vim.api.nvim_buf_get_name(buffer))
end
end
return result
`, [], binding);
if (Array.isArray(modified) && modified.some(name => name.endsWith(artifact.source))) {
throw new Error('Bufor Task04 ma niezapisane zmiany; zapis lub przebudowa są wymagane przed replay.');
}
const elf = await nvimLua('return vim.env.ELF_FILE or ""', [], binding);
if (typeof elf !== 'string' || !elf) throw new Error('Neovim nie ma ELF_FILE bieżącej sesji.');
const elfOnHost = hostPath(repositoryRoot, elf);
if (artifact.source_git_blob) {
const sourceOnHost = hostPath(repositoryRoot, artifact.source);
const actualSourceBlob = await gitBlobSha1(sourceOnHost);
if (actualSourceBlob !== artifact.source_git_blob) {
throw new Error('Źródło Task04 różni się od wersji użytej do pomiaru karty; przebuduj kartę i snapshoty.');
}
}
if (artifact.hazard3_image_sha256 && binding.metadata.profile === 'hazard3-sim') {
const imageOnHost = hostPath(repositoryRoot, artifact.hazard3_image);
const actual = await sha256(imageOnHost);
if (actual !== artifact.hazard3_image_sha256) {
throw new Error('Obraz wykonywalny Hazard3 różni się od obrazu użytego do pomiaru karty; zregeneruj snapshoty.');
}
}
return { elf, elfOnHost };
}
async function waitForResult(resultFile, operationId, timeoutMs, binding) {
const deadline = Date.now() + timeoutMs;
let latest = null;
while (Date.now() < deadline) {
try {
const current = await readJson(resultFile);
if (current.operation_id === operationId) {
latest = current;
state.active = current;
if (current.status === 'ready' || current.status === 'failed') return current;
}
} catch {
// GDB writes atomically; absence before the first phase is expected.
}
await delay(POLL_MS);
}
await interruptGdb(binding).catch(() => undefined);
await delay(200);
throw new Error(`Timeout replay; ostatni stan: ${latest?.status ?? 'brak odpowiedzi GDB'}`);
}
async function runActivation({
repositoryRoot,
cardFile,
snapshotRef,
generation,
expectedBinding,
expectedIdentity
}) {
if (generation !== state.wantedGeneration) {
return { status: 'cancelled', generation, snapshot_ref: snapshotRef, message: 'Zastąpione nowszym krokiem.' };
}
const [cardSnapshot, binding] = await Promise.all([readCardSnapshot(cardFile), selectedBinding()]);
const card = cardSnapshot.document;
assertExpectedIdentity(cardSnapshot.identity, expectedIdentity);
assertExpectedBinding(binding, expectedBinding);
const selection = binding.metadata;
const registry = card.debug_checkpoints;
const recipe = registry?.items?.[snapshotRef];
if (!recipe) throw new Error(`Brak recepty checkpointu: ${snapshotRef}`);
if (!registry.targets?.[selection.profile]) {
throw new Error(`Karta nie ma adaptera replay dla profilu ${selection.profile ?? '?'}.`);
}
await assertBindingCurrent(binding);
await ensureTermdebug(binding);
await assertEditorReady(repositoryRoot, registry.artifact, binding);
const operationId = randomUUID();
const payload = {
operation_id: operationId,
generation,
snapshot_ref: snapshotRef,
profile: selection.profile,
semantics: registry.semantics,
stop: recipe.stop,
verify: recipe.verify ?? { expressions: [] }
};
state.active = {
status: 'replaying',
phase: 'dispatch',
operation_id: operationId,
generation,
snapshot_ref: snapshotRef,
profile: selection.profile
};
state.activeControl = { generation, binding };
await sendGdb('source /workspace/tools/gdb/stem_checkpoint.py', binding);
const token = Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
await sendGdb(`stem-checkpoint-activate ${token}`, binding);
const resultFile = path.join(
repositoryRoot,
'.stem/instances',
selection.instance,
'gdb-sync.json.checkpoint.json'
);
const result = await waitForResult(
resultFile,
operationId,
selection.profile === 'rp2350' ? 45000 : 20000,
binding
);
if (result.status !== 'ready') {
throw new Error(result.message ?? `Replay ${snapshotRef} nie osiągnął stanu ready.`);
}
await assertBindingCurrent(binding);
state.last = result;
state.active = null;
state.activeControl = null;
await callPinnedNvim(binding, 'nvim_command', ['silent! StudentViewReset']).catch(() => undefined);
await callPinnedNvim(binding, 'nvim_command', ['redraw!']).catch(() => undefined);
return result;
}
export function checkpointStatus() {
return { active: state.active, last: state.last, wanted_generation: state.wantedGeneration };
}
export function activateCheckpoint({
repositoryRoot,
cardFile,
snapshotRef,
generation,
expectedBinding,
expectedIdentity
}) {
if (typeof snapshotRef !== 'string' || !/^task04\.[a-z0-9.-]+$/.test(snapshotRef)) {
return Promise.reject(new Error('Niepoprawny snapshot_ref.'));
}
if (generation != null && (!Number.isSafeInteger(generation) || generation <= 0)) {
return Promise.reject(new Error('generation musi być dodatnią, bezpieczną liczbą całkowitą.'));
}
const nextGeneration = Number.isSafeInteger(generation) && generation > 0
? generation
: state.wantedGeneration + 1;
state.wantedGeneration = Math.max(state.wantedGeneration + 1, nextGeneration);
const activeControl = state.activeControl;
if (
activeControl
&& activeControl.generation < state.wantedGeneration
&& state.active?.phase === 'run-to-stop'
) {
void interruptGdb(activeControl.binding).catch(() => undefined);
}
const request = {
repositoryRoot,
cardFile,
snapshotRef,
generation: state.wantedGeneration,
expectedBinding,
expectedIdentity
};
const operation = state.queue.then(() => runActivation(request));
state.queue = operation.catch(error => {
state.last = {
status: 'failed',
snapshot_ref: snapshotRef,
generation: request.generation,
message: errorText(error)
};
state.active = null;
if (state.activeControl?.generation === request.generation) state.activeControl = null;
});
return operation;
}
+447
View File
@@ -0,0 +1,447 @@
import net from 'node:net';
import path from 'node:path';
import { readFile, realpath } from 'node:fs/promises';
import { decodeMultiStream, encode } from '@msgpack/msgpack';
const OPEN = 1;
const DEFAULT_COLUMNS = 190;
const DEFAULT_ROWS = 50;
const RETRY_DELAY_MS = 1200;
const TARGET_POLL_MS = 750;
const MAX_BUFFERED_BYTES = 4 * 1024 * 1024;
function errorText(error) {
return error instanceof Error ? error.message : String(error);
}
function delay(milliseconds) {
return new Promise(resolve => setTimeout(resolve, milliseconds));
}
async function selectedTarget(selectionRoot) {
const current = path.join(selectionRoot, 'current');
const socketLink = path.join(current, 'n.sock');
const [socketPath, metadataText] = await Promise.all([
realpath(socketLink),
readFile(path.join(current, 'selection.json'), 'utf8')
]);
const metadata = JSON.parse(metadataText);
return {
socketPath,
metadata,
epoch: `${metadata.container_id ?? 'unknown'}:${socketPath}`
};
}
class NvimRpcClient {
constructor(socketPath, onNotification) {
this.socketPath = socketPath;
this.onNotification = onNotification;
this.socket = new net.Socket();
this.nextMessageId = 1;
this.pending = new Map();
this.closed = false;
this.readLoop = null;
}
async connect() {
await new Promise((resolve, reject) => {
const connected = () => {
this.socket.off('error', failed);
resolve();
};
const failed = error => {
this.socket.off('connect', connected);
reject(error);
};
this.socket.once('connect', connected);
this.socket.once('error', failed);
this.socket.connect(this.socketPath);
});
this.readLoop = this.consume().catch(error => {
this.rejectPending(error);
if (!this.closed) this.socket.destroy(error);
throw error;
});
}
async consume() {
for await (const message of decodeMultiStream(this.socket)) {
if (!Array.isArray(message)) continue;
if (message[0] === 1) {
const pending = this.pending.get(message[1]);
if (!pending) continue;
this.pending.delete(message[1]);
if (message[2]) pending.reject(new Error(JSON.stringify(message[2])));
else pending.resolve(message[3]);
} else if (message[0] === 2) {
this.onNotification(message[1], message[2]);
}
}
throw new Error('Neovim zamknął połączenie RPC.');
}
call(method, parameters = []) {
if (this.closed) return Promise.reject(new Error('Połączenie RPC jest zamknięte.'));
const id = this.nextMessageId++;
return new Promise((resolve, reject) => {
this.pending.set(id, { resolve, reject });
this.socket.write(encode([0, id, method, parameters]), error => {
if (!error) return;
this.pending.delete(id);
reject(error);
});
});
}
rejectPending(error) {
for (const pending of this.pending.values()) pending.reject(error);
this.pending.clear();
}
close() {
if (this.closed) return;
this.closed = true;
this.rejectPending(new Error('Połączenie RPC zostało zamknięte.'));
this.socket.destroy();
}
}
function safeSend(websocket, message) {
if (websocket.readyState !== OPEN) return;
if (websocket.bufferedAmount > MAX_BUFFERED_BYTES) {
websocket.close(1013, 'Neovim UI client is too slow');
return;
}
websocket.send(JSON.stringify(message));
}
function existingUiSize(uis) {
const valid = Array.isArray(uis)
? uis.filter(item => Number.isInteger(item?.width) && Number.isInteger(item?.height))
: [];
const terminal = valid.find(item => item?.stdin_tty && item?.stdout_tty);
if (terminal) {
return { width: terminal.width, height: terminal.height };
}
return {
// Without a terminal UI there is nothing to mirror, so retain a useful
// external-UI fallback size until the selected terminal is attached again.
width: Math.max(DEFAULT_COLUMNS, ...valid.map(item => item.width)),
height: Math.max(DEFAULT_ROWS, ...valid.map(item => item.height))
};
}
class NvimUiHub {
constructor(selectionRoot, onEmpty) {
this.selectionRoot = selectionRoot;
this.onEmpty = onEmpty;
this.clients = new Set();
this.rpc = null;
this.stopped = false;
this.started = false;
this.status = { type: 'status', state: 'connecting' };
this.pendingResize = null;
this.resizeTimer = null;
this.appliedUiSize = '';
this.uiSize = { width: DEFAULT_COLUMNS, height: DEFAULT_ROWS };
this.uiAttached = false;
this.refreshingUi = null;
}
broadcast(message) {
this.status = message.type === 'status' ? message : this.status;
for (const client of this.clients) safeSend(client, message);
}
add(websocket) {
this.clients.add(websocket);
safeSend(websocket, this.status);
const remove = () => this.remove(websocket);
websocket.once('close', remove);
websocket.once('error', remove);
websocket.on('message', payload => this.handleMessage(payload));
if (!this.started) {
this.started = true;
void this.run().catch(error => {
this.broadcast({ type: 'status', state: 'offline', message: errorText(error) });
});
} else if (this.rpc && this.uiAttached) {
// A newly opened browser has no copy of redraw events emitted before it
// joined the shared hub. Reattaching only this external UI makes Neovim
// send one complete line-grid frame. The real TTY UI stays attached.
void this.refreshUi().catch(() => undefined);
}
}
refreshUi() {
if (this.refreshingUi) return this.refreshingUi;
const rpc = this.rpc;
const size = this.uiSize;
if (!rpc || !this.uiAttached) return Promise.resolve();
this.refreshingUi = (async () => {
await rpc.call('nvim_ui_detach');
if (rpc !== this.rpc || this.stopped) return;
this.uiAttached = false;
await rpc.call('nvim_ui_attach', [size.width, size.height, {
rgb: false,
ext_linegrid: true,
ext_multigrid: false,
override: false
}]);
if (rpc === this.rpc) this.uiAttached = true;
})().finally(() => {
this.refreshingUi = null;
});
return this.refreshingUi;
}
remove(websocket) {
this.clients.delete(websocket);
if (this.clients.size > 0) return;
this.stopped = true;
if (this.resizeTimer) clearTimeout(this.resizeTimer);
this.rpc?.close();
this.onEmpty();
}
scheduleResize(columns, rows) {
this.pendingResize = { columns, rows };
if (this.resizeTimer) clearTimeout(this.resizeTimer);
this.resizeTimer = setTimeout(() => {
this.resizeTimer = null;
void this.applyResize().catch(() => undefined);
}, 90);
}
async resizeToTerminal() {
if (this.resizeTimer) {
clearTimeout(this.resizeTimer);
this.resizeTimer = null;
}
this.pendingResize = {
columns: this.uiSize.width,
rows: this.uiSize.height
};
await this.applyResize();
return this.uiSize;
}
async applyResize() {
const requested = this.pendingResize;
const rpc = this.rpc;
this.pendingResize = null;
if (!requested || !rpc || this.stopped) return;
const uis = await rpc.call('nvim_list_uis');
if (rpc !== this.rpc) return;
const terminalUi = Array.isArray(uis)
? uis.find(ui => ui?.stdin_tty && ui?.stdout_tty)
: null;
const columns = Number.isInteger(terminalUi?.width)
? terminalUi.width
: requested.columns;
const rows = Number.isInteger(terminalUi?.height)
? terminalUi.height
: requested.rows;
const size = `${columns}x${rows}`;
if (size === this.appliedUiSize) return;
await rpc.call('nvim_ui_try_resize', [columns, rows]);
if (rpc !== this.rpc) return;
await rpc.call('nvim_command', ['silent! StudentLayoutFit']);
this.uiSize = { width: columns, height: rows };
this.appliedUiSize = size;
}
handleMessage(payload) {
if (!this.rpc || this.stopped) return;
try {
const message = JSON.parse(payload.toString('utf8'));
if (message?.type === 'refresh') {
void this.resizeToTerminal()
.then(() => this.refreshUi())
.catch(() => undefined);
return;
}
if (message?.type === 'input' && typeof message.keys === 'string' && message.keys.length <= 256) {
void this.rpc.call('nvim_input', [message.keys]).catch(() => undefined);
return;
}
if (message?.type !== 'mouse') return;
const button = ['left', 'middle', 'right', 'wheel'].includes(message.button)
? message.button
: null;
const action = ['press', 'drag', 'release', 'up', 'down'].includes(message.action)
? message.action
: null;
const row = Number(message.row);
const column = Number(message.column);
if (!button || !action || !Number.isInteger(row) || !Number.isInteger(column)) return;
void this.rpc.call('nvim_input_mouse', [
button,
action,
typeof message.modifier === 'string' ? message.modifier.slice(0, 16) : '',
0,
Math.max(0, row),
Math.max(0, column)
]).catch(() => undefined);
} catch {
// Only the typed input/mouse/refresh capabilities above are exposed.
}
}
async run() {
while (!this.stopped && this.clients.size > 0) {
let selected;
try {
selected = await selectedTarget(this.selectionRoot);
} catch (error) {
this.broadcast({
type: 'status',
state: 'unselected',
message: `Brak aktywnego celu MCP: ${errorText(error)}`
});
await delay(RETRY_DELAY_MS);
continue;
}
this.broadcast({
type: 'status',
state: 'connecting',
epoch: selected.epoch,
target: selected.metadata
});
let targetTimer = null;
try {
this.rpc = new NvimRpcClient(selected.socketPath, (method, parameters) => {
if (method !== 'redraw') return;
this.broadcast({
type: 'redraw',
epoch: selected.epoch,
events: parameters
});
});
await this.rpc.connect();
this.appliedUiSize = '';
this.uiAttached = false;
void this.rpc.readLoop.catch(() => undefined);
const uis = await this.rpc.call('nvim_list_uis');
const size = existingUiSize(uis);
this.uiSize = size;
await this.rpc.call('nvim_set_client_info', [
'stem-card-browser',
{ major: 0, minor: 1, patch: 0 },
'ui',
{},
{ website: 'local://stem-card', license: 'private' }
]);
await this.rpc.call('nvim_ui_attach', [size.width, size.height, {
rgb: false,
ext_linegrid: true,
ext_multigrid: false,
override: false
}]);
this.uiAttached = true;
this.broadcast({
type: 'status',
state: 'connected',
epoch: selected.epoch,
target: selected.metadata,
grid: size,
control: 'typed-input'
});
const targetChanged = new Promise((_, reject) => {
targetTimer = setInterval(async () => {
try {
const current = await selectedTarget(this.selectionRoot);
if (current.epoch !== selected.epoch) {
reject(new Error('Zmieniono cel MCP.'));
}
} catch (error) {
reject(error);
}
}, TARGET_POLL_MS);
});
await Promise.race([this.rpc.readLoop, targetChanged]);
} catch (error) {
if (!this.stopped) {
this.broadcast({
type: 'status',
state: 'offline',
epoch: selected.epoch,
target: selected.metadata,
message: errorText(error)
});
}
} finally {
if (targetTimer) clearInterval(targetTimer);
this.uiAttached = false;
this.rpc?.close();
this.rpc = null;
}
if (!this.stopped) await delay(RETRY_DELAY_MS);
}
}
}
const hubs = new Map();
function selectionRootFor(options = {}) {
return options.selectionRoot
?? process.env.MCP_CONTAINER_SELECTION_ROOT
?? path.join(process.env.XDG_RUNTIME_DIR ?? '/run/user/1000', 'stem/mcp-selected');
}
export async function callSelectedNvim(method, parameters = [], options = {}) {
const selectionRoot = selectionRootFor(options);
const hub = hubs.get(selectionRoot);
const pinnedSocketPath = options.socketPath
? await realpath(options.socketPath)
: null;
if (
hub?.rpc
&& !hub.stopped
&& (!pinnedSocketPath || hub.rpc.socketPath === pinnedSocketPath)
) {
return hub.rpc.call(method, parameters);
}
const socketPath = pinnedSocketPath ?? (await selectedTarget(selectionRoot)).socketPath;
const rpc = new NvimRpcClient(socketPath, () => undefined);
await rpc.connect();
void rpc.readLoop.catch(() => undefined);
try {
return await rpc.call(method, parameters);
} finally {
rpc.close();
}
}
export async function resizeSelectedNvimUi(options = {}) {
const selectionRoot = selectionRootFor(options);
const hub = hubs.get(selectionRoot);
if (!hub?.rpc || hub.stopped || !hub.uiAttached) {
return { attached: false, grid: null };
}
const grid = await hub.resizeToTerminal();
await hub.refreshUi();
return { attached: true, grid };
}
export async function selectedNvimTarget(options = {}) {
return selectedTarget(selectionRootFor(options));
}
/**
* Attach a browser to the one shared external UI for the selected container.
* The browser never receives the Unix socket and cannot issue arbitrary RPC.
*/
export function attachNvimUi(websocket, options = {}) {
const selectionRoot = selectionRootFor(options);
let hub = hubs.get(selectionRoot);
if (!hub || hub.stopped) {
hub = new NvimUiHub(selectionRoot, () => hubs.delete(selectionRoot));
hubs.set(selectionRoot, hub);
}
hub.add(websocket);
}
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env sh
set -eu
script_dir=$(CDPATH= cd "$(dirname "$0")" && pwd)
repo_root=$(CDPATH= cd "$script_dir/.." && pwd)
if [ -n "${CARD_LAYOUTS_ROOT:-}" ]; then
layouts_root=$CARD_LAYOUTS_ROOT
else
layouts_root=$repo_root/../../../tools/card-layouts
fi
renderer=$layouts_root/tools/render_card.py
if [ ! -f "$renderer" ]; then
echo "Nie znaleziono generatora card-layouts: $renderer" >&2
echo "Ustaw CARD_LAYOUTS_ROOT na checkout edu-tools/card-layouts." >&2
exit 1
fi
exec python3 "$renderer" "$repo_root" "$@"
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env sh
set -eu
DOC_DIR="doc"
TEX_FILE="${CARD_TEX_FILE:-generated/main.tex}"
METADATA_TEX_FILE="${CARD_PDF_METADATA_TEX_FILE:-main.tex}"
script_dir=$(CDPATH= cd "$(dirname "$0")" && pwd)
repo_root=$(CDPATH= cd "$script_dir/.." && pwd)
repo_name="${REPO_NAME:-$(basename "$repo_root")}"
tex_path="$repo_root/$DOC_DIR/$TEX_FILE"
metadata_tex_path="$repo_root/$DOC_DIR/$METADATA_TEX_FILE"
tex_dir=$(dirname "$tex_path")
tex_name=$(basename "$tex_path")
read_tex_command() {
sed -n "s/^\\\\newcommand{\\\\$1}{\\([^}]*\\)}$/\\1/p" "$metadata_tex_path" | head -n 1
}
publisher_domain=$(read_tex_command PublisherDomain)
card_area=$(read_tex_command CardArea)
card_series=$(read_tex_command CardSeries)
card_number=$(read_tex_command CardNumber)
card_slug=$(read_tex_command CardSlug)
card_version=$(read_tex_command CardVersion)
document_uuid=$(read_tex_command DocumentUUID)
if [ -z "$publisher_domain" ] || [ -z "$card_area" ] || [ -z "$card_series" ] || \
[ -z "$card_number" ] || [ -z "$card_slug" ] || [ -z "$card_version" ] || \
[ -z "$document_uuid" ]; then
echo "missing PDF filename metadata in $metadata_tex_path" >&2
exit 1
fi
if [ ! -f "$tex_path" ]; then
echo "missing generated card TeX: $tex_path" >&2
exit 1
fi
commit="${GITEA_SHA:-${GITHUB_SHA:-}}"
if [ -z "$commit" ]; then
commit=$(git -C "$repo_root" rev-parse HEAD)
fi
short_commit=$(printf '%s' "$commit" | cut -c1-12)
meta_file="$tex_dir/build-meta.tex"
tmp_meta="$meta_file.tmp.$$"
cleanup() {
rm -f \
"$meta_file" \
"$tmp_meta" \
"$tex_dir/${tex_name%.tex}.upa" \
"$tex_dir/${tex_name%.tex}.upb"
}
trap cleanup EXIT HUP INT TERM
printf '\\newcommand{\\BuildCommit}{%s}\n' "$short_commit" > "$tmp_meta"
mv "$tmp_meta" "$meta_file"
pdf_out_dir="${PDF_OUT_DIR:-$repo_root/doc/pdf}"
mkdir -p "$pdf_out_dir"
out_dir=$(CDPATH= cd "$pdf_out_dir" && pwd)
pdf_basename="$publisher_domain-$card_area-$card_series-$card_number-$card_slug-$card_version-$document_uuid.pdf"
find "$out_dir" -maxdepth 1 -type f -name '*.pdf' -delete
(
cd "$tex_dir"
latexmk -pdf -interaction=nonstopmode -halt-on-error -outdir="$out_dir" "$tex_name"
)
source_pdf="$out_dir/${tex_name%.tex}.pdf"
target_pdf="$out_dir/$pdf_basename"
if [ "$source_pdf" != "$target_pdf" ]; then
mv "$source_pdf" "$target_pdf"
fi
find "$out_dir" -maxdepth 1 -type f \( \
-name '*.aux' -o \
-name '*.log' -o \
-name '*.out' -o \
-name '*.fls' -o \
-name '*.fdb_latexmk' -o \
-name '*.upa' -o \
-name '*.upb' \
\) -delete