diff --git a/cf-proxy/scripts/rebuild-search-index-from-component-catalog.sql b/cf-proxy/scripts/rebuild-search-index-from-component-catalog.sql index eb9c5dc6..cd9a92a7 100644 --- a/cf-proxy/scripts/rebuild-search-index-from-component-catalog.sql +++ b/cf-proxy/scripts/rebuild-search-index-from-component-catalog.sql @@ -14,6 +14,7 @@ SELECT END AS price1, basic, preferred, + is_extended_promotional, category, subcategory, CASE @@ -55,3 +56,5 @@ CREATE INDEX IF NOT EXISTS idx_search_index_basic ON search_index(basic); CREATE INDEX IF NOT EXISTS idx_search_index_basic_stock ON search_index(basic, stock DESC); CREATE INDEX IF NOT EXISTS idx_search_index_preferred ON search_index(preferred); CREATE INDEX IF NOT EXISTS idx_search_index_preferred_stock ON search_index(preferred, stock DESC); +CREATE INDEX IF NOT EXISTS idx_search_index_is_extended_promotional ON search_index(is_extended_promotional); +CREATE INDEX IF NOT EXISTS idx_search_index_is_extended_promotional_stock ON search_index(is_extended_promotional, stock DESC); diff --git a/cf-proxy/scripts/sync-db.sh b/cf-proxy/scripts/sync-db.sh index 7542f8a7..61127019 100755 --- a/cf-proxy/scripts/sync-db.sh +++ b/cf-proxy/scripts/sync-db.sh @@ -296,6 +296,7 @@ SELECT package, basic, preferred, + is_extended_promotional, description, stock, price, @@ -306,6 +307,7 @@ CREATE INDEX IF NOT EXISTS idx_component_catalog_subcategory ON component_catalo CREATE INDEX IF NOT EXISTS idx_component_catalog_package ON component_catalog(package); CREATE INDEX IF NOT EXISTS idx_component_catalog_basic ON component_catalog(basic); CREATE INDEX IF NOT EXISTS idx_component_catalog_preferred ON component_catalog(preferred); +CREATE INDEX IF NOT EXISTS idx_component_catalog_is_extended_promotional ON component_catalog(is_extended_promotional); CREATE INDEX IF NOT EXISTS idx_component_catalog_stock ON component_catalog(stock DESC); COMPONENT_CATALOG_SCHEMA @@ -319,6 +321,7 @@ CREATE TABLE component_catalog ( package TEXT, basic INTEGER, preferred INTEGER, + is_extended_promotional INTEGER, description TEXT, stock INTEGER, price TEXT, @@ -328,6 +331,7 @@ CREATE INDEX IF NOT EXISTS idx_component_catalog_subcategory ON component_catalo CREATE INDEX IF NOT EXISTS idx_component_catalog_package ON component_catalog(package); CREATE INDEX IF NOT EXISTS idx_component_catalog_basic ON component_catalog(basic); CREATE INDEX IF NOT EXISTS idx_component_catalog_preferred ON component_catalog(preferred); +CREATE INDEX IF NOT EXISTS idx_component_catalog_is_extended_promotional ON component_catalog(is_extended_promotional); CREATE INDEX IF NOT EXISTS idx_component_catalog_stock ON component_catalog(stock DESC); COMPONENT_CATALOG_SCHEMA_EXPORT } @@ -350,6 +354,7 @@ SELECT END AS price1, basic, preferred, + is_extended_promotional, category, subcategory, CASE @@ -391,6 +396,8 @@ CREATE INDEX IF NOT EXISTS idx_search_index_basic ON search_index(basic); CREATE INDEX IF NOT EXISTS idx_search_index_basic_stock ON search_index(basic, stock DESC); CREATE INDEX IF NOT EXISTS idx_search_index_preferred ON search_index(preferred); CREATE INDEX IF NOT EXISTS idx_search_index_preferred_stock ON search_index(preferred, stock DESC); +CREATE INDEX IF NOT EXISTS idx_search_index_is_extended_promotional ON search_index(is_extended_promotional); +CREATE INDEX IF NOT EXISTS idx_search_index_is_extended_promotional_stock ON search_index(is_extended_promotional, stock DESC); SEARCH_INDEX_SCHEMA cat > search_index_schema.sql <<'SEARCH_INDEX_SCHEMA_EXPORT' @@ -405,6 +412,7 @@ CREATE TABLE search_index ( price1 REAL, basic INTEGER, preferred INTEGER, + is_extended_promotional INTEGER, category TEXT, subcategory TEXT, manufacturer_name TEXT, @@ -422,6 +430,8 @@ CREATE INDEX IF NOT EXISTS idx_search_index_basic ON search_index(basic); CREATE INDEX IF NOT EXISTS idx_search_index_basic_stock ON search_index(basic, stock DESC); CREATE INDEX IF NOT EXISTS idx_search_index_preferred ON search_index(preferred); CREATE INDEX IF NOT EXISTS idx_search_index_preferred_stock ON search_index(preferred, stock DESC); +CREATE INDEX IF NOT EXISTS idx_search_index_is_extended_promotional ON search_index(is_extended_promotional); +CREATE INDEX IF NOT EXISTS idx_search_index_is_extended_promotional_stock ON search_index(is_extended_promotional, stock DESC); SEARCH_INDEX_SCHEMA_EXPORT } diff --git a/cf-proxy/src/components.ts b/cf-proxy/src/components.ts index 6b25a12a..d169ade1 100644 --- a/cf-proxy/src/components.ts +++ b/cf-proxy/src/components.ts @@ -8,6 +8,7 @@ export interface ComponentCatalogQueryParams { search?: string is_basic?: string is_preferred?: string + is_extended_promotional?: string } export async function queryComponentCatalog( @@ -22,6 +23,7 @@ export async function queryComponentCatalog( package: string | null basic: number | null preferred: number | null + is_extended_promotional: number | null description: string | null stock: number | null price: string | null @@ -34,6 +36,7 @@ export async function queryComponentCatalog( subcategory_name: params.subcategory_name, is_basic: params.is_basic, is_preferred: params.is_preferred, + is_extended_promotional: params.is_extended_promotional, limit: "100", }) diff --git a/cf-proxy/src/db/types.ts b/cf-proxy/src/db/types.ts index 41a98f30..a6a83696 100644 --- a/cf-proxy/src/db/types.ts +++ b/cf-proxy/src/db/types.ts @@ -161,6 +161,7 @@ export interface ComponentCatalog { category: string | null description: string | null extra: string | null + is_extended_promotional: number | null lcsc: Generated mfr: string | null package: string | null @@ -660,6 +661,7 @@ export interface SearchIndex { basic: number | null category: string | null description: string | null + is_extended_promotional: number | null lcsc: Generated mfr: string | null manufacturer_name: string | null diff --git a/cf-proxy/src/index.ts b/cf-proxy/src/index.ts index 36b6160c..833f8fe1 100644 --- a/cf-proxy/src/index.ts +++ b/cf-proxy/src/index.ts @@ -367,6 +367,7 @@ async function handleD1Search( package: row.package ?? "", is_basic: Boolean(row.basic), is_preferred: Boolean(row.preferred), + is_extended_promotional: Boolean(row.is_extended_promotional), description: row.description ?? "", stock: row.stock ?? 0, price: row.price1 ?? extractSmallQuantityPrice(row.price), @@ -491,6 +492,7 @@ async function handleD1ComponentsList( subcategory: row.subcategory ?? "", is_basic: Boolean(row.basic), is_preferred: Boolean(row.preferred), + is_extended_promotional: Boolean(row.is_extended_promotional), })), } diff --git a/cf-proxy/src/render.ts b/cf-proxy/src/render.ts index 11ce224c..0e61c100 100644 --- a/cf-proxy/src/render.ts +++ b/cf-proxy/src/render.ts @@ -147,6 +147,7 @@ const COLUMN_LABELS: Record = { in_stock: "In Stock", is_basic: "Basic", is_preferred: "Preferred", + is_extended_promotional: "Promotional Extended", capacitance_farads: "Capacitance", tolerance_fraction: "Tolerance", voltage_rating: "Voltage", @@ -546,6 +547,9 @@ const renderComponentsFilters = (
+
+ +
` diff --git a/cf-proxy/src/search.ts b/cf-proxy/src/search.ts index f1b60d3c..3ee93a4a 100644 --- a/cf-proxy/src/search.ts +++ b/cf-proxy/src/search.ts @@ -9,6 +9,7 @@ export interface SearchQueryParams { limit?: string is_basic?: string is_preferred?: string + is_extended_promotional?: string } interface SearchRow { @@ -21,6 +22,7 @@ interface SearchRow { price1: number | null basic: number | null preferred: number | null + is_extended_promotional: number | null category: string | null subcategory: string | null } @@ -110,6 +112,13 @@ export async function searchIndex( conditions.push(sql`search_index.preferred = 1`) } + if ( + params.is_extended_promotional === "true" || + params.is_extended_promotional === "1" + ) { + conditions.push(sql`search_index.is_extended_promotional = 1`) + } + const raw = params.q?.trim() if (raw) { @@ -151,6 +160,7 @@ export async function searchIndex( search_index.price1, search_index.basic, search_index.preferred, + search_index.is_extended_promotional, search_index.category, search_index.subcategory FROM search_index diff --git a/lib/db/generated/kysely.ts b/lib/db/generated/kysely.ts index 6f300dff..4b7de8f0 100644 --- a/lib/db/generated/kysely.ts +++ b/lib/db/generated/kysely.ts @@ -192,6 +192,7 @@ export interface Component { description: string; extra: string | null; flag: Generated; + is_extended_promotional: Generated; joints: number; last_on_stock: Generated; last_update: number; @@ -802,6 +803,7 @@ export interface VComponent { datasheet: string | null; description: string | null; extra: string | null; + is_extended_promotional: number | null; joints: number | null; last_on_stock: number | null; lcsc: number | null; diff --git a/lib/db/optimizations/component-extended-promotional-column.ts b/lib/db/optimizations/component-extended-promotional-column.ts new file mode 100644 index 00000000..53be692c --- /dev/null +++ b/lib/db/optimizations/component-extended-promotional-column.ts @@ -0,0 +1,54 @@ +import { sql } from "kysely" +import type { KyselyDatabaseInstance } from "../kysely-types" +import type { DbOptimizationSpec } from "./types" + +const isExtendedPromotionalSql = sql` + CASE + WHEN COALESCE(basic, 0) = 0 + AND COALESCE(preferred, 0) = 0 + AND extra IS NOT NULL + AND ( + LOWER(extra) LIKE '%promotional extended%' + OR LOWER(extra) LIKE '%extended promotional%' + OR LOWER(extra) LIKE '%promotional_extended%' + OR LOWER(extra) LIKE '%extended_promotional%' + OR LOWER(extra) LIKE '%promotionalextended%' + OR LOWER(extra) LIKE '%extendedpromotional%' + ) + THEN 1 + ELSE 0 + END +` + +export const componentExtendedPromotionalColumn: DbOptimizationSpec = { + name: "add_components_is_extended_promotional_column", + description: + "Adds is_extended_promotional boolean column to components from source metadata", + + async checkIfAdded(db: KyselyDatabaseInstance) { + const result = await sql` + SELECT name FROM pragma_table_info('components') + WHERE name = 'is_extended_promotional' + `.execute(db) + + return result.rows.length > 0 + }, + + async execute(db: KyselyDatabaseInstance) { + await sql` + ALTER TABLE components + ADD COLUMN is_extended_promotional INTEGER NOT NULL DEFAULT 0 + `.execute(db) + + await sql` + UPDATE components + SET is_extended_promotional = ${isExtendedPromotionalSql} + `.execute(db) + + await db.schema + .createIndex("idx_components_is_extended_promotional") + .on("components") + .column("is_extended_promotional") + .execute() + }, +} diff --git a/lib/util/is-extended-promotional.ts b/lib/util/is-extended-promotional.ts new file mode 100644 index 00000000..575ff56d --- /dev/null +++ b/lib/util/is-extended-promotional.ts @@ -0,0 +1,74 @@ +const PROMOTIONAL_EXTENDED_PATTERNS = [ + "promotional extended", + "extended promotional", + "promotional_extended", + "extended_promotional", +] + +const PROMOTIONAL_EXTENDED_BOOLEAN_KEYS = new Set([ + "is_extended_promotional", + "isExtendedPromotional", + "extendedPromotional", + "promotionalExtended", + "promotional_extended", + "extended_promotional", + "promotionalExtendedFlag", + "promotionExtendedFlag", +]) + +const valueLooksPromotionalExtended = (value: unknown): boolean => { + if (typeof value === "boolean") return value + if (typeof value === "number") return value === 1 + if (typeof value !== "string") return false + + const normalized = value + .toLowerCase() + .replace(/[-_/]+/g, " ") + .trim() + return PROMOTIONAL_EXTENDED_PATTERNS.some((pattern) => + normalized.includes(pattern.replace(/[-_/]+/g, " ")), + ) +} + +const hasPromotionalExtendedMarker = (value: unknown): boolean => { + if (!value || typeof value !== "object") { + return typeof value === "string" && valueLooksPromotionalExtended(value) + } + if (Array.isArray(value)) return value.some(hasPromotionalExtendedMarker) + + for (const [key, entryValue] of Object.entries(value)) { + if ( + PROMOTIONAL_EXTENDED_BOOLEAN_KEYS.has(key) && + valueLooksPromotionalExtended(entryValue) + ) { + return true + } + + if ( + valueLooksPromotionalExtended(key) && + valueLooksPromotionalExtended(entryValue) + ) { + return true + } + + if (hasPromotionalExtendedMarker(entryValue)) { + return true + } + } + + return false +} + +export const isExtendedPromotionalComponent = ( + extra: string | null | undefined, + basic?: number | boolean | null, + preferred?: number | boolean | null, +): boolean => { + if (Boolean(basic) || Boolean(preferred) || !extra) return false + + try { + return hasPromotionalExtendedMarker(JSON.parse(extra)) + } catch { + return valueLooksPromotionalExtended(extra) + } +} diff --git a/routes/api/search.tsx b/routes/api/search.tsx index a95d0da2..e3444f35 100644 --- a/routes/api/search.tsx +++ b/routes/api/search.tsx @@ -50,6 +50,7 @@ export default withWinterSpec({ limit: z.string().optional(), is_basic: z.boolean().optional(), is_preferred: z.boolean().optional(), + is_extended_promotional: z.boolean().optional(), }), jsonResponse: z.any(), } as const)(async (req, ctx) => { @@ -72,6 +73,9 @@ export default withWinterSpec({ if (req.query.is_preferred) { query = query.where("preferred", "=", 1) } + if (req.query.is_extended_promotional) { + query = query.where("is_extended_promotional", "=", 1) + } const baseQuery = query let fallbackLikeTokens: string[] = [] @@ -193,6 +197,7 @@ export default withWinterSpec({ package: c.package, is_basic: Boolean(c.basic), is_preferred: Boolean(c.preferred), + is_extended_promotional: Boolean(c.is_extended_promotional), description: c.description, stock: c.stock, price: extractSmallQuantityPrice(c.price), diff --git a/routes/components/list.tsx b/routes/components/list.tsx index 640785d1..7ee53adb 100644 --- a/routes/components/list.tsx +++ b/routes/components/list.tsx @@ -2,6 +2,7 @@ import { sql } from "kysely" import { Table } from "lib/ui/Table" import { ExpressionBuilder } from "kysely" import { buildSearchTokenGroups } from "lib/util/search-token-groups" +import { isExtendedPromotionalComponent } from "lib/util/is-extended-promotional" import { withWinterSpec } from "lib/with-winter-spec" import { z } from "zod" @@ -35,6 +36,7 @@ export default withWinterSpec({ search: z.string().optional(), is_basic: z.boolean().optional(), is_preferred: z.boolean().optional(), + is_extended_promotional: z.boolean().optional(), }), jsonResponse: z.any(), } as const)(async (req, ctx) => { @@ -51,6 +53,8 @@ export default withWinterSpec({ "price", "extra", "basic", + "preferred", + "is_extended_promotional", ]) .limit(limit) .orderBy("stock", "desc") @@ -70,6 +74,9 @@ export default withWinterSpec({ if (req.query.is_preferred) { query = query.where("preferred", "=", 1) } + if (req.query.is_extended_promotional) { + query = query.where("is_extended_promotional", "=", 1) + } if (req.query.search) { const search = req.query.search @@ -111,6 +118,10 @@ export default withWinterSpec({ package: c.package, is_basic: Boolean(c.basic), is_preferred: Boolean(c.preferred), + is_extended_promotional: + "is_extended_promotional" in c + ? Boolean(c.is_extended_promotional) + : isExtendedPromotionalComponent(c.extra, c.basic, c.preferred), description: c.description, stock: c.stock, price: extractSmallQuantityPrice(c.price), @@ -155,6 +166,17 @@ export default withWinterSpec({ /> +
+ +
diff --git a/scripts/setup-7z.ts b/scripts/setup-7z.ts index 75f1b7c0..51a94b2e 100644 --- a/scripts/setup-7z.ts +++ b/scripts/setup-7z.ts @@ -7,10 +7,10 @@ const BINARY_NAME = "7zz" // Map of platform-arch combinations to download URLs const BINARY_URLS: Record = { - "linux-x64": "https://7-zip.org/a/7z2408-linux-x64.tar.xz", - "linux-arm64": "https://7-zip.org/a/7z2408-linux-arm64.tar.xz", - "darwin-x64": "https://7-zip.org/a/7z2408-mac.tar.xz", - "darwin-arm64": "https://7-zip.org/a/7z2408-mac.tar.xz", + "linux-x64": "https://7-zip.org/a/7z2601-linux-x64.tar.xz", + "linux-arm64": "https://7-zip.org/a/7z2601-linux-arm64.tar.xz", + "darwin-x64": "https://7-zip.org/a/7z2601-mac.tar.xz", + "darwin-arm64": "https://7-zip.org/a/7z2601-mac.tar.xz", } async function downloadAndExtract7z() { diff --git a/scripts/setup-db-optimizations.ts b/scripts/setup-db-optimizations.ts index 062ed9d5..373ea8a3 100644 --- a/scripts/setup-db-optimizations.ts +++ b/scripts/setup-db-optimizations.ts @@ -9,12 +9,14 @@ import { componentSearchFTS } from "lib/db/optimizations/component-search-fts" import { componentPackageIndex } from "lib/db/optimizations/component-indexes" import { componentBasicIndex } from "lib/db/optimizations/component-basic-index" import { componentPreferredIndex } from "lib/db/optimizations/component-preferred-index" +import { componentExtendedPromotionalColumn } from "lib/db/optimizations/component-extended-promotional-column" const OPTIMIZATIONS: DbOptimizationSpec[] = [ componentSearchFTS, componentPackageIndex, componentBasicIndex, componentPreferredIndex, + componentExtendedPromotionalColumn, removeStaleComponents, componentStockIndex, componentInStockColumn, diff --git a/tests/lib/is-extended-promotional.test.ts b/tests/lib/is-extended-promotional.test.ts new file mode 100644 index 00000000..349a3c53 --- /dev/null +++ b/tests/lib/is-extended-promotional.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from "bun:test" +import { isExtendedPromotionalComponent } from "lib/util/is-extended-promotional" + +test("detects promotional extended parts from source metadata strings", () => { + expect( + isExtendedPromotionalComponent( + JSON.stringify({ + attributes: { + "Basic/Extended": "Promotional Extended", + }, + }), + 0, + 0, + ), + ).toBe(true) +}) + +test("detects promotional extended parts from source boolean flags", () => { + expect( + isExtendedPromotionalComponent( + JSON.stringify({ + promotionalExtendedFlag: true, + }), + 0, + 0, + ), + ).toBe(true) +}) + +test("does not mark basic or preferred parts as promotional extended", () => { + const extra = JSON.stringify({ + attributes: { + "Basic/Extended": "Promotional Extended", + }, + }) + + expect(isExtendedPromotionalComponent(extra, 1, 0)).toBe(false) + expect(isExtendedPromotionalComponent(extra, 0, 1)).toBe(false) +})