Improve GVL explorer usability and evidence workflows

Dieser Commit ist enthalten in:
2026-06-10 18:00:44 +02:00
Ursprung f8a23ba643
Commit eb70f0ce81
6 geänderte Dateien mit 1090 neuen und 22 gelöschten Zeilen
+3
Datei anzeigen
@@ -38,3 +38,6 @@ web-ext-artifacts/
# Environment files # Environment files
.env .env
.env.* .env.*
# Local GVL evidence exports
data/GVL-Dumps/*.json
+1
Datei anzeigen
@@ -23,6 +23,7 @@
"src/background/db/db-constants.js", "src/background/db/db-constants.js",
"src/background/db/db-core.js", "src/background/db/db-core.js",
"src/core/evidence-export-json.js", "src/core/evidence-export-json.js",
"src/core/gvl-evidence-json.js",
"src/background/gvl/gvl-vendor-normalizer.js", "src/background/gvl/gvl-vendor-normalizer.js",
"src/background/gvl/gvl-vendor-relationship-normalizer.js", "src/background/gvl/gvl-vendor-relationship-normalizer.js",
"src/background/gvl/gvl-catalog-normalizer.js", "src/background/gvl/gvl-catalog-normalizer.js",
+320
Datei anzeigen
@@ -29,6 +29,22 @@ async function handleVendorGetMessage(message, sender) {
return null; return null;
} }
if (message.type === "export_gvl_evidence_json") {
return handleExportGvlEvidenceJsonMessage();
}
if (message.type === "export_gvl_revision_evidence_json") {
return handleExportGvlRevisionEvidenceJsonMessage(message);
}
if (message.type === "verify_gvl_revision_evidence_json") {
return handleVerifyGvlRevisionEvidenceJsonMessage(message);
}
if (message.type === "import_gvl_evidence_json") {
return handleImportGvlEvidenceJsonMessage(message);
}
if (message.type === "gvl_import_json") { if (message.type === "gvl_import_json") {
return handleGvlImportJsonMessage(message); return handleGvlImportJsonMessage(message);
} }
@@ -196,6 +212,59 @@ async function handleExportEvidenceJsonMessage() {
}; };
} }
async function handleExportGvlEvidenceJsonMessage() {
return {
success: true,
export: await exportVendorGetGvlEvidenceJson()
};
}
async function handleExportGvlRevisionEvidenceJsonMessage(message) {
try {
return {
success: true,
export: await exportVendorGetGvlRevisionEvidenceJson(
message?.payload?.snapshotSha256 ?? null
)
};
} catch (error) {
return {
success: false,
error: error?.message ?? String(error)
};
}
}
async function handleVerifyGvlRevisionEvidenceJsonMessage(message) {
try {
return {
success: true,
verification: await verifyVendorGetGvlRevisionEvidenceJson(
message?.payload?.export ?? null
)
};
} catch (error) {
return {
success: false,
error: error?.message ?? String(error)
};
}
}
async function handleImportGvlEvidenceJsonMessage(message) {
try {
return {
success: true,
import: await importVendorGetGvlEvidenceJson(message?.payload?.export)
};
} catch (error) {
return {
success: false,
error: error?.message ?? String(error)
};
}
}
async function handleGetLatestGvlUpdateStatusMessage() { async function handleGetLatestGvlUpdateStatusMessage() {
const db = await openVendorGetDb(); const db = await openVendorGetDb();
const latestSnapshot = await getLatestGvlSnapshotByVendorListVersion(db); const latestSnapshot = await getLatestGvlSnapshotByVendorListVersion(db);
@@ -445,11 +514,13 @@ async function handleGetGvlVendorDetailMessage(message) {
const rawEvidence = rawGvlSha256 const rawEvidence = rawGvlSha256
? await getGvlRawEvidenceBySha256(db, rawGvlSha256) ? await getGvlRawEvidenceBySha256(db, rawGvlSha256)
: null; : null;
const gvlInfo = await getGvlVendorDetailGvlInfo(db, vendorRecord);
return { return {
success: true, success: true,
vendorDetail: { vendorDetail: {
vendor: vendorRecord, vendor: vendorRecord,
gvlInfo,
snapshot: buildGvlVendorDetailSnapshotSummary(snapshot, snapshotSha256), snapshot: buildGvlVendorDetailSnapshotSummary(snapshot, snapshotSha256),
rawEvidence: buildGvlVendorDetailRawEvidenceSummary( rawEvidence: buildGvlVendorDetailRawEvidenceSummary(
rawEvidence, rawEvidence,
@@ -459,6 +530,255 @@ async function handleGetGvlVendorDetailMessage(message) {
}; };
} }
async function getGvlVendorDetailGvlInfo(db, vendorRecord) {
const vendorListVersion = vendorRecord?.vendorListVersion ?? null;
const vendorId = vendorRecord?.vendorId ?? null;
if (vendorListVersion === null || vendorId === null) {
return buildEmptyGvlVendorDetailGvlInfo();
}
const relationships = await getGvlVendorRelationshipsForVendor(
db,
vendorListVersion,
vendorId
);
const catalogs = await getGvlVendorDetailCatalogs(db, vendorListVersion);
const rawVendor = vendorRecord?.rawVendor ?? {};
return {
purposes: buildGvlVendorDetailCatalogList(
rawVendor.purposes,
relationships.purpose,
catalogs.purposes
),
legIntPurposes: buildGvlVendorDetailCatalogList(
rawVendor.legIntPurposes,
relationships.legIntPurpose,
catalogs.purposes
),
flexiblePurposes: buildGvlVendorDetailCatalogList(
rawVendor.flexiblePurposes,
relationships.flexiblePurpose,
catalogs.purposes
),
specialPurposes: buildGvlVendorDetailCatalogList(
rawVendor.specialPurposes,
relationships.specialPurpose,
catalogs.specialPurposes
),
features: buildGvlVendorDetailCatalogList(
rawVendor.features,
relationships.feature,
catalogs.features
),
specialFeatures: buildGvlVendorDetailCatalogList(
rawVendor.specialFeatures,
relationships.specialFeature,
catalogs.specialFeatures
),
dataDeclaration: buildGvlVendorDetailCatalogList(
rawVendor.dataDeclaration,
[],
catalogs.dataCategories
),
dataCategoriesTextAvailable: catalogs.dataCategories.size > 0,
dataRetention: rawVendor.dataRetention ?? null,
overflow: rawVendor.overflow ?? null
};
}
function buildEmptyGvlVendorDetailGvlInfo() {
return {
purposes: [],
legIntPurposes: [],
flexiblePurposes: [],
specialPurposes: [],
features: [],
specialFeatures: [],
dataDeclaration: [],
dataCategoriesTextAvailable: false,
dataRetention: null,
overflow: null
};
}
function getGvlVendorRelationshipsForVendor(db, vendorListVersion, vendorId) {
return new Promise((resolve, reject) => {
const storeName = VENDORGET_STORE_NAMES.gvlVendorRelationships;
const tx = db.transaction([storeName], "readonly");
const relationshipsStore = tx.objectStore(storeName);
const vendorIdIndex = relationshipsStore.index("vendorId");
const cursorRequest = vendorIdIndex.openCursor(IDBKeyRange.only(vendorId));
const relationships = {};
cursorRequest.onerror = () => reject(cursorRequest.error);
cursorRequest.onsuccess = () => {
const cursor = cursorRequest.result;
if (!cursor) {
return;
}
const record = cursor.value;
if (record?.vendorListVersion === vendorListVersion) {
const relationshipType = record.relationshipType ?? "unknown";
if (!relationships[relationshipType]) {
relationships[relationshipType] = [];
}
relationships[relationshipType].push(record.relatedId);
}
cursor.continue();
};
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
tx.oncomplete = () => resolve(relationships);
});
}
async function getGvlVendorDetailCatalogs(db, vendorListVersion) {
const [
purposes,
specialPurposes,
features,
specialFeatures,
dataCategories
] = await Promise.all([
getGvlCatalogMapForVersion(
db,
VENDORGET_STORE_NAMES.gvlPurposes,
vendorListVersion
),
getGvlCatalogMapForVersion(
db,
VENDORGET_STORE_NAMES.gvlSpecialPurposes,
vendorListVersion
),
getGvlCatalogMapForVersion(
db,
VENDORGET_STORE_NAMES.gvlFeatures,
vendorListVersion
),
getGvlCatalogMapForVersion(
db,
VENDORGET_STORE_NAMES.gvlSpecialFeatures,
vendorListVersion
),
getGvlCatalogMapForVersion(
db,
VENDORGET_STORE_NAMES.gvlDataCategories,
vendorListVersion
)
]);
return {
purposes,
specialPurposes,
features,
specialFeatures,
dataCategories
};
}
function getGvlCatalogMapForVersion(db, storeName, vendorListVersion) {
return new Promise((resolve, reject) => {
const tx = db.transaction([storeName], "readonly");
const catalogStore = tx.objectStore(storeName);
const vendorListVersionIndex = catalogStore.index("vendorListVersion");
const cursorRequest = vendorListVersionIndex.openCursor(
IDBKeyRange.only(vendorListVersion)
);
const catalogMap = new Map();
cursorRequest.onerror = () => reject(cursorRequest.error);
cursorRequest.onsuccess = () => {
const cursor = cursorRequest.result;
if (!cursor) {
return;
}
const record = cursor.value;
const catalogId = getGvlCatalogRecordId(record);
if (catalogId !== null) {
catalogMap.set(catalogId, record);
}
cursor.continue();
};
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
tx.oncomplete = () => resolve(catalogMap);
});
}
function getGvlCatalogRecordId(record) {
const catalogId =
record?.catalogId ??
record?.purposeId ??
record?.specialPurposeId ??
record?.featureId ??
record?.specialFeatureId ??
record?.dataCategoryId ??
null;
if (catalogId === null || catalogId === undefined) {
return null;
}
const numericId = Number(catalogId);
return Number.isFinite(numericId) ? numericId : null;
}
function buildGvlVendorDetailCatalogList(rawIds, relationshipIds, catalogMap) {
const ids = mergeGvlVendorDetailIds(rawIds, relationshipIds);
return ids.map((id) => {
const catalogRecord = catalogMap.get(id) ?? null;
return {
id,
name: catalogRecord?.name ?? null,
description: catalogRecord?.description ?? null,
descriptionLegal: catalogRecord?.descriptionLegal ?? null,
illustrations: catalogRecord?.illustrations ?? null,
rawCatalog: catalogRecord?.rawCatalog ?? null
};
});
}
function mergeGvlVendorDetailIds(...sources) {
const ids = [];
const seen = new Set();
sources.forEach((source) => {
if (!Array.isArray(source)) {
return;
}
source.forEach((value) => {
const id = Number(value);
if (!Number.isFinite(id) || seen.has(id)) {
return;
}
seen.add(id);
ids.push(id);
});
});
return ids;
}
function parseGvlVendorDetailId(value) { function parseGvlVendorDetailId(value) {
const vendorId = Number(value); const vendorId = Number(value);
+153
Datei anzeigen
@@ -90,6 +90,37 @@ p {
color: #cbd5e1; color: #cbd5e1;
} }
.evidence-status {
padding: 8px 10px;
border: 1px solid transparent;
border-radius: 4px;
overflow-wrap: anywhere;
}
.evidence-status.is-neutral {
color: #cbd5e1;
border-color: #334155;
background: #1f2937;
}
.evidence-status.is-success {
color: #bbf7d0;
border-color: #166534;
background: #052e16;
}
.evidence-status.is-warning {
color: #fde68a;
border-color: #92400e;
background: #422006;
}
.evidence-status.is-error {
color: #fecaca;
border-color: #991b1b;
background: #450a0a;
}
.vendor-detail-form { .vendor-detail-form {
display: grid; display: grid;
grid-template-columns: auto minmax(120px, 180px) auto; grid-template-columns: auto minmax(120px, 180px) auto;
@@ -137,11 +168,40 @@ button {
background: #1f2937; background: #1f2937;
} }
.file-action {
display: inline-block;
padding: 8px 10px;
border: 1px solid #475569;
border-radius: 4px;
font: inherit;
font-size: 13px;
color: #e5edf5;
background: #1f2937;
cursor: pointer;
}
.file-action.is-disabled {
cursor: default;
opacity: 0.65;
pointer-events: none;
}
button:disabled { button:disabled {
cursor: default; cursor: default;
opacity: 0.65; opacity: 0.65;
} }
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
table { table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
@@ -211,11 +271,100 @@ th {
margin-top: 14px; margin-top: 14px;
} }
.vendor-detail-panel {
font-size: 13px;
color: #cbd5e1;
}
.vendor-detail-panel > summary,
.subject-details > summary {
cursor: pointer;
margin-bottom: 12px;
font-weight: 700;
}
.vendor-detail-section h3 { .vendor-detail-section h3 {
margin: 0 0 8px; margin: 0 0 8px;
font-size: 13px; font-size: 13px;
} }
.summary-table td {
white-space: pre-wrap;
}
.subject-details {
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid #334155;
}
.gvl-catalog-section {
display: grid;
gap: 8px;
margin-top: 14px;
}
.gvl-catalog-section h4 {
margin: 0;
font-size: 13px;
color: #e5edf5;
}
.catalog-list {
display: grid;
gap: 6px;
}
.catalog-item {
border: 1px solid #334155;
border-radius: 4px;
background: #182231;
}
.catalog-item summary {
cursor: pointer;
padding: 8px 10px;
color: #e5edf5;
font-weight: 700;
}
.definition-list {
display: grid;
grid-template-columns: minmax(120px, 180px) 1fr;
gap: 0;
margin: 0;
border-top: 1px solid #334155;
}
.definition-list dt,
.definition-list dd {
margin: 0;
padding: 8px 10px;
border-bottom: 1px solid #334155;
overflow-wrap: anywhere;
}
.definition-list dt {
color: #cbd5e1;
font-weight: 700;
background: #0f172a;
}
.definition-list dd {
color: #e5edf5;
white-space: pre-wrap;
}
.muted-text {
max-width: none;
color: #94a3b8;
}
.secondary-action {
width: fit-content;
margin-top: 12px;
}
.empty-state { .empty-state {
padding: 10px 12px; padding: 10px 12px;
border: 1px solid #334155; border: 1px solid #334155;
@@ -258,4 +407,8 @@ th {
.vendor-detail-form { .vendor-detail-form {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.definition-list {
grid-template-columns: 1fr;
}
} }
+17
Datei anzeigen
@@ -24,10 +24,27 @@
<button id="gvl-fetch-official-button" type="button"> <button id="gvl-fetch-official-button" type="button">
GVL aus Web laden GVL aus Web laden
</button> </button>
<button id="gvl-revision-evidence-export-button" type="button">
Ausgewählte GVL-Revision exportieren
</button>
<label class="file-action" for="gvl-revision-evidence-verify-input">
GVL-Revision-Export verifizieren
</label>
<input
id="gvl-revision-evidence-verify-input"
class="visually-hidden"
type="file"
accept="application/json,.json"
>
<span id="gvl-fetch-status" class="fetch-status" aria-live="polite"> <span id="gvl-fetch-status" class="fetch-status" aria-live="polite">
Bereit Bereit
</span> </span>
</div> </div>
<div
id="gvl-evidence-transport-status"
class="fetch-status"
aria-live="polite"
></div>
<p id="gvl-snapshot-empty" class="empty-state" hidden> <p id="gvl-snapshot-empty" class="empty-state" hidden>
Keine gespeicherten offiziellen Vendorlisten vorhanden. Keine gespeicherten offiziellen Vendorlisten vorhanden.
</p> </p>
+596 -22
Datei anzeigen
@@ -9,6 +9,15 @@ const gvlDebugData = document.getElementById("gvl-debug-data");
const gvlFetchOfficialButton = document.getElementById( const gvlFetchOfficialButton = document.getElementById(
"gvl-fetch-official-button" "gvl-fetch-official-button"
); );
const gvlRevisionEvidenceExportButton = document.getElementById(
"gvl-revision-evidence-export-button"
);
const gvlRevisionEvidenceVerifyInput = document.getElementById(
"gvl-revision-evidence-verify-input"
);
const gvlEvidenceTransportStatus = document.getElementById(
"gvl-evidence-transport-status"
);
const gvlFetchStatus = document.getElementById("gvl-fetch-status"); const gvlFetchStatus = document.getElementById("gvl-fetch-status");
const gvlRebuildNormalizedButton = document.getElementById( const gvlRebuildNormalizedButton = document.getElementById(
"gvl-rebuild-normalized-button" "gvl-rebuild-normalized-button"
@@ -53,6 +62,14 @@ document.addEventListener("DOMContentLoaded", async () => {
await fetchOfficialGvl(); await fetchOfficialGvl();
}); });
gvlRevisionEvidenceExportButton.addEventListener("click", async () => {
await exportSelectedGvlRevisionEvidenceJsonFile();
});
gvlRevisionEvidenceVerifyInput.addEventListener("change", async () => {
await verifyGvlRevisionEvidenceJsonFile();
});
gvlRebuildNormalizedButton.addEventListener("click", async () => { gvlRebuildNormalizedButton.addEventListener("click", async () => {
await rebuildSelectedGvlSnapshotNormalizedData(); await rebuildSelectedGvlSnapshotNormalizedData();
}); });
@@ -112,10 +129,230 @@ function renderFetchStatus(message) {
gvlFetchStatus.textContent = message; gvlFetchStatus.textContent = message;
} }
function renderGvlEvidenceTransportStatus(message, statusKind = "neutral") {
gvlEvidenceTransportStatus.textContent = message;
gvlEvidenceTransportStatus.className = [
"fetch-status",
"evidence-status",
`is-${statusKind}`
].join(" ");
}
function renderRebuildStatus(message) { function renderRebuildStatus(message) {
gvlRebuildNormalizedStatus.textContent = message; gvlRebuildNormalizedStatus.textContent = message;
} }
async function exportSelectedGvlRevisionEvidenceJsonFile() {
const snapshot = findGvlSnapshot(selectedSnapshotSha256);
if (!snapshot) {
renderGvlEvidenceTransportStatus(
"Keine GVL-Revision ausgewählt.",
"warning"
);
return;
}
gvlRevisionEvidenceExportButton.disabled = true;
renderGvlEvidenceTransportStatus("GVL-Revision-Export läuft...");
try {
const result = await browser.runtime.sendMessage({
type: "export_gvl_revision_evidence_json",
payload: {
snapshotSha256: snapshot.sha256
}
});
if (!result?.success || !result.export) {
throw new Error(
result?.error ?? "export_gvl_revision_evidence_json_failed"
);
}
renderGvlEvidenceTransportStatus(
"GVL-Revision-Export wird intern verifiziert..."
);
const verification = await verifyGeneratedGvlRevisionEvidenceExport(
result.export
);
if (!verification.valid) {
renderGvlEvidenceTransportStatus(
[
"GVL-Revision-Export nicht erzeugt: interne Verifikation fehlgeschlagen.",
buildGvlRevisionEvidenceVerificationMessage(verification)
].join(" "),
"error"
);
return;
}
downloadGvlRevisionEvidenceJsonExport(result.export);
renderGvlEvidenceTransportStatus(
[
"GVL-Revision exportiert und intern verifiziert.",
buildGvlRevisionEvidenceExportSuccessMessage(result.export)
].join(" "),
"success"
);
} catch (error) {
renderGvlEvidenceTransportStatus(
"GVL-Revision-Export fehlgeschlagen.",
"error"
);
console.warn("VG-Observe GVL revision evidence export failed", error);
} finally {
gvlRevisionEvidenceExportButton.disabled = false;
}
}
async function verifyGeneratedGvlRevisionEvidenceExport(exportContainer) {
const result = await browser.runtime.sendMessage({
type: "verify_gvl_revision_evidence_json",
payload: {
export: exportContainer
}
});
if (!result?.success || !result.verification) {
throw new Error(
result?.error ?? "verify_gvl_revision_evidence_json_failed"
);
}
return result.verification;
}
function downloadGvlRevisionEvidenceJsonExport(exportContainer) {
const json = JSON.stringify(exportContainer, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const downloadLink = document.createElement("a");
const metadata = exportContainer?.metadata ?? {};
const vendorListVersion = metadata.vendorListVersion ?? "unknown";
const exportedAtUtcCompact =
metadata.exportedAtUtcCompact ?? formatExportTimestampUtcCompact(new Date());
downloadLink.href = url;
downloadLink.download = `GVL-REV-${vendorListVersion}-${exportedAtUtcCompact}.json`;
document.body.append(downloadLink);
downloadLink.click();
downloadLink.remove();
setTimeout(() => URL.revokeObjectURL(url), 0);
}
function buildGvlRevisionEvidenceExportSuccessMessage(exportContainer) {
const metadata = exportContainer?.metadata ?? {};
const recordCount = getGvlRevisionEvidenceNormalizedRecordCount(
exportContainer?.normalized
);
return [
"GVL-Revision exportiert:",
`Vendorlisten-Version ${formatNullable(metadata.vendorListVersion)}`,
`Snapshot ${shortenSha256(metadata.snapshotSha256)}`,
`Raw-GVL ${shortenSha256(metadata.rawGvlSha256)}`,
`Payload ${shortenSha256(metadata.exportPayloadSha256)}`,
`${recordCount} normalisierte Records.`
].join(" ");
}
function getGvlRevisionEvidenceNormalizedRecordCount(normalized) {
return [
"gvl_vendors",
"gvl_purposes",
"gvl_special_purposes",
"gvl_features",
"gvl_special_features",
"gvl_data_categories",
"gvl_vendor_relationships"
].reduce((total, storeName) => {
return total + (normalized?.[storeName]?.length ?? 0);
}, 0);
}
async function verifyGvlRevisionEvidenceJsonFile() {
const file = gvlRevisionEvidenceVerifyInput.files?.[0] ?? null;
if (!file) {
return;
}
setGvlRevisionEvidenceVerifyDisabled(true);
renderGvlEvidenceTransportStatus("GVL-Revision-Export wird verifiziert...");
try {
const exportContainer = JSON.parse(await file.text());
const verification = await verifyGeneratedGvlRevisionEvidenceExport(
exportContainer
);
renderGvlEvidenceTransportStatus(
buildGvlRevisionEvidenceVerificationMessage(verification),
verification.valid ? "success" : "error"
);
} catch (error) {
renderGvlEvidenceTransportStatus(
"GVL-Revision-Export ist nicht valide.",
"error"
);
console.warn("VG-Observe GVL revision evidence verification failed", error);
} finally {
gvlRevisionEvidenceVerifyInput.value = "";
setGvlRevisionEvidenceVerifyDisabled(false);
}
}
function setGvlRevisionEvidenceVerifyDisabled(disabled) {
const importLabel = document.querySelector(
"label[for='gvl-revision-evidence-verify-input']"
);
gvlRevisionEvidenceVerifyInput.disabled = disabled;
importLabel?.classList.toggle("is-disabled", disabled);
}
function buildGvlRevisionEvidenceVerificationMessage(verification) {
const validityLabel = verification.valid ? "valide" : "nicht valide";
const counts = verification.normalizedCounts ?? {};
const normalizedRecordCount = Object.values(counts).reduce((total, count) => {
return total + Number(count ?? 0);
}, 0);
return [
`Verifikation: ${validityLabel}.`,
`Vendorlisten-Version ${formatNullable(verification.vendorListVersion)}.`,
`Snapshot ${shortenSha256(verification.snapshotSha256)}.`,
`Raw-GVL ${shortenSha256(verification.rawGvlSha256)}.`,
`Payload ${shortenSha256(verification.exportPayloadSha256)}.`,
`Normalisierte Records: ${normalizedRecordCount} (${formatNormalizedCounts(
counts
)}).`,
verification.valid
? ""
: `Fehler: ${(verification.errors ?? []).join(", ")}.`
]
.filter(Boolean)
.join(" ");
}
function formatNormalizedCounts(counts) {
return [
["Vendoren", counts.gvl_vendors],
["Purposes", counts.gvl_purposes],
["Special Purposes", counts.gvl_special_purposes],
["Features", counts.gvl_features],
["Special Features", counts.gvl_special_features],
["Data Categories", counts.gvl_data_categories],
["Vendor-Beziehungen", counts.gvl_vendor_relationships]
]
.map(([label, count]) => `${label}: ${Number(count ?? 0)}`)
.join(", ");
}
async function rebuildSelectedGvlSnapshotNormalizedData() { async function rebuildSelectedGvlSnapshotNormalizedData() {
const snapshot = findGvlSnapshot(selectedSnapshotSha256); const snapshot = findGvlSnapshot(selectedSnapshotSha256);
@@ -213,43 +450,292 @@ function renderGvlVendorDetailResult(detail) {
const vendor = detail.vendor ?? {}; const vendor = detail.vendor ?? {};
const snapshot = detail.snapshot ?? {}; const snapshot = detail.snapshot ?? {};
const rawEvidence = detail.rawEvidence ?? {}; const rawEvidence = detail.rawEvidence ?? {};
const gvlInfo = detail.gvlInfo ?? {};
gvlVendorDetailResult.textContent = ""; gvlVendorDetailResult.textContent = "";
gvlVendorDetailResult.append( gvlVendorDetailResult.append(
buildKeyValueSection("Normalisierte Vendor-Felder", [ buildVendorDetailPanel(vendor, snapshot, rawEvidence, gvlInfo)
["Vendor-ID", formatNullable(vendor.vendorId)], );
["Name", formatNullable(vendor.name)], }
["Policy URL", formatNullable(vendor.policyUrl)],
["Deleted Date", formatNullable(vendor.deletedDate)], function buildVendorDetailPanel(vendor, snapshot, rawEvidence, gvlInfo) {
["Uses Cookies", formatNullable(vendor.usesCookies)], const details = document.createElement("details");
["Cookie Max Age Seconds", formatNullable(vendor.cookieMaxAgeSeconds)], const summary = document.createElement("summary");
["Uses Non-Cookie Access", formatNullable(vendor.usesNonCookieAccess)], const closeButton = document.createElement("button");
[
"Device Storage Disclosure URL", details.className = "vendor-detail-panel";
formatNullable(vendor.deviceStorageDisclosureUrl) details.open = true;
], summary.textContent = `Vendor-Details: ${formatNullable(
["Domains", formatJsonValue(vendor.domains)], vendor.name
["Snapshot SHA256", formatNullable(vendor.snapshotSha256)] )} (ID ${formatNullable(vendor.vendorId)})`;
]),
buildKeyValueSection("Snapshot-Herkunft", [ closeButton.type = "button";
closeButton.className = "secondary-action";
closeButton.textContent = "Vendor-Details schließen";
closeButton.addEventListener("click", () => {
details.open = false;
});
details.append(
summary,
buildHumanReadableVendorCard(vendor),
buildGvlSubjectMatterDetails(gvlInfo),
buildTechnicalEvidenceDetails(vendor, snapshot, rawEvidence),
closeButton
);
return details;
}
function buildHumanReadableVendorCard(vendor) {
const rawVendor = vendor.rawVendor ?? {};
const urlRows = getVendorUrlRows(rawVendor, vendor);
return buildKeyValueSection("Vendor-Karte", [
["Vendor-Name", formatNullable(vendor.name ?? rawVendor.name)],
["Vendor-ID", formatNullable(vendor.vendorId ?? rawVendor.id)],
["Status", formatVendorStatus(vendor.deletedDate ?? rawVendor.deletedDate)],
["Privacy-/Datenschutz-URLs", formatMultilineValue(urlRows.privacy)],
[
"Legitimate-Interest-Claim-URLs",
formatMultilineValue(urlRows.legitimateInterest)
],
[
"Device-Storage-Disclosure-URL",
formatNullable(
vendor.deviceStorageDisclosureUrl ??
rawVendor.deviceStorageDisclosureUrl
)
],
["Nutzt Cookies", formatBooleanGerman(vendor.usesCookies)],
[
"Cookie Refresh",
formatBooleanGerman(rawVendor.cookieRefresh ?? vendor.cookieRefresh)
],
[
"Cookie Max Age",
formatCookieMaxAge(vendor.cookieMaxAgeSeconds)
],
[
"Non-Cookie Access",
formatBooleanGerman(vendor.usesNonCookieAccess)
],
["Domains", formatArrayValue(vendor.domains ?? rawVendor.domains)]
]);
}
function getVendorUrlRows(rawVendor, vendor) {
const urls = Array.isArray(rawVendor.urls) ? rawVendor.urls : [];
const privacy = [];
const legitimateInterest = [];
urls.forEach((urlEntry) => {
if (!urlEntry || typeof urlEntry !== "object") {
return;
}
const label = urlEntry.langId ? `${urlEntry.langId}: ` : "";
appendUrlIfPresent(privacy, `${label}${urlEntry.privacy}`);
appendUrlIfPresent(
legitimateInterest,
`${label}${urlEntry.legIntClaim}`
);
appendUrlIfPresent(
legitimateInterest,
`${label}${urlEntry.legitimateInterestClaim}`
);
});
appendUrlIfPresent(privacy, vendor.policyUrl ?? rawVendor.policyUrl);
appendUrlIfPresent(
legitimateInterest,
vendor.legitimateInterestDisclosureUrl ??
rawVendor.legitimateInterestDisclosureUrl
);
return {
privacy: uniqueValues(privacy),
legitimateInterest: uniqueValues(legitimateInterest)
};
}
function appendUrlIfPresent(values, value) {
if (!value) {
return;
}
const urlValue = String(value);
if (urlValue.endsWith("undefined") || urlValue.endsWith("null")) {
return;
}
values.push(urlValue);
}
function uniqueValues(values) {
return Array.from(new Set(values));
}
function buildGvlSubjectMatterDetails(gvlInfo) {
const details = document.createElement("details");
const summary = document.createElement("summary");
details.className = "subject-details";
details.open = true;
summary.textContent = "GVL-Fachinformationen";
details.append(
summary,
buildCatalogListSection("Purposes", "Purpose", gvlInfo.purposes),
buildCatalogListSection(
"Legitimate-Interest-Purposes",
"Purpose",
gvlInfo.legIntPurposes
),
buildCatalogListSection(
"Flexible Purposes",
"Purpose",
gvlInfo.flexiblePurposes
),
buildCatalogListSection(
"Special Purposes",
"Special Purpose",
gvlInfo.specialPurposes
),
buildCatalogListSection("Features", "Feature", gvlInfo.features),
buildCatalogListSection(
"Special Features",
"Special Feature",
gvlInfo.specialFeatures
),
buildDataDeclarationSection(gvlInfo),
buildJsonDetails("Data Retention", gvlInfo.dataRetention),
buildJsonDetails("Overflow", gvlInfo.overflow)
);
return details;
}
function buildCatalogListSection(title, entityLabel, items) {
const section = document.createElement("section");
const heading = document.createElement("h4");
const list = document.createElement("div");
const catalogItems = Array.isArray(items) ? items : [];
section.className = "gvl-catalog-section";
heading.textContent = title;
list.className = "catalog-list";
section.append(heading);
if (!catalogItems.length) {
const empty = document.createElement("p");
empty.className = "muted-text";
empty.textContent = "Keine Angaben vorhanden.";
section.append(empty);
return section;
}
catalogItems.forEach((item) => {
list.append(buildCatalogItemDetails(entityLabel, item));
});
section.append(list);
return section;
}
function buildCatalogItemDetails(entityLabel, item) {
const details = document.createElement("details");
const summary = document.createElement("summary");
const rows = [
["ID", formatNullable(item?.id)],
["Name", formatNullable(item?.name)],
["Description", formatNullable(item?.description)],
["Description Legal", formatNullable(item?.descriptionLegal)],
["Illustrations", formatJsonValue(item?.illustrations)]
];
details.className = "catalog-item";
summary.textContent = formatCatalogItemSummary(entityLabel, item);
details.append(summary, buildDefinitionList(rows));
return details;
}
function formatCatalogItemSummary(entityLabel, item) {
const prefix = `${entityLabel} ${formatNullable(item?.id)}`;
if (!item?.name) {
return `${prefix} - Klartext lokal nicht verfügbar`;
}
return `${prefix} - ${item.name}`;
}
function buildDataDeclarationSection(gvlInfo) {
const section = buildCatalogListSection(
"Data Declaration",
"Data Category",
gvlInfo.dataDeclaration
);
if (!gvlInfo.dataCategoriesTextAvailable) {
const note = document.createElement("p");
note.className = "muted-text";
note.textContent = "Klartext derzeit nicht normalisiert verfügbar.";
section.prepend(note);
}
return section;
}
function buildTechnicalEvidenceDetails(vendor, snapshot, rawEvidence) {
const details = document.createElement("details");
const summary = document.createElement("summary");
details.className = "technical-details";
summary.textContent = "Technischer Evidence-Nachweis";
details.append(
summary,
buildKeyValueSection("Snapshot und Raw-GVL", [
["Snapshot SHA256", formatNullable(snapshot.snapshotSha256)], ["Snapshot SHA256", formatNullable(snapshot.snapshotSha256)],
["Raw-GVL SHA256", formatNullable(snapshot.rawGvlSha256)], ["Raw-GVL SHA256", formatNullable(snapshot.rawGvlSha256)],
["Vendorlisten-Version", formatNullable(snapshot.vendorListVersion)], ["Vendorlisten-Version", formatNullable(snapshot.vendorListVersion)],
["TCF Policy Version", formatNullable(snapshot.tcfPolicyVersion)], ["TCF Policy Version", formatNullable(snapshot.tcfPolicyVersion)],
["Abrufzeitpunkt", formatNullable(snapshot.fetchedAt)], ["Fetched At", formatNullable(snapshot.fetchedAt)],
["Snapshot erstellt", formatNullable(snapshot.createdAt)] ["Snapshot createdAt", formatNullable(snapshot.createdAt)]
]), ]),
buildKeyValueSection("Raw-GVL-Evidence", [ buildKeyValueSection("Raw-GVL-Evidence", [
["Raw-GVL SHA256", formatNullable(rawEvidence.rawGvlSha256)], ["Source URL", formatNullable(rawEvidence.sourceUrl)],
["Quelle", formatNullable(rawEvidence.sourceUrl)], ["Fetched At", formatNullable(rawEvidence.fetchedAt)],
["Abrufzeitpunkt", formatNullable(rawEvidence.fetchedAt)],
["HTTP Status", formatNullable(rawEvidence.httpStatus)], ["HTTP Status", formatNullable(rawEvidence.httpStatus)],
["Content-Type", formatNullable(rawEvidence.contentType)], ["Content-Type", formatNullable(rawEvidence.contentType)],
["Raw Body vorhanden", formatNullable(rawEvidence.hasRawBody)] ["hasRawBody", formatNullable(rawEvidence.hasRawBody)]
]), ]),
buildJsonDetails("Vollständiger rawVendor-Datensatz", vendor.rawVendor), buildJsonDetails("Vollständiger rawVendor-Datensatz", vendor.rawVendor),
buildJsonDetails("Vollständiger gvl_vendors-Datensatz", vendor) buildJsonDetails("Vollständiger gvl_vendors-Datensatz", vendor)
); );
return details;
}
function buildDefinitionList(rows) {
const list = document.createElement("dl");
list.className = "definition-list";
rows.forEach(([label, value]) => {
const term = document.createElement("dt");
const description = document.createElement("dd");
term.textContent = label;
description.textContent = value;
list.append(term, description);
});
return list;
} }
function buildKeyValueSection(title, rows) { function buildKeyValueSection(title, rows) {
@@ -293,6 +779,79 @@ function buildJsonDetails(title, value) {
return details; return details;
} }
function formatVendorStatus(deletedDate) {
if (deletedDate) {
return `Gelöscht seit ${deletedDate}`;
}
return "Aktiv";
}
function formatBooleanGerman(value) {
if (value === true) {
return "ja";
}
if (value === false) {
return "nein";
}
return "-";
}
function formatCookieMaxAge(value) {
if (value === null || value === undefined || value === "") {
return "-";
}
const seconds = Number(value);
if (!Number.isFinite(seconds)) {
return formatNullable(value);
}
const humanReadable = formatDurationFromSeconds(seconds);
return humanReadable
? `${seconds} Sekunden (${humanReadable})`
: `${seconds} Sekunden`;
}
function formatDurationFromSeconds(seconds) {
const units = [
["Jahr", "Jahre", 365 * 24 * 60 * 60],
["Tag", "Tage", 24 * 60 * 60],
["Stunde", "Stunden", 60 * 60],
["Minute", "Minuten", 60]
];
for (const [singular, plural, unitSeconds] of units) {
if (seconds >= unitSeconds && seconds % unitSeconds === 0) {
const amount = seconds / unitSeconds;
return `${amount} ${amount === 1 ? singular : plural}`;
}
}
return null;
}
function formatArrayValue(value) {
if (!Array.isArray(value) || !value.length) {
return "-";
}
return value.join("\n");
}
function formatMultilineValue(values) {
if (!Array.isArray(values) || !values.length) {
return "-";
}
return values.join("\n");
}
async function renderGvlSnapshots() { async function renderGvlSnapshots() {
try { try {
const result = await browser.runtime.sendMessage({ const result = await browser.runtime.sendMessage({
@@ -661,3 +1220,18 @@ function shortenSha256(value) {
return `${String(value).slice(0, 12)}...`; return `${String(value).slice(0, 12)}...`;
} }
function formatExportTimestampUtcCompact(date) {
const year = date.getUTCFullYear();
const month = padDatePart(date.getUTCMonth() + 1);
const day = padDatePart(date.getUTCDate());
const hours = padDatePart(date.getUTCHours());
const minutes = padDatePart(date.getUTCMinutes());
const seconds = padDatePart(date.getUTCSeconds());
return `${year}${month}${day}T${hours}${minutes}${seconds}Z`;
}
function padDatePart(value) {
return String(value).padStart(2, "0");
}