Files
ai-disclosure-standard/bulkweb/static/bulk.js
T
jbergner 13efcfb986
release-tag / release-image (push) Successful in 1m50s
Update auf 1.8.0 mit Bulk-Option
2026-07-22 22:27:42 +02:00

527 lines
20 KiB
JavaScript

(() => {
'use strict';
const PROFILE_KEY = 'ai-disclosure.bulk.profiles.v1';
const TEMPLATE_KEY = 'ai-disclosure.bulk.templates.v1';
const EXPORT_FORMAT = 'ai-disclosure-bulk-browser-data';
const $ = id => document.getElementById(id);
const profileFields = [
['profile-lang', 'lang'],
['profile-role', 'legalRole'],
['profile-use-context', 'useContext'],
['profile-assurance', 'assurance'],
['profile-author', 'author'],
['profile-author-url', 'authorUrl'],
['profile-responsible-role', 'responsibleRole'],
['profile-responsible', 'responsible'],
['profile-responsible-url', 'responsibleUrl'],
['profile-complaint-name', 'complaintName'],
['profile-complaint-email', 'complaintEmail'],
['profile-complaint-url', 'complaintUrl']
];
let cfg = null;
let activeTemplate = null;
let currentResults = [];
let exportsCache = {};
function readStore(key) {
try {
const parsed = JSON.parse(localStorage.getItem(key) || '{}');
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
} catch (_) {
return {};
}
}
function writeStore(key, value) {
localStorage.setItem(key, JSON.stringify(value));
}
function setStatus(text, level = '') {
const target = $('template-status');
target.textContent = text;
target.className = `status ${level}`.trim();
}
function addMessage(text, level = '') {
const p = document.createElement('p');
p.className = `message ${level}`.trim();
p.textContent = text;
$('url-messages').appendChild(p);
}
function safeHTTPURL(raw) {
try {
const url = new URL(raw);
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return null;
return url;
} catch (_) {
return null;
}
}
function parseTemplate(raw) {
const value = raw.trim();
if (!value) throw new Error('Bitte zuerst eine Declaration-URL oder einen Query-String einfügen.');
let params;
const absolute = safeHTTPURL(value);
if (absolute) {
params = new URLSearchParams(absolute.search);
} else {
const query = value.startsWith('?') ? value.slice(1) : value;
params = new URLSearchParams(query);
}
params.delete('subject');
params.delete('link');
if ([...params.keys()].length === 0) throw new Error('Die Vorlage enthält keine Parameter.');
return params;
}
function activateTemplate(params, label = 'Vorlage geladen') {
activeTemplate = new URLSearchParams(params);
activeTemplate.delete('subject');
activeTemplate.delete('link');
$('template-input').value = activeTemplate.toString();
renderTemplateParams();
setStatus(`${label}: ${[...activeTemplate.keys()].length} Parameter aktiv.`, 'good');
}
function renderTemplateParams() {
const list = $('template-params');
list.replaceChildren();
if (!activeTemplate) return;
const entries = [...activeTemplate.entries()].sort(([a], [b]) => a.localeCompare(b));
for (const [key, value] of entries) {
const row = document.createElement('div');
const dt = document.createElement('dt');
const dd = document.createElement('dd');
dt.textContent = key;
dd.textContent = value;
row.append(dt, dd);
list.appendChild(row);
}
}
function refreshTemplateSelect(selected = '') {
const templates = readStore(TEMPLATE_KEY);
const select = $('saved-template-select');
select.replaceChildren(new Option('Gespeicherte Vorlage wählen …', ''));
Object.keys(templates).sort((a, b) => a.localeCompare(b)).forEach(name => select.add(new Option(name, name)));
if (selected && templates[selected]) select.value = selected;
}
function saveTemplate() {
if (!activeTemplate) {
setStatus('Bitte zuerst eine gültige Vorlage laden.', 'error');
return;
}
const name = $('template-name').value.trim();
if (!name) {
setStatus('Bitte einen Namen für die Vorlage angeben.', 'error');
$('template-name').focus();
return;
}
const templates = readStore(TEMPLATE_KEY);
templates[name] = {query: activeTemplate.toString(), updatedAt: new Date().toISOString()};
writeStore(TEMPLATE_KEY, templates);
refreshTemplateSelect(name);
setStatus(`Vorlage „${name}“ wurde nur in diesem Browser gespeichert.`, 'good');
}
function loadSavedTemplate(name) {
if (!name) return;
const item = readStore(TEMPLATE_KEY)[name];
if (!item || typeof item.query !== 'string') return;
$('template-name').value = name;
activateTemplate(new URLSearchParams(item.query), `Vorlage „${name}“ geladen`);
}
function deleteTemplate() {
const name = $('saved-template-select').value;
if (!name) return;
const templates = readStore(TEMPLATE_KEY);
delete templates[name];
writeStore(TEMPLATE_KEY, templates);
refreshTemplateSelect();
if ($('template-name').value === name) $('template-name').value = '';
setStatus(`Vorlage „${name}“ wurde aus diesem Browser gelöscht.`);
}
function emptyProfileForm() {
$('profile-name').value = '';
for (const [id] of profileFields) $(id).value = '';
$('profile-select').value = '';
}
function profileFromForm() {
const profile = {};
for (const [id, key] of profileFields) {
const value = $(id).value.trim();
if (value) profile[key] = value;
}
return profile;
}
function validateProfile(profile) {
const urlKeys = ['authorUrl', 'responsibleUrl', 'complaintUrl'];
for (const key of urlKeys) {
if (profile[key] && !safeHTTPURL(profile[key])) return `${key} muss eine absolute http(s)-URL ohne Zugangsdaten sein.`;
}
if (profile.complaintEmail && !$('profile-complaint-email').checkValidity()) return 'Die E-Mail-Adresse der Rückmeldestelle ist nicht gültig.';
return '';
}
function validateMergedParams(params) {
if (params.get('authorUrl') && !params.get('author')) return 'Zu einer Autoren-URL muss auch ein Autor bzw. eine Autorin angegeben werden.';
const responsibilityStarted = params.get('responsibleRole') || params.get('responsible') || params.get('responsibleUrl');
if (responsibilityStarted && (!params.get('responsibleRole') || !params.get('responsible'))) return 'Bei redaktioneller Verantwortung sind Rolle und Name/Organisation gemeinsam erforderlich.';
const complaintStarted = params.get('complaintName') || params.get('complaintEmail') || params.get('complaintUrl');
if (complaintStarted && (!params.get('complaintName') || (!params.get('complaintEmail') && !params.get('complaintUrl')))) return 'Die Rückmeldestelle benötigt einen Namen und mindestens E-Mail oder Kontakt-URL.';
return '';
}
function refreshProfileSelect(selected = '') {
const profiles = readStore(PROFILE_KEY);
const select = $('profile-select');
select.replaceChildren(new Option('Website-Profil wählen …', ''));
Object.keys(profiles).sort((a, b) => a.localeCompare(b)).forEach(name => select.add(new Option(name, name)));
if (selected && profiles[selected]) select.value = selected;
}
function saveProfile() {
const name = $('profile-name').value.trim();
if (!name) {
alert('Bitte einen Profilnamen angeben.');
$('profile-name').focus();
return;
}
const profile = profileFromForm();
const error = validateProfile(profile);
if (error) {
alert(error);
return;
}
const profiles = readStore(PROFILE_KEY);
profiles[name] = {values: profile, updatedAt: new Date().toISOString()};
writeStore(PROFILE_KEY, profiles);
refreshProfileSelect(name);
}
function loadProfile(name) {
if (!name) {
emptyProfileForm();
return;
}
const item = readStore(PROFILE_KEY)[name];
if (!item || !item.values) return;
$('profile-name').value = name;
for (const [id, key] of profileFields) $(id).value = item.values[key] || '';
}
function deleteProfile() {
const name = $('profile-select').value;
if (!name) return;
const profiles = readStore(PROFILE_KEY);
delete profiles[name];
writeStore(PROFILE_KEY, profiles);
refreshProfileSelect();
emptyProfileForm();
}
function activeQuery() {
if (!activeTemplate) throw new Error('Bitte zuerst eine Kennzeichnungsvorlage laden.');
const params = new URLSearchParams(activeTemplate);
params.delete('subject');
params.delete('link');
const profile = profileFromForm();
const error = validateProfile(profile);
if (error) throw new Error(error);
for (const [key, value] of Object.entries(profile)) params.set(key, value);
const mergedError = validateMergedParams(params);
if (mergedError) throw new Error(mergedError);
return params;
}
function csvCellLine(line) {
let value = line.trim();
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
value = value.slice(1, -1).replaceAll('""', '"');
}
return value;
}
function parseSubjects() {
$('url-messages').replaceChildren();
let lines = $('url-input').value.split(/\r?\n/).map(csvCellLine).filter(Boolean);
if (lines[0]?.toLowerCase() === 'url') lines = lines.slice(1);
const baseRaw = $('base-url').value.trim();
let base = null;
if (baseRaw) {
base = safeHTTPURL(baseRaw);
if (!base) addMessage('Die Basis-URL ist ungültig und wird nicht verwendet.', 'error');
}
const unique = new Map();
const invalid = [];
let duplicates = 0;
for (const raw of lines) {
let parsed = safeHTTPURL(raw);
if (!parsed && base) {
try {
const candidate = new URL(raw, base);
if (['http:', 'https:'].includes(candidate.protocol) && !candidate.username && !candidate.password) parsed = candidate;
} catch (_) {}
}
if (!parsed) {
invalid.push(raw);
continue;
}
const normalized = parsed.href;
if (unique.has(normalized)) {
duplicates++;
continue;
}
unique.set(normalized, normalized);
}
const subjects = [...unique.values()];
$('url-counter').querySelector('strong').textContent = String(subjects.length);
if (duplicates) addMessage(`${duplicates} Duplikat${duplicates === 1 ? '' : 'e'} entfernt.`, 'info');
if (invalid.length) addMessage(`${invalid.length} ungültige Zeile${invalid.length === 1 ? '' : 'n'} ignoriert: ${invalid.slice(0, 3).join(', ')}${invalid.length > 3 ? ' …' : ''}`, 'error');
if (cfg && subjects.length > cfg.maxURLs) addMessage(`Maximal ${cfg.maxURLs} URLs pro Lauf. Bitte die Liste aufteilen.`, 'error');
return subjects;
}
function csvEscape(value) {
const text = String(value ?? '');
return /[",\n\r]/.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
}
function buildExports(results) {
const successful = results.filter(item => item.ok && item.data);
const html = successful.map(item => `<!-- ${item.subject} -->\n${item.data.html}`).join('\n\n');
const markdown = successful.map(item => `### ${item.subject}\n\n${item.data.markdown}`).join('\n\n');
const jsonArray = successful.map(item => item.data.jsonLd);
const json = JSON.stringify(jsonArray, null, 2);
const jsonl = successful.map(item => JSON.stringify(item.data.jsonLd)).join('\n');
const csvRows = [['subject', 'declaration', 'manifest', 'badge']];
successful.forEach(item => csvRows.push([item.subject, item.data.declarationUrl, item.data.manifestUrl, item.data.badgeUrl]));
const csv = csvRows.map(row => row.map(csvEscape).join(',')).join('\n');
return {html, markdown, json, jsonl, csv};
}
function renderResults(results) {
currentResults = results;
exportsCache = buildExports(results);
const successful = results.filter(item => item.ok).length;
const failed = results.length - successful;
$('results-section').hidden = false;
$('result-summary').textContent = `${successful} Kennzeichnung${successful === 1 ? '' : 'en'} erzeugt${failed ? `, ${failed} Fehler` : ''}.`;
const tbody = $('result-rows');
tbody.replaceChildren();
results.forEach(item => {
const tr = document.createElement('tr');
const subjectCell = document.createElement('td');
const code = document.createElement('code');
code.textContent = item.subject;
subjectCell.appendChild(code);
const statusCell = document.createElement('td');
statusCell.className = item.ok ? 'ok' : 'failed';
statusCell.textContent = item.ok ? '✓ erzeugt' : `Fehler: ${item.error || 'unbekannt'}`;
const linksCell = document.createElement('td');
if (item.ok && item.data) {
const links = document.createElement('div');
links.className = 'result-links';
[['Declaration', item.data.declarationUrl], ['JSON-LD', item.data.manifestUrl], ['Badge', item.data.badgeUrl]].forEach(([label, href]) => {
const a = document.createElement('a');
a.textContent = label;
a.href = href;
a.target = '_blank';
a.rel = 'noopener';
links.appendChild(a);
});
linksCell.appendChild(links);
}
tr.append(subjectCell, statusCell, linksCell);
tbody.appendChild(tr);
});
$('output-html').value = exportsCache.html;
$('output-markdown').value = exportsCache.markdown;
$('output-json').value = exportsCache.json;
$('output-csv').value = exportsCache.csv;
$('results-section').scrollIntoView({behavior: 'smooth', block: 'start'});
}
async function generate() {
let query;
try {
query = activeQuery();
} catch (error) {
setStatus(error.message, 'error');
return;
}
const subjects = parseSubjects();
if (!subjects.length) {
addMessage('Bitte mindestens eine gültige URL einfügen.', 'error');
return;
}
if (subjects.length > cfg.maxURLs) return;
const button = $('generate');
const original = button.textContent;
button.disabled = true;
button.textContent = `${subjects.length} Kennzeichnungen werden erzeugt …`;
try {
const response = await fetch('/api/render-batch', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'Accept': 'application/json'},
body: JSON.stringify({template: query.toString(), subjects})
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(payload.detail || `HTTP ${response.status}`);
renderResults(payload.results || []);
} catch (error) {
addMessage(`Bulk-Lauf fehlgeschlagen: ${error.message}`, 'error');
} finally {
button.disabled = false;
button.textContent = original;
}
}
async function copyExport(kind, button) {
const value = exportsCache[kind] || '';
if (!value) return;
await navigator.clipboard.writeText(value);
const old = button.textContent;
button.textContent = 'Kopiert';
setTimeout(() => { button.textContent = old; }, 1300);
}
function downloadExport(kind) {
const spec = {
html: ['ai-disclosure-bulk.html', 'text/html;charset=utf-8'],
markdown: ['ai-disclosure-bulk.md', 'text/markdown;charset=utf-8'],
json: ['ai-disclosure-bulk.json', 'application/ld+json;charset=utf-8'],
jsonl: ['ai-disclosure-bulk.jsonl', 'application/x-ndjson;charset=utf-8'],
csv: ['ai-disclosure-bulk.csv', 'text/csv;charset=utf-8']
}[kind];
const value = exportsCache[kind] || '';
if (!spec || !value) return;
downloadBlob(spec[0], value, spec[1]);
}
function downloadBlob(filename, content, type) {
const blob = new Blob([content], {type});
const href = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = href;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(href), 500);
}
function exportBrowserData() {
const payload = {
format: EXPORT_FORMAT,
version: 1,
exportedAt: new Date().toISOString(),
profiles: readStore(PROFILE_KEY),
templates: readStore(TEMPLATE_KEY)
};
downloadBlob('ai-disclosure-bulk-browser-data.json', JSON.stringify(payload, null, 2), 'application/json;charset=utf-8');
}
async function importBrowserData(file) {
if (!file) return;
try {
const payload = JSON.parse(await file.text());
if (payload.format !== EXPORT_FORMAT || payload.version !== 1) throw new Error('Unbekanntes Exportformat.');
if (!payload.profiles || typeof payload.profiles !== 'object' || !payload.templates || typeof payload.templates !== 'object') throw new Error('Export ist unvollständig.');
writeStore(PROFILE_KEY, payload.profiles);
writeStore(TEMPLATE_KEY, payload.templates);
refreshProfileSelect();
refreshTemplateSelect();
alert('Profile und Vorlagen wurden importiert.');
} catch (error) {
alert(`Import fehlgeschlagen: ${error.message}`);
} finally {
$('import-browser-data').value = '';
}
}
function importFragmentTemplate() {
if (!location.hash.startsWith('#')) return;
const hash = new URLSearchParams(location.hash.slice(1));
const raw = hash.get('template');
if (!raw) return;
try {
const payload = JSON.parse(raw);
if (payload.version !== 1 || typeof payload.query !== 'string') throw new Error('Unbekanntes Vorlagenformat.');
const params = new URLSearchParams(payload.query);
if (payload.theme) params.set('theme', payload.theme);
activateTemplate(params, 'Aus dem Generator übernommen');
$('template-name').value = payload.name || '';
} catch (error) {
setStatus(`Vorlage aus dem Generator konnte nicht übernommen werden: ${error.message}`, 'error');
} finally {
history.replaceState(null, '', location.pathname + location.search);
}
}
function clearBrowserData() {
if (!confirm('Alle lokal gespeicherten Website-Profile und Kennzeichnungsvorlagen in diesem Browser löschen?')) return;
localStorage.removeItem(PROFILE_KEY);
localStorage.removeItem(TEMPLATE_KEY);
refreshProfileSelect();
refreshTemplateSelect();
emptyProfileForm();
setStatus('Lokale Profile und Vorlagen wurden gelöscht.');
}
async function init() {
try {
const response = await fetch('/api/config', {headers: {'Accept': 'application/json'}});
cfg = await response.json();
} catch (_) {
cfg = {maxURLs: 500};
}
refreshTemplateSelect();
refreshProfileSelect();
importFragmentTemplate();
parseSubjects();
$('load-template').addEventListener('click', () => {
try {
activateTemplate(parseTemplate($('template-input').value));
} catch (error) {
setStatus(error.message, 'error');
}
});
$('save-template').addEventListener('click', saveTemplate);
$('saved-template-select').addEventListener('change', event => loadSavedTemplate(event.target.value));
$('delete-template').addEventListener('click', deleteTemplate);
$('profile-select').addEventListener('change', event => loadProfile(event.target.value));
$('new-profile').addEventListener('click', emptyProfileForm);
$('save-profile').addEventListener('click', saveProfile);
$('delete-profile').addEventListener('click', deleteProfile);
$('export-browser-data').addEventListener('click', exportBrowserData);
$('import-browser-data').addEventListener('change', event => importBrowserData(event.target.files?.[0]));
$('clear-browser-data').addEventListener('click', clearBrowserData);
$('url-input').addEventListener('input', parseSubjects);
$('base-url').addEventListener('input', parseSubjects);
$('clear-urls').addEventListener('click', () => {
$('url-input').value = '';
$('url-messages').replaceChildren();
parseSubjects();
});
$('generate').addEventListener('click', generate);
document.querySelectorAll('[data-copy]').forEach(button => button.addEventListener('click', () => copyExport(button.dataset.copy, button)));
document.querySelectorAll('[data-download]').forEach(button => button.addEventListener('click', () => downloadExport(button.dataset.download)));
}
init();
})();