194 lines
6.9 KiB
JavaScript
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');
|
|
}
|