132 lines
5.5 KiB
JavaScript
132 lines
5.5 KiB
JavaScript
// convert/index.mjs
|
|
// Step 11 (SC format): konverterings-adaptere for innboks-ingestion.
|
|
// Dispatch paa extension: .txt/.md -> node:-builtins (les direkte);
|
|
// .docx -> mammoth(->HTML)->turndown; .eml -> postal-mime (kun body --
|
|
// vedlegg = dokumentert v1-non-goal); .pdf -> unpdf tekst (flat, ingen
|
|
// headings -- dokumentert v1-caveat M5). Ukjent extension -> skip + norsk
|
|
// notice (returner null), IKKE feil -- en enkelt ukjent fil skal aldri felle
|
|
// hele kjoeringen.
|
|
//
|
|
// Sikkerhet (innboks-dok = fiendtlig):
|
|
// - INGEN nettverk: all input leses fra disk og mates som buffer/streng.
|
|
// - CVE-2024-4367: unpdf kalles med eksplisitt { isEvalSupported: false }
|
|
// (default i unpdf 1.6.2, A0-verifisert -- settes likevel, belte+seler).
|
|
// - Eksterne lenker NOEYTRALISERES ved konvertering (B3/F2): lenketekst
|
|
// bevares, scheme-/ikke-bundle-maal droppes -- i ALLE lenkeformer
|
|
// (inline, referanse-definisjon, autolink, raa HTML-anker, bilde). Kun
|
|
// trygge bundle-root-relative .md-lenker (isSafeBundleLink, den delte
|
|
// allow-listen) overlever. Legitim konvertert output kan dermed aldri
|
|
// felles av strict-gaten pga. medbrakte eksterne lenker.
|
|
// - M5/F4: turndown pinnes til { headingStyle: 'atx' } -- default er setext,
|
|
// som heading-splitten (innboks-split) aldri ser.
|
|
//
|
|
// npm-deps (mammoth/turndown/postal-mime/unpdf) er dokumentert prerequisite
|
|
// (exact-pinnet i package.json, Step 10) -- mangler de, kastes en klar norsk
|
|
// installasjonshint (samme moenster som scripts/export-pdf.py), aldri traceback.
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
import { isSafeBundleLink } from '../okf-links.mjs';
|
|
|
|
const INSTALL_HINT = 'kjoer «npm install --ignore-scripts» i plugin-rota (krever Node >= 22)';
|
|
|
|
// Lazy-import av en konverterings-motor; mangler den -> klar norsk hint.
|
|
async function loadEngine(name) {
|
|
try {
|
|
return await import(name);
|
|
} catch (e) {
|
|
if (e && (e.code === 'ERR_MODULE_NOT_FOUND' || e.code === 'MODULE_NOT_FOUND')) {
|
|
throw new Error(`innboks: mangler npm-avhengighet «${name}» -- ${INSTALL_HINT}`);
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
// M5/F4-pinning: atx ('#') er OBLIGATORISK for at heading-splitten skal virke.
|
|
async function newTurndown() {
|
|
const TurndownService = (await loadEngine('turndown')).default;
|
|
return new TurndownService({ headingStyle: 'atx', codeBlockStyle: 'fenced' });
|
|
}
|
|
|
|
// Noeytraliser alle lenkeformer med ikke-bundle-maal; behold lenketeksten.
|
|
// Rekkefoelgen er semantisk: HTML-anker foer autolink (begge bruker <...>),
|
|
// ref-definisjoner foer ref-bruk-kollaps, bilder foer inline ( baerer
|
|
// samme hale som [..](..)).
|
|
export function neutralizeExternalLinks(markdown) {
|
|
let out = String(markdown);
|
|
// 1. Raa HTML-anker -> indre tekst (file://-href o.l. droppes med taggen).
|
|
out = out.replace(/<a\b[^>]*>([\s\S]*?)<\/a>/gi, '$1');
|
|
out = out.replace(/<\/?a\b[^>]*>/gi, '');
|
|
// 2. Autolink <scheme:...> -> ren tekst (URL mister lenke-formen).
|
|
out = out.replace(/<([a-z][a-z0-9+.-]*:[^<>\s]*)>/gi, '$1');
|
|
// 3. Referanse-definisjoner droppes linjevis; ref-bruk kollapses til tekst.
|
|
out = out.replace(/^[ \t]{0,3}\[[^\]]+\]:[ \t]*\S.*$/gm, '');
|
|
out = out.replace(/\[([^\]]+)\]\[[^\]]*\]/g, '$1');
|
|
// 4. Bilder -> alt-tekst (EchoLeak-stil exfil-piksel har ingen plass i bundlen).
|
|
out = out.replace(/!\[([^\]]*)\]\(([^)]*)\)/g, '$1');
|
|
// 5. Inline-lenker: KUN trygge bundle-root-relative .md-maal beholdes.
|
|
out = out.replace(/\[([^\]]*)\]\(([^)]+)\)/g, (whole, text, target) => {
|
|
const dest = target.trim().split(/\s+/)[0];
|
|
return isSafeBundleLink(dest) ? whole : text;
|
|
});
|
|
return out;
|
|
}
|
|
|
|
async function docxToMarkdown(filePath) {
|
|
const mammothMod = await loadEngine('mammoth');
|
|
const mammoth = mammothMod.default ?? mammothMod;
|
|
const { value: html } = await mammoth.convertToHtml({ buffer: readFileSync(filePath) });
|
|
return (await newTurndown()).turndown(html);
|
|
}
|
|
|
|
async function emlToMarkdown(filePath) {
|
|
const PostalMime = (await loadEngine('postal-mime')).default;
|
|
const parsed = await new PostalMime().parse(readFileSync(filePath, 'utf8'));
|
|
const parts = [];
|
|
if (parsed.subject) parts.push(`# ${parsed.subject}`);
|
|
if (parsed.text) {
|
|
parts.push(parsed.text.trim());
|
|
} else if (parsed.html) {
|
|
parts.push((await newTurndown()).turndown(parsed.html));
|
|
}
|
|
return parts.join('\n\n');
|
|
}
|
|
|
|
async function pdfToMarkdown(filePath) {
|
|
const { getDocumentProxy, extractText } = await loadEngine('unpdf');
|
|
const data = new Uint8Array(readFileSync(filePath));
|
|
const proxy = await getDocumentProxy(data, { isEvalSupported: false });
|
|
const { text } = await extractText(proxy, { mergePages: true });
|
|
return text;
|
|
}
|
|
|
|
// filsti -> markdown-streng (neoytralisert, garantert trailing newline),
|
|
// eller null for ukjent extension (skip + norsk notice via onNotice).
|
|
export async function convert(
|
|
filePath,
|
|
{ onNotice = (msg) => process.stderr.write(`${msg}\n`) } = {},
|
|
) {
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
let markdown;
|
|
switch (ext) {
|
|
case '.md':
|
|
case '.txt':
|
|
markdown = readFileSync(filePath, 'utf8');
|
|
break;
|
|
case '.docx':
|
|
markdown = await docxToMarkdown(filePath);
|
|
break;
|
|
case '.eml':
|
|
markdown = await emlToMarkdown(filePath);
|
|
break;
|
|
case '.pdf':
|
|
markdown = await pdfToMarkdown(filePath);
|
|
break;
|
|
default:
|
|
onNotice(`innboks: hopper over ukjent filtype «${ext || '(ingen)'}»: ${path.basename(filePath)}`);
|
|
return null;
|
|
}
|
|
const neutralized = neutralizeExternalLinks(markdown);
|
|
return neutralized.endsWith('\n') ? neutralized : `${neutralized}\n`;
|
|
}
|