// 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(/]*>([\s\S]*?)<\/a>/gi, '$1'); out = out.replace(/<\/?a\b[^>]*>/gi, ''); // 2. Autolink -> 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`; }