Files
lab-console-neovim/scripts/lib/card_progress.mjs
T

194 lines
6.9 KiB
JavaScript

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');
}