import { parseHTML } from 'linkedom'; import { createHash } from 'node:crypto'; import { mkdir, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; const PROGRAMMES = new Map([ ['bachelor', 'bachelor'], ['master_ai', 'master-ai'], ['master_dsdm', 'master-dsdm'], ]); function slug(value) { return value.toLowerCase().replaceAll('_', '-').replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, ''); } export function extractSitemapEntries(html) { const { document } = parseHTML(html); const tree = document.querySelector('#index__tree'); if (!tree) throw new Error('DokuWiki sitemap response is missing #index__tree'); const namespaces = new Set(); const pageIds = new Set(); for (const anchor of tree.querySelectorAll('a.idx_dir')) { const href = anchor.getAttribute('href'); if (!href) continue; const url = new URL(href, 'https://msvincognito.nl'); const namespace = url.searchParams.get('idx'); if (namespace) namespaces.add(namespace); } for (const anchor of tree.querySelectorAll('a[data-wiki-id]')) { const id = anchor.getAttribute('data-wiki-id')?.trim(); if (id) pageIds.add(id); } return { namespaces: [...namespaces].sort(), pageIds: [...pageIds].sort(), }; } export function destinationForPageId(pageId) { const parts = pageId.split(':'); if (parts[0] !== 'study' || !PROGRAMMES.has(parts[1]) || !/^year_\d+$/.test(parts[2] ?? '') || parts.length < 4) { return null; } const routeParts = [PROGRAMMES.get(parts[1]), ...parts.slice(2).map(slug)]; return `src/content/docs/${routeParts.join('/')}.md`; } export function classifyPreviousPage(pageId, representedPageIds = new Set(), source = '') { if (representedPageIds.has(pageId)) return 'represented'; if (pageId.endsWith(':placeholder') && /This course is a placeholder/i.test(source)) return 'empty'; return destinationForPageId(pageId) ? 'migrate' : 'excluded'; } function humanLabel(value) { return value .replace(/\.pdf$/i, '') .replaceAll('_', ' ') .replace(/\b\w/g, (character) => character.toUpperCase()); } function markdownRouteForPageId(pageId) { const destination = destinationForPageId(pageId); return destination ? `/${destination.replace(/^src\/content\/docs\//, '').replace(/\.md$/, '')}/` : `https://msvincognito.nl/wiki/${pageId.replaceAll(':', '/')}`; } export function convertCourseSource({ pageId, sourceUrl, source, pdfs }) { const titleMatch = source.match(/^={2,6}\s*(.*?)\s*={2,6}\s*$/m); if (!titleMatch) throw new Error(`course source has no DokuWiki heading: ${pageId}`); const sourceTitle = titleMatch[1].trim(); const title = /^Course Title$/i.test(sourceTitle) ? humanLabel(pageId.split(':').at(-1)) : sourceTitle; let body = source.replace(titleMatch[0], '').trim(); body = body.replace(/\{\{(?:filelist|medialist)>([^}]+)\}\}/g, (_match, expression) => { let namespace = expression.split(/[&*]/, 1)[0].replace(/^:/, '').replace(/:$/, ''); if (namespace.includes('@NS@') || namespace.includes('@PAGE@')) namespace = pageId; const matches = pdfs.filter(({ mediaId }) => mediaId.startsWith(`${namespace}:`)); if (matches.length === 0) return 'No recovered PDF files were found for this source folder.'; return matches .map(({ mediaId, publicUrl }) => `- [${humanLabel(mediaId.split(':').at(-1))}](${publicUrl})`) .join('\n'); }); body = body .replace(/^={2,6}\s*(.*?)\s*={2,6}\s*$/gm, '## $1') .replace(/\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_match, target, text) => { const href = /^https?:\/\//.test(target) ? target : markdownRouteForPageId(target); return `[${text || target}](${href})`; }) .replace(/^\s+([*-])\s+/gm, '$1 ') .replace(/\\\\\s*$/gm, '') .replace(/^~~NOCACHE~~\s*$/gm, '') .replace(/\{\{[^}]+\}\}/g, '') .replace(/\n{3,}/g, '\n\n') .trim(); return `--- title: ${title} description: Historical course details for ${title}, recovered from the previous Incognito wiki. --- :::caution[Historical information] This information originated in the previous wiki and may be outdated. Check the current Maastricht University course information before relying on it. ::: ${body} --- Source: [Previous wiki page](${sourceUrl}) `; } function pagePath(outputRoot, pageId) { const parts = pageId.split(':'); if (parts.some((part) => !part || part === '.' || part === '..' || part.includes('/') || part.includes('\\'))) { throw new Error(`unsafe DokuWiki page ID: ${pageId}`); } return join(outputRoot, 'pages', ...parts.slice(0, -1), `${parts.at(-1)}.txt`); } export async function crawlPreviousWiki({ capturedAt, outputRoot, fetchText, onProgress = () => {} }) { const queuedNamespaces = ['']; const seenNamespaces = new Set(); const pageIds = new Set(); while (queuedNamespaces.length > 0) { queuedNamespaces.sort(); const namespace = queuedNamespaces.shift(); if (seenNamespaces.has(namespace)) continue; seenNamespaces.add(namespace); const query = namespace ? `&idx=${encodeURIComponent(namespace)}` : ''; const html = await fetchText(`/wiki/start?do=index${query}`); const entries = extractSitemapEntries(html); onProgress({ phase: 'sitemap', completed: seenNamespaces.size, discovered: queuedNamespaces.length + seenNamespaces.size }); for (const child of entries.namespaces) { if (!seenNamespaces.has(child)) queuedNamespaces.push(child); } for (const id of entries.pageIds) pageIds.add(id); } const pages = []; const failures = []; for (const id of [...pageIds].sort()) { const url = `https://msvincognito.nl/wiki/${id.replaceAll(':', '/')}`; const rawPath = `/wiki/_export/raw/${encodeURIComponent(id)}`; try { const source = await fetchText(rawPath); const localPath = pagePath(outputRoot, id); await mkdir(join(localPath, '..'), { recursive: true }); await writeFile(localPath, source); pages.push({ id, url, localPath: localPath.slice(outputRoot.length + 1), bytes: Buffer.byteLength(source), sha256: createHash('sha256').update(source).digest('hex'), }); } catch (error) { failures.push({ id, url, error: error instanceof Error ? error.message : String(error) }); } onProgress({ phase: 'pages', completed: pages.length + failures.length, discovered: pageIds.size }); } const manifest = { source: 'https://msvincognito.nl/wiki/', capturedAt, namespaces: [...seenNamespaces].sort(), pages, failures, }; await mkdir(outputRoot, { recursive: true }); await writeFile(join(outputRoot, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`); return manifest; }