diff --git a/src/background.js b/src/background.js index 6ab280f..3010638 100644 --- a/src/background.js +++ b/src/background.js @@ -73,6 +73,18 @@ async function handleVendorGetMessage(message, sender) { return handleGetGvlSnapshotSummaryMessage(message); } + if (message.type === "rebuild_gvl_snapshot_normalized_data") { + return handleRebuildGvlSnapshotNormalizedDataMessage(message); + } + + if (message.type === "list_gvl_vendors_for_snapshot") { + return handleListGvlVendorsForSnapshotMessage(message); + } + + if (message.type === "get_gvl_vendor_detail") { + return handleGetGvlVendorDetailMessage(message); + } + if (message.type === "get_latest_consent_state") { return handleGetLatestConsentStateMessage(); } @@ -280,12 +292,15 @@ async function handleGetGvlSnapshotSummaryMessage(message) { eventType: event?.eventType ?? null, eventCapturedAt: event?.capturedAt ?? null, vendorCount: snapshot.vendorCount ?? counts.vendorCount, + snapshotVendorCount: snapshot.vendorCount ?? null, + normalizedVendorCount: counts.vendorCount, purposeCount: snapshot.purposeCount ?? counts.purposeCount, specialPurposeCount: counts.specialPurposeCount, featureCount: counts.featureCount, specialFeatureCount: counts.specialFeatureCount, dataCategoryCount: counts.dataCategoryCount, vendorRelationshipCount: counts.vendorRelationshipCount, + normalizedVendorRelationshipCount: counts.vendorRelationshipCount, technicalFields: { snapshotStore: "gvl_snapshots", vendorListVersion: "vendorListVersion", @@ -300,6 +315,267 @@ async function handleGetGvlSnapshotSummaryMessage(message) { }; } +async function handleRebuildGvlSnapshotNormalizedDataMessage(message) { + const db = await openVendorGetDb(); + const payload = message?.payload ?? {}; + const snapshot = await getGvlSnapshotByIdentifier(db, { + sha256: payload.sha256 ?? null, + vendorListVersion: payload.vendorListVersion ?? null + }); + + if (!snapshot) { + return { + success: false, + error: "gvl_snapshot_not_found" + }; + } + + const normalizationSummary = await normalizeGvlSnapshotWithExistingPipeline( + snapshot + ); + const counts = await countGvlNormalizedRecordsForVersion( + db, + snapshot.vendorListVersion ?? null + ); + + return { + success: true, + snapshotSha256: snapshot.sha256 ?? null, + vendorListVersion: snapshot.vendorListVersion ?? null, + normalizationSummary, + counts + }; +} + +async function handleListGvlVendorsForSnapshotMessage(message) { + const db = await openVendorGetDb(); + const payload = message?.payload ?? {}; + const snapshot = await getGvlSnapshotByIdentifier(db, { + sha256: payload.sha256 ?? null, + vendorListVersion: payload.vendorListVersion ?? null + }); + + if (!snapshot) { + return { + success: false, + error: "gvl_snapshot_not_found" + }; + } + + const vendors = await listGvlVendorsForSnapshot(db, snapshot); + + return { + success: true, + snapshotSha256: snapshot.sha256 ?? null, + vendorListVersion: snapshot.vendorListVersion ?? null, + vendors + }; +} + +function listGvlVendorsForSnapshot(db, snapshot) { + return new Promise((resolve, reject) => { + const tx = db.transaction([VENDORGET_STORE_NAMES.gvlVendors], "readonly"); + const vendorsStore = tx.objectStore(VENDORGET_STORE_NAMES.gvlVendors); + const vendorListVersionIndex = vendorsStore.index("vendorListVersion"); + const cursorRequest = vendorListVersionIndex.openCursor( + IDBKeyRange.only(snapshot.vendorListVersion) + ); + const vendors = []; + + cursorRequest.onerror = () => reject(cursorRequest.error); + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result; + + if (!cursor) { + return; + } + + if (cursor.value?.snapshotSha256 === snapshot.sha256) { + vendors.push({ + vendorId: cursor.value.vendorId ?? null, + name: cursor.value.name ?? null, + deletedDate: cursor.value.deletedDate ?? null, + snapshotSha256: cursor.value.snapshotSha256 ?? null + }); + } + + cursor.continue(); + }; + + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + tx.oncomplete = () => { + resolve( + vendors.sort((left, right) => { + return ( + toComparableNumber(left.vendorId) - + toComparableNumber(right.vendorId) + ); + }) + ); + }; + }); +} + +async function handleGetGvlVendorDetailMessage(message) { + const db = await openVendorGetDb(); + const vendorId = parseGvlVendorDetailId(message?.payload?.vendorId); + + if (vendorId === null) { + return { + success: false, + error: "invalid_vendor_id" + }; + } + + const vendorRecord = await getLatestGvlVendorByVendorId(db, vendorId); + + if (!vendorRecord) { + return { + success: false, + error: "gvl_vendor_not_found" + }; + } + + const snapshotSha256 = vendorRecord.snapshotSha256 ?? null; + const snapshot = snapshotSha256 + ? await getGvlSnapshotBySha256(db, snapshotSha256) + : null; + const rawGvlSha256 = snapshot?.rawGvlSha256 ?? null; + const rawEvidence = rawGvlSha256 + ? await getGvlRawEvidenceBySha256(db, rawGvlSha256) + : null; + + return { + success: true, + vendorDetail: { + vendor: vendorRecord, + snapshot: buildGvlVendorDetailSnapshotSummary(snapshot, snapshotSha256), + rawEvidence: buildGvlVendorDetailRawEvidenceSummary( + rawEvidence, + rawGvlSha256 + ) + } + }; +} + +function parseGvlVendorDetailId(value) { + const vendorId = Number(value); + + if (!Number.isInteger(vendorId) || vendorId <= 0) { + return null; + } + + return vendorId; +} + +function getLatestGvlVendorByVendorId(db, vendorId) { + return new Promise((resolve, reject) => { + const tx = db.transaction([VENDORGET_STORE_NAMES.gvlVendors], "readonly"); + const vendorsStore = tx.objectStore(VENDORGET_STORE_NAMES.gvlVendors); + const vendorIdIndex = vendorsStore.index("vendorId"); + const cursorRequest = vendorIdIndex.openCursor(IDBKeyRange.only(vendorId)); + const vendors = []; + + cursorRequest.onerror = () => reject(cursorRequest.error); + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result; + + if (!cursor) { + return; + } + + vendors.push(cursor.value); + cursor.continue(); + }; + + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + tx.oncomplete = () => { + resolve(sortGvlVendorRecordsNewestFirst(vendors)[0] ?? null); + }; + }); +} + +function sortGvlVendorRecordsNewestFirst(vendorRecords) { + return vendorRecords.slice().sort((left, right) => { + const fetchedAtComparison = + toComparableTime(right.snapshotFetchedAt) - + toComparableTime(left.snapshotFetchedAt); + + if (fetchedAtComparison !== 0) { + return fetchedAtComparison; + } + + return ( + toComparableNumber(right.vendorListVersion) - + toComparableNumber(left.vendorListVersion) + ); + }); +} + +function getGvlRawEvidenceBySha256(db, rawGvlSha256) { + return new Promise((resolve, reject) => { + const tx = db.transaction([VENDORGET_STORE_NAMES.gvlRawEvidence], "readonly"); + const rawEvidenceStore = tx.objectStore(VENDORGET_STORE_NAMES.gvlRawEvidence); + const getRequest = rawEvidenceStore.get(rawGvlSha256); + let rawEvidenceOrNull = null; + + getRequest.onerror = () => reject(getRequest.error); + getRequest.onsuccess = () => { + rawEvidenceOrNull = getRequest.result ?? null; + }; + + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + tx.oncomplete = () => resolve(rawEvidenceOrNull); + }); +} + +function buildGvlVendorDetailSnapshotSummary(snapshot, snapshotSha256) { + if (!snapshot) { + return { + snapshotSha256: snapshotSha256 ?? null, + rawGvlSha256: null, + vendorListVersion: null, + tcfPolicyVersion: null, + fetchedAt: null, + createdAt: null + }; + } + + return { + snapshotSha256: snapshot.sha256 ?? snapshotSha256 ?? null, + rawGvlSha256: snapshot.rawGvlSha256 ?? null, + vendorListVersion: snapshot.vendorListVersion ?? null, + tcfPolicyVersion: snapshot.tcfPolicyVersion ?? null, + fetchedAt: snapshot.fetchedAt ?? null, + createdAt: snapshot.createdAt ?? null + }; +} + +function buildGvlVendorDetailRawEvidenceSummary(rawEvidence, rawGvlSha256) { + if (!rawEvidence) { + return { + rawGvlSha256: rawGvlSha256 ?? null, + sourceUrl: null, + fetchedAt: null, + httpStatus: null, + contentType: null, + hasRawBody: false + }; + } + + return { + rawGvlSha256: rawEvidence.rawGvlSha256 ?? rawGvlSha256 ?? null, + sourceUrl: rawEvidence.sourceUrl ?? null, + fetchedAt: rawEvidence.fetchedAt ?? null, + httpStatus: rawEvidence.httpStatus ?? null, + contentType: rawEvidence.contentType ?? null, + hasRawBody: typeof rawEvidence.rawBody === "string" + }; +} + async function handleGetLatestConsentStateMessage() { const db = await openVendorGetDb(); const latestStateOrNull = await getLatestConsentState(db); @@ -631,21 +907,55 @@ async function handleFetchOfficialGvlMessage() { } const db = await openVendorGetDb(); - const result = await VendorGetGvlService.ingestGvlSnapshot(db, rawJson, { - sourceUrl: OFFICIAL_IAB_GVL_URL, - fetchedAt: new Date().toISOString(), - rawGvlSha256: rawGvlSha256, - diagnostics: { - ingestionSource: "official_iab_fetch", - responseStatus: responseStatus - } - }); + const currentVendorListVersion = rawJson.vendorListVersion ?? null; + const existingSnapshot = await getGvlSnapshotByVendorListVersion( + db, + currentVendorListVersion + ); + const ingestResult = existingSnapshot + ? null + : await VendorGetGvlService.ingestGvlSnapshot(db, rawJson, { + sourceUrl: OFFICIAL_IAB_GVL_URL, + fetchedAt: new Date().toISOString(), + rawGvlSha256: rawGvlSha256, + diagnostics: { + ingestionSource: "official_iab_fetch", + responseStatus: responseStatus + } + }); + const snapshot = existingSnapshot ?? ingestResult.snapshot; + const completeness = await getGvlSnapshotNormalizedCompleteness( + db, + snapshot + ); + let normalizationSummary = null; + let syncStatus = "current_and_locally_available"; + + if (!existingSnapshot) { + normalizationSummary = await normalizeGvlSnapshotWithExistingPipeline( + snapshot + ); + syncStatus = "new_gvl_revision_stored_and_normalized"; + } else if (!completeness.complete) { + normalizationSummary = await normalizeGvlSnapshotWithExistingPipeline( + existingSnapshot + ); + syncStatus = "known_gvl_rebuilt_from_local_evidence"; + } + + const counts = await countGvlNormalizedRecordsForVersion( + db, + snapshot.vendorListVersion ?? null + ); return { success: true, - alreadyKnown: result.alreadyKnown, - vendorListVersion: result.snapshot.vendorListVersion, - sha256: result.snapshot.sha256 + alreadyKnown: Boolean(existingSnapshot), + syncStatus, + vendorListVersion: snapshot.vendorListVersion, + sha256: snapshot.sha256, + normalizationSummary, + counts }; } catch (error) { console.warn("VG-Observe official GVL fetch failed", error); @@ -657,6 +967,24 @@ async function handleFetchOfficialGvlMessage() { } } +async function getGvlSnapshotNormalizedCompleteness(db, snapshot) { + const counts = await countGvlNormalizedRecordsForVersion( + db, + snapshot?.vendorListVersion ?? null + ); + const expectedVendorCount = Number(snapshot?.vendorCount ?? 0); + const hasVendors = + expectedVendorCount > 0 + ? counts.vendorCount >= expectedVendorCount + : counts.vendorCount > 0; + const hasVendorRelationships = counts.vendorRelationshipCount > 0; + + return { + complete: hasVendors && hasVendorRelationships, + counts + }; +} + async function fetchOfficialGvlJson() { const response = await fetch(OFFICIAL_IAB_GVL_URL, { method: "GET", diff --git a/src/gvl-explorer/gvl-explorer.css b/src/gvl-explorer/gvl-explorer.css index bb0650b..a485568 100644 --- a/src/gvl-explorer/gvl-explorer.css +++ b/src/gvl-explorer/gvl-explorer.css @@ -76,12 +76,57 @@ p { margin-bottom: 12px; } +.rebuild-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} + .fetch-status { min-height: 18px; font-size: 13px; color: #cbd5e1; } +.vendor-detail-form { + display: grid; + grid-template-columns: auto minmax(120px, 180px) auto; + align-items: center; + gap: 10px; + margin-bottom: 12px; +} + +.vendor-overview { + margin-top: 18px; + margin-bottom: 12px; + font-size: 13px; + color: #cbd5e1; +} + +.vendor-overview summary { + cursor: pointer; + font-weight: 700; +} + +.vendor-detail-form label { + font-size: 13px; + font-weight: 700; + color: #cbd5e1; +} + +.vendor-detail-form input { + min-width: 0; + padding: 8px 10px; + border: 1px solid #475569; + border-radius: 4px; + font: inherit; + font-size: 13px; + color: #e5edf5; + background: #0f172a; +} + button { padding: 8px 10px; border: 1px solid #475569; @@ -160,6 +205,17 @@ th { overflow-wrap: anywhere; } +.vendor-detail-result { + display: grid; + gap: 14px; + margin-top: 14px; +} + +.vendor-detail-section h3 { + margin: 0 0 8px; + font-size: 13px; +} + .empty-state { padding: 10px 12px; border: 1px solid #334155; @@ -198,4 +254,8 @@ th { .summary-table th { width: 160px; } + + .vendor-detail-form { + grid-template-columns: 1fr; + } } diff --git a/src/gvl-explorer/gvl-explorer.html b/src/gvl-explorer/gvl-explorer.html index b275d2b..47f93f1 100644 --- a/src/gvl-explorer/gvl-explorer.html +++ b/src/gvl-explorer/gvl-explorer.html @@ -22,7 +22,7 @@

Gespeicherte Vendorlisten

Bereit @@ -48,9 +48,40 @@

Ausgewählte Vendorliste

+
+ + +
+
+ + Vendoren-Übersicht anzeigen + + + +
+
Technische Feldnamen

@@ -61,6 +92,30 @@
           
+ +
+

Lokaler Vendor-Nachweis

+
+ + + +
+
+
+
diff --git a/src/gvl-explorer/gvl-explorer.js b/src/gvl-explorer/gvl-explorer.js index e930e81..8978f68 100644 --- a/src/gvl-explorer/gvl-explorer.js +++ b/src/gvl-explorer/gvl-explorer.js @@ -10,21 +10,64 @@ const gvlFetchOfficialButton = document.getElementById( "gvl-fetch-official-button" ); const gvlFetchStatus = document.getElementById("gvl-fetch-status"); +const gvlRebuildNormalizedButton = document.getElementById( + "gvl-rebuild-normalized-button" +); +const gvlRebuildNormalizedStatus = document.getElementById( + "gvl-rebuild-normalized-status" +); +const gvlVendorOverviewEmpty = document.getElementById( + "gvl-vendor-overview-empty" +); +const gvlVendorOverviewDetails = document.getElementById( + "gvl-vendor-overview-details" +); +const gvlVendorOverviewSummary = document.getElementById( + "gvl-vendor-overview-summary" +); +const gvlVendorOverviewContent = document.getElementById( + "gvl-vendor-overview-content" +); +const gvlVendorOverviewList = document.getElementById( + "gvl-vendor-overview-list" +); +const gvlVendorDetailForm = document.getElementById("gvl-vendor-detail-form"); +const gvlVendorIdInput = document.getElementById("gvl-vendor-id-input"); +const gvlVendorDetailButton = document.getElementById( + "gvl-vendor-detail-button" +); +const gvlVendorDetailStatus = document.getElementById( + "gvl-vendor-detail-status" +); +const gvlVendorDetailResult = document.getElementById( + "gvl-vendor-detail-result" +); let gvlSnapshots = []; let selectedSnapshotSha256 = null; +let selectedSnapshotSummary = null; +let selectedSnapshotVendors = []; document.addEventListener("DOMContentLoaded", async () => { gvlFetchOfficialButton.addEventListener("click", async () => { await fetchOfficialGvl(); }); + gvlRebuildNormalizedButton.addEventListener("click", async () => { + await rebuildSelectedGvlSnapshotNormalizedData(); + }); + + gvlVendorDetailForm.addEventListener("submit", async (event) => { + event.preventDefault(); + await renderGvlVendorDetail(); + }); + await renderGvlSnapshots(); }); async function fetchOfficialGvl() { gvlFetchOfficialButton.disabled = true; - renderFetchStatus("Vendorliste wird abgerufen..."); + renderFetchStatus("GVL-Referenzbasis wird synchronisiert..."); try { const result = await browser.runtime.sendMessage({ @@ -35,26 +78,221 @@ async function fetchOfficialGvl() { throw new Error(result?.error ?? "official_gvl_fetch_failed"); } - renderFetchStatus( - result.alreadyKnown - ? "Vendorliste bereits bekannt." - : "Vendorliste abgerufen." - ); + renderFetchStatus(buildGvlSyncStatusMessage(result)); await renderGvlSnapshots(); await renderSelectedGvlSnapshotSummary(); } catch (error) { - renderFetchStatus("Vendorliste konnte nicht abgerufen werden."); + renderFetchStatus("GVL-Referenzbasis konnte nicht synchronisiert werden."); console.warn("VG-Observe manual official GVL fetch failed", error); } finally { gvlFetchOfficialButton.disabled = false; } } +function buildGvlSyncStatusMessage(result) { + if (result?.syncStatus === "new_gvl_revision_stored_and_normalized") { + return "Neue GVL-Revision gespeichert und normalisiert."; + } + + if (result?.syncStatus === "known_gvl_rebuilt_from_local_evidence") { + return "Bekannte GVL aus lokaler Evidence neu aufgebaut."; + } + + if (result?.syncStatus === "current_and_locally_available") { + return "Offizielle GVL ist aktuell und lokal vollständig verfügbar."; + } + + return result?.alreadyKnown + ? "Offizielle GVL ist lokal verfügbar." + : "GVL-Referenzbasis synchronisiert."; +} + function renderFetchStatus(message) { gvlFetchStatus.textContent = message; } +function renderRebuildStatus(message) { + gvlRebuildNormalizedStatus.textContent = message; +} + +async function rebuildSelectedGvlSnapshotNormalizedData() { + const snapshot = findGvlSnapshot(selectedSnapshotSha256); + + if (!snapshot) { + renderRebuildStatus("Keine Vendorliste ausgewählt."); + return; + } + + gvlRebuildNormalizedButton.disabled = true; + renderRebuildStatus("Lokale Evidence wird neu normalisiert..."); + + try { + const result = await browser.runtime.sendMessage({ + type: "rebuild_gvl_snapshot_normalized_data", + payload: { + sha256: snapshot.sha256 + } + }); + + if (!result?.success) { + throw new Error( + result?.error ?? "rebuild_gvl_snapshot_normalized_data_failed" + ); + } + + await renderSelectedGvlSnapshotSummary(); + renderRebuildStatus(buildRebuildSuccessMessage(result)); + + if (gvlVendorIdInput.value) { + await renderGvlVendorDetail(); + } + } catch (error) { + renderRebuildStatus("Lokaler Neuaufbau fehlgeschlagen."); + console.warn("VG-Observe GVL normalized rebuild failed", error); + } finally { + gvlRebuildNormalizedButton.disabled = + !doesSnapshotNeedNormalizedRebuild(selectedSnapshotSummary); + } +} + +function buildRebuildSuccessMessage(result) { + const counts = result.counts ?? {}; + + return [ + "Lokale GVL-Daten neu aufgebaut.", + `Vendoren: ${formatCount(counts.vendorCount)}`, + `Beziehungen: ${formatCount(counts.vendorRelationshipCount)}` + ].join(" "); +} + +async function renderGvlVendorDetail() { + const vendorId = Number(gvlVendorIdInput.value); + + clearGvlVendorDetail(); + + if (!Number.isInteger(vendorId) || vendorId <= 0) { + renderGvlVendorDetailStatus("Bitte eine gültige Vendor-ID eingeben."); + return; + } + + gvlVendorDetailButton.disabled = true; + renderGvlVendorDetailStatus("Vendor wird geladen..."); + + try { + const result = await browser.runtime.sendMessage({ + type: "get_gvl_vendor_detail", + payload: { + vendorId + } + }); + + if (!result?.success) { + throw new Error(result?.error ?? "get_gvl_vendor_detail_failed"); + } + + renderGvlVendorDetailStatus("Vendor geladen."); + renderGvlVendorDetailResult(result.vendorDetail ?? {}); + } catch (error) { + renderGvlVendorDetailStatus("Vendor konnte nicht geladen werden."); + console.warn("VG-Observe GVL vendor detail failed", error); + } finally { + gvlVendorDetailButton.disabled = false; + } +} + +function clearGvlVendorDetail() { + gvlVendorDetailResult.textContent = ""; +} + +function renderGvlVendorDetailStatus(message) { + gvlVendorDetailStatus.textContent = message; +} + +function renderGvlVendorDetailResult(detail) { + const vendor = detail.vendor ?? {}; + const snapshot = detail.snapshot ?? {}; + const rawEvidence = detail.rawEvidence ?? {}; + + 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)], + [ + "Device Storage Disclosure URL", + formatNullable(vendor.deviceStorageDisclosureUrl) + ], + ["Domains", formatJsonValue(vendor.domains)], + ["Snapshot SHA256", formatNullable(vendor.snapshotSha256)] + ]), + buildKeyValueSection("Snapshot-Herkunft", [ + ["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)] + ]), + buildKeyValueSection("Raw-GVL-Evidence", [ + ["Raw-GVL SHA256", formatNullable(rawEvidence.rawGvlSha256)], + ["Quelle", formatNullable(rawEvidence.sourceUrl)], + ["Abrufzeitpunkt", formatNullable(rawEvidence.fetchedAt)], + ["HTTP Status", formatNullable(rawEvidence.httpStatus)], + ["Content-Type", formatNullable(rawEvidence.contentType)], + ["Raw Body vorhanden", formatNullable(rawEvidence.hasRawBody)] + ]), + buildJsonDetails("Vollständiger rawVendor-Datensatz", vendor.rawVendor), + buildJsonDetails("Vollständiger gvl_vendors-Datensatz", vendor) + ); +} + +function buildKeyValueSection(title, rows) { + const section = document.createElement("section"); + const heading = document.createElement("h3"); + const table = document.createElement("table"); + const body = document.createElement("tbody"); + + section.className = "vendor-detail-section"; + heading.textContent = title; + table.className = "summary-table"; + + for (const [label, value] of rows) { + const row = document.createElement("tr"); + const labelCell = document.createElement("th"); + const valueCell = document.createElement("td"); + + labelCell.scope = "row"; + labelCell.textContent = label; + valueCell.textContent = value; + row.append(labelCell, valueCell); + body.append(row); + } + + table.append(body); + section.append(heading, table); + + return section; +} + +function buildJsonDetails(title, value) { + const details = document.createElement("details"); + const summary = document.createElement("summary"); + const valuePre = document.createElement("pre"); + + details.className = "technical-details"; + summary.textContent = title; + valuePre.textContent = JSON.stringify(value ?? null, null, 2); + details.append(summary, valuePre); + + return details; +} + async function renderGvlSnapshots() { try { const result = await browser.runtime.sendMessage({ @@ -93,6 +331,7 @@ async function renderGvlSnapshots() { function renderNoGvlSnapshots() { gvlSnapshotList.textContent = ""; clearGvlSnapshotSummary(); + clearGvlVendorOverview(); gvlSnapshotEmpty.hidden = false; gvlSnapshotContent.hidden = true; gvlSnapshotEmpty.textContent = @@ -145,6 +384,7 @@ function appendListCell(row, value, className) { async function selectGvlSnapshot(sha256) { selectedSnapshotSha256 = sha256; renderGvlSnapshotList(); + renderRebuildStatus(""); await renderSelectedGvlSnapshotSummary(); } @@ -152,8 +392,10 @@ async function renderSelectedGvlSnapshotSummary() { const snapshot = findGvlSnapshot(selectedSnapshotSha256); clearGvlSnapshotSummary(); + clearGvlVendorOverview(); if (!snapshot) { + updateRebuildActionState(null); return; } @@ -169,14 +411,149 @@ async function renderSelectedGvlSnapshotSummary() { throw new Error(result?.error ?? "get_gvl_snapshot_summary_failed"); } - renderSummaryTable(result.summary ?? {}); + selectedSnapshotSummary = result.summary ?? {}; + renderSummaryTable(selectedSnapshotSummary); + updateRebuildActionState(selectedSnapshotSummary); + await renderVendorOverviewForSelectedSnapshot(); } catch (error) { + selectedSnapshotSummary = null; + updateRebuildActionState(null); gvlSnapshotSummary.textContent = "Zusammenfassung dieser Vendorliste konnte nicht geladen werden."; console.warn("VG-Observe GVL snapshot summary failed", error); } } +async function renderVendorOverviewForSelectedSnapshot() { + const snapshot = findGvlSnapshot(selectedSnapshotSha256); + + clearGvlVendorOverview(); + + if (!snapshot) { + return; + } + + try { + const result = await browser.runtime.sendMessage({ + type: "list_gvl_vendors_for_snapshot", + payload: { + sha256: snapshot.sha256 + } + }); + + if (!result?.success) { + throw new Error(result?.error ?? "list_gvl_vendors_for_snapshot_failed"); + } + + selectedSnapshotVendors = result.vendors ?? []; + updateVendorOverviewSummary(); + renderVendorOverview(); + } catch (error) { + gvlVendorOverviewEmpty.hidden = false; + gvlVendorOverviewEmpty.textContent = + "Vendoren-Übersicht konnte nicht geladen werden."; + console.warn("VG-Observe GVL vendor overview failed", error); + } +} + +function renderVendorOverview() { + gvlVendorOverviewList.textContent = ""; + + if (!selectedSnapshotVendors.length) { + gvlVendorOverviewEmpty.hidden = false; + gvlVendorOverviewContent.hidden = true; + gvlVendorOverviewEmpty.textContent = + "Keine normalisierten Vendoren für diese Vendorliste vorhanden."; + return; + } + + gvlVendorOverviewEmpty.hidden = true; + gvlVendorOverviewContent.hidden = false; + + for (const vendor of selectedSnapshotVendors) { + const row = document.createElement("tr"); + + row.tabIndex = 0; + row.setAttribute("role", "button"); + row.addEventListener("click", async () => { + await selectVendorFromOverview(vendor.vendorId); + }); + row.addEventListener("keydown", async (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + await selectVendorFromOverview(vendor.vendorId); + } + }); + + appendListCell(row, formatNullable(vendor.vendorId), "numeric"); + appendListCell(row, formatNullable(vendor.name)); + appendListCell(row, formatNullable(vendor.deletedDate)); + gvlVendorOverviewList.append(row); + } +} + +async function selectVendorFromOverview(vendorId) { + if (vendorId === null || vendorId === undefined) { + return; + } + + gvlVendorIdInput.value = String(vendorId); + await renderGvlVendorDetail(); +} + +function clearGvlVendorOverview() { + gvlVendorOverviewList.textContent = ""; + selectedSnapshotVendors = []; + gvlVendorOverviewEmpty.hidden = true; + gvlVendorOverviewContent.hidden = true; + gvlVendorOverviewDetails.open = false; + updateVendorOverviewSummary(); +} + +function updateVendorOverviewSummary() { + const count = selectedSnapshotVendors.length; + + gvlVendorOverviewSummary.textContent = count + ? `Vendoren-Übersicht anzeigen (${count})` + : "Vendoren-Übersicht anzeigen"; +} + +function updateRebuildActionState(summary) { + const needsRebuild = doesSnapshotNeedNormalizedRebuild(summary); + + gvlRebuildNormalizedButton.disabled = !needsRebuild; + + if (!summary) { + renderRebuildStatus(""); + return; + } + + if (needsRebuild) { + renderRebuildStatus("Reparatur möglich: normalisierte lokale Daten fehlen."); + return; + } + + renderRebuildStatus("Normalisierte lokale GVL-Daten sind verfügbar."); +} + +function doesSnapshotNeedNormalizedRebuild(summary) { + if (!summary) { + return false; + } + + const expectedVendorCount = Number(summary.snapshotVendorCount ?? 0); + const normalizedVendorCount = Number(summary.normalizedVendorCount ?? 0); + const normalizedRelationshipCount = Number( + summary.normalizedVendorRelationshipCount ?? 0 + ); + + if (expectedVendorCount > 0 && normalizedVendorCount < expectedVendorCount) { + return true; + } + + return normalizedVendorCount === 0 || normalizedRelationshipCount === 0; +} + function renderSummaryTable(summary) { const table = document.createElement("table"); const body = document.createElement("tbody"); @@ -185,6 +562,10 @@ function renderSummaryTable(summary) { ["Abrufzeitpunkt", formatNullable(summary.fetchedAt)], ["Quelle", formatNullable(summary.sourceUrl)], ["Anzahl Firmen/Vendoren", formatCount(summary.vendorCount)], + [ + "Normalisierte Vendoren", + formatCount(summary.normalizedVendorCount) + ], ["Anzahl Zwecke/Purposes", formatCount(summary.purposeCount)], ["Anzahl Special Purposes", formatCount(summary.specialPurposeCount)], ["Anzahl Features", formatCount(summary.featureCount)], @@ -234,6 +615,7 @@ function clearGvlSnapshotSummary() { gvlSnapshotSummary.textContent = ""; gvlTechnicalFields.textContent = ""; gvlDebugData.textContent = ""; + selectedSnapshotSummary = null; } function findGvlSnapshot(sha256) { @@ -256,6 +638,14 @@ function formatNullable(value) { return String(value); } +function formatJsonValue(value) { + if (value === null || value === undefined || value === "") { + return "-"; + } + + return JSON.stringify(value); +} + function formatCount(value) { if (value === null || value === undefined) { return "0";