174 lines
6.6 KiB
JavaScript
174 lines
6.6 KiB
JavaScript
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;
|
|
}
|