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
.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-core.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-relationship-normalizer.js",
"src/background/gvl/gvl-catalog-normalizer.js",
+320
Datei anzeigen
@@ -29,6 +29,22 @@ async function handleVendorGetMessage(message, sender) {
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") {
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() {
const db = await openVendorGetDb();
const latestSnapshot = await getLatestGvlSnapshotByVendorListVersion(db);
@@ -445,11 +514,13 @@ async function handleGetGvlVendorDetailMessage(message) {
const rawEvidence = rawGvlSha256
? await getGvlRawEvidenceBySha256(db, rawGvlSha256)
: null;
const gvlInfo = await getGvlVendorDetailGvlInfo(db, vendorRecord);
return {
success: true,
vendorDetail: {
vendor: vendorRecord,
gvlInfo,
snapshot: buildGvlVendorDetailSnapshotSummary(snapshot, snapshotSha256),
rawEvidence: buildGvlVendorDetailRawEvidenceSummary(
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) {
const vendorId = Number(value);
+153
Datei anzeigen
@@ -90,6 +90,37 @@ p {
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 {
display: grid;
grid-template-columns: auto minmax(120px, 180px) auto;
@@ -137,11 +168,40 @@ button {
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 {
cursor: default;
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 {
width: 100%;
border-collapse: collapse;
@@ -211,11 +271,100 @@ th {
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 {
margin: 0 0 8px;
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 {
padding: 10px 12px;
border: 1px solid #334155;
@@ -258,4 +407,8 @@ th {
.vendor-detail-form {
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">
GVL aus Web laden
</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">
Bereit
</span>
</div>
<div
id="gvl-evidence-transport-status"
class="fetch-status"
aria-live="polite"
></div>
<p id="gvl-snapshot-empty" class="empty-state" hidden>
Keine gespeicherten offiziellen Vendorlisten vorhanden.
</p>
+594 -20
Datei anzeigen
@@ -9,6 +9,15 @@ const gvlDebugData = document.getElementById("gvl-debug-data");
const gvlFetchOfficialButton = document.getElementById(
"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 gvlRebuildNormalizedButton = document.getElementById(
"gvl-rebuild-normalized-button"
@@ -53,6 +62,14 @@ document.addEventListener("DOMContentLoaded", async () => {
await fetchOfficialGvl();
});
gvlRevisionEvidenceExportButton.addEventListener("click", async () => {
await exportSelectedGvlRevisionEvidenceJsonFile();
});
gvlRevisionEvidenceVerifyInput.addEventListener("change", async () => {
await verifyGvlRevisionEvidenceJsonFile();
});
gvlRebuildNormalizedButton.addEventListener("click", async () => {
await rebuildSelectedGvlSnapshotNormalizedData();
});
@@ -112,10 +129,230 @@ function renderFetchStatus(message) {
gvlFetchStatus.textContent = message;
}
function renderGvlEvidenceTransportStatus(message, statusKind = "neutral") {
gvlEvidenceTransportStatus.textContent = message;
gvlEvidenceTransportStatus.className = [
"fetch-status",
"evidence-status",
`is-${statusKind}`
].join(" ");
}
function renderRebuildStatus(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() {
const snapshot = findGvlSnapshot(selectedSnapshotSha256);
@@ -213,43 +450,292 @@ function renderGvlVendorDetailResult(detail) {
const vendor = detail.vendor ?? {};
const snapshot = detail.snapshot ?? {};
const rawEvidence = detail.rawEvidence ?? {};
const gvlInfo = detail.gvlInfo ?? {};
gvlVendorDetailResult.textContent = "";
gvlVendorDetailResult.append(
buildKeyValueSection("Normalisierte Vendor-Felder", [
["Vendor-ID", formatNullable(vendor.vendorId)],
["Name", formatNullable(vendor.name)],
["Policy URL", formatNullable(vendor.policyUrl)],
["Deleted Date", formatNullable(vendor.deletedDate)],
["Uses Cookies", formatNullable(vendor.usesCookies)],
["Cookie Max Age Seconds", formatNullable(vendor.cookieMaxAgeSeconds)],
["Uses Non-Cookie Access", formatNullable(vendor.usesNonCookieAccess)],
buildVendorDetailPanel(vendor, snapshot, rawEvidence, gvlInfo)
);
}
function buildVendorDetailPanel(vendor, snapshot, rawEvidence, gvlInfo) {
const details = document.createElement("details");
const summary = document.createElement("summary");
const closeButton = document.createElement("button");
details.className = "vendor-detail-panel";
details.open = true;
summary.textContent = `Vendor-Details: ${formatNullable(
vendor.name
)} (ID ${formatNullable(vendor.vendorId)})`;
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)],
[
"Device Storage Disclosure URL",
formatNullable(vendor.deviceStorageDisclosureUrl)
"Legitimate-Interest-Claim-URLs",
formatMultilineValue(urlRows.legitimateInterest)
],
["Domains", formatJsonValue(vendor.domains)],
["Snapshot SHA256", formatNullable(vendor.snapshotSha256)]
]),
buildKeyValueSection("Snapshot-Herkunft", [
[
"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)],
["Raw-GVL SHA256", formatNullable(snapshot.rawGvlSha256)],
["Vendorlisten-Version", formatNullable(snapshot.vendorListVersion)],
["TCF Policy Version", formatNullable(snapshot.tcfPolicyVersion)],
["Abrufzeitpunkt", formatNullable(snapshot.fetchedAt)],
["Snapshot erstellt", formatNullable(snapshot.createdAt)]
["Fetched At", formatNullable(snapshot.fetchedAt)],
["Snapshot createdAt", formatNullable(snapshot.createdAt)]
]),
buildKeyValueSection("Raw-GVL-Evidence", [
["Raw-GVL SHA256", formatNullable(rawEvidence.rawGvlSha256)],
["Quelle", formatNullable(rawEvidence.sourceUrl)],
["Abrufzeitpunkt", formatNullable(rawEvidence.fetchedAt)],
["Source URL", formatNullable(rawEvidence.sourceUrl)],
["Fetched At", formatNullable(rawEvidence.fetchedAt)],
["HTTP Status", formatNullable(rawEvidence.httpStatus)],
["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 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) {
@@ -293,6 +779,79 @@ function buildJsonDetails(title, value) {
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() {
try {
const result = await browser.runtime.sendMessage({
@@ -661,3 +1220,18 @@ function shortenSha256(value) {
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");
}