Improve GVL explorer usability and evidence workflows
Dieser Commit ist enthalten in:
@@ -38,3 +38,6 @@ web-ext-artifacts/
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Local GVL evidence exports
|
||||
data/GVL-Dumps/*.json
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
In neuem Issue referenzieren
Einen Benutzer sperren