fix
This commit is contained in:
@@ -685,8 +685,21 @@ const (
|
|||||||
// looked unchanged. Review must compare against these instead.
|
// looked unchanged. Review must compare against these instead.
|
||||||
processedOriginalNameSQL = `COALESCE(NULLIF(p.name, ''), NULLIF(r.mapped_data->>'name', ''), NULLIF(r.mapped_data->>'title', ''), '')`
|
processedOriginalNameSQL = `COALESCE(NULLIF(p.name, ''), NULLIF(r.mapped_data->>'name', ''), NULLIF(r.mapped_data->>'title', ''), '')`
|
||||||
processedOriginalDescriptionSQL = `COALESCE(NULLIF(p.description, ''), NULLIF(r.mapped_data->>'description', ''), '')`
|
processedOriginalDescriptionSQL = `COALESCE(NULLIF(p.description, ''), NULLIF(r.mapped_data->>'description', ''), '')`
|
||||||
// Category the feed supplied (before categorize assigned one).
|
// Category the feed supplied, before categorize assigned one.
|
||||||
processedFeedCategorySQL = `COALESCE(NULLIF(BTRIM(r.mapped_data->>'category'), ''), NULLIF(BTRIM(r.mapped_data->>'category_unique_id'), ''), '')`
|
//
|
||||||
|
// mapped_data.category is NOT proof of a feed category: the pipeline writes its
|
||||||
|
// own pick back there so the Products UI and reprocess keep it
|
||||||
|
// (processing.persistMappedCategorySQL). Reading it blindly made Review show an
|
||||||
|
// AI-chosen category as the product's Original. category_source records who
|
||||||
|
// chose it — anything other than "mapped" means we determined it, not the feed.
|
||||||
|
// field_sources.category is the second signal and the one that works for rows
|
||||||
|
// processed before category_source existed: it records which step chose the
|
||||||
|
// category on the last run, so an llm/vector pick is never shown as the feed's.
|
||||||
|
processedFeedCategorySQL = `CASE
|
||||||
|
WHEN COALESCE(NULLIF(BTRIM(r.mapped_data->>'category_source'), ''), 'mapped') <> 'mapped' THEN ''
|
||||||
|
WHEN COALESCE(p.field_sources->>'category', '') IN ('llm', 'vector') THEN ''
|
||||||
|
ELSE COALESCE(NULLIF(BTRIM(r.mapped_data->>'category'), ''), NULLIF(BTRIM(r.mapped_data->>'category_unique_id'), ''), '')
|
||||||
|
END`
|
||||||
processedHasNameSQL = `(` + processedPreferredNameSQL + ` <> '')`
|
processedHasNameSQL = `(` + processedPreferredNameSQL + ` <> '')`
|
||||||
processedHasDescriptionSQL = `(` + processedPreferredDescriptionSQL + ` <> '')`
|
processedHasDescriptionSQL = `(` + processedPreferredDescriptionSQL + ` <> '')`
|
||||||
processedHasCategorySQL = `(COALESCE(NULLIF(p.category, ''), '') <> '' AND lower(p.category) <> 'none')`
|
processedHasCategorySQL = `(COALESCE(NULLIF(p.category, ''), '') <> '' AND lower(p.category) <> 'none')`
|
||||||
@@ -1284,6 +1297,8 @@ func (s *Service) GetProcessedProduct(ctx context.Context, companyID, id uuid.UU
|
|||||||
`+processedPreferredDescriptionSQL+` AS description,
|
`+processedPreferredDescriptionSQL+` AS description,
|
||||||
`+processedOriginalDescriptionSQL+` AS original_description,
|
`+processedOriginalDescriptionSQL+` AS original_description,
|
||||||
p.processed_name, p.processed_description,
|
p.processed_name, p.processed_description,
|
||||||
|
COALESCE(p.meta_title, '') AS meta_title,
|
||||||
|
COALESCE(p.meta_description, '') AS meta_description,
|
||||||
p.status, p.attributes, p.processed_attributes, r.gtin, r.mapped_data,
|
p.status, p.attributes, p.processed_attributes, r.gtin, r.mapped_data,
|
||||||
COALESCE(p.feed_id, r.feed_id) AS feed_id,
|
COALESCE(p.feed_id, r.feed_id) AS feed_id,
|
||||||
f.name AS feed_name,
|
f.name AS feed_name,
|
||||||
@@ -1304,6 +1319,7 @@ func (s *Service) GetProcessedProduct(ctx context.Context, companyID, id uuid.UU
|
|||||||
"id", "product_id", "name", "original_name", "category", "category_name", "category_unique_id",
|
"id", "product_id", "name", "original_name", "category", "category_name", "category_unique_id",
|
||||||
"feed_category",
|
"feed_category",
|
||||||
"description", "original_description", "processed_name", "processed_description",
|
"description", "original_description", "processed_name", "processed_description",
|
||||||
|
"meta_title", "meta_description",
|
||||||
"status", "attributes", "processed_attributes", "gtin", "mapped_data",
|
"status", "attributes", "processed_attributes", "gtin", "mapped_data",
|
||||||
"feed_id", "feed_name", "feed_last_synced_at", "raw_updated_at",
|
"feed_id", "feed_name", "feed_last_synced_at", "raw_updated_at",
|
||||||
"has_processed_name", "has_processed_description", "has_attributes", "has_processed_attributes",
|
"has_processed_name", "has_processed_description", "has_attributes", "has_processed_attributes",
|
||||||
|
|||||||
@@ -440,7 +440,16 @@ func (s *Server) handleGetProduct(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
stripCatalogSEOMetaIfOmitted(processing.CompanyOmitsSEOMeta(r.Context(), s.Pool, cid), item)
|
// Deliberately NOT stripped here. The omit rule hides SEO meta from list views
|
||||||
|
// for cohorts that do not sell on it, but this payload feeds the review screen
|
||||||
|
// and meta the pipeline actually generated has to be reviewable.
|
||||||
|
//
|
||||||
|
// The cohort flag still travels, so the panel can say "disabled for this
|
||||||
|
// company" instead of "the AI generated nothing" — without it an empty block is
|
||||||
|
// unexplained, which is exactly what made SEO meta look missing.
|
||||||
|
if item != nil && processing.CompanyOmitsSEOMeta(r.Context(), s.Pool, cid) {
|
||||||
|
item["seo_meta_omitted"] = true
|
||||||
|
}
|
||||||
JSON(w, http.StatusOK, item)
|
JSON(w, http.StatusOK, item)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,6 +476,12 @@ func (s *Server) handleUpdateProduct(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// stripCatalogSEOMetaIfOmitted drops meta_title / meta_description from dashboard
|
// stripCatalogSEOMetaIfOmitted drops meta_title / meta_description from dashboard
|
||||||
// product payloads when omit is true (same detection as V1 process / CompanyOmitsSEOMeta).
|
// product payloads when omit is true (same detection as V1 process / CompanyOmitsSEOMeta).
|
||||||
|
//
|
||||||
|
// Only EMPTY values are dropped. The omit cohort does not generate SEO meta, but
|
||||||
|
// rows enriched before that rule (or by an earlier pipeline) still carry it, and
|
||||||
|
// hiding stored copy from the dashboard meant Review could not show what the AI had
|
||||||
|
// actually produced. Generation still respects the cohort — this only stops the
|
||||||
|
// read path from concealing data that exists.
|
||||||
func stripCatalogSEOMetaIfOmitted(omit bool, items ...map[string]any) {
|
func stripCatalogSEOMetaIfOmitted(omit bool, items ...map[string]any) {
|
||||||
if !omit {
|
if !omit {
|
||||||
return
|
return
|
||||||
@@ -475,14 +490,15 @@ func stripCatalogSEOMetaIfOmitted(omit bool, items ...map[string]any) {
|
|||||||
if item == nil {
|
if item == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
delete(item, "meta_title")
|
// Tell the client the cohort does not generate SEO meta, so an empty block
|
||||||
delete(item, "meta_description")
|
// reads as "disabled here" instead of "the AI produced nothing".
|
||||||
if loc, ok := item["localized_content"].(company.LocalizedContent); ok {
|
item["seo_meta_omitted"] = true
|
||||||
for lang, fields := range loc {
|
if asMapString(item["meta_title"]) == "" {
|
||||||
fields.MetaTitle = ""
|
delete(item, "meta_title")
|
||||||
fields.MetaDescription = ""
|
|
||||||
loc[lang] = fields
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if asMapString(item["meta_description"]) == "" {
|
||||||
|
delete(item, "meta_description")
|
||||||
|
}
|
||||||
|
// Localized meta is left exactly as stored, for the same reason.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,23 +19,53 @@ func catalogSEOItem() map[string]any {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The omit cohort does not GENERATE SEO meta, but rows enriched before that rule
|
||||||
|
// still carry it. Hiding stored copy meant Review could not show what the AI had
|
||||||
|
// actually produced, so the read path now keeps non-empty values and only flags
|
||||||
|
// that the cohort is opted out. Empty values are still dropped.
|
||||||
func assertCatalogSEOOmitted(t *testing.T, item map[string]any) {
|
func assertCatalogSEOOmitted(t *testing.T, item map[string]any) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if _, ok := item["meta_title"]; ok {
|
if item["seo_meta_omitted"] != true {
|
||||||
t.Fatalf("must omit meta_title: %v", item)
|
t.Fatalf("omit cohort must be flagged so an empty block explains itself: %v", item)
|
||||||
}
|
}
|
||||||
if _, ok := item["meta_description"]; ok {
|
if item["meta_title"] != "T" || item["meta_description"] != "D" {
|
||||||
t.Fatalf("must omit meta_description: %v", item)
|
t.Fatalf("stored meta must stay readable for Review: %v", item)
|
||||||
}
|
}
|
||||||
loc := item["localized_content"].(company.LocalizedContent)
|
loc := item["localized_content"].(company.LocalizedContent)
|
||||||
if loc["sl"].MetaTitle != "" || loc["sl"].MetaDescription != "" {
|
|
||||||
t.Fatalf("localized meta leaked: %#v", loc["sl"])
|
|
||||||
}
|
|
||||||
if loc["sl"].ProcessedName != "N" {
|
if loc["sl"].ProcessedName != "N" {
|
||||||
t.Fatalf("stripped too much: %#v", loc["sl"])
|
t.Fatalf("stripped too much: %#v", loc["sl"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Empty meta on an omit-cohort product is dropped, leaving only the flag — the UI
|
||||||
|
// then says "disabled for this company" instead of showing two blank boxes.
|
||||||
|
func TestStripCatalogSEOMetaIfOmitted_dropsEmptyValues(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
other := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||||
|
item := map[string]any{"name": "Widget", "meta_title": "", "meta_description": ""}
|
||||||
|
stripCatalogSEOMetaIfOmitted(processing.CompanyOmitsSEOMetaLookup(other, "", "Platform Demo"), item)
|
||||||
|
if _, ok := item["meta_title"]; ok {
|
||||||
|
t.Fatalf("empty meta_title should be dropped: %v", item)
|
||||||
|
}
|
||||||
|
if item["seo_meta_omitted"] != true {
|
||||||
|
t.Fatalf("omit flag missing: %v", item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An ordinary tenant is never flagged and never touched.
|
||||||
|
func TestStripCatalogSEOMetaIfOmitted_ordinaryTenantUnflagged(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
other := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||||
|
item := catalogSEOItem()
|
||||||
|
stripCatalogSEOMetaIfOmitted(processing.CompanyOmitsSEOMetaLookup(other, "", "Acme"), item)
|
||||||
|
if _, ok := item["seo_meta_omitted"]; ok {
|
||||||
|
t.Fatalf("ordinary company must not be flagged: %v", item)
|
||||||
|
}
|
||||||
|
if item["meta_title"] != "T" {
|
||||||
|
t.Fatalf("ordinary company must keep meta: %v", item["meta_title"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStripCatalogSEOMetaIfOmitted(t *testing.T) {
|
func TestStripCatalogSEOMetaIfOmitted(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
a1 := uuid.MustParse("604f23a8-b66e-4b21-8b45-0d72b68f4790")
|
a1 := uuid.MustParse("604f23a8-b66e-4b21-8b45-0d72b68f4790")
|
||||||
|
|||||||
@@ -358,14 +358,28 @@ func runCategorizeStep(ctx context.Context, e *Engine, companyID string, out *St
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MappedCategorySourceKey marks a mapped_data.category this pipeline wrote itself.
|
||||||
|
//
|
||||||
|
// Without it the write is indistinguishable from a feed-supplied category on the
|
||||||
|
// next run, so Review showed an AI-chosen category as the product's "Original" and
|
||||||
|
// every product looked already-categorised. $4 carries the step that chose it
|
||||||
|
// (mapped / vector / llm); only "mapped" means the feed actually supplied one.
|
||||||
|
const MappedCategorySourceKey = "category_source"
|
||||||
|
|
||||||
// persistMappedCategorySQL writes a taxonomy unique_id onto mapped_data.category
|
// persistMappedCategorySQL writes a taxonomy unique_id onto mapped_data.category
|
||||||
// when the mapped value is still empty (so Products UI + reprocess stick).
|
// when the mapped value is still empty (so Products UI + reprocess stick), and
|
||||||
|
// records who chose it alongside.
|
||||||
const persistMappedCategorySQL = `
|
const persistMappedCategorySQL = `
|
||||||
UPDATE raw_products
|
UPDATE raw_products
|
||||||
SET mapped_data = jsonb_set(
|
SET mapped_data = jsonb_set(
|
||||||
COALESCE(mapped_data, '{}'::jsonb),
|
jsonb_set(
|
||||||
'{category}',
|
COALESCE(mapped_data, '{}'::jsonb),
|
||||||
to_jsonb($3::text),
|
'{category}',
|
||||||
|
to_jsonb($3::text),
|
||||||
|
true
|
||||||
|
),
|
||||||
|
'{category_source}',
|
||||||
|
to_jsonb($4::text),
|
||||||
true
|
true
|
||||||
),
|
),
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
|
|||||||
@@ -1701,7 +1701,13 @@ func (p *Pipeline) processOne(ctx context.Context, companyID, jobID uuid.UUID, i
|
|||||||
// Stick taxonomy unique_id onto mapped_data.category when still empty so the
|
// Stick taxonomy unique_id onto mapped_data.category when still empty so the
|
||||||
// Products UI (reads mapped) and reprocess keep the AI/vector/mapped pick.
|
// Products UI (reads mapped) and reprocess keep the AI/vector/mapped pick.
|
||||||
if cat := strings.TrimSpace(result.Category); cat != "" {
|
if cat := strings.TrimSpace(result.Category); cat != "" {
|
||||||
if _, err := tx.Exec(ctx, persistMappedCategorySQL, it.RawID, companyID, cat); err != nil {
|
// Record who chose it: a category this pipeline derived must not read back
|
||||||
|
// as the feed's own on the next run (see MappedCategorySourceKey).
|
||||||
|
catSource, _ := result.FieldSources["category"].(string)
|
||||||
|
if strings.TrimSpace(catSource) == "" {
|
||||||
|
catSource = "pipeline"
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx, persistMappedCategorySQL, it.RawID, companyID, cat, catSource); err != nil {
|
||||||
return false, 0, result, fmt.Errorf("persist mapped category: %w", err)
|
return false, 0, result, fmt.Errorf("persist mapped category: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
productEnrichmentTitle,
|
||||||
productEprelLabel,
|
productEprelLabel,
|
||||||
productFeedAttributeEntries,
|
productFeedAttributeEntries,
|
||||||
|
productAttributeDiff,
|
||||||
productFeedFieldSummary,
|
productFeedFieldSummary,
|
||||||
productFeedMappedEntries,
|
productFeedMappedEntries,
|
||||||
productFeedSyncLabel,
|
productFeedSyncLabel,
|
||||||
@@ -430,27 +431,56 @@
|
|||||||
assignedCategoryLabel.trim().toLowerCase() !== feedCategoryLabel.trim().toLowerCase()
|
assignedCategoryLabel.trim().toLowerCase() !== feedCategoryLabel.trim().toLowerCase()
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Per-key attribute diff: what enrichment added or rewrote versus the feed. */
|
/** Feed-vs-enriched attribute diff, alias-collapsed (see buildAttributeDiff). */
|
||||||
const attrDiff = $derived.by(() => {
|
const attrDiff = $derived.by(() => productAttributeDiff(product));
|
||||||
const before = new Map(
|
|
||||||
productAttrEntries(product?.attributes).map((a) => [a.key.toLowerCase(), a])
|
const attrsChanged = $derived(
|
||||||
);
|
kind === "processed" && (attrDiff.added.length > 0 || attrDiff.changed.length > 0)
|
||||||
const after = new Map(
|
);
|
||||||
productAttrEntries(product?.processed_attributes).map((a) => [a.key.toLowerCase(), a])
|
/** Unchanged attributes are noise in a review; keep them one click away. */
|
||||||
);
|
let showUnchangedAttrs = $state(false);
|
||||||
const keys = [...new Set([...before.keys(), ...after.keys()])].sort();
|
// Nothing in the feed to compare against is "Added", not "Changed" — saying
|
||||||
const rows = keys.map((k) => {
|
// Matched (or Changed) about a field the supplier never sent reads as if the
|
||||||
const b = before.get(k);
|
// feed had one.
|
||||||
const a = after.get(k);
|
const categoryAdded = $derived(kind === "processed" && !feedCategoryLabel && !!assignedCategoryLabel);
|
||||||
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 };
|
/** SEO meta the feed supplied, when it had any. */
|
||||||
});
|
function feedMetaValue(...keys: string[]): string {
|
||||||
return {
|
const mapped = product?.mapped_data as Record<string, unknown> | null | undefined;
|
||||||
rows,
|
if (!mapped || typeof mapped !== "object") return "";
|
||||||
changed: rows.filter((r) => r.state !== "same")
|
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 feedAttrs = $derived.by(() => {
|
||||||
const rows = productFeedAttributeEntries(product);
|
const rows = productFeedAttributeEntries(product);
|
||||||
@@ -796,7 +826,9 @@
|
|||||||
>
|
>
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<Label for="review-category">{i18n.t("products.enrichment.piece.category")}</Label>
|
<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>
|
<Badge variant="outline" class="text-[10px]">{i18n.t("products.edit.changed")}</Badge>
|
||||||
{:else}
|
{:else}
|
||||||
<Badge variant="secondary" class="text-[10px]">{i18n.t("products.edit.matched")}</Badge>
|
<Badge variant="secondary" class="text-[10px]">{i18n.t("products.edit.matched")}</Badge>
|
||||||
@@ -808,7 +840,9 @@
|
|||||||
{i18n.t("products.edit.original")}
|
{i18n.t("products.edit.original")}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<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")}
|
{feedCategoryLabel || i18n.t("products.edit.noCategoryFromFeed")}
|
||||||
</div>
|
</div>
|
||||||
@@ -837,46 +871,155 @@
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<div
|
||||||
class="space-y-2 rounded-md border border-transparent p-1 {attrsChanged
|
class="space-y-2 rounded-md border border-transparent p-1 {attrsChanged
|
||||||
? 'border-chart-amber/40 bg-chart-amber/10'
|
? '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>
|
<Label>{i18n.t("products.enrichment.piece.attributes")}</Label>
|
||||||
{#if attrsChanged}
|
<div class="flex items-center gap-1">
|
||||||
<Badge variant="outline" class="text-[10px]">
|
{#if attrDiff.added.length > 0}
|
||||||
{i18n.t("products.edit.attrsChangedCount", { count: attrDiff.changed.length })}
|
<Badge variant="outline" class="text-[10px]">
|
||||||
</Badge>
|
{i18n.t("products.edit.attrsAddedCount", { count: attrDiff.added.length })}
|
||||||
{:else}
|
</Badge>
|
||||||
<Badge variant="secondary" class="text-[10px]">{i18n.t("products.edit.matched")}</Badge>
|
{/if}
|
||||||
{/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>
|
</div>
|
||||||
{#if attrDiff.rows.length === 0}
|
{#if attrDiff.rows.length === 0}
|
||||||
<p class="text-xs text-muted-foreground">{i18n.t("products.edit.noAttributes")}</p>
|
<p class="text-xs text-muted-foreground">{i18n.t("products.edit.noAttributes")}</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="overflow-x-auto rounded-md border border-border">
|
{@const visible = showUnchangedAttrs
|
||||||
<table class="w-full text-xs">
|
? attrDiff.rows
|
||||||
<thead class="bg-muted/40 text-muted-foreground">
|
: [...attrDiff.added, ...attrDiff.changed]}
|
||||||
<tr>
|
{#if visible.length === 0}
|
||||||
<th class="px-2 py-1.5 text-left font-medium">{i18n.t("products.edit.attrKey")}</th>
|
<p class="text-xs text-muted-foreground">
|
||||||
<th class="px-2 py-1.5 text-left font-medium">{i18n.t("products.edit.original")}</th>
|
{i18n.t("products.edit.attrsAllUnchanged", { count: attrDiff.same.length })}
|
||||||
<th class="px-2 py-1.5 text-left font-medium">{i18n.t("products.edit.enriched")}</th>
|
</p>
|
||||||
</tr>
|
{:else}
|
||||||
</thead>
|
<div class="overflow-x-auto rounded-md border border-border">
|
||||||
<tbody>
|
<table class="w-full text-xs">
|
||||||
{#each attrDiff.rows as row (row.key)}
|
<thead class="bg-muted/40 text-muted-foreground">
|
||||||
<tr class="border-t border-border {row.state === 'same' ? '' : 'bg-chart-amber/5'}">
|
<tr>
|
||||||
<td class="px-2 py-1.5 font-mono">{row.key}</td>
|
<th class="px-2 py-1.5 text-left font-medium">{i18n.t("products.edit.attrKey")}</th>
|
||||||
<td class="px-2 py-1.5 text-muted-foreground">{row.before || "—"}</td>
|
<th class="px-2 py-1.5 text-left font-medium">{i18n.t("products.edit.original")}</th>
|
||||||
<td class="px-2 py-1.5 {row.state === 'same' ? 'text-muted-foreground' : 'font-medium text-foreground'}">
|
<th class="px-2 py-1.5 text-left font-medium">{i18n.t("products.edit.enriched")}</th>
|
||||||
{row.after || "—"}
|
<th class="px-2 py-1.5 text-right font-medium">{i18n.t("products.edit.attrState")}</th>
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
{/each}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{#each visible as row (row.key)}
|
||||||
</div>
|
<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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
@@ -1113,6 +1256,19 @@
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="content" class="mt-4 space-y-4">
|
<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="space-y-2">
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<Label for="edit-processed-desc">{i18n.t("products.edit.aiProcessedDescription")}</Label>
|
<Label for="edit-processed-desc">{i18n.t("products.edit.aiProcessedDescription")}</Label>
|
||||||
@@ -1170,6 +1326,36 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<TabsContent value="attributes" class="mt-4 space-y-5">
|
<TabsContent value="attributes" class="mt-4 space-y-5">
|
||||||
|
|||||||
@@ -1,4 +1,28 @@
|
|||||||
import { i18n } from "$lib/i18n";
|
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 {
|
export type {
|
||||||
ProductListURLFilters,
|
ProductListURLFilters,
|
||||||
@@ -72,6 +96,12 @@ export type ProductRow = {
|
|||||||
processed_description?: string | null;
|
processed_description?: string | null;
|
||||||
meta_title?: string | null;
|
meta_title?: string | null;
|
||||||
meta_description?: 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<
|
localized_content?: Record<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
@@ -455,67 +485,8 @@ export function formatRelativeUpdated(value?: string | Date | null): string {
|
|||||||
return rtf.format(-Math.round(months / 12), "year");
|
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). */
|
/** Original feed description when processed_products.description was never filled (legacy). */
|
||||||
export function resolveOriginalDescription(product: ProductRow | null | undefined): string {
|
export function resolveOriginalDescription(product: ProductRow | null | undefined): string {
|
||||||
@@ -573,22 +544,6 @@ export function productFeedFieldSummary(
|
|||||||
return productFeedMappedEntries(product, options).filter((e) => e.preferred);
|
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 = {
|
export type FeedMappedEntry = {
|
||||||
key: string;
|
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(
|
function digAttrBag(
|
||||||
product: ProductRow | null | undefined,
|
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()}`;
|
if (energy != null && String(energy).trim()) return `EPREL ${String(energy).trim()}`;
|
||||||
return "EPREL";
|
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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -5772,4 +5772,27 @@ export const de: MessageDict = {
|
|||||||
"admin.aiCosts.colOut": "Ausgabe",
|
"admin.aiCosts.colOut": "Ausgabe",
|
||||||
"admin.aiCosts.colCost": "Kosten",
|
"admin.aiCosts.colCost": "Kosten",
|
||||||
"admin.aiCosts.unattributed": "Nicht zugeordnet",
|
"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.",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5858,4 +5858,27 @@ export const en: MessageDict = {
|
|||||||
"admin.aiCosts.colOut": "Output",
|
"admin.aiCosts.colOut": "Output",
|
||||||
"admin.aiCosts.colCost": "Cost",
|
"admin.aiCosts.colCost": "Cost",
|
||||||
"admin.aiCosts.unattributed": "Unattributed",
|
"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.",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5781,4 +5781,27 @@ export const es: MessageDict = {
|
|||||||
"admin.aiCosts.colOut": "Salida",
|
"admin.aiCosts.colOut": "Salida",
|
||||||
"admin.aiCosts.colCost": "Coste",
|
"admin.aiCosts.colCost": "Coste",
|
||||||
"admin.aiCosts.unattributed": "Sin asignar",
|
"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.",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5781,4 +5781,27 @@ export const fr: MessageDict = {
|
|||||||
"admin.aiCosts.colOut": "Sortie",
|
"admin.aiCosts.colOut": "Sortie",
|
||||||
"admin.aiCosts.colCost": "Cout",
|
"admin.aiCosts.colCost": "Cout",
|
||||||
"admin.aiCosts.unattributed": "Non attribue",
|
"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.",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5781,4 +5781,27 @@ export const it: MessageDict = {
|
|||||||
"admin.aiCosts.colOut": "Output",
|
"admin.aiCosts.colOut": "Output",
|
||||||
"admin.aiCosts.colCost": "Costo",
|
"admin.aiCosts.colCost": "Costo",
|
||||||
"admin.aiCosts.unattributed": "Non attribuito",
|
"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.",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5781,4 +5781,27 @@ export const ja: MessageDict = {
|
|||||||
"admin.aiCosts.colOut": "出力",
|
"admin.aiCosts.colOut": "出力",
|
||||||
"admin.aiCosts.colCost": "コスト",
|
"admin.aiCosts.colCost": "コスト",
|
||||||
"admin.aiCosts.unattributed": "未割り当て",
|
"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 メタが無効なため生成されません。",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5781,4 +5781,27 @@ export const nl: MessageDict = {
|
|||||||
"admin.aiCosts.colOut": "Uitvoer",
|
"admin.aiCosts.colOut": "Uitvoer",
|
||||||
"admin.aiCosts.colCost": "Kosten",
|
"admin.aiCosts.colCost": "Kosten",
|
||||||
"admin.aiCosts.unattributed": "Niet toegewezen",
|
"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.",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5781,4 +5781,27 @@ export const pl: MessageDict = {
|
|||||||
"admin.aiCosts.colOut": "Wyjscie",
|
"admin.aiCosts.colOut": "Wyjscie",
|
||||||
"admin.aiCosts.colCost": "Koszt",
|
"admin.aiCosts.colCost": "Koszt",
|
||||||
"admin.aiCosts.unattributed": "Nieprzypisane",
|
"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.",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5781,4 +5781,27 @@ export const pt: MessageDict = {
|
|||||||
"admin.aiCosts.colOut": "Saida",
|
"admin.aiCosts.colOut": "Saida",
|
||||||
"admin.aiCosts.colCost": "Custo",
|
"admin.aiCosts.colCost": "Custo",
|
||||||
"admin.aiCosts.unattributed": "Nao atribuido",
|
"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.",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -92,4 +92,27 @@ export const sl: MessageDict = {
|
|||||||
"admin.aiCosts.colOut": "Izhod",
|
"admin.aiCosts.colOut": "Izhod",
|
||||||
"admin.aiCosts.colCost": "Strosek",
|
"admin.aiCosts.colCost": "Strosek",
|
||||||
"admin.aiCosts.unattributed": "Nedodeljeno",
|
"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: "" } }), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -78,6 +78,47 @@ go run ./cmd/formula-e2e -new-tenant -llm-base http://127.0.0.1:18767/v1
|
|||||||
`-llm-base` pins the provider for the run; without it the harness uses whatever
|
`-llm-base` pins the provider for the run; without it the harness uses whatever
|
||||||
`platformsettings.ResolveOpenAI` returns (admin DB setting first, then env).
|
`platformsettings.ResolveOpenAI` returns (admin DB setting first, then env).
|
||||||
|
|
||||||
|
## What Review compares
|
||||||
|
|
||||||
|
Review shows Original vs Enriched for name, description, category, attributes and
|
||||||
|
SEO meta. Each "Original" must come from a source the pipeline never writes, which
|
||||||
|
is less obvious than it sounds:
|
||||||
|
|
||||||
|
| field | Original comes from | trap |
|
||||||
|
|---|---|---|
|
||||||
|
| name / description | `original_name` / `original_description` | `name`/`description` from the API are display-preferred and resolve to the ENRICHED copy |
|
||||||
|
| category | `feed_category` | `mapped_data.category` is written back by the pipeline; `category_source` says who chose it |
|
||||||
|
| attributes | `mapped_data.specifications` (feed specs) | `processed_products.attributes` is written to the same value as `processed_attributes` |
|
||||||
|
| | | EPREL writes one fact under several names — `product-attributes.ts` folds the aliases so it is listed once |
|
||||||
|
| SEO meta | `mapped_data.meta_*` | most feeds have none, so Enriched shows as **Added** |
|
||||||
|
|
||||||
|
Attributes are grouped **Added / Changed / Unchanged**, with unchanged collapsed —
|
||||||
|
a review should lead with what the run actually did. Alias keys are folded into one
|
||||||
|
row: enrichment stores `eprel_pdf` and `eprel_pdf_url`, `energy_class` and
|
||||||
|
`eprel_energy_class`, which otherwise listed the same fact two or three times.
|
||||||
|
Folding needs a related name AND an identical value, so width and height are never
|
||||||
|
merged just because they match (`$lib/attribute-diff`, unit-tested).
|
||||||
|
|
||||||
|
The product detail endpoint deliberately does **not** apply the SEO-meta omit rule.
|
||||||
|
That rule keeps meta out of list views for cohorts that do not sell on it, but the
|
||||||
|
detail payload feeds review, and meta the pipeline actually generated has to be
|
||||||
|
reviewable — the panel hides the block itself when both sides are empty.
|
||||||
|
|
||||||
|
Anything with no Original is labelled **Added** and shown as "not in feed", not
|
||||||
|
"Matched" — saying Matched about a field the supplier never sent reads as if the
|
||||||
|
feed had one.
|
||||||
|
|
||||||
|
Two things behave this way by design, not by accident:
|
||||||
|
|
||||||
|
- **A1 Slovenija and Platform Demo do not GENERATE SEO meta**
|
||||||
|
(`processing.CompanyOmitsSEOMetaID`). Meta already stored on their rows is still
|
||||||
|
shown — the read path only hides empty values, so Review and Content display what
|
||||||
|
the AI actually produced.
|
||||||
|
- **Rows processed before `category_source` existed** cannot say whether their
|
||||||
|
`mapped_data.category` came from the feed or from an earlier AI run, so Review
|
||||||
|
still treats it as feed-supplied. Reprocessing the product records the real
|
||||||
|
provenance.
|
||||||
|
|
||||||
## Low-confidence categorisation fails the product
|
## Low-confidence categorisation fails the product
|
||||||
|
|
||||||
The categorize model always answers with *some* id from the list, so a reply like
|
The categorize model always answers with *some* id from the list, so a reply like
|
||||||
|
|||||||
Reference in New Issue
Block a user