feat(okr): pure-JS konverterings-adaptere txt/docx/eml/pdf + ukjent-ext-skip (SC format) [skip-docs]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-07-16 20:06:43 +02:00
commit a807ee2b79
5 changed files with 293 additions and 0 deletions

BIN
tests/fixtures/inbox-sample/dok.docx vendored Normal file

Binary file not shown.

11
tests/fixtures/inbox-sample/dok.eml vendored Normal file
View file

@ -0,0 +1,11 @@
From: styringsstab@etat.no
To: okr-team@etat.no
Subject: Statusoppdatering T2
Date: Mon, 18 May 2026 09:00:00 +0200
MIME-Version: 1.0
Content-Type: text/plain; charset=utf-8
Fremdrift paa maaltallene for andre tertial.
KR1 ligger paa 0.6 etter maai. KR2 er i rute. Vi maa diskutere
ambisjonsnivaaet for KR3 paa neste check-in.

10
tests/fixtures/inbox-sample/dok.pdf vendored Normal file
View file

@ -0,0 +1,10 @@
%PDF-1.4
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 612 792]/Contents 4 0 R/Resources<</Font<</F1 5 0 R>>>>>>endobj
4 0 obj<</Length 55>>stream
BT /F1 12 Tf 72 720 Td (Tildelingsbrev 2026 for etaten) Tj ET
endstream
endobj
5 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj
trailer<</Root 1 0 R/Size 6>>

View file

@ -0,0 +1,140 @@
// innboks-convert.test.mjs
// Step 11 (SC format): konverterings-adaptere txt/md/docx/eml/pdf -> markdown.
// Verifiserer:
// - .txt/.md -> markdown alltid (zero-dep-sti, node:-builtins)
// - eksterne lenker NOEYTRALISERES ved konvertering (inline/ref-def/autolink/
// HTML-anker/bilde): lenketeksten bevares, scheme-maalet droppes -- slik at
// strict-gaten (B3/F2) aldri felles av legitim konvertert output
// - .docx/.eml -> heading-STRUKTUR bevart ('# '-linjer, atx -- IKKE byte-snapshot);
// .pdf -> tekst ekstrahert (flat, dokumentert v1-caveat M5)
// - convert-twice-identical: samme binaerfil konvertert 2x gir byte-identisk
// markdown (konverterings-determinismen idempotensen hviler paa)
// - ukjent extension (.xlsx) -> null + norsk notice, INGEN throw
// Binaer-adapterne kjoerer conditional { skip: !engines } (plugin kan vaere
// installert uten node_modules -- npm-deps er dokumentert prerequisite).
// Moenster: node:test conditional skip; fixtures under tests/fixtures/inbox-sample/.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { convert } from '../lib/convert/index.mjs';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const SAMPLE = join(ROOT, 'tests', 'fixtures', 'inbox-sample');
// Er binaer-konverterings-motorene installert? (npm-deps = dokumentert prerequisite.)
async function enginesAvailable() {
try {
await import('mammoth');
await import('turndown');
await import('postal-mime');
await import('unpdf');
return true;
} catch {
return false;
}
}
const engines = await enginesAvailable();
// Temp-katalog for haandlagde input-filer. Ryddes alltid.
function withTmp(fn) {
const dir = mkdtempSync(join(tmpdir(), 'innboks-convert-'));
try {
return fn(dir);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
test('.txt konverteres alltid (zero-dep): innhold bevart som markdown', async () => {
const md = await convert(join(SAMPLE, 'dok-a.txt'));
assert.equal(typeof md, 'string');
assert.ok(md.length > 0);
});
test('.md passthrough: markdown-struktur bevart', async () => {
await withTmp(async (dir) => {
const p = join(dir, 'notat.md');
writeFileSync(p, '# Tittel\n\nAvsnitt.\n\n## Under\n\nMer.\n');
const md = await convert(p);
assert.match(md, /^# Tittel$/m);
assert.match(md, /^## Under$/m);
});
});
test('eksterne lenker noeytraliseres: inline/ref-def/autolink/HTML/bilde', async () => {
await withTmp(async (dir) => {
const p = join(dir, 'fiendtlig.txt');
writeFileSync(
p,
'# Notat\n\n'
+ 'Se [rapporten](https://evil.example/exfil) og [vedlegget][r].\n\n'
+ '[r]: https://evil.example/ref\n\n'
+ '<https://evil.example/auto>\n\n'
+ '<a href="file:///etc/passwd">passord</a>\n\n'
+ '![skjermbilde](https://evil.example/pixel.png)\n\n'
+ 'Intern relasjon: [Notat](/dokumenter/notat.md) beholdes.\n',
);
const md = await convert(p);
// Lenketekst bevart:
assert.match(md, /rapporten/);
assert.match(md, /vedlegget/);
assert.match(md, /passord/);
assert.match(md, /skjermbilde/);
// Ingen lenke-FORM med eksterne maal igjen (inline, ref-def, autolink, HTML):
assert.doesNotMatch(md, /\]\(https?:/);
assert.doesNotMatch(md, /^\s{0,3}\[[^\]]+\]:/m);
assert.doesNotMatch(md, /<https?:/);
assert.doesNotMatch(md, /<a\s/i);
assert.doesNotMatch(md, /!\[/);
assert.doesNotMatch(md, /file:\/\//);
// Trygg bundle-root-relativ .md-lenke beholdes (relasjons-formatet):
assert.match(md, /\[Notat\]\(\/dokumenter\/notat\.md\)/);
});
});
test('.docx -> markdown med atx-heading-struktur (ikke byte-snapshot)', { skip: !engines }, async () => {
const md = await convert(join(SAMPLE, 'dok.docx'));
assert.equal(typeof md, 'string');
// M5/F4: {headingStyle:'atx'} er pinnet -> '#'-headings, aldri setext.
assert.match(md, /^# /m);
assert.match(md, /^## /m);
assert.doesNotMatch(md, /^=+$/m);
});
test('.eml -> markdown: subject som heading + body-tekst (kun body, vedlegg = non-goal)', { skip: !engines }, async () => {
const md = await convert(join(SAMPLE, 'dok.eml'));
assert.match(md, /^# Statusoppdatering T2/m);
assert.match(md, /Fremdrift paa maaltallene/);
});
test('.pdf -> tekst ekstrahert (flat markdown, isEvalSupported:false-sti)', { skip: !engines }, async () => {
const md = await convert(join(SAMPLE, 'dok.pdf'));
assert.equal(typeof md, 'string');
assert.match(md, /Tildelingsbrev 2026/);
});
test('convert-twice-identical: docx og pdf gir byte-identisk markdown', { skip: !engines }, async () => {
for (const f of ['dok.docx', 'dok.pdf']) {
const first = await convert(join(SAMPLE, f));
const second = await convert(join(SAMPLE, f));
assert.equal(first, second, `ikke-deterministisk konvertering: ${f}`);
}
});
test('ukjent extension (.xlsx) -> null + norsk notice, ingen throw', async () => {
await withTmp(async (dir) => {
const p = join(dir, 'regneark.xlsx');
writeFileSync(p, 'ikke egentlig xlsx');
const notices = [];
const md = await convert(p, { onNotice: (msg) => notices.push(msg) });
assert.equal(md, null);
assert.equal(notices.length, 1);
assert.match(notices[0], /hopper over/);
assert.match(notices[0], /regneark\.xlsx/);
});
});