fix(review): slug-scoped tags, decode names, drop fig picker & AI labels
- Taxonomy sync keeps fig-/feat- by slug (hyphen), not name; HTML-unescape names - Review: remove AI assist labels; Features picker for feat-*; derive fig_tag_ids from Figure term names (casefold match, no bridges); decodeName on display - Unit tests for scoped_tags, fig match, Review UI contract; docs slug wording
This commit is contained in:
+25
-7
@@ -1,11 +1,14 @@
|
|||||||
"""Taxonomy sync: pull Woo categories/tags/attributes/terms into the local cache.
|
"""Taxonomy sync: pull Woo categories/tags/attributes/terms into the local cache.
|
||||||
|
|
||||||
Rebuild happens in one transaction; on any failure the prior cache is kept.
|
Rebuild happens in one transaction; on any failure the prior cache is kept.
|
||||||
Only fig-* / feat-* tag families are retained (METADATA-15). taxonomy_anchor is a
|
Only fig-* / feat-* tag families are retained by **slug** prefix with hyphen
|
||||||
stable hash of the stored subset, recorded only when it changes.
|
(SETTINGS-51 / METADATA-15). taxonomy_anchor is a stable hash of the stored
|
||||||
|
subset, recorded only when it changes. Display names are HTML-unescaped so
|
||||||
|
Woo entities like ``&`` store as ``&``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import html
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
@@ -16,8 +19,23 @@ def _now_iso() -> str:
|
|||||||
return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat()
|
return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_name(name: str | None) -> str:
|
||||||
|
"""Decode HTML entities Woo puts in taxonomy display names."""
|
||||||
|
return html.unescape(name or "")
|
||||||
|
|
||||||
|
|
||||||
def _scoped_tags(tags: list[dict]) -> list[dict]:
|
def _scoped_tags(tags: list[dict]) -> list[dict]:
|
||||||
return [t for t in tags if t.get("name", "").startswith(("fig-", "feat-"))]
|
"""Keep product tags whose slug starts with fig- or feat- (hyphen required).
|
||||||
|
|
||||||
|
Live Woo uses human display names (e.g. "Curl") with slugs like fig-curl.
|
||||||
|
Filtering by name prefix kept 0 live tags; slug is authoritative.
|
||||||
|
"""
|
||||||
|
out = []
|
||||||
|
for t in tags:
|
||||||
|
slug = t.get("slug") or ""
|
||||||
|
if slug.startswith("fig-") or slug.startswith("feat-"):
|
||||||
|
out.append(t)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _normalize_category(c: dict) -> tuple:
|
def _normalize_category(c: dict) -> tuple:
|
||||||
@@ -34,7 +52,7 @@ def _normalize_category(c: dict) -> tuple:
|
|||||||
woo_id = c.get("woo_id", c.get("id"))
|
woo_id = c.get("woo_id", c.get("id"))
|
||||||
if woo_id is not None:
|
if woo_id is not None:
|
||||||
woo_id = int(woo_id)
|
woo_id = int(woo_id)
|
||||||
return (c["id"], c["name"], c["slug"], parent_id, woo_id)
|
return (c["id"], _decode_name(c["name"]), c["slug"], parent_id, woo_id)
|
||||||
|
|
||||||
|
|
||||||
def _subset_hash(categories: list[dict], attributes: list[dict],
|
def _subset_hash(categories: list[dict], attributes: list[dict],
|
||||||
@@ -90,16 +108,16 @@ def sync(client: WooClient, conn) -> dict:
|
|||||||
|
|
||||||
conn.executemany(
|
conn.executemany(
|
||||||
"INSERT INTO woo_attributes(id, name, slug, woo_id) VALUES (?,?,?,?)",
|
"INSERT INTO woo_attributes(id, name, slug, woo_id) VALUES (?,?,?,?)",
|
||||||
[(a["id"], a["name"], a["slug"], a.get("woo_id")) for a in attributes],
|
[(a["id"], _decode_name(a["name"]), a["slug"], a.get("woo_id")) for a in attributes],
|
||||||
)
|
)
|
||||||
for attr_id, terms in terms_by_attr.items():
|
for attr_id, terms in terms_by_attr.items():
|
||||||
conn.executemany(
|
conn.executemany(
|
||||||
"INSERT INTO woo_attribute_terms(id, attribute_id, name, slug, woo_id) VALUES (?,?,?,?,?)",
|
"INSERT INTO woo_attribute_terms(id, attribute_id, name, slug, woo_id) VALUES (?,?,?,?,?)",
|
||||||
[(t["id"], attr_id, t["name"], t["slug"], t.get("woo_id")) for t in terms],
|
[(t["id"], attr_id, _decode_name(t["name"]), t["slug"], t.get("woo_id")) for t in terms],
|
||||||
)
|
)
|
||||||
conn.executemany(
|
conn.executemany(
|
||||||
"INSERT INTO woo_tags(id, name, slug, woo_id) VALUES (?,?,?,?)",
|
"INSERT INTO woo_tags(id, name, slug, woo_id) VALUES (?,?,?,?)",
|
||||||
[(t["id"], t["name"], t["slug"], t.get("woo_id")) for t in scoped_tags],
|
[(t["id"], _decode_name(t["name"]), t["slug"], t.get("woo_id")) for t in scoped_tags],
|
||||||
)
|
)
|
||||||
|
|
||||||
anchor = _subset_hash(categories, attributes, terms_by_attr, scoped_tags)
|
anchor = _subset_hash(categories, attributes, terms_by_attr, scoped_tags)
|
||||||
|
|||||||
@@ -95,12 +95,29 @@ def test_401_raises_auth_error(db_dir):
|
|||||||
c.get_categories()
|
c.get_categories()
|
||||||
|
|
||||||
|
|
||||||
|
def test_scoped_tags_by_slug_not_name():
|
||||||
|
"""SETTINGS-51: keep fig-/feat- by slug prefix with hyphen; human names stay."""
|
||||||
|
tags = [
|
||||||
|
{"id": 1, "name": "Curl", "slug": "fig-curl"},
|
||||||
|
{"id": 2, "name": "Knot", "slug": "feat-knot"},
|
||||||
|
{"id": 3, "name": "fig-looks-like", "slug": "other"}, # name prefix alone — drop
|
||||||
|
{"id": 4, "name": "Figured", "slug": "figured"}, # no hyphen — drop
|
||||||
|
{"id": 5, "name": "other", "slug": "other-tag"},
|
||||||
|
]
|
||||||
|
kept = taxonomy_mod._scoped_tags(tags)
|
||||||
|
assert {t["id"] for t in kept} == {1, 2}
|
||||||
|
|
||||||
|
|
||||||
def test_sync_populates_scoped(db_dir):
|
def test_sync_populates_scoped(db_dir):
|
||||||
cats = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 1, "name": "Cedar", "slug": "cedar", "parent_id": None, "woo_id": 10}]).encode())
|
# Live Woo: human names + fig-/feat- slugs. Name-prefix filter would keep 0.
|
||||||
|
cats = (200, {"X-WP-TotalPages": "1"}, json.dumps([
|
||||||
|
{"id": 1, "name": "Cookies & Burl Slices", "slug": "cookies-burl", "parent_id": None, "woo_id": 10},
|
||||||
|
]).encode())
|
||||||
tags = (200, {"X-WP-TotalPages": "1"}, json.dumps([
|
tags = (200, {"X-WP-TotalPages": "1"}, json.dumps([
|
||||||
{"id": 2, "name": "fig-curl", "slug": "fig-curl", "woo_id": 20},
|
{"id": 2, "name": "Curl", "slug": "fig-curl", "woo_id": 20},
|
||||||
{"id": 3, "name": "feat-knot", "slug": "feat-knot", "woo_id": 21},
|
{"id": 3, "name": "Knot", "slug": "feat-knot", "woo_id": 21},
|
||||||
{"id": 4, "name": "other-tag", "slug": "other-tag", "woo_id": 22}, # must be dropped
|
{"id": 4, "name": "fig-fake", "slug": "other-tag", "woo_id": 22}, # name looks scoped; slug not — drop
|
||||||
|
{"id": 5, "name": "other-tag", "slug": "other-tag-2", "woo_id": 23},
|
||||||
]).encode())
|
]).encode())
|
||||||
attrs = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 5, "name": "Edge Type", "slug": "edge", "woo_id": 30}]).encode())
|
attrs = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 5, "name": "Edge Type", "slug": "edge", "woo_id": 30}]).encode())
|
||||||
terms = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 6, "name": "Flat", "slug": "flat", "woo_id": 40}]).encode())
|
terms = (200, {"X-WP-TotalPages": "1"}, json.dumps([{"id": 6, "name": "Flat", "slug": "flat", "woo_id": 40}]).encode())
|
||||||
@@ -108,8 +125,11 @@ def test_sync_populates_scoped(db_dir):
|
|||||||
result = taxonomy_mod.sync(c, db_mod.get_connection())
|
result = taxonomy_mod.sync(c, db_mod.get_connection())
|
||||||
conn = db_mod.get_connection()
|
conn = db_mod.get_connection()
|
||||||
assert [r[0] for r in conn.execute("SELECT id FROM woo_categories")] == [1]
|
assert [r[0] for r in conn.execute("SELECT id FROM woo_categories")] == [1]
|
||||||
tag_ids = {r[0] for r in conn.execute("SELECT id FROM woo_tags")}
|
assert conn.execute("SELECT name FROM woo_categories WHERE id=1").fetchone()[0] == "Cookies & Burl Slices"
|
||||||
assert tag_ids == {2, 3} # fig- + feat- kept, other-tag dropped
|
tag_rows = {r[0]: (r[1], r[2]) for r in conn.execute("SELECT id, name, slug FROM woo_tags")}
|
||||||
|
assert set(tag_rows) == {2, 3} # fig- + feat- by slug; name-only fig-fake dropped
|
||||||
|
assert tag_rows[2] == ("Curl", "fig-curl")
|
||||||
|
assert tag_rows[3] == ("Knot", "feat-knot")
|
||||||
assert [r[0] for r in conn.execute("SELECT id FROM woo_attribute_terms")] == [6]
|
assert [r[0] for r in conn.execute("SELECT id FROM woo_attribute_terms")] == [6]
|
||||||
assert result["taxonomy_anchor"]
|
assert result["taxonomy_anchor"]
|
||||||
assert conn.execute("SELECT value FROM settings WHERE key='taxonomy_last_sync_at'").fetchone()[0]
|
assert conn.execute("SELECT value FROM settings WHERE key='taxonomy_last_sync_at'").fetchone()[0]
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ The listing is built in two inference calls, both optional. Call 1 (vision) read
|
|||||||
| SETTINGS-40 | test-woo verifies the connection and always resyncs taxonomy on success. | M3 | — |
|
| SETTINGS-40 | test-woo verifies the connection and always resyncs taxonomy on success. | M3 | — |
|
||||||
| SETTINGS-45 | test-inference is a two-turn probe returning ok, mode (stateful, stateless, or fail), vision_capable, and detail; fail blocks Call 2 only. | M3 | — |
|
| SETTINGS-45 | test-inference is a two-turn probe returning ok, mode (stateful, stateless, or fail), vision_capable, and detail; fail blocks Call 2 only. | M3 | — |
|
||||||
| SETTINGS-50 | Taxonomy sync pulls categories, tags, and attributes with terms, rebuilds the cache in one transaction, and on failure returns 502 while keeping the previous cache. | M3 | — |
|
| SETTINGS-50 | Taxonomy sync pulls categories, tags, and attributes with terms, rebuilds the cache in one transaction, and on failure returns 502 while keeping the previous cache. | M3 | — |
|
||||||
| SETTINGS-51 | Taxonomy sync keeps only the store's figure and feature product-tag families; all other store product tags are ignored so non-wood product metadata does not enter the workflow. | M3 | — |
|
| SETTINGS-51 | Taxonomy sync keeps only product tags whose slug starts with fig- or feat- (hyphen required); all other store product tags are ignored so non-wood product metadata does not enter the workflow. | M3 | backend/tests/test_woo.py |
|
||||||
| SETTINGS-55 | Every successful sync updates taxonomy_last_sync_at; taxonomy_anchor bumps only when the stored-subset hash changes, including deletions. | M3 | — |
|
| SETTINGS-55 | Every successful sync updates taxonomy_last_sync_at; taxonomy_anchor bumps only when the stored-subset hash changes, including deletions. | M3 | — |
|
||||||
| SETTINGS-60 | The client re-pulls the full taxonomy only when the anchor advances, and publish refreshes when the last sync is over one hour old. | M3 | — |
|
| SETTINGS-60 | The client re-pulls the full taxonomy only when the anchor advances, and publish refreshes when the last sync is over one hour old. | M3 | — |
|
||||||
| SETTINGS-65 | woo_create_status defaults to draft. | M3 | — |
|
| SETTINGS-65 | woo_create_status defaults to draft. | M3 | — |
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ default off (`SETTINGS-90`). Species rates are one flat value per synced leaf (`
|
|||||||
test-woo returns ok and detail, fails closed on missing credentials or non-HTTPS, and always
|
test-woo returns ok and detail, fails closed on missing credentials or non-HTTPS, and always
|
||||||
resyncs taxonomy on success (`SETTINGS-80`, `SETTINGS-40`). test-inference returns ok, mode
|
resyncs taxonomy on success (`SETTINGS-80`, `SETTINGS-40`). test-inference returns ok, mode
|
||||||
(stateful, stateless, or fail), vision_capable, and detail; fail blocks Call 2 only
|
(stateful, stateless, or fail), vision_capable, and detail; fail blocks Call 2 only
|
||||||
(`SETTINGS-45`). Taxonomy sync keeps only fig-* and feat-* tag families (`SETTINGS-51`), rebuilds
|
(`SETTINGS-45`). Taxonomy sync keeps only tags whose **slug** starts with `fig-` or `feat-` (`SETTINGS-51`), rebuilds
|
||||||
in one transaction, keeps the previous cache on failure (`SETTINGS-50`), and bumps the anchor only
|
in one transaction, keeps the previous cache on failure (`SETTINGS-50`), and bumps the anchor only
|
||||||
on a real stored-subset change (`SETTINGS-55`). The client re-pulls when the anchor advances, and
|
on a real stored-subset change (`SETTINGS-55`). The client re-pulls when the anchor advances, and
|
||||||
publish refreshes when the last sync is over an hour old (`SETTINGS-60`).
|
publish refreshes when the last sync is over an hour old (`SETTINGS-60`).
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ Returns `202`; the client polls. Cites PUBLISH-5, PUBLISH-10, PUBLISH-15, PUBLIS
|
|||||||
## 8. Taxonomy
|
## 8. Taxonomy
|
||||||
|
|
||||||
- `GET /api/v1/admin/taxonomy` → grouped categories, attributes with terms, and tags, plus
|
- `GET /api/v1/admin/taxonomy` → grouped categories, attributes with terms, and tags, plus
|
||||||
`taxonomy_anchor` and `taxonomy_last_sync_at`. Only fig-* and feat-* tag families are kept.
|
`taxonomy_anchor` and `taxonomy_last_sync_at`. Only tags whose slug starts with `fig-` or `feat-` are kept.
|
||||||
These lists are the only legal picker options.
|
These lists are the only legal picker options.
|
||||||
- `POST /api/v1/admin/taxonomy/sync` → pulls from Woo, rebuilds the cache in one transaction, sets
|
- `POST /api/v1/admin/taxonomy/sync` → pulls from Woo, rebuilds the cache in one transaction, sets
|
||||||
`taxonomy_last_sync_at`, bumps `taxonomy_anchor` only when the stored-subset hash changes, and
|
`taxonomy_last_sync_at`, bumps `taxonomy_anchor` only when the stored-subset hash changes, and
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ Cites METADATA-5, SETTINGS-50, SETTINGS-51, SETTINGS-55.
|
|||||||
- `woo_categories` — species and wood-category leaves only; parents group and are never assigned.
|
- `woo_categories` — species and wood-category leaves only; parents group and are never assigned.
|
||||||
- `woo_attributes` / `woo_attribute_terms` — exactly the five locked attributes Edge Type, Figure,
|
- `woo_attributes` / `woo_attribute_terms` — exactly the five locked attributes Edge Type, Figure,
|
||||||
Grade, Thickness, Moisture, with their terms.
|
Grade, Thickness, Moisture, with their terms.
|
||||||
- `woo_tags` — only the fig-* and feat-* families; other store tags are ignored.
|
- `woo_tags` — only tags whose slug starts with `fig-` or `feat-`; other store tags are ignored.
|
||||||
|
|
||||||
Rebuild is one transaction; on failure the previous cache is kept.
|
Rebuild is one transaction; on failure the previous cache is kept.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import { decodeName } from './decodeName';
|
||||||
|
|
||||||
|
test('decodeName: ampersand and common entities', () => {
|
||||||
|
assert.equal(decodeName('Cookies & Burl Slices'), 'Cookies & Burl Slices');
|
||||||
|
assert.equal(decodeName('A < B > C'), 'A < B > C');
|
||||||
|
assert.equal(decodeName('say "hi" 'there''), 'say "hi" \'there\'');
|
||||||
|
assert.equal(decodeName('plain'), 'plain');
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/** Decode common HTML entities Woo puts in taxonomy display names. */
|
||||||
|
export function decodeName(name: string): string {
|
||||||
|
return name
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
import type { CalibratedCapture } from '../flow';
|
import type { CalibratedCapture } from '../flow';
|
||||||
import { canCreateTitleFromSpecies, effectiveSpeciesId } from './speciesFromCapture';
|
import { canCreateTitleFromSpecies, effectiveSpeciesId } from './speciesFromCapture';
|
||||||
import MultiSelect from './MultiSelect.svelte';
|
import MultiSelect from './MultiSelect.svelte';
|
||||||
import { deriveThicknessTermId } from './thicknessBand';
|
import { decodeName } from '../decodeName';
|
||||||
|
import { deriveFigTagIds, scopedFeatTags } from './figTags';
|
||||||
import {
|
import {
|
||||||
keptInOrder, moveItem, missingMinimums, roleLabel, resolvePhotoUrl,
|
keptInOrder, moveItem, missingMinimums, roleLabel, resolvePhotoUrl,
|
||||||
type LineupItem,
|
type LineupItem,
|
||||||
@@ -41,7 +42,6 @@
|
|||||||
let figureOpts: Opt[] = [];
|
let figureOpts: Opt[] = [];
|
||||||
let gradeOpts: Opt[] = [];
|
let gradeOpts: Opt[] = [];
|
||||||
let moistureOpts: Opt[] = [];
|
let moistureOpts: Opt[] = [];
|
||||||
let figTagOpts: Opt[] = [];
|
|
||||||
let featTagOpts: Opt[] = [];
|
let featTagOpts: Opt[] = [];
|
||||||
|
|
||||||
function deriveOptions() {
|
function deriveOptions() {
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
// attributes ignore it. "2-4 in" → 2; a name with no leading number → undefined.
|
// attributes ignore it. "2-4 in" → 2; a name with no leading number → undefined.
|
||||||
const m = t.name.match(/\d+(?:[./]\d+)?/);
|
const m = t.name.match(/\d+(?:[./]\d+)?/);
|
||||||
const lowIn = m ? Number(m[0]) : undefined;
|
const lowIn = m ? Number(m[0]) : undefined;
|
||||||
return { id: t.id, name: t.name, ai: false, lowIn: Number.isFinite(lowIn) ? lowIn : undefined };
|
return { id: t.id, name: decodeName(t.name), ai: false, lowIn: Number.isFinite(lowIn) ? lowIn : undefined };
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
// Wood categories: parent/child grouped (METADATA-10 — parents group only, never
|
// Wood categories: parent/child grouped (METADATA-10 — parents group only, never
|
||||||
@@ -73,9 +73,9 @@
|
|||||||
woodCatGroups = (childrenOf(wcatGrouper)).map((parent) => {
|
woodCatGroups = (childrenOf(wcatGrouper)).map((parent) => {
|
||||||
const kids = cats.filter((c) => c.parent_id === parent.id);
|
const kids = cats.filter((c) => c.parent_id === parent.id);
|
||||||
const leaves: Opt[] = kids.length
|
const leaves: Opt[] = kids.length
|
||||||
? kids.map((c) => ({ id: c.id, name: c.name, ai: false }))
|
? kids.map((c) => ({ id: c.id, name: decodeName(c.name), ai: false }))
|
||||||
: [{ id: parent.id, name: parent.name, ai: false }];
|
: [{ id: parent.id, name: decodeName(parent.name), ai: false }];
|
||||||
return { parent: { id: parent.id, name: parent.name }, leaves };
|
return { parent: { id: parent.id, name: decodeName(parent.name) }, leaves };
|
||||||
});
|
});
|
||||||
edgeOpts = termsOf('edge type');
|
edgeOpts = termsOf('edge type');
|
||||||
figureOpts = termsOf('figure');
|
figureOpts = termsOf('figure');
|
||||||
@@ -83,12 +83,12 @@
|
|||||||
// No thickness options: the band is derived server-side and never shown here
|
// No thickness options: the band is derived server-side and never shown here
|
||||||
// (METADATA-15 / QUALITY-95).
|
// (METADATA-15 / QUALITY-95).
|
||||||
moistureOpts = termsOf('moisture');
|
moistureOpts = termsOf('moisture');
|
||||||
// fig-* / feat-* product tags (METADATA-30): pulled from the live synced set,
|
// feat-* product tags (METADATA-30): slug prefix with hyphen. fig-* tags are
|
||||||
// filtered by the family the backend keeps. Never invent a prefix — if the
|
// derived from Figure attribute selections (no separate picker).
|
||||||
// store has none, the picker is legitimately empty.
|
|
||||||
const tags: TaxTag[] = tax.tags ?? [];
|
const tags: TaxTag[] = tax.tags ?? [];
|
||||||
figTagOpts = tags.filter((t) => t.slug.startsWith('fig')).map((t) => ({ id: t.id, name: t.name, ai: false }));
|
featTagOpts = scopedFeatTags(tags).map((t) => ({
|
||||||
featTagOpts = tags.filter((t) => t.slug.startsWith('feat')).map((t) => ({ id: t.id, name: t.name, ai: false }));
|
id: t.id, name: decodeName(t.name), ai: false,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Editable model (mirrors the live slab) ---
|
// --- Editable model (mirrors the live slab) ---
|
||||||
@@ -148,9 +148,6 @@
|
|||||||
let genBusy = false;
|
let genBusy = false;
|
||||||
let genError = '';
|
let genError = '';
|
||||||
let canGenerateContent = false;
|
let canGenerateContent = false;
|
||||||
let aiRecommendations: number[] = []; // union of AI-suggested ids (for row bubbles)
|
|
||||||
|
|
||||||
const num = (v: unknown) => (typeof v === 'number' ? v : null);
|
|
||||||
const fmt1 = (v: number) => { const s = v.toFixed(1); return s.endsWith('.0') ? s.slice(0, -2) : s; };
|
const fmt1 = (v: number) => { const s = v.toFixed(1); return s.endsWith('.0') ? s.slice(0, -2) : s; };
|
||||||
const fmt2 = (v: number) => v.toFixed(2);
|
const fmt2 = (v: number) => v.toFixed(2);
|
||||||
|
|
||||||
@@ -327,7 +324,6 @@
|
|||||||
figureIds = [...(s.figure_term_ids ?? [])];
|
figureIds = [...(s.figure_term_ids ?? [])];
|
||||||
gradeSel = s.grade_term_id != null ? String(s.grade_term_id) : '';
|
gradeSel = s.grade_term_id != null ? String(s.grade_term_id) : '';
|
||||||
moistureId = s.moisture_term_id ?? '';
|
moistureId = s.moisture_term_id ?? '';
|
||||||
figTagIds = [...(s.fig_tag_ids ?? [])];
|
|
||||||
featTagIds = [...(s.feat_tag_ids ?? [])];
|
featTagIds = [...(s.feat_tag_ids ?? [])];
|
||||||
title = s.title ?? '';
|
title = s.title ?? '';
|
||||||
shortDescription = s.short_description ?? '';
|
shortDescription = s.short_description ?? '';
|
||||||
@@ -338,36 +334,29 @@
|
|||||||
sku = s.sku ?? '';
|
sku = s.sku ?? '';
|
||||||
// REVIEW-15: primary defaults to first selected wood cat; keep if still valid.
|
// REVIEW-15: primary defaults to first selected wood cat; keep if still valid.
|
||||||
primaryCatId = nextPrimary(woodCatIds, woodCatIds[0] ?? '');
|
primaryCatId = nextPrimary(woodCatIds, woodCatIds[0] ?? '');
|
||||||
// Seed AI-recommended markers from stored confidences (>=0.7 means the AI put
|
// fig_tag_ids are derived reactively from figureIds (no hydrate of stored fig tags).
|
||||||
// its weight behind the value the owner kept).
|
|
||||||
aiRecommendations = [];
|
|
||||||
if (num(s.species_confidence) != null && num(s.species_confidence)! >= 0.7 && s.species_id)
|
|
||||||
aiRecommendations.push(s.species_id);
|
|
||||||
if (num(s.figure_confidence) != null && num(s.figure_confidence)! >= 0.7)
|
|
||||||
aiRecommendations.push(...(s.figure_term_ids ?? []));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AI-marked option views (legacy Call 1 badges; Call 1 disabled on this branch).
|
// Call 1 out: no AI badges on options. Groups/opts pass through as-is.
|
||||||
$: woodCatGroupsView = woodCatGroups.map((g) => ({
|
$: woodCatGroupsView = woodCatGroups;
|
||||||
parent: g.parent,
|
$: figureOptsView = figureOpts;
|
||||||
leaves: g.leaves.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) })),
|
$: featTagOptsView = featTagOpts;
|
||||||
}));
|
// fig_tag_ids derived from selected Figure term names ↔ fig-* tag names (casefold).
|
||||||
$: figureOptsView = figureOpts.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
|
$: figTagIds = tax
|
||||||
$: featTagOptsView = featTagOpts.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
|
? deriveFigTagIds(figureIds, figureOpts, tax.tags ?? [])
|
||||||
|
: figTagIds;
|
||||||
// Flat leaf name lookup for the primary radio set (REVIEW-15).
|
// Flat leaf name lookup for the primary radio set (REVIEW-15).
|
||||||
$: woodLeafById = new Map(
|
$: woodLeafById = new Map(
|
||||||
woodCatGroups.flatMap((g) => g.leaves.map((l) => [l.id, l] as [number, Opt])),
|
woodCatGroups.flatMap((g) => g.leaves.map((l) => [l.id, l] as [number, Opt])),
|
||||||
);
|
);
|
||||||
$: primaryOpts = woodCatIds
|
$: primaryOpts = woodCatIds
|
||||||
.map((id) => woodLeafById.get(id))
|
.map((id) => woodLeafById.get(id))
|
||||||
.filter((o): o is Opt => !!o)
|
.filter((o): o is Opt => !!o);
|
||||||
.map((o) => ({ ...o, ai: aiRecommendations.includes(o.id) }));
|
|
||||||
|
|
||||||
// METADATA-15 / QUALITY-95: the thickness band is derived server-side from the
|
// METADATA-15 / QUALITY-95: the thickness band is derived server-side from the
|
||||||
// measured thickness against the live synced Woo bands. It is deliberately absent
|
// measured thickness against the live synced Woo bands. It is deliberately absent
|
||||||
// from this screen — no owner control, no read-only echo. The only thickness the
|
// from this screen — no owner control, no read-only echo. The only thickness the
|
||||||
// owner sees here is the measured value in the summary above.
|
// owner sees here is the measured value in the summary above.
|
||||||
const aiIn = (ids: number[]) => (id: number) => ids.includes(id);
|
|
||||||
// Persist taxonomy/price/sku edits to the slab (PUT), then recompute ready.
|
// Persist taxonomy/price/sku edits to the slab (PUT), then recompute ready.
|
||||||
// #89: the deterministic title/short_description are written here — the server's
|
// #89: the deterministic title/short_description are written here — the server's
|
||||||
// review returns the build from the current metadata, and if the field is empty
|
// review returns the build from the current metadata, and if the field is empty
|
||||||
@@ -678,7 +667,7 @@
|
|||||||
|
|
||||||
<!-- Wood categories (multi + inline primary radio set, REVIEW-15 / #90 wireframe) -->
|
<!-- Wood categories (multi + inline primary radio set, REVIEW-15 / #90 wireframe) -->
|
||||||
<div class="field" data-testid="wood-cat-field">
|
<div class="field" data-testid="wood-cat-field">
|
||||||
<label class="lbl">Wood categories <span class="ai">AI assist</span></label>
|
<label class="lbl">Wood categories</label>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
label="Wood categories"
|
label="Wood categories"
|
||||||
groups={woodCatGroupsView}
|
groups={woodCatGroupsView}
|
||||||
@@ -703,7 +692,6 @@
|
|||||||
>
|
>
|
||||||
<span class="ps-radio" aria-hidden="true"></span>
|
<span class="ps-radio" aria-hidden="true"></span>
|
||||||
<span class="ps-name">{o.name}</span>
|
<span class="ps-name">{o.name}</span>
|
||||||
{#if o.ai}<span class="ps-tag">AI</span>{/if}
|
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
<p class="ps-note">
|
<p class="ps-note">
|
||||||
@@ -714,9 +702,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Edge type (single, AI-assist) -->
|
<!-- Edge type (single) -->
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="lbl" for="edge">Edge type <span class="ai">AI assist</span></label>
|
<label class="lbl" for="edge">Edge type</label>
|
||||||
<select id="edge" class="sel" data-testid="edge-type" bind:this={edgeEl} bind:value={edgeSel} on:change={commitReview}>
|
<select id="edge" class="sel" data-testid="edge-type" bind:this={edgeEl} bind:value={edgeSel} on:change={commitReview}>
|
||||||
<option value="">— select —</option>
|
<option value="">— select —</option>
|
||||||
{#each edgeOpts as o (o.id)}
|
{#each edgeOpts as o (o.id)}
|
||||||
@@ -725,9 +713,9 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Figure (multi, searchable list, AI-assist) -->
|
<!-- Figure (multi, searchable list); fig-* tags derived from selections -->
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="lbl">Figure <span class="ai">AI assist</span></label>
|
<label class="lbl">Figure</label>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
label="Figure"
|
label="Figure"
|
||||||
options={figureOptsView}
|
options={figureOptsView}
|
||||||
@@ -748,28 +736,14 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- fig-* product tags (optional, owner-only, no AI) -->
|
<!-- Features = feat-* product tags (optional; Call 1 out — no AI badge) -->
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label class="lbl">fig-* tags <span class="sug">optional</span></label>
|
<label class="lbl">Features <span class="sug">optional</span></label>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
label="fig-* tags"
|
label="Features"
|
||||||
options={figTagOpts}
|
|
||||||
selected={figTagIds}
|
|
||||||
placeholder="Choose fig-* tags"
|
|
||||||
testid="fig-tag"
|
|
||||||
onToggle={(id) => (figTagIds = toggleMulti(figTagIds, id))}
|
|
||||||
on:commit={commitReview}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- feat-* product tags (optional; Call 1 assist disabled on this branch) -->
|
|
||||||
<div class="field">
|
|
||||||
<label class="lbl">feat-* tags <span class="sug">optional</span> <span class="ai">AI assist</span></label>
|
|
||||||
<MultiSelect
|
|
||||||
label="feat-* tags"
|
|
||||||
options={featTagOptsView}
|
options={featTagOptsView}
|
||||||
selected={featTagIds}
|
selected={featTagIds}
|
||||||
placeholder="Choose feat-* tags"
|
placeholder="Choose features"
|
||||||
testid="feat-tag"
|
testid="feat-tag"
|
||||||
onToggle={(id) => (featTagIds = toggleMulti(featTagIds, id))}
|
onToggle={(id) => (featTagIds = toggleMulti(featTagIds, id))}
|
||||||
on:commit={commitReview}
|
on:commit={commitReview}
|
||||||
@@ -875,7 +849,6 @@
|
|||||||
.math div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding: 5px 0; font-size: .85rem; }
|
.math div { display: flex; justify-content: space-between; border-bottom: 1px solid #2a2a2a; padding: 5px 0; font-size: .85rem; }
|
||||||
.math dt { color: #9a9a9a; margin: 0; } .math dd { margin: 0; font-variant-numeric: tabular-nums; }
|
.math dt { color: #9a9a9a; margin: 0; } .math dd { margin: 0; font-variant-numeric: tabular-nums; }
|
||||||
.lbl { display: block; font-size: .8rem; color: #bbb; margin: 0 0 4px; }
|
.lbl { display: block; font-size: .8rem; color: #bbb; margin: 0 0 4px; }
|
||||||
.lbl .ai { margin-left: 6px; font-size: .58rem; font-weight: 700; padding: 1px 5px; border-radius: 2px; background: #00e5ff; color: #002; }
|
|
||||||
/* box-sizing: the app has no global reset, so a width:100% control with padding
|
/* box-sizing: the app has no global reset, so a width:100% control with padding
|
||||||
and a border pokes out of its container by that padding+border (20px here) —
|
and a border pokes out of its container by that padding+border (20px here) —
|
||||||
which becomes real page overflow whenever the column sits at the viewport edge
|
which becomes real page overflow whenever the column sits at the viewport edge
|
||||||
@@ -908,7 +881,6 @@
|
|||||||
.ps-opt.on .ps-radio { border-color: #00e5ff; }
|
.ps-opt.on .ps-radio { border-color: #00e5ff; }
|
||||||
.ps-opt.on .ps-radio::after { content: ''; position: absolute; inset: 2px; border-radius: 50%; background: #00e5ff; }
|
.ps-opt.on .ps-radio::after { content: ''; position: absolute; inset: 2px; border-radius: 50%; background: #00e5ff; }
|
||||||
.ps-name { flex: 1; }
|
.ps-name { flex: 1; }
|
||||||
.ps-tag { font-size: .56rem; font-weight: 700; padding: 0 4px; border-radius: 2px; background: #00e5ff; color: #002; letter-spacing: .3px; }
|
|
||||||
.ps-note { font-size: .7rem; color: #777; margin: 6px 0 0; line-height: 1.4; }
|
.ps-note { font-size: .7rem; color: #777; margin: 6px 0 0; line-height: 1.4; }
|
||||||
.status { margin-top: 14px; font-weight: 700; padding: 10px 12px; border-radius: 3px; background: #2a1512; color: #ffb4a2; font-size: .9rem; }
|
.status { margin-top: 14px; font-weight: 700; padding: 10px 12px; border-radius: 3px; background: #2a1512; color: #ffb4a2; font-size: .9rem; }
|
||||||
.status.ok { background: #122a12; color: #7fd37f; }
|
.status.ok { background: #122a12; color: #7fd37f; }
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import {
|
||||||
|
deriveFigTagIds,
|
||||||
|
normName,
|
||||||
|
scopedFeatTags,
|
||||||
|
scopedFigTags,
|
||||||
|
} from './figTags';
|
||||||
|
|
||||||
|
test('scoped tags require hyphenated slug prefix (not bare fig/feat)', () => {
|
||||||
|
const tags = [
|
||||||
|
{ id: 1, name: 'Curl', slug: 'fig-curl' },
|
||||||
|
{ id: 2, name: 'Knot', slug: 'feat-knot' },
|
||||||
|
{ id: 3, name: 'Figured', slug: 'figured' }, // no hyphen — out
|
||||||
|
{ id: 4, name: 'Featured', slug: 'featured' },
|
||||||
|
{ id: 5, name: 'fig-fake-name', slug: 'other' },
|
||||||
|
];
|
||||||
|
assert.deepEqual(scopedFigTags(tags).map((t) => t.id), [1]);
|
||||||
|
assert.deepEqual(scopedFeatTags(tags).map((t) => t.id), [2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deriveFigTagIds: casefold/strip name equality; Curl≠Curly', () => {
|
||||||
|
const terms = [
|
||||||
|
{ id: 10, name: 'Curl' },
|
||||||
|
{ id: 11, name: ' Birdseye ' },
|
||||||
|
];
|
||||||
|
const tags = [
|
||||||
|
{ id: 100, name: 'curl', slug: 'fig-curl' },
|
||||||
|
{ id: 101, name: 'Curly', slug: 'fig-curly' },
|
||||||
|
{ id: 102, name: 'Birdseye', slug: 'fig-birdseye' },
|
||||||
|
{ id: 103, name: 'Knot', slug: 'feat-knot' },
|
||||||
|
];
|
||||||
|
assert.deepEqual(deriveFigTagIds([10], terms, tags), [100]);
|
||||||
|
assert.deepEqual(deriveFigTagIds([10, 11], terms, tags).sort(), [100, 102]);
|
||||||
|
// Curly tag stays unmatched when only Curl is selected
|
||||||
|
assert.ok(!deriveFigTagIds([10], terms, tags).includes(101));
|
||||||
|
assert.deepEqual(deriveFigTagIds([], terms, tags), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normName strips and lowercases', () => {
|
||||||
|
assert.equal(normName(' Curl '), 'curl');
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Derive fig-* product tag ids from selected Figure attribute terms.
|
||||||
|
* Match is case-insensitive name equality (casefold/strip) against tags whose
|
||||||
|
* slug starts with `fig-`. No hardcoded name bridges (Curl ≠ Curly).
|
||||||
|
*/
|
||||||
|
export function normName(name: string): string {
|
||||||
|
// JS has no String.casefold; toLowerCase covers ASCII wood-term names.
|
||||||
|
return name.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scopedFigTags<T extends { slug: string }>(tags: T[]): T[] {
|
||||||
|
return tags.filter((t) => t.slug.startsWith('fig-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scopedFeatTags<T extends { slug: string }>(tags: T[]): T[] {
|
||||||
|
return tags.filter((t) => t.slug.startsWith('feat-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveFigTagIds(
|
||||||
|
figureTermIds: number[],
|
||||||
|
figureTerms: { id: number; name: string }[],
|
||||||
|
tags: { id: number; name: string; slug: string }[],
|
||||||
|
): number[] {
|
||||||
|
const selected = new Set(
|
||||||
|
figureTerms
|
||||||
|
.filter((t) => figureTermIds.includes(t.id))
|
||||||
|
.map((t) => normName(t.name)),
|
||||||
|
);
|
||||||
|
if (selected.size === 0) return [];
|
||||||
|
return scopedFigTags(tags)
|
||||||
|
.filter((t) => selected.has(normName(t.name)))
|
||||||
|
.map((t) => t.id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { test } from 'node:test';
|
||||||
|
|
||||||
|
const dir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const reviewSrc = readFileSync(join(dir, 'Review.svelte'), 'utf8');
|
||||||
|
const figSrc = readFileSync(join(dir, 'figTags.ts'), 'utf8');
|
||||||
|
|
||||||
|
test('Review UI: no AI assist labels or AI badge chrome', () => {
|
||||||
|
assert.equal(reviewSrc.includes('AI assist'), false);
|
||||||
|
assert.equal(reviewSrc.includes('class="ai"'), false);
|
||||||
|
assert.equal(reviewSrc.includes('ps-tag'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Review UI: no fig-* MultiSelect picker', () => {
|
||||||
|
assert.equal(reviewSrc.includes('data-testid="fig-tag"'), false);
|
||||||
|
assert.equal(reviewSrc.includes('figTagOpts'), false);
|
||||||
|
assert.equal(reviewSrc.includes('label="fig-* tags"'), false);
|
||||||
|
assert.equal(reviewSrc.includes('placeholder="Choose fig-* tags"'), false);
|
||||||
|
assert.match(reviewSrc, /deriveFigTagIds/);
|
||||||
|
assert.match(reviewSrc, /Features/);
|
||||||
|
assert.match(reviewSrc, /testid="feat-tag"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Tag family scope uses hyphenated slug prefix', () => {
|
||||||
|
assert.match(figSrc, /startsWith\('fig-'\)/);
|
||||||
|
assert.match(figSrc, /startsWith\('feat-'\)/);
|
||||||
|
assert.equal(/startsWith\('fig'\)/.test(figSrc), false);
|
||||||
|
assert.equal(/startsWith\('feat'\)/.test(figSrc), false);
|
||||||
|
assert.equal(/startsWith\('fig'\)/.test(reviewSrc), false);
|
||||||
|
assert.equal(/startsWith\('feat'\)/.test(reviewSrc), false);
|
||||||
|
});
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { decodeName } from '../decodeName';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { speciesLeavesFromTaxonomy, type TaxonomyCategory } from './speciesLeaves';
|
import { speciesLeavesFromTaxonomy, type TaxonomyCategory } from './speciesLeaves';
|
||||||
|
|
||||||
@@ -52,16 +53,6 @@
|
|||||||
const secretValue = (s: Secret): string => (isUnmasked(s) ? (s as string) : '');
|
const secretValue = (s: Secret): string => (isUnmasked(s) ? (s as string) : '');
|
||||||
const secretConfigured = (s: Secret): boolean => (isUnmasked(s) ? (s as string).length > 0 : s.configured);
|
const secretConfigured = (s: Secret): boolean => (isUnmasked(s) ? (s as string).length > 0 : s.configured);
|
||||||
|
|
||||||
function decodeName(name: string): string {
|
|
||||||
// Woo sometimes returns HTML entities in category names.
|
|
||||||
return name
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadRates() {
|
async function loadRates() {
|
||||||
ratesLoading = true;
|
ratesLoading = true;
|
||||||
ratesMessage = '';
|
ratesMessage = '';
|
||||||
|
|||||||
Reference in New Issue
Block a user