feat(ubl): UBL-profil-detektor + identifikator-afkoder (/ubl, client-side) + /profiler ?q=-prefill + ⌘K
Build & Deploy / deploy (push) Successful in 2m9s

This commit is contained in:
Asger Geel Weirsøe
2026-06-16 19:20:40 +02:00
parent b6aca5e245
commit e67045b2bf
3 changed files with 197 additions and 0 deletions
+2
View File
@@ -164,6 +164,8 @@ const kilde = (p: (typeof profiles)[number]) => {
setTimeout(function () { btn.textContent = orig; }, 1200);
}).catch(function () {});
});
var qp = new URLSearchParams(location.search).get('q');
if (qp) { search.value = qp; q = qp.trim().toLowerCase(); }
apply();
})();
</script>
+1
View File
@@ -11,6 +11,7 @@ export async function GET() {
{ type: 'page', title: 'Tags', url: '/tags' },
{ type: 'page', title: 'NemHandel-opslag', url: '/nemhandel' },
{ type: 'page', title: 'Profil-opslag (Nemhandel & Peppol)', url: '/profiler' },
{ type: 'page', title: 'UBL-tjek & identifikator-afkoder', url: '/ubl' },
{ type: 'page', title: 'Spørg mit CV (AI-chat)', url: '/chat' },
{ type: 'page', title: 'Status (homelab)', url: '/status' },
{ type: 'page', title: 'Now', url: '/now' },
+194
View File
@@ -0,0 +1,194 @@
---
import Base from '../layouts/Base.astro';
import { profiles } from '../data/profiles';
// Minimal data til klient-matchning (id + customization + navn + standard + dokumenter).
const idx = profiles.map((p) => ({
standard: p.standard,
navn: p.navn,
id: p.id,
customization: p.customization,
dokumenter: p.dokumenter,
}));
---
<Base
title="UBL-tjek & identifikator-afkoder"
description="Træk en UBL/OIOUBL-faktura ind eller indsæt en CustomizationID/ProfileID — så afkoder den profilen, syntaksen og EN 16931-status. Alt sker lokalt i browseren; filen sendes aldrig nogen steder."
>
<h1 class="text-4xl font-bold tracking-tight">UBL-tjek</h1>
<p class="mt-4 max-w-2xl text-muted">
Træk en <strong class="text-text">UBL/OIOUBL-faktura</strong> (eller andet UBL-dokument) ind — eller indsæt en
<strong class="text-text">CustomizationID/ProfileID</strong> — så afkoder den profilen, syntaksen og
EN 16931-status og kobler til <a href="/profiler" class="text-accent">profil-opslaget</a>.
Alt sker <strong class="text-text">lokalt i din browser</strong>; intet sendes nogen steder.
</p>
<div id="drop" class="mt-8 rounded-2xl border border-dashed border-border bg-surface/40 p-8 text-center transition-colors">
<p class="text-muted">Træk en <code class="rounded bg-surface-2 px-1.5 py-0.5">.xml</code>-fil hertil, eller
<label class="cursor-pointer text-accent hover:underline">vælg en fil<input id="file" type="file" accept=".xml,text/xml,application/xml" class="hidden" /></label>.
</p>
</div>
<details class="mt-3">
<summary class="cursor-pointer text-sm text-muted">…eller indsæt XML / en identifikator manuelt</summary>
<textarea id="paste" rows="6" placeholder="Indsæt UBL-XML, eller bare en CustomizationID/ProfileID…"
class="mt-2 w-full rounded-xl border border-border bg-surface/60 px-4 py-3 font-mono text-xs outline-none focus:border-accent"></textarea>
<p class="mt-2"><button id="analyze" type="button" class="rounded-xl bg-accent px-4 py-2 text-sm font-medium text-bg">Afkod</button></p>
</details>
<div id="result" class="mt-6" hidden></div>
<p id="err" class="mt-6 text-sm" hidden></p>
<p class="mt-10 text-xs text-muted/80">
Heuristik mod kendte profiler (samme datagrundlag som <a href="/profiler" class="text-accent">/profiler</a>) +
EN 16931-/Peppol-/OIOUBL-mønstre. Privat: ingen upload, ingen sporing af indholdet.
</p>
<script type="application/json" id="profiles-data" set:html={JSON.stringify(idx)}></script>
<script is:inline>
(function () {
var PROFILES = JSON.parse(document.getElementById('profiles-data').textContent || '[]');
var drop = document.getElementById('drop'), fileInput = document.getElementById('file'),
paste = document.getElementById('paste'), result = document.getElementById('result'),
err = document.getElementById('err');
var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; }); };
// Electronic Address Scheme (ISO 6523) — de gængse, DK/Norden i fokus.
var EAS = {
'0088': 'GLN (GS1 Global Location Number)',
'0184': 'DK:CVR (dansk CVR-nummer)',
'0198': 'DK:SE (dansk SE-nummer)',
'0096': 'DK:P (dansk P-nummer)',
'0192': 'NO:ORG (norsk organisasjonsnummer)',
'0007': 'SE:ORG (svensk organisationsnummer)',
'0208': 'BE:CBE (belgisk virksomhedsnr.)',
'0151': 'AU:ABN', '0060': 'DUNS', '0235': 'JP', '0211': 'IT:PA',
'9915': 'Peppol test-deltager', '9930': 'DE:LWID (Leitweg-ID)',
};
function findEl(doc, name) {
var all = doc.getElementsByTagName('*');
for (var i = 0; i < all.length; i++) if (all[i].localName === name) return all[i];
return null;
}
function txt(doc, name) { var e = findEl(doc, name); return e ? (e.textContent || '').trim() : ''; }
// afkod en CustomizationID/ProfileID-streng til menneskesprog
function decode(custom, profile) {
var notes = [], standard = null, match = null;
var c = (custom || '').trim(), p = (profile || '').trim();
// 1) eksakt match mod kendte profiler
match = PROFILES.find(function (x) { return c && x.customization === c; })
|| PROFILES.find(function (x) { return p && x.id === p; })
|| PROFILES.find(function (x) { return c && x.id === c; });
// 2) mønster-heuristik (også når der ikke er fuldt match)
var blob = (c + ' ' + p).toLowerCase();
if (blob.indexOf('en16931') > -1) notes.push('EN 16931-kompatibel (CIUS af den europæiske kernefaktura)');
if (blob.indexOf('peppol.eu') > -1 || blob.indexOf('fdc:peppol') > -1) { standard = 'Peppol BIS'; notes.push('Peppol-netværket'); }
if (blob.indexOf('pint') > -1) { standard = 'Peppol PINT'; notes.push('PINT (Peppol International)'); }
if (blob.indexOf('billing:3.0') > -1) notes.push('Billing 3.0-proces');
if (blob.indexOf('selfbilling') > -1) notes.push('Selvfakturering (køber udsteder)');
if (blob.indexOf('oioubl') > -1) { standard = standard || 'OIOUBL (NemHandel)'; notes.push('OIOUBL — dansk UBL-tilpasning'); }
if (/procurement-/i.test(p)) { standard = standard || 'OIOUBL (NemHandel)'; if (/r-|r$|simr|advr/i.test(p)) notes.push("'R' = svar påkrævet"); }
return { match: match, standard: match ? match.standard : standard, notes: notes };
}
function badge(text, kind) {
var cls = kind === 'ok' ? 'border-accent/40 bg-accent/10 text-accent' : 'border-border bg-surface-2 text-text';
return '<span class="rounded-full border ' + cls + ' px-2.5 py-0.5 text-xs">' + esc(text) + '</span>';
}
function row(dt, dd) {
return '<div class="grid gap-1 sm:grid-cols-[10rem_1fr]"><dt class="text-muted">' + esc(dt) +
'</dt><dd class="break-all font-mono">' + (dd || '<span class="text-muted">—</span>') + '</dd></div>';
}
function renderXml(doc) {
var root = doc.documentElement;
var docType = root ? root.localName : '?';
var ublv = txt(doc, 'UBLVersionID');
var custom = txt(doc, 'CustomizationID');
var profile = txt(doc, 'ProfileID');
var id = txt(doc, 'ID');
var dec = decode(custom, profile);
// EAS på parter
var endpoints = [];
var eps = doc.getElementsByTagName('*');
for (var i = 0; i < eps.length; i++) {
if (eps[i].localName === 'EndpointID') {
var scheme = eps[i].getAttribute('schemeID') || '';
endpoints.push({ scheme: scheme, val: (eps[i].textContent || '').trim() });
}
}
var epHtml = endpoints.slice(0, 4).map(function (e) {
return '<div>' + badge((EAS[e.scheme] || ('EAS ' + e.scheme || 'ukendt')), '') +
' <span class="font-mono text-xs">' + esc(e.val) + '</span></div>';
}).join('');
var head = dec.match
? '<div class="flex flex-wrap items-center gap-2">' + badge(dec.match.standard, 'ok') +
'</div><h2 class="mt-2 text-xl font-semibold tracking-tight">' + esc(dec.match.navn) + '</h2>'
: '<h2 class="text-xl font-semibold tracking-tight">' + esc(docType) +
(dec.standard ? ' <span class="text-muted text-base font-normal">· ' + esc(dec.standard) + '</span>' : '') + '</h2>';
result.innerHTML =
'<div class="rounded-2xl border border-border bg-surface/60 p-5">' + head +
'<dl class="mt-3 space-y-1 text-xs">' +
row('Dokumenttype', '<span class="font-semibold">' + esc(docType) + '</span> (UBL ' + (esc(ublv) || '2.x') + ')') +
row('ID', esc(id)) +
row('ProfileID', esc(profile)) +
row('CustomizationID', esc(custom)) +
'</dl>' +
(dec.notes.length ? '<div class="mt-3 flex flex-wrap gap-1.5">' + dec.notes.map(function (n) { return badge(n, ''); }).join('') + '</div>' : '') +
(epHtml ? '<p class="mt-3 text-xs text-muted">Parter (EAS):</p><div class="mt-1 space-y-1">' + epHtml + '</div>' : '') +
(dec.match ? '<p class="mt-3 text-xs"><a class="text-accent hover:underline" href="/profiler?q=' + encodeURIComponent(dec.match.id) + '">Se profilen i /profiler ↗</a></p>'
: '<p class="mt-3 text-xs text-muted">Ingen eksakt match i profil-databasen — afkodet ud fra mønstre.</p>') +
'</div>';
result.hidden = false; err.hidden = true;
}
function renderId(s) {
var dec = decode(s, s);
result.innerHTML = '<div class="rounded-2xl border border-border bg-surface/60 p-5">' +
(dec.match ? '<div class="flex flex-wrap items-center gap-2">' + badge(dec.match.standard, 'ok') +
'</div><h2 class="mt-2 text-xl font-semibold tracking-tight">' + esc(dec.match.navn) + '</h2>'
: '<h2 class="text-xl font-semibold tracking-tight">Identifikator</h2>') +
'<dl class="mt-3 space-y-1 text-xs">' + row('Streng', esc(s)) + '</dl>' +
(dec.notes.length ? '<div class="mt-3 flex flex-wrap gap-1.5">' + dec.notes.map(function (n) { return badge(n, ''); }).join('') + '</div>'
: '<p class="mt-3 text-xs text-muted">Genkendte ingen kendte mønstre.</p>') +
(dec.match ? '<p class="mt-3 text-xs"><a class="text-accent hover:underline" href="/profiler?q=' + encodeURIComponent(dec.match.id) + '">Se profilen i /profiler ↗</a></p>' : '') +
'</div>';
result.hidden = false; err.hidden = true;
}
function analyze(text) {
text = (text || '').trim();
if (!text) return;
if (text.indexOf('<') === 0 || text.indexOf('<?xml') === 0) {
var doc = new DOMParser().parseFromString(text, 'application/xml');
if (doc.getElementsByTagName('parsererror').length) {
err.textContent = 'Kunne ikke parse XML — er filen velformet?'; err.hidden = false; result.hidden = true; return;
}
renderXml(doc);
} else {
renderId(text);
}
}
// fil-input + drag/drop
function readFile(f) { var r = new FileReader(); r.onload = function () { analyze(String(r.result)); }; r.readAsText(f); }
fileInput.addEventListener('change', function () { if (fileInput.files[0]) readFile(fileInput.files[0]); });
['dragenter', 'dragover'].forEach(function (ev) { drop.addEventListener(ev, function (e) {
e.preventDefault(); drop.classList.add('border-accent'); }); });
['dragleave', 'drop'].forEach(function (ev) { drop.addEventListener(ev, function (e) {
e.preventDefault(); drop.classList.remove('border-accent'); }); });
drop.addEventListener('drop', function (e) { var f = e.dataTransfer && e.dataTransfer.files[0]; if (f) readFile(f); });
document.getElementById('analyze').addEventListener('click', function () { analyze(paste.value); });
paste.addEventListener('keydown', function (e) { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') analyze(paste.value); });
})();
</script>
</Base>