feat(L08): add Docker fundamentals card
This commit is contained in:
@@ -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');
|
||||
}
|
||||
Reference in New Issue
Block a user