This commit is contained in:
2026-08-24 03:41:06 +02:00
parent a246ed8fe5
commit 8a690a0464
22 changed files with 1253 additions and 358 deletions
+103
View File
@@ -0,0 +1,103 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { attributeAliasKey, buildAttributeDiff } from "./attribute-diff.ts";
describe("attribute diff", () => {
it("folds EPREL alias keys into one row", () => {
// eprel_pdf / eprel_pdf_url and energy_class / eprel_energy_class are the
// same fact stored twice — the review table showed "energy class twice".
const diff = buildAttributeDiff(
[],
[
{ key: "energy_class", value: "F" },
{ key: "eprel_energy_class", value: "F" },
{ key: "eprel_pdf", value: "https://x/f.pdf" },
{ key: "eprel_pdf_url", value: "https://x/f.pdf" }
]
);
assert.equal(diff.rows.length, 2);
const energy = diff.rows.find((r) => r.key === "energy_class");
assert.ok(energy, "shortest name should label the group");
assert.deepEqual(energy?.aliases, ["eprel_energy_class"]);
const pdf = diff.rows.find((r) => r.key === "eprel_pdf");
assert.deepEqual(pdf?.aliases, ["eprel_pdf_url"]);
});
it("only folds aliases that share a value", () => {
// Same alias family, different values: two separate facts, two rows.
const diff = buildAttributeDiff(
[],
[
{ key: "eprel_label", value: "A" },
{ key: "eprel_label_url", value: "B" }
]
);
assert.equal(diff.rows.length, 2);
});
it("never folds unrelated keys that happen to share a value", () => {
const diff = buildAttributeDiff(
[],
[
{ key: "net_width", value: "0.5 m" },
{ key: "net_height", value: "0.5 m" }
]
);
assert.equal(diff.rows.length, 2);
});
it("marks pipeline-derived attributes as added, not matched", () => {
// The feed had nothing; enrichment supplied everything.
const diff = buildAttributeDiff(
[{ key: "brand", value: "Vox" }],
[
{ key: "brand", value: "Vox" },
{ key: "eprel_id", value: "1517569" }
]
);
assert.equal(diff.added.length, 1);
assert.equal(diff.added[0].key, "eprel_id");
assert.equal(diff.added[0].before, "");
assert.equal(diff.same.length, 1);
assert.equal(diff.same[0].key, "brand");
});
it("reports changed and removed separately from added", () => {
const diff = buildAttributeDiff(
[
{ key: "warranty", value: "24" },
{ key: "colour", value: "black" }
],
[
{ key: "warranty", value: "36" },
{ key: "brand", value: "Vox" }
]
);
assert.deepEqual(
diff.changed.map((r) => [r.key, r.state]),
[
["warranty", "changed"],
["colour", "removed"]
]
);
assert.deepEqual(diff.added.map((r) => r.key), ["brand"]);
});
it("sorts rows so changes come before unchanged", () => {
const diff = buildAttributeDiff(
[{ key: "a_same", value: "1" }],
[
{ key: "a_same", value: "1" },
{ key: "z_added", value: "2" }
]
);
assert.deepEqual(diff.rows.map((r) => r.key), ["z_added", "a_same"]);
});
it("normalises alias keys", () => {
assert.equal(attributeAliasKey("eprel-pdf-url"), "pdf");
assert.equal(attributeAliasKey("eprel_pdf"), "pdf");
assert.equal(attributeAliasKey("energy_class"), "energyclass");
assert.equal(attributeAliasKey("eprel_energy_class"), "energyclass");
});
});
+100
View File
@@ -0,0 +1,100 @@
/**
* Feed-vs-enriched attribute diff for the product review screen.
*
* Two things make a naive diff wrong here:
*
* 1. `processed_products.attributes` is NOT the feed. The pipeline writes it and
* `processed_attributes` to the same value, so comparing them showed EPREL
* lookups and AI-extracted keys as if the supplier had sent them, and every row
* read "Matched". The "before" side must come from the feed's own specs.
* 2. Enrichment stores the same fact under several names — `eprel_pdf` and
* `eprel_pdf_url`, `energy_class` and `eprel_energy_class` — which listed the
* same value repeatedly ("energy class twice"). Aliases are folded into one row.
*/
export type AttributeEntry = { key: string; value: string };
export type AttributeDiffState = "added" | "changed" | "removed" | "same";
export type AttributeDiffRow = {
/** Shortest name for the fact; the others are in `aliases`. */
key: string;
aliases: string[];
before: string;
after: string;
state: AttributeDiffState;
};
export type AttributeDiff = {
rows: AttributeDiffRow[];
added: AttributeDiffRow[];
/** Changed and removed — anything that had a feed value and no longer matches. */
changed: AttributeDiffRow[];
same: AttributeDiffRow[];
};
/**
* Grouping key for attributes carrying the same fact under different names.
*
* Folding needs BOTH a related name and an identical value — matching on value
* alone would merge width and height whenever they happen to be equal.
*/
export function attributeAliasKey(key: string): string {
let k = key.toLowerCase().replace(/[^a-z0-9]/g, "");
if (k.startsWith("eprel") && k.length > 5) k = k.slice(5);
if (k.endsWith("url") && k.length > 3) k = k.slice(0, -3);
return k;
}
function stateFor(before: string | undefined, after: string | undefined): AttributeDiffState {
if (!before) return "added";
if (!after) return "removed";
return before === after ? "same" : "changed";
}
/** buildAttributeDiff compares feed entries against enriched entries. */
export function buildAttributeDiff(
feed: AttributeEntry[],
enriched: AttributeEntry[]
): AttributeDiff {
const before = new Map(feed.map((a) => [a.key.toLowerCase(), a]));
const after = new Map(enriched.map((a) => [a.key.toLowerCase(), a]));
const keys = [...new Set([...before.keys(), ...after.keys()])].sort();
const grouped = new Map<string, AttributeDiffRow>();
for (const k of keys) {
const b = before.get(k);
const a = after.get(k);
const row: AttributeDiffRow = {
key: (a ?? b)?.key ?? k,
aliases: [],
before: b?.value ?? "",
after: a?.value ?? "",
state: stateFor(b?.value, a?.value)
};
const groupKey = `${attributeAliasKey(row.key)}::${row.before}::${row.after}`;
const existing = grouped.get(groupKey);
if (!existing) {
grouped.set(groupKey, row);
continue;
}
// Keep the shortest name as the label; the rest become aliases.
const [keep, alias] =
row.key.length < existing.key.length ? [row.key, existing.key] : [existing.key, row.key];
existing.aliases = [...existing.aliases, alias].filter((name) => name !== keep);
existing.key = keep;
}
// Anything that needs attention first; unchanged rows last.
const rank: Record<AttributeDiffState, number> = { added: 0, changed: 1, removed: 2, same: 3 };
const rows = [...grouped.values()].sort(
(x, y) => rank[x.state] - rank[y.state] || x.key.localeCompare(y.key)
);
return {
rows,
added: rows.filter((r) => r.state === "added"),
changed: rows.filter((r) => r.state === "changed" || r.state === "removed"),
same: rows.filter((r) => r.state === "same")
};
}
@@ -28,6 +28,7 @@
productEnrichmentTitle,
productEprelLabel,
productFeedAttributeEntries,
productAttributeDiff,
productFeedFieldSummary,
productFeedMappedEntries,
productFeedSyncLabel,
@@ -430,27 +431,56 @@
assignedCategoryLabel.trim().toLowerCase() !== feedCategoryLabel.trim().toLowerCase()
);
/** Per-key attribute diff: what enrichment added or rewrote versus the feed. */
const attrDiff = $derived.by(() => {
const before = new Map(
productAttrEntries(product?.attributes).map((a) => [a.key.toLowerCase(), a])
);
const after = new Map(
productAttrEntries(product?.processed_attributes).map((a) => [a.key.toLowerCase(), a])
);
const keys = [...new Set([...before.keys(), ...after.keys()])].sort();
const rows = keys.map((k) => {
const b = before.get(k);
const a = after.get(k);
const state = !b ? "added" : !a ? "removed" : b.value === a.value ? "same" : "changed";
return { key: (a ?? b)?.key ?? k, before: b?.value ?? "", after: a?.value ?? "", state };
});
return {
rows,
changed: rows.filter((r) => r.state !== "same")
};
/** Feed-vs-enriched attribute diff, alias-collapsed (see buildAttributeDiff). */
const attrDiff = $derived.by(() => productAttributeDiff(product));
const attrsChanged = $derived(
kind === "processed" && (attrDiff.added.length > 0 || attrDiff.changed.length > 0)
);
/** Unchanged attributes are noise in a review; keep them one click away. */
let showUnchangedAttrs = $state(false);
// Nothing in the feed to compare against is "Added", not "Changed" — saying
// Matched (or Changed) about a field the supplier never sent reads as if the
// feed had one.
const categoryAdded = $derived(kind === "processed" && !feedCategoryLabel && !!assignedCategoryLabel);
/** SEO meta the feed supplied, when it had any. */
function feedMetaValue(...keys: string[]): string {
const mapped = product?.mapped_data as Record<string, unknown> | null | undefined;
if (!mapped || typeof mapped !== "object") return "";
for (const k of keys) {
const v = (mapped as Record<string, unknown>)[k];
if (typeof v === "string" && v.trim()) return v.trim();
}
return "";
}
const originalMetaTitle = $derived(feedMetaValue("meta_title", "metaTitle", "seo_title"));
const originalMetaDescription = $derived(
feedMetaValue("meta_description", "metaDescription", "seo_description")
);
const metaFields = $derived.by(() => {
const loc = product?.localized_content?.[contentLang];
const title = String(loc?.meta_title ?? product?.meta_title ?? "").trim();
const desc = String(loc?.meta_description ?? product?.meta_description ?? "").trim();
return [
{ key: "title", label: i18n.t("products.edit.metaTitle"), before: originalMetaTitle, after: title },
{
key: "description",
label: i18n.t("products.edit.metaDescription"),
before: originalMetaDescription,
after: desc
}
];
});
const attrsChanged = $derived(kind === "processed" && attrDiff.changed.length > 0);
const metaChanged = $derived(
kind === "processed" && metaFields.some((f) => f.after && f.after !== f.before)
);
const metaOmitted = $derived(product?.seo_meta_omitted === true);
/**
* Show the block whenever there is meta to show OR the company is opted out —
* silently hiding it is what made "where is the meta description?" unanswerable.
*/
const metaPresent = $derived(metaFields.some((f) => f.before || f.after) || metaOmitted);
const feedAttrs = $derived.by(() => {
const rows = productFeedAttributeEntries(product);
@@ -796,7 +826,9 @@
>
<div class="flex items-center justify-between gap-2">
<Label for="review-category">{i18n.t("products.enrichment.piece.category")}</Label>
{#if categoryChanged}
{#if categoryAdded}
<Badge variant="outline" class="text-[10px]">{i18n.t("products.edit.added")}</Badge>
{:else if categoryChanged}
<Badge variant="outline" class="text-[10px]">{i18n.t("products.edit.changed")}</Badge>
{:else}
<Badge variant="secondary" class="text-[10px]">{i18n.t("products.edit.matched")}</Badge>
@@ -808,7 +840,9 @@
{i18n.t("products.edit.original")}
</p>
<div
class="min-h-10 rounded-md border border-border bg-muted/30 px-3 py-2 text-sm text-muted-foreground"
class="min-h-10 rounded-md border border-border bg-muted/30 px-3 py-2 text-sm {feedCategoryLabel
? 'text-muted-foreground'
: 'italic text-muted-foreground/70'}"
>
{feedCategoryLabel || i18n.t("products.edit.noCategoryFromFeed")}
</div>
@@ -837,46 +871,155 @@
</div>
</div>
{#if kind === "processed"}
<div
class="space-y-2 rounded-md border border-transparent p-1 {metaChanged
? 'border-chart-amber/40 bg-chart-amber/10'
: ''}"
>
<div class="flex items-center justify-between gap-2">
<Label>{i18n.t("products.edit.seoMeta")}</Label>
{#if metaOmitted && !metaChanged}
<Badge variant="secondary" class="text-[10px]">
{i18n.t("products.edit.seoMetaDisabled")}
</Badge>
{:else if metaChanged}
<Badge variant="outline" class="text-[10px]">
{i18n.t(
metaFields.every((f) => !f.before || f.before === f.after)
? "products.edit.added"
: "products.edit.changed"
)}
</Badge>
{:else}
<Badge variant="secondary" class="text-[10px]">{i18n.t("products.edit.matched")}</Badge>
{/if}
</div>
{#if metaOmitted && !metaFields.some((f) => f.after)}
<p class="text-xs text-muted-foreground">
{i18n.t("products.edit.seoMetaDisabledHint")}
</p>
{/if}
{#each metaFields as field (field.key)}
<div class="space-y-1">
<p class="text-xs font-medium text-muted-foreground">{field.label}</p>
<div class="grid gap-3 sm:grid-cols-2">
<div
class="min-h-10 rounded-md border border-border bg-muted/30 px-3 py-2 text-xs {field.before
? 'text-muted-foreground'
: 'italic text-muted-foreground/60'}"
>
{field.before || i18n.t("products.edit.notInFeed")}
</div>
<div
class="min-h-10 rounded-md border border-border bg-muted/50 px-3 py-2 text-xs {field.after
? 'text-foreground'
: 'italic text-muted-foreground/70'}"
>
{field.after || i18n.t("products.edit.metaNotGenerated")}
</div>
</div>
</div>
{/each}
</div>
{/if}
<div
class="space-y-2 rounded-md border border-transparent p-1 {attrsChanged
? 'border-chart-amber/40 bg-chart-amber/10'
: ''}"
>
<div class="flex items-center justify-between gap-2">
<div class="flex flex-wrap items-center justify-between gap-2">
<Label>{i18n.t("products.enrichment.piece.attributes")}</Label>
{#if attrsChanged}
<Badge variant="outline" class="text-[10px]">
{i18n.t("products.edit.attrsChangedCount", { count: attrDiff.changed.length })}
</Badge>
{:else}
<Badge variant="secondary" class="text-[10px]">{i18n.t("products.edit.matched")}</Badge>
{/if}
<div class="flex items-center gap-1">
{#if attrDiff.added.length > 0}
<Badge variant="outline" class="text-[10px]">
{i18n.t("products.edit.attrsAddedCount", { count: attrDiff.added.length })}
</Badge>
{/if}
{#if attrDiff.changed.length > 0}
<Badge variant="outline" class="text-[10px]">
{i18n.t("products.edit.attrsChangedCount", { count: attrDiff.changed.length })}
</Badge>
{/if}
{#if !attrsChanged}
<Badge variant="secondary" class="text-[10px]">{i18n.t("products.edit.matched")}</Badge>
{/if}
</div>
</div>
{#if attrDiff.rows.length === 0}
<p class="text-xs text-muted-foreground">{i18n.t("products.edit.noAttributes")}</p>
{:else}
<div class="overflow-x-auto rounded-md border border-border">
<table class="w-full text-xs">
<thead class="bg-muted/40 text-muted-foreground">
<tr>
<th class="px-2 py-1.5 text-left font-medium">{i18n.t("products.edit.attrKey")}</th>
<th class="px-2 py-1.5 text-left font-medium">{i18n.t("products.edit.original")}</th>
<th class="px-2 py-1.5 text-left font-medium">{i18n.t("products.edit.enriched")}</th>
</tr>
</thead>
<tbody>
{#each attrDiff.rows as row (row.key)}
<tr class="border-t border-border {row.state === 'same' ? '' : 'bg-chart-amber/5'}">
<td class="px-2 py-1.5 font-mono">{row.key}</td>
<td class="px-2 py-1.5 text-muted-foreground">{row.before || "—"}</td>
<td class="px-2 py-1.5 {row.state === 'same' ? 'text-muted-foreground' : 'font-medium text-foreground'}">
{row.after || "—"}
</td>
{@const visible = showUnchangedAttrs
? attrDiff.rows
: [...attrDiff.added, ...attrDiff.changed]}
{#if visible.length === 0}
<p class="text-xs text-muted-foreground">
{i18n.t("products.edit.attrsAllUnchanged", { count: attrDiff.same.length })}
</p>
{:else}
<div class="overflow-x-auto rounded-md border border-border">
<table class="w-full text-xs">
<thead class="bg-muted/40 text-muted-foreground">
<tr>
<th class="px-2 py-1.5 text-left font-medium">{i18n.t("products.edit.attrKey")}</th>
<th class="px-2 py-1.5 text-left font-medium">{i18n.t("products.edit.original")}</th>
<th class="px-2 py-1.5 text-left font-medium">{i18n.t("products.edit.enriched")}</th>
<th class="px-2 py-1.5 text-right font-medium">{i18n.t("products.edit.attrState")}</th>
</tr>
{/each}
</tbody>
</table>
</div>
</thead>
<tbody>
{#each visible as row (row.key)}
<tr class="border-t border-border {row.state === 'same' ? '' : 'bg-chart-amber/5'}">
<td class="px-2 py-1.5 align-top">
<span class="font-mono">{row.key}</span>
{#if row.aliases.length > 0}
<span
class="block text-[10px] text-muted-foreground/70"
title={i18n.t("products.edit.attrAliasHint")}
>
= {row.aliases.join(", ")}
</span>
{/if}
</td>
<td
class="px-2 py-1.5 align-top {row.before
? 'text-muted-foreground'
: 'italic text-muted-foreground/60'}"
>
{row.before || i18n.t("products.edit.notInFeed")}
</td>
<td
class="px-2 py-1.5 align-top {row.state === 'same'
? 'text-muted-foreground'
: 'font-medium text-foreground'}"
>
{row.after || "—"}
</td>
<td class="px-2 py-1.5 text-right align-top">
{#if row.state !== "same"}
<Badge variant="outline" class="text-[10px]">
{i18n.t(`products.edit.attrState.${row.state}`)}
</Badge>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{#if attrDiff.same.length > 0}
<button
type="button"
class="text-[11px] text-primary underline-offset-2 hover:underline"
onclick={() => (showUnchangedAttrs = !showUnchangedAttrs)}
>
{showUnchangedAttrs
? i18n.t("products.edit.hideUnchanged")
: i18n.t("products.edit.showUnchanged", { count: attrDiff.same.length })}
</button>
{/if}
{/if}
</div>
</TabsContent>
@@ -1113,6 +1256,19 @@
</TabsContent>
<TabsContent value="content" class="mt-4 space-y-4">
{#if metaPresent}
<section class="space-y-2 rounded-md border border-border bg-muted/20 p-3">
<h3 class="text-sm font-medium">{i18n.t("products.edit.seoMeta")}</h3>
{#each metaFields as field (field.key)}
<div class="space-y-1">
<p class="text-xs font-medium text-muted-foreground">{field.label}</p>
<p class="rounded-md border border-border bg-background px-3 py-2 text-sm">
{field.after || field.before || "—"}
</p>
</div>
{/each}
</section>
{/if}
<div class="space-y-2">
<div class="flex items-center justify-between gap-2">
<Label for="edit-processed-desc">{i18n.t("products.edit.aiProcessedDescription")}</Label>
@@ -1170,6 +1326,36 @@
</div>
{/if}
</div>
{#if kind === "processed"}
<div class="space-y-2 rounded-md border border-border p-3">
<div class="flex items-center justify-between gap-2">
<Label>{i18n.t("products.edit.seoMeta")}</Label>
<span class="text-[11px] text-muted-foreground">
{i18n.t("products.edit.metaLengthHint")}
</span>
</div>
{#each metaFields as field (field.key)}
<div class="space-y-1">
<div class="flex items-center justify-between gap-2">
<p class="text-xs font-medium text-muted-foreground">{field.label}</p>
{#if field.after}
<span class="text-[11px] tabular-nums text-muted-foreground">
{field.after.length}
</span>
{/if}
</div>
<div
class="min-h-10 rounded-md border border-border bg-muted/50 px-3 py-2 text-sm {field.after
? 'text-foreground'
: 'italic text-muted-foreground/70'}"
>
{field.after || i18n.t("products.edit.metaNotGenerated")}
</div>
</div>
{/each}
</div>
{/if}
</TabsContent>
<TabsContent value="attributes" class="mt-4 space-y-5">
+42 -284
View File
@@ -1,4 +1,28 @@
import { i18n } from "$lib/i18n";
import { buildAttributeDiff, type AttributeDiff } from "$lib/attribute-diff";
import {
asRecord,
formatProductAttrValue,
productAttrEntries,
formatMappedFeedValue,
attributeKeyFromLabel,
isValidAttributeKey,
canonicalizeAttributeKey,
productFeedAttributeEntries
} from "../../product-attributes";
export {
asRecord,
formatProductAttrValue,
productAttrEntries,
formatMappedFeedValue,
attributeKeyFromLabel,
isValidAttributeKey,
canonicalizeAttributeKey,
productFeedAttributeEntries
};
export type {
ProductListURLFilters,
@@ -72,6 +96,12 @@ export type ProductRow = {
processed_description?: string | null;
meta_title?: string | null;
meta_description?: string | null;
/**
* The company's cohort does not generate SEO meta (A1 / Platform Demo).
* Set by the API so an empty meta block can say "disabled here" instead of
* looking like the AI produced nothing.
*/
seo_meta_omitted?: boolean | null;
localized_content?: Record<
string,
{
@@ -455,67 +485,8 @@ export function formatRelativeUpdated(value?: string | Date | null): string {
return rtf.format(-Math.round(months / 12), "year");
}
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
/**
* Format a product attribute value for display.
* Legacy dumps store select-like attrs as `{ key, name }` and empties as JSON null
* never show raw JSON or the literal string "null".
*/
export function formatProductAttrValue(value: unknown): string {
if (value == null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
if (Array.isArray(value)) {
return value
.map((item) => formatProductAttrValue(item))
.filter((s) => s.trim() !== "")
.join(", ");
}
const rec = asRecord(value);
if (!rec) return "";
for (const key of ["value", "name", "label", "text", "#text"]) {
const nested = formatProductAttrValue(rec[key]);
if (nested.trim() !== "") return nested;
}
return "";
}
/** Flatten an attributes blob into display rows; skips empty / junk keys. */
export function productAttrEntries(raw: unknown): { key: string; value: string }[] {
if (!raw) return [];
const out: { key: string; value: string }[] = [];
const seen = new Set<string>();
const push = (rawKey: string, rawValue: unknown) => {
const value = formatProductAttrValue(rawValue).trim();
if (!value) return;
const key = canonicalizeAttributeKey(rawKey);
if (!key) return;
const norm = key.toLowerCase();
if (seen.has(norm)) return;
seen.add(norm);
out.push({ key, value });
};
if (Array.isArray(raw)) {
for (const item of raw) {
if (item && typeof item === "object") {
const rec = item as Record<string, unknown>;
push(String(rec.key ?? rec.name ?? rec.attribute_key ?? ""), rec.value ?? rec.name ?? item);
}
}
return out;
}
const rec = asRecord(raw);
if (!rec) return [];
for (const [key, value] of Object.entries(rec)) {
if (key === "specifications" || key === "specs" || key === "eprel") continue;
push(key, value);
}
return out;
}
/** Original feed description when processed_products.description was never filled (legacy). */
export function resolveOriginalDescription(product: ProductRow | null | undefined): string {
@@ -573,22 +544,6 @@ export function productFeedFieldSummary(
return productFeedMappedEntries(product, options).filter((e) => e.preferred);
}
/** Format any mapped_data value for display (scalars + nested JSON). */
export function formatMappedFeedValue(value: unknown): string {
if (value == null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
const simple = formatProductAttrValue(value);
if (simple.trim() !== "") return simple;
if (typeof value === "object") {
try {
return JSON.stringify(value, null, 2);
} catch {
return "";
}
}
return String(value);
}
export type FeedMappedEntry = {
key: string;
@@ -642,224 +597,15 @@ export function productFeedMappedEntries(
});
}
const FEED_ATTR_KEYS = [
"specifications",
"specs",
"warranty",
"productmodel",
"product_model",
"netwidth",
"net_width",
"netheight",
"net_height",
"netdepth",
"net_depth",
"netmass",
"net_mass",
"visina",
"sirina",
"globina",
"teza",
"eprel_id",
"eprel"
] as const;
/** Locale / supplier aliases → Descrybe standard field keys (snake_case). */
const ATTR_KEY_ALIASES: Record<string, string> = {
visina: "net_height",
height: "net_height",
netheight: "net_height",
net_height: "net_height",
sirina: "net_width",
width: "net_width",
netwidth: "net_width",
net_width: "net_width",
globina: "net_depth",
depth: "net_depth",
netdepth: "net_depth",
net_depth: "net_depth",
netmass: "net_mass",
mass: "net_mass",
weight: "net_mass",
teza: "net_mass",
net_mass: "net_mass",
productmodel: "product_model",
product_model: "product_model",
model: "product_model",
eprel_id: "eprel_id",
eprelid: "eprel_id",
eprel: "eprel_id",
energyclass: "energy_class",
energijskirazred: "energy_class",
warranty: "warranty"
};
function compactAttributeKey(key: string): string {
return key
.trim()
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[šśş]/g, "s")
.replace(/[čćç]/g, "c")
.replace(/[žźż]/g, "z")
.replace(/đ/g, "d")
.replace(/[^a-z0-9]+/g, "");
}
/** Kebab-case slug from a human label (matches A1 attribute_key style). */
export function attributeKeyFromLabel(label: string): string {
const folded = label
.trim()
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[šśş]/g, "s")
.replace(/[čćç]/g, "c")
.replace(/[žźż]/g, "z")
.replace(/đ/g, "d");
return folded
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
export function isValidAttributeKey(key: string): boolean {
const compact = compactAttributeKey(key);
if (compact.length < 2) return false;
if (!/[a-z]/.test(compact)) return false;
switch (compact) {
case "true":
case "false":
case "yes":
case "no":
case "null":
case "undefined":
case "none":
case "n":
case "y":
return false;
default:
return true;
}
}
/** Normalize feed/UI attribute labels onto standard keys; rejects junk. */
export function canonicalizeAttributeKey(label: string): string {
const slug = attributeKeyFromLabel(label);
const compact = compactAttributeKey(slug || label);
if (!compact) return "";
const alias = ATTR_KEY_ALIASES[compact];
if (alias) return alias;
if (!slug || !isValidAttributeKey(slug)) return "";
return slug;
}
function stripHtmlLight(value: string): string {
return value
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/(li|p|div|tr)>/gi, "\n")
.replace(/<\/>/g, "\n")
.replace(/<[^>]+>/g, " ")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.replace(/[ \t]{2,}/g, " ")
.trim();
}
function pushFeedAttr(
out: { key: string; value: string; linked?: boolean; label?: string }[],
seen: Set<string>,
key: string,
value: string,
label?: string
) {
const formatted = value.trim();
if (!formatted) return;
const canonical = canonicalizeAttributeKey(key);
if (!canonical) return;
const norm = canonical.toLowerCase();
if (seen.has(norm)) return;
seen.add(norm);
const sourceLabel = (label || key).trim();
out.push({
key: canonical,
value: formatted,
linked: Boolean(sourceLabel && sourceLabel !== canonical),
label: sourceLabel && sourceLabel !== canonical ? sourceLabel : canonical
});
}
function parseSpecificationPairs(raw: string): { key: string; value: string; label: string }[] {
const plain = stripHtmlLight(raw);
if (!plain) return [];
const chunks = plain
.split(/\n+|;|\|/)
.map((c) => c.trim())
.filter(Boolean);
const out: { key: string; value: string; label: string }[] = [];
for (const chunk of chunks) {
const m = chunk.match(/^([^:=]{1,120})\s*[:=]\s*(.+)$/);
if (!m) continue;
const label = m[1].trim();
const value = m[2].trim();
if (!label || !value) continue;
const key = canonicalizeAttributeKey(label);
if (!key) continue;
out.push({ key, value, label });
}
return out;
}
/** Feed-sourced attribute-like fields (specs, dimensions, eprel) for the Attributes tab. */
export function productFeedAttributeEntries(
product: ProductRow | null | undefined
): { key: string; value: string; linked?: boolean; label?: string }[] {
const mapped = asRecord(product?.mapped_data);
if (!mapped) return [];
const out: { key: string; value: string; linked?: boolean; label?: string }[] = [];
const seen = new Set<string>();
for (const key of ["specifications", "specs"] as const) {
if (!(key in mapped)) continue;
const raw = mapped[key];
if (typeof raw === "string") {
for (const pair of parseSpecificationPairs(raw)) {
pushFeedAttr(out, seen, pair.key, pair.value, pair.label);
}
continue;
}
const rec = asRecord(raw);
if (rec) {
for (const [k, v] of Object.entries(rec)) {
if (k === "_raw" && typeof v === "string") {
for (const pair of parseSpecificationPairs(v)) {
pushFeedAttr(out, seen, pair.key, pair.value, pair.label);
}
continue;
}
const formatted = formatMappedFeedValue(v);
if (!formatted.trim()) continue;
const attrKey = canonicalizeAttributeKey(k);
if (!attrKey) continue;
pushFeedAttr(out, seen, attrKey, formatted, k);
}
}
}
for (const key of FEED_ATTR_KEYS) {
if (key === "specifications" || key === "specs") continue;
if (!(key in mapped) || seen.has(key.toLowerCase())) continue;
let formatted = formatMappedFeedValue(mapped[key]);
if (/<\/?[a-z][\s\S]*>/i.test(formatted) || formatted.includes("</>")) {
formatted = stripHtmlLight(formatted);
}
if (formatted.trim() === "") continue;
const linkedKey = canonicalizeAttributeKey(key);
if (!linkedKey) continue;
pushFeedAttr(out, seen, linkedKey, formatted, key);
}
return out;
}
function digAttrBag(
product: ProductRow | null | undefined,
@@ -927,3 +673,15 @@ export function productEprelLabel(product: ProductRow | null | undefined): strin
if (energy != null && String(energy).trim()) return `EPREL ${String(energy).trim()}`;
return "EPREL";
}
/**
* Review diff between the attributes the FEED supplied and the enriched set.
* The comparison itself lives in $lib/attribute-diff (dependency-free so it can be
* unit-tested); this only supplies the two extracted lists.
*/
export function productAttributeDiff(product: ProductRow | null | undefined): AttributeDiff {
return buildAttributeDiff(
productFeedAttributeEntries(product),
productAttrEntries(product?.processed_attributes ?? product?.attributes)
);
}
+23
View File
@@ -5772,4 +5772,27 @@ export const de: MessageDict = {
"admin.aiCosts.colOut": "Ausgabe",
"admin.aiCosts.colCost": "Kosten",
"admin.aiCosts.unattributed": "Nicht zugeordnet",
"products.edit.added": "Hinzugefuegt",
"products.edit.notInFeed": "nicht im Feed",
"products.edit.attrsAddedCount": "{count} hinzugefuegt",
"products.edit.seoMeta": "SEO-Meta",
"products.edit.metaTitle": "Meta-Titel",
"products.edit.metaDescription": "Meta-Beschreibung",
"products.edit.attrState": "Aenderung",
"products.edit.attrState.added": "Hinzugefuegt",
"products.edit.attrState.changed": "Geaendert",
"products.edit.attrState.removed": "Entfernt",
"products.edit.attrAliasHint": "Gleicher Wert unter diesen anderen Attributnamen gespeichert",
"products.edit.attrsAllUnchanged": "Alle {count} Attribute entsprechen dem Feed.",
"products.edit.showUnchanged": "{count} unveraenderte anzeigen",
"products.edit.hideUnchanged": "Unveraenderte ausblenden",
"products.edit.alsoStoredAs": "+{count} Alias",
"products.edit.attrsAddedHeading": "Durch Anreicherung ergaenzt ({count})",
"products.edit.attrsChangedHeading": "Gegenueber Feed geaendert ({count})",
"products.edit.attrsShowUnchanged": "{count} unveraenderte anzeigen",
"products.edit.attrsHideUnchanged": "{count} unveraenderte ausblenden",
"products.edit.metaNotGenerated": "nicht erzeugt",
"products.edit.metaLengthHint": "Titel 50-60 / Beschreibung 120-155 Zeichen",
"products.edit.seoMetaDisabled": "Nicht genutzt",
"products.edit.seoMetaDisabledHint": "SEO-Meta ist fuer diese Firma deaktiviert und wird daher nicht erzeugt.",
};
+23
View File
@@ -5858,4 +5858,27 @@ export const en: MessageDict = {
"admin.aiCosts.colOut": "Output",
"admin.aiCosts.colCost": "Cost",
"admin.aiCosts.unattributed": "Unattributed",
"products.edit.added": "Added",
"products.edit.notInFeed": "not in feed",
"products.edit.attrsAddedCount": "{count} added",
"products.edit.seoMeta": "SEO meta",
"products.edit.metaTitle": "Meta title",
"products.edit.metaDescription": "Meta description",
"products.edit.attrState": "Change",
"products.edit.attrState.added": "Added",
"products.edit.attrState.changed": "Changed",
"products.edit.attrState.removed": "Removed",
"products.edit.attrAliasHint": "Same value stored under these other attribute names",
"products.edit.attrsAllUnchanged": "All {count} attributes match the feed.",
"products.edit.showUnchanged": "Show {count} unchanged",
"products.edit.hideUnchanged": "Hide unchanged",
"products.edit.alsoStoredAs": "+{count} alias",
"products.edit.attrsAddedHeading": "Added by enrichment ({count})",
"products.edit.attrsChangedHeading": "Changed from feed ({count})",
"products.edit.attrsShowUnchanged": "Show {count} unchanged",
"products.edit.attrsHideUnchanged": "Hide {count} unchanged",
"products.edit.metaNotGenerated": "not generated",
"products.edit.metaLengthHint": "title 50-60 / description 120-155 characters",
"products.edit.seoMetaDisabled": "Not used",
"products.edit.seoMetaDisabledHint": "SEO meta is turned off for this company, so processing does not generate it.",
};
+23
View File
@@ -5781,4 +5781,27 @@ export const es: MessageDict = {
"admin.aiCosts.colOut": "Salida",
"admin.aiCosts.colCost": "Coste",
"admin.aiCosts.unattributed": "Sin asignar",
"products.edit.added": "Anadido",
"products.edit.notInFeed": "no esta en el feed",
"products.edit.attrsAddedCount": "{count} anadidos",
"products.edit.seoMeta": "Meta SEO",
"products.edit.metaTitle": "Meta titulo",
"products.edit.metaDescription": "Meta descripcion",
"products.edit.attrState": "Cambio",
"products.edit.attrState.added": "Anadido",
"products.edit.attrState.changed": "Modificado",
"products.edit.attrState.removed": "Eliminado",
"products.edit.attrAliasHint": "El mismo valor guardado con estos otros nombres de atributo",
"products.edit.attrsAllUnchanged": "Los {count} atributos coinciden con el feed.",
"products.edit.showUnchanged": "Mostrar {count} sin cambios",
"products.edit.hideUnchanged": "Ocultar sin cambios",
"products.edit.alsoStoredAs": "+{count} alias",
"products.edit.attrsAddedHeading": "Anadido por el enriquecimiento ({count})",
"products.edit.attrsChangedHeading": "Modificado respecto al feed ({count})",
"products.edit.attrsShowUnchanged": "Mostrar {count} sin cambios",
"products.edit.attrsHideUnchanged": "Ocultar {count} sin cambios",
"products.edit.metaNotGenerated": "no generado",
"products.edit.metaLengthHint": "titulo 50-60 / descripcion 120-155 caracteres",
"products.edit.seoMetaDisabled": "No se usa",
"products.edit.seoMetaDisabledHint": "El meta SEO esta desactivado para esta empresa, por lo que no se genera.",
};
+23
View File
@@ -5781,4 +5781,27 @@ export const fr: MessageDict = {
"admin.aiCosts.colOut": "Sortie",
"admin.aiCosts.colCost": "Cout",
"admin.aiCosts.unattributed": "Non attribue",
"products.edit.added": "Ajoute",
"products.edit.notInFeed": "absent du flux",
"products.edit.attrsAddedCount": "{count} ajoutes",
"products.edit.seoMeta": "Meta SEO",
"products.edit.metaTitle": "Meta titre",
"products.edit.metaDescription": "Meta description",
"products.edit.attrState": "Changement",
"products.edit.attrState.added": "Ajoute",
"products.edit.attrState.changed": "Modifie",
"products.edit.attrState.removed": "Supprime",
"products.edit.attrAliasHint": "Meme valeur enregistree sous ces autres noms d attribut",
"products.edit.attrsAllUnchanged": "Les {count} attributs correspondent au flux.",
"products.edit.showUnchanged": "Afficher {count} inchanges",
"products.edit.hideUnchanged": "Masquer les inchanges",
"products.edit.alsoStoredAs": "+{count} alias",
"products.edit.attrsAddedHeading": "Ajoute par l enrichissement ({count})",
"products.edit.attrsChangedHeading": "Modifie par rapport au flux ({count})",
"products.edit.attrsShowUnchanged": "Afficher {count} inchanges",
"products.edit.attrsHideUnchanged": "Masquer {count} inchanges",
"products.edit.metaNotGenerated": "non genere",
"products.edit.metaLengthHint": "titre 50-60 / description 120-155 caracteres",
"products.edit.seoMetaDisabled": "Non utilise",
"products.edit.seoMetaDisabledHint": "Le meta SEO est desactive pour cette entreprise, il n est donc pas genere.",
};
+23
View File
@@ -5781,4 +5781,27 @@ export const it: MessageDict = {
"admin.aiCosts.colOut": "Output",
"admin.aiCosts.colCost": "Costo",
"admin.aiCosts.unattributed": "Non attribuito",
"products.edit.added": "Aggiunto",
"products.edit.notInFeed": "non nel feed",
"products.edit.attrsAddedCount": "{count} aggiunti",
"products.edit.seoMeta": "Meta SEO",
"products.edit.metaTitle": "Meta titolo",
"products.edit.metaDescription": "Meta descrizione",
"products.edit.attrState": "Modifica",
"products.edit.attrState.added": "Aggiunto",
"products.edit.attrState.changed": "Modificato",
"products.edit.attrState.removed": "Rimosso",
"products.edit.attrAliasHint": "Stesso valore salvato con questi altri nomi di attributo",
"products.edit.attrsAllUnchanged": "Tutti i {count} attributi corrispondono al feed.",
"products.edit.showUnchanged": "Mostra {count} invariati",
"products.edit.hideUnchanged": "Nascondi invariati",
"products.edit.alsoStoredAs": "+{count} alias",
"products.edit.attrsAddedHeading": "Aggiunto dall arricchimento ({count})",
"products.edit.attrsChangedHeading": "Modificato rispetto al feed ({count})",
"products.edit.attrsShowUnchanged": "Mostra {count} invariati",
"products.edit.attrsHideUnchanged": "Nascondi {count} invariati",
"products.edit.metaNotGenerated": "non generato",
"products.edit.metaLengthHint": "titolo 50-60 / descrizione 120-155 caratteri",
"products.edit.seoMetaDisabled": "Non usato",
"products.edit.seoMetaDisabledHint": "Il meta SEO e disattivato per questa azienda, quindi non viene generato.",
};
+23
View File
@@ -5781,4 +5781,27 @@ export const ja: MessageDict = {
"admin.aiCosts.colOut": "出力",
"admin.aiCosts.colCost": "コスト",
"admin.aiCosts.unattributed": "未割り当て",
"products.edit.added": "追加",
"products.edit.notInFeed": "フィードにありません",
"products.edit.attrsAddedCount": "{count} 件追加",
"products.edit.seoMeta": "SEO メタ",
"products.edit.metaTitle": "メタタイトル",
"products.edit.metaDescription": "メタディスクリプション",
"products.edit.attrState": "変更",
"products.edit.attrState.added": "追加",
"products.edit.attrState.changed": "変更",
"products.edit.attrState.removed": "削除",
"products.edit.attrAliasHint": "同じ値が別の属性名でも保存されています",
"products.edit.attrsAllUnchanged": "{count} 件の属性はすべてフィードと一致します。",
"products.edit.showUnchanged": "変更なし {count} 件を表示",
"products.edit.hideUnchanged": "変更なしを隠す",
"products.edit.alsoStoredAs": "+{count} 別名",
"products.edit.attrsAddedHeading": "エンリッチメントで追加 ({count})",
"products.edit.attrsChangedHeading": "フィードから変更 ({count})",
"products.edit.attrsShowUnchanged": "変更なし {count} 件を表示",
"products.edit.attrsHideUnchanged": "変更なし {count} 件を隠す",
"products.edit.metaNotGenerated": "未生成",
"products.edit.metaLengthHint": "タイトル 50-60 / 説明 120-155 文字",
"products.edit.seoMetaDisabled": "未使用",
"products.edit.seoMetaDisabledHint": "この会社では SEO メタが無効なため生成されません。",
};
+23
View File
@@ -5781,4 +5781,27 @@ export const nl: MessageDict = {
"admin.aiCosts.colOut": "Uitvoer",
"admin.aiCosts.colCost": "Kosten",
"admin.aiCosts.unattributed": "Niet toegewezen",
"products.edit.added": "Toegevoegd",
"products.edit.notInFeed": "niet in de feed",
"products.edit.attrsAddedCount": "{count} toegevoegd",
"products.edit.seoMeta": "SEO-meta",
"products.edit.metaTitle": "Metatitel",
"products.edit.metaDescription": "Metabeschrijving",
"products.edit.attrState": "Wijziging",
"products.edit.attrState.added": "Toegevoegd",
"products.edit.attrState.changed": "Gewijzigd",
"products.edit.attrState.removed": "Verwijderd",
"products.edit.attrAliasHint": "Dezelfde waarde opgeslagen onder deze andere attribuutnamen",
"products.edit.attrsAllUnchanged": "Alle {count} attributen komen overeen met de feed.",
"products.edit.showUnchanged": "Toon {count} ongewijzigd",
"products.edit.hideUnchanged": "Verberg ongewijzigde",
"products.edit.alsoStoredAs": "+{count} alias",
"products.edit.attrsAddedHeading": "Toegevoegd door verrijking ({count})",
"products.edit.attrsChangedHeading": "Gewijzigd t.o.v. feed ({count})",
"products.edit.attrsShowUnchanged": "Toon {count} ongewijzigd",
"products.edit.attrsHideUnchanged": "Verberg {count} ongewijzigd",
"products.edit.metaNotGenerated": "niet gegenereerd",
"products.edit.metaLengthHint": "titel 50-60 / beschrijving 120-155 tekens",
"products.edit.seoMetaDisabled": "Niet gebruikt",
"products.edit.seoMetaDisabledHint": "SEO-meta is uitgeschakeld voor dit bedrijf en wordt dus niet gegenereerd.",
};
+23
View File
@@ -5781,4 +5781,27 @@ export const pl: MessageDict = {
"admin.aiCosts.colOut": "Wyjscie",
"admin.aiCosts.colCost": "Koszt",
"admin.aiCosts.unattributed": "Nieprzypisane",
"products.edit.added": "Dodano",
"products.edit.notInFeed": "brak w zrodle",
"products.edit.attrsAddedCount": "Dodano: {count}",
"products.edit.seoMeta": "Meta SEO",
"products.edit.metaTitle": "Meta tytul",
"products.edit.metaDescription": "Meta opis",
"products.edit.attrState": "Zmiana",
"products.edit.attrState.added": "Dodano",
"products.edit.attrState.changed": "Zmieniono",
"products.edit.attrState.removed": "Usunieto",
"products.edit.attrAliasHint": "Ta sama wartosc zapisana pod tymi innymi nazwami atrybutow",
"products.edit.attrsAllUnchanged": "Wszystkie {count} atrybuty zgadzaja sie ze zrodlem.",
"products.edit.showUnchanged": "Pokaz {count} niezmienionych",
"products.edit.hideUnchanged": "Ukryj niezmienione",
"products.edit.alsoStoredAs": "+{count} alias",
"products.edit.attrsAddedHeading": "Dodane przez wzbogacanie ({count})",
"products.edit.attrsChangedHeading": "Zmienione wzgledem zrodla ({count})",
"products.edit.attrsShowUnchanged": "Pokaz niezmienione ({count})",
"products.edit.attrsHideUnchanged": "Ukryj niezmienione ({count})",
"products.edit.metaNotGenerated": "nie wygenerowano",
"products.edit.metaLengthHint": "tytul 50-60 / opis 120-155 znakow",
"products.edit.seoMetaDisabled": "Nieuzywane",
"products.edit.seoMetaDisabledHint": "Meta SEO jest wylaczone dla tej firmy, wiec nie jest generowane.",
};
+23
View File
@@ -5781,4 +5781,27 @@ export const pt: MessageDict = {
"admin.aiCosts.colOut": "Saida",
"admin.aiCosts.colCost": "Custo",
"admin.aiCosts.unattributed": "Nao atribuido",
"products.edit.added": "Adicionado",
"products.edit.notInFeed": "nao esta no feed",
"products.edit.attrsAddedCount": "{count} adicionados",
"products.edit.seoMeta": "Meta SEO",
"products.edit.metaTitle": "Meta titulo",
"products.edit.metaDescription": "Meta descricao",
"products.edit.attrState": "Alteracao",
"products.edit.attrState.added": "Adicionado",
"products.edit.attrState.changed": "Alterado",
"products.edit.attrState.removed": "Removido",
"products.edit.attrAliasHint": "O mesmo valor guardado sob estes outros nomes de atributo",
"products.edit.attrsAllUnchanged": "Os {count} atributos correspondem ao feed.",
"products.edit.showUnchanged": "Mostrar {count} sem alteracoes",
"products.edit.hideUnchanged": "Ocultar sem alteracoes",
"products.edit.alsoStoredAs": "+{count} alias",
"products.edit.attrsAddedHeading": "Adicionado pelo enriquecimento ({count})",
"products.edit.attrsChangedHeading": "Alterado face ao feed ({count})",
"products.edit.attrsShowUnchanged": "Mostrar {count} sem alteracoes",
"products.edit.attrsHideUnchanged": "Ocultar {count} sem alteracoes",
"products.edit.metaNotGenerated": "nao gerado",
"products.edit.metaLengthHint": "titulo 50-60 / descricao 120-155 caracteres",
"products.edit.seoMetaDisabled": "Nao utilizado",
"products.edit.seoMetaDisabledHint": "O meta SEO esta desativado para esta empresa, por isso nao e gerado.",
};
+23
View File
@@ -92,4 +92,27 @@ export const sl: MessageDict = {
"admin.aiCosts.colOut": "Izhod",
"admin.aiCosts.colCost": "Strosek",
"admin.aiCosts.unattributed": "Nedodeljeno",
"products.edit.added": "Dodano",
"products.edit.notInFeed": "ni v viru",
"products.edit.attrsAddedCount": "Dodanih: {count}",
"products.edit.seoMeta": "SEO meta",
"products.edit.metaTitle": "Meta naslov",
"products.edit.metaDescription": "Meta opis",
"products.edit.attrState": "Sprememba",
"products.edit.attrState.added": "Dodano",
"products.edit.attrState.changed": "Spremenjeno",
"products.edit.attrState.removed": "Odstranjeno",
"products.edit.attrAliasHint": "Ista vrednost je shranjena tudi pod temi imeni atributov",
"products.edit.attrsAllUnchanged": "Vseh {count} atributov se ujema z virom.",
"products.edit.showUnchanged": "Prikazi {count} nespremenjenih",
"products.edit.hideUnchanged": "Skrij nespremenjene",
"products.edit.alsoStoredAs": "+{count} vzdevek",
"products.edit.attrsAddedHeading": "Dodano z obogatitvijo ({count})",
"products.edit.attrsChangedHeading": "Spremenjeno glede na vir ({count})",
"products.edit.attrsShowUnchanged": "Prikazi nespremenjene ({count})",
"products.edit.attrsHideUnchanged": "Skrij nespremenjene ({count})",
"products.edit.metaNotGenerated": "ni ustvarjeno",
"products.edit.metaLengthHint": "naslov 50-60 / opis 120-155 znakov",
"products.edit.seoMetaDisabled": "Ni v uporabi",
"products.edit.seoMetaDisabledHint": "SEO meta je za to podjetje izklopljen, zato ga obdelava ne ustvari.",
};
@@ -0,0 +1,75 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
canonicalizeAttributeKey,
productAttrEntries,
productFeedAttributeEntries
} from "./product-attributes.ts";
/**
* EPREL enrichment stores the same fact under several names. Review listed each of
* them as its own row, so a product showed "energy class" twice with identical
* values reading as if it carried two different measurements.
*
* Key set below is copied from a real enriched row (Dell monitor, EPREL 1870858).
*/
const ENRICHED_ATTRS = {
brand: "Dell",
energy_class: "D",
eprel_energy_class: "D",
eprel_energy_scale: "A_G",
eprel_id: "1870858",
eprel_label: "https://eprel.ec.europa.eu/api/product/1870858/labels?format=png",
eprel_label_url: "https://eprel.ec.europa.eu/api/product/1870858/labels?format=png",
eprel_pdf: "https://eprel.ec.europa.eu/fiches/electronicdisplays/Fiche_1870858_EN.pdf",
eprel_pdf_url: "https://eprel.ec.europa.eu/fiches/electronicdisplays/Fiche_1870858_EN.pdf",
eprel: { energy_class: "D", energy_scale: "A_G", id: "1870858" },
product_model: "210-BMJD",
warranty: "36M"
};
describe("product attribute aliases", () => {
it("folds every EPREL alias onto one key", () => {
assert.equal(canonicalizeAttributeKey("eprel_energy_class"), "energy_class");
assert.equal(canonicalizeAttributeKey("eprel-energy-class"), "energy_class");
assert.equal(canonicalizeAttributeKey("energy_class"), "energy_class");
assert.equal(canonicalizeAttributeKey("eprel_pdf_url"), "eprel_pdf");
assert.equal(canonicalizeAttributeKey("eprel-pdf"), "eprel_pdf");
assert.equal(canonicalizeAttributeKey("eprel_label_url"), "eprel_label");
assert.equal(canonicalizeAttributeKey("eprel_energy_scale"), "energy_scale");
});
it("lists each enriched fact exactly once", () => {
const keys = productAttrEntries(ENRICHED_ATTRS).map((a) => a.key);
const counts = new Map<string, number>();
for (const k of keys) counts.set(k, (counts.get(k) ?? 0) + 1);
const duplicated = [...counts.entries()].filter(([, n]) => n > 1);
assert.deepEqual(duplicated, [], `duplicate attribute rows: ${JSON.stringify(duplicated)}`);
assert.equal(counts.get("energy_class"), 1, "energy class must appear once");
assert.equal(counts.get("eprel_pdf"), 1, "EPREL pdf must appear once");
assert.equal(counts.get("eprel_label"), 1, "EPREL label must appear once");
// The nested eprel object duplicates all of the above and must not add a row.
assert.ok(!keys.includes("eprel"), "nested eprel object should not be listed");
});
it("lines the feed's own energy class up with the enriched key", () => {
// Slovenian feed label for the same fact — if it canonicalised differently the
// review would show it as Added instead of Unchanged.
assert.equal(canonicalizeAttributeKey("energijski-razred"), "energy_class");
const feed = productFeedAttributeEntries({
id: "x",
mapped_data: { specifications: { "energijski-razred": "D", barva: "črna" } }
});
const byKey = new Map(feed.map((a) => [a.key, a.value]));
assert.equal(byKey.get("energy_class"), "D");
});
it("reports nothing for a feed with no specs", () => {
// Products whose specs the pipeline derived entirely (EPREL / AI) must show an
// empty Original, not a copy of the enriched values.
assert.deepEqual(productFeedAttributeEntries({ id: "x", mapped_data: {} }), []);
assert.deepEqual(productFeedAttributeEntries({ id: "x", mapped_data: { specifications: "" } }), []);
});
});
+320
View File
@@ -0,0 +1,320 @@
/**
* Product attribute keys and values, extracted from the product types module so it
* can be unit tested: that module imports $lib/i18n, which the node test runner
* cannot resolve.
*
* The canonical-key map here is what stops enrichment aliases (energy_class vs
* eprel_energy_class, eprel_pdf vs eprel_pdf_url) from showing up as separate rows
* in the review diff.
*/
/** Minimal shape needed here; the full ProductRow lives in components/products/types. */
type ProductRow = { mapped_data?: unknown; [k: string]: unknown };
const FEED_ATTR_KEYS = [
"specifications",
"specs",
"warranty",
"productmodel",
"product_model",
"netwidth",
"net_width",
"netheight",
"net_height",
"netdepth",
"net_depth",
"netmass",
"net_mass",
"visina",
"sirina",
"globina",
"teza",
"eprel_id",
"eprel"
] as const;
export function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
/**
* Format a product attribute value for display.
* Legacy dumps store select-like attrs as `{ key, name }` and empties as JSON null
* never show raw JSON or the literal string "null".
*/
export function formatProductAttrValue(value: unknown): string {
if (value == null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
if (Array.isArray(value)) {
return value
.map((item) => formatProductAttrValue(item))
.filter((s) => s.trim() !== "")
.join(", ");
}
const rec = asRecord(value);
if (!rec) return "";
for (const key of ["value", "name", "label", "text", "#text"]) {
const nested = formatProductAttrValue(rec[key]);
if (nested.trim() !== "") return nested;
}
return "";
}
/** Flatten an attributes blob into display rows; skips empty / junk keys. */
export function productAttrEntries(raw: unknown): { key: string; value: string }[] {
if (!raw) return [];
const out: { key: string; value: string }[] = [];
const seen = new Set<string>();
const push = (rawKey: string, rawValue: unknown) => {
const value = formatProductAttrValue(rawValue).trim();
if (!value) return;
const key = canonicalizeAttributeKey(rawKey);
if (!key) return;
const norm = key.toLowerCase();
if (seen.has(norm)) return;
seen.add(norm);
out.push({ key, value });
};
if (Array.isArray(raw)) {
for (const item of raw) {
if (item && typeof item === "object") {
const rec = item as Record<string, unknown>;
push(String(rec.key ?? rec.name ?? rec.attribute_key ?? ""), rec.value ?? rec.name ?? item);
}
}
return out;
}
const rec = asRecord(raw);
if (!rec) return [];
for (const [key, value] of Object.entries(rec)) {
if (key === "specifications" || key === "specs" || key === "eprel") continue;
push(key, value);
}
return out;
}
/** Format any mapped_data value for display (scalars + nested JSON). */
export function formatMappedFeedValue(value: unknown): string {
if (value == null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
const simple = formatProductAttrValue(value);
if (simple.trim() !== "") return simple;
if (typeof value === "object") {
try {
return JSON.stringify(value, null, 2);
} catch {
return "";
}
}
return String(value);
}
/** Locale / supplier aliases → Descrybe standard field keys (snake_case). */
export const ATTR_KEY_ALIASES: Record<string, string> = {
visina: "net_height",
height: "net_height",
netheight: "net_height",
net_height: "net_height",
sirina: "net_width",
width: "net_width",
netwidth: "net_width",
net_width: "net_width",
globina: "net_depth",
depth: "net_depth",
netdepth: "net_depth",
net_depth: "net_depth",
netmass: "net_mass",
mass: "net_mass",
weight: "net_mass",
teza: "net_mass",
net_mass: "net_mass",
productmodel: "product_model",
product_model: "product_model",
model: "product_model",
eprel_id: "eprel_id",
eprelid: "eprel_id",
eprel: "eprel_id",
energyclass: "energy_class",
energijskirazred: "energy_class",
// EPREL enrichment writes the same fact under several names (energy_class +
// eprel_energy_class, eprel_pdf + eprel_pdf_url, …). Without these the review
// table listed energy class twice and each EPREL link twice, which reads as if
// the product carried two different values.
eprelenergyclass: "energy_class",
eprelenergyscale: "energy_scale",
energyscale: "energy_scale",
eprelpdf: "eprel_pdf",
eprelpdfurl: "eprel_pdf",
eprellabel: "eprel_label",
eprellabelurl: "eprel_label",
warranty: "warranty"
};
export function compactAttributeKey(key: string): string {
return key
.trim()
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[šśş]/g, "s")
.replace(/[čćç]/g, "c")
.replace(/[žźż]/g, "z")
.replace(/đ/g, "d")
.replace(/[^a-z0-9]+/g, "");
}
/** Kebab-case slug from a human label (matches A1 attribute_key style). */
export function attributeKeyFromLabel(label: string): string {
const folded = label
.trim()
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[šśş]/g, "s")
.replace(/[čćç]/g, "c")
.replace(/[žźż]/g, "z")
.replace(/đ/g, "d");
return folded
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
export function isValidAttributeKey(key: string): boolean {
const compact = compactAttributeKey(key);
if (compact.length < 2) return false;
if (!/[a-z]/.test(compact)) return false;
switch (compact) {
case "true":
case "false":
case "yes":
case "no":
case "null":
case "undefined":
case "none":
case "n":
case "y":
return false;
default:
return true;
}
}
/** Normalize feed/UI attribute labels onto standard keys; rejects junk. */
export function canonicalizeAttributeKey(label: string): string {
const slug = attributeKeyFromLabel(label);
const compact = compactAttributeKey(slug || label);
if (!compact) return "";
const alias = ATTR_KEY_ALIASES[compact];
if (alias) return alias;
if (!slug || !isValidAttributeKey(slug)) return "";
return slug;
}
export function stripHtmlLight(value: string): string {
return value
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/(li|p|div|tr)>/gi, "\n")
.replace(/<\/>/g, "\n")
.replace(/<[^>]+>/g, " ")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.replace(/[ \t]{2,}/g, " ")
.trim();
}
export function pushFeedAttr(
out: { key: string; value: string; linked?: boolean; label?: string }[],
seen: Set<string>,
key: string,
value: string,
label?: string
) {
const formatted = value.trim();
if (!formatted) return;
const canonical = canonicalizeAttributeKey(key);
if (!canonical) return;
const norm = canonical.toLowerCase();
if (seen.has(norm)) return;
seen.add(norm);
const sourceLabel = (label || key).trim();
out.push({
key: canonical,
value: formatted,
linked: Boolean(sourceLabel && sourceLabel !== canonical),
label: sourceLabel && sourceLabel !== canonical ? sourceLabel : canonical
});
}
export function parseSpecificationPairs(raw: string): { key: string; value: string; label: string }[] {
const plain = stripHtmlLight(raw);
if (!plain) return [];
const chunks = plain
.split(/\n+|;|\|/)
.map((c) => c.trim())
.filter(Boolean);
const out: { key: string; value: string; label: string }[] = [];
for (const chunk of chunks) {
const m = chunk.match(/^([^:=]{1,120})\s*[:=]\s*(.+)$/);
if (!m) continue;
const label = m[1].trim();
const value = m[2].trim();
if (!label || !value) continue;
const key = canonicalizeAttributeKey(label);
if (!key) continue;
out.push({ key, value, label });
}
return out;
}
/** Feed-sourced attribute-like fields (specs, dimensions, eprel) for the Attributes tab. */
export function productFeedAttributeEntries(
product: ProductRow | null | undefined
): { key: string; value: string; linked?: boolean; label?: string }[] {
const mapped = asRecord(product?.mapped_data);
if (!mapped) return [];
const out: { key: string; value: string; linked?: boolean; label?: string }[] = [];
const seen = new Set<string>();
for (const key of ["specifications", "specs"] as const) {
if (!(key in mapped)) continue;
const raw = mapped[key];
if (typeof raw === "string") {
for (const pair of parseSpecificationPairs(raw)) {
pushFeedAttr(out, seen, pair.key, pair.value, pair.label);
}
continue;
}
const rec = asRecord(raw);
if (rec) {
for (const [k, v] of Object.entries(rec)) {
if (k === "_raw" && typeof v === "string") {
for (const pair of parseSpecificationPairs(v)) {
pushFeedAttr(out, seen, pair.key, pair.value, pair.label);
}
continue;
}
const formatted = formatMappedFeedValue(v);
if (!formatted.trim()) continue;
const attrKey = canonicalizeAttributeKey(k);
if (!attrKey) continue;
pushFeedAttr(out, seen, attrKey, formatted, k);
}
}
}
for (const key of FEED_ATTR_KEYS) {
if (key === "specifications" || key === "specs") continue;
if (!(key in mapped) || seen.has(key.toLowerCase())) continue;
let formatted = formatMappedFeedValue(mapped[key]);
if (/<\/?[a-z][\s\S]*>/i.test(formatted) || formatted.includes("</>")) {
formatted = stripHtmlLight(formatted);
}
if (formatted.trim() === "") continue;
const linkedKey = canonicalizeAttributeKey(key);
if (!linkedKey) continue;
pushFeedAttr(out, seen, linkedKey, formatted, key);
}
return out;
}