527 lines
20 KiB
JavaScript
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();
|
|
})();
|