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