feat(ubl): UBL-profil-detektor + identifikator-afkoder (/ubl, client-side) + /profiler ?q=-prefill + ⌘K
Build & Deploy / deploy (push) Successful in 2m9s
Build & Deploy / deploy (push) Successful in 2m9s
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 { '&': '&', '<': '<', '>': '>', '"': '"' }[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>
|
||||
Reference in New Issue
Block a user