diff --git a/LICENSE_POLICY.md b/LICENSE_POLICY.md index 2b4c87f..f23e491 100644 --- a/LICENSE_POLICY.md +++ b/LICENSE_POLICY.md @@ -17,12 +17,12 @@ MIT, ISC, 0BSD, BSD-2-Clause, BSD-3-Clause, and Apache-2.0 dependencies are norm ## Audited dependency snapshot -Audit date: 2026-09-18. Exact inputs and expected counts are recorded in [`licenses/audit.json`](licenses/audit.json). +Audit date: 2026-10-14. Exact inputs and expected counts are recorded in [`licenses/audit.json`](licenses/audit.json). | Scope | Result | | --- | --- | -| Linux/Windows/macOS Go release closure | 37 external modules: MIT, BSD-3-Clause, and Apache-2.0 only | -| Full Go module graph | 60 external modules; one reviewed MPL-2.0 module is used by build/test paths and is verified absent from both release closures | +| Linux/Windows/macOS Go release closure | 62 external modules: MIT, BSD-3-Clause, and Apache-2.0 only | +| Full Go module graph | 100 external modules; one reviewed MPL-2.0 module is used by build/test paths and is verified absent from all four release closures | | Embedded production UI graph | 106 package records: MIT, BSD-3-Clause, and 0BSD only | | Full frontend build/test graph | 198 package records: production licenses plus Apache-2.0, ISC, and one CC-BY-4.0 build-data package | | GitHub Actions and release CLI | `checkout`, `setup-go`, `action-setup`, `setup-node`, `upload-artifact`, and GitHub CLI are MIT-licensed | @@ -32,7 +32,11 @@ The MPL-2.0 module is `github.com/hashicorp/golang-lru/v2@v2.0.7`. It is reached The CC-BY-4.0 component is `caniuse-lite@1.0.30001810`, used as browser-compatibility build data. It is absent from the production package set and is not shipped as a runtime package. Its attribution remains available through the pinned source package and lockfile. -No third-party GPL, AGPL, LGPL, SSPL, Business Source License, Commons Clause, PolyForm, non-commercial, or no-derivatives dependency was found in the audited graphs. +The 2026-10-14 review covers all 40 newly introduced Go modules and the updated `github.com/bits-and-blooms/bitset@v1.24.2`, including modules outside the release closure. The selected module licenses in the full graph are Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC, MIT, and the existing build-only MPL-2.0 exception; the release closure uses only Apache-2.0, BSD-3-Clause, and MIT. Roaring retains an additional Go BSD-3-Clause notice, and the pinned Bleve source retains Apache-licensed Lucene, OpenSearch/Elasticsearch, and historical Cockroach attributions. + +The build-only `github.com/blevesearch/stempel@v0.2.0` includes Polish dictionary data offered under multiple licenses; this audit selects its stated Apache-2.0 option. Bleve also contains a Wikipedia-derived CC-BY-SA benchmark fixture in `search/facet/benchmark_data.txt`, read only by test code and absent from every release target's selected source and embedded files. The build-only `go.etcd.io/gofail@v0.2.0` has a CoreOS NOTICE, retained in its pinned source distribution. These source-only materials must retain their own attribution and license terms if redistributed. + +No dependency requiring GPL, AGPL, LGPL, SSPL, Business Source License, Commons Clause, PolyForm, non-commercial, or no-derivatives terms was found in the audited release closure. ## Release obligations diff --git a/README-zh_CN.md b/README-zh_CN.md index c340255..2d20c5b 100644 --- a/README-zh_CN.md +++ b/README-zh_CN.md @@ -25,6 +25,8 @@ Prods 是供制造商与经销商自托管的技术产品目录与 RFQ(询价 指南描述 2026-09-17 查看时的工作目录(`VERSION`:`v0.6.8`)。源代码、测试与发布 workflow 展示已实现的机制;本次核查未验证运行中的生产部署、客户负载或可用性目标。Release workflow 发布构建产物,不部署运行中的站点。历史需求与 ADR 保持内部文档属性;指南提供源代码链接与说明,不重新公开内部内容。 +架构与工程案例指南保留 2026-09-17、`v0.6.8` 的核查范围。本 README 的数值搜索概览与对应运维章节描述 2026-10-14 核查的 `v0.7.0` 后端变更;这不代表历史指南已重新全面核查,也不构成生产部署证据。 + ## 已实现范围 | 领域 | 代码仓库中的实现 | @@ -125,12 +127,21 @@ data_dir=data backup_dir=backups trusted_proxies=127.0.0.1/32 asset_gc_grace_days=7 +spec_search_backend=bleve ``` 对应环境变数包含 `PRODS_LISTEN`、`PRODS_BASE_URL`、`PRODS_DATA_DIR`、`PRODS_BACKUP_DIR`、`PRODS_CONFIG`、`PRODS_TRUSTED_PROXIES`。 面向公网服务时,请由受信任的反向代理终止 HTTPS,明确配置正式 HTTPS Base URL,只接受已知 proxy IP/CIDR 提供的 forwarded client 信息。不要用通用文件服务器公开 data、backup、secret 或 generated-history 目录。 +## 数值规格搜索(后端) + +`v0.7.0` 新增受控数值 profile、规范化/采用命令与 `POST /api/search/specs`。每个 Product + Spec 明确选择一份共享 Raw 来源;来源语言记录 provenance,翻译仍是展示内容。搜索使用已激活公开 `PublicView` 中采用的数值事实及冻结 profile,所有条件以 AND 组合,只纳入 `MATCH`。缺失、过期、有条件或不兼容的事实为 `UNKNOWN`,不纳入结果。精确判定后才计数、分页;待发布修改不取代当前公开版本。 + +候选后端默认为 `bleve`;可用 `--spec-search-backend`、`PRODS_SPEC_SEARCH_BACKEND` 或 `prods.ini` 的 `[prods] spec_search_backend` 选择 `sqlite`,优先序为 CLI > ENV > INI > 默认值。Bleve 私有派生文件位于 `/generated/search/specs`;SQLite 仍是正式数据来源。候选不可用或不符时,改用已准入公开事实完整精确扫描。Adapter 初次打开失败时,必须修正原因并重启;内部诊断/重建方法没有 Admin/Public endpoint。 + +数值 profile 编辑器、采用/来源选择控件与 Public 数值筛选 UI 延后提供。现有文本搜索仍独立运行。配置、migration 及带 Admin 认证/Public API 示例见[运维:数值规格搜索](docs/zh-CN/operations.md#数值规格搜索后端)。内置合成样本不会自动配置数值 profile 或填入已采用数值事实。 + ## 发布与 RFQ 行为 Prods 将 Domain Truth、Website 配置、Public Copy 与产生后的公开 representation 分开管理。 @@ -224,7 +235,7 @@ CGO_ENABLED=0 go build -trimpath -o prods ./cmd/prods 创建含嵌入版本、对应 sample data、源代码 metadata、许可文件与 checksum 的 Linux amd64、Windows amd64、macOS Intel 与 macOS Apple Silicon release: ```sh -./scripts/build-release.sh v0.6.8 +./scripts/build-release.sh v0.7.0 ``` 输出位于 `dist//`。根目录 [`VERSION`](VERSION) 是唯一版本来源;script 会拒绝不同参数,GitHub Actions 也会拒绝不同 tag。同一版本会写入每个 binary、sample payload、`BUILD_INFO.txt`、产物目录与 GitHub Release tag。Cross-build 只能证明可编译;Windows 必须在 Windows 实际运行后才能宣称通过 runtime 验证,两个 macOS 产物在 native macOS 验收前均明确列为未测试。 @@ -241,6 +252,6 @@ Release script 要求 tracked working tree干净,并将版本与 source revisi Prods Community 采用 [GNU Affero General Public License v3.0 only](LICENSE),SPDX 标识为 `AGPL-3.0-only`。通过网络使用修改版 Community 程序的用户,有权获取对应源代码。 -当前 release closure 的第三方组件仅使用 MIT、BSD、Apache-2.0、ISC/0BSD 或 public-domain 类型许可,未发现 GPL、AGPL、LGPL、SSPL、BSL、Commons Clause、PolyForm、非商用或禁止衍生许可。完整范围、build-only 例外、未来闭源商业发行条件与自动检查方式请见 [LICENSE_POLICY.md](LICENSE_POLICY.md);保留声明与许可文字请见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。 +已审计 Go release closure 采用 MIT、BSD-3-Clause 与 Apache-2.0;production UI 采用 MIT、BSD-3-Clause 与 0BSD。完整 build/test graph 另有经过审查的例外及 source-only fixture,包含 MPL-2.0、CC-BY-4.0 与 CC-BY-SA 素材;不能将这些条款概括为已交付 runtime code 的许可。所选许可、精确范围、source-only 义务、未来闭源商业发行条件及自动检查方式见 [LICENSE_POLICY.md](LICENSE_POLICY.md);保留的 runtime 声明与许可文字见 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。 第三方许可不会自动阻止未来另行许可的 proprietary edition;主要条件是 Prods 本身所有贡献都必须具有可重新许可的权利。在正式 Contributor License Agreement、接受 CLA 的法律实体及记录流程创建前,不应合并实质外部贡献。Product data、图片、datasheet、RFQ、backup 与其他客户内容不会因使用 Prods 而自动改采 AGPL。 diff --git a/README-zh_TW.md b/README-zh_TW.md index b57540a..6555b38 100644 --- a/README-zh_TW.md +++ b/README-zh_TW.md @@ -25,6 +25,8 @@ Prods 是供製造商與經銷商自架的技術產品型錄與 RFQ(詢價請 指南描述 2026-09-17 檢視的工作目錄(`VERSION`:`v0.6.8`)。原始碼、測試與發布 workflow 呈現已實作機制;本次查核未驗證運行中的正式部署、客戶負載或可用性目標。Release workflow 發布成品,不部署運行中的站點。歷史需求與 ADR 保持內部文件身分;指南提供原始碼連結與說明,不重新公開內部內容。 +架構與工程案例指南保留 2026-09-17、`v0.6.8` 的查核範圍。本 README 的數值搜尋概覽與對應維運章節描述 2026-10-14 查核的 `v0.7.0` 後端變更;這不代表歷史指南已重新全面查核,也不構成正式部署證據。 + ## 已實作範圍 | 領域 | 儲存庫中的實作 | @@ -125,12 +127,21 @@ data_dir=data backup_dir=backups trusted_proxies=127.0.0.1/32 asset_gc_grace_days=7 +spec_search_backend=bleve ``` 對應環境變數包含 `PRODS_LISTEN`、`PRODS_BASE_URL`、`PRODS_DATA_DIR`、`PRODS_BACKUP_DIR`、`PRODS_CONFIG`、`PRODS_TRUSTED_PROXIES`。 對外服務時,請由受信任的反向代理終止 HTTPS,明確設定正式 HTTPS Base URL,只接受已知 proxy IP/CIDR 提供的 forwarded client 資訊。不要用通用檔案伺服器公開 data、backup、secret 或 generated-history 目錄。 +## 數值規格搜尋(後端) + +`v0.7.0` 新增受控數值 profile、正規化/採用命令與 `POST /api/search/specs`。每個 Product + Spec 明確選擇一份共用 Raw 來源;來源語系記錄 provenance,翻譯仍是展示內容。搜尋使用已啟用公開 `PublicView` 中採用的數值事實及凍結 profile,所有條件以 AND 結合,只納入 `MATCH`。缺失、過期、有條件或不相容的事實為 `UNKNOWN`,不納入結果。精確判定後才計數、分頁;待發布修改不取代目前公開版本。 + +候選後端預設為 `bleve`;可用 `--spec-search-backend`、`PRODS_SPEC_SEARCH_BACKEND` 或 `prods.ini` 的 `[prods] spec_search_backend` 選擇 `sqlite`,優先序為 CLI > ENV > INI > 預設值。Bleve 私有衍生檔位於 `/generated/search/specs`;SQLite 仍是正式資料來源。候選不可用或不符時,改以已准入公開事實完整精確掃描。Adapter 初次開啟失敗時,必須修正原因並重啟;內部診斷/重建方法沒有 Admin/Public endpoint。 + +數值 profile 編輯器、採用/來源選擇控制與 Public 數值篩選 UI 延後提供。既有文字搜尋仍獨立運作。設定、migration 及具 Admin 認證/Public API 範例見[維運:數值規格搜尋](docs/zh-TW/operations.md#數值規格搜尋後端)。內建合成樣本不會自動設定數值 profile 或填入已採用數值事實。 + ## 發布與 RFQ 行為 Prods 將 Domain Truth、Website 設定、Public Copy 與產生後的公開 representation 分開管理。 @@ -249,7 +260,7 @@ CGO_ENABLED=0 go build -trimpath -o prods ./cmd/prods 建立含內嵌版本、對應 sample data、原始碼 metadata、授權文件與 checksum 的 Linux amd64、Windows amd64、macOS Intel 與 macOS Apple Silicon release: ```sh -./scripts/build-release.sh v0.6.8 +./scripts/build-release.sh v0.7.0 ``` 輸出位於 `dist//`。根目錄 [`VERSION`](VERSION) 是唯一版本來源;script 會拒絕不同參數,GitHub Actions 也會拒絕不同 tag。同一版本會寫入每個 binary、sample payload、`BUILD_INFO.txt`、產物目錄與 GitHub Release tag。Cross-build 只能證明可編譯;Windows 必須在 Windows 實際執行後才能宣稱通過 runtime 驗證,兩個 macOS 產物在 native macOS 驗收前均明確列為未測試。 @@ -266,6 +277,6 @@ Release script 要求 tracked working tree 乾淨,並將版本與 source revis Prods Community 採 [GNU Affero General Public License v3.0 only](LICENSE),SPDX 識別為 `AGPL-3.0-only`。透過網路使用修改版 Community 程式的使用者,有權取得對應原始碼。 -目前 release closure 的第三方元件僅使用 MIT、BSD、Apache-2.0、ISC/0BSD 或 public-domain 類型授權,未發現 GPL、AGPL、LGPL、SSPL、BSL、Commons Clause、PolyForm、非商用或禁止衍生授權。完整範圍、build-only 例外、未來封閉商用條件與自動檢查方式請見 [LICENSE_POLICY.md](LICENSE_POLICY.md);保留聲明與授權文字請見 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。 +已稽核 Go release closure 採 MIT、BSD-3-Clause 與 Apache-2.0;production UI 採 MIT、BSD-3-Clause 與 0BSD。完整 build/test graph 另有經審查的例外及 source-only fixture,包含 MPL-2.0、CC-BY-4.0 與 CC-BY-SA 素材;不能將這些條款概括為已交付 runtime code 的授權。選用授權、精確範圍、source-only 義務、未來封閉商用條件及自動檢查方式見 [LICENSE_POLICY.md](LICENSE_POLICY.md);保留的 runtime 聲明與授權文字見 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md)。 第三方授權不會自動阻止未來另行授權的 proprietary edition;主要條件是 Prods 本身所有貢獻都必須具有可重新授權的權利。在正式 Contributor License Agreement、接受 CLA 的法律實體及紀錄流程建立前,不應合併實質外部貢獻。Product data、圖片、datasheet、RFQ、backup 與其他客戶內容不會因使用 Prods 而自動改採 AGPL。 diff --git a/README.md b/README.md index e86fd67..f1c43b1 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ One Go process serves the public website, embedded Admin application and lifecyc These guides describe the working tree reviewed on 2026-09-17 (`VERSION`: `v0.6.8`). Source, tests and release workflows demonstrate implemented mechanisms; this review did not verify a live production deployment, customer workload or availability target. Release workflows publish artifacts, not a running site. Historical requirements and ADRs remain internal; the guides provide source-linked explanations without republishing them. +Architecture and case-study guides retain their 2026-09-17 `v0.6.8` review scope. The numeric-search overview here and the corresponding Operations section describe the `v0.7.0` backend changes reviewed on 2026-10-14; they do not renew the historical guides or establish production deployment evidence. + ## Implemented scope | Area | Implementation in this repository | @@ -125,12 +127,21 @@ data_dir=data backup_dir=backups trusted_proxies=127.0.0.1/32 asset_gc_grace_days=7 +spec_search_backend=bleve ``` Equivalent environment variables include `PRODS_LISTEN`, `PRODS_BASE_URL`, `PRODS_DATA_DIR`, `PRODS_BACKUP_DIR`, `PRODS_CONFIG`, and `PRODS_TRUSTED_PROXIES`. For an Internet-facing deployment, terminate HTTPS at a trusted reverse proxy, set the canonical HTTPS Base URL explicitly, and accept forwarded client information only from known proxy IPs or CIDRs. Never expose data, backup, secret, or generated-history directories through a generic file server. +## Numeric specification search (backend) + +`v0.7.0` adds controlled numeric profiles, normalization/adoption commands and `POST /api/search/specs`. A Product + Spec has one explicitly selected shared Raw source; its source locale records provenance, while translations remain display content. Search reads adopted numeric facts and their frozen profiles from the active published `PublicView`, combines predicates with AND, and includes only `MATCH`. Missing, stale, conditional or incompatible facts remain `UNKNOWN` and are excluded. Counts and pagination follow exact evaluation; pending edits do not replace the active public revision. + +The default candidate backend is `bleve`; `sqlite` is selectable through `--spec-search-backend`, `PRODS_SPEC_SEARCH_BACKEND`, or `[prods] spec_search_backend` in `prods.ini`, using CLI > ENV > INI > default precedence. Bleve's private derived files live at `/generated/search/specs`; SQLite remains authoritative. Unavailable or mismatched candidates fall back to a complete exact scan of admitted public facts. An adapter that failed its initial open requires fixing the cause and restarting; internal diagnostic/rebuild methods have no Admin/Public endpoint. + +The numeric-profile editor, adoption/source-selection controls and numeric Public filter UI are deferred. Existing text search remains separate. See [Operations: numeric specification search](docs/en/operations.md#numeric-specification-search-backend) for configuration, migration and authenticated Admin/Public API examples. The bundled synthetic sample does not configure numeric profiles or populate adopted numeric facts automatically. + ## Publishing and RFQ behavior Prods keeps Domain Truth, Website configuration, Public Copy, and generated public representations separate. @@ -249,7 +260,7 @@ The frontend build writes embedded assets under `internal/webapp/static/`; commi Build a complete Linux amd64, Windows amd64, macOS Intel, and macOS Apple Silicon release with an embedded version, matching sample data, source metadata, notices, and checksums: ```sh -./scripts/build-release.sh v0.6.8 +./scripts/build-release.sh v0.7.0 ``` Output is written to `dist//`. Root [`VERSION`](VERSION) is the release source of truth; the script rejects any different argument, and GitHub Actions rejects a different tag. The same version is embedded in every binary, written into the sample payload and `BUILD_INFO.txt`, and used for the artifact directory and GitHub Release tag. Cross-builds prove compilation only. Windows must be tested on Windows before claiming Windows runtime verification; both macOS artifacts remain explicitly untested until native macOS acceptance is recorded. @@ -266,6 +277,6 @@ The release script requires a clean tracked working tree and records the version Prods Community is licensed under the [GNU Affero General Public License v3.0 only](LICENSE), identified as `AGPL-3.0-only`. Network users are entitled to the corresponding source for the running version under that license. -The current release closure contains only third-party components under MIT, BSD, Apache-2.0, ISC/0BSD, or public-domain terms. The audited graphs contain no third-party GPL, AGPL, LGPL, SSPL, BSL, Commons Clause, PolyForm, non-commercial, or no-derivatives dependency. See [LICENSE_POLICY.md](LICENSE_POLICY.md) for the full scope, build-only exceptions, future closed-source conditions, and automated gate; retained notices and license texts are in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). +The audited Go release closure uses MIT, BSD-3-Clause and Apache-2.0 terms; the production UI uses MIT, BSD-3-Clause and 0BSD. The full build/test graphs have separately reviewed exceptions and source-only fixtures, including MPL-2.0, CC-BY-4.0 and CC-BY-SA material; those terms are not a blanket description of shipped runtime code. See [LICENSE_POLICY.md](LICENSE_POLICY.md) for the selected license options, exact scope, source-only obligations, future closed-source conditions and automated gate; retained runtime notices and license texts are in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). Permissive third-party licenses do not prevent a future separately licensed proprietary edition. The central condition is ownership of Prods contributions: before merging material external contributions, the project needs a Contributor License Agreement that explicitly grants the accepting legal entity the rights required to relicense those contributions in open-source and proprietary products. A commit sign-off or DCO alone is not a substitute. Product data, images, datasheets, RFQs, backups, and other customer content are not automatically relicensed as Prods source code. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 17a61bc..559e2e0 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1020,3 +1020,595 @@ Copyright notices: - muesli/cancelreader: Copyright (c) 2022 Erik Geiser and Christian Muehlhaeuser. - uniseg: Copyright (c) 2019 Oliver Kuederle. - terminfo: Copyright (c) 2016 Anmol Sethi. + +## Search dependency notices + +The following pinned modules are linked by the CGO-disabled release builds for Linux/amd64, Windows/amd64, macOS/amd64, or macOS/arm64. Apache-2.0 modules use the complete Apache license text retained earlier in this file; their copyright and source attributions are retained below. The other modules retain their complete pinned license texts in this section. + +| Module | Version | License / retained notice | +| --- | --- | --- | +| github.com/RoaringBitmap/roaring/v2 | v2.14.5 | Apache-2.0 plus the Go BSD-3-Clause appendix below | +| github.com/bits-and-blooms/bitset | v1.24.2 | BSD-3-Clause | +| github.com/blevesearch/bleve/v2 | v2.6.1 | Apache-2.0 | +| github.com/blevesearch/bleve_index_api | v1.4.1 | Apache-2.0 | +| github.com/blevesearch/geo | v0.2.6 | Apache-2.0 | +| github.com/blevesearch/go-porterstemmer | v1.0.3 | MIT | +| github.com/blevesearch/gtreap | v0.1.1 | MIT | +| github.com/blevesearch/mmap-go | v1.2.0 | BSD-3-Clause | +| github.com/blevesearch/scorch_segment_api/v2 | v2.4.10 | Apache-2.0 | +| github.com/blevesearch/segment | v0.9.1 | Apache-2.0 | +| github.com/blevesearch/snowballstem | v0.9.0 | BSD-3-Clause | +| github.com/blevesearch/upsidedown_store_api | v1.0.2 | Apache-2.0 | +| github.com/blevesearch/vellum | v1.2.0 | Apache-2.0 | +| github.com/blevesearch/zapx/v11 | v11.4.3 | Apache-2.0 | +| github.com/blevesearch/zapx/v12 | v12.4.3 | Apache-2.0 | +| github.com/blevesearch/zapx/v13 | v13.4.3 | Apache-2.0 | +| github.com/blevesearch/zapx/v14 | v14.4.3 | Apache-2.0 | +| github.com/blevesearch/zapx/v15 | v15.4.3 | Apache-2.0 | +| github.com/blevesearch/zapx/v16 | v16.3.4 | Apache-2.0 | +| github.com/blevesearch/zapx/v17 | v17.2.3 | Apache-2.0 | +| github.com/golang/snappy | v1.0.0 | BSD-3-Clause | +| github.com/json-iterator/go | v0.0.0-20171115153421-f7279a603ede | MIT | +| github.com/mschoch/smat | v0.2.0 | Apache-2.0 | +| go.etcd.io/bbolt | v1.4.0 | MIT | +| google.golang.org/protobuf | v1.36.6 | BSD-3-Clause | + +### Apache-2.0 copyright and source attributions + +#### github.com/RoaringBitmap/roaring/v2@v2.14.5 + +```text +Copyright (c) 2009 The Go Authors. All rights reserved. +Copyright (c) 2016 by the roaring authors. +Copyright 2016 by the authors +``` + +#### github.com/blevesearch/bleve/v2@v2.6.1 + +```text +Copyright (c) 2014 Couchbase, Inc. +Copyright (c) 2015 Couchbase, Inc. +Copyright (c) 2016 Couchbase, Inc. +Copyright (c) 2017 Couchbase, Inc. +Copyright (c) 2018 Couchbase, Inc. +Copyright (c) 2019 Couchbase, Inc. +Copyright (c) 2020 Couchbase, Inc. +Copyright (c) 2021 Couchbase, Inc. +Copyright (c) 2022 Couchbase, Inc. +Copyright (c) 2023 Couchbase, Inc. +Copyright (c) 2024 Couchbase, Inc. +Copyright (c) 2025 Couchbase, Inc. +Copyright (c) 2026 Couchbase, Inc. +Copyright 2014 The Cockroach Authors. +``` + +#### github.com/blevesearch/bleve_index_api@v1.4.1 + +```text +Copyright (c) 2014 Couchbase, Inc. +Copyright (c) 2015 Couchbase, Inc. +Copyright (c) 2020 Couchbase, Inc. +Copyright (c) 2021 Couchbase, Inc. +Copyright (c) 2022 Couchbase, Inc. +``` + +#### github.com/blevesearch/geo@v0.2.6 + +```text +Copyright (c) 2022 Couchbase, Inc. +Copyright (c) 2023 Couchbase, Inc. +Copyright (c) 2026 Couchbase, Inc. +Copyright 2014 Google Inc. All rights reserved. +Copyright 2015 Google Inc. All rights reserved. +Copyright 2016 Google Inc. All rights reserved. +Copyright 2017 Google Inc. All rights reserved. +Copyright 2018 Google Inc. All rights reserved. +Copyright 2019 Google Inc. All rights reserved. +Copyright 2020 Google Inc. All rights reserved. +Copyright 2021 Google Inc. All rights reserved. +Copyright 2023 Google Inc. All Rights Reserved. +Copyright 2023 Google Inc. All rights reserved. +``` + +#### github.com/blevesearch/scorch_segment_api/v2@v2.4.10 + +```text +Copyright (c) 2017 Couchbase, Inc. +Copyright (c) 2021 Couchbase, Inc. +Copyright (c) 2026 Couchbase, Inc. +``` + +#### github.com/blevesearch/segment@v0.9.1 + +```text +Copyright (c) 2014 Couchbase, Inc. +Copyright (c) 2015 Couchbase, Inc. +``` + +#### github.com/blevesearch/upsidedown_store_api@v1.0.2 + +```text +Copyright (c) 2014 Couchbase, Inc. +Copyright (c) 2016 Couchbase, Inc. +``` + +#### github.com/blevesearch/vellum@v1.2.0 + +```text +Copyright (c) 2017 Couchbase, Inc. +Copyright (c) 2018 Couchbase, Inc. +``` + +#### github.com/blevesearch/zapx/v11@v11.4.3 + +```text +Copyright (c) 2017 Couchbase, Inc. +Copyright (c) 2018 Couchbase, Inc. +Copyright (c) 2020 Couchbase, Inc. +``` + +#### github.com/blevesearch/zapx/v12@v12.4.3 + +```text +Copyright (c) 2017 Couchbase, Inc. +Copyright (c) 2018 Couchbase, Inc. +Copyright (c) 2019 Couchbase, Inc. +Copyright (c) 2020 Couchbase, Inc. +``` + +#### github.com/blevesearch/zapx/v13@v13.4.3 + +```text +Copyright (c) 2017 Couchbase, Inc. +Copyright (c) 2018 Couchbase, Inc. +Copyright (c) 2019 Couchbase, Inc. +Copyright (c) 2020 Couchbase, Inc. +``` + +#### github.com/blevesearch/zapx/v14@v14.4.3 + +```text +Copyright (c) 2017 Couchbase, Inc. +Copyright (c) 2018 Couchbase, Inc. +Copyright (c) 2019 Couchbase, Inc. +Copyright (c) 2020 Couchbase, Inc. +``` + +#### github.com/blevesearch/zapx/v15@v15.4.3 + +```text +Copyright (c) 2017 Couchbase, Inc. +Copyright (c) 2018 Couchbase, Inc. +Copyright (c) 2019 Couchbase, Inc. +Copyright (c) 2020 Couchbase, Inc. +``` + +#### github.com/blevesearch/zapx/v16@v16.3.4 + +```text +Copyright (c) 2017 Couchbase, Inc. +Copyright (c) 2018 Couchbase, Inc. +Copyright (c) 2019 Couchbase, Inc. +Copyright (c) 2020 Couchbase, Inc. +Copyright (c) 2023 Couchbase, Inc. +Copyright (c) 2024 Couchbase, Inc. +``` + +#### github.com/blevesearch/zapx/v17@v17.2.3 + +```text +Copyright (c) 2017 Couchbase, Inc. +Copyright (c) 2018 Couchbase, Inc. +Copyright (c) 2019 Couchbase, Inc. +Copyright (c) 2020 Couchbase, Inc. +Copyright (c) 2023 Couchbase, Inc. +Copyright (c) 2024 Couchbase, Inc. +Copyright (c) 2025 Couchbase, Inc. +Copyright (c) 2026 Couchbase, Inc. +``` + +#### github.com/mschoch/smat@v0.2.0 + +```text +Copyright (c) 2016 Marty Schoch +``` + +Bleve includes adaptations of Apache Lucene geo, numeric, and language-analysis code. The following source attributions are retained from the pinned Bleve source. + +#### Bleve analysis/lang/en/plural_stemmer.go + +```text +/* + This code was ported from the Open Search Project + https://github.com/opensearch-project/OpenSearch/blob/main/modules/analysis-common/src/main/java/org/opensearch/analysis/common/EnglishPluralStemFilter.java + The algorithm itself was created by Mark Harwood + https://github.com/markharwood +*/ + +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + */ + +/* + * Licensed to Elasticsearch under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +``` + +#### Bleve index/scorch/int.go + +```text +// Copyright 2014 The Cockroach Authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +// implied. See the License for the specific language governing +// permissions and limitations under the License. + +// This code originated from: +// https://github.com/cockroachdb/cockroach/blob/2dd65dde5d90c157f4b93f92502ca1063b904e1d/pkg/util/encoding/encoding.go + +// Modified to not use pkg/errors +``` + +### Roaring Go standard-library appendix + +```text +Portions of runcontainer.go are from the Go standard library, which is licensed +under: + +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following disclaimer + in the documentation and/or other materials provided with the + distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +``` + +### github.com/bits-and-blooms/bitset@v1.24.2 + +Pinned LICENSE: + +```text +Copyright (c) 2014 Will Fitzgerald. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +``` + +### github.com/blevesearch/go-porterstemmer@v1.0.3 + +Pinned LICENSE: + +```text +Copyright (c) 2013 Charles Iliya Krempeaux :: http://changelog.ca/ + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +``` + +### github.com/blevesearch/gtreap@v0.1.1 + +Pinned LICENSE: + +```text +Copyright (C) 2012 Steve Yen + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +``` + +### github.com/blevesearch/mmap-go@v1.2.0 + +Additional notices from linked source files: + +```text +Copyright 2011 Evan Shaw. All rights reserved. +``` + +Pinned LICENSE: + +```text +Copyright (c) 2011, Evan Shaw +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the copyright holder nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +``` + +### github.com/blevesearch/snowballstem@v0.9.0 + +Pinned COPYING: + +```text +Copyright (c) 2001, Dr Martin Porter +Copyright (c) 2004,2005, Richard Boulton +Copyright (c) 2013, Yoshiki Shibukawa +Copyright (c) 2006,2007,2009,2010,2011,2014-2019, Olly Betts +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions +are met: + + 1. Redistributions of source code must retain the above copyright notice, + this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + 3. Neither the name of the Snowball project nor the names of its contributors + may be used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +``` + +### github.com/golang/snappy@v1.0.0 + +Additional notices from linked source files: + +```text +Copyright 2011 The Snappy-Go Authors. All rights reserved. +Copyright 2016 The Go Authors. All rights reserved. +Copyright 2016 The Snappy-Go Authors. All rights reserved. +Copyright 2020 The Go Authors. All rights reserved. +``` + +Pinned LICENSE: + +```text +Copyright (c) 2011 The Snappy-Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +``` + +### github.com/json-iterator/go@v0.0.0-20171115153421-f7279a603ede + +Pinned LICENSE: + +```text +MIT License + +Copyright (c) 2016 json-iterator + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +### go.etcd.io/bbolt@v1.4.0 + +Pinned LICENSE: + +```text +The MIT License (MIT) + +Copyright (c) 2013 Ben Johnson + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +``` + +### google.golang.org/protobuf@v1.36.6 + +Additional notices from linked source files: + +```text +Copyright 2018 The Go Authors. All rights reserved. +Copyright 2019 The Go Authors. All rights reserved. +Copyright 2020 The Go Authors. All rights reserved. +Copyright 2022 The Go Authors. All rights reserved. +Copyright 2024 The Go Authors. All rights reserved. +``` + +Pinned LICENSE: + +```text +Copyright (c) 2018 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +``` diff --git a/VERSION b/VERSION index ca46cd2..8b20e48 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.6.8 +v0.7.0 diff --git a/cmd/prods/main.go b/cmd/prods/main.go index 61ff50c..1946cee 100644 --- a/cmd/prods/main.go +++ b/cmd/prods/main.go @@ -172,6 +172,7 @@ func runWithStop(stop <-chan struct{}) (returnErr error) { } inspection := sqlite.Inspect(dbPath) slog.Info("resolved Prods runtime", + "spec_search_backend", options.SpecSearchBackend, "version", applicationVersion, "go", runtime.Version(), "config", options.ConfigPath, @@ -380,7 +381,8 @@ func runWithStop(stop <-chan struct{}) (returnErr error) { } } app, generatedToken, err := webapp.New(store, webapp.Config{ - BaseURL: *baseURL, AdminToken: *adminToken, EnablePOCAdmin: pocMode, + SpecSearchBackend: options.SpecSearchBackend, + BaseURL: *baseURL, AdminToken: *adminToken, EnablePOCAdmin: pocMode, SecureCookies: secureCookies, EnforceHost: true, WorkDir: filepath.Join(*dataDir, "work"), PublicDir: filepath.Join(*dataDir, "generated", "public"), AssetDir: filepath.Join(*dataDir, "assets"), DatabasePath: dbPath, BackupDir: *backupDir, ResourceGate: resourceGate, diff --git a/cmd/prods/migration_test.go b/cmd/prods/migration_test.go index e12784a..82e4b93 100644 --- a/cmd/prods/migration_test.go +++ b/cmd/prods/migration_test.go @@ -89,6 +89,16 @@ func newMigrationFixture(t *testing.T) migrationFixture { t.Fatal(err) } if _, err := db.Exec(` + DROP TABLE public_spec_projection_fields; + DROP TABLE public_spec_projection_products; + DROP TABLE spec_numeric_profile_work; + DROP TABLE spec_normalization_selections; + DROP TABLE product_spec_sources; + DROP TABLE spec_numeric_profiles; + ALTER TABLE product_spec_values DROP COLUMN source_origin_locale; + ALTER TABLE normalized_values DROP COLUMN numeric_schema_version; + ALTER TABLE normalized_values DROP COLUMN source_origin_locale; + ALTER TABLE normalized_values DROP COLUMN profile_revision; DROP TABLE search_submission_jobs; DROP TABLE search_notification_state; DROP TABLE search_integration_settings; diff --git a/docs/en/operations.md b/docs/en/operations.md index 3d6510e..9bfa415 100644 --- a/docs/en/operations.md +++ b/docs/en/operations.md @@ -104,3 +104,166 @@ The full source-verification commands remain in the [README](../../README.md). F ## Evidence still needed for a production account An operator can supplement this repository with the deployed version/source revision, host/platform, service/proxy configuration, TLS validation, persistent-root mapping, backup retention/off-host destination, a dated restore exercise and measured workload/incident evidence. Keep credentials and customer data private. Until that evidence exists, describe the implementation and its tests rather than an unverified production success story. + +## Numeric specification search (backend) + +This section documents the `v0.7.0` backend reviewed on 2026-10-14. Earlier sections and the architecture/case-study guides retain their historical `v0.6.8` review scope. Numeric profile editing, shared-source/adoption controls and Public numeric filter UI are deferred; existing text search is separate. + +### Configure the candidate backend + +`bleve` is the default. The flag `--spec-search-backend`, environment variable `PRODS_SPEC_SEARCH_BACKEND` and INI key `[prods] spec_search_backend` accept `bleve` or `sqlite` with CLI > ENV > INI > default precedence. Select one of these alternatives and restart using the same site paths: + +```ini +[prods] +spec_search_backend=bleve +``` + +```sh +PRODS_SPEC_SEARCH_BACKEND=sqlite ./prods-linux-amd64 +./prods-linux-amd64 --spec-search-backend sqlite +``` + +Bleve is embedded in the Go process and writes a private, rebuildable candidate index at `/generated/search/specs`. SQLite remains the primary store; `sqlite` uses its derived Spec-ID projection instead. Never expose the generated/search directory through a filesystem server. Candidate replacement/rebuild runs in the publication worker after active public state changes. + +If candidates fail, are incomplete, or do not match the admitted inventory/revisions, queries scan the complete active public inventory and evaluate it exactly. This preserves results and counts at additional scan cost; it is not a latency guarantee. Logs report candidate-index failures. Later worker ticks retry refresh on an opened adapter; a Bleve adapter that failed its initial open is not reopened by those ticks. Fix its filesystem/ownership/resource cause and restart, or restart with `sqlite`. `SpecSearchDiagnostics` and `RebuildSpecSearchIndex` are internal methods, with no Admin/Public diagnostics or rebuild endpoint. An unhealthy authoritative public index still returns 503. + +### Prepare and adopt a numeric fact + +A Product + Spec has one selected shared Raw source. `source_origin_locale` records the source provenance; it does not create a different numeric fact per public language. Localized display text stays separate. Migration 19 retains legacy Raw/history, selects the source only where there is exactly one row per Product + Spec, and leaves multiple-source legacy groups unselected until an explicit command. It creates no numeric profiles or adopted facts from old display units/history. Published Current Products receive a new source revision and publication intent; the previous valid public activation remains until replacement. The bundled synthetic sample likewise provides Raw specifications, not configured profiles/adopted numeric facts. + +Use a legitimate same-origin Admin login and its private cookie jar (`admin_cookie_jar`); obtain `admin_csrf` from the signed-in Admin page's `csrf-token` meta value. Set `base` to the configured Base URL and the ID variables to existing domain IDs. Admin reads require `catalog.view`; writes require `catalog.edit`, the session cookie and `X-CSRF-Token`. These are browser Admin commands, not external write tokens. The following bodies are illustrative files: substitute actual IDs and the revisions observed when opening the operation. Each mutation example is a separate operation, not a script to run blindly in sequence. + +| Method / path | Capability / body | +| --- | --- | +| `PUT /admin/api/specs/{id}/numeric-profile` | `catalog.edit`; `expected_revision`, `profile` | +| `GET /admin/api/products/{id}/spec-values` | `catalog.view`; inspect stored Raw sources | +| `POST /admin/api/products/{id}/spec-source` | `catalog.edit`; `spec_id`, `spec_value_id`, `expected_product_revision`, `expected_source_revision`, `expected_source_selection_revision` | +| `GET /admin/api/spec-values/{id}/normalization-context` | `catalog.view`; frozen Raw, profile, `expected`, `selection`, `source_selection` | +| `POST /admin/api/spec-values/{id}/normalize` | `catalog.edit`; `expected` | +| `POST /admin/api/spec-values/{id}/normalized` | `catalog.edit`; `expected`, `value_json` (JSON encoded as a string) | +| `POST /admin/api/spec-values/{id}/normalization/automatic` | `catalog.edit`; `expected`, `candidate_id` | + +Example `profile-command.json` for an operating-voltage Spec (use the Spec definition's actual `revision` as `expected_revision`): + +```json +{ + "expected_revision": 1, + "profile": { + "quantity": "voltage", + "semantic_role": "operating_support", + "allowed_shapes": ["scalar", "discrete_set", "interval_set"], + "allowed_operators": ["contains_point", "contains_range", "overlaps_range"], + "canonical_unit": "V", + "declared_input_unit": "V", + "numeric_format": "decimal-dot", + "schema_version": 1, + "parser_version": "electrical-parser-1", + "unit_registry_version": "electrical-1" + } +} +``` + +```sh +curl -b "$admin_cookie_jar" -H "X-CSRF-Token: $admin_csrf" \ + -H 'Content-Type: application/json' -X PUT "$base/admin/api/specs/$spec_id/numeric-profile" --data-binary @profile-command.json +curl -b "$admin_cookie_jar" \ + "$base/admin/api/products/$product_id/spec-values" +curl -b "$admin_cookie_jar" \ + "$base/admin/api/spec-values/$spec_value_id/normalization-context" +``` + +If the context has no selected shared source, select one first with `source-command.json`. Use context `expected.product_revision`, `expected.source_revision` and `source_selection.revision` for the three expectations, then open a new normalization context after success: + +```json +{ + "spec_id": "spec_input_voltage", + "spec_value_id": "spv_selected_source", + "expected_product_revision": 7, + "expected_source_revision": 1, + "expected_source_selection_revision": 0 +} +``` + +```sh +curl -b "$admin_cookie_jar" -H "X-CSRF-Token: $admin_csrf" \ + -H 'Content-Type: application/json' "$base/admin/api/products/$product_id/spec-source" --data-binary @source-command.json +``` + +Save the following as `normalize-command.json`, replacing all five values with the entire `expected` object returned by `normalization-context`: `product_revision`, `source_revision`, `spec_semantic_version`, `profile_revision`, and `selection_revision`. The first four must be positive; the selection revision may be zero. The write transaction rechecks every expectation and the selected shared source. + +```json +{ + "expected": { + "product_revision": 7, + "source_revision": 1, + "spec_semantic_version": 2, + "profile_revision": 1, + "selection_revision": 0 + } +} +``` + +```sh +curl -b "$admin_cookie_jar" -H "X-CSRF-Token: $admin_csrf" \ + -H 'Content-Type: application/json' "$base/admin/api/spec-values/$spec_value_id/normalize" --data-binary @normalize-command.json +``` + +Normalization may adopt a valid automatic result, record a candidate when manual preference is active, or return HTTP 200 with `state: "failed"` and `reason_code` for unparseable Raw. It does not overwrite a manually adopted selection. Manual adoption uses a validated, canonical-unit `Value` encoded in `value_json`; for the voltage profile above, `manual-command.json` can be: + +```json +{ + "expected": {"product_revision": 7, "source_revision": 1, "spec_semantic_version": 2, "profile_revision": 1, "selection_revision": 0}, + "value_json": "{\"schema_version\":1,\"quantity\":\"voltage\",\"semantic_role\":\"operating_support\",\"shape\":\"scalar\",\"unit\":\"V\",\"unit_registry_version\":\"electrical-1\",\"scalar\":\"5\",\"context\":{\"kind\":\"unconditional\"}}" +} +``` + +```sh +curl -b "$admin_cookie_jar" -H "X-CSRF-Token: $admin_csrf" \ + -H 'Content-Type: application/json' "$base/admin/api/spec-values/$spec_value_id/normalized" --data-binary @manual-command.json +``` + +To replace manual preference with a current automatic candidate, explicitly choose its server-recorded ID and a newly reviewed context in `automatic-command.json`; an arbitrary, stale or incompatible candidate is rejected: + +```json +{ + "expected": {"product_revision": 8, "source_revision": 1, "spec_semantic_version": 2, "profile_revision": 1, "selection_revision": 1}, + "candidate_id": "nrm_current_automatic_candidate" +} +``` + +```sh +curl -b "$admin_cookie_jar" -H "X-CSRF-Token: $admin_csrf" \ + -H 'Content-Type: application/json' "$base/admin/api/spec-values/$spec_value_id/normalization/automatic" --data-binary @automatic-command.json +``` + +Successful adoption increments Product/selection revisions and may return `publication_pending: true`. Saved or scheduled does not mean publicly active. Do not retry writes automatically. On 409, retain edits, read a new context and review the changed versions before issuing a new command. + +### Query frozen public facts + +`POST /api/search/specs` is a bounded read command and does not require an Admin session or Admin CSRF token. Replace the example Spec/category IDs with published IDs whose profiles allow those operators and units. The output-current example requires a separate current profile allowing `>=`: + +```sh +curl -H 'Content-Type: application/json' "$base/api/search/specs" --data-binary '{ + "predicates": [ + {"spec_id":"spec_input_voltage","operator":"contains_point","value":"5","unit":"V"}, + {"spec_id":"spec_output_current","operator":">=","value":"0.5","unit":"A"} + ], + "page":1,"page_size":20 +}' +curl -H 'Content-Type: application/json' "$base/api/search/specs" --data-binary '{ + "predicates":[{"spec_id":"spec_input_voltage","operator":"contains_range","lower":"3","upper":"5","unit":"V"}], + "category_id":"cat_power","locale":"en-US","page":1,"page_size":20 +}' +``` + +Requests require 1–16 predicates, positive `page`, and `page_size` of 1–100. Decimal operands are JSON strings with an explicit unit. Point operators (`contains_point`, `=`, `<`, `<=`, `>`, `>=`) use only `value`; range operators (`contains_range`, `overlaps_range`, `between`) use only `lower` and `upper`. Do not send unrelated operand keys even as `null`. Optional `category_id` includes that category and descendants; `manufacturer_id` is an exact ID scope. `locale` must be publicly enabled; omission resolves the active Site Default. + +All predicates must evaluate `MATCH` against adopted facts and profiles frozen in the admitted active `PublicView`. Missing, stale, conditional or incompatible facts yield `UNKNOWN` and are excluded. Disjoint operating ranges keep their gaps; nominal/tolerance and one-sided limit claims follow their declared roles, not an inferred operating range. Candidate indexes never establish numeric truth. Exact evaluation and visibility/scope checks precede `total` and pagination in one admitted snapshot. Results are `{rows,total,page,page_size}`, ordered by Part Number then stable Product ID; an out-of-range page has empty rows and retains the exact total. Pending source/profile edits affect future publication, not the currently active frozen facts. + +### Interpret API outcomes + +Admin: 400 `invalid_json` covers malformed bodies/expectations and invalid controlled value JSON; 401 `unauthorized` means no valid session; 403 `forbidden` or `invalid_csrf` means capability/security rejection; 404 `not_found` includes a missing source/profile context; 409 `revision_conflict` covers stale expectations or an unselected shared source; 422 `validation_failed` covers domain/profile incompatibility. Manual unit/quantity/role/allowed-shape incompatibility is 422, while a malformed tagged Value is 400. A normalization parse failure is the successful 200 outcome described above, not a transport error. + +Public search: malformed JSON/operand shape, decimal encoding or page bounds returns 400 `invalid_json`; an explicitly disabled/unknown locale returns 400 `validation_failed` (an admitted locale-policy race may instead be 400 `invalid_json`). Unknown operators or an operator/unit incompatible with a scoped frozen definition return 422 `validation_failed`. No scoped definition/fact establishes no match, rather than inventing profile semantics. Unavailable authoritative publication returns 503 `service_unavailable`; unexpected server errors return 500 `internal_error`. Maintenance intercepts the route with HTTP 503 HTML and `Retry-After: 300`; draining may return a plain-text 503 before API handling. A degraded candidate index alone triggers exact fallback, not an approximate answer. + +Evidence: [`spec_numeric.go`](../../internal/webapp/spec_numeric.go), [`spec_search.go`](../../internal/webapp/spec_search.go), [`numeric profile`](../../internal/specnumeric/profile.go), [`numeric value`](../../internal/specnumeric/value.go), [`exact evaluation`](../../internal/specnumeric/predicate.go), [`search engine`](../../internal/publishing/spec_search.go), [`backend lifecycle`](../../internal/publishing/spec_search_backend.go) and [`migration 19`](../../internal/storage/sqlite/migrations.go). These links describe implementation; production scale and native Windows/macOS acceptance remain separate evidence. diff --git a/docs/zh-CN/operations.md b/docs/zh-CN/operations.md index cf3335a..29549b7 100644 --- a/docs/zh-CN/operations.md +++ b/docs/zh-CN/operations.md @@ -104,3 +104,166 @@ Restore 准备并验证各 root、持久记录 `prepared`,再依逐 root 证 ## 生产环境叙事仍需的证据 操作者可补充部署版本/source revision、主机/平台、service/proxy 配置、TLS 验证、持久 root 对照、备份保留/异地主机位置、有日期的恢复演练及测量负载/事件记录。凭证与客户数据保持私密。在证据齐备前,描述实现与测试,不使用尚未验证的生产环境成功叙事。 + +## 数值规格搜索(后端) + +本节描述 2026-10-14 核查的 `v0.7.0` 后端。前述章节与架构/工程案例指南保留历史 `v0.6.8` 核查范围。数值 profile 编辑、共享来源/采用控件及 Public 数值筛选 UI 延后提供;现有文本搜索独立运行。 + +### 配置候选后端 + +默认为 `bleve`。Flag `--spec-search-backend`、环境变量 `PRODS_SPEC_SEARCH_BACKEND` 与 INI key `[prods] spec_search_backend` 接受 `bleve` 或 `sqlite`,优先序为 CLI > ENV > INI > 默认值。选择下列其中一种配置,使用相同站点路径重启: + +```ini +[prods] +spec_search_backend=bleve +``` + +```sh +PRODS_SPEC_SEARCH_BACKEND=sqlite ./prods-linux-amd64 +./prods-linux-amd64 --spec-search-backend sqlite +``` + +Bleve 内嵌于 Go 进程,在 `/generated/search/specs` 写入私有、可重建候选索引。SQLite 仍为主存储;`sqlite` 后端改用其派生 Spec-ID 投影。不要通过 filesystem server 公开 generated/search 目录。公开激活状态变更后,由 publication worker 替换/重建候选。 + +候选失败、不完整或与已准入 inventory/revision 不符时,查询改为完整扫描当前公开 inventory 并精确判定。这保留结果与计数,但增加扫描成本,不保证延迟。Log 会记录候选索引失败。已打开 adapter 的 refresh 会在后续 worker tick 重试;初次打开失败的 Bleve adapter 不会由 tick 重新打开。修正 filesystem/ownership/resource 原因后重启,或以 `sqlite` 重启。`SpecSearchDiagnostics` 与 `RebuildSpecSearchIndex` 是内部方法,没有 Admin/Public 诊断或重建 endpoint。正式公开索引本身不健康时仍返回 503。 + +### 准备与采用数值事实 + +每个 Product + Spec 选择一份共享 Raw 来源。`source_origin_locale` 记录来源 provenance,不为每个公开语言创建另一份数值事实;本地化展示文字独立保存。Migration 19 保留旧 Raw/history,只为 Product + Spec 恰有一行的情况选择来源;多来源旧分组保持未选择,直到明确命令处理。它不从旧 display unit/history 创建数值 profile 或采用事实。Published Current Product 获得新 source revision 与 publication intent;替换完成前保留旧的有效公开 activation。内置合成样本也只提供 Raw 规格,不包含已配置 profile/已采用数值事实。 + +使用合法同源 Admin 登录与私有 cookie jar(`admin_cookie_jar`),从已登录 Admin 页面的 `csrf-token` meta 值取得 `admin_csrf`。`base` 设为指定 Base URL,ID 变量设为现有 domain ID。Admin 读取需要 `catalog.view`;写入需要 `catalog.edit`、session cookie 及 `X-CSRF-Token`。这是 browser Admin 命令,不是外部 write token。以下 body 是示意文件,请填入操作打开时观察到的 ID/revision。每个 mutation 示例是独立操作,不是可盲目依次执行的 script。 + +| 方法/路径 | 权限/body | +| --- | --- | +| `PUT /admin/api/specs/{id}/numeric-profile` | `catalog.edit`; `expected_revision`, `profile` | +| `GET /admin/api/products/{id}/spec-values` | `catalog.view`; 查看已保存 Raw 来源 | +| `POST /admin/api/products/{id}/spec-source` | `catalog.edit`; `spec_id`, `spec_value_id`, `expected_product_revision`, `expected_source_revision`, `expected_source_selection_revision` | +| `GET /admin/api/spec-values/{id}/normalization-context` | `catalog.view`; 冻结 Raw、profile、`expected`、`selection`、`source_selection` | +| `POST /admin/api/spec-values/{id}/normalize` | `catalog.edit`; `expected` | +| `POST /admin/api/spec-values/{id}/normalized` | `catalog.edit`; `expected`, `value_json` (以字符串编码的 JSON) | +| `POST /admin/api/spec-values/{id}/normalization/automatic` | `catalog.edit`; `expected`, `candidate_id` | + +Operating-voltage Spec 的 `profile-command.json` 示例(`expected_revision` 使用 Spec definition 的实际 `revision`): + +```json +{ + "expected_revision": 1, + "profile": { + "quantity": "voltage", + "semantic_role": "operating_support", + "allowed_shapes": ["scalar", "discrete_set", "interval_set"], + "allowed_operators": ["contains_point", "contains_range", "overlaps_range"], + "canonical_unit": "V", + "declared_input_unit": "V", + "numeric_format": "decimal-dot", + "schema_version": 1, + "parser_version": "electrical-parser-1", + "unit_registry_version": "electrical-1" + } +} +``` + +```sh +curl -b "$admin_cookie_jar" -H "X-CSRF-Token: $admin_csrf" \ + -H 'Content-Type: application/json' -X PUT "$base/admin/api/specs/$spec_id/numeric-profile" --data-binary @profile-command.json +curl -b "$admin_cookie_jar" \ + "$base/admin/api/products/$product_id/spec-values" +curl -b "$admin_cookie_jar" \ + "$base/admin/api/spec-values/$spec_value_id/normalization-context" +``` + +若 context 尚未选定共享来源,先用 `source-command.json` 选择。三个 expectation 分别采用 context 的 `expected.product_revision`、`expected.source_revision` 与 `source_selection.revision`;成功后重新打开 normalization context: + +```json +{ + "spec_id": "spec_input_voltage", + "spec_value_id": "spv_selected_source", + "expected_product_revision": 7, + "expected_source_revision": 1, + "expected_source_selection_revision": 0 +} +``` + +```sh +curl -b "$admin_cookie_jar" -H "X-CSRF-Token: $admin_csrf" \ + -H 'Content-Type: application/json' "$base/admin/api/products/$product_id/spec-source" --data-binary @source-command.json +``` + +将下列保存为 `normalize-command.json`,用 `normalization-context` 返回的完整 `expected` 对象替换五个值:`product_revision`、`source_revision`、`spec_semantic_version`、`profile_revision`、`selection_revision`。前四个须为正数,selection revision 可为零。写入事务重新验证所有 expectation 及选定共享来源。 + +```json +{ + "expected": { + "product_revision": 7, + "source_revision": 1, + "spec_semantic_version": 2, + "profile_revision": 1, + "selection_revision": 0 + } +} +``` + +```sh +curl -b "$admin_cookie_jar" -H "X-CSRF-Token: $admin_csrf" \ + -H 'Content-Type: application/json' "$base/admin/api/spec-values/$spec_value_id/normalize" --data-binary @normalize-command.json +``` + +规范化可能采用有效 automatic 结果、在 manual preference 有效时记录 candidate,或对无法解析 Raw 返回 HTTP 200、`state: "failed"` 与 `reason_code`;不覆盖手动采用的 selection。Manual adoption 以 `value_json` 字符串提供经过验证、canonical-unit 的 `Value`。上述 voltage profile 可用以下 `manual-command.json`: + +```json +{ + "expected": {"product_revision": 7, "source_revision": 1, "spec_semantic_version": 2, "profile_revision": 1, "selection_revision": 0}, + "value_json": "{\"schema_version\":1,\"quantity\":\"voltage\",\"semantic_role\":\"operating_support\",\"shape\":\"scalar\",\"unit\":\"V\",\"unit_registry_version\":\"electrical-1\",\"scalar\":\"5\",\"context\":{\"kind\":\"unconditional\"}}" +} +``` + +```sh +curl -b "$admin_cookie_jar" -H "X-CSRF-Token: $admin_csrf" \ + -H 'Content-Type: application/json' "$base/admin/api/spec-values/$spec_value_id/normalized" --data-binary @manual-command.json +``` + +若要将 manual preference 改为当前 automatic candidate,明确选择服务器已记录的 ID,并将重新查看的 context 填入 `automatic-command.json`;任意、过期或不兼容 candidate 会被拒绝: + +```json +{ + "expected": {"product_revision": 8, "source_revision": 1, "spec_semantic_version": 2, "profile_revision": 1, "selection_revision": 1}, + "candidate_id": "nrm_current_automatic_candidate" +} +``` + +```sh +curl -b "$admin_cookie_jar" -H "X-CSRF-Token: $admin_csrf" \ + -H 'Content-Type: application/json' "$base/admin/api/spec-values/$spec_value_id/normalization/automatic" --data-binary @automatic-command.json +``` + +采用成功会增加 Product/selection revision,可能返回 `publication_pending: true`。已保存或已排程不代表公开激活。不要自动重试写入;409 时保留编辑,读取新 context 并查看变更版本后再发出新命令。 + +### 查询冻结公开事实 + +`POST /api/search/specs` 是有界读取命令,不要求 Admin session 或 Admin CSRF token。将示意 Spec/category ID 换成已公开、profile 允许该 operator/unit 的 ID。Output-current 示例另需允许 `>=` 的 current profile: + +```sh +curl -H 'Content-Type: application/json' "$base/api/search/specs" --data-binary '{ + "predicates": [ + {"spec_id":"spec_input_voltage","operator":"contains_point","value":"5","unit":"V"}, + {"spec_id":"spec_output_current","operator":">=","value":"0.5","unit":"A"} + ], + "page":1,"page_size":20 +}' +curl -H 'Content-Type: application/json' "$base/api/search/specs" --data-binary '{ + "predicates":[{"spec_id":"spec_input_voltage","operator":"contains_range","lower":"3","upper":"5","unit":"V"}], + "category_id":"cat_power","locale":"en-US","page":1,"page_size":20 +}' +``` + +请求需有 1–16 个 predicate、正数 `page` 及 1–100 的 `page_size`。Decimal operand 是 JSON 字符串,必须明确指定 unit。Point operator(`contains_point`、`=`、`<`、`<=`、`>`、`>=`)只用 `value`;range operator(`contains_range`、`overlaps_range`、`between`)只用 `lower`/`upper`。不要传不相关 operand key,即使值为 `null`。选填 `category_id` 包含该分类及后代;`manufacturer_id` 按精确 ID 筛选。`locale` 必须已公开启用,省略时解析 active Site Default。 + +所有 predicate 须对已准入 active `PublicView` 中采用的事实/冻结 profile 判定为 `MATCH`。缺失、过期、有条件或不兼容事实为 `UNKNOWN`,不纳入结果。分离 operating range 保留间隙;nominal/tolerance 与单向 limit claim 遵循声明 role,不推断为 operating range。候选索引不决定数值真相。在同一准入 snapshot 完成精确判定、可见性/scope 检查后才计算 `total` 与分页。结果为 `{rows,total,page,page_size}`,按 Part Number 再 stable Product ID 排序;超出范围的页面返回空 rows,保留精确 total。待发布 source/profile 编辑影响未来 publication,不改当前 active 冻结事实。 + +### 解读 API 结果 + +Admin:400 `invalid_json` 包含 body/expectation 格式错误及不合法 controlled value JSON;401 `unauthorized` 表示无有效 session;403 `forbidden` 或 `invalid_csrf` 表示权限/安全拒绝;404 `not_found` 包含缺少 source/profile context;409 `revision_conflict` 包含过期 expectation 或未选定共享来源;422 `validation_failed` 表示 domain/profile 不兼容。Manual unit/quantity/role/allowed-shape 不兼容返回 422,tagged Value 格式错误返回 400。Raw 解析失败是上述成功 200 outcome,不是传输错误。 + +Public search:JSON/operand shape、decimal encoding 或页面范围错误返回 400 `invalid_json`;明确指定 disabled/unknown locale 返回 400 `validation_failed`(准入 locale-policy 竞争可能返回 400 `invalid_json`)。未知 operator 或与 scope 内冻结 definition 不兼容的 operator/unit 返回 422 `validation_failed`。Scope 内无 definition/fact 时不成立 match,不虚构 profile 语义。正式 publication 不可用返回 503 `service_unavailable`;非预期 server error 返回 500 `internal_error`。Maintenance 会拦截路由,返回 HTTP 503 HTML 与 `Retry-After: 300`;draining 可能在 API 处理前返回纯文本 503。候选索引降级本身触发精确 fallback,不返回近似答案。 + +证据:[`spec_numeric.go`](../../internal/webapp/spec_numeric.go)、[`spec_search.go`](../../internal/webapp/spec_search.go)、[数值 profile](../../internal/specnumeric/profile.go)、[数值 value](../../internal/specnumeric/value.go)、[精确判定](../../internal/specnumeric/predicate.go)、[搜索引擎](../../internal/publishing/spec_search.go)、[后端生命周期](../../internal/publishing/spec_search_backend.go) 与 [migration 19](../../internal/storage/sqlite/migrations.go)。这些链接说明实现;生产规模与 Windows/macOS 原生验收仍需独立证据。 diff --git a/docs/zh-TW/operations.md b/docs/zh-TW/operations.md index 14cd08a..877309f 100644 --- a/docs/zh-TW/operations.md +++ b/docs/zh-TW/operations.md @@ -104,3 +104,166 @@ Restore 準備並驗證各 root、持久記錄 `prepared`,再依逐 root 證 ## 正式環境敘事仍需的證據 操作者可補充部署版本/source revision、主機/平台、service/proxy 設定、TLS 驗證、持久 root 對照、備份保留/異地主機位置、有日期的還原演練及量測負載/事件紀錄。憑證與客戶資料保持私密。在證據齊備前,描述實作與測試,不使用尚未驗證的正式環境成功敘事。 + +## 數值規格搜尋(後端) + +本節描述 2026-10-14 查核的 `v0.7.0` 後端。前述章節與架構/工程案例指南保留歷史 `v0.6.8` 查核範圍。數值 profile 編輯、共用來源/採用控制及 Public 數值篩選 UI 延後提供;既有文字搜尋獨立運作。 + +### 設定候選後端 + +預設為 `bleve`。Flag `--spec-search-backend`、環境變數 `PRODS_SPEC_SEARCH_BACKEND` 與 INI key `[prods] spec_search_backend` 接受 `bleve` 或 `sqlite`,優先序為 CLI > ENV > INI > 預設值。選擇下列其中一種設定,使用相同站點路徑重啟: + +```ini +[prods] +spec_search_backend=bleve +``` + +```sh +PRODS_SPEC_SEARCH_BACKEND=sqlite ./prods-linux-amd64 +./prods-linux-amd64 --spec-search-backend sqlite +``` + +Bleve 內嵌於 Go 行程,在 `/generated/search/specs` 寫入私有、可重建候選索引。SQLite 仍為主要儲存;`sqlite` 後端改用其衍生 Spec-ID 投影。不要透過 filesystem server 公開 generated/search 目錄。公開啟用狀態變更後,由 publication worker 替換/重建候選。 + +候選失敗、不完整或與已准入 inventory/revision 不符時,查詢改為完整掃描目前公開 inventory 並精確判定。這保留結果與計數,但增加掃描成本,不保證延遲。Log 會記錄候選索引失敗。已開啟 adapter 的 refresh 會在後續 worker tick 重試;初次開啟失敗的 Bleve adapter 不會由 tick 重新開啟。修正 filesystem/ownership/resource 原因後重啟,或以 `sqlite` 重啟。`SpecSearchDiagnostics` 與 `RebuildSpecSearchIndex` 是內部方法,沒有 Admin/Public 診斷或重建 endpoint。正式公開索引本身不健康時仍回 503。 + +### 準備與採用數值事實 + +每個 Product + Spec 選擇一份共用 Raw 來源。`source_origin_locale` 記錄來源 provenance,不為每個公開語系建立另一份數值事實;語系展示文字獨立保存。Migration 19 保留舊 Raw/history,只為 Product + Spec 恰有一列的情況選擇來源;多來源舊群組保持未選擇,直到明確命令處理。它不從舊 display unit/history 建立數值 profile 或採用事實。Published Current Product 取得新 source revision 與 publication intent;替換完成前保留舊的有效公開 activation。內建合成樣本也只提供 Raw 規格,不包含設定好的 profile/已採用數值事實。 + +使用合法同源 Admin 登入與私有 cookie jar(`admin_cookie_jar`),從已登入 Admin 頁面的 `csrf-token` meta 值取得 `admin_csrf`。`base` 設為指定 Base URL,ID 變數設為既有 domain ID。Admin 讀取需要 `catalog.view`;寫入需要 `catalog.edit`、session cookie 及 `X-CSRF-Token`。這是 browser Admin 命令,不是外部 write token。以下 body 是示意檔案,請填入操作開啟時觀察到的 ID/revision。每個 mutation 範例是獨立操作,不是可盲目依序執行的 script。 + +| 方法/路徑 | 權限/body | +| --- | --- | +| `PUT /admin/api/specs/{id}/numeric-profile` | `catalog.edit`; `expected_revision`, `profile` | +| `GET /admin/api/products/{id}/spec-values` | `catalog.view`; 檢視已保存 Raw 來源 | +| `POST /admin/api/products/{id}/spec-source` | `catalog.edit`; `spec_id`, `spec_value_id`, `expected_product_revision`, `expected_source_revision`, `expected_source_selection_revision` | +| `GET /admin/api/spec-values/{id}/normalization-context` | `catalog.view`; 凍結 Raw、profile、`expected`、`selection`、`source_selection` | +| `POST /admin/api/spec-values/{id}/normalize` | `catalog.edit`; `expected` | +| `POST /admin/api/spec-values/{id}/normalized` | `catalog.edit`; `expected`, `value_json` (以字串編碼的 JSON) | +| `POST /admin/api/spec-values/{id}/normalization/automatic` | `catalog.edit`; `expected`, `candidate_id` | + +Operating-voltage Spec 的 `profile-command.json` 範例(`expected_revision` 使用 Spec definition 的實際 `revision`): + +```json +{ + "expected_revision": 1, + "profile": { + "quantity": "voltage", + "semantic_role": "operating_support", + "allowed_shapes": ["scalar", "discrete_set", "interval_set"], + "allowed_operators": ["contains_point", "contains_range", "overlaps_range"], + "canonical_unit": "V", + "declared_input_unit": "V", + "numeric_format": "decimal-dot", + "schema_version": 1, + "parser_version": "electrical-parser-1", + "unit_registry_version": "electrical-1" + } +} +``` + +```sh +curl -b "$admin_cookie_jar" -H "X-CSRF-Token: $admin_csrf" \ + -H 'Content-Type: application/json' -X PUT "$base/admin/api/specs/$spec_id/numeric-profile" --data-binary @profile-command.json +curl -b "$admin_cookie_jar" \ + "$base/admin/api/products/$product_id/spec-values" +curl -b "$admin_cookie_jar" \ + "$base/admin/api/spec-values/$spec_value_id/normalization-context" +``` + +若 context 尚未選定共用來源,先用 `source-command.json` 選擇。三個 expectation 分別採用 context 的 `expected.product_revision`、`expected.source_revision` 與 `source_selection.revision`;成功後重新開啟 normalization context: + +```json +{ + "spec_id": "spec_input_voltage", + "spec_value_id": "spv_selected_source", + "expected_product_revision": 7, + "expected_source_revision": 1, + "expected_source_selection_revision": 0 +} +``` + +```sh +curl -b "$admin_cookie_jar" -H "X-CSRF-Token: $admin_csrf" \ + -H 'Content-Type: application/json' "$base/admin/api/products/$product_id/spec-source" --data-binary @source-command.json +``` + +將下列存為 `normalize-command.json`,用 `normalization-context` 回傳的完整 `expected` 物件取代五個值:`product_revision`、`source_revision`、`spec_semantic_version`、`profile_revision`、`selection_revision`。前四個須為正數,selection revision 可為零。寫入交易重驗所有 expectation 及選定共用來源。 + +```json +{ + "expected": { + "product_revision": 7, + "source_revision": 1, + "spec_semantic_version": 2, + "profile_revision": 1, + "selection_revision": 0 + } +} +``` + +```sh +curl -b "$admin_cookie_jar" -H "X-CSRF-Token: $admin_csrf" \ + -H 'Content-Type: application/json' "$base/admin/api/spec-values/$spec_value_id/normalize" --data-binary @normalize-command.json +``` + +正規化可能採用有效 automatic 結果、在 manual preference 有效時記錄 candidate,或對無法解析 Raw 回 HTTP 200、`state: "failed"` 與 `reason_code`;不覆寫手動採用的 selection。Manual adoption 以 `value_json` 字串提供經驗證、canonical-unit 的 `Value`。上述 voltage profile 可用以下 `manual-command.json`: + +```json +{ + "expected": {"product_revision": 7, "source_revision": 1, "spec_semantic_version": 2, "profile_revision": 1, "selection_revision": 0}, + "value_json": "{\"schema_version\":1,\"quantity\":\"voltage\",\"semantic_role\":\"operating_support\",\"shape\":\"scalar\",\"unit\":\"V\",\"unit_registry_version\":\"electrical-1\",\"scalar\":\"5\",\"context\":{\"kind\":\"unconditional\"}}" +} +``` + +```sh +curl -b "$admin_cookie_jar" -H "X-CSRF-Token: $admin_csrf" \ + -H 'Content-Type: application/json' "$base/admin/api/spec-values/$spec_value_id/normalized" --data-binary @manual-command.json +``` + +若要將 manual preference 改為目前 automatic candidate,明確選擇伺服器已記錄的 ID,並將重新檢視的 context 填入 `automatic-command.json`;任意、過期或不相容 candidate 會被拒絕: + +```json +{ + "expected": {"product_revision": 8, "source_revision": 1, "spec_semantic_version": 2, "profile_revision": 1, "selection_revision": 1}, + "candidate_id": "nrm_current_automatic_candidate" +} +``` + +```sh +curl -b "$admin_cookie_jar" -H "X-CSRF-Token: $admin_csrf" \ + -H 'Content-Type: application/json' "$base/admin/api/spec-values/$spec_value_id/normalization/automatic" --data-binary @automatic-command.json +``` + +採用成功會增加 Product/selection revision,可能回 `publication_pending: true`。已保存或已排程不代表公開啟用。不要自動重試寫入;409 時保留編輯,讀取新 context 並檢視變更版本後再發出新命令。 + +### 查詢凍結公開事實 + +`POST /api/search/specs` 是有界讀取命令,不要求 Admin session 或 Admin CSRF token。將示意 Spec/category ID 換成已公開、profile 允許該 operator/unit 的 ID。Output-current 範例另需允許 `>=` 的 current profile: + +```sh +curl -H 'Content-Type: application/json' "$base/api/search/specs" --data-binary '{ + "predicates": [ + {"spec_id":"spec_input_voltage","operator":"contains_point","value":"5","unit":"V"}, + {"spec_id":"spec_output_current","operator":">=","value":"0.5","unit":"A"} + ], + "page":1,"page_size":20 +}' +curl -H 'Content-Type: application/json' "$base/api/search/specs" --data-binary '{ + "predicates":[{"spec_id":"spec_input_voltage","operator":"contains_range","lower":"3","upper":"5","unit":"V"}], + "category_id":"cat_power","locale":"en-US","page":1,"page_size":20 +}' +``` + +請求需有 1–16 個 predicate、正數 `page` 及 1–100 的 `page_size`。Decimal operand 是 JSON 字串,必須明確指定 unit。Point operator(`contains_point`、`=`、`<`、`<=`、`>`、`>=`)只用 `value`;range operator(`contains_range`、`overlaps_range`、`between`)只用 `lower`/`upper`。不要傳不相關 operand key,即使值為 `null`。選填 `category_id` 包含該分類及子孫;`manufacturer_id` 以精確 ID 篩選。`locale` 必須已公開啟用,省略時解析 active Site Default。 + +所有 predicate 須對已准入 active `PublicView` 中採用的事實/凍結 profile 判定為 `MATCH`。缺失、過期、有條件或不相容事實為 `UNKNOWN`,不納入結果。分離 operating range 保留缺口;nominal/tolerance 與單向 limit claim 遵循宣告 role,不推斷為 operating range。候選索引不決定數值真相。在同一准入 snapshot 完成精確判定、可見性/scope 檢查後才計算 `total` 與分頁。結果為 `{rows,total,page,page_size}`,依 Part Number 再 stable Product ID 排序;超出範圍的頁面回空 rows,保留精確 total。待發布 source/profile 編輯影響未來 publication,不改目前 active 凍結事實。 + +### 解讀 API 結果 + +Admin:400 `invalid_json` 包含 body/expectation 格式錯誤及不合法 controlled value JSON;401 `unauthorized` 表示無有效 session;403 `forbidden` 或 `invalid_csrf` 表示權限/安全拒絕;404 `not_found` 包含缺少 source/profile context;409 `revision_conflict` 包含過期 expectation 或未選定共用來源;422 `validation_failed` 表示 domain/profile 不相容。Manual unit/quantity/role/allowed-shape 不相容回 422,tagged Value 格式錯誤回 400。Raw 解析失敗是上述成功 200 outcome,不是傳輸錯誤。 + +Public search:JSON/operand shape、decimal encoding 或頁面範圍錯誤回 400 `invalid_json`;明確指定 disabled/unknown locale 回 400 `validation_failed`(准入 locale-policy 競態可能回 400 `invalid_json`)。未知 operator 或與 scope 內凍結 definition 不相容的 operator/unit 回 422 `validation_failed`。Scope 內無 definition/fact 時不成立 match,不虛構 profile 語意。正式 publication 不可用回 503 `service_unavailable`;非預期 server error 回 500 `internal_error`。Maintenance 會攔截路由,回 HTTP 503 HTML 與 `Retry-After: 300`;draining 可能在 API 處理前回純文字 503。候選索引降級本身觸發精確 fallback,不回近似答案。 + +證據:[`spec_numeric.go`](../../internal/webapp/spec_numeric.go)、[`spec_search.go`](../../internal/webapp/spec_search.go)、[數值 profile](../../internal/specnumeric/profile.go)、[數值 value](../../internal/specnumeric/value.go)、[精確判定](../../internal/specnumeric/predicate.go)、[搜尋引擎](../../internal/publishing/spec_search.go)、[後端生命週期](../../internal/publishing/spec_search_backend.go) 與 [migration 19](../../internal/storage/sqlite/migrations.go)。這些連結說明實作;正式規模與 Windows/macOS 原生驗收仍需獨立證據。 diff --git a/go.mod b/go.mod index caf1fb6..c15f403 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,8 @@ module prods go 1.27.1 require ( + github.com/blevesearch/bleve/v2 v2.6.1 + github.com/blevesearch/bleve_index_api v1.4.1 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/x/ansi v0.10.1 @@ -17,16 +19,38 @@ require ( ) require ( + github.com/RoaringBitmap/roaring/v2 v2.14.5 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/bits-and-blooms/bitset v1.24.2 // indirect + github.com/blevesearch/geo v0.2.6 // indirect + github.com/blevesearch/go-faiss v1.1.5 // indirect + github.com/blevesearch/go-porterstemmer v1.0.3 // indirect + github.com/blevesearch/gtreap v0.1.1 // indirect + github.com/blevesearch/mmap-go v1.2.0 // indirect + github.com/blevesearch/scorch_segment_api/v2 v2.4.10 // indirect + github.com/blevesearch/segment v0.9.1 // indirect + github.com/blevesearch/snowballstem v0.9.0 // indirect + github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect + github.com/blevesearch/vellum v1.2.0 // indirect + github.com/blevesearch/zapx/v11 v11.4.3 // indirect + github.com/blevesearch/zapx/v12 v12.4.3 // indirect + github.com/blevesearch/zapx/v13 v13.4.3 // indirect + github.com/blevesearch/zapx/v14 v14.4.3 // indirect + github.com/blevesearch/zapx/v15 v15.4.3 // indirect + github.com/blevesearch/zapx/v16 v16.3.4 // indirect + github.com/blevesearch/zapx/v17 v17.2.3 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/golang/snappy v1.0.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/json-iterator/go v0.0.0-20171115153421-f7279a603ede // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mschoch/smat v0.2.0 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect @@ -39,7 +63,9 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xuri/efp v0.0.1 // indirect github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 // indirect + go.etcd.io/bbolt v1.4.0 // indirect golang.org/x/crypto v0.53.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go.sum b/go.sum index 3b2374d..5283397 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,47 @@ +github.com/RoaringBitmap/roaring/v2 v2.14.5 h1:ckd0o545JqDPeVJDgeFoaM21eBixUnlWfYgjE5VnyWw= +github.com/RoaringBitmap/roaring/v2 v2.14.5/go.mod h1:eq4wdNXxtJIS/oikeCzdX1rBzek7ANzbth041hrU8Q4= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/bits-and-blooms/bitset v1.24.2 h1:M7/NzVbsytmtfHbumG+K2bremQPMJuqv1JD3vOaFxp0= +github.com/bits-and-blooms/bitset v1.24.2/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/blevesearch/bleve/v2 v2.6.1 h1:47vLskRTqxvQEtxVPYHjf5KpOgzD2msslXFjvUQCgWQ= +github.com/blevesearch/bleve/v2 v2.6.1/go.mod h1:Dvvx6ZoEBTOj6RSzfk0lEz0wce/qhe2yOUubXeuzd2c= +github.com/blevesearch/bleve_index_api v1.4.1 h1:CYIyecFlI+/RYjzUm+NmDjYbSvk870Bb7f+Vl4b12q8= +github.com/blevesearch/bleve_index_api v1.4.1/go.mod h1:xvd48t5XMeeioWQ5/jZvgLrV98flT2rdvEJ3l/ki4Ko= +github.com/blevesearch/geo v0.2.6 h1:7K1oyQKYlauC+mJuo2AfNPyjN/4mihEoJMfyClVH1Mo= +github.com/blevesearch/geo v0.2.6/go.mod h1:6qzVUiB4BK47QkSZcRqiXEP2W3EeXuzM5XFTF8AdZ8A= +github.com/blevesearch/go-faiss v1.1.5 h1:/IU5lkOahH9Ghfk9n3F6N0XD7PYVXZJWmNDc9TtXuco= +github.com/blevesearch/go-faiss v1.1.5/go.mod h1:w3W9AiWsFRGVaMG+/cmJi7iHEAuGyC6blsgO1EzCK/M= +github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo= +github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M= +github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y= +github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgYICSZ3w0tYk= +github.com/blevesearch/mmap-go v1.2.0 h1:l33nNKPFcBjJUMwem6sAYJPUzhUCABoK9FxZDGiFNBI= +github.com/blevesearch/mmap-go v1.2.0/go.mod h1:Vd6+20GBhEdwJnU1Xohgt88XCD/CTWcqbCNxkZpyBo0= +github.com/blevesearch/scorch_segment_api/v2 v2.4.10 h1:C3873+iWZ0YJM2ijaSHhJJzSvD4x1k+5UaQdGygZVhM= +github.com/blevesearch/scorch_segment_api/v2 v2.4.10/go.mod h1:WUUkAocbkDlNK/kgAE13NvS9oxe+u618mYZ8sOvcCc4= +github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU= +github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw= +github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s= +github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs= +github.com/blevesearch/upsidedown_store_api v1.0.2 h1:U53Q6YoWEARVLd1OYNc9kvhBMGZzVrdmaozG2MfoB+A= +github.com/blevesearch/upsidedown_store_api v1.0.2/go.mod h1:M01mh3Gpfy56Ps/UXHjEO/knbqyQ1Oamg8If49gRwrQ= +github.com/blevesearch/vellum v1.2.0 h1:xkDiOEsHc2t3Cp0NsNZZ36pvc130sCzcGKOPMzXe+e0= +github.com/blevesearch/vellum v1.2.0/go.mod h1:uEcfBJz7mAOf0Kvq6qoEKQQkLODBF46SINYNkZNae4k= +github.com/blevesearch/zapx/v11 v11.4.3 h1:PTZOO5loKpHC/x/GzmPZNa9cw7GZIQxd5qRjwij9tHY= +github.com/blevesearch/zapx/v11 v11.4.3/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc= +github.com/blevesearch/zapx/v12 v12.4.3 h1:eElXvAaAX4m04t//CGBQAtHNPA+Q6A1hHZVrN3LSFYo= +github.com/blevesearch/zapx/v12 v12.4.3/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58= +github.com/blevesearch/zapx/v13 v13.4.3 h1:qsdhRhaSpVnqDFlRiH9vG5+KJ+dE7KAW9WyZz/KXAiE= +github.com/blevesearch/zapx/v13 v13.4.3/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk= +github.com/blevesearch/zapx/v14 v14.4.3 h1:GY4Hecx0C6UTmiNC2pKdeA2rOKiLR5/rwpU9WR51dgM= +github.com/blevesearch/zapx/v14 v14.4.3/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8= +github.com/blevesearch/zapx/v15 v15.4.3 h1:iJiMJOHrz216jyO6lS0m9RTCEkprUnzvqAI2lc/0/CU= +github.com/blevesearch/zapx/v15 v15.4.3/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw= +github.com/blevesearch/zapx/v16 v16.3.4 h1:hDAqA8qusZTNbPEL7//w5P65UZ2de6yhSeUaTbp0Po0= +github.com/blevesearch/zapx/v16 v16.3.4/go.mod h1:zqkPPqs9GS9FzVWzCO3Wf1X044yWAV17+4zb+FTiEHg= +github.com/blevesearch/zapx/v17 v17.2.3 h1:UYYJPAt5b2tVxldx5h0jmv23RMsg8/UZKFVya7v92po= +github.com/blevesearch/zapx/v17 v17.2.3/go.mod h1:r7mb4QWbDQSkbAnOjCb9iCfkcrzajB4yBdJpuBIo/fE= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= @@ -18,12 +60,16 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= +github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/json-iterator/go v0.0.0-20171115153421-f7279a603ede h1:YrgBGwxMRK0Vq0WSCWFaZUnTsrA/PZE/xs1QZh+/edg= +github.com/json-iterator/go v0.0.0-20171115153421-f7279a603ede/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -32,6 +78,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM= +github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -63,6 +111,8 @@ github.com/xuri/excelize/v2 v2.11.0 h1:HxaEFl6sRN2+8J5a8HaKq+0M4FsjBGMnWWtjOCPSG github.com/xuri/excelize/v2 v2.11.0/go.mod h1:jxFLbzaIwGQ5ufFNvYfUOHqXhfPaNmP14KWfmNz2Uak= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9 h1:+C0TIdyyYmzadGaL/HBLbf3WdLgC29pgyhTjAT/0nuE= github.com/xuri/nfp v0.0.2-0.20250530014748-2ddeb826f9a9/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ= +go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk= +go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= @@ -83,6 +133,8 @@ golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= diff --git a/internal/catalog/content_locale.go b/internal/catalog/content_locale.go index ece9e31..039dc48 100644 --- a/internal/catalog/content_locale.go +++ b/internal/catalog/content_locale.go @@ -9,7 +9,7 @@ import ( var ErrInvalidContentLocale = errors.New("invalid customer content source locale") -var ProductTranslatableFields = []string{"name", "description", "features", "specification"} +var ProductTranslatableFields = []string{"name", "description", "features"} var TaxonomyTranslatableFields = []string{"name", "description"} func NormalizeFieldSourceLocales(values map[string]string, fallback string, allowedFields []string) (map[string]string, error) { diff --git a/internal/catalog/content_locale_test.go b/internal/catalog/content_locale_test.go index 8789038..1bf6282 100644 --- a/internal/catalog/content_locale_test.go +++ b/internal/catalog/content_locale_test.go @@ -10,7 +10,18 @@ func TestNormalizeFieldSourceLocalesDefaultsAndAllowsPerFieldCorrection(t *testi if got["name"] != "zh-TW" || got["description"] != "ja-JP" || len(got) != len(ProductTranslatableFields) { t.Fatalf("source locales = %+v", got) } - if _, err := NormalizeFieldSourceLocales(map[string]string{"price": "en-US"}, "en-US", ProductTranslatableFields); err != ErrInvalidContentLocale { + if _, err := NormalizeFieldSourceLocales(map[string]string{"specification": "en-US"}, "en-US", ProductTranslatableFields); err != ErrInvalidContentLocale { t.Fatalf("unknown field error = %v", err) } } + +func TestProductPrepareRejectsSharedSpecificationSourceLocale(t *testing.T) { + product := Product{ID: "source-1", PartNumber: "SOURCE-1", SourceLocales: map[string]string{"specification": "en-US"}} + if err := product.Prepare(); err != ErrInvalidProduct { + t.Fatalf("shared specification source=%v", err) + } + product.SourceLocales = map[string]string{"name": "en-US", "description": "ja-JP", "features": "zh-TW"} + if err := product.Prepare(); err != nil { + t.Fatal(err) + } +} diff --git a/internal/catalog/maintenance.go b/internal/catalog/maintenance.go index fbddce4..6468382 100644 --- a/internal/catalog/maintenance.go +++ b/internal/catalog/maintenance.go @@ -191,22 +191,26 @@ const ( ) type NormalizedValue struct { - ID string `json:"id"` - SpecValueID string `json:"spec_value_id"` - ValueJSON string `json:"value_json,omitempty"` - SourceRevision int64 `json:"source_revision"` - SpecSemanticVersion int64 `json:"spec_semantic_version"` - NormalizerVersion string `json:"normalizer_version"` - Source NormalizedSource `json:"source"` - Status NormalizedStatus `json:"status"` - FailureReason string `json:"failure_reason,omitempty"` + ID string `json:"id"` + SpecValueID string `json:"spec_value_id"` + ValueJSON string `json:"value_json,omitempty"` + SourceRevision int64 `json:"source_revision"` + SpecSemanticVersion int64 `json:"spec_semantic_version"` + NormalizerVersion string `json:"normalizer_version"` + Source NormalizedSource `json:"source"` + Status NormalizedStatus `json:"status"` + FailureReason string `json:"failure_reason,omitempty"` + ProfileRevision int64 `json:"profile_revision"` + SourceOriginLocale string `json:"source_origin_locale,omitempty"` + NumericSchemaVersion int `json:"numeric_schema_version"` } // SpecValueDetail is the Admin read model for an authoritative raw value and // its derived normalization history. Public rendering does not consume it. type SpecValueDetail struct { - Value SpecValue `json:"value"` - Normalized []NormalizedValue `json:"normalized"` + SourceSelection ProductSpecSource `json:"source_selection"` + Value SpecValue `json:"value"` + Normalized []NormalizedValue `json:"normalized"` } type ProductDocument struct { diff --git a/internal/catalog/product.go b/internal/catalog/product.go index e816ebe..ceeda4a 100644 --- a/internal/catalog/product.go +++ b/internal/catalog/product.go @@ -79,7 +79,7 @@ type ProductTranslation struct { Name string `json:"name,omitempty"` Description string `json:"description,omitempty"` Features string `json:"features,omitempty"` - Specification string `json:"specification,omitempty"` + Specification string `json:"-"` Revision int64 `json:"revision"` UpdatedBy string `json:"updated_by,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` @@ -101,6 +101,9 @@ func (p *Product) Prepare() error { p.Name = strings.TrimSpace(p.Name) p.SourceLocale = strings.TrimSpace(p.SourceLocale) for field, locale := range p.SourceLocales { + if field != "name" && field != "description" && field != "features" { + return ErrInvalidProduct + } trimmedField := strings.TrimSpace(field) trimmedLocale := strings.TrimSpace(locale) if trimmedField == "" || trimmedLocale == "" || trimmedField != field { diff --git a/internal/catalog/spec_numeric.go b/internal/catalog/spec_numeric.go new file mode 100644 index 0000000..50602b4 --- /dev/null +++ b/internal/catalog/spec_numeric.go @@ -0,0 +1,83 @@ +package catalog + +import "prods/internal/specnumeric" + +type SpecNumericProfile struct { + SpecID string `json:"spec_id"` + SpecSemanticVersion int64 `json:"spec_semantic_version"` + Revision int64 `json:"revision"` + Profile specnumeric.Profile `json:"profile"` +} + +type NormalizationSelection struct { + SpecValueID string `json:"spec_value_id"` + SelectedNormalizedValueID string `json:"selected_normalized_value_id"` + Preference NormalizedSource `json:"preference"` + Revision int64 `json:"revision"` +} + +type SpecSourceSelection struct { + ProductID string `json:"product_id"` + SpecID string `json:"spec_id"` + SpecValueID string `json:"spec_value_id"` + Revision int64 `json:"revision"` +} + +// ProductSpecSource names the persisted shared Product+Spec mapping. +// It is the same command result model, not a second representation. +type ProductSpecSource = SpecSourceSelection + +type NormalizationExpectation struct { + ProductRevision int64 `json:"product_revision"` + SourceRevision int64 `json:"source_revision"` + SpecSemanticVersion int64 `json:"spec_semantic_version"` + ProfileRevision int64 `json:"profile_revision"` + SelectionRevision int64 `json:"selection_revision"` +} + +type NormalizeSpecCommand struct { + SpecValueID string `json:"spec_value_id"` + Expected NormalizationExpectation `json:"expected"` +} + +type AdoptSpecCommand struct { + SpecValueID string `json:"spec_value_id"` + Expected NormalizationExpectation `json:"expected"` + ValueJSON string `json:"value_json"` +} + +type SelectAutomaticSpecCommand struct { + SpecValueID string `json:"spec_value_id"` + Expected NormalizationExpectation `json:"expected"` + CandidateID string `json:"candidate_id"` +} + +type SelectSpecSourceCommand struct { + ProductID string `json:"product_id"` + SpecID string `json:"spec_id"` + SpecValueID string `json:"spec_value_id"` + ExpectedProductRevision int64 `json:"expected_product_revision"` + ExpectedSourceRevision int64 `json:"expected_source_revision"` + ExpectedSourceSelectionRevision int64 `json:"expected_source_selection_revision"` +} + +type SpecNormalizationContext struct { + SpecValueID string `json:"spec_value_id"` + ProductID string `json:"product_id"` + SpecID string `json:"spec_id"` + Expected NormalizationExpectation `json:"expected"` + Raw string `json:"raw"` + SourceLocale string `json:"source_locale"` + Profile specnumeric.Profile `json:"profile"` + Selection NormalizationSelection `json:"selection"` + SourceSelection ProductSpecSource `json:"source_selection"` +} + +type NormalizationOutcome struct { + State string `json:"state"` + ReasonCode string `json:"reason_code"` + NormalizedValueID string `json:"normalized_value_id"` + ProductRevision int64 `json:"product_revision"` + SelectionRevision int64 `json:"selection_revision"` + PublicationPending bool `json:"publication_pending"` +} diff --git a/internal/distribution/embedded_sample_test.go b/internal/distribution/embedded_sample_test.go index 854f74f..352f11f 100644 --- a/internal/distribution/embedded_sample_test.go +++ b/internal/distribution/embedded_sample_test.go @@ -6,11 +6,11 @@ import ( ) func TestEmbeddedSampleMatchesCompiledRelease(t *testing.T) { - sample, err := LoadEmbeddedSample("v0.6.8") + sample, err := LoadEmbeddedSample("v0.7.0") if err != nil { t.Fatal(err) } - if sample.Data.SchemaVersion != 1 || sample.Data.ReleaseVersion != "v0.6.8" || sample.Data.DatasetVersion == "" { + if sample.Data.SchemaVersion != 1 || sample.Data.ReleaseVersion != "v0.7.0" || sample.Data.DatasetVersion == "" { t.Fatalf("embedded sample metadata = %+v", sample.Data) } if len(sample.Data.Dictionaries) != 64 || len(sample.Data.Categories) != 58 || len(sample.Data.Specs) != 34 || diff --git a/internal/distribution/prods-sample-data-v1.json.gz b/internal/distribution/prods-sample-data-v1.json.gz index 7ad6d99..dc66247 100644 Binary files a/internal/distribution/prods-sample-data-v1.json.gz and b/internal/distribution/prods-sample-data-v1.json.gz differ diff --git a/internal/hostconfig/config.go b/internal/hostconfig/config.go index 5fa4574..07860b8 100644 --- a/internal/hostconfig/config.go +++ b/internal/hostconfig/config.go @@ -11,9 +11,12 @@ import ( "path/filepath" "strconv" "strings" + + "prods/internal/publishing" ) const ( + EnvSpecSearchBackend = "PRODS_SPEC_SEARCH_BACKEND" EnvGoogleSearchClientID = "PRODS_GOOGLE_SEARCH_CLIENT_ID" EnvGoogleSearchClientSecretFile = "PRODS_GOOGLE_SEARCH_CLIENT_SECRET_FILE" EnvGoogleSearchRefreshTokenFile = "PRODS_GOOGLE_SEARCH_REFRESH_TOKEN_FILE" @@ -35,6 +38,7 @@ const ( ) type Options struct { + SpecSearchBackend string GoogleSearchClientID string GoogleSearchClientSecretFile string GoogleSearchRefreshTokenFile string @@ -109,6 +113,7 @@ func (resolver Resolver) Resolve() (Options, error) { dataDirFlag := flags.String("data-dir", "", "private data directory") backupDirFlag := flags.String("backup-dir", "", "backup directory") baseURLFlag := flags.String("base-url", "", "canonical public base URL") + specSearchBackendFlag := flags.String("spec-search-backend", "", "spec search candidate backend: bleve or sqlite (default bleve)") adminTokenFlag := flags.String("admin-token", "", "one-time Admin token for explicit PoC mode only") pocFixtures := flags.Bool("poc-fixtures", false, "explicitly create or reopen an isolated PoC database with synthetic fixtures") restoreBackup := flags.String("restore-backup", "", "offline restore from a backup ID or path; exits after verified roll-forward") @@ -159,13 +164,14 @@ func (resolver Resolver) Resolve() (Options, error) { } options := Options{ - Listen: ":8080", - ListenSource: "default", - DataDir: filepath.Join(executableDir, "data"), - BackupDir: filepath.Join(executableDir, "backups"), - BaseURL: "http://127.0.0.1:8080", - BaseURLSource: "default", - AssetGCGraceDays: 7, + SpecSearchBackend: "bleve", + Listen: ":8080", + ListenSource: "default", + DataDir: filepath.Join(executableDir, "data"), + BackupDir: filepath.Join(executableDir, "backups"), + BaseURL: "http://127.0.0.1:8080", + BaseURLSource: "default", + AssetGCGraceDays: 7, } configPath := strings.TrimSpace(*configFlag) configRequired := explicit["config"] @@ -192,6 +198,9 @@ func (resolver Resolver) Resolve() (Options, error) { } } else { options.ConfigLoaded = true + if value := fileValues["spec_search_backend"]; value != "" { + options.SpecSearchBackend = value + } configDir := filepath.Dir(configPath) if value := fileValues["listen"]; value != "" { options.Listen = value @@ -276,6 +285,9 @@ func (resolver Resolver) Resolve() (Options, error) { options.BaseURL = value options.BaseURLSource = "environment" } + if value, ok := nonEmptyEnv(lookupEnv, EnvSpecSearchBackend); ok { + options.SpecSearchBackend = value + } if value, ok := nonEmptyEnv(lookupEnv, EnvAdminToken); ok { options.AdminToken = value } @@ -354,6 +366,12 @@ func (resolver Resolver) Resolve() (Options, error) { options.BaseURL = strings.TrimSpace(*baseURLFlag) options.BaseURLSource = "cli" } + if explicit["spec-search-backend"] { + options.SpecSearchBackend = strings.TrimSpace(*specSearchBackendFlag) + if options.SpecSearchBackend == "" { + return Options{}, errors.New("--spec-search-backend must not be empty") + } + } if explicit["admin-token"] { options.AdminToken = strings.TrimSpace(*adminTokenFlag) } @@ -410,6 +428,10 @@ func (resolver Resolver) Resolve() (Options, error) { return Options{}, errors.New("listen, data_dir, backup_dir, and base_url must not be empty") } options.POCFixtures = *pocFixtures + options.SpecSearchBackend, err = publishing.NormalizeSpecSearchBackend(options.SpecSearchBackend) + if err != nil { + return Options{}, err + } options.RestoreBackup = strings.TrimSpace(*restoreBackup) options.AllowRestoreWithoutPrebackup = *allowRestoreWithoutPrebackup options.BackupNow = *backupNow @@ -469,6 +491,11 @@ func pathWithin(root, target string) bool { // not persist secrets or one-shot operational flags and never overwrites an // existing file. func WriteNew(options Options) error { + backend, err := publishing.NormalizeSpecSearchBackend(options.SpecSearchBackend) + if err != nil { + return err + } + options.SpecSearchBackend = backend if strings.TrimSpace(options.ConfigPath) == "" { return errors.New("config path is empty") } @@ -503,12 +530,13 @@ func WriteNew(options Options) error { }() configDir := filepath.Dir(options.ConfigPath) content := fmt.Sprintf( - "[prods]\nlisten=%s\ndata_dir=%s\nbackup_dir=%s\nbase_url=%s\nasset_gc_grace_days=%d\n", + "[prods]\nlisten=%s\ndata_dir=%s\nbackup_dir=%s\nbase_url=%s\nasset_gc_grace_days=%d\nspec_search_backend=%s\n", options.Listen, portableConfigPath(configDir, options.DataDir), portableConfigPath(configDir, options.BackupDir), options.BaseURL, options.AssetGCGraceDays, + options.SpecSearchBackend, ) if len(options.TrustedProxyCIDRs) != 0 { trusted, err := parseTrustedProxies(strings.Join(options.TrustedProxyCIDRs, ",")) @@ -607,7 +635,7 @@ func readConfig(path string) (map[string]string, error) { key = strings.ReplaceAll(strings.ToLower(strings.TrimSpace(key)), "-", "_") value = strings.TrimSpace(value) switch key { - case "listen", "data_dir", "backup_dir", "base_url", "smtp_host", "smtp_port", "smtp_username", + case "listen", "data_dir", "backup_dir", "base_url", "spec_search_backend", "smtp_host", "smtp_port", "smtp_username", "smtp_password_file", "smtp_from", "smtp_tls_mode", "google_search_client_id", "google_search_client_secret_file", "google_search_refresh_token_file", "asset_gc_grace_days", "trusted_proxies": default: diff --git a/internal/hostconfig/config_test.go b/internal/hostconfig/config_test.go index 7bba773..607468f 100644 --- a/internal/hostconfig/config_test.go +++ b/internal/hostconfig/config_test.go @@ -305,6 +305,92 @@ func TestWriteNewUsesRelativePathsForPortableInTreeData(t *testing.T) { } } +func TestSpecSearchBackendUsesCLIEnvironmentConfigPrecedence(t *testing.T) { + root := t.TempDir() + configPath := filepath.Join(root, "prods.ini") + environment := map[string]string{} + resolver := Resolver{ + Executable: filepath.Join(root, "prods"), + LookupEnv: func(name string) (string, bool) { value, ok := environment[name]; return value, ok }, + Getwd: func() (string, error) { return root, nil }, + FlagOutput: &bytes.Buffer{}, + } + check := func(want string) { + t.Helper() + options, err := resolver.Resolve() + if err != nil { + t.Fatal(err) + } + if options.SpecSearchBackend != want { + t.Fatalf("backend=%q, want %q", options.SpecSearchBackend, want) + } + } + check("bleve") + if err := os.WriteFile(configPath, []byte("[prods]\nspec_search_backend= SqLiTe \n"), 0o600); err != nil { + t.Fatal(err) + } + check("sqlite") + environment[EnvSpecSearchBackend] = "" + check("sqlite") + environment[EnvSpecSearchBackend] = " BlEvE " + check("bleve") + // Invalid losing values are ignored when the explicit CLI value wins. + if err := os.WriteFile(configPath, []byte("[prods]\nspec_search_backend=obsolete\n"), 0o600); err != nil { + t.Fatal(err) + } + environment[EnvSpecSearchBackend] = "obsolete" + resolver.Args = []string{"--spec-search-backend", " SqLiTe "} + check("sqlite") +} + +func TestWriteNewSpecSearchBackendPersistsAndRejectsInvalidSelection(t *testing.T) { + root := t.TempDir() + options := Options{ + ConfigPath: filepath.Join(root, "prods.ini"), Listen: ":8080", + DataDir: filepath.Join(root, "data"), BackupDir: filepath.Join(root, "backups"), + BaseURL: "http://127.0.0.1:8080", SpecSearchBackend: " SqLiTe ", + } + if err := WriteNew(options); err != nil { + t.Fatal(err) + } + resolver := Resolver{Executable: filepath.Join(root, "prods"), LookupEnv: emptyEnv, FlagOutput: &bytes.Buffer{}} + reloaded, err := resolver.Resolve() + if err != nil { + t.Fatal(err) + } + if reloaded.SpecSearchBackend != "sqlite" { + t.Fatalf("reloaded backend=%q", reloaded.SpecSearchBackend) + } + + for _, selection := range []string{"obsolete", ""} { + resolver.Args = []string{"--spec-search-backend", selection} + if _, err := resolver.Resolve(); err == nil { + t.Fatalf("explicit backend %q accepted", selection) + } + } + options.ConfigPath = filepath.Join(root, "invalid", "prods.ini") + options.SpecSearchBackend = "obsolete" + if err := WriteNew(options); err == nil { + t.Fatal("invalid backend persisted") + } + if _, err := os.Stat(options.ConfigPath); !os.IsNotExist(err) { + t.Fatalf("invalid selection created config: %v", err) + } + options.ConfigPath = filepath.Join(root, "default", "prods.ini") + options.SpecSearchBackend = "" + if err := WriteNew(options); err != nil { + t.Fatal(err) + } + resolver.Args = []string{"--config", options.ConfigPath} + defaults, err := resolver.Resolve() + if err != nil { + t.Fatal(err) + } + if defaults.SpecSearchBackend != "bleve" { + t.Fatalf("programmatic default backend=%q", defaults.SpecSearchBackend) + } +} + func TestAssetGCGraceDaysUsesCLIEnvironmentConfigPrecedenceAndValidation(t *testing.T) { root := t.TempDir() configPath := filepath.Join(root, "prods.ini") diff --git a/internal/importing/importing.go b/internal/importing/importing.go index 613b78b..ee011ee 100644 --- a/internal/importing/importing.go +++ b/internal/importing/importing.go @@ -19,22 +19,21 @@ var ( type Target string const ( - TargetPartNumber Target = "part_number" - TargetProductName Target = "product_name" - TargetManufacturerID Target = "manufacturer_id" - TargetBrandID Target = "brand_id" - TargetCategoryID Target = "category_id" - TargetPackageFormFactor Target = "package_form_factor" - TargetDescription Target = "description" - TargetFeatures Target = "features" - TargetSpecification Target = "specification" - TargetLifecycleID Target = "lifecycle_id" - TargetApplicationIDs Target = "application_ids" - TargetSourceLocale Target = "source_locale" - TargetNameSourceLocale Target = "name_source_locale" - TargetDescriptionLocale Target = "description_source_locale" - TargetFeaturesLocale Target = "features_source_locale" - TargetSpecificationLocale Target = "specification_source_locale" + TargetPartNumber Target = "part_number" + TargetProductName Target = "product_name" + TargetManufacturerID Target = "manufacturer_id" + TargetBrandID Target = "brand_id" + TargetCategoryID Target = "category_id" + TargetPackageFormFactor Target = "package_form_factor" + TargetDescription Target = "description" + TargetFeatures Target = "features" + TargetSpecification Target = "specification" + TargetLifecycleID Target = "lifecycle_id" + TargetApplicationIDs Target = "application_ids" + TargetSourceLocale Target = "source_locale" + TargetNameSourceLocale Target = "name_source_locale" + TargetDescriptionLocale Target = "description_source_locale" + TargetFeaturesLocale Target = "features_source_locale" ) type Limits struct { @@ -199,7 +198,7 @@ func validTarget(target Target) bool { switch target { case TargetPartNumber, TargetProductName, TargetManufacturerID, TargetBrandID, TargetCategoryID, TargetPackageFormFactor, TargetDescription, TargetFeatures, TargetSpecification, TargetLifecycleID, TargetApplicationIDs, - TargetSourceLocale, TargetNameSourceLocale, TargetDescriptionLocale, TargetFeaturesLocale, TargetSpecificationLocale: + TargetSourceLocale, TargetNameSourceLocale, TargetDescriptionLocale, TargetFeaturesLocale: return true default: return false diff --git a/internal/publishing/drain_test.go b/internal/publishing/drain_test.go index eb5f42b..a6abae8 100644 --- a/internal/publishing/drain_test.go +++ b/internal/publishing/drain_test.go @@ -1,6 +1,9 @@ package publishing -import "testing" +import ( + "context" + "testing" +) func TestEngineBeginDrainStopsNewPublicationDispatch(t *testing.T) { engine := &Engine{wake: make(chan struct{}, 1)} @@ -14,3 +17,26 @@ func TestEngineBeginDrainStopsNewPublicationDispatch(t *testing.T) { t.Fatalf("draining publisher processed=%t err=%v", processed, err) } } + +// Existing repositories need not implement profile work. This optional adapter +// proves that profile-only progress keeps the serial publishing loop alive. +type profileOnlyRepository struct { + Repository + calls int +} + +func (r *profileOnlyRepository) ProcessSpecNumericProfileWork(_ context.Context, limit int) (bool, error) { + r.calls++ + return limit == 100, nil +} +func (r *profileOnlyRepository) ClaimPublicationIntent(context.Context) (Intent, bool, error) { + return Intent{}, false, nil +} +func TestEngineProcessOneReportsProfileOnlyWork(t *testing.T) { + repository := &profileOnlyRepository{} + engine := &Engine{repository: repository} + worked, err := engine.ProcessOne(t.Context()) + if err != nil || !worked || repository.calls != 1 { + t.Fatalf("worked=%v calls=%d err=%v", worked, repository.calls, err) + } +} diff --git a/internal/publishing/protocol.go b/internal/publishing/protocol.go index 7572e63..2afd635 100644 --- a/internal/publishing/protocol.go +++ b/internal/publishing/protocol.go @@ -39,11 +39,12 @@ type Intent struct { } type SourceSpec struct { - ID string - Name string - RawValue string - PreferredUnit string - Language string + SourceRevision int64 + ID string + Name string + RawValue string + PreferredUnit string + Language string } type SourceDocument struct { @@ -65,6 +66,8 @@ type SourceImage struct { } type Source struct { + NumericFacts []PublicNumericFact + NumericDefinitions []PublicNumericDefinition ApplicableSpecs []SourceSpec Product catalog.Product Translations []catalog.ProductTranslation @@ -201,16 +204,20 @@ type Repository interface { } type Config struct { - Root string - AssetRoot string - BaseURL string - ResourceGate *platform.ResourceGate - ByteHeadroom uint64 - InodeHeadroom uint64 - MinFreePercent uint8 + SpecSearchBackend string + Root string + AssetRoot string + BaseURL string + ResourceGate *platform.ResourceGate + ByteHeadroom uint64 + InodeHeadroom uint64 + MinFreePercent uint8 } type publicIndex struct { + specDigest string + specDigestError error + specInventory map[string]SpecCandidateBinding byID map[string]ActivePublication byRoute map[string]routeTarget byAsset map[string]assetTarget @@ -237,6 +244,13 @@ type assetTarget struct { } type Engine struct { + specBackend string + specBleve *bleveSpecIndex + specRefreshMu sync.Mutex + specDirty atomic.Bool + specStatus atomic.Pointer[specSearchStatus] + specCandidateQueries atomic.Uint64 + specFallbackQueries atomic.Uint64 processMu sync.Mutex deferVisibility atomic.Bool deferredVisibilityDirty atomic.Bool @@ -259,6 +273,10 @@ type Engine struct { } func NewEngine(ctx context.Context, repository Repository, config Config) (*Engine, error) { + backend, err := NormalizeSpecSearchBackend(config.SpecSearchBackend) + if err != nil { + return nil, err + } if repository == nil || strings.TrimSpace(config.Root) == "" || strings.TrimSpace(config.BaseURL) == "" { return nil, errors.New("publishing repository, root, and base URL are required") } @@ -287,10 +305,24 @@ func NewEngine(ctx context.Context, repository Repository, config Config) (*Engi config.ResourceGate = platform.NewResourceGate(nil) } engine := &Engine{ - repository: repository, root: root, assetRoot: assetRoot, baseURL: strings.TrimRight(config.BaseURL, "/"), + specBackend: backend, + repository: repository, root: root, assetRoot: assetRoot, baseURL: strings.TrimRight(config.BaseURL, "/"), resources: config.ResourceGate, byteHeadroom: config.ByteHeadroom, inodeHeadroom: config.InodeHeadroom, minFreePercent: config.MinFreePercent, wake: make(chan struct{}, 1), } + if backend == "bleve" { + engine.specBleve, err = newBleveSpecIndex(ctx, filepath.Join(filepath.Dir(root), "search", "specs"), config) + if err != nil { + engine.specSearchFailure(err) + } + } + // Adapter resources must be released if authoritative startup fails. + started := false + defer func() { + if !started && engine.specBleve != nil { + _ = engine.specBleve.close() + } + }() if err := repository.ResetPublicationClaims(ctx); err != nil { return nil, err } @@ -301,6 +333,7 @@ func NewEngine(ctx context.Context, repository Repository, config Config) (*Engi engine.cancel = cancel engine.wg.Add(1) go engine.worker(workerContext) + started = true engine.Wake() return engine, nil } @@ -312,6 +345,14 @@ func (e *Engine) Close() { e.BeginDrain() e.cancel() e.wg.Wait() + e.specRefreshMu.Lock() + defer e.specRefreshMu.Unlock() + if e.specBleve != nil { + if err := e.specBleve.close(); err != nil { + e.specSearchFailure(err) + } + } + e.specStatus.Store(&specSearchStatus{lastError: "spec search index closed"}) } func (e *Engine) BeginDrain() { @@ -348,7 +389,7 @@ func (e *Engine) worker(ctx context.Context) { if e.draining.Load() { return } - processed, err := e.ProcessOne(ctx) + processed, err := e.processWorkerBatch(ctx) if err != nil { slog.Error("publication worker failed", "error", err) break @@ -360,19 +401,52 @@ func (e *Engine) worker(ctx context.Context) { } } +// Keep ordinary Product visibility installation synchronous, but coalesce the +// derived refresh across bounded worker progress. The bound matches profile +// work's chunk size and prevents a sustained backlog from delaying refresh +// indefinitely. The worker continues full chunks without waiting for a tick. +func (e *Engine) processWorkerBatch(ctx context.Context) (processed bool, err error) { + e.processMu.Lock() + defer e.processMu.Unlock() + defer e.refreshSpecSearch(ctx, false) + for i := 0; i < 100; i++ { + if err := ctx.Err(); err != nil { + return processed, err + } + worked, err := e.processOne(ctx) + processed = processed || worked + if err != nil || !worked { + return processed, err + } + } + return processed, nil +} + func (e *Engine) ProcessOne(ctx context.Context) (bool, error) { e.processMu.Lock() defer e.processMu.Unlock() - return e.processOne(ctx) + processed, err := e.processOne(ctx) + e.refreshSpecSearch(ctx, false) + return processed, err } func (e *Engine) processOne(ctx context.Context) (bool, error) { if e == nil || e.draining.Load() { return false, nil } + profileWorked := false + if worker, ok := e.repository.(interface { + ProcessSpecNumericProfileWork(context.Context, int) (bool, error) + }); ok { + var err error + profileWorked, err = worker.ProcessSpecNumericProfileWork(ctx, 100) + if err != nil { + return profileWorked, err + } + } intent, found, err := e.repository.ClaimPublicationIntent(ctx) if err != nil || !found { - return found, err + return profileWorked || found, err } source, err := e.repository.PublicationSource(ctx, intent.ProductID) if err != nil { @@ -432,6 +506,7 @@ func (e *Engine) ProcessBatch(ctx context.Context, limit int) (processed int, er } e.deferVisibility.Store(true) defer func() { + defer e.refreshSpecSearch(ctx, false) e.deferVisibility.Store(false) if !e.deferredVisibilityDirty.Swap(false) { return @@ -536,6 +611,17 @@ func (e *Engine) Reconcile(ctx context.Context) error { if err := e.repository.RebuildSearchPublications(ctx, active); err != nil { return err } + // Keep the existing rebuildable SQLite projection repaired on startup, + // including when Bleve is selected. Its failure is a derived-cache failure + // and cannot invalidate otherwise healthy public authority. + if e.specBackend != "sqlite" { + if repository, ok := e.repository.(SpecSearchRepository); ok { + if err := repository.RebuildSpecSearchProjection(ctx, active); err != nil { + slog.Warn("SQLite spec projection repair failed", "error", err) + } + } + } + e.refreshSpecSearch(ctx, false) for _, activation := range active { if err := e.repository.ConvergePublicationDependencies(ctx, activation.ProductID, activation.SourceRevision); err != nil { return err @@ -640,6 +726,7 @@ func (e *Engine) installLocked(ctx context.Context) error { return err } e.index = next + e.installSpecSearchLocked(next) return nil } @@ -653,6 +740,7 @@ func applicableSpecifications(specs []SourceSpec) []ApplicableSpecification { func (e *Engine) viewFromSource(source Source) PublicView { view := PublicView{ + NumericFacts: source.NumericFacts, NumericDefinitions: source.NumericDefinitions, PackageFormFactor: source.Product.PackageFormFactor, ApplicableSpecifications: applicableSpecifications(source.ApplicableSpecs), Site: source.Site, @@ -715,7 +803,7 @@ func (e *Engine) viewFromSource(source Source) PublicView { view.Language = "en-US" } for _, spec := range source.Specs { - view.Specifications = append(view.Specifications, Specification(spec)) + view.Specifications = append(view.Specifications, Specification{ID: spec.ID, Name: spec.Name, RawValue: spec.RawValue, PreferredUnit: spec.PreferredUnit, Language: spec.Language, SourceRevision: spec.SourceRevision}) } for _, document := range source.Documents { location := document.URL @@ -834,11 +922,14 @@ func (e *Engine) validateAsset(asset PublicAsset, verifyChecksum bool) (string, return resolvedPath, nil } +const renderContractVersion = "shared-specs-1" + type unitManifest struct { - ProductID string `json:"product_id"` - Revision int64 `json:"revision"` - SiteEpoch int64 `json:"site_epoch"` - Files map[string]string `json:"files"` + RenderContractVersion string `json:"render_contract_version"` + ProductID string `json:"product_id"` + Revision int64 `json:"revision"` + SiteEpoch int64 `json:"site_epoch"` + Files map[string]string `json:"files"` } func (e *Engine) stage(ctx context.Context, operationID string, view PublicView) (string, string, error) { @@ -921,7 +1012,7 @@ func (e *Engine) stage(ctx context.Context, operationID string, view PublicView) return "", "", err } defer os.RemoveAll(staging) - manifest := unitManifest{ProductID: view.ID, Revision: view.Revision, SiteEpoch: view.SiteEpoch, Files: make(map[string]string, len(files))} + manifest := unitManifest{RenderContractVersion: renderContractVersion, ProductID: view.ID, Revision: view.Revision, SiteEpoch: view.SiteEpoch, Files: make(map[string]string, len(files))} for name, body := range files { manifest.Files[name] = hashBytes(body) if err := writeDurable(filepath.Join(staging, name), body); err != nil { @@ -960,7 +1051,7 @@ func (e *Engine) validateUnit(activation ActivePublication) error { return ErrArtifactInvalid } var manifest unitManifest - if json.Unmarshal(manifestBody, &manifest) != nil || manifest.ProductID != activation.ProductID || manifest.Revision != activation.PublicRevision || manifest.SiteEpoch != activation.SiteEpoch { + if json.Unmarshal(manifestBody, &manifest) != nil || manifest.RenderContractVersion != renderContractVersion || manifest.ProductID != activation.ProductID || manifest.Revision != activation.PublicRevision || manifest.SiteEpoch != activation.SiteEpoch { return ErrArtifactInvalid } for name, expected := range manifest.Files { diff --git a/internal/publishing/spec_bleve.go b/internal/publishing/spec_bleve.go new file mode 100644 index 0000000..f37eae5 --- /dev/null +++ b/internal/publishing/spec_bleve.go @@ -0,0 +1,583 @@ +package publishing + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "os" + "path/filepath" + "reflect" + "runtime" + "slices" + "sync" + "time" + + "prods/internal/platform" + + "github.com/blevesearch/bleve/v2" + "github.com/blevesearch/bleve/v2/analysis/analyzer/keyword" + "github.com/blevesearch/bleve/v2/index/scorch" + "github.com/blevesearch/bleve/v2/mapping" + indexapi "github.com/blevesearch/bleve_index_api" +) + +var specBleveMetadataKey = []byte("prods-spec-metadata") + +const specBleveOwner = "prods-spec-search-root-1\n" + +type specBleveMetadata struct { + Version string `json:"version"` + ProjectionVersion string `json:"projection_version"` + Digest string `json:"digest"` + Documents []specBleveDocument `json:"documents"` +} + +// All readers, replacement, and close share this mutex. The cache contains +// only deep-copied frozen publication data verified against stored documents. +type bleveSpecIndex struct { + mu sync.Mutex + root string + index bleve.Index + owner *platform.Ownership + config Config + resources *platform.ResourceGate + documents []specBleveDocument + sourceDigest string + payloadBytes uint64 + closed bool +} + +func specBleveMapping() *mapping.IndexMappingImpl { + m := bleve.NewIndexMapping() + m.DefaultAnalyzer = keyword.Name + m.StoreDynamic = false + m.IndexDynamic = true + m.DocValuesDynamic = false + m.DefaultMapping.DefaultAnalyzer = keyword.Name + // Dynamic names are exclusively generated by specBleveIndexFields from + // full SHA256 namespaces. No user field name or raw document is indexed. + payload := bleve.NewTextFieldMapping() + payload.Index = false + payload.Store = true + payload.IncludeInAll = false + payload.IncludeTermVectors = false + payload.DocValues = false + m.DefaultMapping.AddFieldMappingsAt("payload", payload) + return m +} + +// Admission estimates reserve 16x canonical JSON plus 8 MiB for payload copies, +// postings, an atomic replacement and query results. This is an overflow-safe +// conservative estimate, not a hard RAM/disk/Scorch background merge limit. +func specBleveEstimate(bytes uint64) (uint64, error) { + const overhead = 8 << 20 + if bytes > (math.MaxUint64-overhead)/16 { + return 0, platform.ErrResourceCritical + } + return bytes*16 + overhead, nil +} + +func (b *bleveSpecIndex) admit(ctx context.Context, size uint64) (*platform.Reservation, error) { + estimate, err := specBleveEstimate(size) + if err != nil { + return nil, err + } + r, _, err := b.resources.Admit(ctx, platform.ResourceRequest{Operation: "spec-search-index", Path: b.root, RequiredBytes: estimate, ByteHeadroom: b.config.ByteHeadroom, MinFreePercent: b.config.MinFreePercent, RequiredInodes: 64, InodeHeadroom: b.config.InodeHeadroom}) + return r, err +} + +func newBleveSpecIndex(ctx context.Context, root string, config Config) (result *bleveSpecIndex, err error) { + if root == "" { + return nil, fmt.Errorf("empty spec index root") + } + root, err = filepath.Abs(root) + if err != nil { + return nil, err + } + b := &bleveSpecIndex{root: root, config: config, resources: config.ResourceGate} + if b.resources == nil { + b.resources = platform.NewResourceGate(nil) + } + reservation, err := b.admit(ctx, 0) + if err != nil { + return nil, err + } + defer reservation.Release() + // Never follow a symlink at either of the private directory boundaries. + for _, path := range []string{filepath.Dir(root), root} { + info, statErr := os.Lstat(path) + if statErr == nil && (!info.IsDir() || info.Mode()&os.ModeSymlink != 0) { + return nil, fmt.Errorf("unsafe spec index directory: %s", path) + } + if statErr != nil && !errors.Is(statErr, os.ErrNotExist) { + return nil, statErr + } + // A caller must give this adapter private directories. Never chmod a + // shared existing parent such as /tmp merely to open a derived index. + if runtime.GOOS != "windows" && statErr == nil && info.Mode().Perm()&0o077 != 0 { + return nil, fmt.Errorf("spec index directory is not private: %s", path) + } + } + if err = os.MkdirAll(filepath.Dir(root), 0o700); err != nil { + return nil, err + } + if err = os.Chmod(filepath.Dir(root), 0o700); err != nil { + return nil, err + } + if err = os.MkdirAll(root, 0o700); err != nil { + return nil, err + } + if err = os.Chmod(root, 0o700); err != nil { + return nil, err + } + lockInfo, lockErr := os.Lstat(filepath.Join(root, "ownership.lock")) + if lockErr == nil && !lockInfo.Mode().IsRegular() { + return nil, fmt.Errorf("unsafe spec index ownership file") + } + if lockErr != nil && !errors.Is(lockErr, os.ErrNotExist) { + return nil, lockErr + } + ownershipTarget := filepath.Join(root, "ownership") + targetInfo, targetErr := os.Lstat(ownershipTarget) + if targetErr == nil && !targetInfo.Mode().IsRegular() { + return nil, fmt.Errorf("unsafe spec index ownership target") + } + if targetErr != nil && !errors.Is(targetErr, os.ErrNotExist) { + return nil, targetErr + } + b.owner, err = platform.AcquireDatabase(ownershipTarget) + if err != nil { + return nil, err + } + defer func() { + if err != nil { + if b.index != nil { + _ = b.index.Close() + } + _ = b.owner.Close() + } + }() + marker := filepath.Join(root, "owner") + info, statErr := os.Lstat(marker) + if errors.Is(statErr, os.ErrNotExist) { + entries, readErr := os.ReadDir(root) + if readErr != nil { + return nil, readErr + } + for _, entry := range entries { + if entry.Name() != "ownership.lock" { + return nil, fmt.Errorf("unowned spec index root: %s", root) + } + } + if err = os.WriteFile(marker, []byte(specBleveOwner), 0o600); err != nil { + return nil, err + } + } else { + if statErr != nil { + return nil, statErr + } + if !info.Mode().IsRegular() || info.Size() != int64(len(specBleveOwner)) { + return nil, fmt.Errorf("unsafe spec index owner marker") + } + encoded, readErr := os.ReadFile(marker) + if readErr != nil { + return nil, readErr + } + if string(encoded) != specBleveOwner { + return nil, fmt.Errorf("unrecognized spec index owner marker") + } + } + child := filepath.Join(root, "index") + childInfo, statErr := os.Lstat(child) + if statErr == nil { + if !childInfo.IsDir() || childInfo.Mode()&os.ModeSymlink != 0 { + return nil, fmt.Errorf("unsafe spec index child") + } + err = filepath.WalkDir(child, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if !entry.IsDir() && !entry.Type().IsRegular() { + return fmt.Errorf("unsafe spec index entry: %s", path) + } + return nil + }) + if err == nil { + b.index, err = bleve.Open(child) + } + if err == nil { + err = b.loadLocked(ctx) + } + if err == nil { + return b, nil + } + if specBleveTransientError(err) { + return nil, err + } + if b.index != nil { + _ = b.index.Close() + b.index = nil + } + // The valid marker and exclusive root lock authorize only a rename of + // our child. Its files remain available for investigation/recovery. + if err = b.quarantineLocked(); err != nil { + return nil, err + } + } else if !errors.Is(statErr, os.ErrNotExist) { + return nil, statErr + } + if err = b.createLocked(ctx); err != nil { + return nil, err + } + return b, nil +} + +func (b *bleveSpecIndex) quarantineLocked() error { + child := filepath.Join(b.root, "index") + if _, err := os.Lstat(child); errors.Is(err, os.ErrNotExist) { + return nil + } else if err != nil { + return err + } + return os.Rename(child, filepath.Join(b.root, fmt.Sprintf("quarantine-%d", time.Now().UnixNano()))) +} + +func (b *bleveSpecIndex) createLocked(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + idx, err := bleve.NewUsing(filepath.Join(b.root, "index"), specBleveMapping(), scorch.Name, scorch.Name, nil) + if err != nil { + return err + } + b.index = idx + if err = os.Chmod(filepath.Join(b.root, "index"), 0o700); err != nil { + return err + } + docs, digest, err := specBleveDocuments(nil) + if err != nil { + return err + } + metadata, err := json.Marshal(specBleveMetadata{Version: specBleveVersion, ProjectionVersion: SpecProjectionVersion, Digest: digest, Documents: docs}) + if err != nil { + return err + } + batch := idx.NewBatch() + batch.SetInternal(specBleveMetadataKey, metadata) + if err = idx.Batch(batch); err != nil { + return err + } + b.documents = docs + b.sourceDigest = digest + b.payloadBytes = uint64(len(metadata)) + return nil +} + +func specBleveDecode(encoded []byte, value any) error { + decoder := json.NewDecoder(bytes.NewReader(encoded)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(value); err != nil { + return err + } + if err := decoder.Decode(new(any)); err != io.EOF { + return fmt.Errorf("extra spec index payload") + } + return nil +} + +func (b *bleveSpecIndex) loadLocked(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + advanced, err := b.index.Advanced() + if err != nil { + return err + } + if _, ok := advanced.(*scorch.Scorch); !ok { + return fmt.Errorf("incompatible spec index type") + } + actualMapping, err := json.Marshal(b.index.Mapping()) + if err != nil { + return err + } + expectedMapping, _ := json.Marshal(specBleveMapping()) + if !bytes.Equal(actualMapping, expectedMapping) { + return fmt.Errorf("incompatible spec index mapping") + } + encoded, err := b.index.GetInternal(specBleveMetadataKey) + if err != nil { + return err + } + reservation, err := b.admit(ctx, uint64(len(encoded))) + if err != nil { + return err + } + defer reservation.Release() + var metadata specBleveMetadata + if err = specBleveDecode(encoded, &metadata); err != nil { + return err + } + if metadata.Version != specBleveVersion || metadata.ProjectionVersion != SpecProjectionVersion { + return fmt.Errorf("incompatible spec index version") + } + docs, digest, err := specBleveValidateDocuments(metadata.Documents) + if err != nil { + return err + } + if digest != metadata.Digest { + return fmt.Errorf("invalid spec index digest") + } + // Reconstruct from each actual stored document, not metadata copies/count. + actual, err := b.storedDocumentsLocked(ctx, docs) + if err != nil { + return err + } + actual, digest, err = specBleveValidateDocuments(actual) + if err != nil { + return err + } + if digest != metadata.Digest || !reflect.DeepEqual(actual, docs) { + return fmt.Errorf("spec index documents do not match metadata") + } + b.documents = actual + b.sourceDigest = digest + b.payloadBytes = uint64(len(encoded)) + return nil +} + +func (b *bleveSpecIndex) storedDocumentsLocked(ctx context.Context, expected []specBleveDocument) ([]specBleveDocument, error) { + count, err := b.index.DocCount() + if err != nil { + return nil, err + } + if count != uint64(len(expected)) { + return nil, fmt.Errorf("incomplete spec index document inventory") + } + actual := make([]specBleveDocument, 0, len(expected)) + for _, expectedDoc := range expected { + if err := ctx.Err(); err != nil { + return nil, err + } + doc, err := b.index.Document(specBleveProductDocID(expectedDoc.Binding.ProductID)) + if err != nil { + return nil, err + } + if doc == nil { + return nil, fmt.Errorf("missing spec index document") + } + var payload []byte + fields := 0 + doc.VisitFields(func(field indexapi.Field) { + if field.Name() == "payload" { + fields++ + payload = append([]byte(nil), field.Value()...) + } + }) + if fields != 1 { + return nil, fmt.Errorf("invalid spec index stored payload") + } + var decoded specBleveDocument + if err := specBleveDecode(payload, &decoded); err != nil { + return nil, err + } + if decoded.Binding.ProductID != expectedDoc.Binding.ProductID { + return nil, fmt.Errorf("spec index document identity mismatch") + } + actual = append(actual, decoded) + } + return actual, nil +} + +func (b *bleveSpecIndex) unavailableLocked() { + b.sourceDigest = "" + b.documents = nil + b.payloadBytes = 0 + if b.index != nil { + _ = b.index.Close() + b.index = nil + } +} + +func specBleveTransientError(err error) bool { + return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) || errors.Is(err, platform.ErrResourceCritical) || errors.Is(err, platform.ErrResourceUnavailable) || errors.Is(err, os.ErrPermission) +} + +func (b *bleveSpecIndex) replace(ctx context.Context, active []ActivePublication) error { + return b.replaceWithMode(ctx, active, false) +} + +// rebuild always performs a real atomic Batch, even for an unchanged source. +func (b *bleveSpecIndex) rebuild(ctx context.Context, active []ActivePublication) error { + return b.replaceWithMode(ctx, active, true) +} + +func (b *bleveSpecIndex) replaceWithMode(ctx context.Context, active []ActivePublication, force bool) (err error) { + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return fmt.Errorf("spec index is closed") + } + // Every failed refresh revokes availability, even when admission or payload + // validation fails before Batch; serving the old digest would hide failure. + defer func() { + if err != nil { + b.unavailableLocked() + } + }() + docs, digest, err := specBleveDocuments(active) + if err != nil { + return err + } + encoded, err := json.Marshal(specBleveMetadata{Version: specBleveVersion, ProjectionVersion: SpecProjectionVersion, Digest: digest, Documents: docs}) + if err != nil { + return err + } + reservation, err := b.admit(ctx, uint64(len(encoded))) + if err != nil { + return err + } + defer reservation.Release() + if b.index == nil { + if err = b.quarantineLocked(); err != nil { + return err + } + if err = b.createLocked(ctx); err != nil { + return err + } + } + if !force && digest == b.sourceDigest { + readErr := b.loadLocked(ctx) + if readErr == nil && reflect.DeepEqual(b.documents, docs) { + return nil + } + if specBleveTransientError(readErr) { + return readErr + } + // A damaged same-digest index must be rebuilt, never silently skipped. + b.unavailableLocked() + if err = b.quarantineLocked(); err != nil { + return err + } + if err = b.createLocked(ctx); err != nil { + return err + } + } + batch := b.index.NewBatch() + for _, old := range b.documents { + batch.Delete(specBleveProductDocID(old.Binding.ProductID)) + } + for _, doc := range docs { + if err = ctx.Err(); err != nil { + return err + } + fields, fieldErr := specBleveIndexFields(doc) + if fieldErr != nil { + return fieldErr + } + if err = batch.Index(specBleveProductDocID(doc.Binding.ProductID), fields); err != nil { + return err + } + } + batch.SetInternal(specBleveMetadataKey, encoded) + if err = ctx.Err(); err != nil { + return err + } + if err = b.index.Batch(batch); err != nil { + return err + } + b.documents = docs + b.sourceDigest = digest + b.payloadBytes = uint64(len(encoded)) + return b.loadLocked(ctx) +} + +func (b *bleveSpecIndex) candidates(ctx context.Context, request SpecSearchRequest) (snapshot SpecCandidateSnapshot, err error) { + b.mu.Lock() + defer b.mu.Unlock() + if b.closed || b.index == nil || b.sourceDigest == "" { + return snapshot, fmt.Errorf("spec index unavailable") + } + if err = ctx.Err(); err != nil { + return snapshot, err + } + for _, predicate := range request.Predicates { + if err = validateSpecPredicate(predicate); err != nil { + return snapshot, err + } + } + reservation, err := b.admit(ctx, b.payloadBytes) + if err != nil { + return snapshot, err + } + defer reservation.Release() + count, err := b.index.DocCount() + if err != nil { + return snapshot, err + } + if count != uint64(len(b.documents)) { + return snapshot, fmt.Errorf("incomplete spec index document inventory") + } + plans := specBlevePlans(b.documents, request.Predicates) + search := bleve.NewSearchRequestOptions(specBleveQuery(plans), len(b.documents), 0, false) + result, err := b.index.SearchInContext(ctx, search) + if err != nil { + return snapshot, err + } + if len(result.Status.Errors) > 0 || result.Total != uint64(len(result.Hits)) { + return snapshot, fmt.Errorf("incomplete spec index hits") + } + expected := make(map[string]bool) + for _, d := range b.documents { + if specBleveCoarseMatch(d, plans) { + expected[specBleveProductDocID(d.Binding.ProductID)] = true + } + } + if len(expected) != len(result.Hits) { + return snapshot, fmt.Errorf("spec index hits disagree with frozen coarse predicate") + } + seen := make(map[string]bool, len(result.Hits)) + for _, hit := range result.Hits { + if !expected[hit.ID] || seen[hit.ID] { + return snapshot, fmt.Errorf("unexpected or duplicate spec index hit") + } + seen[hit.ID] = true + } + snapshot = SpecCandidateSnapshot{ProjectionVersion: SpecProjectionVersion, SourceDigest: b.sourceDigest, Inventory: make([]SpecCandidateBinding, 0, len(b.documents)), Candidates: []SpecCandidateBinding{}} + for _, d := range b.documents { + binding := d.Binding + binding.SpecIDs = slices.Clone(binding.SpecIDs) + snapshot.Inventory = append(snapshot.Inventory, binding) + if expected[specBleveProductDocID(d.Binding.ProductID)] { + binding.SpecIDs = slices.Clone(binding.SpecIDs) + snapshot.Candidates = append(snapshot.Candidates, binding) + } + } + // All bindings and their only nested slice are copied from the frozen cache. + return snapshot, nil +} + +func (b *bleveSpecIndex) digest() string { b.mu.Lock(); defer b.mu.Unlock(); return b.sourceDigest } +func (b *bleveSpecIndex) close() error { + b.mu.Lock() + defer b.mu.Unlock() + if b.closed { + return nil + } + b.closed = true + b.sourceDigest = "" + b.documents = nil + var err error + if b.index != nil { + err = b.index.Close() + b.index = nil + } + if b.owner != nil { + err = errors.Join(err, b.owner.Close()) + b.owner = nil + } + return err +} diff --git a/internal/publishing/spec_bleve_coarse.go b/internal/publishing/spec_bleve_coarse.go new file mode 100644 index 0000000..3ff089b --- /dev/null +++ b/internal/publishing/spec_bleve_coarse.go @@ -0,0 +1,315 @@ +package publishing + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "slices" + "sort" + "strings" + + "prods/internal/specnumeric" + + "github.com/blevesearch/bleve/v2" + "github.com/blevesearch/bleve/v2/search/query" +) + +const specBleveVersion = "bleve-specs-1" + +// Every supported decimal has at most 57 fractional places and magnitude +// below 10^25. Scaling by 10^57 therefore fits exactly into 82 digits. +func specBleveDecimalKey(value specnumeric.Decimal) (string, error) { + d, err := specnumeric.ParseDecimal(value.String()) + if err != nil { + return "", err + } + text := d.String() + negative := strings.HasPrefix(text, "-") + text = strings.TrimPrefix(text, "-") + parts := strings.SplitN(text, ".", 2) + frac := "" + if len(parts) == 2 { + frac = parts[1] + } + if len(frac) > 57 { + return "", specnumeric.ErrUnsupportedPrecision + } + digits := strings.TrimLeft(parts[0]+frac+strings.Repeat("0", 57-len(frac)), "0") + if len(digits) > 82 { + return "", specnumeric.ErrUnsupportedPrecision + } + digits = strings.Repeat("0", 82-len(digits)) + digits + if !negative { + return "1" + digits, nil + } + complement := []byte(digits) + for i, c := range complement { + complement[i] = '9' - (c - '0') + } + return "0" + string(complement), nil +} + +type specBleveDocument struct { + Binding SpecCandidateBinding `json:"binding"` + CategoryID string `json:"category_id"` + CategoryTrail []string `json:"category_trail"` + ManufacturerID string `json:"manufacturer_id"` + Definitions []PublicNumericDefinition `json:"definitions"` + Facts []PublicNumericFact `json:"facts"` +} + +func specBleveDocuments(active []ActivePublication) ([]specBleveDocument, string, error) { + docs := make([]specBleveDocument, 0, len(active)) + seen := make(map[string]bool, len(active)) + for _, a := range active { + if a.ProductID == "" || a.View.ID != a.ProductID || seen[a.ProductID] || a.PublicRevision <= 0 || a.SourceRevision <= 0 || a.SiteEpoch <= 0 || a.VisibilityGeneration < 0 { + return nil, "", fmt.Errorf("invalid Bleve publication binding: %q", a.ProductID) + } + seen[a.ProductID] = true + for _, def := range a.View.NumericDefinitions { + if def.SpecID == "" || def.SpecSemanticVersion <= 0 || specnumeric.ValidateProfile(def.Profile) != nil { + return nil, "", fmt.Errorf("invalid Bleve numeric definition") + } + } + for _, fact := range a.View.NumericFacts { + if fact.SpecID == "" || fact.SpecSemanticVersion <= 0 || specnumeric.ValidateProfile(fact.Profile) != nil || specnumeric.ValidateValue(fact.Value, fact.Profile) != nil { + return nil, "", fmt.Errorf("invalid Bleve numeric fact") + } + } + d := specBleveDocument{Binding: SpecCandidateBinding{ProductID: a.ProductID, PublicRevision: a.PublicRevision, SourceRevision: a.SourceRevision, SiteEpoch: a.SiteEpoch, VisibilityGeneration: a.VisibilityGeneration, ProjectionVersion: SpecProjectionVersion, SpecIDs: SpecProjectionIDs(a.View)}, CategoryID: a.View.CategoryID, ManufacturerID: a.View.ManufacturerID, Definitions: a.View.NumericDefinitions, Facts: a.View.NumericFacts, CategoryTrail: []string{}} + for _, c := range a.View.CategoryTrail { + d.CategoryTrail = append(d.CategoryTrail, c.ID) + } + docs = append(docs, d) + } + sort.Slice(docs, func(i, j int) bool { return docs[i].Binding.ProductID < docs[j].Binding.ProductID }) + // Round-trip is also the immutable deep copy: caller-owned nested slices and + // values can never alter cache contents after an atomic replacement. + encoded, err := json.Marshal(docs) + if err != nil { + return nil, "", err + } + if err = json.Unmarshal(encoded, &docs); err != nil { + return nil, "", err + } + digest := sha256.Sum256(encoded) + return docs, hex.EncodeToString(digest[:]), nil +} + +func specActiveDigest(active []ActivePublication) (string, error) { + _, digest, err := specBleveDocuments(active) + return digest, err +} + +func specBleveValidateDocuments(docs []specBleveDocument) ([]specBleveDocument, string, error) { + active := make([]ActivePublication, 0, len(docs)) + for _, d := range docs { + a := ActivePublication{ProductID: d.Binding.ProductID, PublicRevision: d.Binding.PublicRevision, SourceRevision: d.Binding.SourceRevision, SiteEpoch: d.Binding.SiteEpoch, VisibilityGeneration: d.Binding.VisibilityGeneration, View: PublicView{ID: d.Binding.ProductID, CategoryID: d.CategoryID, ManufacturerID: d.ManufacturerID, NumericDefinitions: d.Definitions, NumericFacts: d.Facts}} + for _, id := range d.CategoryTrail { + a.View.CategoryTrail = append(a.View.CategoryTrail, CategoryRef{ID: id}) + } + active = append(active, a) + } + canonical, digest, err := specBleveDocuments(active) + if err != nil { + return nil, "", err + } + a, _ := json.Marshal(docs) + b, _ := json.Marshal(canonical) + if !bytes.Equal(a, b) { + return nil, "", fmt.Errorf("noncanonical Bleve inventory payload") + } + return canonical, digest, nil +} + +type specBleveEnvelope struct { + present, broad bool + low, high string +} + +func specBleveEnvelopes(d specBleveDocument) map[string]specBleveEnvelope { + result := make(map[string]specBleveEnvelope, len(d.Binding.SpecIDs)) + for _, id := range d.Binding.SpecIDs { + result[id] = specBleveEnvelope{present: true} + } + for _, f := range d.Facts { + e := result[f.SpecID] + var values []specnumeric.Decimal + if f.Value.Context.Kind != specnumeric.ContextUnconditional { + e.broad = true + } else { + switch f.Value.Shape { + case specnumeric.ShapeScalar: + values = append(values, *f.Value.Scalar) + case specnumeric.ShapeTolerance: + if f.Profile.SemanticRole == specnumeric.RoleNominal { + values = append(values, f.Value.Tolerance.Nominal) + } else { + e.broad = true + } + case specnumeric.ShapeDiscreteSet: + if f.Profile.SemanticRole == specnumeric.RoleOperatingSupport { + values = f.Value.Values + } else { + e.broad = true + } + case specnumeric.ShapeIntervalSet: + if f.Profile.SemanticRole == specnumeric.RoleOperatingSupport { + for _, interval := range f.Value.Intervals { + values = append(values, interval.Lower, interval.Upper) + } + } else { + e.broad = true + } + default: + e.broad = true + } + } + for _, v := range values { + key, err := specBleveDecimalKey(v) + if err != nil { + e.broad = true + continue + } + if e.low == "" || key < e.low { + e.low = key + } + if e.high == "" || key > e.high { + e.high = key + } + } + result[f.SpecID] = e + } + return result +} + +func specBleveNamespace(id string) string { + h := sha256.Sum256([]byte(id)) + return "s_" + hex.EncodeToString(h[:]) +} +func specBleveProductDocID(id string) string { return "product:" + id } + +type specBlevePlan struct{ id, lowMax, highMin string } + +// A global profile conflict can change unit interpretation. In that case use +// presence only, leaving profile validation and exact truth to the engine. +func specBlevePlans(docs []specBleveDocument, predicates []specnumeric.Predicate) []specBlevePlan { + plans := make([]specBlevePlan, 0, len(predicates)) + for _, p := range predicates { + plan := specBlevePlan{id: p.SpecID} + var definition *PublicNumericDefinition + compatible := true + for _, d := range docs { + defs := slices.Clone(d.Definitions) + for _, f := range d.Facts { + defs = append(defs, PublicNumericDefinition{SpecID: f.SpecID, SpecSemanticVersion: f.SpecSemanticVersion, Profile: f.Profile}) + } + for _, def := range defs { + if def.SpecID != p.SpecID { + continue + } + if definition == nil { + copy := def + definition = © + } else if !compatibleSpecDefinitions(*definition, def) { + compatible = false + } + } + } + if definition != nil && compatible { + if normalized, err := specnumeric.NormalizePredicate(p, definition.Profile); err == nil { + key := func(d *specnumeric.Decimal) string { + if d == nil { + return "" + } + v, _ := specBleveDecimalKey(*d) + return v + } + switch normalized.Operator { + case specnumeric.OperatorEqual, specnumeric.OperatorContainsPoint: + plan.lowMax = key(normalized.Value) + plan.highMin = plan.lowMax + case specnumeric.OperatorContainsRange: + plan.lowMax = key(normalized.Lower) + plan.highMin = key(normalized.Upper) + case specnumeric.OperatorBetween, specnumeric.OperatorOverlapsRange: + plan.lowMax = key(normalized.Upper) + plan.highMin = key(normalized.Lower) + case specnumeric.OperatorLess, specnumeric.OperatorLessEqual: + plan.lowMax = key(normalized.Value) + case specnumeric.OperatorGreater, specnumeric.OperatorGreaterEqual: + plan.highMin = key(normalized.Value) + } + } + } + plans = append(plans, plan) + } + return plans +} + +func specBleveCoarseMatch(doc specBleveDocument, plans []specBlevePlan) bool { + envelopes := specBleveEnvelopes(doc) + for _, p := range plans { + e := envelopes[p.id] + if !e.present { + return false + } + if e.broad { + continue + } + if p.lowMax != "" && (e.low == "" || e.low > p.lowMax) { + return false + } + if p.highMin != "" && (e.high == "" || e.high < p.highMin) { + return false + } + } + return true +} + +func specBleveQuery(plans []specBlevePlan) query.Query { + term := func(field, value string) query.Query { q := bleve.NewTermQuery(value); q.SetField(field); return q } + clauses := []query.Query{term("product", "yes")} + inclusive := true + for _, p := range plans { + field := specBleveNamespace(p.id) + clauses = append(clauses, term(field+"_present", "yes")) + var bounds []query.Query + if p.lowMax != "" { + q := bleve.NewTermRangeInclusiveQuery("", p.lowMax, &inclusive, &inclusive) + q.SetField(field + "_low") + bounds = append(bounds, q) + } + if p.highMin != "" { + q := bleve.NewTermRangeInclusiveQuery(p.highMin, "", &inclusive, &inclusive) + q.SetField(field + "_high") + bounds = append(bounds, q) + } + if len(bounds) > 0 { + clauses = append(clauses, bleve.NewDisjunctionQuery(term(field+"_broad", "yes"), bleve.NewConjunctionQuery(bounds...))) + } + } + return bleve.NewConjunctionQuery(clauses...) +} + +func specBleveIndexFields(doc specBleveDocument) (map[string]any, error) { + payload, err := json.Marshal(doc) + if err != nil { + return nil, err + } + fields := map[string]any{"product": "yes", "payload": string(payload)} + for id, e := range specBleveEnvelopes(doc) { + ns := specBleveNamespace(id) + fields[ns+"_present"] = "yes" + if e.broad { + fields[ns+"_broad"] = "yes" + } + if e.low != "" { + fields[ns+"_low"] = e.low + fields[ns+"_high"] = e.high + } + } + return fields, nil +} diff --git a/internal/publishing/spec_bleve_test.go b/internal/publishing/spec_bleve_test.go new file mode 100644 index 0000000..ddf0e67 --- /dev/null +++ b/internal/publishing/spec_bleve_test.go @@ -0,0 +1,149 @@ +package publishing + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/blevesearch/bleve/v2/index/scorch" + "prods/internal/specnumeric" +) + +func TestBleveRejectsSymlinkOwnershipTargetBeforeExternalLock(t *testing.T) { + root := filepath.Join(t.TempDir(), "search", "specs") + if err := os.MkdirAll(root, 0o700); err != nil { + t.Fatal(err) + } + external := filepath.Join(t.TempDir(), "target") + if err := os.WriteFile(external, []byte("external"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(external, filepath.Join(root, "ownership")); err != nil { + t.Fatal(err) + } + b, err := newBleveSpecIndex(t.Context(), root, Config{}) + if b != nil { + _ = b.close() + } + if err == nil { + t.Fatal("symlink ownership target was admitted") + } + if _, err := os.Lstat(external + ".lock"); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("external ownership lock was created: %v", err) + } +} + +func bleveDecimal(t *testing.T, text string) specnumeric.Decimal { + t.Helper() + d, err := specnumeric.ParseDecimal(text) + if err != nil { + t.Fatal(err) + } + return d +} + +func TestSpecBleveKeyPreservesExactDecimalOrder(t *testing.T) { + values := []string{"-999999999999999999999999.9999999999", "-1.000000000000000000000000000000001", "-1", "-0.000000000000000000000001", "0", "0.000000000000000000000001", "1", "1.000000000000000000000000000000001"} + previous := "" + for _, value := range values { + key, err := specBleveDecimalKey(bleveDecimal(t, value)) + if err != nil { + t.Fatal(err) + } + if len(key) != 83 || (previous != "" && previous >= key) { + t.Fatalf("key order for %s: %q >= %q", value, previous, key) + } + previous = key + } +} + +func blevePublication(t *testing.T) ActivePublication { + t.Helper() + p := specnumeric.Profile{Quantity: "voltage", SemanticRole: specnumeric.RoleOperatingSupport, AllowedShapes: []specnumeric.Shape{specnumeric.ShapeIntervalSet}, AllowedOperators: []specnumeric.Operator{specnumeric.OperatorContainsPoint}, CanonicalUnit: "V", DeclaredInputUnit: "V", NumericFormat: specnumeric.NumericFormatDecimalDot, SchemaVersion: specnumeric.SchemaVersion, ParserVersion: specnumeric.ParserVersion, UnitRegistryVersion: specnumeric.UnitRegistryVersion} + v := specnumeric.Value{SchemaVersion: p.SchemaVersion, Quantity: p.Quantity, SemanticRole: p.SemanticRole, Shape: specnumeric.ShapeIntervalSet, Unit: "V", UnitRegistryVersion: p.UnitRegistryVersion, Context: specnumeric.Context{Kind: specnumeric.ContextUnconditional}, Intervals: []specnumeric.Interval{{Lower: bleveDecimal(t, "3"), Upper: bleveDecimal(t, "3.6"), LowerInclusive: true, UpperInclusive: false}, {Lower: bleveDecimal(t, "4.5"), Upper: bleveDecimal(t, "5.5"), LowerInclusive: true, UpperInclusive: true}}} + return ActivePublication{ProductID: "opaque-product", SourceRevision: 9007199254740993, PublicRevision: 7, SiteEpoch: 2, VisibilityGeneration: 9, View: PublicView{ID: "opaque-product", NumericDefinitions: []PublicNumericDefinition{{SpecID: "voltage/opaque", SpecSemanticVersion: 1, Profile: p}}, NumericFacts: []PublicNumericFact{{SpecID: "voltage/opaque", SpecSemanticVersion: 1, Profile: p, Value: v}}}} +} + +func TestBleveCoarseEnvelopeRetainsGapPoint(t *testing.T) { + a := blevePublication(t) + docs, _, err := specBleveDocuments([]ActivePublication{a}) + if err != nil { + t.Fatal(err) + } + q := bleveDecimal(t, "4") + predicate := specnumeric.Predicate{SpecID: "voltage/opaque", Operator: specnumeric.OperatorContainsPoint, Unit: "V", Value: &q} + plans := specBlevePlans(docs, []specnumeric.Predicate{predicate}) + if !specBleveCoarseMatch(docs[0], plans) { + t.Fatal("coarse envelope dropped gap point") + } + if got := specnumeric.Evaluate(&a.View.NumericFacts[0].Value, a.View.NumericFacts[0].Profile, predicate); got != specnumeric.NoMatch { + t.Fatalf("exact oracle = %s", got) + } +} + +func TestBlevePersistsAndRejectsMissingCandidateDocument(t *testing.T) { + ctx := context.Background() + root := filepath.Join(t.TempDir(), "search", "specs") + b, err := newBleveSpecIndex(ctx, root, Config{}) + if err != nil { + t.Fatal(err) + } + a := blevePublication(t) + if err := b.replace(ctx, []ActivePublication{a}); err != nil { + t.Fatal(err) + } + advanced, err := b.index.Advanced() + if err != nil { + t.Fatal(err) + } + scorchIndex := advanced.(*scorch.Scorch) + batchCount := scorchIndex.StatsMap()["TotBatches"].(uint64) + if err := b.rebuild(ctx, []ActivePublication{a}); err != nil { + t.Fatal(err) + } + if scorchIndex.StatsMap()["TotBatches"].(uint64) <= batchCount { + t.Fatal("forced rebuild skipped the real Scorch Batch") + } + q := bleveDecimal(t, "4") + request := SpecSearchRequest{Predicates: []specnumeric.Predicate{{SpecID: "voltage/opaque", Operator: specnumeric.OperatorContainsPoint, Unit: "V", Value: &q}}} + before, err := b.candidates(ctx, request) + if err != nil { + t.Fatal(err) + } + if len(before.Candidates) != 1 || before.SourceDigest == "" || before.Inventory[0].SourceRevision != a.SourceRevision { + t.Fatalf("snapshot = %#v", before) + } + if err := b.close(); err != nil { + t.Fatal(err) + } + b, err = newBleveSpecIndex(ctx, root, Config{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = b.close() }) + after, err := b.candidates(ctx, request) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(before, after) || b.digest() != before.SourceDigest { + t.Fatal("reopen changed coherent snapshot") + } + if err := b.index.Delete(specBleveProductDocID(a.ProductID)); err != nil { + t.Fatal(err) + } + if _, err := b.candidates(ctx, request); err == nil { + t.Fatal("missing matching document returned successful narrowing") + } + // Keep DocCount unchanged to distinguish the ALL-hit proof from a count + // check: an unrelated index document cannot stand in for the missing hit. + if err := b.index.Index("unexpected", map[string]any{"product": "yes"}); err != nil { + t.Fatal(err) + } + if _, err := b.candidates(ctx, request); err == nil || !strings.Contains(err.Error(), "hits disagree") { + t.Fatalf("missing hit with unchanged document count = %v", err) + } +} diff --git a/internal/publishing/spec_search.go b/internal/publishing/spec_search.go new file mode 100644 index 0000000..cb27f05 --- /dev/null +++ b/internal/publishing/spec_search.go @@ -0,0 +1,325 @@ +package publishing + +import ( + "context" + "errors" + "fmt" + "reflect" + "slices" + "sort" + "strings" + + "prods/internal/specnumeric" +) + +const SpecProjectionVersion = "spec-ids-1" + +var ErrInvalidSpecSearch = errors.New("invalid spec search") + +// ErrUnsupportedSpecSearch distinguishes valid query shapes unsupported by a frozen profile. +var ErrUnsupportedSpecSearch = errors.New("unsupported spec search") + +// SpecCandidateBinding includes the actual field-row inventory, never a stored +// row count. All revision fields bind the projection to one frozen activation. +type SpecCandidateBinding struct { + ProductID string + PublicRevision int64 + SourceRevision int64 + SiteEpoch int64 + VisibilityGeneration int64 + ProjectionVersion string + SpecIDs []string +} + +type SpecCandidateSnapshot struct { + ProjectionVersion string + SourceDigest string + Inventory []SpecCandidateBinding + Candidates []SpecCandidateBinding +} + +// SpecSearchRepository is optional so older publication repositories can use +// the same exact oracle over their complete admitted active views. +type SpecSearchRepository interface { + SpecSearchCandidates(context.Context, []string, string) (SpecCandidateSnapshot, error) + RebuildSpecSearchProjection(context.Context, []ActivePublication) error +} + +type SpecSearchRequest struct { + Predicates []specnumeric.Predicate + CategoryID string + ManufacturerID string + Locale string + Page int + PageSize int +} + +type SpecSearchResult struct { + Rows []PublicView `json:"rows"` + Total int `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +// SpecProjectionIDs is deliberately coarse: no interval envelopes or numeric +// comparisons are stored. Exact facts are read only from the active PublicView. +func SpecProjectionIDs(view PublicView) []string { + ids := make([]string, 0, len(view.NumericFacts)) + for _, fact := range view.NumericFacts { + if fact.SpecID != "" && specnumeric.ValidateProfile(fact.Profile) == nil && specnumeric.ValidateValue(fact.Value, fact.Profile) == nil { + ids = append(ids, fact.SpecID) + } + } + sort.Strings(ids) + return slices.Compact(ids) +} + +func (e *Engine) SearchSpecs(ctx context.Context, request SpecSearchRequest) (SpecSearchResult, error) { + result := SpecSearchResult{Rows: []PublicView{}, Page: request.Page, PageSize: request.PageSize} + if request.Page < 1 || request.PageSize < 1 || request.PageSize > 100 { + return result, fmt.Errorf("%w: page must be positive and page_size must be 1..100", ErrInvalidSpecSearch) + } + ids := make([]string, 0, len(request.Predicates)) + for _, predicate := range request.Predicates { + if err := validateSpecPredicate(predicate); err != nil { + return result, err + } + ids = append(ids, predicate.SpecID) + } + var snapshot SpecCandidateSnapshot + var projected bool + var candidateErr error + switch e.specBackend { + case "bleve": + if e.specBleve != nil { + snapshot, candidateErr = e.specBleve.candidates(ctx, request) + projected = candidateErr == nil + } else { + candidateErr = errors.New("Bleve spec search adapter unavailable") + if status := e.specStatus.Load(); status != nil && status.lastError != "" { + candidateErr = errors.New(status.lastError) + } + } + case "sqlite": + if repository, ok := e.repository.(SpecSearchRepository); ok { + snapshot, candidateErr = repository.SpecSearchCandidates(ctx, ids, SpecProjectionVersion) + projected = candidateErr == nil + } else { + candidateErr = errors.New("SQLite spec search repository unavailable") + } + } + // The database read is outside the gate. Everything establishing truth, + // visibility, scope, count and pagination uses this one current index. + e.gate.RLock() + defer e.gate.RUnlock() + if e.index == nil || e.index.unhealthy != nil { + return result, ErrPublicUnavailable + } + if err := ctx.Err(); err != nil { + return result, err + } + // Resolve and recheck locale against the same admitted site epoch as facts. + // HTTP may have read active Website settings before a site-wide activation. + if len(e.index.views) > 0 { + view := e.index.views[0] + if request.Locale == "" { + request.Locale = view.DefaultLocale + if request.Locale == "" { + request.Locale = view.Language + } + } + if !slices.Contains(publicSupportedLocales(view), request.Locale) { + return result, fmt.Errorf("%w: locale is not enabled", ErrInvalidSpecSearch) + } + } + + definitions := make(map[string]PublicNumericDefinition) + requested := make(map[string]bool, len(ids)) + for _, id := range ids { + requested[id] = true + } + for _, active := range e.index.byID { + if !specSearchScope(active.View, request) { + continue + } + for _, definition := range active.View.NumericDefinitions { + if !requested[definition.SpecID] { + continue + } + if specnumeric.ValidateProfile(definition.Profile) != nil { + return result, ErrPublicUnavailable + } + if prior, ok := definitions[definition.SpecID]; ok && !compatibleSpecDefinitions(prior, definition) { + return result, ErrPublicUnavailable + } + definitions[definition.SpecID] = definition + } + } + for _, predicate := range request.Predicates { + if definition, exists := definitions[predicate.SpecID]; exists { + if _, err := specnumeric.NormalizePredicate(predicate, definition.Profile); err != nil { + return result, fmt.Errorf("%w: %w: %s: %v", ErrInvalidSpecSearch, ErrUnsupportedSpecSearch, predicate.SpecID, err) + } + } + } + candidates := make(map[string]bool) + if projected && e.index.specDigestError == nil && e.completeSpecInventory(snapshot) && (e.specBackend != "bleve" || snapshot.SourceDigest == e.index.specDigest) { + for _, candidate := range snapshot.Candidates { + current, exists := e.index.byID[candidate.ProductID] + if !exists || !specBindingMatches(candidate, current) { + projected = false + break + } + candidates[candidate.ProductID] = true + } + } else { + projected = false + } + if projected { + e.specCandidateQueries.Add(1) + e.specStatus.Store(&specSearchStatus{digest: e.index.specDigest}) + } else { + e.specFallbackQueries.Add(1) + if candidateErr == nil { + candidateErr = errors.New("spec search candidate snapshot does not match admitted public inventory") + } + if e.index.specDigestError != nil { + candidateErr = e.index.specDigestError + } + // Atomic diagnostics cannot wait on refresh/adapter while the gate is held. + e.specStatus.Store(&specSearchStatus{digest: snapshot.SourceDigest, lastError: candidateErr.Error()}) + } + matches := make([]PublicView, 0) + for id, active := range e.index.byID { + if err := ctx.Err(); err != nil { + return result, err + } + if projected && !candidates[id] || !specSearchScope(active.View, request) { + continue + } + if specViewTruth(active.View, request.Predicates) == specnumeric.Match { + view := e.admittedViewLocked(active.View) + if request.Locale != "" { + // ForLocale resolves labels in these slices. Keep the active + // index immutable while concurrent readers hold the gate. + view.CategoryTrail = slices.Clone(view.CategoryTrail) + view.Applications = slices.Clone(view.Applications) + view = view.ForLocale(request.Locale) + } + matches = append(matches, view) + } + } + sort.Slice(matches, func(i, j int) bool { + if matches[i].PartNumber != matches[j].PartNumber { + return matches[i].PartNumber < matches[j].PartNumber + } + return matches[i].ID < matches[j].ID + }) + result.Total = len(matches) + // Divide before multiplying so arbitrarily large pages cannot overflow. + if len(matches) == 0 || request.Page-1 > (len(matches)-1)/request.PageSize { + return result, nil + } + start := (request.Page - 1) * request.PageSize + end := start + min(request.PageSize, len(matches)-start) + result.Rows = matches[start:end] + return result, nil +} + +func (e *Engine) completeSpecInventory(snapshot SpecCandidateSnapshot) bool { + if len(e.index.specInventory) != len(e.index.byID) { + return false + } + return specInventoryMatches(snapshot, e.index.specInventory) +} + +func specBindingMatches(binding SpecCandidateBinding, active ActivePublication) bool { + return binding.ProjectionVersion == SpecProjectionVersion && binding.ProductID == active.ProductID && binding.PublicRevision == active.PublicRevision && binding.SourceRevision == active.SourceRevision && binding.SiteEpoch == active.SiteEpoch && binding.VisibilityGeneration == active.VisibilityGeneration +} + +func specSearchScope(view PublicView, request SpecSearchRequest) bool { + if request.ManufacturerID != "" && request.ManufacturerID != view.ManufacturerID { + return false + } + if request.CategoryID == "" || request.CategoryID == view.CategoryID { + return true + } + for _, category := range view.CategoryTrail { + if category.ID == request.CategoryID { + return true + } + } + return false +} + +func compatibleSpecDefinitions(a, b PublicNumericDefinition) bool { + if a.SpecSemanticVersion != b.SpecSemanticVersion { + return false + } + normalized := func(profile specnumeric.Profile) specnumeric.Profile { + profile.AllowedShapes = append([]specnumeric.Shape(nil), profile.AllowedShapes...) + profile.AllowedOperators = append([]specnumeric.Operator(nil), profile.AllowedOperators...) + slices.Sort(profile.AllowedShapes) + slices.Sort(profile.AllowedOperators) + profile.AllowedShapes = slices.Compact(profile.AllowedShapes) + profile.AllowedOperators = slices.Compact(profile.AllowedOperators) + return profile + } + return reflect.DeepEqual(normalized(a.Profile), normalized(b.Profile)) +} + +func specViewTruth(view PublicView, predicates []specnumeric.Predicate) specnumeric.Truth { + truths := make([]specnumeric.Truth, 0, len(predicates)) + for _, predicate := range predicates { + truth := specnumeric.Unknown + for _, definition := range view.NumericDefinitions { + if definition.SpecID != predicate.SpecID { + continue + } + for _, fact := range view.NumericFacts { + if fact.SpecID == predicate.SpecID && compatibleSpecDefinitions(definition, PublicNumericDefinition{SpecID: fact.SpecID, SpecSemanticVersion: fact.SpecSemanticVersion, Profile: fact.Profile}) { + truth = specnumeric.Evaluate(&fact.Value, fact.Profile, predicate) + break + } + } + break + } + truths = append(truths, truth) + } + return specnumeric.All(truths) +} + +// Structural validation still applies when every scoped definition is absent. +// Absence establishes UNKNOWN; it cannot supply quantity or role semantics. +func validateSpecPredicate(predicate specnumeric.Predicate) error { + invalid := func(message string) error { return fmt.Errorf("%w: %s", ErrInvalidSpecSearch, message) } + if strings.TrimSpace(predicate.SpecID) == "" || strings.TrimSpace(predicate.Unit) == "" { + return invalid("predicate requires spec ID and explicit unit") + } + rangeQuery := false + switch predicate.Operator { + case specnumeric.OperatorBetween, specnumeric.OperatorContainsRange, specnumeric.OperatorOverlapsRange: + rangeQuery = true + case specnumeric.OperatorContainsPoint, specnumeric.OperatorEqual, specnumeric.OperatorLess, specnumeric.OperatorLessEqual, specnumeric.OperatorGreater, specnumeric.OperatorGreaterEqual: + default: + return fmt.Errorf("%w: %w: unknown operator", ErrInvalidSpecSearch, ErrUnsupportedSpecSearch) + } + if rangeQuery { + if predicate.Value != nil || predicate.Lower == nil || predicate.Upper == nil { + return invalid("range predicate requires only lower and upper") + } + } else if predicate.Value == nil || predicate.Lower != nil || predicate.Upper != nil { + return invalid("point predicate requires only value") + } + for _, operand := range []*specnumeric.Decimal{predicate.Value, predicate.Lower, predicate.Upper} { + if operand != nil { + if _, err := specnumeric.ParseDecimal(operand.String()); err != nil { + return invalid("invalid decimal operand") + } + } + } + if rangeQuery && predicate.Lower.Cmp(*predicate.Upper) > 0 { + return invalid("reversed predicate range") + } + return nil +} diff --git a/internal/publishing/spec_search_backend.go b/internal/publishing/spec_search_backend.go new file mode 100644 index 0000000..f07fafc --- /dev/null +++ b/internal/publishing/spec_search_backend.go @@ -0,0 +1,181 @@ +package publishing + +import ( + "context" + "errors" + "fmt" + "log/slog" + "slices" + "sort" + "strings" +) + +func NormalizeSpecSearchBackend(value string) (string, error) { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return "bleve", nil + } + if value != "bleve" && value != "sqlite" { + return "", fmt.Errorf("invalid spec search backend %q: expected bleve or sqlite", value) + } + return value, nil +} + +type SpecSearchDiagnostics struct { + Backend string + Ready bool + SourceDigest string + LastError string + CandidateQueries uint64 + FallbackQueries uint64 +} + +type specSearchStatus struct { + digest string + lastError string +} + +func (e *Engine) SpecSearchDiagnostics() SpecSearchDiagnostics { + d := SpecSearchDiagnostics{Backend: e.specBackend, CandidateQueries: e.specCandidateQueries.Load(), FallbackQueries: e.specFallbackQueries.Load()} + // Observe the real adapter digest before taking the public gate. Replacement + // may wait on disk/ownership, and must never extend request admission locks. + if e.specBleve != nil { + d.SourceDigest = e.specBleve.digest() + } + // Atomic status never waits for adapter IO while holding the public gate. + e.gate.RLock() + defer e.gate.RUnlock() + status := e.specStatus.Load() + if status != nil { + if e.specBackend != "bleve" { + d.SourceDigest = status.digest + } + d.LastError = status.lastError + } + d.Ready = !e.draining.Load() && e.index != nil && e.index.unhealthy == nil && e.index.specDigestError == nil && status != nil && status.lastError == "" && d.SourceDigest != "" && d.SourceDigest == e.index.specDigest && status.digest == e.index.specDigest + return d +} + +func (e *Engine) specSearchFailure(err error) { + if err == nil { + return + } + e.specStatus.Store(&specSearchStatus{lastError: err.Error()}) + slog.Warn("spec search candidate index unavailable; using active public facts", "backend", e.specBackend, "error", err) +} + +func (e *Engine) installSpecSearchLocked(index *publicIndex) { + active := make([]ActivePublication, 0, len(index.byID)) + index.specInventory = make(map[string]SpecCandidateBinding, len(index.byID)) + for id, a := range index.byID { + active = append(active, a) + index.specInventory[id] = SpecCandidateBinding{ProductID: a.ProductID, PublicRevision: a.PublicRevision, SourceRevision: a.SourceRevision, SiteEpoch: a.SiteEpoch, VisibilityGeneration: a.VisibilityGeneration, ProjectionVersion: SpecProjectionVersion, SpecIDs: SpecProjectionIDs(a.View)} + } + index.specDigest, index.specDigestError = specActiveDigest(active) + if index.specDigestError != nil { + e.specStatus.Store(&specSearchStatus{lastError: index.specDigestError.Error()}) + } + e.specDirty.Store(true) + e.Wake() +} + +func (e *Engine) RebuildSpecSearchIndex(ctx context.Context) error { + return e.refreshSpecSearch(ctx, true) +} + +// Refresh serializes derived writes only. Consume dirty BEFORE capture so an +// install overlapping IO leaves its own dirty flag and wake-up intact. No +// caller activation is used: visibility generations come from committed index. +func (e *Engine) refreshSpecSearch(ctx context.Context, force bool) error { + e.specRefreshMu.Lock() + defer e.specRefreshMu.Unlock() + if !force && !e.specDirty.Swap(false) { + return nil + } + if force { + e.specDirty.Store(false) + } + e.gate.RLock() + index := e.index + var active []ActivePublication + var digest string + var err error + if index == nil || index.unhealthy != nil { + err = ErrPublicUnavailable + } else { + digest, err = index.specDigest, index.specDigestError + active = make([]ActivePublication, 0, len(index.byID)) + for _, a := range index.byID { + active = append(active, a) + } + } + e.gate.RUnlock() + if err == nil { + err = ctx.Err() + } + if err == nil && e.draining.Load() { + err = errors.New("spec search index closed") + } + if err == nil { + switch e.specBackend { + case "bleve": + if e.specBleve == nil { + err = errors.New("Bleve spec search adapter unavailable") + if status := e.specStatus.Load(); status != nil && status.lastError != "" { + err = errors.New(status.lastError) + } + } else if force { + err = e.specBleve.rebuild(ctx, active) + } else { + err = e.specBleve.replace(ctx, active) + } + case "sqlite": + repository, ok := e.repository.(SpecSearchRepository) + if !ok { + err = errors.New("SQLite spec search repository unavailable") + } else { + err = repository.RebuildSpecSearchProjection(ctx, active) + if err == nil { + var snapshot SpecCandidateSnapshot + snapshot, err = repository.SpecSearchCandidates(ctx, nil, SpecProjectionVersion) + if err == nil && !specInventoryMatches(snapshot, index.specInventory) { + err = errors.New("SQLite spec search inventory mismatch") + } + } + } + default: + return nil // Legacy directly constructed Engine fixtures. + } + } + if err != nil { + e.specSearchFailure(err) + // Retry on a later worker tick; avoid a busy loop on persistent failure. + if !e.draining.Load() { + e.specDirty.Store(true) + } + return err + } + e.specStatus.Store(&specSearchStatus{digest: digest}) + return nil +} + +func specInventoryMatches(snapshot SpecCandidateSnapshot, expected map[string]SpecCandidateBinding) bool { + if snapshot.ProjectionVersion != SpecProjectionVersion || len(snapshot.Inventory) != len(expected) { + return false + } + seen := make(map[string]bool, len(expected)) + for _, binding := range snapshot.Inventory { + want, ok := expected[binding.ProductID] + ids := slices.Clone(binding.SpecIDs) + sort.Strings(ids) + if !ok || seen[binding.ProductID] || !specCachedBindingMatches(binding, want) || !slices.Equal(ids, want.SpecIDs) { + return false + } + seen[binding.ProductID] = true + } + return true +} + +func specCachedBindingMatches(binding, expected SpecCandidateBinding) bool { + return binding.ProjectionVersion == expected.ProjectionVersion && binding.ProductID == expected.ProductID && binding.PublicRevision == expected.PublicRevision && binding.SourceRevision == expected.SourceRevision && binding.SiteEpoch == expected.SiteEpoch && binding.VisibilityGeneration == expected.VisibilityGeneration +} diff --git a/internal/publishing/spec_search_backend_test.go b/internal/publishing/spec_search_backend_test.go new file mode 100644 index 0000000..6c283cd --- /dev/null +++ b/internal/publishing/spec_search_backend_test.go @@ -0,0 +1,214 @@ +package publishing + +import ( + "context" + "errors" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "prods/internal/platform" + "prods/internal/site" + "prods/internal/specnumeric" +) + +type specBackendRepository struct { + Repository + active []ActivePublication + queries atomic.Int64 + startError error +} + +func (r *specBackendRepository) ResetPublicationClaims(context.Context) error { return r.startError } +func (r *specBackendRepository) ClaimPublicationIntent(context.Context) (Intent, bool, error) { + return Intent{}, false, nil +} +func (r *specBackendRepository) ActivePublications(context.Context) ([]ActivePublication, error) { + return r.active, nil +} +func (r *specBackendRepository) ActiveWebsiteConfiguration(context.Context) (site.Configuration, int64, int64, error) { + return site.Configuration{}, 1, 2, nil +} +func (r *specBackendRepository) WebsiteCustomCSSState(context.Context) (bool, int64, error) { + return false, 1, nil +} +func (r *specBackendRepository) ActiveRouteHistory(context.Context) ([]RouteHistory, error) { + return nil, nil +} +func (r *specBackendRepository) RebuildSearchPublications(context.Context, []ActivePublication) error { + return nil +} +func (r *specBackendRepository) RebuildSpecSearchProjection(context.Context, []ActivePublication) error { + return nil +} +func (r *specBackendRepository) ConvergePublicationDependencies(context.Context, string, int64) error { + return nil +} +func (r *specBackendRepository) SpecSearchCandidates(context.Context, []string, string) (SpecCandidateSnapshot, error) { + r.queries.Add(1) + s := SpecCandidateSnapshot{ProjectionVersion: SpecProjectionVersion} + for _, a := range r.active { + b := SpecCandidateBinding{ProductID: a.ProductID, PublicRevision: a.PublicRevision, SourceRevision: a.SourceRevision, SiteEpoch: a.SiteEpoch, VisibilityGeneration: a.VisibilityGeneration, ProjectionVersion: SpecProjectionVersion, SpecIDs: SpecProjectionIDs(a.View)} + s.Inventory = append(s.Inventory, b) + s.Candidates = append(s.Candidates, b) + } + return s, nil +} + +func specBackendFixture(t *testing.T, backend string) (*Engine, *specBackendRepository, string) { + t.Helper() + root := filepath.Join(t.TempDir(), "generated") + if err := os.MkdirAll(filepath.Join(root, "units"), 0o700); err != nil { + t.Fatal(err) + } + config := Config{Root: root, BaseURL: "https://example.test", SpecSearchBackend: backend, ResourceGate: platform.NewResourceGate(nil)} + stager := &Engine{root: root, baseURL: config.BaseURL, resources: config.ResourceGate} + a := blevePublication(t) + a.Route = "/products/opaque-product" + a.View.PartNumber = "PART" + a.View.Revision = a.PublicRevision + a.View.SiteEpoch = a.SiteEpoch + a.View.Language = "en-US" + a.View.DefaultLocale = "en-US" + a.View.SupportedLocales = []string{"en-US"} + a.View.CanonicalURL = config.BaseURL + a.Route + var err error + a.ArtifactID, a.ManifestHash, err = stager.stage(t.Context(), "fixture", a.View) + if err != nil { + t.Fatal(err) + } + r := &specBackendRepository{active: []ActivePublication{a}} + e, err := NewEngine(t.Context(), r, config) + if err != nil { + t.Fatal(err) + } + t.Cleanup(e.Close) + return e, r, filepath.Join(filepath.Dir(root), "search", "specs") +} + +func specBackendRequest(t *testing.T) SpecSearchRequest { + q := bleveDecimal(t, "3.2") + return SpecSearchRequest{Predicates: []specnumeric.Predicate{{SpecID: "voltage/opaque", Operator: specnumeric.OperatorContainsPoint, Value: &q, Unit: "V"}}, Page: 1, PageSize: 10} +} + +func TestSpecSearchDefaultBleveAndStaleFallback(t *testing.T) { + e, r, _ := specBackendFixture(t, "") + result, err := e.SearchSpecs(t.Context(), specBackendRequest(t)) + if err != nil || result.Total != 1 { + t.Fatalf("candidate result=%+v err=%v", result, err) + } + d := e.SpecSearchDiagnostics() + if d.Backend != "bleve" || !d.Ready || d.CandidateQueries != 1 || d.FallbackQueries != 0 || r.queries.Load() != 0 { + t.Fatalf("actual provider diagnostics=%+v sqlite queries=%d", d, r.queries.Load()) + } + if err := e.specBleve.replace(t.Context(), nil); err != nil { + t.Fatal(err) + } + result, err = e.SearchSpecs(t.Context(), specBackendRequest(t)) + if err != nil || result.Total != 1 { + t.Fatalf("stale fallback result=%+v err=%v", result, err) + } + d = e.SpecSearchDiagnostics() + if d.Ready || d.FallbackQueries != 1 { + t.Fatalf("stale diagnostics=%+v", d) + } +} + +func TestSpecSearchSQLiteDoesNotOpenPrivateIndex(t *testing.T) { + e, r, root := specBackendFixture(t, "sqlite") + result, err := e.SearchSpecs(t.Context(), specBackendRequest(t)) + if err != nil || result.Total != 1 { + t.Fatalf("sqlite result=%+v err=%v", result, err) + } + if _, err := os.Stat(root); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("sqlite touched private index: %v", err) + } + d := e.SpecSearchDiagnostics() + if d.Backend != "sqlite" || !d.Ready || d.CandidateQueries != 1 || d.FallbackQueries != 0 || r.queries.Load() == 0 { + t.Fatalf("sqlite diagnostics=%+v queries=%d", d, r.queries.Load()) + } +} + +func TestSpecSearchConstructorFailureReleasesAdapterOwnership(t *testing.T) { + root := filepath.Join(t.TempDir(), "generated") + errStartup := errors.New("claim reset failed") + config := Config{Root: root, BaseURL: "https://example.test"} + engine, err := NewEngine(t.Context(), &specBackendRepository{startError: errStartup}, config) + if engine != nil || !errors.Is(err, errStartup) { + t.Fatalf("startup result engine=%v err=%v", engine, err) + } + // The constructor acquired the default provider before the authoritative + // startup failure. A fresh adapter must immediately reacquire its ownership. + b, err := newBleveSpecIndex(t.Context(), filepath.Join(filepath.Dir(root), "search", "specs"), config) + if err != nil { + t.Fatalf("startup leaked adapter ownership: %v", err) + } + defer b.close() +} + +func TestSpecSearchOverlappingRefreshPreservesNewInstall(t *testing.T) { + e, r, _ := specBackendFixture(t, "") + // Keep this test's refresh scheduling deterministic; Close still owns the + // adapter and existing readers keep using its real mutex and Scorch index. + e.cancel() + e.wg.Wait() + started, release := make(chan struct{}), make(chan struct{}) + t.Cleanup(func() { + select { + case <-release: + default: + close(release) + } + }) + var block atomic.Bool + block.Store(true) + e.specBleve.resources = platform.NewResourceGate(func(string) (platform.ResourceStats, error) { + if block.Swap(false) { + close(started) + <-release + } + return platform.ResourceStats{VolumeID: "specs", TotalBytes: 1 << 40, FreeBytes: 1 << 40}, nil + }) + done := make(chan error, 1) + go func() { done <- e.RebuildSpecSearchIndex(t.Context()) }() + select { + case <-started: + case <-time.After(10 * time.Second): + t.Fatal("refresh did not reach resource admission") + } + // IO has captured the old committed binding. A new gate installation must + // remain possible and dirty even while the old adapter batch is waiting. + a := r.active[0] + a.PublicRevision++ + a.SourceRevision++ + a.VisibilityGeneration++ + a.View.Revision = a.PublicRevision + e.gate.Lock() + next := *e.index + next.byID = map[string]ActivePublication{a.ProductID: a} + next.views = []PublicView{a.View} + e.index = &next + e.installSpecSearchLocked(&next) + e.gate.Unlock() + close(release) + if err := <-done; err != nil { + t.Fatal(err) + } + if !e.specDirty.Load() { + t.Fatal("old refresh consumed the newer install's dirty marker") + } + result, err := e.SearchSpecs(t.Context(), specBackendRequest(t)) + if err != nil || result.Total != 1 || e.SpecSearchDiagnostics().FallbackQueries != 1 { + t.Fatalf("overlap fallback=%+v err=%v diagnostics=%+v", result, err, e.SpecSearchDiagnostics()) + } + if err := e.refreshSpecSearch(t.Context(), false); err != nil { + t.Fatal(err) + } + result, err = e.SearchSpecs(t.Context(), specBackendRequest(t)) + d := e.SpecSearchDiagnostics() + if err != nil || result.Total != 1 || !d.Ready || d.CandidateQueries != 1 || e.specDirty.Load() { + t.Fatalf("new snapshot result=%+v err=%v diagnostics=%+v dirty=%v", result, err, d, e.specDirty.Load()) + } +} diff --git a/internal/publishing/view.go b/internal/publishing/view.go index e10e7d2..dc17c6a 100644 --- a/internal/publishing/view.go +++ b/internal/publishing/view.go @@ -7,6 +7,7 @@ import ( "prods/internal/catalog" "prods/internal/localization" "prods/internal/site" + "prods/internal/specnumeric" ) type Document struct { @@ -19,11 +20,12 @@ type Document struct { } type Specification struct { - ID string `json:"id"` - Name string `json:"name"` - RawValue string `json:"raw_value"` - PreferredUnit string `json:"preferred_unit,omitempty"` - Language string `json:"language,omitempty"` + SourceRevision int64 `json:"source_revision"` + ID string `json:"id"` + Name string `json:"name"` + RawValue string `json:"raw_value"` + PreferredUnit string `json:"preferred_unit,omitempty"` + Language string `json:"language,omitempty"` } type Application struct { @@ -46,7 +48,24 @@ type ApplicableSpecification struct { PreferredUnit string `json:"preferred_unit,omitempty"` } +// PublicNumericDefinition freezes an applicable, filterable semantic definition. +type PublicNumericDefinition struct { + SpecID string `json:"spec_id"` + SpecSemanticVersion int64 `json:"spec_semantic_version"` + Profile specnumeric.Profile `json:"profile"` +} + +// PublicNumericFact contains only the adopted public value and its fixed meaning. +type PublicNumericFact struct { + SpecID string `json:"spec_id"` + SpecSemanticVersion int64 `json:"spec_semantic_version"` + Profile specnumeric.Profile `json:"profile"` + Value specnumeric.Value `json:"value"` +} + type PublicView struct { + NumericFacts []PublicNumericFact `json:"numeric_facts,omitempty"` + NumericDefinitions []PublicNumericDefinition `json:"numeric_definitions,omitempty"` PackageFormFactor string `json:"package_form_factor,omitempty"` ApplicableSpecifications []ApplicableSpecification `json:"applicable_specifications,omitempty"` ID string `json:"id"` @@ -118,7 +137,7 @@ func (v PublicView) ForLocale(locale string) PublicView { v.Language = locale v.FieldLocalization = make(map[string]FieldLocalization, len(catalog.ProductTranslatableFields)) fields := map[string]*string{ - "name": &v.Name, "description": &v.Description, "features": &v.Features, "specification": &v.Specification, + "name": &v.Name, "description": &v.Description, "features": &v.Features, } for field, target := range fields { values := make(map[string]string, len(v.Localizations)+1) @@ -138,8 +157,6 @@ func (v PublicView) ForLocale(locale string) PublicView { values[translationLocale] = item.Description case "features": values[translationLocale] = item.Features - case "specification": - values[translationLocale] = item.Specification } } resolved := localization.ResolveCustomerValue(locale, defaultLocale, sourceLocale, v.SupportedLocales, values) diff --git a/internal/publishing/worker_refresh_test.go b/internal/publishing/worker_refresh_test.go new file mode 100644 index 0000000..d2ab080 --- /dev/null +++ b/internal/publishing/worker_refresh_test.go @@ -0,0 +1,170 @@ +package publishing + +import ( + "context" + "errors" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/blevesearch/bleve/v2/index/scorch" + "prods/internal/catalog" +) + +// Keep the existing backend fixture's real staging, visibility installation, +// oracle and Scorch adapter; supply only the repository's queued work. +type workerRefreshRepository struct { + *specBackendRepository + sources []Source + claimed int + checkpoint chan struct{} + release chan struct{} + idle chan struct{} + idleOnce sync.Once + failID string + failed atomic.Int64 +} + +func (r *workerRefreshRepository) ClaimPublicationIntent(ctx context.Context) (Intent, bool, error) { + if r.claimed == 1 && r.checkpoint != nil { + close(r.checkpoint) + select { + case <-r.release: + case <-ctx.Done(): + return Intent{}, false, ctx.Err() + } + } + if r.claimed == len(r.sources) { + r.idleOnce.Do(func() { close(r.idle) }) + return Intent{}, false, nil + } + s := r.sources[r.claimed] + r.claimed++ + return Intent{ID: s.Product.ID, ProductID: s.Product.ID, DesiredRevision: s.Product.Revision}, true, nil +} + +func (r *workerRefreshRepository) PublicationSource(_ context.Context, id string) (Source, error) { + if id == r.failID { + return Source{}, errors.New("publication source unavailable") + } + for _, s := range r.sources { + if s.Product.ID == id { + return s, nil + } + } + return Source{}, errors.New("unknown product") +} + +func (r *workerRefreshRepository) ActivatePublication(ctx context.Context, _ Intent, a ActivePublication, coordinator VisibilityCoordinator) (bool, error) { + coordinator.LockVisibility() + defer coordinator.UnlockVisibility() + a.VisibilityGeneration = int64(len(r.active) + 10) + r.active = append(r.active, a) + return true, coordinator.InstallVisibility(ctx) +} + +func (r *workerRefreshRepository) FailPublicationIntent(context.Context, string, string) error { + r.failed.Add(1) + return nil +} + +func workerRefreshFixture(t *testing.T) (*Engine, *workerRefreshRepository) { + t.Helper() + e, base, _ := specBackendFixture(t, "bleve") + e.cancel() + e.wg.Wait() + // Start the real worker only after its deterministic backlog is installed. + r := &workerRefreshRepository{specBackendRepository: base, idle: make(chan struct{})} + for _, id := range []string{"first", "second"} { + a := blevePublication(t) + r.sources = append(r.sources, Source{ + Product: catalog.Product{ID: id, PartNumber: id, Revision: 1, RecordState: catalog.RecordCurrent, Status: catalog.Published}, + Route: "/products/" + id, SiteEpoch: 2, Language: "en-US", SourceLocale: "en-US", SupportedLocales: []string{"en-US"}, + NumericFacts: a.View.NumericFacts, NumericDefinitions: a.View.NumericDefinitions, + }) + } + e.repository = r + return e, r +} + +func startRefreshWorker(e *Engine) { + ctx, cancel := context.WithCancel(context.Background()) + e.cancel = cancel + e.wg.Add(1) + go e.worker(ctx) + e.Wake() +} + +func waitRefreshWorker(t *testing.T, signal <-chan struct{}) { + t.Helper() + select { + case <-signal: + case <-time.After(10 * time.Second): + t.Fatal("publication worker did not reach checkpoint") + } +} + +func workerRefreshBatches(t *testing.T, e *Engine) uint64 { + t.Helper() + e.specBleve.mu.Lock() + defer e.specBleve.mu.Unlock() + advanced, err := e.specBleve.index.Advanced() + if err != nil { + t.Fatal(err) + } + return advanced.(*scorch.Scorch).StatsMap()["TotBatches"].(uint64) +} + +func TestPublicationWorkerCoalescesRefreshWithoutDeferringVisibility(t *testing.T) { + e, r := workerRefreshFixture(t) + r.checkpoint, r.release = make(chan struct{}), make(chan struct{}) + before := workerRefreshBatches(t, e) + startRefreshWorker(e) + waitRefreshWorker(t, r.checkpoint) + // The first Product is already admitted while the next intent is pending. + result, err := e.SearchSpecs(t.Context(), specBackendRequest(t)) + if err != nil || result.Total != 2 { + t.Fatalf("intermediate visibility total=%d err=%v", result.Total, err) + } + midBatches := workerRefreshBatches(t, e) - before + midFallbacks := e.SpecSearchDiagnostics().FallbackQueries + close(r.release) + waitRefreshWorker(t, r.idle) + // Claim's empty checkpoint is before the final refresh; wait for the + // production processing lock to be released before inspecting the adapter. + e.processMu.Lock() + e.processMu.Unlock() + if batches := workerRefreshBatches(t, e) - before; batches != 1 { + t.Fatalf("two changed activations performed %d full Scorch batches; want 1", batches) + } + if midBatches != 0 || midFallbacks != 1 { + t.Fatalf("intermediate batches=%d fallback queries=%d; want 0/1", midBatches, midFallbacks) + } + result, err = e.SearchSpecs(t.Context(), specBackendRequest(t)) + d := e.SpecSearchDiagnostics() + if err != nil || result.Total != 3 || !d.Ready || d.CandidateQueries != 1 || e.specDirty.Load() { + t.Fatalf("drained result=%+v err=%v diagnostics=%+v dirty=%v", result, err, d, e.specDirty.Load()) + } + t.Log("activations=2 Scorch batches=1 intermediate fallback=1 final candidate=1") +} + +func TestPublicationWorkerRefreshesInstalledChangesAfterIntentFailure(t *testing.T) { + e, r := workerRefreshFixture(t) + r.failID = "second" + before := workerRefreshBatches(t, e) + startRefreshWorker(e) + waitRefreshWorker(t, r.idle) + e.processMu.Lock() + e.processMu.Unlock() + result, err := e.SearchSpecs(t.Context(), specBackendRequest(t)) + d := e.SpecSearchDiagnostics() + if err != nil || result.Total != 2 || r.failed.Load() != 1 || workerRefreshBatches(t, e)-before != 1 || !d.Ready || e.specDirty.Load() { + t.Fatalf("failure drain result=%+v err=%v failed=%d batches=%d diagnostics=%+v dirty=%v", result, err, r.failed.Load(), workerRefreshBatches(t, e)-before, d, e.specDirty.Load()) + } + e.BeginDrain() + if worked, err := e.ProcessOne(t.Context()); err != nil || worked { + t.Fatalf("after drain worked=%v err=%v", worked, err) + } + t.Log("activations=1 failed intents=1 Scorch batches=1; drain accepts no new work") +} diff --git a/internal/specnumeric/decimal.go b/internal/specnumeric/decimal.go new file mode 100644 index 0000000..1a6281b --- /dev/null +++ b/internal/specnumeric/decimal.go @@ -0,0 +1,128 @@ +package specnumeric + +import ( + "encoding/json" + "errors" + "fmt" + "math/big" + "regexp" + "strconv" + "strings" +) + +var ( + ErrInvalidDecimal = errors.New("invalid decimal-dot value") + ErrUnsupportedPrecision = errors.New("unsupported_precision") + decimalPattern = regexp.MustCompile(`^[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$`) +) + +// Decimal is an immutable exact decimal. Its zero value is not a valid number. +// Authority is its canonical decimal string, never a floating-point value. +type Decimal struct{ text string } + +func ParseDecimal(text string) (Decimal, error) { + // Bound work before regex matching and any big integer allocation. + if len(text) > 256 { + return Decimal{}, ErrUnsupportedPrecision + } + if !decimalPattern.MatchString(text) { + return Decimal{}, ErrInvalidDecimal + } + negative := strings.HasPrefix(text, "-") + text = strings.TrimPrefix(strings.TrimPrefix(text, "-"), "+") + exponent := int64(0) + if i := strings.IndexAny(text, "eE"); i >= 0 { + var err error + exponent, err = strconv.ParseInt(text[i+1:], 10, 32) + if err != nil { + return Decimal{}, ErrUnsupportedPrecision + } + text = text[:i] + } + if i := strings.IndexByte(text, '.'); i >= 0 { + exponent -= int64(len(text) - i - 1) + text = text[:i] + text[i+1:] + } + digits := strings.TrimLeft(text, "0") + if digits == "" { + // Zero has no scientific order; insignificant fractional zeroes cannot + // turn an exact zero into an out-of-range numeric value. + return Decimal{text: "0"}, nil + } + trimmed := strings.TrimRight(digits, "0") + exponent += int64(len(digits) - len(trimmed)) + digits = trimmed + order := int64(len(digits)) - 1 + exponent + if len(digits) > 34 || order < -24 || order > 24 { + return Decimal{}, ErrUnsupportedPrecision + } + var canonical string + point := int64(len(digits)) + exponent + switch { + case point <= 0: + canonical = "0." + strings.Repeat("0", int(-point)) + digits + case point >= int64(len(digits)): + canonical = digits + strings.Repeat("0", int(point)-len(digits)) + default: + canonical = digits[:point] + "." + digits[point:] + } + if negative { + canonical = "-" + canonical + } + return Decimal{text: canonical}, nil +} + +func (d Decimal) String() string { return d.text } + +func (d Decimal) valid() bool { return d.text != "" } + +func (d Decimal) rat() *big.Rat { + value, ok := new(big.Rat).SetString(d.text) + if !ok { + panic("specnumeric: comparison of invalid Decimal") + } + return value +} + +// Cmp compares valid decimals exactly. Invalid zero-value Decimals are rejected +// by boundary validators and must not be passed to arithmetic or comparison. +func (d Decimal) Cmp(other Decimal) int { return d.rat().Cmp(other.rat()) } + +func (d Decimal) MarshalJSON() ([]byte, error) { + if !d.valid() { + return nil, ErrInvalidDecimal + } + return json.Marshal(d.text) +} + +func (d *Decimal) UnmarshalJSON(encoded []byte) error { + var text string + if err := json.Unmarshal(encoded, &text); err != nil { + return fmt.Errorf("decimal must be a string: %w", err) + } + value, err := ParseDecimal(text) + if err != nil { + return err + } + *d = value + return nil +} + +// decimalFromRat accepts only terminating decimals within the same v1 bounds. +// It never rounds intermediate or converted values. +func decimalFromRat(value *big.Rat) (Decimal, error) { + denominator := new(big.Int).Set(value.Denom()) + two, five := big.NewInt(2), big.NewInt(5) + counts := [2]int{} + for i, factor := range []*big.Int{two, five} { + for new(big.Int).Mod(denominator, factor).Sign() == 0 { + denominator.Quo(denominator, factor) + counts[i]++ + } + } + if denominator.Cmp(big.NewInt(1)) != 0 { + return Decimal{}, ErrUnsupportedPrecision + } + places := max(counts[0], counts[1]) + return ParseDecimal(value.FloatString(places)) +} diff --git a/internal/specnumeric/normalize.go b/internal/specnumeric/normalize.go new file mode 100644 index 0000000..3c2d3e7 --- /dev/null +++ b/internal/specnumeric/normalize.go @@ -0,0 +1,378 @@ +package specnumeric + +import ( + "errors" + "math/big" + "regexp" + "strings" + "unicode" + "unicode/utf8" +) + +const maxNormalizationInput = 64 * 1024 + +var rawNumberPattern = regexp.MustCompile(`^[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?`) + +type rawAtom struct { + number Decimal + unit string +} + +type rawSegment struct { + lower, upper rawAtom + interval bool + lowerInclusive, upperInclusive bool +} + +type rawParser struct{ rest string } + +// Normalize derives a candidate under an explicit profile. A nonempty Reason +// always prevents treating that candidate as an unconditional numeric fact. +// Raw remains the caller's authoritative text; parsing only consumes a copy. +func Normalize(raw string, profile Profile) Result { + if len(raw) > maxNormalizationInput { + return Result{Reason: ReasonUnsupportedInputSize} + } + input := strings.TrimSpace(raw) + if input == "" { + return Result{Reason: ReasonMissing} + } + if profile.SemanticRole == "" || len(profile.AllowedShapes) == 0 { + return Result{Reason: ReasonAmbiguous} + } + if err := ValidateProfile(profile); err != nil { + return Result{Reason: ReasonUnsupported} + } + // Markers must agree with the configured role; they never redefine it. + if rest, role := consumeRoleMarker(input); role != "" { + if role != profile.SemanticRole { + return Result{Reason: ReasonAmbiguous} + } + input = rest + } + p := rawParser{rest: input} + value := Value{SchemaVersion: profile.SchemaVersion, Quantity: profile.Quantity, + SemanticRole: profile.SemanticRole, Unit: profile.CanonicalUnit, + UnitRegistryVersion: profile.UnitRegistryVersion, Context: Context{Kind: ContextUnconditional}} + var reason Reason + if operator := p.takeOne(">=", "<=", "≥", "≤", ">", "<"); operator != "" { + value.Shape = ShapeBoundClaim + atom, failure := p.atom() + if failure != "" { + return Result{Reason: failure} + } + number, failure := canonicalAtom(atom, "", profile) + if failure != "" { + return Result{Reason: failure} + } + direction := BoundUpper + if strings.ContainsAny(operator, ">≥") { + direction = BoundLower + } + value.Bound = &BoundClaim{Direction: direction, Value: number, Inclusive: operator != ">" && operator != "<"} + } else { + segment, failure := p.segment() + if failure != "" { + return Result{Reason: failure} + } + if !segment.interval && p.takeOne("±") != "" { + value.Shape = ShapeTolerance + value.Tolerance, reason = p.tolerance(segment.lower, profile) + } else { + segments := []rawSegment{segment} + for { + before := p.rest + separator := p.takeOne(",", "/") + if separator == "" { + break + } + // Adjacent bare numbers with comma are ambiguous decimal/grouping + // notation, even if a source unit is declared. A bracket's comma + // is consumed inside segment and is not a list separator. + if separator == "," && !segments[len(segments)-1].interval && segments[len(segments)-1].lower.unit == "" && !strings.HasPrefix(before, " ") && !strings.HasPrefix(p.rest, " ") { + return Result{Reason: ReasonAmbiguous} + } + next, failure := p.segment() + if failure != "" { + return Result{Reason: failure} + } + if next.interval != segment.interval { + return Result{Reason: ReasonUnsupportedShape} + } + segments = append(segments, next) + } + reason = value.setSegments(segments, profile) + } + } + if reason != "" { + return Result{Reason: reason} + } + if !profile.AllowsShape(value.Shape) { + if value.Shape == ShapeScalar { + return Result{Reason: ReasonAmbiguous} + } + return Result{Reason: ReasonUnsupportedShape} + } + if rest, role := consumeRoleMarker(strings.TrimSpace(p.rest)); role != "" { + if role != profile.SemanticRole { + return Result{Reason: ReasonAmbiguous} + } + p.rest = rest + } + if remainder := strings.TrimSpace(p.rest); remainder != "" { + if !conditionalSuffix(remainder) { + return Result{Reason: ReasonUnsupportedShape} + } + value.Context = Context{Kind: ContextConditional, Raw: raw} + reason = ReasonContextRequired + } + if err := ValidateValue(value, profile); err != nil { + return Result{Reason: ReasonUnsupportedShape} + } + return Result{Candidate: &value, Reason: reason} +} + +func (p *rawParser) takeOne(tokens ...string) string { + rest := strings.TrimLeftFunc(p.rest, unicode.IsSpace) + for _, token := range tokens { + if strings.HasPrefix(rest, token) { + p.rest = rest[len(token):] + return token + } + } + return "" +} + +func (p *rawParser) atom() (rawAtom, Reason) { + rest := strings.TrimLeftFunc(p.rest, unicode.IsSpace) + text := rawNumberPattern.FindString(rest) + if text == "" { + return rawAtom{}, ReasonUnsupportedShape + } + number, err := ParseDecimal(text) + if err != nil { + return rawAtom{}, decimalReason(err) + } + rest = rest[len(text):] + unitRest := strings.TrimLeftFunc(rest, unicode.IsSpace) + end := 0 + for end < len(unitRest) { + r, size := utf8.DecodeRuneInString(unitRest[end:]) + if !unicode.IsLetter(r) { + break + } + end += size + } + unit := unitRest[:end] + // Only registered electrical units are consumed. Words marking roles or + // conditions remain suffixes; other adjacent unit text fails closed. + if unit != "" { + if _, known := electricalUnits[unit]; known { + rest = unitRest[end:] + } else if !conditionalSuffix(unitRest) && !startsRoleMarker(unitRest) { + return rawAtom{}, ReasonUnsupportedUnit + } else { + unit = "" + } + } + p.rest = rest + return rawAtom{number: number, unit: unit}, "" +} + +func (p *rawParser) segment() (rawSegment, Reason) { + opening := p.takeOne("[", "(") + lower, reason := p.atom() + if reason != "" { + return rawSegment{}, reason + } + segment := rawSegment{lower: lower, lowerInclusive: true, upperInclusive: true} + delimiters := []string{"–", "-", "—"} + if opening != "" { + delimiters = append(delimiters, ",") + } + if p.takeOne(delimiters...) == "" { + if opening != "" { + return rawSegment{}, ReasonUnsupportedShape + } + return segment, "" + } + segment.interval = true + segment.upper, reason = p.atom() + if reason != "" { + return rawSegment{}, reason + } + if opening != "" { + closing := p.takeOne("]", ")") + if closing == "" { + return rawSegment{}, ReasonUnsupportedShape + } + segment.lowerInclusive = opening == "[" + segment.upperInclusive = closing == "]" + // A unit following the closing bracket applies to both endpoints. + rest := strings.TrimLeftFunc(p.rest, unicode.IsSpace) + end := 0 + for end < len(rest) { + r, size := utf8.DecodeRuneInString(rest[end:]) + if !unicode.IsLetter(r) { + break + } + end += size + } + if end > 0 { + unit := rest[:end] + if _, known := electricalUnits[unit]; known { + if segment.lower.unit != "" || segment.upper.unit != "" { + return rawSegment{}, ReasonAmbiguous + } + segment.upper.unit = unit + p.rest = rest[end:] + } + } + } + return segment, "" +} + +func (v *Value) setSegments(segments []rawSegment, profile Profile) Reason { + sharedUnit := "" + last := segments[len(segments)-1] + if last.interval { + sharedUnit = last.upper.unit + } else if len(segments) > 1 { + sharedUnit = last.lower.unit + } + if last.interval { + v.Shape = ShapeIntervalSet + } else if len(segments) > 1 { + v.Shape = ShapeDiscreteSet + } else { + v.Shape = ShapeScalar + } + for _, segment := range segments { + unit := sharedUnit + if segment.interval && segment.upper.unit != "" { + unit = segment.upper.unit + } + lower, reason := canonicalAtom(segment.lower, unit, profile) + if reason != "" { + return reason + } + switch v.Shape { + case ShapeScalar: + v.Scalar = &lower + case ShapeDiscreteSet: + v.Values = append(v.Values, lower) + case ShapeIntervalSet: + upper, reason := canonicalAtom(segment.upper, sharedUnit, profile) + if reason != "" { + return reason + } + interval := Interval{Lower: lower, Upper: upper, LowerInclusive: segment.lowerInclusive, UpperInclusive: segment.upperInclusive} + if err := validateInterval(interval); err != nil { + return ReasonInvalidInterval + } + v.Intervals = append(v.Intervals, interval) + } + } + return "" +} + +func (p *rawParser) tolerance(nominalAtom rawAtom, profile Profile) (*Tolerance, Reason) { + amountAtom, reason := p.atom() + if reason != "" { + return nil, reason + } + nominal, reason := canonicalAtom(nominalAtom, "", profile) + if reason != "" { + return nil, reason + } + tolerance := &Tolerance{Nominal: nominal, Type: ToleranceAbsolute} + if p.takeOne("%") != "" { + if amountAtom.unit != "" { + return nil, ReasonUnsupportedShape + } + tolerance.Type = ToleranceRelative + tolerance.Amount = amountAtom.number + } else { + tolerance.Amount, reason = canonicalAtom(amountAtom, "", profile) + if reason != "" { + return nil, reason + } + } + amount := tolerance.Amount.rat() + if amount.Sign() < 0 { + return nil, ReasonUnsupportedShape + } + if tolerance.Type == ToleranceRelative { + amount.Mul(amount, new(big.Rat).Abs(nominal.rat())) + amount.Quo(amount, big.NewRat(100, 1)) + } + lower, err := decimalFromRat(new(big.Rat).Sub(nominal.rat(), amount)) + if err != nil { + return nil, decimalReason(err) + } + upper, err := decimalFromRat(new(big.Rat).Add(nominal.rat(), amount)) + if err != nil { + return nil, decimalReason(err) + } + tolerance.Interval = Interval{Lower: lower, Upper: upper, LowerInclusive: true, UpperInclusive: true} + return tolerance, "" +} + +func canonicalAtom(atom rawAtom, sharedUnit string, profile Profile) (Decimal, Reason) { + unit := atom.unit + if unit == "" { + unit = sharedUnit + } + if unit == "" && profile.DeclaredInputUnit == "" { + return Decimal{}, ReasonMissingUnit + } + number, err := Convert(atom.number, unit, profile) + if err != nil { + if errors.Is(err, ErrUnsupportedPrecision) { + return Decimal{}, ReasonUnsupportedPrecision + } + return Decimal{}, ReasonUnsupportedUnit + } + return number, "" +} + +func decimalReason(err error) Reason { + if errors.Is(err, ErrUnsupportedPrecision) { + return ReasonUnsupportedPrecision + } + return ReasonUnsupportedShape +} + +func consumeRoleMarker(input string) (string, string) { + lower := strings.ToLower(input) + for _, marker := range []struct{ text, role string }{ + {"absolute maximum", RoleAbsoluteMaximum}, {"nominal", RoleNominal}, + {"typical", RoleTypical}, {"typ.", RoleTypical}, + } { + if strings.HasPrefix(lower, marker.text) { + rest := input[len(marker.text):] + if rest == "" || unicode.IsSpace([]rune(rest)[0]) { + return strings.TrimSpace(rest), marker.role + } + } + } + return input, "" +} + +func startsRoleMarker(input string) bool { + _, role := consumeRoleMarker(input) + return role != "" +} + +func conditionalSuffix(input string) bool { + lower := strings.ToLower(input) + if strings.HasPrefix(lower, "@") { + return true + } + for _, marker := range []string{"at ", "when ", "if ", "under ", "with ", "depends on "} { + if strings.HasPrefix(lower, marker) { + return true + } + } + return false +} diff --git a/internal/specnumeric/normalize_test.go b/internal/specnumeric/normalize_test.go new file mode 100644 index 0000000..393838c --- /dev/null +++ b/internal/specnumeric/normalize_test.go @@ -0,0 +1,160 @@ +package specnumeric + +import ( + "strings" + "testing" +) + +func normalizationProfile(role string, shapes ...Shape) Profile { + p := electricalProfile("voltage", "V") + p.SemanticRole = role + p.AllowedShapes = shapes + return p +} + +func normalizedCandidate(t *testing.T, raw string, p Profile, shape Shape) *Value { + t.Helper() + original := raw + result := Normalize(raw, p) + if result.Reason != "" || result.Candidate == nil { + t.Fatalf("Normalize(%q): %+v, want usable candidate", raw, result) + } + if raw != original { + t.Fatal("raw changed") + } + if result.Candidate.Shape != shape || result.Candidate.Context.Kind != ContextUnconditional { + t.Fatalf("candidate = %+v, want unconditional %s", result.Candidate, shape) + } + if err := ValidateValue(*result.Candidate, p); err != nil { + t.Fatalf("invalid candidate: %v", err) + } + return result.Candidate +} + +func TestNormalizePreservesDisjointVoltageIntervals(t *testing.T) { + v := normalizedCandidate(t, "3.0–3.6 V, 4.5–5.5 V", normalizationProfile(RoleOperatingSupport, ShapeIntervalSet), ShapeIntervalSet) + if len(v.Intervals) != 2 || v.Intervals[0].Lower.String() != "3" || v.Intervals[0].Upper.String() != "3.6" || v.Intervals[1].Lower.String() != "4.5" || v.Intervals[1].Upper.String() != "5.5" { + t.Fatalf("lost interval gap: %+v", v.Intervals) + } + for _, interval := range v.Intervals { + if !interval.LowerInclusive || !interval.UpperInclusive { + t.Fatal("unmarked range must include both endpoints") + } + } +} + +func TestNormalizeSingleVoltageNeedsDeclaredMeaning(t *testing.T) { + result := Normalize("5V", normalizationProfile(RoleOperatingSupport, ShapeIntervalSet)) + if result.Reason != ReasonAmbiguous || result.Candidate != nil { + t.Fatalf("single voltage without permitted single-value meaning = %+v", result) + } +} + +func TestNormalizeRejectsDecimalCommaGuess(t *testing.T) { + result := Normalize("3,3V", normalizationProfile(RoleOperatingSupport, ShapeDiscreteSet)) + if result.Reason != ReasonAmbiguous || result.Candidate != nil { + t.Fatalf("decimal comma guessed: %+v", result) + } +} + +func TestNormalizeRetainsConditionalInput(t *testing.T) { + raw := "3.0–3.6 V at 25°C" + p := normalizationProfile(RoleOperatingSupport, ShapeIntervalSet) + result := Normalize(raw, p) + if result.Reason != ReasonContextRequired || result.Candidate == nil || result.Candidate.Context.Kind != ContextConditional || result.Candidate.Context.Raw != raw { + t.Fatalf("condition was discarded: %+v", result) + } + if err := ValidateValue(*result.Candidate, p); err != nil { + t.Fatal(err) + } +} + +func TestNormalizeNegativeNominalTolerance(t *testing.T) { + v := normalizedCandidate(t, "-5 V ±10%", normalizationProfile(RoleNominal, ShapeTolerance), ShapeTolerance) + if v.Tolerance.Nominal.String() != "-5" || v.Tolerance.Type != ToleranceRelative || v.Tolerance.Amount.String() != "10" || v.Tolerance.Interval.Lower.String() != "-5.5" || v.Tolerance.Interval.Upper.String() != "-4.5" { + t.Fatalf("negative relative tolerance = %+v", v.Tolerance) + } +} + +func TestNormalizeBoundClaimPreservesUnknownExtent(t *testing.T) { + v := normalizedCandidate(t, "≥3.3 V", normalizationProfile(RoleMinimumLimit, ShapeBoundClaim), ShapeBoundClaim) + if v.Bound.Direction != BoundLower || v.Bound.Value.String() != "3.3" || !v.Bound.Inclusive || len(v.Intervals) != 0 { + t.Fatalf("bound became a support interval: %+v", v) + } +} + +func TestNormalizeDeclaredScalarConvertsExactly(t *testing.T) { + v := normalizedCandidate(t, "3300 mV", normalizationProfile(RoleNominal, ShapeScalar), ShapeScalar) + if v.Scalar.String() != "3.3" { + t.Fatalf("scalar = %s, want 3.3", v.Scalar.String()) + } +} + +func TestNormalizeDiscreteSetAllowsSharedTrailingUnit(t *testing.T) { + v := normalizedCandidate(t, "3.3 / 5 V", normalizationProfile(RoleOperatingSupport, ShapeDiscreteSet), ShapeDiscreteSet) + if len(v.Values) != 2 || v.Values[0].String() != "3.3" || v.Values[1].String() != "5" { + t.Fatalf("discrete values = %+v", v.Values) + } +} + +func TestNormalizeMissingUnitRequiresDeclaredInputUnit(t *testing.T) { + p := normalizationProfile(RoleNominal, ShapeScalar) + result := Normalize("3300", p) + if result.Reason != Reason("missing_unit") || result.Candidate != nil { + t.Fatalf("canonical unit used as implicit input unit: %+v", result) + } + p.DeclaredInputUnit = "mV" + v := normalizedCandidate(t, "3300", p, ShapeScalar) + if v.Scalar.String() != "3.3" { + t.Fatalf("declared source unit ignored: %s", v.Scalar.String()) + } +} + +func TestNormalizePreservesExplicitEndpointOpenness(t *testing.T) { + v := normalizedCandidate(t, "(3.0, 3.6] V", normalizationProfile(RoleOperatingSupport, ShapeIntervalSet), ShapeIntervalSet) + if v.Intervals[0].LowerInclusive || !v.Intervals[0].UpperInclusive { + t.Fatalf("explicit endpoint flags lost: %+v", v.Intervals[0]) + } +} + +func TestNormalizeRejectsReversedInterval(t *testing.T) { + result := Normalize("5–3 V", normalizationProfile(RoleOperatingSupport, ShapeIntervalSet)) + if result.Reason != Reason("invalid_interval") || result.Candidate != nil { + t.Fatalf("reversed endpoints swapped: %+v", result) + } +} + +func TestNormalizeRejectsConflictingRoleMarker(t *testing.T) { + result := Normalize("5 V typ.", normalizationProfile(RoleOperatingSupport, ShapeScalar)) + if result.Reason != ReasonAmbiguous || result.Candidate != nil { + t.Fatalf("typical value promoted to operating support: %+v", result) + } +} + +func TestNormalizeAbsoluteToleranceUsesCanonicalUnits(t *testing.T) { + v := normalizedCandidate(t, "5 V ±100 mV", normalizationProfile(RoleNominal, ShapeTolerance), ShapeTolerance) + if v.Tolerance.Type != ToleranceAbsolute || v.Tolerance.Amount.String() != "0.1" || v.Tolerance.Interval.Lower.String() != "4.9" || v.Tolerance.Interval.Upper.String() != "5.1" { + t.Fatalf("absolute tolerance = %+v", v.Tolerance) + } +} + +func TestNormalizeRejectsPartialUnsupportedExpression(t *testing.T) { + result := Normalize("3–5 V / unknown", normalizationProfile(RoleOperatingSupport, ShapeIntervalSet)) + if result.Reason == "" || result.Candidate != nil { + t.Fatalf("partial expression became usable fact: %+v", result) + } +} + +func TestNormalizeBoundsInputWork(t *testing.T) { + result := Normalize(strings.Repeat(" ", 64*1024+1), normalizationProfile(RoleNominal, ShapeScalar)) + if result.Reason != Reason("unsupported_input_size") || result.Candidate != nil { + t.Fatalf("oversized input = %+v", result) + } +} + +func TestNormalizeRejectsUnsupportedPrecision(t *testing.T) { + result := Normalize("1e25 V", normalizationProfile(RoleNominal, ShapeScalar)) + if result.Reason != ReasonUnsupportedPrecision || result.Candidate != nil { + t.Fatalf("out-of-profile decimal = %+v", result) + } +} diff --git a/internal/specnumeric/predicate.go b/internal/specnumeric/predicate.go new file mode 100644 index 0000000..f14c79c --- /dev/null +++ b/internal/specnumeric/predicate.go @@ -0,0 +1,252 @@ +package specnumeric + +import ( + "fmt" + "sort" + "strings" +) + +// NormalizePredicate validates the entire condition before converting its +// operands to the profile's canonical comparison unit. It never drops a query. +func NormalizePredicate(input Predicate, profile Profile) (Predicate, error) { + if err := ValidateProfile(profile); err != nil { + return Predicate{}, err + } + if strings.TrimSpace(input.SpecID) == "" || input.Unit == "" { + return Predicate{}, fmt.Errorf("predicate requires spec ID and explicit unit") + } + compatible := false + for _, shape := range profile.AllowedShapes { + compatible = compatible || predicateCompatible(profile.SemanticRole, shape, input.Operator) + } + if !profile.AllowsOperator(input.Operator) || !compatible { + return Predicate{}, fmt.Errorf("unsupported predicate operator %q for profile", input.Operator) + } + rangeQuery := input.Operator == OperatorBetween || input.Operator == OperatorContainsRange || input.Operator == OperatorOverlapsRange + if rangeQuery { + if input.Value != nil || input.Lower == nil || input.Upper == nil { + return Predicate{}, fmt.Errorf("range predicate requires only lower and upper") + } + } else if input.Value == nil || input.Lower != nil || input.Upper != nil { + return Predicate{}, fmt.Errorf("point predicate requires only value") + } + output := input + for _, operand := range []struct { + input *Decimal + output **Decimal + }{{input.Value, &output.Value}, {input.Lower, &output.Lower}, {input.Upper, &output.Upper}} { + if operand.input == nil { + continue + } + converted, err := Convert(*operand.input, input.Unit, profile) + if err != nil { + return Predicate{}, err + } + *operand.output = &converted + } + if rangeQuery && output.Lower.Cmp(*output.Upper) > 0 { + return Predicate{}, fmt.Errorf("reversed predicate range") + } + output.Unit = profile.CanonicalUnit + return output, nil +} + +func predicateCompatible(role string, shape Shape, operator Operator) bool { + switch operator { + case OperatorContainsPoint, OperatorContainsRange, OperatorOverlapsRange: + return role == RoleOperatingSupport && (shape == ShapeScalar || shape == ShapeDiscreteSet || shape == ShapeIntervalSet) + case OperatorEqual, OperatorLess, OperatorLessEqual, OperatorGreater, OperatorGreaterEqual, OperatorBetween: + return shape == ShapeScalar || (shape == ShapeTolerance && role == RoleNominal) || (shape == ShapeBoundClaim && (role == RoleMinimumLimit || role == RoleMaximumLimit || role == RoleAbsoluteMaximum)) + } + return false +} + +// Evaluate is the exact oracle for a fact already associated with predicate's +// SpecID by its caller. Missing, invalid, conditional or inapplicable facts +// cannot establish either a match or a non-match. +func Evaluate(value *Value, profile Profile, predicate Predicate) Truth { + if value == nil || ValidateValue(*value, profile) != nil || value.Context.Kind != ContextUnconditional || !predicateCompatible(profile.SemanticRole, value.Shape, predicate.Operator) { + return Unknown + } + query, err := NormalizePredicate(predicate, profile) + if err != nil { + return Unknown + } + switch query.Operator { + case OperatorContainsPoint, OperatorContainsRange, OperatorOverlapsRange: + return evaluateSupport(*value, query) + } + switch value.Shape { + case ShapeScalar: + return evaluateScalar(*value.Scalar, query) + case ShapeTolerance: + return evaluateScalar(value.Tolerance.Nominal, query) + case ShapeBoundClaim: + return evaluateBound(*value.Bound, profile.SemanticRole, query) + } + return Unknown +} + +func evaluateScalar(value Decimal, query Predicate) Truth { + if query.Operator == OperatorBetween { + return truthOf(value.Cmp(*query.Lower) >= 0 && value.Cmp(*query.Upper) <= 0) + } + comparison := value.Cmp(*query.Value) + switch query.Operator { + case OperatorEqual: + return truthOf(comparison == 0) + case OperatorLess: + return truthOf(comparison < 0) + case OperatorLessEqual: + return truthOf(comparison <= 0) + case OperatorGreater: + return truthOf(comparison > 0) + case OperatorGreaterEqual: + return truthOf(comparison >= 0) + } + return Unknown +} + +func evaluateSupport(value Value, query Predicate) Truth { + intervals := append([]Interval(nil), value.Intervals...) + points := value.Values + if value.Shape == ShapeScalar { + points = []Decimal{*value.Scalar} + } + for _, point := range points { + intervals = append(intervals, Interval{Lower: point, Upper: point, LowerInclusive: true, UpperInclusive: true}) + } + if query.Operator == OperatorContainsPoint { + for _, interval := range intervals { + if intervalContains(interval, *query.Value) { + return Match + } + } + return NoMatch + } + if query.Operator == OperatorOverlapsRange { + for _, interval := range intervals { + lower := interval.Upper.Cmp(*query.Lower) + upper := interval.Lower.Cmp(*query.Upper) + if (lower > 0 || lower == 0 && interval.UpperInclusive) && (upper < 0 || upper == 0 && interval.LowerInclusive) { + return Match + } + } + return NoMatch + } + // Union connected intervals without filling any excluded point or gap. + sort.Slice(intervals, func(i, j int) bool { + comparison := intervals[i].Lower.Cmp(intervals[j].Lower) + if comparison == 0 { + return intervals[i].LowerInclusive && !intervals[j].LowerInclusive + } + return comparison < 0 + }) + var covered *Interval + for _, interval := range intervals { + if covered == nil { + if intervalContains(interval, *query.Lower) { + current := interval + covered = ¤t + } + } else { + connection := interval.Lower.Cmp(covered.Upper) + if connection > 0 || connection == 0 && !interval.LowerInclusive && !covered.UpperInclusive { + return NoMatch + } + end := interval.Upper.Cmp(covered.Upper) + if end > 0 { + covered.Upper, covered.UpperInclusive = interval.Upper, interval.UpperInclusive + } else if end == 0 { + covered.UpperInclusive = covered.UpperInclusive || interval.UpperInclusive + } + } + if covered != nil && intervalContains(*covered, *query.Upper) { + return Match + } + } + return NoMatch +} + +func intervalContains(interval Interval, point Decimal) bool { + lower, upper := point.Cmp(interval.Lower), point.Cmp(interval.Upper) + return (lower > 0 || lower == 0 && interval.LowerInclusive) && (upper < 0 || upper == 0 && interval.UpperInclusive) +} + +func evaluateBound(bound BoundClaim, role string, query Predicate) Truth { + if bound.Direction == BoundLower && role != RoleMinimumLimit || bound.Direction == BoundUpper && role != RoleMaximumLimit && role != RoleAbsoluteMaximum { + return Unknown + } + // A one-sided claim constrains possible limit values; it is never promoted + // into an infinite operating-support interval or an exact endpoint value. + if query.Operator == OperatorBetween { + comparison := bound.Value.Cmp(*query.Lower) + if bound.Direction == BoundLower { + comparison = query.Upper.Cmp(bound.Value) + } + if comparison < 0 || comparison == 0 && !bound.Inclusive { + return NoMatch + } + return Unknown + } + comparison, operator := bound.Value.Cmp(*query.Value), query.Operator + if bound.Direction == BoundLower { + comparison = -comparison + switch operator { + case OperatorLess: + operator = OperatorGreater + case OperatorLessEqual: + operator = OperatorGreaterEqual + case OperatorGreater: + operator = OperatorLess + case OperatorGreaterEqual: + operator = OperatorLessEqual + } + } + below := comparison < 0 || comparison == 0 && !bound.Inclusive + switch operator { + case OperatorEqual: + if below { + return NoMatch + } + case OperatorLess: + if below { + return Match + } + case OperatorLessEqual: + if comparison <= 0 { + return Match + } + case OperatorGreater: + if comparison <= 0 { + return NoMatch + } + case OperatorGreaterEqual: + if below { + return NoMatch + } + } + return Unknown +} + +func truthOf(condition bool) Truth { + if condition { + return Match + } + return NoMatch +} + +// All implements conjunction: NO_MATCH dominates UNKNOWN; only all MATCH +// values (including an empty conjunction) establish MATCH. +func All(results []Truth) Truth { + result := Match + for _, value := range results { + if value == NoMatch { + return NoMatch + } + if value != Match { + result = Unknown + } + } + return result +} diff --git a/internal/specnumeric/predicate_test.go b/internal/specnumeric/predicate_test.go new file mode 100644 index 0000000..84ee56f --- /dev/null +++ b/internal/specnumeric/predicate_test.go @@ -0,0 +1,175 @@ +package specnumeric + +import "testing" + +func predicateProfile(role string, shapes []Shape, operators ...Operator) Profile { + p := electricalProfile("voltage", "V") + p.SemanticRole = role + p.AllowedShapes = shapes + p.AllowedOperators = operators + return p +} + +func predicateValue(profile Profile, shape Shape) Value { + return Value{SchemaVersion: profile.SchemaVersion, Quantity: profile.Quantity, SemanticRole: profile.SemanticRole, Shape: shape, Unit: profile.CanonicalUnit, UnitRegistryVersion: profile.UnitRegistryVersion, Context: Context{Kind: ContextUnconditional}} +} + +func disjointVoltageValue(t *testing.T, profile Profile) Value { + t.Helper() + v := predicateValue(profile, ShapeIntervalSet) + v.Intervals = []Interval{ + {Lower: decimalForTest(t, "3"), Upper: decimalForTest(t, "3.6"), LowerInclusive: true, UpperInclusive: true}, + {Lower: decimalForTest(t, "4.5"), Upper: decimalForTest(t, "5.5"), LowerInclusive: true, UpperInclusive: true}, + } + return v +} + +func TestIntervalSetCannotBridgeGap(t *testing.T) { + p := predicateProfile(RoleOperatingSupport, []Shape{ShapeIntervalSet}, OperatorContainsRange) + v := disjointVoltageValue(t, p) + lower, upper := decimalForTest(t, "3.3"), decimalForTest(t, "5") + query := Predicate{SpecID: "operating-voltage", Operator: OperatorContainsRange, Lower: &lower, Upper: &upper, Unit: "V"} + if got := Evaluate(&v, p, query); got != NoMatch { + t.Fatalf("range across gap = %s, want NO_MATCH", got) + } +} + +func TestIntervalSetContainsSupportedPoint(t *testing.T) { + p := predicateProfile(RoleOperatingSupport, []Shape{ShapeIntervalSet}, OperatorContainsPoint) + v := disjointVoltageValue(t, p) + point := decimalForTest(t, "3.3") + query := Predicate{SpecID: "operating-voltage", Operator: OperatorContainsPoint, Value: &point, Unit: "V"} + if got := Evaluate(&v, p, query); got != Match { + t.Fatalf("supported point = %s, want MATCH", got) + } +} + +func TestMissingNumericValueIsUnknown(t *testing.T) { + p := predicateProfile(RoleNominal, []Shape{ShapeScalar}, OperatorEqual) + point := decimalForTest(t, "0") + query := Predicate{SpecID: "nominal-voltage", Operator: OperatorEqual, Value: &point, Unit: "V"} + if got := Evaluate(nil, p, query); got != Unknown { + t.Fatalf("missing numeric value = %s, want UNKNOWN", got) + } +} + +func TestZeroScalarRemainsAValue(t *testing.T) { + p := predicateProfile(RoleNominal, []Shape{ShapeScalar}, OperatorEqual) + zero := decimalForTest(t, "0") + v := predicateValue(p, ShapeScalar) + v.Scalar = &zero + query := Predicate{SpecID: "nominal-voltage", Operator: OperatorEqual, Value: &zero, Unit: "V"} + if got := Evaluate(&v, p, query); got != Match { + t.Fatalf("zero scalar equality = %s, want MATCH", got) + } +} + +func TestUpperBoundDoesNotEstablishExactLimit(t *testing.T) { + p := predicateProfile(RoleMaximumLimit, []Shape{ShapeBoundClaim}, OperatorEqual) + five := decimalForTest(t, "5") + v := predicateValue(p, ShapeBoundClaim) + v.Bound = &BoundClaim{Direction: BoundUpper, Value: five, Inclusive: true} + query := Predicate{SpecID: "maximum-voltage", Operator: OperatorEqual, Value: &five, Unit: "V"} + if got := Evaluate(&v, p, query); got != Unknown { + t.Fatalf("upper-bound equality = %s, want UNKNOWN", got) + } +} + +func TestUpperBoundEstablishesGuaranteedLimit(t *testing.T) { + p := predicateProfile(RoleMaximumLimit, []Shape{ShapeBoundClaim}, OperatorLessEqual) + five := decimalForTest(t, "5") + v := predicateValue(p, ShapeBoundClaim) + v.Bound = &BoundClaim{Direction: BoundUpper, Value: five, Inclusive: true} + query := Predicate{SpecID: "maximum-voltage", Operator: OperatorLessEqual, Value: &five, Unit: "V"} + if got := Evaluate(&v, p, query); got != Match { + t.Fatalf("guaranteed limit = %s, want MATCH", got) + } +} + +func TestNominalToleranceComparesRetainedNominal(t *testing.T) { + p := predicateProfile(RoleNominal, []Shape{ShapeTolerance}, OperatorEqual) + five := decimalForTest(t, "5") + v := predicateValue(p, ShapeTolerance) + v.Tolerance = &Tolerance{Nominal: five, Type: ToleranceRelative, Amount: decimalForTest(t, "10"), Interval: Interval{Lower: decimalForTest(t, "4.5"), Upper: decimalForTest(t, "5.5"), LowerInclusive: true, UpperInclusive: true}} + query := Predicate{SpecID: "nominal-voltage", Operator: OperatorEqual, Value: &five, Unit: "V"} + if got := Evaluate(&v, p, query); got != Match { + t.Fatalf("nominal tolerance equality = %s, want MATCH", got) + } +} + +func TestConditionalNumericValueIsUnknown(t *testing.T) { + p := predicateProfile(RoleOperatingSupport, []Shape{ShapeIntervalSet}, OperatorContainsPoint) + v := disjointVoltageValue(t, p) + v.Context = Context{Kind: ContextConditional, Raw: "mode A"} + point := decimalForTest(t, "3.3") + query := Predicate{SpecID: "operating-voltage", Operator: OperatorContainsPoint, Value: &point, Unit: "V"} + if got := Evaluate(&v, p, query); got != Unknown { + t.Fatalf("conditional fact = %s, want UNKNOWN", got) + } +} + +func TestScalarComparisonNormalizesQueryUnitExactly(t *testing.T) { + p := predicateProfile(RoleNominal, []Shape{ShapeScalar}, OperatorLess) + v := predicateValue(p, ShapeScalar) + nominal, queryValue := decimalForTest(t, "3.3"), decimalForTest(t, "3300.0000000000000000000000000001") + v.Scalar = &nominal + query, err := NormalizePredicate(Predicate{SpecID: "nominal-voltage", Operator: OperatorLess, Value: &queryValue, Unit: "mV"}, p) + if err != nil { + t.Fatal(err) + } + if got := Evaluate(&v, p, query); got != Match { + t.Fatalf("exact comparison after unit conversion = %s, want MATCH", got) + } +} + +func TestNormalizePredicateRejectsSupportQueryForNominalTolerance(t *testing.T) { + p := predicateProfile(RoleNominal, []Shape{ShapeTolerance}, OperatorContainsPoint) + point := decimalForTest(t, "5") + _, err := NormalizePredicate(Predicate{SpecID: "nominal-voltage", Operator: OperatorContainsPoint, Value: &point, Unit: "V"}, p) + if err == nil { + t.Fatal("nominal tolerance accepted as operating support") + } +} + +func TestIntervalSetOverlapRespectsOpenEndpoint(t *testing.T) { + p := predicateProfile(RoleOperatingSupport, []Shape{ShapeIntervalSet}, OperatorOverlapsRange) + v := predicateValue(p, ShapeIntervalSet) + v.Intervals = []Interval{{Lower: decimalForTest(t, "3"), Upper: decimalForTest(t, "3.6"), LowerInclusive: true, UpperInclusive: false}} + lower, upper := decimalForTest(t, "3.6"), decimalForTest(t, "4") + query := Predicate{SpecID: "operating-voltage", Operator: OperatorOverlapsRange, Lower: &lower, Upper: &upper, Unit: "V"} + if got := Evaluate(&v, p, query); got != NoMatch { + t.Fatalf("overlap at excluded endpoint = %s, want NO_MATCH", got) + } +} + +func TestIntervalSetCombinesConnectedCoverage(t *testing.T) { + p := predicateProfile(RoleOperatingSupport, []Shape{ShapeIntervalSet}, OperatorContainsRange) + v := predicateValue(p, ShapeIntervalSet) + v.Intervals = []Interval{ + {Lower: decimalForTest(t, "3"), Upper: decimalForTest(t, "5"), LowerInclusive: false, UpperInclusive: true}, + {Lower: decimalForTest(t, "3"), Upper: decimalForTest(t, "4"), LowerInclusive: true, UpperInclusive: true}, + } + lower, upper := decimalForTest(t, "3"), decimalForTest(t, "5") + query := Predicate{SpecID: "operating-voltage", Operator: OperatorContainsRange, Lower: &lower, Upper: &upper, Unit: "V"} + if got := Evaluate(&v, p, query); got != Match { + t.Fatalf("connected complete coverage = %s, want MATCH", got) + } +} + +func TestAllPreservesThreeValuedConjunction(t *testing.T) { + for _, test := range []struct { + name string + values []Truth + want Truth + }{ + {"no_match_dominates_unknown", []Truth{Unknown, NoMatch}, NoMatch}, + {"unknown_prevents_match", []Truth{Match, Unknown}, Unknown}, + {"all_match", []Truth{Match, Match}, Match}, + } { + t.Run(test.name, func(t *testing.T) { + if got := All(test.values); got != test.want { + t.Fatalf("conjunction = %s, want %s", got, test.want) + } + }) + } +} diff --git a/internal/specnumeric/profile.go b/internal/specnumeric/profile.go new file mode 100644 index 0000000..3107368 --- /dev/null +++ b/internal/specnumeric/profile.go @@ -0,0 +1,84 @@ +package specnumeric + +import "fmt" + +const ( + SchemaVersion = 1 + ParserVersion = "electrical-parser-1" + NumericFormatDecimalDot = "decimal-dot" + RoleOperatingSupport = "operating_support" + RoleNominal = "nominal" + RoleTypical = "typical" + RoleMinimumLimit = "minimum_limit" + RoleMaximumLimit = "maximum_limit" + RoleAbsoluteMaximum = "absolute_maximum" +) + +type Profile struct { + Quantity string `json:"quantity"` + SemanticRole string `json:"semantic_role"` + AllowedShapes []Shape `json:"allowed_shapes"` + AllowedOperators []Operator `json:"allowed_operators"` + CanonicalUnit string `json:"canonical_unit"` + DeclaredInputUnit string `json:"declared_input_unit"` + NumericFormat string `json:"numeric_format"` + SchemaVersion int `json:"schema_version"` + ParserVersion string `json:"parser_version"` + UnitRegistryVersion string `json:"unit_registry_version"` +} + +func ValidateProfile(profile Profile) error { + if profile.SchemaVersion != SchemaVersion || profile.ParserVersion != ParserVersion || profile.UnitRegistryVersion != UnitRegistryVersion || profile.NumericFormat != NumericFormatDecimalDot { + return fmt.Errorf("unsupported profile version or numeric format") + } + switch profile.SemanticRole { + case RoleOperatingSupport, RoleNominal, RoleTypical, RoleMinimumLimit, RoleMaximumLimit, RoleAbsoluteMaximum: + default: + return fmt.Errorf("unsupported semantic role: %q", profile.SemanticRole) + } + unit, ok := electricalUnits[profile.CanonicalUnit] + if !ok || unit.exponent != 0 || unit.canonical != profile.CanonicalUnit || unit.quantity != profile.Quantity { + return fmt.Errorf("invalid canonical unit for quantity") + } + if profile.DeclaredInputUnit != "" { + input, ok := electricalUnits[profile.DeclaredInputUnit] + if !ok || input.quantity != profile.Quantity { + return fmt.Errorf("invalid declared input unit for quantity") + } + } + if len(profile.AllowedShapes) == 0 || len(profile.AllowedOperators) == 0 { + return fmt.Errorf("profile must explicitly allow shapes and operators") + } + shapes := map[Shape]bool{} + for _, shape := range profile.AllowedShapes { + if !knownShape(shape) || shapes[shape] { + return fmt.Errorf("invalid or duplicate allowed shape: %q", shape) + } + shapes[shape] = true + } + operators := map[Operator]bool{} + for _, operator := range profile.AllowedOperators { + if !knownOperator(operator) || operators[operator] { + return fmt.Errorf("invalid or duplicate allowed operator: %q", operator) + } + operators[operator] = true + } + return nil +} + +func (p Profile) AllowsShape(shape Shape) bool { + for _, allowed := range p.AllowedShapes { + if shape == allowed { + return true + } + } + return false +} +func (p Profile) AllowsOperator(operator Operator) bool { + for _, allowed := range p.AllowedOperators { + if operator == allowed { + return true + } + } + return false +} diff --git a/internal/specnumeric/units.go b/internal/specnumeric/units.go new file mode 100644 index 0000000..ce66621 --- /dev/null +++ b/internal/specnumeric/units.go @@ -0,0 +1,52 @@ +package specnumeric + +import ( + "fmt" + "math/big" +) + +const UnitRegistryVersion = "electrical-1" + +type unitDefinition struct { + quantity, canonical string + exponent int +} + +var electricalUnits = map[string]unitDefinition{ + "V": {"voltage", "V", 0}, "mV": {"voltage", "V", -3}, "µV": {"voltage", "V", -6}, "uV": {"voltage", "V", -6}, "μV": {"voltage", "V", -6}, "kV": {"voltage", "V", 3}, + "A": {"current", "A", 0}, "mA": {"current", "A", -3}, "µA": {"current", "A", -6}, "uA": {"current", "A", -6}, "μA": {"current", "A", -6}, + "F": {"capacitance", "F", 0}, "mF": {"capacitance", "F", -3}, "µF": {"capacitance", "F", -6}, "uF": {"capacitance", "F", -6}, "μF": {"capacitance", "F", -6}, "nF": {"capacitance", "F", -9}, "pF": {"capacitance", "F", -12}, + "Ω": {"resistance", "Ω", 0}, "Ohm": {"resistance", "Ω", 0}, "mΩ": {"resistance", "Ω", -3}, "mOhm": {"resistance", "Ω", -3}, "kΩ": {"resistance", "Ω", 3}, "kOhm": {"resistance", "Ω", 3}, "MΩ": {"resistance", "Ω", 6}, "MOhm": {"resistance", "Ω", 6}, + "Hz": {"frequency", "Hz", 0}, "kHz": {"frequency", "Hz", 3}, "MHz": {"frequency", "Hz", 6}, "GHz": {"frequency", "Hz", 9}, +} + +func Convert(value Decimal, unit string, profile Profile) (Decimal, error) { + if err := ValidateProfile(profile); err != nil { + return Decimal{}, err + } + if !value.valid() { + return Decimal{}, ErrInvalidDecimal + } + if unit == "" { + unit = profile.DeclaredInputUnit + } + definition, ok := electricalUnits[unit] + if !ok || definition.quantity != profile.Quantity || definition.canonical != profile.CanonicalUnit { + return Decimal{}, fmt.Errorf("unsupported_unit: %q", unit) + } + factor := new(big.Int).Exp(big.NewInt(10), big.NewInt(int64(abs(definition.exponent))), nil) + converted := value.rat() + if definition.exponent >= 0 { + converted.Mul(converted, new(big.Rat).SetInt(factor)) + } else { + converted.Quo(converted, new(big.Rat).SetInt(factor)) + } + return decimalFromRat(converted) +} + +func abs(value int) int { + if value < 0 { + return -value + } + return value +} diff --git a/internal/specnumeric/units_test.go b/internal/specnumeric/units_test.go new file mode 100644 index 0000000..be82e08 --- /dev/null +++ b/internal/specnumeric/units_test.go @@ -0,0 +1,44 @@ +package specnumeric + +import "testing" + +func electricalProfile(quantity, unit string) Profile { + return Profile{Quantity: quantity, SemanticRole: "operating_support", AllowedShapes: []Shape{ShapeScalar}, AllowedOperators: []Operator{OperatorContainsPoint}, CanonicalUnit: unit, NumericFormat: NumericFormatDecimalDot, SchemaVersion: SchemaVersion, ParserVersion: ParserVersion, UnitRegistryVersion: UnitRegistryVersion} +} + +func decimalForTest(t *testing.T, text string) Decimal { + t.Helper() + value, err := ParseDecimal(text) + if err != nil { + t.Fatal(err) + } + return value +} + +func TestElectricalUnitsPreservePrefixCase(t *testing.T) { + profile := electricalProfile("resistance", "Ω") + for _, tc := range []struct{ unit, want string }{{"mΩ", "0.001"}, {"MΩ", "1000000"}} { + got, err := Convert(decimalForTest(t, "1"), tc.unit, profile) + if err != nil { + t.Fatal(err) + } + if got.String() != tc.want { + t.Errorf("1 %s = %s Ω, want %s", tc.unit, got, tc.want) + } + } +} + +func TestCapacitanceEquivalentUnits(t *testing.T) { + profile := electricalProfile("capacitance", "F") + left, err := Convert(decimalForTest(t, "0.1"), "µF", profile) + if err != nil { + t.Fatal(err) + } + right, err := Convert(decimalForTest(t, "100"), "nF", profile) + if err != nil { + t.Fatal(err) + } + if left.Cmp(right) != 0 || left.String() != "0.0000001" { + t.Fatalf("0.1 µF = %s F; 100 nF = %s F, want 0.0000001 F", left, right) + } +} diff --git a/internal/specnumeric/value.go b/internal/specnumeric/value.go new file mode 100644 index 0000000..9f5ec91 --- /dev/null +++ b/internal/specnumeric/value.go @@ -0,0 +1,350 @@ +package specnumeric + +import ( + "encoding/json" + "fmt" + "io" + "math/big" + "strings" +) + +type Shape string + +const ( + ShapeScalar Shape = "scalar" + ShapeDiscreteSet Shape = "discrete_set" + ShapeIntervalSet Shape = "interval_set" + ShapeTolerance Shape = "tolerance" + ShapeBoundClaim Shape = "bound_claim" +) + +type Operator string + +const ( + OperatorContainsPoint Operator = "contains_point" + OperatorContainsRange Operator = "contains_range" + OperatorOverlapsRange Operator = "overlaps_range" + OperatorEqual Operator = "=" + OperatorLess Operator = "<" + OperatorLessEqual Operator = "<=" + OperatorGreater Operator = ">" + OperatorGreaterEqual Operator = ">=" + OperatorBetween Operator = "between" +) + +type Truth string + +const ( + Match Truth = "MATCH" + NoMatch Truth = "NO_MATCH" + Unknown Truth = "UNKNOWN" +) + +type Reason string + +const ( + ReasonUnsupported Reason = "unsupported" + ReasonUnsupportedShape Reason = "unsupported_shape" + ReasonUnsupportedUnit Reason = "unsupported_unit" + ReasonUnsupportedPrecision Reason = "unsupported_precision" + ReasonAmbiguous Reason = "ambiguous" + ReasonContextRequired Reason = "context_required" + ReasonMissing Reason = "missing" + ReasonMissingUnit Reason = "missing_unit" + ReasonInvalidInterval Reason = "invalid_interval" + ReasonUnsupportedInputSize Reason = "unsupported_input_size" +) + +type Result struct { + Candidate *Value `json:"candidate,omitempty"` + Reason Reason `json:"reason,omitempty"` +} + +type Predicate struct { + SpecID string `json:"spec_id"` + Operator Operator `json:"operator"` + Value *Decimal `json:"value,omitempty"` + Lower *Decimal `json:"lower,omitempty"` + Upper *Decimal `json:"upper,omitempty"` + Unit string `json:"unit"` +} + +type ContextKind string + +const ( + ContextUnconditional ContextKind = "unconditional" + ContextConditional ContextKind = "conditional" +) + +type Context struct { + Kind ContextKind `json:"kind"` + Raw string `json:"raw,omitempty"` +} + +type Interval struct { + Lower Decimal `json:"lower"` + Upper Decimal `json:"upper"` + LowerInclusive bool `json:"lower_inclusive"` + UpperInclusive bool `json:"upper_inclusive"` +} + +// Controlled JSON must state openness explicitly. The Raw parser owns any +// syntax defaults; omission or null here cannot silently become false. +func (interval *Interval) UnmarshalJSON(encoded []byte) error { + var wire struct { + Lower Decimal `json:"lower"` + Upper Decimal `json:"upper"` + LowerInclusive *bool `json:"lower_inclusive"` + UpperInclusive *bool `json:"upper_inclusive"` + } + decoder := json.NewDecoder(strings.NewReader(string(encoded))) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&wire); err != nil { + return err + } + if wire.LowerInclusive == nil || wire.UpperInclusive == nil { + return fmt.Errorf("interval requires non-null lower_inclusive and upper_inclusive") + } + *interval = Interval{Lower: wire.Lower, Upper: wire.Upper, LowerInclusive: *wire.LowerInclusive, UpperInclusive: *wire.UpperInclusive} + return nil +} + +type ToleranceType string + +const ( + ToleranceAbsolute ToleranceType = "absolute" + ToleranceRelative ToleranceType = "relative" +) + +// Amount is in canonical units for absolute tolerance and percent for relative. +type Tolerance struct { + Nominal Decimal `json:"nominal"` + Type ToleranceType `json:"type"` + Amount Decimal `json:"amount"` + Interval Interval `json:"interval"` +} + +type BoundDirection string + +const ( + BoundLower BoundDirection = "lower" + BoundUpper BoundDirection = "upper" +) + +type BoundClaim struct { + Direction BoundDirection `json:"direction"` + Value Decimal `json:"value"` + Inclusive bool `json:"inclusive"` +} + +func (bound *BoundClaim) UnmarshalJSON(encoded []byte) error { + var wire struct { + Direction BoundDirection `json:"direction"` + Value Decimal `json:"value"` + Inclusive *bool `json:"inclusive"` + } + decoder := json.NewDecoder(strings.NewReader(string(encoded))) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&wire); err != nil { + return err + } + if wire.Inclusive == nil { + return fmt.Errorf("bound requires non-null inclusive") + } + *bound = BoundClaim{Direction: wire.Direction, Value: wire.Value, Inclusive: *wire.Inclusive} + return nil +} + +// Value is a flattened tagged union: exactly one payload must match Shape. +type Value struct { + SchemaVersion int `json:"schema_version"` + Quantity string `json:"quantity"` + SemanticRole string `json:"semantic_role"` + Shape Shape `json:"shape"` + Unit string `json:"unit"` + UnitRegistryVersion string `json:"unit_registry_version"` + Scalar *Decimal `json:"scalar,omitempty"` + Values []Decimal `json:"values,omitempty"` + Intervals []Interval `json:"intervals,omitempty"` + Tolerance *Tolerance `json:"tolerance,omitempty"` + Bound *BoundClaim `json:"bound,omitempty"` + Context Context `json:"context"` +} + +func knownShape(shape Shape) bool { + switch shape { + case ShapeScalar, ShapeDiscreteSet, ShapeIntervalSet, ShapeTolerance, ShapeBoundClaim: + return true + } + return false +} +func knownOperator(operator Operator) bool { + switch operator { + case OperatorContainsPoint, OperatorContainsRange, OperatorOverlapsRange, OperatorEqual, OperatorLess, OperatorLessEqual, OperatorGreater, OperatorGreaterEqual, OperatorBetween: + return true + } + return false +} + +func ValidateValue(value Value, profile Profile) error { + if err := ValidateProfile(profile); err != nil { + return err + } + if value.SchemaVersion != profile.SchemaVersion || value.Quantity != profile.Quantity || value.SemanticRole != profile.SemanticRole || value.Unit != profile.CanonicalUnit || value.UnitRegistryVersion != profile.UnitRegistryVersion { + return fmt.Errorf("value does not match profile") + } + if !profile.AllowsShape(value.Shape) { + return fmt.Errorf("shape not allowed by profile") + } + switch value.Context.Kind { + case ContextUnconditional: + if value.Context.Raw != "" { + return fmt.Errorf("unconditional value has conditional text") + } + case ContextConditional: + if strings.TrimSpace(value.Context.Raw) == "" { + return fmt.Errorf("conditional value requires original context") + } + default: + return fmt.Errorf("invalid context kind") + } + payloads := 0 + for _, present := range []bool{value.Scalar != nil, value.Values != nil, value.Intervals != nil, value.Tolerance != nil, value.Bound != nil} { + if present { + payloads++ + } + } + if payloads != 1 { + return fmt.Errorf("value must have exactly one shape payload") + } + switch value.Shape { + case ShapeScalar: + if value.Scalar == nil || !value.Scalar.valid() { + return ErrInvalidDecimal + } + case ShapeDiscreteSet: + if len(value.Values) == 0 { + return fmt.Errorf("empty discrete set") + } + for _, number := range value.Values { + if !number.valid() { + return ErrInvalidDecimal + } + } + case ShapeIntervalSet: + if len(value.Intervals) == 0 { + return fmt.Errorf("empty interval set") + } + for _, interval := range value.Intervals { + if err := validateInterval(interval); err != nil { + return err + } + } + case ShapeTolerance: + if value.Tolerance == nil { + return fmt.Errorf("missing tolerance") + } + if err := validateTolerance(*value.Tolerance); err != nil { + return err + } + case ShapeBoundClaim: + if value.Bound == nil || !value.Bound.Value.valid() { + return fmt.Errorf("missing bound") + } + if value.Bound.Direction != BoundLower && value.Bound.Direction != BoundUpper { + return fmt.Errorf("invalid bound direction") + } + default: + return fmt.Errorf("unsupported shape") + } + return nil +} + +func validateInterval(interval Interval) error { + if !interval.Lower.valid() || !interval.Upper.valid() { + return ErrInvalidDecimal + } + comparison := interval.Lower.Cmp(interval.Upper) + if comparison > 0 || (comparison == 0 && (!interval.LowerInclusive || !interval.UpperInclusive)) { + return fmt.Errorf("empty or reversed interval") + } + return nil +} + +func validateTolerance(tolerance Tolerance) error { + if !tolerance.Nominal.valid() || !tolerance.Amount.valid() { + return ErrInvalidDecimal + } + amount := tolerance.Amount.rat() + if amount.Sign() < 0 { + return fmt.Errorf("negative tolerance") + } + switch tolerance.Type { + case ToleranceAbsolute: + case ToleranceRelative: + amount.Mul(amount, new(big.Rat).Abs(tolerance.Nominal.rat())) + amount.Quo(amount, big.NewRat(100, 1)) + default: + return fmt.Errorf("unsupported tolerance type") + } + lower, err := decimalFromRat(new(big.Rat).Sub(tolerance.Nominal.rat(), amount)) + if err != nil { + return err + } + upper, err := decimalFromRat(new(big.Rat).Add(tolerance.Nominal.rat(), amount)) + if err != nil { + return err + } + if err := validateInterval(tolerance.Interval); err != nil { + return err + } + if !tolerance.Interval.LowerInclusive || !tolerance.Interval.UpperInclusive || lower.Cmp(tolerance.Interval.Lower) != 0 || upper.Cmp(tolerance.Interval.Upper) != 0 { + return fmt.Errorf("tolerance interval does not match nominal and amount") + } + return nil +} + +func MarshalValue(value Value) (string, error) { + profile := Profile{Quantity: value.Quantity, SemanticRole: value.SemanticRole, AllowedShapes: []Shape{value.Shape}, AllowedOperators: []Operator{OperatorEqual}, CanonicalUnit: value.Unit, NumericFormat: NumericFormatDecimalDot, SchemaVersion: value.SchemaVersion, ParserVersion: ParserVersion, UnitRegistryVersion: value.UnitRegistryVersion} + if err := ValidateValue(value, profile); err != nil { + return "", err + } + encoded, err := json.Marshal(value) + return string(encoded), err +} + +func UnmarshalValue(encoded string, profile Profile) (Value, error) { + decoder := json.NewDecoder(strings.NewReader(encoded)) + decoder.DisallowUnknownFields() + var value Value + if err := decoder.Decode(&value); err != nil { + return Value{}, err + } + if err := decoder.Decode(new(any)); err != io.EOF { + return Value{}, fmt.Errorf("value must contain one JSON object") + } + // Presence matters at the wire boundary: an unrelated null payload cannot + // disappear into the Go pointer's zero value and bypass the tagged union. + var fields map[string]json.RawMessage + if err := json.Unmarshal([]byte(encoded), &fields); err != nil { + return Value{}, err + } + payload := map[Shape]string{ShapeScalar: "scalar", ShapeDiscreteSet: "values", ShapeIntervalSet: "intervals", ShapeTolerance: "tolerance", ShapeBoundClaim: "bound"}[value.Shape] + for name := range fields { + // The controlled union uses canonical wire keys, rather than allowing + // decoder case folding to disagree with payload presence validation. + switch name { + case "schema_version", "quantity", "semantic_role", "shape", "unit", "unit_registry_version", "context": + case "scalar", "values", "intervals", "tolerance", "bound": + if name != payload { + return Value{}, fmt.Errorf("payload %q incompatible with shape", name) + } + default: + return Value{}, fmt.Errorf("noncanonical value field %q", name) + } + } + if err := ValidateValue(value, profile); err != nil { + return Value{}, err + } + return value, nil +} diff --git a/internal/specnumeric/value_test.go b/internal/specnumeric/value_test.go new file mode 100644 index 0000000..ffd6ac8 --- /dev/null +++ b/internal/specnumeric/value_test.go @@ -0,0 +1,111 @@ +package specnumeric + +import ( + "errors" + "strings" + "testing" +) + +func TestDecimalRejectsUnsupportedPrecision(t *testing.T) { + if _, err := ParseDecimal("1.2345678901234567890123456789012345"); !errors.Is(err, ErrUnsupportedPrecision) { + t.Fatalf("35 significant digits: got %v, want unsupported precision", err) + } +} + +func TestDecimalCanonicalizesZero(t *testing.T) { + value, err := ParseDecimal("-0.00000000000000000000000000") + if err != nil || value.String() != "0" { + t.Fatalf("exact zero: got %q, %v; want 0", value.String(), err) + } +} + +func TestConversionRejectsUnsupportedExponent(t *testing.T) { + _, err := Convert(decimalForTest(t, "1e24"), "kV", electricalProfile("voltage", "V")) + if !errors.Is(err, ErrUnsupportedPrecision) { + t.Fatalf("converted exponent 27: got %v, want unsupported precision", err) + } +} + +func TestProfileRequiresExplicitSemanticRole(t *testing.T) { + profile := electricalProfile("voltage", "V") + profile.SemanticRole = "" + if err := ValidateProfile(profile); err == nil { + t.Fatal("blank semantic role was accepted") + } +} + +func TestControlledValueRejectsMixedShapes(t *testing.T) { + profile := electricalProfile("voltage", "V") + encoded := `{"schema_version":1,"quantity":"voltage","semantic_role":"operating_support","shape":"scalar","unit":"V","unit_registry_version":"electrical-1","scalar":"3.3","values":["5"],"context":{"kind":"unconditional"}}` + if _, err := UnmarshalValue(encoded, profile); err == nil { + t.Fatal("scalar carrying a discrete set was accepted") + } +} + +func TestControlledValueRejectsUnrelatedNullPayload(t *testing.T) { + profile := electricalProfile("voltage", "V") + encoded := `{"schema_version":1,"quantity":"voltage","semantic_role":"operating_support","shape":"scalar","unit":"V","unit_registry_version":"electrical-1","scalar":"3.3","bound":null,"context":{"kind":"unconditional"}}` + if _, err := UnmarshalValue(encoded, profile); err == nil { + t.Fatal("scalar carrying a null bound payload was accepted") + } +} + +func TestControlledValueRejectsCaseVariantNullPayload(t *testing.T) { + profile := electricalProfile("voltage", "V") + encoded := `{"schema_version":1,"quantity":"voltage","semantic_role":"operating_support","shape":"scalar","unit":"V","unit_registry_version":"electrical-1","scalar":"3.3","BOUND":null,"context":{"kind":"unconditional"}}` + if _, err := UnmarshalValue(encoded, profile); err == nil { + t.Fatal("case-variant incompatible payload was accepted") + } +} + +func TestControlledIntervalRequiresExplicitEndpointFlags(t *testing.T) { + profile := electricalProfile("voltage", "V") + profile.AllowedShapes = []Shape{ShapeIntervalSet} + encoded := `{"schema_version":1,"quantity":"voltage","semantic_role":"operating_support","shape":"interval_set","unit":"V","unit_registry_version":"electrical-1","intervals":[{"lower":"3","upper":"3.6"}],"context":{"kind":"unconditional"}}` + if _, err := UnmarshalValue(encoded, profile); err == nil { + t.Fatal("omitted interval flags were accepted as exclusive endpoints") + } +} + +func TestControlledBoundRequiresExplicitInclusionFlag(t *testing.T) { + profile := electricalProfile("voltage", "V") + profile.SemanticRole = RoleMaximumLimit + profile.AllowedShapes = []Shape{ShapeBoundClaim} + encoded := `{"schema_version":1,"quantity":"voltage","semantic_role":"maximum_limit","shape":"bound_claim","unit":"V","unit_registry_version":"electrical-1","bound":{"direction":"upper","value":"5","inclusive":null},"context":{"kind":"unconditional"}}` + if _, err := UnmarshalValue(encoded, profile); err == nil { + t.Fatal("null bound flag was accepted as an exclusive bound") + } +} + +func TestControlledToleranceRejectsContradictoryInterval(t *testing.T) { + profile := electricalProfile("voltage", "V") + profile.SemanticRole = RoleNominal + profile.AllowedShapes = []Shape{ShapeTolerance} + encoded := `{"schema_version":1,"quantity":"voltage","semantic_role":"nominal","shape":"tolerance","unit":"V","unit_registry_version":"electrical-1","tolerance":{"nominal":"-5","type":"relative","amount":"10","interval":{"lower":"-5.5","upper":"-4.5","lower_inclusive":true,"upper_inclusive":true}},"context":{"kind":"unconditional"}}` + if _, err := UnmarshalValue(encoded, profile); err != nil { + t.Fatal(err) + } + if _, err := UnmarshalValue(strings.Replace(encoded, `"lower":"-5.5"`, `"lower":"-6"`, 1), profile); err == nil { + t.Fatal("tolerance's derived interval contradicted its nominal and amount") + } +} + +func TestControlledValuePreservesIntervalGap(t *testing.T) { + profile := electricalProfile("voltage", "V") + profile.AllowedShapes = []Shape{ShapeIntervalSet} + encoded := `{"schema_version":1,"quantity":"voltage","semantic_role":"operating_support","shape":"interval_set","unit":"V","unit_registry_version":"electrical-1","intervals":[{"lower":"3","upper":"3.6","lower_inclusive":true,"upper_inclusive":true},{"lower":"4.5","upper":"5.5","lower_inclusive":false,"upper_inclusive":true}],"context":{"kind":"unconditional"}}` + value, err := UnmarshalValue(encoded, profile) + if err != nil { + t.Fatal(err) + } + got, err := MarshalValue(value) + if err != nil { + t.Fatal(err) + } + if got != encoded { + t.Fatalf("round trip = %s, want %s", got, encoded) + } + if _, err := UnmarshalValue(strings.Replace(encoded, `"upper":"3.6"`, `"upper":"2"`, 1), profile); err == nil { + t.Fatal("reversed interval was accepted") + } +} diff --git a/internal/storage/sqlite/catalog_store.go b/internal/storage/sqlite/catalog_store.go index 7f0d8e1..13d22b6 100644 --- a/internal/storage/sqlite/catalog_store.go +++ b/internal/storage/sqlite/catalog_store.go @@ -407,15 +407,19 @@ func (s *Store) CloneProduct(ctx context.Context, actorID, sourceID string, expe } func cloneSpecValues(ctx context.Context, tx *sql.Tx, sourceID, targetID, categoryID, actorID, now string) error { - rows, err := tx.QueryContext(ctx, `SELECT spec_id,raw_value,source_locale FROM product_spec_values WHERE product_id=?`, sourceID) + rows, err := tx.QueryContext(ctx, `SELECT pv.spec_id,pv.raw_value,pv.source_locale,pv.source_origin_locale,COALESCE(ps.spec_value_id=pv.id,0) + FROM product_spec_values pv LEFT JOIN product_spec_sources ps ON ps.product_id=pv.product_id AND ps.spec_id=pv.spec_id WHERE pv.product_id=?`, sourceID) if err != nil { return err } - type sourceValue struct{ specID, rawValue, sourceLocale string } + type sourceValue struct { + specID, rawValue, sourceLocale, sourceOriginLocale string + selected bool + } var values []sourceValue for rows.Next() { var value sourceValue - if err := rows.Scan(&value.specID, &value.rawValue, &value.sourceLocale); err != nil { + if err := rows.Scan(&value.specID, &value.rawValue, &value.sourceLocale, &value.sourceOriginLocale, &value.selected); err != nil { rows.Close() return err } @@ -436,11 +440,16 @@ func cloneSpecValues(ctx context.Context, tx *sql.Tx, sourceID, targetID, catego return err } if _, err := tx.ExecContext(ctx, `INSERT INTO product_spec_values( - id,product_id,spec_id,raw_value,source_locale,source_revision,active,created_by,updated_by,created_at,updated_at - ) VALUES(?,?,?,?,?,1,?,?,?,?,?)`, id, targetID, value.specID, value.rawValue, value.sourceLocale, + id,product_id,spec_id,raw_value,source_locale,source_origin_locale,source_revision,active,created_by,updated_by,created_at,updated_at + ) VALUES(?,?,?,?,?,?,1,?,?,?,?,?)`, id, targetID, value.specID, value.rawValue, value.sourceLocale, value.sourceOriginLocale, applicable != 0, actorID, actorID, now, now); err != nil { return err } + if value.selected { + if _, err := tx.ExecContext(ctx, `INSERT INTO product_spec_sources(product_id,spec_id,spec_value_id,revision,updated_by,updated_at) VALUES(?,?,?,1,?,?)`, targetID, value.specID, id, actorID, now); err != nil { + return err + } + } } return nil } diff --git a/internal/storage/sqlite/export_store.go b/internal/storage/sqlite/export_store.go index c745564..7c08094 100644 --- a/internal/storage/sqlite/export_store.go +++ b/internal/storage/sqlite/export_store.go @@ -105,7 +105,7 @@ func (s *Store) ForEachProductExport(ctx context.Context, actorID string, if err := rows.Close(); err != nil { return err } - rows, err = tx.QueryContext(ctx, `SELECT v.product_id,v.spec_id,d.name,v.raw_value,v.source_locale,v.source_revision,v.active,v.updated_at + rows, err = tx.QueryContext(ctx, `SELECT v.product_id,v.spec_id,d.name,v.raw_value,v.source_origin_locale,v.source_revision,v.active,v.updated_at FROM product_spec_values v JOIN spec_definitions d ON d.id=v.spec_id ORDER BY v.product_id,d.name,v.spec_id,v.source_locale`) if err != nil { diff --git a/internal/storage/sqlite/export_store_test.go b/internal/storage/sqlite/export_store_test.go new file mode 100644 index 0000000..fd35b96 --- /dev/null +++ b/internal/storage/sqlite/export_store_test.go @@ -0,0 +1,43 @@ +package sqlite + +import ( + "testing" + + "prods/internal/catalog" +) + +func TestProductExportUsesCorrectedSpecSourceLocale(t *testing.T) { + store, owner := installedStore(t) + spec, err := store.CreateSpecDefinition(t.Context(), owner.ID, catalog.SpecDefinition{Name: "Source locale export"}) + if err != nil { + t.Fatal(err) + } + product, err := store.CreateProduct(t.Context(), owner.ID, catalog.Product{PartNumber: "EXPORT-ORIGIN"}) + if err != nil { + t.Fatal(err) + } + _, err = store.SaveSpecValue(t.Context(), owner.ID, product.ID, spec.ID, "original", "en-US", product.Revision) + if err != nil { + t.Fatal(err) + } + updated, err := store.SaveSpecValue(t.Context(), owner.ID, product.ID, spec.ID, "corrected", "zh-TW", product.Revision+1) + if err != nil { + t.Fatal(err) + } + + var got string + err = store.ForEachProductExport(t.Context(), owner.ID, func(ProductExportRow) error { + return nil + }, func(row RawSpecExportRow) error { + if row.ProductID == product.ID && row.SpecID == spec.ID { + got = row.SourceLocale + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if got != updated.SourceLocale { + t.Fatalf("export source locale = %q, corrected Raw origin = %q", got, updated.SourceLocale) + } +} diff --git a/internal/storage/sqlite/import_store.go b/internal/storage/sqlite/import_store.go index d9b7b8d..8508517 100644 --- a/internal/storage/sqlite/import_store.go +++ b/internal/storage/sqlite/import_store.go @@ -294,10 +294,9 @@ func applyMappedValues(product *catalog.Product, values map[importing.Target]str } } for target, field := range map[importing.Target]string{ - importing.TargetNameSourceLocale: "name", - importing.TargetDescriptionLocale: "description", - importing.TargetFeaturesLocale: "features", - importing.TargetSpecificationLocale: "specification", + importing.TargetNameSourceLocale: "name", + importing.TargetDescriptionLocale: "description", + importing.TargetFeaturesLocale: "features", } { value, mapped := values[target] if !mapped || (preserveBlank && strings.TrimSpace(value) == "") { @@ -329,7 +328,13 @@ func loadImportContentMetadata(ctx context.Context, tx *sql.Tx, product *catalog if err := tx.QueryRowContext(ctx, `SELECT source_locale,source_locales_json FROM product_content_metadata WHERE product_id=?`, product.ID).Scan(&product.SourceLocale, &encoded); err != nil { return err } - return json.Unmarshal([]byte(encoded), &product.SourceLocales) + if err := json.Unmarshal([]byte(encoded), &product.SourceLocales); err != nil { + return err + } + // An obsolete key loaded from private metadata is not new caller input + // to the three-field Product preparation contract. + delete(product.SourceLocales, "specification") + return nil } func (s *Store) CommitImport(ctx context.Context, actorID string, preview importing.Preview) (importing.Receipt, error) { diff --git a/internal/storage/sqlite/import_store_test.go b/internal/storage/sqlite/import_store_test.go index 04a3a11..de56d94 100644 --- a/internal/storage/sqlite/import_store_test.go +++ b/internal/storage/sqlite/import_store_test.go @@ -276,6 +276,10 @@ func TestImportD7SourceLocaleAndD10PreserveClearAndDuplicateTargetRules(t *testi if err != nil { t.Fatal(err) } + // Loaded legacy metadata must not become new caller input during import. + if _, err := store.db.Exec(`UPDATE product_content_metadata SET source_locales_json='{"name":"en-US","description":"fr-FR","features":"en-US","specification":"ja-JP"}' WHERE product_id=?`, existing.ID); err != nil { + t.Fatal(err) + } headers := []string{"Part", "Description", "Features", "Specification", "Applications", "Description locale"} mappings := []importing.ColumnMapping{ {SourceIndex: 0, Target: importing.TargetPartNumber}, @@ -333,7 +337,7 @@ func TestImportD7SourceLocaleAndD10PreserveClearAndDuplicateTargetRules(t *testi t.Fatal(err) } createdContent, err := store.ProductContent(ctx, createdPreview.Items[0].Product.ID) - if err != nil || createdContent.SourceLocale != "ja-JP" || createdContent.SourceLocales["specification"] != "ja-JP" { + if err != nil || createdContent.SourceLocale != "ja-JP" || createdContent.SourceLocales["name"] != "ja-JP" || createdContent.SourceLocales["specification"] != "" { t.Fatalf("committed D7 Source Locale=%+v err=%v", createdContent, err) } diff --git a/internal/storage/sqlite/installation_test.go b/internal/storage/sqlite/installation_test.go index 48b58ae..b5babf8 100644 --- a/internal/storage/sqlite/installation_test.go +++ b/internal/storage/sqlite/installation_test.go @@ -28,10 +28,14 @@ func TestInstallationSampleDataCommitsWithOwnerAndReadyMarker(t *testing.T) { OwnerEmail: "owner@example.test", PasswordHash: passwordHash, DefaultLocale: "en-US", SupportedLocales: []string{"en-US"}, TimeZone: "UTC", SampleData: &InstallationSampleData{ - Version: "v1.2.3", DatasetVersion: "a01-r1", SourceURL: "embedded:sample-data/prods-sample-data-v1.json", - SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", - Categories: []catalog.Category{{ID: "cat_sample", ParentID: "cat_root", Name: "Sample", Slug: "sample", SourceLocale: "en-US"}}, - Products: []catalog.Product{{ID: "sample-1", PartNumber: "SAMPLE-1", Name: "Sample product", CategoryID: "cat_sample", Manufacturer: "Example", Status: catalog.Published}}, + Version: "v1.2.3", DatasetVersion: "a01-r1", SourceURL: "embedded:sample-data/prods-sample-data-v1.json", + SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", + Categories: []catalog.Category{{ID: "cat_sample", ParentID: "cat_root", Name: "Sample", Slug: "sample", SourceLocale: "en-US"}}, + Products: []catalog.Product{{ID: "sample-1", PartNumber: "SAMPLE-1", Name: "Sample product", CategoryID: "cat_sample", Manufacturer: "Example", Status: catalog.Published}}, + Specs: []catalog.SpecDefinition{{ID: "spc_sample", Name: "Voltage", Status: catalog.EntryActive, SemanticVer: 1}}, + SpecSets: []catalog.SpecSet{{ID: "sps_sample", Name: "Sample", SpecIDs: []string{"spc_sample"}, Status: catalog.EntryActive}}, + CategorySpecSets: []InstallationCategorySpecSet{{CategoryID: "cat_sample", SpecSetID: "sps_sample"}}, + SpecValues: []catalog.SpecValue{{ID: "spv_sample", ProductID: "sample-1", SpecID: "spc_sample", RawValue: "5V", SourceLocale: "ja-JP"}}, }, }) if err != nil { @@ -58,6 +62,13 @@ func TestInstallationSampleDataCommitsWithOwnerAndReadyMarker(t *testing.T) { if products != 1 || intents != 1 || details["sample_data"] == nil { t.Fatalf("sample installation products=%d intents=%d audit=%v", products, intents, details) } + var raw, origin string + if err := store.db.QueryRowContext(t.Context(), `SELECT pv.raw_value,pv.source_origin_locale FROM product_spec_sources ps JOIN product_spec_values pv ON pv.id=ps.spec_value_id WHERE ps.product_id='sample-1' AND ps.spec_id='spc_sample'`).Scan(&raw, &origin); err != nil { + t.Fatal(err) + } + if raw != "5V" || origin != "ja-JP" { + t.Fatalf("sample shared Raw: %q %q", raw, origin) + } } func TestInstallationSampleDataPreservesArchivedProducts(t *testing.T) { @@ -75,7 +86,7 @@ func TestInstallationSampleDataPreservesArchivedProducts(t *testing.T) { SupportedLocales: []string{"en-US"}, TimeZone: "UTC", SampleData: &InstallationSampleData{ Version: "v1.2.3", DatasetVersion: "a01-r1", - SourceURL: "embedded:sample-data/prods-sample-data-v1.json", + SourceURL: "embedded:sample-data/prods-sample-data-v1.json", SHA256: strings.Repeat("a", 64), Products: []catalog.Product{ {ID: "current", PartNumber: "SAME", Manufacturer: "Example", Status: catalog.Hidden, RecordState: catalog.RecordCurrent}, @@ -109,7 +120,7 @@ func TestInvalidSampleDataRollsBackWholeInstallation(t *testing.T) { OwnerEmail: "owner@example.test", PasswordHash: passwordHash, DefaultLocale: "en-US", SupportedLocales: []string{"en-US"}, TimeZone: "UTC", SampleData: &InstallationSampleData{ - Version: "v1.2.3", DatasetVersion: "a01-r1", SourceURL: "embedded:sample-data/prods-sample-data-v1.json", + Version: "v1.2.3", DatasetVersion: "a01-r1", SourceURL: "embedded:sample-data/prods-sample-data-v1.json", SHA256: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", Products: []catalog.Product{ {ID: "sample-1", PartNumber: "SAME", Manufacturer: "Example"}, diff --git a/internal/storage/sqlite/migrations.go b/internal/storage/sqlite/migrations.go index d184593..5b023fb 100644 --- a/internal/storage/sqlite/migrations.go +++ b/internal/storage/sqlite/migrations.go @@ -13,7 +13,7 @@ import ( const ( MinimumSupportedSchemaVersion = 1 - CurrentSchemaVersion = 18 + CurrentSchemaVersion = 19 ) var ( @@ -479,6 +479,80 @@ CREATE TABLE IF NOT EXISTS product_bulk_items ( ); CREATE INDEX IF NOT EXISTS product_bulk_runs_status_idx ON product_bulk_runs(status,updated_at);`, }, + { + Version: 19, Name: "shared-spec-sources-and-controlled-numeric-selection", Transactional: true, + SQL: `ALTER TABLE product_spec_values ADD COLUMN source_origin_locale TEXT NOT NULL DEFAULT ''; +UPDATE product_spec_values SET source_origin_locale=source_locale; +ALTER TABLE normalized_values ADD COLUMN profile_revision INTEGER NOT NULL DEFAULT 0; +ALTER TABLE normalized_values ADD COLUMN source_origin_locale TEXT NOT NULL DEFAULT ''; +ALTER TABLE normalized_values ADD COLUMN numeric_schema_version INTEGER NOT NULL DEFAULT 0; +CREATE TABLE spec_numeric_profiles ( + spec_id TEXT PRIMARY KEY REFERENCES spec_definitions(id), + spec_semantic_version INTEGER NOT NULL CHECK(spec_semantic_version>=1), + revision INTEGER NOT NULL CHECK(revision>=1), + profile_json TEXT NOT NULL, + updated_by TEXT NOT NULL REFERENCES users(id), + updated_at TEXT NOT NULL +); +CREATE TABLE product_spec_sources ( + product_id TEXT NOT NULL REFERENCES products(id), + spec_id TEXT NOT NULL REFERENCES spec_definitions(id), + spec_value_id TEXT NOT NULL REFERENCES product_spec_values(id), + revision INTEGER NOT NULL CHECK(revision>=1), + updated_by TEXT REFERENCES users(id), + updated_at TEXT NOT NULL, + PRIMARY KEY(product_id,spec_id) +); +INSERT INTO product_spec_sources(product_id,spec_id,spec_value_id,revision,updated_by,updated_at) +SELECT product_id,spec_id,MIN(id),1,NULL,strftime('%Y-%m-%dT%H:%M:%fZ','now') +FROM product_spec_values GROUP BY product_id,spec_id HAVING COUNT(*)=1; +CREATE TABLE spec_normalization_selections ( + spec_value_id TEXT PRIMARY KEY REFERENCES product_spec_values(id), + selected_normalized_value_id TEXT REFERENCES normalized_values(id), + preference TEXT NOT NULL CHECK(preference IN ('automatic','manual')), + revision INTEGER NOT NULL CHECK(revision>=1), + updated_by TEXT NOT NULL REFERENCES users(id), + updated_at TEXT NOT NULL +); +CREATE TABLE spec_numeric_profile_work ( + spec_id TEXT NOT NULL REFERENCES spec_definitions(id), + profile_revision INTEGER NOT NULL CHECK(profile_revision>=1), + spec_semantic_version INTEGER NOT NULL CHECK(spec_semantic_version>=1), + cursor_product_id TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL CHECK(status IN ('pending','completed')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY(spec_id,profile_revision) +); +CREATE INDEX spec_numeric_profile_work_pending_idx ON spec_numeric_profile_work(status,spec_id,profile_revision); +CREATE TABLE public_spec_projection_products ( + product_id TEXT PRIMARY KEY REFERENCES products(id), + public_revision INTEGER NOT NULL, + source_revision INTEGER NOT NULL, + site_epoch INTEGER NOT NULL, + visibility_generation INTEGER NOT NULL, + projection_version TEXT NOT NULL +); +CREATE TABLE public_spec_projection_fields ( + product_id TEXT NOT NULL REFERENCES public_spec_projection_products(product_id) ON DELETE CASCADE, + spec_id TEXT NOT NULL, + PRIMARY KEY(product_id,spec_id) +); +CREATE INDEX public_spec_projection_fields_spec_idx ON public_spec_projection_fields(spec_id,product_id); +INSERT INTO admin_log(id,actor_id,action,target_type,target_id,result,details_json,created_at) +SELECT 'log_'||lower(hex(randomblob(16))),NULL,'product.spec_model_migrated','product',p.id,'success', + json_object('migration',19,'old_revision',p.revision,'new_revision',p.revision+1,'render_contract_version','shared-specs-1'), + strftime('%Y-%m-%dT%H:%M:%fZ','now') +FROM products p WHERE p.record_state='current' AND p.status='published'; +INSERT INTO publication_intents(id,entity_type,entity_id,desired_revision,cause,status,created_at,updated_at) +SELECT 'out_'||lower(hex(randomblob(16))),'product',p.id,p.revision+1,'product.spec_model_migrated','pending', + strftime('%Y-%m-%dT%H:%M:%fZ','now'),strftime('%Y-%m-%dT%H:%M:%fZ','now') +FROM products p WHERE p.record_state='current' AND p.status='published' + AND NOT EXISTS(SELECT 1 FROM publication_intents pi WHERE pi.entity_type='product' AND pi.entity_id=p.id + AND pi.desired_revision=p.revision+1 AND pi.status IN ('pending','processing')); +UPDATE products SET revision=revision+1,updated_by=NULL,updated_at=strftime('%Y-%m-%dT%H:%M:%fZ','now') +WHERE record_state='current' AND status='published';`, + }, } func SupportedSchemaVersion(version int) bool { diff --git a/internal/storage/sqlite/migrations_test.go b/internal/storage/sqlite/migrations_test.go index 3a05d7a..131ab3c 100644 --- a/internal/storage/sqlite/migrations_test.go +++ b/internal/storage/sqlite/migrations_test.go @@ -36,6 +36,7 @@ func TestVersionSeventeenToEighteenAddsDurableProductBulkRuns(t *testing.T) { if err != nil { t.Fatal(err) } + removeNumericSchema(t, store) if _, err := store.db.Exec(` DROP TABLE product_bulk_items; DROP TABLE product_bulk_runs; @@ -96,6 +97,7 @@ func TestVersionOneUpgradeRequiresBackupAndBecomesReady(t *testing.T) { // CreatePOC builds the current schema. Reconstruct the v1 RFQ shape so this // test exercises the complete ordered upgrade rather than merely changing // the version marker under a current schema. + removeNumericSchema(t, store) if _, err := store.db.Exec(` DROP TABLE smtp_delivery_recipients; DROP TABLE smtp_delivery_attempts; @@ -200,6 +202,7 @@ func TestVersionFourUpgradePreservesRFQAndClassifiesLegacyRecipient(t *testing.T t.Fatal(err) } created := "2026-09-14T12:00:00Z" + removeNumericSchema(t, store) if _, err := store.db.Exec(` INSERT INTO rfqs(id,name,email,company,phone,country,general_message,created_at) VALUES('rfq_legacy','Legacy Buyer','legacy@example.test','','','','',?); @@ -337,6 +340,7 @@ func TestVersionSeventeenUpgradeSeedsPinnedLocalesAndPreservesPublishedLocaleSet if err != nil { t.Fatal(err) } + removeNumericSchema(t, store) if _, err := store.db.Exec(` UPDATE site_settings SET default_locale='zh-TW',supported_locales_json='["en-US","zh-TW","fr-FR"]',content_multilingual_enabled=1 WHERE singleton=1; INSERT INTO product_translations(product_id,locale,name,description,features,specification,revision,updated_at) diff --git a/internal/storage/sqlite/product_translation_store.go b/internal/storage/sqlite/product_translation_store.go index c3db4f3..94002b3 100644 --- a/internal/storage/sqlite/product_translation_store.go +++ b/internal/storage/sqlite/product_translation_store.go @@ -87,6 +87,7 @@ func (s *Store) ProductContent(ctx context.Context, productID string) (catalog.P if err := json.Unmarshal([]byte(sourceLocalesJSON), &content.SourceLocales); err != nil { return content, err } + delete(content.SourceLocales, "specification") if len(content.SourceLocales) == 0 { content.SourceLocales, _ = catalog.NormalizeFieldSourceLocales(nil, content.SourceLocale, catalog.ProductTranslatableFields) } @@ -111,7 +112,6 @@ func (s *Store) SaveProductTranslation(ctx context.Context, actorID, productID s item.Name = strings.TrimSpace(item.Name) item.Description = strings.TrimSpace(item.Description) item.Features = strings.TrimSpace(item.Features) - item.Specification = strings.TrimSpace(item.Specification) if actorID == "" || productID == "" || expectedProductRevision < 1 || item.Locale == "" { return catalog.ProductContent{}, catalog.ErrInvalidProduct } @@ -139,10 +139,10 @@ func (s *Store) SaveProductTranslation(ctx context.Context, actorID, productID s return ErrContentLocaleUnavailable } now := time.Now().UTC().Format(time.RFC3339Nano) - _, err := tx.ExecContext(ctx, `INSERT INTO product_translations(product_id,locale,name,description,features,specification,revision,updated_by,updated_at) - VALUES(?,?,?,?,?,?,1,?,?) ON CONFLICT(product_id,locale) DO UPDATE SET name=excluded.name,description=excluded.description, - features=excluded.features,specification=excluded.specification,revision=product_translations.revision+1,updated_by=excluded.updated_by,updated_at=excluded.updated_at`, - productID, item.Locale, item.Name, item.Description, item.Features, item.Specification, actorID, now) + _, err := tx.ExecContext(ctx, `INSERT INTO product_translations(product_id,locale,name,description,features,revision,updated_by,updated_at) + VALUES(?,?,?,?,?,1,?,?) ON CONFLICT(product_id,locale) DO UPDATE SET name=excluded.name,description=excluded.description, + features=excluded.features,revision=product_translations.revision+1,updated_by=excluded.updated_by,updated_at=excluded.updated_at`, + productID, item.Locale, item.Name, item.Description, item.Features, actorID, now) if err != nil { return err } diff --git a/internal/storage/sqlite/product_translation_store_test.go b/internal/storage/sqlite/product_translation_store_test.go index 50d6b46..1bc1366 100644 --- a/internal/storage/sqlite/product_translation_store_test.go +++ b/internal/storage/sqlite/product_translation_store_test.go @@ -70,6 +70,10 @@ func TestProductTranslationsPreserveSourceRevisionAndDisabledLocaleData(t *testi if err != nil { t.Fatal(err) } + // Simulate an existing private translation from the old four-field contract. + if _, err := store.db.Exec(`INSERT INTO product_translations(product_id,locale,name,description,features,specification,revision,updated_by,updated_at) VALUES(?,'zh-TW','','','','Legacy specification',1,?,'2026-01-01T00:00:00Z')`, product.ID, owner.ID); err != nil { + t.Fatal(err) + } content, err := store.SaveProductTranslation(t.Context(), owner.ID, product.ID, product.Revision, catalog.ProductTranslation{Locale: "zh-TW", Name: "翻譯名稱", Description: "翻譯說明"}) if err != nil { t.Fatal(err) @@ -77,6 +81,9 @@ func TestProductTranslationsPreserveSourceRevisionAndDisabledLocaleData(t *testi if content.SourceLocale != "en-US" || content.ProductRevision != product.Revision+1 || len(content.Translations) != 1 || content.Translations[0].Name != "翻譯名稱" { t.Fatalf("content = %+v", content) } + if content.Translations[0].Specification != "Legacy specification" { + t.Fatal("translation save erased private legacy specification") + } clone, err := store.CloneProduct(t.Context(), owner.ID, product.ID, content.ProductRevision, catalog.Product{PartNumber: "ML-2"}) if err != nil { t.Fatal(err) diff --git a/internal/storage/sqlite/publication_store.go b/internal/storage/sqlite/publication_store.go index 8231aa0..55a90ac 100644 --- a/internal/storage/sqlite/publication_store.go +++ b/internal/storage/sqlite/publication_store.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "reflect" "strings" "time" @@ -14,6 +15,7 @@ import ( "prods/internal/localization" "prods/internal/publishing" "prods/internal/site" + "prods/internal/specnumeric" ) func (s *Store) ResetPublicationClaims(ctx context.Context) error { @@ -216,8 +218,9 @@ func (s *Store) PublicationSource(ctx context.Context, productID string) (publis if err := applicableRows.Err(); err != nil { return publishing.Source{}, err } - specRows, err := tx.QueryContext(ctx, `SELECT sd.id,sd.name,pv.raw_value,sd.preferred_unit,pv.source_locale + specRows, err := tx.QueryContext(ctx, `SELECT sd.id,sd.name,pv.raw_value,sd.preferred_unit,pv.source_origin_locale,pv.source_revision FROM product_spec_values pv JOIN spec_definitions sd ON sd.id=pv.spec_id + JOIN product_spec_sources ps ON ps.product_id=pv.product_id AND ps.spec_id=pv.spec_id AND ps.spec_value_id=pv.id WHERE pv.product_id=? AND pv.active=1 AND sd.status='active' ORDER BY sd.name,sd.id,pv.source_locale`, productID) if err != nil { @@ -225,7 +228,7 @@ func (s *Store) PublicationSource(ctx context.Context, productID string) (publis } for specRows.Next() { var spec publishing.SourceSpec - if err := specRows.Scan(&spec.ID, &spec.Name, &spec.RawValue, &spec.PreferredUnit, &spec.Language); err != nil { + if err := specRows.Scan(&spec.ID, &spec.Name, &spec.RawValue, &spec.PreferredUnit, &spec.Language, &spec.SourceRevision); err != nil { specRows.Close() return publishing.Source{}, err } @@ -237,6 +240,9 @@ func (s *Store) PublicationSource(ctx context.Context, productID string) (publis if err := specRows.Err(); err != nil { return publishing.Source{}, err } + if err := loadPublicationNumericSnapshot(ctx, tx, productID, &source); err != nil { + return publishing.Source{}, err + } documentRows, err := tx.QueryContext(ctx, `SELECT pd.id,pd.label,dt.name,COALESCE(pd.asset_id,''), COALESCE(pd.external_url,''),pd.language FROM product_documents pd JOIN dictionary_entries dt ON dt.id=pd.document_type_id @@ -626,6 +632,13 @@ func (s *Store) ActivateSiteRoutes(ctx context.Context, request publishing.SiteR seenRoutes := make(map[string]struct{}, len(activations)) prepared := make([]preparedActivation, 0, len(activations)) for _, activation := range activations { + current, err := publicationNumericSnapshotCurrent(ctx, tx, activation) + if err != nil { + return err + } + if !current { + return publishing.ErrSiteRouteInvalid + } activationSite, err := json.Marshal(activation.View.Site) if err != nil || string(activationSite) != string(normalizedWorking) { return publishing.ErrSiteRouteInvalid @@ -704,6 +717,9 @@ func (s *Store) ActivateSiteRoutes(ctx context.Context, request publishing.SiteR if _, err := tx.ExecContext(ctx, `DELETE FROM public_activations`); err != nil { return err } + if _, err := tx.ExecContext(ctx, `DELETE FROM public_spec_projection_products`); err != nil { + return err + } for _, item := range prepared { activation := item.activation if oldRoute := oldRoutes[activation.ProductID]; oldRoute != "" && oldRoute != activation.Route { @@ -732,6 +748,9 @@ func (s *Store) ActivateSiteRoutes(ctx context.Context, request publishing.SiteR if err := upsertPublicSearchProjection(ctx, tx, activation, generation, now); err != nil { return err } + if err := upsertSpecSearchProjection(ctx, tx, activation); err != nil { + return err + } if _, err := tx.ExecContext(ctx, `UPDATE publication_intents SET status='superseded',updated_at=? WHERE entity_type='product' AND entity_id=? AND desired_revision<=? AND status IN ('pending','processing')`, now, activation.ProductID, activation.SourceRevision); err != nil { return err } @@ -773,9 +792,16 @@ func (s *Store) ActivatePublication(ctx context.Context, intent publishing.Inten if activation.ProductID != intent.ProductID || activation.SourceRevision != revision || activation.PublicRevision != revision || activation.Route == "" { return errors.New("activation does not match current source") } + current, err := publicationNumericSnapshotCurrent(ctx, tx, activation) + if err != nil { + return err + } + if !current { + return nil + } var generation int64 var currentlyVisible int - err := tx.QueryRowContext(ctx, `SELECT generation,visible FROM public_visibility WHERE product_id=?`, intent.ProductID). + err = tx.QueryRowContext(ctx, `SELECT generation,visible FROM public_visibility WHERE product_id=?`, intent.ProductID). Scan(&generation, ¤tlyVisible) if errors.Is(err, sql.ErrNoRows) { generation = 1 @@ -814,6 +840,9 @@ func (s *Store) ActivatePublication(ctx context.Context, intent publishing.Inten if err := upsertPublicSearchProjection(ctx, tx, activation, generation, now); err != nil { return err } + if err := upsertSpecSearchProjection(ctx, tx, activation); err != nil { + return err + } if _, err := tx.ExecContext(ctx, `UPDATE publication_intents SET status='completed',updated_at=? WHERE id=? AND status='processing'`, now, intent.ID); err != nil { return err } @@ -1215,6 +1244,9 @@ func (s *Store) RevokeProduct(ctx context.Context, request publishing.RevokeRequ if _, err := tx.ExecContext(ctx, `DELETE FROM public_search_projection WHERE product_id=?`, request.ProductID); err != nil { return err } + if _, err := tx.ExecContext(ctx, `DELETE FROM public_spec_projection_products WHERE product_id=?`, request.ProductID); err != nil { + return err + } if newRevision != revision { if err := appendAudit(ctx, tx, request.ActorID, cause, "product", request.ProductID, map[string]any{ "from_revision": revision, "to_revision": newRevision, @@ -1318,3 +1350,55 @@ func markPublicationDependenciesDirty(ctx context.Context, tx *sql.Tx, productID } return nil } + +// Read from the same transaction as Product, shared Raw, and the publication revision. +// Missing or untrusted derived data omits the fact without preventing Raw publication. +func loadPublicationNumericSnapshot(ctx context.Context, tx *sql.Tx, productID string, source *publishing.Source) error { + rows, err := tx.QueryContext(ctx, `SELECT sd.id,sd.semantic_version,np.profile_json, + COALESCE(nv.value_json,''),COALESCE(nv.normalizer_version,'') + FROM products p JOIN category_spec_sets cs ON cs.category_id=p.category_id + JOIN spec_set_members sm ON sm.spec_set_id=cs.spec_set_id + JOIN spec_definitions sd ON sd.id=sm.spec_id AND sd.status='active' AND sd.filterable=1 + JOIN spec_numeric_profiles np ON np.spec_id=sd.id AND np.spec_semantic_version=sd.semantic_version + LEFT JOIN product_spec_sources ps ON ps.product_id=p.id AND ps.spec_id=sd.id + LEFT JOIN product_spec_values pv ON pv.id=ps.spec_value_id AND pv.product_id=p.id AND pv.spec_id=sd.id AND pv.active=1 + LEFT JOIN spec_normalization_selections ns ON ns.spec_value_id=pv.id + LEFT JOIN normalized_values nv ON nv.id=ns.selected_normalized_value_id AND nv.spec_value_id=pv.id + AND nv.status='current' AND nv.source_revision=pv.source_revision AND nv.spec_semantic_version=sd.semantic_version + AND nv.profile_revision=np.revision AND nv.numeric_schema_version=? AND nv.source_origin_locale=pv.source_origin_locale + WHERE p.id=? ORDER BY sd.id`, specnumeric.SchemaVersion, productID) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var definition publishing.PublicNumericDefinition + var profileJSON, valueJSON, parserVersion string + if err := rows.Scan(&definition.SpecID, &definition.SpecSemanticVersion, &profileJSON, &valueJSON, &parserVersion); err != nil { + return err + } + if json.Unmarshal([]byte(profileJSON), &definition.Profile) != nil || specnumeric.ValidateProfile(definition.Profile) != nil { + continue + } + source.NumericDefinitions = append(source.NumericDefinitions, definition) + if valueJSON == "" || parserVersion != definition.Profile.ParserVersion { + continue + } + value, err := specnumeric.UnmarshalValue(valueJSON, definition.Profile) + if err != nil { + continue + } + source.NumericFacts = append(source.NumericFacts, publishing.PublicNumericFact{SpecID: definition.SpecID, SpecSemanticVersion: definition.SpecSemanticVersion, Profile: definition.Profile, Value: value}) + } + return rows.Err() +} + +// A profile edit becomes authoritative before its durable fan-out advances the +// Product revision. Recheck that dependency in the admitted activation transaction. +func publicationNumericSnapshotCurrent(ctx context.Context, tx *sql.Tx, activation publishing.ActivePublication) (bool, error) { + var current publishing.Source + if err := loadPublicationNumericSnapshot(ctx, tx, activation.ProductID, ¤t); err != nil { + return false, err + } + return reflect.DeepEqual(current.NumericDefinitions, activation.View.NumericDefinitions) && reflect.DeepEqual(current.NumericFacts, activation.View.NumericFacts), nil +} diff --git a/internal/storage/sqlite/source_locale_update_test.go b/internal/storage/sqlite/source_locale_update_test.go index 828bdb7..03045be 100644 --- a/internal/storage/sqlite/source_locale_update_test.go +++ b/internal/storage/sqlite/source_locale_update_test.go @@ -24,7 +24,7 @@ func TestPerFieldSourceLocaleUpdatesAreRevisionedAndUseWorkingEditingGate(t *tes if err != nil { t.Fatal(err) } - productContent, err := store.SaveProductSourceLocales(t.Context(), owner.ID, product.ID, product.Revision, "de-DE", map[string]string{"name": "de-DE", "description": "fr-FR", "features": "de-DE", "specification": "ja-JP"}) + productContent, err := store.SaveProductSourceLocales(t.Context(), owner.ID, product.ID, product.Revision, "de-DE", map[string]string{"name": "de-DE", "description": "fr-FR", "features": "de-DE"}) if err != nil { t.Fatal(err) } diff --git a/internal/storage/sqlite/spec_document_store.go b/internal/storage/sqlite/spec_document_store.go index 33b37f8..2f124ee 100644 --- a/internal/storage/sqlite/spec_document_store.go +++ b/internal/storage/sqlite/spec_document_store.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "fmt" "time" "prods/internal/catalog" @@ -275,8 +276,21 @@ func (s *Store) SaveSpecValue(ctx context.Context, actorID, productID, specID, r active = applicable != 0 var existingID string var sourceRevision int64 - err := tx.QueryRowContext(ctx, `SELECT id,source_revision FROM product_spec_values - WHERE product_id=? AND spec_id=? AND source_locale=?`, productID, specID, sourceLocale).Scan(&existingID, &sourceRevision) + err := tx.QueryRowContext(ctx, `SELECT pv.id,pv.source_revision FROM product_spec_sources ps + JOIN product_spec_values pv ON pv.id=ps.spec_value_id AND pv.product_id=ps.product_id AND pv.spec_id=ps.spec_id + WHERE ps.product_id=? AND ps.spec_id=?`, productID, specID).Scan(&existingID, &sourceRevision) + if errors.Is(err, sql.ErrNoRows) { + var count int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM product_spec_values WHERE product_id=? AND spec_id=?`, productID, specID).Scan(&count); err != nil { + return err + } + if count > 1 { + return fmt.Errorf("%w: multiple Raw sources require explicit selection", catalog.ErrRevisionConflict) + } + if count == 1 { + err = tx.QueryRowContext(ctx, `SELECT id,source_revision FROM product_spec_values WHERE product_id=? AND spec_id=?`, productID, specID).Scan(&existingID, &sourceRevision) + } + } now := time.Now().UTC().Format(time.RFC3339Nano) if errors.Is(err, sql.ErrNoRows) { existingID, err = randomID("spv") @@ -285,13 +299,13 @@ func (s *Store) SaveSpecValue(ctx context.Context, actorID, productID, specID, r } sourceRevision = 1 _, err = tx.ExecContext(ctx, `INSERT INTO product_spec_values( - id,product_id,spec_id,raw_value,source_locale,source_revision,active,created_by,updated_by,created_at,updated_at - ) VALUES(?,?,?,?,?,?,?,?,?,?,?)`, existingID, productID, specID, rawValue, sourceLocale, sourceRevision, active, + id,product_id,spec_id,raw_value,source_locale,source_origin_locale,source_revision,active,created_by,updated_by,created_at,updated_at + ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`, existingID, productID, specID, rawValue, sourceLocale, sourceLocale, sourceRevision, active, actorID, actorID, now, now) } else if err == nil { sourceRevision++ - _, err = tx.ExecContext(ctx, `UPDATE product_spec_values SET raw_value=?,source_revision=?,active=?,updated_by=?,updated_at=? - WHERE id=?`, rawValue, sourceRevision, active, actorID, now, existingID) + _, err = tx.ExecContext(ctx, `UPDATE product_spec_values SET raw_value=?,source_origin_locale=?,source_revision=?,active=?,updated_by=?,updated_at=? + WHERE id=?`, rawValue, sourceLocale, sourceRevision, active, actorID, now, existingID) if err == nil { _, err = tx.ExecContext(ctx, `UPDATE normalized_values SET status='stale' WHERE spec_value_id=? AND status='current'`, existingID) @@ -301,6 +315,10 @@ func (s *Store) SaveSpecValue(ctx context.Context, actorID, productID, specID, r return err } newProductRevision := expectedProductRevision + 1 + if _, err := tx.ExecContext(ctx, `INSERT INTO product_spec_sources(product_id,spec_id,spec_value_id,revision,updated_by,updated_at) + VALUES(?,?,?,1,?,?) ON CONFLICT(product_id,spec_id) DO NOTHING`, productID, specID, existingID, actorID, now); err != nil { + return err + } result, err := tx.ExecContext(ctx, `UPDATE products SET revision=?,updated_by=?,updated_at=? WHERE id=? AND revision=?`, newProductRevision, actorID, now, productID, expectedProductRevision) if err != nil { @@ -326,7 +344,7 @@ func (s *Store) ProductSpecValues(ctx context.Context, productID string) ([]cata if err := s.db.QueryRowContext(ctx, `SELECT 1 FROM products WHERE id=?`, productID).Scan(&exists); err != nil { return nil, err } - rows, err := s.db.QueryContext(ctx, `SELECT id,product_id,spec_id,raw_value,source_locale,source_revision,active + rows, err := s.db.QueryContext(ctx, `SELECT id,product_id,spec_id,raw_value,source_origin_locale,source_revision,active FROM product_spec_values WHERE product_id=? ORDER BY spec_id,source_locale,id`, productID) if err != nil { return nil, err @@ -348,8 +366,14 @@ func (s *Store) ProductSpecValues(ctx context.Context, productID string) ([]cata return nil, err } for index := range details { + selection := &details[index].SourceSelection + selection.ProductID, selection.SpecID = productID, details[index].Value.SpecID + err := s.db.QueryRowContext(ctx, `SELECT spec_value_id,revision FROM product_spec_sources WHERE product_id=? AND spec_id=?`, productID, selection.SpecID).Scan(&selection.SpecValueID, &selection.Revision) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, err + } normalizedRows, err := s.db.QueryContext(ctx, `SELECT id,spec_value_id,value_json,source_revision,spec_semantic_version, - normalizer_version,source,status,failure_reason + normalizer_version,source,status,failure_reason,profile_revision,source_origin_locale,numeric_schema_version FROM normalized_values WHERE spec_value_id=? ORDER BY created_at DESC,id DESC`, details[index].Value.ID) if err != nil { return nil, err @@ -358,7 +382,7 @@ func (s *Store) ProductSpecValues(ctx context.Context, productID string) ([]cata var normalized catalog.NormalizedValue if err := normalizedRows.Scan(&normalized.ID, &normalized.SpecValueID, &normalized.ValueJSON, &normalized.SourceRevision, &normalized.SpecSemanticVersion, &normalized.NormalizerVersion, - &normalized.Source, &normalized.Status, &normalized.FailureReason); err != nil { + &normalized.Source, &normalized.Status, &normalized.FailureReason, &normalized.ProfileRevision, &normalized.SourceOriginLocale, &normalized.NumericSchemaVersion); err != nil { normalizedRows.Close() return nil, err } @@ -375,6 +399,10 @@ func (s *Store) ProductSpecValues(ctx context.Context, productID string) ([]cata } func (s *Store) SaveNormalizedValue(ctx context.Context, actorID string, normalized catalog.NormalizedValue) (catalog.NormalizedValue, error) { + // Legacy history writes never establish controlled adoption metadata. + normalized.ProfileRevision = 0 + normalized.SourceOriginLocale = "" + normalized.NumericSchemaVersion = 0 if normalized.SpecValueID == "" || (normalized.Source != catalog.NormalizedAutomatic && normalized.Source != catalog.NormalizedManual) || (normalized.Status != catalog.NormalizedCurrent && normalized.Status != catalog.NormalizedFailed) || normalized.NormalizerVersion == "" { return catalog.NormalizedValue{}, catalog.ErrInvalidSpec diff --git a/internal/storage/sqlite/spec_numeric_store.go b/internal/storage/sqlite/spec_numeric_store.go new file mode 100644 index 0000000..b7a588c --- /dev/null +++ b/internal/storage/sqlite/spec_numeric_store.go @@ -0,0 +1,362 @@ +package sqlite + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "prods/internal/catalog" + "prods/internal/identity" + "prods/internal/specnumeric" +) + +func (s *Store) ConfigureSpecNumericProfile(ctx context.Context, actorID, specID string, expectedRevision int64, profile specnumeric.Profile) (catalog.SpecNumericProfile, error) { + if err := specnumeric.ValidateProfile(profile); err != nil { + return catalog.SpecNumericProfile{}, fmt.Errorf("%w: %v", catalog.ErrInvalidSpec, err) + } + encoded, err := json.Marshal(profile) + if err != nil { + return catalog.SpecNumericProfile{}, err + } + var result catalog.SpecNumericProfile + err = s.withWriteTx(ctx, func(tx *sql.Tx) error { + if err := requireActorCapability(ctx, tx, actorID, identity.CapabilityCatalogEdit); err != nil { + return err + } + var revision, semantic int64 + if err := tx.QueryRowContext(ctx, `SELECT revision,semantic_version FROM spec_definitions WHERE id=?`, specID).Scan(&revision, &semantic); err != nil { + return err + } + if revision != expectedRevision { + return catalog.ErrRevisionConflict + } + var previousJSON string + var profileRevision int64 + err := tx.QueryRowContext(ctx, `SELECT profile_json,revision FROM spec_numeric_profiles WHERE spec_id=?`, specID).Scan(&previousJSON, &profileRevision) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + if err == nil { + var previous specnumeric.Profile + if err := json.Unmarshal([]byte(previousJSON), &previous); err != nil { + return err + } + if previous.Quantity != profile.Quantity || previous.SemanticRole != profile.SemanticRole { + return catalog.ErrInvalidSpec + } + } + semantic++ + profileRevision++ + now := time.Now().UTC().Format(time.RFC3339Nano) + if _, err := tx.ExecContext(ctx, `INSERT INTO spec_numeric_profiles(spec_id,spec_semantic_version,revision,profile_json,updated_by,updated_at) + VALUES(?,?,?,?,?,?) ON CONFLICT(spec_id) DO UPDATE SET spec_semantic_version=excluded.spec_semantic_version, + revision=excluded.revision,profile_json=excluded.profile_json,updated_by=excluded.updated_by,updated_at=excluded.updated_at`, specID, semantic, profileRevision, string(encoded), actorID, now); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `UPDATE spec_definitions SET semantic_version=?,revision=revision+1,updated_by=?,updated_at=? WHERE id=? AND revision=?`, semantic, actorID, now, specID, expectedRevision); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO spec_numeric_profile_work(spec_id,profile_revision,spec_semantic_version,status,created_at,updated_at) VALUES(?,?,?,'pending',?,?)`, specID, profileRevision, semantic, now, now); err != nil { + return err + } + if err := appendAudit(ctx, tx, actorID, "spec.numeric_profile_configured", "spec", specID, map[string]any{"profile_revision": profileRevision, "spec_semantic_version": semantic}); err != nil { + return err + } + result = catalog.SpecNumericProfile{SpecID: specID, SpecSemanticVersion: semantic, Revision: profileRevision, Profile: profile} + return nil + }) + if err != nil { + return catalog.SpecNumericProfile{}, err + } + return result, nil +} + +// One statement freezes Raw, profile and all expectations in one read snapshot. +// Neither legacy normalized history nor display units infer a selection/profile. +func (s *Store) SpecNormalizationContext(ctx context.Context, specValueID string) (catalog.SpecNormalizationContext, error) { + var result catalog.SpecNormalizationContext + var encoded string + err := s.db.QueryRowContext(ctx, `SELECT pv.id,pv.product_id,pv.spec_id,pv.raw_value,pv.source_origin_locale, + p.revision,pv.source_revision,sd.semantic_version,np.revision,np.profile_json, + COALESCE(ns.selected_normalized_value_id,''),COALESCE(ns.preference,''),COALESCE(ns.revision,0), + COALESCE(ps.spec_value_id,''),COALESCE(ps.revision,0) + FROM product_spec_values pv JOIN products p ON p.id=pv.product_id JOIN spec_definitions sd ON sd.id=pv.spec_id + JOIN spec_numeric_profiles np ON np.spec_id=pv.spec_id + LEFT JOIN spec_normalization_selections ns ON ns.spec_value_id=pv.id + LEFT JOIN product_spec_sources ps ON ps.product_id=pv.product_id AND ps.spec_id=pv.spec_id + WHERE pv.id=?`, specValueID).Scan(&result.SpecValueID, &result.ProductID, &result.SpecID, &result.Raw, &result.SourceLocale, + &result.Expected.ProductRevision, &result.Expected.SourceRevision, &result.Expected.SpecSemanticVersion, + &result.Expected.ProfileRevision, &encoded, &result.Selection.SelectedNormalizedValueID, &result.Selection.Preference, + &result.Expected.SelectionRevision, &result.SourceSelection.SpecValueID, &result.SourceSelection.Revision) + if err != nil { + return catalog.SpecNormalizationContext{}, err + } + if err := json.Unmarshal([]byte(encoded), &result.Profile); err != nil { + return catalog.SpecNormalizationContext{}, err + } + if err := specnumeric.ValidateProfile(result.Profile); err != nil { + return catalog.SpecNormalizationContext{}, err + } + result.Selection.SpecValueID = result.SpecValueID + result.Selection.Revision = result.Expected.SelectionRevision + result.SourceSelection.ProductID, result.SourceSelection.SpecID = result.ProductID, result.SpecID + return result, nil +} + +// Parsing is deliberately completed before acquiring writer admission. The +// frozen source and every expectation are checked again in the write transaction. +func (s *Store) RecordSpecNormalization(ctx context.Context, actorID string, command catalog.NormalizeSpecCommand, result specnumeric.Result) (catalog.NormalizationOutcome, error) { + c, err := s.SpecNormalizationContext(ctx, command.SpecValueID) + if err != nil { + return catalog.NormalizationOutcome{}, err + } + if c.Expected != command.Expected { + return catalog.NormalizationOutcome{}, catalog.ErrRevisionConflict + } + encoded := "" + // Result is produced by the server's named parser on the frozen Context; + // it is not part of the HTTP command DTO. Validate its controlled schema. + if result.Candidate != nil { + if err := specnumeric.ValidateValue(*result.Candidate, c.Profile); err != nil { + return catalog.NormalizationOutcome{}, fmt.Errorf("%w: %v", catalog.ErrInvalidSpec, err) + } + encoded, err = specnumeric.MarshalValue(*result.Candidate) + if err != nil { + return catalog.NormalizationOutcome{}, err + } + } + return s.recordControlledNormalization(ctx, actorID, c, command.Expected, encoded, string(result.Reason), catalog.NormalizedAutomatic, false) +} + +func (s *Store) AdoptManualSpecValue(ctx context.Context, actorID string, command catalog.AdoptSpecCommand) (catalog.NormalizationOutcome, error) { + c, err := s.SpecNormalizationContext(ctx, command.SpecValueID) + if err != nil { + return catalog.NormalizationOutcome{}, err + } + if c.Expected != command.Expected { + return catalog.NormalizationOutcome{}, catalog.ErrRevisionConflict + } + value, err := specnumeric.UnmarshalValue(command.ValueJSON, c.Profile) + if err != nil { + return catalog.NormalizationOutcome{}, fmt.Errorf("%w: %v", catalog.ErrInvalidSpec, err) + } + encoded, err := specnumeric.MarshalValue(value) + if err != nil { + return catalog.NormalizationOutcome{}, err + } + return s.recordControlledNormalization(ctx, actorID, c, command.Expected, encoded, "", catalog.NormalizedManual, true) +} + +func checkNormalizationContextTx(ctx context.Context, tx *sql.Tx, actorID string, frozen catalog.SpecNormalizationContext, expected catalog.NormalizationExpectation) error { + if err := requireActorCapability(ctx, tx, actorID, identity.CapabilityCatalogEdit); err != nil { + return err + } + var actual catalog.NormalizationExpectation + var raw, origin, shared string + var state catalog.RecordState + var applicable bool + err := tx.QueryRowContext(ctx, `SELECT p.revision,pv.source_revision,sd.semantic_version,np.revision, + COALESCE(ns.revision,0),pv.raw_value,pv.source_origin_locale,COALESCE(ps.spec_value_id,''),p.record_state, + pv.active AND sd.status='active' AND EXISTS(SELECT 1 FROM category_spec_sets cs + JOIN spec_set_members sm ON sm.spec_set_id=cs.spec_set_id WHERE cs.category_id=p.category_id AND sm.spec_id=pv.spec_id) + FROM product_spec_values pv JOIN products p ON p.id=pv.product_id + JOIN spec_definitions sd ON sd.id=pv.spec_id JOIN spec_numeric_profiles np ON np.spec_id=pv.spec_id + LEFT JOIN spec_normalization_selections ns ON ns.spec_value_id=pv.id + LEFT JOIN product_spec_sources ps ON ps.product_id=pv.product_id AND ps.spec_id=pv.spec_id + WHERE pv.id=? AND pv.product_id=? AND pv.spec_id=?`, frozen.SpecValueID, frozen.ProductID, frozen.SpecID).Scan( + &actual.ProductRevision, &actual.SourceRevision, &actual.SpecSemanticVersion, &actual.ProfileRevision, &actual.SelectionRevision, + &raw, &origin, &shared, &state, &applicable) + if err != nil { + return err + } + if state != catalog.RecordCurrent { + return catalog.ErrArchivedProduct + } + if actual != expected || actual != frozen.Expected || raw != frozen.Raw || origin != frozen.SourceLocale { + return catalog.ErrRevisionConflict + } + if !applicable { + return catalog.ErrInvalidSpec + } + if shared != frozen.SpecValueID { + return fmt.Errorf("%w: select the shared Raw source before normalization", catalog.ErrRevisionConflict) + } + return nil +} + +func (s *Store) recordControlledNormalization(ctx context.Context, actorID string, frozen catalog.SpecNormalizationContext, expected catalog.NormalizationExpectation, encoded, reason string, source catalog.NormalizedSource, explicit bool) (catalog.NormalizationOutcome, error) { + id, err := randomID("nrm") + if err != nil { + return catalog.NormalizationOutcome{}, err + } + var outcome catalog.NormalizationOutcome + err = s.withWriteTx(ctx, func(tx *sql.Tx) error { + if err := checkNormalizationContextTx(ctx, tx, actorID, frozen, expected); err != nil { + return err + } + status := catalog.NormalizedCurrent + state := "candidate" + if reason != "" || encoded == "" { + status = catalog.NormalizedFailed + state = "failed" + } + now := time.Now().UTC().Format(time.RFC3339Nano) + if source == catalog.NormalizedAutomatic { + var previous string + err := tx.QueryRowContext(ctx, `SELECT id FROM normalized_values WHERE spec_value_id=? AND value_json=? AND source_revision=? + AND spec_semantic_version=? AND profile_revision=? AND source_origin_locale=? AND numeric_schema_version=? + AND normalizer_version=? AND source='automatic' AND status=? AND failure_reason=? ORDER BY created_at DESC,id DESC LIMIT 1`, + frozen.SpecValueID, encoded, expected.SourceRevision, expected.SpecSemanticVersion, expected.ProfileRevision, frozen.SourceLocale, specnumeric.SchemaVersion, frozen.Profile.ParserVersion, status, reason).Scan(&previous) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + if err == nil { + outcome = catalog.NormalizationOutcome{State: state, ReasonCode: reason, NormalizedValueID: previous, ProductRevision: expected.ProductRevision, SelectionRevision: expected.SelectionRevision} + if status == catalog.NormalizedCurrent && frozen.Selection.Preference != catalog.NormalizedManual { + if frozen.Selection.SelectedNormalizedValueID == previous { + outcome.State = "recorded" + return nil + } + return adoptNormalizationTx(ctx, tx, actorID, frozen, previous, source, "product.spec_normalized", now, &outcome) + } + return nil + } + } + if _, err := tx.ExecContext(ctx, `INSERT INTO normalized_values(id,spec_value_id,value_json,source_revision,spec_semantic_version, + normalizer_version,source,status,failure_reason,created_by,created_at,profile_revision,source_origin_locale,numeric_schema_version) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, id, frozen.SpecValueID, encoded, expected.SourceRevision, expected.SpecSemanticVersion, + frozen.Profile.ParserVersion, source, status, reason, actorID, now, expected.ProfileRevision, frozen.SourceLocale, specnumeric.SchemaVersion); err != nil { + return err + } + outcome = catalog.NormalizationOutcome{State: state, ReasonCode: reason, NormalizedValueID: id, ProductRevision: expected.ProductRevision, SelectionRevision: expected.SelectionRevision} + if status == catalog.NormalizedCurrent && (explicit || frozen.Selection.Preference != catalog.NormalizedManual) { + action := "product.spec_normalized" + if source == catalog.NormalizedManual { + action = "product.spec_manual_adopted" + } + return adoptNormalizationTx(ctx, tx, actorID, frozen, id, source, action, now, &outcome) + } + return appendAudit(ctx, tx, actorID, "product.spec_normalization_recorded", "product", frozen.ProductID, map[string]any{"spec_value_id": frozen.SpecValueID, "normalized_value_id": id, "state": state, "reason": reason}) + }) + if err != nil { + return catalog.NormalizationOutcome{}, err + } + return outcome, nil +} + +func adoptNormalizationTx(ctx context.Context, tx *sql.Tx, actorID string, frozen catalog.SpecNormalizationContext, id string, preference catalog.NormalizedSource, action, now string, outcome *catalog.NormalizationOutcome) error { + if _, err := tx.ExecContext(ctx, `UPDATE normalized_values SET status='stale' WHERE spec_value_id=? AND id<>? AND status='current'`, frozen.SpecValueID, id); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `INSERT INTO spec_normalization_selections(spec_value_id,selected_normalized_value_id,preference,revision,updated_by,updated_at) + VALUES(?,?,?,?,?,?) ON CONFLICT(spec_value_id) DO UPDATE SET selected_normalized_value_id=excluded.selected_normalized_value_id, + preference=excluded.preference,revision=excluded.revision,updated_by=excluded.updated_by,updated_at=excluded.updated_at`, frozen.SpecValueID, id, preference, frozen.Expected.SelectionRevision+1, actorID, now); err != nil { + return err + } + result, err := tx.ExecContext(ctx, `UPDATE products SET revision=revision+1,updated_by=?,updated_at=? WHERE id=? AND revision=?`, actorID, now, frozen.ProductID, frozen.Expected.ProductRevision) + if err != nil { + return err + } + if changed, err := result.RowsAffected(); err != nil { + return err + } else if changed != 1 { + return catalog.ErrRevisionConflict + } + outcome.State = "adopted" + outcome.ProductRevision = frozen.Expected.ProductRevision + 1 + outcome.SelectionRevision = frozen.Expected.SelectionRevision + 1 + outcome.PublicationPending = true + if err := appendAudit(ctx, tx, actorID, action, "product", frozen.ProductID, map[string]any{"spec_value_id": frozen.SpecValueID, "normalized_value_id": id, "selection_revision": outcome.SelectionRevision, "revision": outcome.ProductRevision}); err != nil { + return err + } + return appendPublicationIntent(ctx, tx, frozen.ProductID, outcome.ProductRevision, action, now) +} + +func (s *Store) SelectAutomaticSpecValue(ctx context.Context, actorID string, command catalog.SelectAutomaticSpecCommand) (catalog.NormalizationOutcome, error) { + c, err := s.SpecNormalizationContext(ctx, command.SpecValueID) + if err != nil { + return catalog.NormalizationOutcome{}, err + } + var encoded, origin, parser, status, source string + var sourceRevision, semantic, profileRevision int64 + var schema int + err = s.db.QueryRowContext(ctx, `SELECT value_json,source_revision,spec_semantic_version,profile_revision,source_origin_locale,numeric_schema_version,normalizer_version,status,source + FROM normalized_values WHERE id=? AND spec_value_id=?`, command.CandidateID, command.SpecValueID).Scan(&encoded, &sourceRevision, &semantic, &profileRevision, &origin, &schema, &parser, &status, &source) + if err != nil { + return catalog.NormalizationOutcome{}, err + } + if sourceRevision != c.Expected.SourceRevision || semantic != c.Expected.SpecSemanticVersion || profileRevision != c.Expected.ProfileRevision || origin != c.SourceLocale || schema != specnumeric.SchemaVersion || parser != c.Profile.ParserVersion || status != string(catalog.NormalizedCurrent) || source != string(catalog.NormalizedAutomatic) { + return catalog.NormalizationOutcome{}, catalog.ErrRevisionConflict + } + if _, err := specnumeric.UnmarshalValue(encoded, c.Profile); err != nil { + return catalog.NormalizationOutcome{}, fmt.Errorf("%w: %v", catalog.ErrInvalidSpec, err) + } + var outcome catalog.NormalizationOutcome + err = s.withWriteTx(ctx, func(tx *sql.Tx) error { + if err := checkNormalizationContextTx(ctx, tx, actorID, c, command.Expected); err != nil { + return err + } + var current int + if err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM normalized_values WHERE id=? AND spec_value_id=? AND value_json=? AND source_revision=? AND spec_semantic_version=? AND profile_revision=? AND source_origin_locale=? AND numeric_schema_version=? AND normalizer_version=? AND status='current' AND source='automatic')`, command.CandidateID, c.SpecValueID, encoded, sourceRevision, semantic, profileRevision, origin, schema, parser).Scan(¤t); err != nil { + return err + } + if current != 1 { + return catalog.ErrRevisionConflict + } + outcome.NormalizedValueID = command.CandidateID + return adoptNormalizationTx(ctx, tx, actorID, c, command.CandidateID, catalog.NormalizedAutomatic, "product.spec_automatic_selected", time.Now().UTC().Format(time.RFC3339Nano), &outcome) + }) + if err != nil { + return catalog.NormalizationOutcome{}, err + } + return outcome, nil +} + +// Source identity is independent of numeric profiles and source locale. +func (s *Store) SelectSpecSource(ctx context.Context, actorID string, command catalog.SelectSpecSourceCommand) (catalog.SpecSourceSelection, error) { + var selected catalog.SpecSourceSelection + err := s.withWriteTx(ctx, func(tx *sql.Tx) error { + if err := requireActorCapability(ctx, tx, actorID, identity.CapabilityCatalogEdit); err != nil { + return err + } + var productRevision, sourceRevision, selectionRevision int64 + var state catalog.RecordState + var applicable bool + if err := tx.QueryRowContext(ctx, `SELECT p.revision,p.record_state,pv.source_revision,COALESCE(ps.revision,0), + pv.active AND sd.status='active' AND EXISTS(SELECT 1 FROM category_spec_sets cs JOIN spec_set_members sm ON sm.spec_set_id=cs.spec_set_id WHERE cs.category_id=p.category_id AND sm.spec_id=pv.spec_id) + FROM product_spec_values pv JOIN products p ON p.id=pv.product_id JOIN spec_definitions sd ON sd.id=pv.spec_id + LEFT JOIN product_spec_sources ps ON ps.product_id=pv.product_id AND ps.spec_id=pv.spec_id + WHERE pv.id=? AND pv.product_id=? AND pv.spec_id=?`, command.SpecValueID, command.ProductID, command.SpecID).Scan(&productRevision, &state, &sourceRevision, &selectionRevision, &applicable); err != nil { + return err + } + if state != catalog.RecordCurrent { + return catalog.ErrArchivedProduct + } + if productRevision != command.ExpectedProductRevision || sourceRevision != command.ExpectedSourceRevision || selectionRevision != command.ExpectedSourceSelectionRevision { + return catalog.ErrRevisionConflict + } + if !applicable { + return catalog.ErrInvalidSpec + } + now := time.Now().UTC().Format(time.RFC3339Nano) + selected = catalog.SpecSourceSelection{ProductID: command.ProductID, SpecID: command.SpecID, SpecValueID: command.SpecValueID, Revision: selectionRevision + 1} + if _, err := tx.ExecContext(ctx, `INSERT INTO product_spec_sources(product_id,spec_id,spec_value_id,revision,updated_by,updated_at) VALUES(?,?,?,?,?,?) + ON CONFLICT(product_id,spec_id) DO UPDATE SET spec_value_id=excluded.spec_value_id,revision=excluded.revision,updated_by=excluded.updated_by,updated_at=excluded.updated_at`, selected.ProductID, selected.SpecID, selected.SpecValueID, selected.Revision, actorID, now); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `UPDATE products SET revision=revision+1,updated_by=?,updated_at=? WHERE id=? AND revision=?`, actorID, now, selected.ProductID, productRevision); err != nil { + return err + } + if err := appendAudit(ctx, tx, actorID, "product.spec_source_selected", "product", selected.ProductID, map[string]any{"spec_id": selected.SpecID, "spec_value_id": selected.SpecValueID, "source_selection_revision": selected.Revision, "revision": productRevision + 1}); err != nil { + return err + } + return appendPublicationIntent(ctx, tx, selected.ProductID, productRevision+1, "product.spec_source_selected", now) + }) + if err != nil { + return catalog.SpecSourceSelection{}, err + } + return selected, nil +} diff --git a/internal/storage/sqlite/spec_numeric_store_test.go b/internal/storage/sqlite/spec_numeric_store_test.go new file mode 100644 index 0000000..744375c --- /dev/null +++ b/internal/storage/sqlite/spec_numeric_store_test.go @@ -0,0 +1,574 @@ +package sqlite + +import ( + "context" + "errors" + "path/filepath" + "prods/internal/specnumeric" + "testing" + + "prods/internal/catalog" +) + +type normalizationFixture struct { + store *Store + actor string + value catalog.SpecValue +} + +func newNormalizationFixture(t *testing.T) normalizationFixture { + t.Helper() + s, owner := installedStore(t) + ctx := t.Context() + category, err := s.CreateCategory(ctx, owner.ID, catalog.Category{Name: "Numeric", Slug: "numeric"}) + if err != nil { + t.Fatal(err) + } + spec, err := s.CreateSpecDefinition(ctx, owner.ID, catalog.SpecDefinition{Name: "Voltage", Filterable: true}) + if err != nil { + t.Fatal(err) + } + set, err := s.CreateSpecSet(ctx, owner.ID, catalog.SpecSet{Name: "Numeric", SpecIDs: []string{spec.ID}}) + if err != nil { + t.Fatal(err) + } + if err := s.SetCategorySpecSet(ctx, owner.ID, category.ID, set.ID, category.Revision); err != nil { + t.Fatal(err) + } + if _, err := s.ConfigureSpecNumericProfile(ctx, owner.ID, spec.ID, spec.Revision, numericTestProfile()); err != nil { + t.Fatal(err) + } + product, err := s.CreateProduct(ctx, owner.ID, catalog.Product{PartNumber: "NORMALIZATION", CategoryID: category.ID}) + if err != nil { + t.Fatal(err) + } + value, err := s.SaveSpecValue(ctx, owner.ID, product.ID, spec.ID, "3–5V", "en-US", product.Revision) + if err != nil { + t.Fatal(err) + } + return normalizationFixture{s, owner.ID, value} +} + +func (f normalizationFixture) snapshot(t *testing.T) catalog.SpecNormalizationContext { + t.Helper() + c, err := f.store.SpecNormalizationContext(t.Context(), f.value.ID) + if err != nil { + t.Fatal(err) + } + if c.SourceLocale == "" { + t.Fatal("missing authoritative Raw origin") + } + return c +} + +func (f normalizationFixture) adopt(t *testing.T) catalog.NormalizationOutcome { + t.Helper() + c := f.snapshot(t) + parsed := specnumeric.Normalize(c.Raw, c.Profile) + if parsed.Candidate == nil || parsed.Reason != "" { + t.Fatalf("fixture parse: %+v", parsed) + } + encoded, err := specnumeric.MarshalValue(*parsed.Candidate) + if err != nil { + t.Fatal(err) + } + out, err := f.store.AdoptManualSpecValue(t.Context(), f.actor, catalog.AdoptSpecCommand{SpecValueID: c.SpecValueID, Expected: c.Expected, ValueJSON: encoded}) + if err != nil { + t.Fatal(err) + } + return out +} + +func TestStaleManualPreferencePreventsAutomaticAdoption(t *testing.T) { + f := newNormalizationFixture(t) + manual := f.adopt(t) + c := f.snapshot(t) + if _, err := f.store.SaveSpecValue(t.Context(), f.actor, c.ProductID, c.SpecID, "4–6V", "zh-TW", c.Expected.ProductRevision); err != nil { + t.Fatal(err) + } + c = f.snapshot(t) + out, err := f.store.RecordSpecNormalization(t.Context(), f.actor, catalog.NormalizeSpecCommand{SpecValueID: c.SpecValueID, Expected: c.Expected}, specnumeric.Normalize(c.Raw, c.Profile)) + if err != nil { + t.Fatal(err) + } + if out.State != "candidate" || out.PublicationPending || out.ProductRevision != c.Expected.ProductRevision { + t.Fatalf("candidate outcome: %+v", out) + } + selection := f.snapshot(t).Selection + if selection.Preference != catalog.NormalizedManual || selection.SelectedNormalizedValueID != manual.NormalizedValueID || selection.Revision != c.Expected.SelectionRevision { + t.Fatalf("manual replaced: %+v", selection) + } + var status string + if err := f.store.db.QueryRow(`SELECT status FROM normalized_values WHERE id=?`, manual.NormalizedValueID).Scan(&status); err != nil { + t.Fatal(err) + } + if status != "stale" { + t.Fatalf("manual status=%s", status) + } +} + +func TestExplicitAutomaticSelectionAdoptsCurrentCandidate(t *testing.T) { + f := newNormalizationFixture(t) + f.adopt(t) + c := f.snapshot(t) + candidate, err := f.store.RecordSpecNormalization(t.Context(), f.actor, catalog.NormalizeSpecCommand{SpecValueID: c.SpecValueID, Expected: c.Expected}, specnumeric.Normalize(c.Raw, c.Profile)) + if err != nil { + t.Fatal(err) + } + var manualStatus string + if err := f.store.db.QueryRow(`SELECT status FROM normalized_values WHERE id=?`, c.Selection.SelectedNormalizedValueID).Scan(&manualStatus); err != nil { + t.Fatal(err) + } + if manualStatus != "current" { + t.Fatalf("candidate staled unchanged manual: %s", manualStatus) + } + repeated, err := f.store.RecordSpecNormalization(t.Context(), f.actor, catalog.NormalizeSpecCommand{SpecValueID: c.SpecValueID, Expected: c.Expected}, specnumeric.Normalize(c.Raw, c.Profile)) + if err != nil { + t.Fatal(err) + } + if repeated.NormalizedValueID != candidate.NormalizedValueID || repeated.ProductRevision != candidate.ProductRevision { + t.Fatalf("identical candidate rerun churned: %+v", repeated) + } + out, err := f.store.SelectAutomaticSpecValue(t.Context(), f.actor, catalog.SelectAutomaticSpecCommand{SpecValueID: c.SpecValueID, Expected: c.Expected, CandidateID: candidate.NormalizedValueID}) + if err != nil { + t.Fatal(err) + } + if out.State != "adopted" || !out.PublicationPending || out.ProductRevision != c.Expected.ProductRevision+1 || out.SelectionRevision != c.Expected.SelectionRevision+1 { + t.Fatalf("adoption: %+v", out) + } + selection := f.snapshot(t).Selection + if selection.Preference != catalog.NormalizedAutomatic || selection.SelectedNormalizedValueID != candidate.NormalizedValueID { + t.Fatalf("selection: %+v", selection) + } + var audits, intents int + if err := f.store.db.QueryRow(`SELECT COUNT(*) FROM admin_log WHERE action='product.spec_automatic_selected' AND target_id=?`, c.ProductID).Scan(&audits); err != nil { + t.Fatal(err) + } + if err := f.store.db.QueryRow(`SELECT COUNT(*) FROM publication_intents WHERE entity_type='product' AND entity_id=? AND desired_revision=?`, c.ProductID, out.ProductRevision).Scan(&intents); err != nil { + t.Fatal(err) + } + if audits != 1 || intents != 1 { + t.Fatalf("durable evidence: audits=%d intents=%d", audits, intents) + } + history, err := f.store.ProductSpecValues(t.Context(), c.ProductID) + if err != nil { + t.Fatal(err) + } + found := false + for _, detail := range history { + for _, normalized := range detail.Normalized { + if normalized.ID == candidate.NormalizedValueID { + found = true + if normalized.ProfileRevision != c.Expected.ProfileRevision || normalized.SourceOriginLocale != c.SourceLocale || normalized.NumericSchemaVersion != specnumeric.SchemaVersion { + t.Fatalf("controlled history metadata: %+v", normalized) + } + } + } + } + if !found { + t.Fatal("adopted candidate missing from history") + } + current := f.snapshot(t) + replayed, err := f.store.RecordSpecNormalization(t.Context(), f.actor, catalog.NormalizeSpecCommand{SpecValueID: current.SpecValueID, Expected: current.Expected}, specnumeric.Normalize(current.Raw, current.Profile)) + if err != nil { + t.Fatal(err) + } + if replayed.State != "recorded" || replayed.NormalizedValueID != candidate.NormalizedValueID || replayed.ProductRevision != out.ProductRevision || replayed.SelectionRevision != out.SelectionRevision || replayed.PublicationPending { + t.Fatalf("identical adopted rerun churned: %+v", replayed) + } +} + +func TestManualSelectionRejectsStaleSelectionRevision(t *testing.T) { + f := newNormalizationFixture(t) + f.adopt(t) + c := f.snapshot(t) + var encoded string + if err := f.store.db.QueryRow(`SELECT value_json FROM normalized_values WHERE id=?`, c.Selection.SelectedNormalizedValueID).Scan(&encoded); err != nil { + t.Fatal(err) + } + if _, err := f.store.db.Exec(`UPDATE spec_normalization_selections SET revision=revision+1 WHERE spec_value_id=?`, c.SpecValueID); err != nil { + t.Fatal(err) + } + _, err := f.store.AdoptManualSpecValue(t.Context(), f.actor, catalog.AdoptSpecCommand{SpecValueID: c.SpecValueID, Expected: c.Expected, ValueJSON: encoded}) + if !errors.Is(err, catalog.ErrRevisionConflict) { + t.Fatalf("selection CAS: %v", err) + } +} + +func TestManualAdoptionRejectsChangedProfileBeforeDecode(t *testing.T) { + f := newNormalizationFixture(t) + c := f.snapshot(t) + parsed := specnumeric.Normalize(c.Raw, c.Profile) + if parsed.Candidate == nil || parsed.Reason != "" { + t.Fatalf("fixture parse: %+v", parsed) + } + encoded, err := specnumeric.MarshalValue(*parsed.Candidate) + if err != nil { + t.Fatal(err) + } + command := catalog.AdoptSpecCommand{SpecValueID: c.SpecValueID, Expected: c.Expected, ValueJSON: encoded} + var specRevision int64 + if err := f.store.db.QueryRow(`SELECT revision FROM spec_definitions WHERE id=?`, c.SpecID).Scan(&specRevision); err != nil { + t.Fatal(err) + } + profile := c.Profile + profile.AllowedShapes = []specnumeric.Shape{specnumeric.ShapeScalar} + if _, err := f.store.ConfigureSpecNumericProfile(t.Context(), f.actor, c.SpecID, specRevision, profile); err != nil { + t.Fatal(err) + } + if _, err := f.store.AdoptManualSpecValue(t.Context(), f.actor, command); !errors.Is(err, catalog.ErrRevisionConflict) { + t.Fatalf("stale manual profile: %v", err) + } + var history, selection int + if err := f.store.db.QueryRow(`SELECT COUNT(*) FROM normalized_values WHERE spec_value_id=?`, c.SpecValueID).Scan(&history); err != nil { + t.Fatal(err) + } + if err := f.store.db.QueryRow(`SELECT COUNT(*) FROM spec_normalization_selections WHERE spec_value_id=?`, c.SpecValueID).Scan(&selection); err != nil { + t.Fatal(err) + } + if history != 0 || selection != 0 { + t.Fatalf("stale manual adopted: history=%d selection=%d", history, selection) + } +} + +func TestNormalizationRejectsChangedRawSource(t *testing.T) { + f := newNormalizationFixture(t) + c := f.snapshot(t) + parsed := specnumeric.Normalize(c.Raw, c.Profile) + encoded, err := specnumeric.MarshalValue(*parsed.Candidate) + if err != nil { + t.Fatal(err) + } + if _, err := f.store.SaveSpecValue(t.Context(), f.actor, c.ProductID, c.SpecID, "4–6V", "en-US", c.Expected.ProductRevision); err != nil { + t.Fatal(err) + } + // A change before the storage context read must retain 409 conflict + // precedence over candidate validation against the new Raw/profile. + if _, err := f.store.RecordSpecNormalization(t.Context(), f.actor, catalog.NormalizeSpecCommand{SpecValueID: c.SpecValueID, Expected: c.Expected}, parsed); !errors.Is(err, catalog.ErrRevisionConflict) { + t.Fatalf("old parsed context conflict precedence: %v", err) + } + // Supply the current Product revision so only the stale Raw snapshot/revision + // can reject this already-parsed result. + c.Expected.ProductRevision++ + _, err = f.store.recordControlledNormalization(t.Context(), f.actor, c, c.Expected, encoded, "", catalog.NormalizedAutomatic, false) + if !errors.Is(err, catalog.ErrRevisionConflict) { + t.Fatalf("parsed old Raw committed: %v", err) + } + var count int + if err := f.store.db.QueryRow(`SELECT COUNT(*) FROM normalized_values WHERE spec_value_id=?`, c.SpecValueID).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 0 { + t.Fatalf("old candidate persisted: %d", count) + } +} + +func TestSharedSpecRawDoesNotCreateLocaleVariant(t *testing.T) { + f := newNormalizationFixture(t) + c := f.snapshot(t) + v, err := f.store.SaveSpecValue(t.Context(), f.actor, c.ProductID, c.SpecID, "4–6V", "zh-TW", c.Expected.ProductRevision) + if err != nil { + t.Fatal(err) + } + if v.ID != f.value.ID || v.SourceRevision != 2 { + t.Fatalf("locale variant: %+v", v) + } + c = f.snapshot(t) + if c.Raw != "4–6V" || c.SourceLocale != "zh-TW" || c.SourceSelection.SpecValueID != v.ID { + t.Fatalf("shared source: %+v", c) + } +} + +func TestLegacySharedSpecSourceRequiresExplicitChoice(t *testing.T) { + f := newNormalizationFixture(t) + c := f.snapshot(t) + if _, err := f.store.db.Exec(`DELETE FROM product_spec_sources WHERE product_id=? AND spec_id=?`, c.ProductID, c.SpecID); err != nil { + t.Fatal(err) + } + if _, err := f.store.db.Exec(`INSERT INTO product_spec_values(id,product_id,spec_id,raw_value,source_locale,source_origin_locale,source_revision,active,created_by,updated_by,created_at,updated_at) SELECT 'spv_legacy_choice',product_id,spec_id,'6–8V','zh-TW','zh-TW',1,active,created_by,updated_by,created_at,updated_at FROM product_spec_values WHERE id=?`, c.SpecValueID); err != nil { + t.Fatal(err) + } + if _, err := f.store.SaveSpecValue(t.Context(), f.actor, c.ProductID, c.SpecID, "9–10V", "en-US", c.Expected.ProductRevision); !errors.Is(err, catalog.ErrRevisionConflict) { + t.Fatalf("ambiguous source guessed: %v", err) + } + // General source selection also works without a numeric profile. + if _, err := f.store.db.Exec(`DELETE FROM spec_numeric_profiles WHERE spec_id=?`, c.SpecID); err != nil { + t.Fatal(err) + } + selected, err := f.store.SelectSpecSource(context.Background(), f.actor, catalog.SelectSpecSourceCommand{ProductID: c.ProductID, SpecID: c.SpecID, SpecValueID: "spv_legacy_choice", ExpectedProductRevision: c.Expected.ProductRevision, ExpectedSourceRevision: 1}) + if err != nil { + t.Fatal(err) + } + if selected.SpecValueID != "spv_legacy_choice" || selected.Revision != 1 { + t.Fatalf("source selection: %+v", selected) + } + saved, err := f.store.SaveSpecValue(t.Context(), f.actor, c.ProductID, c.SpecID, "9–10V", "de-DE", c.Expected.ProductRevision+1) + if err != nil { + t.Fatal(err) + } + if saved.ID != selected.SpecValueID || saved.SourceRevision != 2 || saved.SourceLocale != "de-DE" { + t.Fatalf("selected shared Raw update: %+v", saved) + } + var count int + if err := f.store.db.QueryRow(`SELECT COUNT(*) FROM product_spec_values WHERE product_id=? AND spec_id=?`, c.ProductID, c.SpecID).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 2 { + t.Fatalf("legacy sources lost: %d", count) + } +} + +func TestCloneSpecSourcePreservesExplicitSharedChoice(t *testing.T) { + f := newNormalizationFixture(t) + manual := f.adopt(t) + c := f.snapshot(t) + if _, err := f.store.db.Exec(`INSERT INTO product_spec_values(id,product_id,spec_id,raw_value,source_locale,source_origin_locale,source_revision,active,created_by,updated_by,created_at,updated_at) + SELECT 'spv_clone_source',product_id,spec_id,'6–8V','zh-TW','ja-JP',1,active,created_by,updated_by,created_at,updated_at FROM product_spec_values WHERE id=?`, c.SpecValueID); err != nil { + t.Fatal(err) + } + if _, err := f.store.SelectSpecSource(t.Context(), f.actor, catalog.SelectSpecSourceCommand{ProductID: c.ProductID, SpecID: c.SpecID, SpecValueID: "spv_clone_source", ExpectedProductRevision: c.Expected.ProductRevision, ExpectedSourceRevision: 1, ExpectedSourceSelectionRevision: c.SourceSelection.Revision}); err != nil { + t.Fatal(err) + } + clone, err := f.store.CloneProduct(t.Context(), f.actor, c.ProductID, c.Expected.ProductRevision+1, catalog.Product{PartNumber: "CLONE-SHARED"}) + if err != nil { + t.Fatal(err) + } + var raw, origin, id string + if err := f.store.db.QueryRow(`SELECT pv.id,pv.raw_value,pv.source_origin_locale FROM product_spec_sources ps JOIN product_spec_values pv ON pv.id=ps.spec_value_id WHERE ps.product_id=? AND ps.spec_id=?`, clone.ID, c.SpecID).Scan(&id, &raw, &origin); err != nil { + t.Fatal(err) + } + if id == "spv_clone_source" || raw != "6–8V" || origin != "ja-JP" { + t.Fatalf("clone shared choice: %q %q %q", id, raw, origin) + } + var rawCount, selectionCount, historyCount int + if err := f.store.db.QueryRow(`SELECT COUNT(*) FROM product_spec_values WHERE product_id=?`, clone.ID).Scan(&rawCount); err != nil { + t.Fatal(err) + } + if err := f.store.db.QueryRow(`SELECT COUNT(*) FROM spec_normalization_selections ns JOIN product_spec_values pv ON pv.id=ns.spec_value_id WHERE pv.product_id=?`, clone.ID).Scan(&selectionCount); err != nil { + t.Fatal(err) + } + if err := f.store.db.QueryRow(`SELECT COUNT(*) FROM normalized_values WHERE id=?`, manual.NormalizedValueID).Scan(&historyCount); err != nil { + t.Fatal(err) + } + if rawCount != 2 || selectionCount != 0 || historyCount != 1 { + t.Fatalf("clone preserved scope: raw=%d adoption=%d original_history=%d", rawCount, selectionCount, historyCount) + } +} + +// removeNumericSchema reconstructs the pre-19 shape for upgrade fixtures. +func removeNumericSchema(t *testing.T, store *Store) { + t.Helper() + if CurrentSchemaVersion < 19 { + return + } + _, err := store.db.Exec(` +DROP TABLE public_spec_projection_fields; +DROP TABLE public_spec_projection_products; +DROP TABLE spec_numeric_profile_work; +DROP TABLE spec_normalization_selections; +DROP TABLE product_spec_sources; +DROP TABLE spec_numeric_profiles; +ALTER TABLE product_spec_values DROP COLUMN source_origin_locale; +ALTER TABLE normalized_values DROP COLUMN numeric_schema_version; +ALTER TABLE normalized_values DROP COLUMN source_origin_locale; +ALTER TABLE normalized_values DROP COLUMN profile_revision; +DELETE FROM schema_migrations WHERE version=19; +UPDATE system_state SET schema_version=18 WHERE singleton=1;`) + if err != nil { + t.Fatal(err) + } +} + +func TestNumericMigrationPreservesLegacyRawAndHistory(t *testing.T) { + store, owner := installedStore(t) + ctx := t.Context() + spec, err := store.CreateSpecDefinition(ctx, owner.ID, catalog.SpecDefinition{Name: "Voltage", PreferredUnit: "V"}) + if err != nil { + t.Fatal(err) + } + other, err := store.CreateSpecDefinition(ctx, owner.ID, catalog.SpecDefinition{Name: "Legacy shared source"}) + if err != nil { + t.Fatal(err) + } + product, err := store.CreateProduct(ctx, owner.ID, catalog.Product{PartNumber: "NUMERIC-LEGACY"}) + if err != nil { + t.Fatal(err) + } + value, err := store.SaveSpecValue(ctx, owner.ID, product.ID, spec.ID, " 3.0–3.6V ", "en-US", 1) + if err != nil { + t.Fatal(err) + } + legacyJSON := `{"unit":"V","min":3.0,"max":3.6}` + history, err := store.SaveNormalizedValue(ctx, owner.ID, catalog.NormalizedValue{SpecValueID: value.ID, ValueJSON: legacyJSON, + SourceRevision: 1, SpecSemanticVersion: 1, NormalizerVersion: "manual-v1", Source: catalog.NormalizedManual, Status: catalog.NormalizedCurrent}) + if err != nil { + t.Fatal(err) + } + // Reconstruct the pre-M19 locale-keyed writes directly: current writes share Raw. + old, err := store.SaveSpecValue(ctx, owner.ID, product.ID, other.ID, "legacy en-US", "en-US", 2) + if err != nil { + t.Fatal(err) + } + if _, err := store.db.Exec(`INSERT INTO product_spec_values(id,product_id,spec_id,raw_value,source_locale,source_origin_locale,source_revision,active,created_by,updated_by,created_at,updated_at) + SELECT 'spv_legacy_variant',product_id,spec_id,'legacy zh-TW','zh-TW','zh-TW',source_revision,active,created_by,updated_by,created_at,updated_at FROM product_spec_values WHERE id=?`, old.ID); err != nil { + t.Fatal(err) + } + removeNumericSchema(t, store) + if err := store.Upgrade(ctx, "verified-numeric-v18-backup"); err != nil { + t.Fatal(err) + } + var raw, encoded, status, origin string + if err := store.db.QueryRow(`SELECT raw_value,source_origin_locale FROM product_spec_values WHERE id=?`, value.ID).Scan(&raw, &origin); err != nil { + t.Fatal(err) + } + if raw != value.RawValue || origin != "en-US" { + t.Fatalf("raw/provenance changed: %q %q", raw, origin) + } + if err := store.db.QueryRow(`SELECT value_json,status FROM normalized_values WHERE id=?`, history.ID).Scan(&encoded, &status); err != nil { + t.Fatal(err) + } + if encoded != legacyJSON || status != "current" { + t.Fatalf("legacy history changed: %q %q", encoded, status) + } + var profileRevision, schemaVersion int + if err := store.db.QueryRow(`SELECT profile_revision,numeric_schema_version FROM normalized_values WHERE id=?`, history.ID).Scan(&profileRevision, &schemaVersion); err != nil { + t.Fatal(err) + } + if profileRevision != 0 || schemaVersion != 0 { + t.Fatalf("legacy history marked trusted: profile=%d schema=%d", profileRevision, schemaVersion) + } + var adopted, sources, preserved int + if err := store.db.QueryRow(`SELECT COUNT(*) FROM spec_normalization_selections`).Scan(&adopted); err != nil { + t.Fatal(err) + } + if adopted != 0 { + t.Fatalf("legacy values automatically adopted: %d", adopted) + } + if err := store.db.QueryRow(`SELECT COUNT(*) FROM product_spec_sources WHERE product_id=?`, product.ID).Scan(&sources); err != nil { + t.Fatal(err) + } + if sources != 1 { + t.Fatalf("mapped sources=%d, want sole source only", sources) + } + var mapped string + if err := store.db.QueryRow(`SELECT spec_value_id FROM product_spec_sources WHERE product_id=? AND spec_id=?`, product.ID, spec.ID).Scan(&mapped); err != nil { + t.Fatal(err) + } + if mapped != value.ID { + t.Fatalf("mapped source=%q", mapped) + } + if err := store.db.QueryRow(`SELECT COUNT(*) FROM product_spec_values WHERE product_id=? AND spec_id=?`, product.ID, other.ID).Scan(&preserved); err != nil { + t.Fatal(err) + } + if preserved != 2 { + t.Fatalf("ambiguous legacy sources lost: %d", preserved) + } +} + +func TestNumericFreshInstallHasSameSchema(t *testing.T) { + store, err := Create(filepath.Join(t.TempDir(), "fresh.db")) + if err != nil { + t.Fatal(err) + } + defer store.Close() + var version, tables int + if err := store.db.QueryRow(`SELECT schema_version FROM system_state`).Scan(&version); err != nil { + t.Fatal(err) + } + if version != 19 { + t.Fatalf("fresh schema=%d, want 19", version) + } + if err := store.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN + ('spec_numeric_profiles','spec_normalization_selections','product_spec_sources','spec_numeric_profile_work','public_spec_projection_products','public_spec_projection_fields')`).Scan(&tables); err != nil { + t.Fatal(err) + } + if tables != 6 { + t.Fatalf("numeric tables=%d, want 6", tables) + } + if _, err := store.db.Exec(`SELECT source_origin_locale FROM product_spec_values LIMIT 1`); err != nil { + t.Fatal(err) + } +} + +func numericTestProfile() specnumeric.Profile { + return specnumeric.Profile{Quantity: "voltage", SemanticRole: specnumeric.RoleOperatingSupport, + AllowedShapes: []specnumeric.Shape{specnumeric.ShapeIntervalSet}, AllowedOperators: []specnumeric.Operator{specnumeric.OperatorContainsPoint}, + CanonicalUnit: "V", NumericFormat: specnumeric.NumericFormatDecimalDot, SchemaVersion: specnumeric.SchemaVersion, + ParserVersion: specnumeric.ParserVersion, UnitRegistryVersion: specnumeric.UnitRegistryVersion} +} + +func TestNumericProfileRejectsStaleRevision(t *testing.T) { + store, owner := installedStore(t) + spec, err := store.CreateSpecDefinition(t.Context(), owner.ID, catalog.SpecDefinition{Name: "Voltage"}) + if err != nil { + t.Fatal(err) + } + profile, err := store.ConfigureSpecNumericProfile(t.Context(), owner.ID, spec.ID, spec.Revision, numericTestProfile()) + if err != nil { + t.Fatal(err) + } + if profile.SpecSemanticVersion != 2 || profile.Revision != 1 { + t.Fatalf("profile=%+v", profile) + } + if _, err := store.ConfigureSpecNumericProfile(t.Context(), owner.ID, spec.ID, spec.Revision, numericTestProfile()); !errors.Is(err, catalog.ErrRevisionConflict) { + t.Fatalf("stale configure=%v", err) + } + var work, audit int + if err := store.db.QueryRow(`SELECT COUNT(*) FROM spec_numeric_profile_work WHERE spec_id=?`, spec.ID).Scan(&work); err != nil { + t.Fatal(err) + } + if err := store.db.QueryRow(`SELECT COUNT(*) FROM admin_log WHERE action='spec.numeric_profile_configured' AND target_id=?`, spec.ID).Scan(&audit); err != nil { + t.Fatal(err) + } + if work != 1 || audit != 1 { + t.Fatalf("durable work=%d audit=%d", work, audit) + } +} + +func TestNumericProfileCannotChangeSemanticIdentity(t *testing.T) { + store, owner := installedStore(t) + spec, err := store.CreateSpecDefinition(t.Context(), owner.ID, catalog.SpecDefinition{Name: "Voltage"}) + if err != nil { + t.Fatal(err) + } + if _, err := store.ConfigureSpecNumericProfile(t.Context(), owner.ID, spec.ID, spec.Revision, numericTestProfile()); err != nil { + t.Fatal(err) + } + changed := numericTestProfile() + changed.SemanticRole = specnumeric.RoleNominal + if _, err := store.ConfigureSpecNumericProfile(t.Context(), owner.ID, spec.ID, spec.Revision+1, changed); !errors.Is(err, catalog.ErrInvalidSpec) { + t.Fatalf("semantic identity change=%v", err) + } +} + +func TestNumericContextFreezesRawAndExplicitRevisions(t *testing.T) { + store, owner := installedStore(t) + spec, err := store.CreateSpecDefinition(t.Context(), owner.ID, catalog.SpecDefinition{Name: "Voltage", PreferredUnit: "mV"}) + if err != nil { + t.Fatal(err) + } + product, err := store.CreateProduct(t.Context(), owner.ID, catalog.Product{PartNumber: "NUMERIC-CONTEXT"}) + if err != nil { + t.Fatal(err) + } + value, err := store.SaveSpecValue(t.Context(), owner.ID, product.ID, spec.ID, " 3.0–3.6 V ", "zh-TW", product.Revision) + if err != nil { + t.Fatal(err) + } + // Until Task 5 adapts Raw Save, seed explicit provenance without asking + // Context to infer it from the immutable legacy locale partition. + if _, err := store.db.Exec(`UPDATE product_spec_values SET source_origin_locale='zh-TW' WHERE id=?`, value.ID); err != nil { + t.Fatal(err) + } + if _, err := store.ConfigureSpecNumericProfile(t.Context(), owner.ID, spec.ID, spec.Revision, numericTestProfile()); err != nil { + t.Fatal(err) + } + context, err := store.SpecNormalizationContext(t.Context(), value.ID) + if err != nil { + t.Fatal(err) + } + want := catalog.NormalizationExpectation{ProductRevision: 2, SourceRevision: 1, SpecSemanticVersion: 2, ProfileRevision: 1} + if context.Expected != want || context.Raw != value.RawValue || context.SourceLocale != "zh-TW" || context.Profile.DeclaredInputUnit != "" { + t.Fatalf("context did not freeze authoritative inputs: %+v", context) + } +} diff --git a/internal/storage/sqlite/spec_numeric_work.go b/internal/storage/sqlite/spec_numeric_work.go new file mode 100644 index 0000000..4f0c5a8 --- /dev/null +++ b/internal/storage/sqlite/spec_numeric_work.go @@ -0,0 +1,93 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "time" +) + +// ProcessSpecNumericProfileWork advances one durable, bounded profile batch. +// Admission, target-version validation, stale marking, audit, publication intent, +// and the cursor commit together. Public activation snapshots are never edited. +func (s *Store) ProcessSpecNumericProfileWork(ctx context.Context, limit int) (bool, error) { + if limit <= 0 || limit > 100 { + limit = 100 + } + worked := false + err := s.withWriteTx(ctx, func(tx *sql.Tx) error { + var specID, cursor string + var revision, semantic int64 + err := tx.QueryRowContext(ctx, `SELECT spec_id,profile_revision,spec_semantic_version,cursor_product_id + FROM spec_numeric_profile_work WHERE status='pending' ORDER BY created_at,spec_id,profile_revision LIMIT 1`).Scan(&specID, &revision, &semantic, &cursor) + if errors.Is(err, sql.ErrNoRows) { + return nil + } + if err != nil { + return err + } + worked = true + now := time.Now().UTC().Format(time.RFC3339Nano) + var current bool + if err = tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM spec_numeric_profiles np JOIN spec_definitions sd ON sd.id=np.spec_id + WHERE np.spec_id=? AND np.revision=? AND np.spec_semantic_version=? AND sd.semantic_version=?)`, specID, revision, semantic, semantic).Scan(¤t); err != nil { + return err + } + if !current { + // Superseded work may complete only its own durable record. + _, err = tx.ExecContext(ctx, `UPDATE spec_numeric_profile_work SET status='completed',updated_at=? WHERE spec_id=? AND profile_revision=? AND spec_semantic_version=?`, now, specID, revision, semantic) + return err + } + rows, err := tx.QueryContext(ctx, `SELECT p.id,p.revision FROM products p WHERE p.record_state='current' AND p.id>? + AND (EXISTS(SELECT 1 FROM product_spec_values pv WHERE pv.product_id=p.id AND pv.spec_id=?) + OR EXISTS(SELECT 1 FROM category_spec_sets cs JOIN spec_set_members sm ON sm.spec_set_id=cs.spec_set_id WHERE cs.category_id=p.category_id AND sm.spec_id=?)) + ORDER BY p.id LIMIT ?`, cursor, specID, specID, limit) + if err != nil { + return err + } + type affectedProduct struct { + id string + revision int64 + } + products := make([]affectedProduct, 0, limit) + for rows.Next() { + var p affectedProduct + if err = rows.Scan(&p.id, &p.revision); err != nil { + rows.Close() + return err + } + products = append(products, p) + } + if err = rows.Err(); err != nil { + rows.Close() + return err + } + if err = rows.Close(); err != nil { + return err + } + for _, p := range products { + if _, err = tx.ExecContext(ctx, `UPDATE normalized_values SET status='stale' WHERE status='current' + AND spec_value_id IN (SELECT id FROM product_spec_values WHERE product_id=? AND spec_id=?) + AND (profile_revision<>? OR spec_semantic_version<>?)`, p.id, specID, revision, semantic); err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `UPDATE products SET revision=revision+1,updated_by=NULL,updated_at=? WHERE id=? AND revision=?`, now, p.id, p.revision); err != nil { + return err + } + if err = appendAudit(ctx, tx, "", "product.spec_profile_changed", "product", p.id, map[string]any{"spec_id": specID, "profile_revision": revision, "spec_semantic_version": semantic, "revision": p.revision + 1}); err != nil { + return err + } + if err = appendPublicationIntent(ctx, tx, p.id, p.revision+1, "product.spec_profile_changed", now); err != nil { + return err + } + cursor = p.id + } + status := "pending" + if len(products) < limit { + status = "completed" + } + _, err = tx.ExecContext(ctx, `UPDATE spec_numeric_profile_work SET cursor_product_id=?,status=?,updated_at=? WHERE spec_id=? AND profile_revision=? AND spec_semantic_version=? AND status='pending'`, cursor, status, now, specID, revision, semantic) + return err + }) + return worked, err +} diff --git a/internal/storage/sqlite/spec_numeric_work_test.go b/internal/storage/sqlite/spec_numeric_work_test.go new file mode 100644 index 0000000..cfafc8e --- /dev/null +++ b/internal/storage/sqlite/spec_numeric_work_test.go @@ -0,0 +1,98 @@ +package sqlite + +import ( + "prods/internal/catalog" + "testing" +) + +func TestNumericProfileWorkResumesAfterReopen(t *testing.T) { + f := newNormalizationFixture(t) + c := f.snapshot(t) + for { + worked, err := f.store.ProcessSpecNumericProfileWork(t.Context(), 100) + if err != nil { + t.Fatal(err) + } + if !worked { + break + } + } + f.adopt(t) + first, err := f.store.Product(t.Context(), c.ProductID) + if err != nil { + t.Fatal(err) + } + second, err := f.store.CreateProduct(t.Context(), f.actor, catalog.Product{PartNumber: "SECOND-NUMERIC", CategoryID: first.CategoryID}) + if err != nil { + t.Fatal(err) + } + var specRevision int64 + if err = f.store.db.QueryRow(`SELECT revision FROM spec_definitions WHERE id=?`, c.SpecID).Scan(&specRevision); err != nil { + t.Fatal(err) + } + profile := c.Profile + profile.DeclaredInputUnit = "V" + configured, err := f.store.ConfigureSpecNumericProfile(t.Context(), f.actor, c.SpecID, specRevision, profile) + if err != nil { + t.Fatal(err) + } + worked, err := f.store.ProcessSpecNumericProfileWork(t.Context(), 1) + if err != nil || !worked { + t.Fatalf("first batch worked=%v err=%v", worked, err) + } + var status, cursor string + if err = f.store.db.QueryRow(`SELECT status,cursor_product_id FROM spec_numeric_profile_work WHERE spec_id=? AND profile_revision=?`, c.SpecID, configured.Revision).Scan(&status, &cursor); err != nil { + t.Fatal(err) + } + if status != "pending" || cursor == "" { + t.Fatalf("partial batch status=%s cursor=%s", status, cursor) + } + var seq int + var name, path string + if err = f.store.db.QueryRow(`PRAGMA database_list`).Scan(&seq, &name, &path); err != nil { + t.Fatal(err) + } + if err = f.store.Close(); err != nil { + t.Fatal(err) + } + reopened, err := OpenReady(path) + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + for { + worked, err = reopened.ProcessSpecNumericProfileWork(t.Context(), 1) + if err != nil { + t.Fatal(err) + } + if !worked { + break + } + } + for _, p := range []catalog.Product{first, second} { + got, err := reopened.Product(t.Context(), p.ID) + if err != nil { + t.Fatal(err) + } + if got.Revision != p.Revision+1 { + t.Fatalf("product %s revision=%d want=%d", p.ID, got.Revision, p.Revision+1) + } + var audit, intent int + if err = reopened.db.QueryRow(`SELECT COUNT(*) FROM admin_log WHERE target_id=? AND action='product.spec_profile_changed' AND actor_id IS NULL`, p.ID).Scan(&audit); err != nil { + t.Fatal(err) + } + if err = reopened.db.QueryRow(`SELECT COUNT(*) FROM publication_intents WHERE entity_id=? AND desired_revision=? AND cause='product.spec_profile_changed'`, p.ID, got.Revision).Scan(&intent); err != nil { + t.Fatal(err) + } + if audit < 1 || intent != 1 { + t.Fatalf("audit=%d intent=%d", audit, intent) + } + } + var preference string + if err = reopened.db.QueryRow(`SELECT ns.preference,nv.status FROM spec_normalization_selections ns JOIN normalized_values nv ON nv.id=ns.selected_normalized_value_id WHERE ns.spec_value_id=?`, f.value.ID).Scan(&preference, &status); err != nil { + t.Fatal(err) + } + if preference != "manual" || status != "stale" { + t.Fatalf("manual preference=%s status=%s", preference, status) + } +} diff --git a/internal/storage/sqlite/spec_search_store.go b/internal/storage/sqlite/spec_search_store.go new file mode 100644 index 0000000..7442aa3 --- /dev/null +++ b/internal/storage/sqlite/spec_search_store.go @@ -0,0 +1,111 @@ +package sqlite + +import ( + "context" + "database/sql" + "slices" + + "prods/internal/publishing" +) + +// Both marker bindings and their actual field rows are read in one SQLite +// snapshot. Working Product revisions deliberately do not join this projection: +// a published activation stays authoritative while a newer edit is pending. +func (s *Store) SpecSearchCandidates(ctx context.Context, specIDs []string, projectionVersion string) (publishing.SpecCandidateSnapshot, error) { + snapshot := publishing.SpecCandidateSnapshot{ProjectionVersion: projectionVersion} + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return snapshot, err + } + defer tx.Rollback() + rows, err := tx.QueryContext(ctx, `SELECT sp.product_id,sp.public_revision,sp.source_revision,sp.site_epoch,sp.visibility_generation,sp.projection_version,f.spec_id + FROM public_spec_projection_products sp + JOIN public_activations a ON a.product_id=sp.product_id AND a.public_revision=sp.public_revision AND a.source_revision=sp.source_revision AND a.site_epoch=sp.site_epoch AND a.visibility_generation=sp.visibility_generation + JOIN public_visibility v ON v.product_id=a.product_id AND v.visible=1 AND v.generation=a.visibility_generation + JOIN products p ON p.id=a.product_id AND p.record_state='current' AND p.status='published' + LEFT JOIN public_spec_projection_fields f ON f.product_id=sp.product_id + ORDER BY sp.product_id,f.spec_id`) + if err != nil { + return snapshot, err + } + for rows.Next() { + var binding publishing.SpecCandidateBinding + var specID sql.NullString + if err = rows.Scan(&binding.ProductID, &binding.PublicRevision, &binding.SourceRevision, &binding.SiteEpoch, &binding.VisibilityGeneration, &binding.ProjectionVersion, &specID); err != nil { + rows.Close() + return snapshot, err + } + if len(snapshot.Inventory) == 0 || snapshot.Inventory[len(snapshot.Inventory)-1].ProductID != binding.ProductID { + snapshot.Inventory = append(snapshot.Inventory, binding) + } + if specID.Valid { + last := &snapshot.Inventory[len(snapshot.Inventory)-1] + last.SpecIDs = append(last.SpecIDs, specID.String) + } + } + if err = rows.Err(); err != nil { + rows.Close() + return snapshot, err + } + if err = rows.Close(); err != nil { + return snapshot, err + } + // Intersect requested Spec IDs using only the actual rows just read. This + // imposes no SQL-variable or keyword candidate cap and keeps numeric truth + // entirely in the gate-protected oracle. + for _, binding := range snapshot.Inventory { + matches := binding.ProjectionVersion == projectionVersion + for _, id := range specIDs { + matches = matches && slices.Contains(binding.SpecIDs, id) + } + if matches { + snapshot.Candidates = append(snapshot.Candidates, binding) + } + } + if err = tx.Commit(); err != nil { + return snapshot, err + } + return snapshot, nil +} + +// Rebuild accepts views already validated by the publisher. Each binding is +// checked again inside writer admission; delayed work cannot replace a newer +// activation or delete rows installed after the supplied snapshot was taken. +func (s *Store) RebuildSpecSearchProjection(ctx context.Context, activations []publishing.ActivePublication) error { + return s.withWriteTx(ctx, func(tx *sql.Tx) error { + for _, activation := range activations { + var current bool + err := tx.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM public_activations a + JOIN public_visibility v ON v.product_id=a.product_id AND v.visible=1 AND v.generation=a.visibility_generation + JOIN products p ON p.id=a.product_id AND p.record_state='current' AND p.status='published' + WHERE a.product_id=? AND a.public_revision=? AND a.source_revision=? AND a.site_epoch=? AND a.visibility_generation=?)`, activation.ProductID, activation.PublicRevision, activation.SourceRevision, activation.SiteEpoch, activation.VisibilityGeneration).Scan(¤t) + if err != nil { + return err + } + if !current { + continue + } + if err = upsertSpecSearchProjection(ctx, tx, activation); err != nil { + return err + } + } + return nil + }) +} + +func upsertSpecSearchProjection(ctx context.Context, tx *sql.Tx, activation publishing.ActivePublication) error { + _, err := tx.ExecContext(ctx, `INSERT INTO public_spec_projection_products(product_id,public_revision,source_revision,site_epoch,visibility_generation,projection_version) + VALUES(?,?,?,?,?,?) ON CONFLICT(product_id) DO UPDATE SET public_revision=excluded.public_revision,source_revision=excluded.source_revision,site_epoch=excluded.site_epoch,visibility_generation=excluded.visibility_generation,projection_version=excluded.projection_version`, activation.ProductID, activation.PublicRevision, activation.SourceRevision, activation.SiteEpoch, activation.VisibilityGeneration, publishing.SpecProjectionVersion) + if err != nil { + return err + } + if _, err = tx.ExecContext(ctx, `DELETE FROM public_spec_projection_fields WHERE product_id=?`, activation.ProductID); err != nil { + return err + } + for _, id := range publishing.SpecProjectionIDs(activation.View) { + if _, err = tx.ExecContext(ctx, `INSERT INTO public_spec_projection_fields(product_id,spec_id) VALUES(?,?)`, activation.ProductID, id); err != nil { + return err + } + } + return nil +} diff --git a/internal/storage/sqlite/spec_search_store_test.go b/internal/storage/sqlite/spec_search_store_test.go new file mode 100644 index 0000000..aa39dd4 --- /dev/null +++ b/internal/storage/sqlite/spec_search_store_test.go @@ -0,0 +1,60 @@ +package sqlite + +import ( + "testing" + "time" + + "prods/internal/catalog" + "prods/internal/publishing" +) + +func TestSpecProjectionOldRebuildCannotReplaceNewActivation(t *testing.T) { + store, owner := installedStore(t) + product, err := store.CreateProduct(t.Context(), owner.ID, catalog.Product{PartNumber: "SPEC-REBUILD", Status: catalog.Published}) + if err != nil { + t.Fatal(err) + } + activate := func(product catalog.Product) { + t.Helper() + intent, found, err := store.ClaimPublicationIntent(t.Context()) + if err != nil || !found || intent.DesiredRevision != product.Revision { + t.Fatalf("intent=%+v found=%v err=%v", intent, found, err) + } + view, err := publishing.FromProduct(product, "https://catalog.example.test") + if err != nil { + t.Fatal(err) + } + view.SiteEpoch = 1 + activation := publishing.ActivePublication{ProductID: product.ID, SourceRevision: product.Revision, PublicRevision: product.Revision, SiteEpoch: 1, Route: "/products/" + product.Slug, ArtifactID: "art_" + product.ID, ManifestHash: "hash_" + product.ID, ActivatedAt: time.Now().UTC(), View: view} + activated, err := store.ActivatePublication(t.Context(), intent, activation, websiteVisibilityStub{}) + if err != nil || !activated { + t.Fatalf("activated=%v err=%v", activated, err) + } + } + activate(product) + old, err := store.ActivePublications(t.Context()) + if err != nil { + t.Fatal(err) + } + product.Name = "New revision" + product, err = store.UpdateProduct(t.Context(), owner.ID, product.Revision, product) + if err != nil { + t.Fatal(err) + } + activate(product) + newer, err := store.ActivePublications(t.Context()) + if err != nil { + t.Fatal(err) + } + // Delayed reconciliation must leave the newer activation's marker intact. + if err = store.RebuildSpecSearchProjection(t.Context(), old); err != nil { + t.Fatal(err) + } + got, err := store.SpecSearchCandidates(t.Context(), nil, publishing.SpecProjectionVersion) + if err != nil { + t.Fatal(err) + } + if len(got.Inventory) != 1 || got.Inventory[0].PublicRevision != newer[0].PublicRevision || len(got.Candidates) != 1 { + t.Fatalf("old rebuild replaced new binding: %+v", got) + } +} diff --git a/internal/storage/sqlite/spec_shared_migration_test.go b/internal/storage/sqlite/spec_shared_migration_test.go new file mode 100644 index 0000000..da96727 --- /dev/null +++ b/internal/storage/sqlite/spec_shared_migration_test.go @@ -0,0 +1,145 @@ +package sqlite + +import ( + "database/sql" + "encoding/json" + "testing" + + "prods/internal/catalog" +) + +func TestSharedSpecMigrationQueuesPublishedRevisionWithoutActivating(t *testing.T) { + store, owner := installedStore(t) + product, err := store.CreateProduct(t.Context(), owner.ID, catalog.Product{PartNumber: "SHARED-SPECS", Specification: "authoritative specification"}) + if err != nil { + t.Fatal(err) + } + spec, err := store.CreateSpecDefinition(t.Context(), owner.ID, catalog.SpecDefinition{Name: "Voltage"}) + if err != nil { + t.Fatal(err) + } + removeNumericSchema(t, store) + if _, err := store.db.Exec(` +UPDATE products SET revision=7,status='published' WHERE id=?1; +INSERT INTO product_spec_values(id,product_id,spec_id,raw_value,source_locale,source_revision,active,created_at,updated_at) +VALUES('spv_shared_legacy',?1,?2,'3–5V','zh-TW',3,1,'legacy-time','legacy-time'); +INSERT INTO normalized_values(id,spec_value_id,value_json,source_revision,spec_semantic_version,normalizer_version,source,status,failure_reason,created_at) +VALUES('nrm_shared_legacy','spv_shared_legacy','{"min":3,"max":5}',3,1,'legacy-manual','manual','current','','legacy-time'); +INSERT INTO product_translations(product_id,locale,specification,revision,updated_at) VALUES(?1,'zh-TW','舊的翻譯規格',2,'legacy-time'); +INSERT INTO public_activations(product_id,source_revision,public_revision,site_epoch,route,artifact_id,manifest_hash,visibility_generation,view_json,activated_at) +VALUES(?1,7,7,1,'/products/shared-specs','artifact_legacy_shared','manifest-legacy',2,'{"specification":"authoritative specification"}','legacy-time'); +INSERT INTO public_visibility(product_id,generation,visible,updated_at) VALUES(?1,2,1,'legacy-time'); +INSERT INTO public_route_history(route,product_id,target_route,state,site_epoch,updated_at) +VALUES('/products/shared-specs-old',?1,'/products/shared-specs','redirect',1,'legacy-time'); +INSERT INTO publication_intents(id,entity_type,entity_id,desired_revision,cause,status,created_at,updated_at,error_message) +VALUES('out_shared_failed','product',?1,7,'legacy','failed','legacy-time','legacy-time','legacy-failure'), +('out_shared_pending','product',?1,7,'legacy','pending','legacy-time','legacy-time','');`, + product.ID, spec.ID); err != nil { + t.Fatal(err) + } + var beforeVersion int + if err := store.db.QueryRow(`SELECT schema_version FROM system_state`).Scan(&beforeVersion); err != nil { + t.Fatal(err) + } + if beforeVersion != 18 { + t.Fatalf("fixture schema=%d, want 18", beforeVersion) + } + if err := store.Upgrade(t.Context(), "verified-shared-model-v18"); err != nil { + t.Fatal(err) + } + var seq int + var name, path string + if err := store.db.QueryRow(`PRAGMA database_list`).Scan(&seq, &name, &path); err != nil { + t.Fatal(err) + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + store, err = OpenReady(path) + if err != nil { + t.Fatal(err) + } + defer store.Close() + var revision int64 + if err := store.db.QueryRow(`SELECT revision FROM products WHERE id=?`, product.ID).Scan(&revision); err != nil { + t.Fatal(err) + } + if revision != 8 { + t.Fatalf("published source revision=%d, want 8", revision) + } + var auditActor sql.NullString + var detailsJSON, auditResult, targetType string + if err := store.db.QueryRow(`SELECT actor_id,result,target_type,details_json FROM admin_log WHERE target_id=? AND action='product.spec_model_migrated'`, product.ID).Scan(&auditActor, &auditResult, &targetType, &detailsJSON); err != nil { + t.Fatal(err) + } + var details struct { + Migration int `json:"migration"` + OldRevision int64 `json:"old_revision"` + NewRevision int64 `json:"new_revision"` + RenderContractVersion string `json:"render_contract_version"` + } + if err := json.Unmarshal([]byte(detailsJSON), &details); err != nil { + t.Fatal(err) + } + if auditActor.Valid || auditResult != "success" || targetType != "product" || details.Migration != 19 || details.OldRevision != 7 || details.NewRevision != 8 || details.RenderContractVersion != "shared-specs-1" { + t.Fatalf("system audit: actor=%+v result=%s type=%s details=%s", auditActor, auditResult, targetType, detailsJSON) + } + var pending, auditCount int + if err := store.db.QueryRow(`SELECT COUNT(*) FROM publication_intents WHERE entity_type='product' AND entity_id=? AND desired_revision=8 AND status='pending' AND cause='product.spec_model_migrated'`, product.ID).Scan(&pending); err != nil { + t.Fatal(err) + } + if err := store.db.QueryRow(`SELECT COUNT(*) FROM admin_log WHERE target_id=? AND action='product.spec_model_migrated'`, product.ID).Scan(&auditCount); err != nil { + t.Fatal(err) + } + if pending != 1 || auditCount != 1 { + t.Fatalf("migration intent=%d audit=%d, want one each", pending, auditCount) + } + var raw, normalizedJSON, translation, activation, route, oldFailed, oldPending string + if err := store.db.QueryRow(`SELECT pv.raw_value,n.value_json,t.specification, + a.source_revision||'|'||a.public_revision||'|'||a.route||'|'||a.artifact_id||'|'||a.manifest_hash||'|'||a.view_json, + h.target_route||'|'||h.state, + (SELECT status||'|'||error_message FROM publication_intents WHERE id='out_shared_failed'), + (SELECT status FROM publication_intents WHERE id='out_shared_pending') + FROM product_spec_values pv JOIN normalized_values n ON n.spec_value_id=pv.id + JOIN product_translations t ON t.product_id=pv.product_id JOIN public_activations a ON a.product_id=pv.product_id + JOIN public_route_history h ON h.product_id=pv.product_id WHERE pv.id='spv_shared_legacy'`).Scan(&raw, &normalizedJSON, &translation, &activation, &route, &oldFailed, &oldPending); err != nil { + t.Fatal(err) + } + if raw != "3–5V" || normalizedJSON != `{"min":3,"max":5}` || translation != "舊的翻譯規格" || + activation != `7|7|/products/shared-specs|artifact_legacy_shared|manifest-legacy|{"specification":"authoritative specification"}` || + route != "/products/shared-specs|redirect" || oldFailed != "failed|legacy-failure" || oldPending != "pending" { + t.Fatalf("legacy data/activation/history changed: %q %q %q %q %q %q %q", raw, normalizedJSON, translation, activation, route, oldFailed, oldPending) + } +} + +func TestSharedSpecMigrationLeavesHiddenAndArchivedProductsUnqueued(t *testing.T) { + store, owner := installedStore(t) + hidden, err := store.CreateProduct(t.Context(), owner.ID, catalog.Product{PartNumber: "SHARED-HIDDEN"}) + if err != nil { + t.Fatal(err) + } + archived, err := store.CreateProduct(t.Context(), owner.ID, catalog.Product{PartNumber: "SHARED-ARCHIVED"}) + if err != nil { + t.Fatal(err) + } + removeNumericSchema(t, store) + if _, err := store.db.Exec(`UPDATE products SET record_state='archived' WHERE id=?`, archived.ID); err != nil { + t.Fatal(err) + } + if err := store.Upgrade(t.Context(), "verified-shared-ineligible-v18"); err != nil { + t.Fatal(err) + } + var unchanged, intents, audits int + if err := store.db.QueryRow(`SELECT COUNT(*) FROM products WHERE id IN (?,?) AND revision=1`, hidden.ID, archived.ID).Scan(&unchanged); err != nil { + t.Fatal(err) + } + if err := store.db.QueryRow(`SELECT COUNT(*) FROM publication_intents WHERE cause='product.spec_model_migrated'`).Scan(&intents); err != nil { + t.Fatal(err) + } + if err := store.db.QueryRow(`SELECT COUNT(*) FROM admin_log WHERE action='product.spec_model_migrated'`).Scan(&audits); err != nil { + t.Fatal(err) + } + if unchanged != 2 || intents != 0 || audits != 0 { + t.Fatalf("ineligible products changed: unchanged=%d intents=%d audits=%d", unchanged, intents, audits) + } +} diff --git a/internal/storage/sqlite/store.go b/internal/storage/sqlite/store.go index 6f2f3cd..a7bfa8a 100644 --- a/internal/storage/sqlite/store.go +++ b/internal/storage/sqlite/store.go @@ -642,7 +642,7 @@ func (s *Store) CompleteInstallation(ctx context.Context, installation Installat if _, exists := seenSpecIDs[value.SpecID]; !exists { return identity.User{}, fmt.Errorf("sample specification value %d references unknown specification %q", index+1, value.SpecID) } - key := value.ProductID + "\x00" + value.SpecID + "\x00" + value.SourceLocale + key := value.ProductID + "\x00" + value.SpecID if _, duplicate := seenSpecValues[key]; duplicate { return identity.User{}, fmt.Errorf("sample specification value %d is duplicated", index+1) } @@ -663,11 +663,14 @@ func (s *Store) CompleteInstallation(ctx context.Context, installation Installat valueID = fmt.Sprintf("spv_sample_%06d", index+1) } if _, err := tx.ExecContext(ctx, `INSERT INTO product_spec_values( - id,product_id,spec_id,raw_value,source_locale,source_revision,active,created_by,updated_by,created_at,updated_at - ) VALUES(?,?,?,?,?,1,1,?,?,?,?)`, valueID, value.ProductID, value.SpecID, value.RawValue, value.SourceLocale, + id,product_id,spec_id,raw_value,source_locale,source_origin_locale,source_revision,active,created_by,updated_by,created_at,updated_at + ) VALUES(?,?,?,?,?,?,1,1,?,?,?,?)`, valueID, value.ProductID, value.SpecID, value.RawValue, value.SourceLocale, value.SourceLocale, ownerID, ownerID, now, now); err != nil { return identity.User{}, fmt.Errorf("install sample specification value for product %q: %w", value.ProductID, err) } + if _, err := tx.ExecContext(ctx, `INSERT INTO product_spec_sources(product_id,spec_id,spec_value_id,revision,updated_by,updated_at) VALUES(?,?,?,1,?,?)`, value.ProductID, value.SpecID, valueID, ownerID, now); err != nil { + return identity.User{}, err + } seenSpecValues[key] = struct{}{} } } diff --git a/internal/webapp/content_translation_routes_test.go b/internal/webapp/content_translation_routes_test.go index 9416402..a06aca8 100644 --- a/internal/webapp/content_translation_routes_test.go +++ b/internal/webapp/content_translation_routes_test.go @@ -77,9 +77,19 @@ func TestContentTranslationRoutesRevisionGateAndEditingLifecycle(t *testing.T) { t.Fatalf("enable editing status=%d body=%s", localization.StatusCode, body) } + badSource := adminJSONMethod(t, client, http.MethodPut, server.URL+"/admin/api/products/"+product.ID+"/content/source-locales", csrf, + `{"expected_revision":1,"source_locale":"en-US","source_locales":{"specification":"en-US"}}`) + if body := responseBody(t, badSource); badSource.StatusCode != http.StatusUnprocessableEntity { + t.Fatalf("shared specification source status=%d body=%s", badSource.StatusCode, body) + } + badTranslation := adminJSONMethod(t, client, http.MethodPut, server.URL+"/admin/api/products/"+product.ID+"/translations/de-DE", csrf, + `{"expected_revision":1,"translation":{"specification":"Not translatable"}}`) + if body := responseBody(t, badTranslation); badTranslation.StatusCode != http.StatusBadRequest { + t.Fatalf("shared specification translation status=%d body=%s", badTranslation.StatusCode, body) + } productSource := adminJSONMethod(t, client, http.MethodPut, server.URL+"/admin/api/products/"+product.ID+"/content/source-locales", csrf, - `{"expected_revision":1,"source_locale":"en-US","source_locales":{"name":"en-US","description":"en-US","features":"en-US","specification":"en-US"}}`) + `{"expected_revision":1,"source_locale":"en-US","source_locales":{"name":"en-US","description":"en-US","features":"en-US"}}`) if productSource.StatusCode != http.StatusOK { t.Fatalf("Product source locale status=%d body=%s", productSource.StatusCode, responseBody(t, productSource)) } diff --git a/internal/webapp/installer_test.go b/internal/webapp/installer_test.go index 250af76..e6a1106 100644 --- a/internal/webapp/installer_test.go +++ b/internal/webapp/installer_test.go @@ -29,7 +29,7 @@ func TestInstallerUsesEmbeddedMatchingVersionSampleData(t *testing.T) { defer store.Close() installer, _, err := NewInstaller(store, InstallerConfig{ BootstrapToken: "bootstrap", DataDir: filepath.Join(root, "data"), BackupDir: filepath.Join(root, "backups"), - DefaultLocale: "en-US", DefaultTimeZone: "UTC", ApplicationVersion: "v0.6.8", + DefaultLocale: "en-US", DefaultTimeZone: "UTC", ApplicationVersion: "v0.7.0", }) if err != nil { t.Fatal(err) @@ -40,7 +40,7 @@ func TestInstallerUsesEmbeddedMatchingVersionSampleData(t *testing.T) { if err := json.Unmarshal(claim.Body.Bytes(), &state); err != nil { t.Fatal(err) } - if !state.SampleDataAvailable || state.ApplicationVersion != "v0.6.8" { + if !state.SampleDataAvailable || state.ApplicationVersion != "v0.7.0" { t.Fatalf("installer sample state = %+v", state) } complete := formRequestWithCookies(installer, http.MethodPost, "/install/complete", url.Values{ @@ -97,7 +97,7 @@ func TestInstallerEmbeddedSampleVersionMismatchStillCompletesBlankInstallation(t } func TestInstallerCommitsCompleteEmbeddedSampleData(t *testing.T) { - const version = "v0.6.8" + const version = "v0.7.0" sample, err := sampledata.Generate(version) if err != nil { t.Fatal(err) diff --git a/internal/webapp/maintenance.go b/internal/webapp/maintenance.go index b7e5dfd..aaab923 100644 --- a/internal/webapp/maintenance.go +++ b/internal/webapp/maintenance.go @@ -427,29 +427,6 @@ func (s *Server) adminSaveSpecValue(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, value) } -func (s *Server) adminSaveNormalizedValue(w http.ResponseWriter, r *http.Request) { - current, ok := s.requireCapability(w, r, identity.CapabilityCatalogEdit, true) - if !ok { - return - } - var normalized catalog.NormalizedValue - if err := decodeJSON(r.Body, &normalized); err != nil { - s.writeAPIError(w, r, http.StatusBadRequest, apiCodeInvalidJSON) - return - } - if normalized.SpecValueID != "" && normalized.SpecValueID != r.PathValue("id") { - s.writeAPIError(w, r, http.StatusUnprocessableEntity, apiCodeValidationFailed) - return - } - normalized.SpecValueID = r.PathValue("id") - normalized, err := s.store.SaveNormalizedValue(r.Context(), current.UserID, normalized) - if err != nil { - s.writeCatalogError(w, r, err) - return - } - writeJSON(w, http.StatusCreated, normalized) -} - func (s *Server) adminProductDocuments(w http.ResponseWriter, r *http.Request) { if _, ok := s.requireCapability(w, r, identity.CapabilityCatalogView, false); !ok { return diff --git a/internal/webapp/maintenance_test.go b/internal/webapp/maintenance_test.go index 8d17fca..238a594 100644 --- a/internal/webapp/maintenance_test.go +++ b/internal/webapp/maintenance_test.go @@ -112,6 +112,20 @@ func TestMaintenanceAPIPreservesCategoryAndDisabledReferenceContracts(t *testing t.Fatalf("saved spec value = %#v", value) } + // Raw source selection metadata is readable without a numeric profile. + sources, err := client.Get(server.URL + "/admin/api/products/" + product.ID + "/spec-values") + if err != nil { + t.Fatal(err) + } + if sources.StatusCode != http.StatusOK { + t.Fatalf("sources status=%d", sources.StatusCode) + } + var details []catalog.SpecValueDetail + decodeResponseJSON(t, sources, &details) + if len(details) != 1 || details[0].SourceSelection.SpecValueID != value.ID || details[0].SourceSelection.Revision != 1 { + t.Fatalf("shared source selection=%+v", details) + } + documentType := postAdminJSON(t, client, server.URL+"/admin/api/dictionaries", csrf, `{"id":"dic_datasheet","kind":"document_type","name":"Datasheet","slug":"datasheet","status":"active","revision":1}`) if documentType.StatusCode != http.StatusCreated { diff --git a/internal/webapp/server.go b/internal/webapp/server.go index f764ddc..224994b 100644 --- a/internal/webapp/server.go +++ b/internal/webapp/server.go @@ -53,6 +53,7 @@ func SystemStaticFS() (fs.FS, error) { } type Config struct { + SpecSearchBackend string IndexNowSubmitter searchnotify.IndexNowSubmitter GoogleSearchSubmitter searchnotify.GoogleSubmitter PublicDir string @@ -144,6 +145,11 @@ type Server struct { } func New(store *sqlite.Store, config Config) (*Server, string, error) { + backend, err := publishing.NormalizeSpecSearchBackend(config.SpecSearchBackend) + if err != nil { + return nil, "", err + } + config.SpecSearchBackend = backend generated := "" if config.SessionTTL <= 0 { config.SessionTTL = 12 * time.Hour @@ -217,7 +223,8 @@ func New(store *sqlite.Store, config Config) (*Server, string, error) { } if !config.EnablePOCAdmin { server.publisher, err = publishing.NewEngine(context.Background(), store, publishing.Config{ - Root: config.PublicDir, AssetRoot: config.AssetDir, BaseURL: config.BaseURL, + SpecSearchBackend: config.SpecSearchBackend, + Root: config.PublicDir, AssetRoot: config.AssetDir, BaseURL: config.BaseURL, ResourceGate: config.ResourceGate, ByteHeadroom: resourceByteHeadroom, InodeHeadroom: resourceInodeHeadroom, MinFreePercent: resourceMinFreePercent, }) @@ -602,7 +609,13 @@ func (s *Server) routes(static fs.FS) { s.mux.HandleFunc("POST /admin/api/categories/{id}/spec-set", s.adminSetCategorySpecSet) s.mux.HandleFunc("GET /admin/api/products/{id}/spec-values", s.adminProductSpecValues) s.mux.HandleFunc("POST /admin/api/products/{id}/spec-values", s.adminSaveSpecValue) - s.mux.HandleFunc("POST /admin/api/spec-values/{id}/normalized", s.adminSaveNormalizedValue) + s.mux.HandleFunc("POST /admin/api/spec-values/{id}/normalized", s.adminAdoptSpec) + s.mux.HandleFunc("PUT /admin/api/specs/{id}/numeric-profile", s.adminConfigureNumericProfile) + s.mux.HandleFunc("GET /admin/api/spec-values/{id}/normalization-context", s.adminSpecNormalizationContext) + s.mux.HandleFunc("POST /admin/api/spec-values/{id}/normalize", s.adminNormalizeSpec) + s.mux.HandleFunc("POST /admin/api/spec-values/{id}/normalization/automatic", s.adminSelectAutomaticSpec) + s.mux.HandleFunc("POST /admin/api/products/{id}/spec-source", s.adminSelectSpecSource) + s.mux.HandleFunc("POST /api/search/specs", s.publicSpecSearch) s.mux.HandleFunc("GET /admin/api/products/{id}/documents", s.adminProductDocuments) s.mux.HandleFunc("POST /admin/api/products/{id}/documents", s.adminAddProductDocument) s.mux.HandleFunc("POST /admin/api/products/{id}/assets", s.adminUploadProductAsset) @@ -1731,9 +1744,22 @@ func (s *Server) internalError(w http.ResponseWriter, err error) { func decodeJSON(body io.ReadCloser, target any) error { defer body.Close() - decoder := json.NewDecoder(io.LimitReader(body, 1<<20)) + encoded, err := io.ReadAll(io.LimitReader(body, (1<<20)+1)) + if err != nil { + return err + } + if len(encoded) > 1<<20 { + return fmt.Errorf("JSON body exceeds 1 MiB") + } + decoder := json.NewDecoder(strings.NewReader(string(encoded))) decoder.DisallowUnknownFields() - return decoder.Decode(target) + if err := decoder.Decode(target); err != nil { + return err + } + if err := decoder.Decode(new(any)); err != io.EOF { + return fmt.Errorf("expected one JSON value") + } + return nil } func writeJSON(w http.ResponseWriter, status int, value any) { diff --git a/internal/webapp/spec_numeric.go b/internal/webapp/spec_numeric.go new file mode 100644 index 0000000..48d0b82 --- /dev/null +++ b/internal/webapp/spec_numeric.go @@ -0,0 +1,167 @@ +package webapp + +import ( + "io" + "net/http" + "strings" + + "prods/internal/catalog" + "prods/internal/identity" + "prods/internal/specnumeric" +) + +func (s *Server) adminConfigureNumericProfile(w http.ResponseWriter, r *http.Request) { + current, ok := s.requireCapability(w, r, identity.CapabilityCatalogEdit, true) + if !ok { + return + } + var request struct { + ExpectedRevision int64 `json:"expected_revision"` + Profile *specnumeric.Profile `json:"profile"` + } + if err := decodeJSON(r.Body, &request); err != nil || request.ExpectedRevision < 1 || request.Profile == nil { + s.writeAPIError(w, r, http.StatusBadRequest, apiCodeInvalidJSON) + return + } + result, err := s.store.ConfigureSpecNumericProfile(r.Context(), current.UserID, r.PathValue("id"), request.ExpectedRevision, *request.Profile) + if err != nil { + s.writeCatalogError(w, r, err) + return + } + writeJSON(w, http.StatusOK, result) +} +func (s *Server) adminSpecNormalizationContext(w http.ResponseWriter, r *http.Request) { + if _, ok := s.requireCapability(w, r, identity.CapabilityCatalogView, false); !ok { + return + } + result, err := s.store.SpecNormalizationContext(r.Context(), r.PathValue("id")) + if err != nil { + s.writeCatalogError(w, r, err) + return + } + writeJSON(w, http.StatusOK, result) +} +func validNormalizationExpectation(expected catalog.NormalizationExpectation) bool { + return expected.ProductRevision > 0 && expected.SourceRevision > 0 && expected.SpecSemanticVersion > 0 && expected.ProfileRevision > 0 && expected.SelectionRevision >= 0 +} +func (s *Server) adminNormalizeSpec(w http.ResponseWriter, r *http.Request) { + current, ok := s.requireCapability(w, r, identity.CapabilityCatalogEdit, true) + if !ok { + return + } + var request struct { + Expected catalog.NormalizationExpectation `json:"expected"` + } + if err := decodeJSON(r.Body, &request); err != nil || !validNormalizationExpectation(request.Expected) { + s.writeAPIError(w, r, http.StatusBadRequest, apiCodeInvalidJSON) + return + } + c, err := s.store.SpecNormalizationContext(r.Context(), r.PathValue("id")) + if err != nil { + s.writeCatalogError(w, r, err) + return + } + if c.Expected != request.Expected { + s.writeCatalogError(w, r, catalog.ErrRevisionConflict) + return + } + // Parsing uses the frozen server context outside writer admission. Record + // rechecks every expected version within the admitted transaction. + result := specnumeric.Normalize(c.Raw, c.Profile) + outcome, err := s.store.RecordSpecNormalization(r.Context(), current.UserID, catalog.NormalizeSpecCommand{SpecValueID: c.SpecValueID, Expected: request.Expected}, result) + if err != nil { + s.writeCatalogError(w, r, err) + return + } + writeJSON(w, http.StatusOK, outcome) +} +func (s *Server) adminAdoptSpec(w http.ResponseWriter, r *http.Request) { + current, ok := s.requireCapability(w, r, identity.CapabilityCatalogEdit, true) + if !ok { + return + } + var request struct { + Expected catalog.NormalizationExpectation `json:"expected"` + ValueJSON string `json:"value_json"` + } + if err := decodeJSON(r.Body, &request); err != nil || !validNormalizationExpectation(request.Expected) { + s.writeAPIError(w, r, http.StatusBadRequest, apiCodeInvalidJSON) + return + } + c, err := s.store.SpecNormalizationContext(r.Context(), r.PathValue("id")) + if err != nil { + s.writeCatalogError(w, r, err) + return + } + // Conflict precedence must survive even a now-invalid payload/profile. + if c.Expected != request.Expected { + s.writeCatalogError(w, r, catalog.ErrRevisionConflict) + return + } + var value specnumeric.Value + if err := decodeJSON(io.NopCloser(strings.NewReader(request.ValueJSON)), &value); err != nil { + s.writeAPIError(w, r, http.StatusBadRequest, apiCodeInvalidJSON) + return + } + unsupportedProfile := value.Unit != "" && value.Unit != c.Profile.CanonicalUnit || + value.Quantity != "" && value.Quantity != c.Profile.Quantity || + value.SemanticRole != "" && value.SemanticRole != c.Profile.SemanticRole || + value.Shape != "" && !c.Profile.AllowsShape(value.Shape) + if unsupportedProfile { + s.writeAPIError(w, r, http.StatusUnprocessableEntity, apiCodeValidationFailed) + return + } + if _, err := specnumeric.UnmarshalValue(request.ValueJSON, c.Profile); err != nil { + s.writeAPIError(w, r, http.StatusBadRequest, apiCodeInvalidJSON) + return + } + outcome, err := s.store.AdoptManualSpecValue(r.Context(), current.UserID, catalog.AdoptSpecCommand{SpecValueID: c.SpecValueID, Expected: request.Expected, ValueJSON: request.ValueJSON}) + if err != nil { + s.writeCatalogError(w, r, err) + return + } + writeJSON(w, http.StatusCreated, outcome) +} +func (s *Server) adminSelectAutomaticSpec(w http.ResponseWriter, r *http.Request) { + current, ok := s.requireCapability(w, r, identity.CapabilityCatalogEdit, true) + if !ok { + return + } + var request struct { + Expected catalog.NormalizationExpectation `json:"expected"` + CandidateID string `json:"candidate_id"` + } + if err := decodeJSON(r.Body, &request); err != nil || !validNormalizationExpectation(request.Expected) || request.CandidateID == "" { + s.writeAPIError(w, r, http.StatusBadRequest, apiCodeInvalidJSON) + return + } + outcome, err := s.store.SelectAutomaticSpecValue(r.Context(), current.UserID, catalog.SelectAutomaticSpecCommand{SpecValueID: r.PathValue("id"), Expected: request.Expected, CandidateID: request.CandidateID}) + if err != nil { + s.writeCatalogError(w, r, err) + return + } + writeJSON(w, http.StatusOK, outcome) +} +func (s *Server) adminSelectSpecSource(w http.ResponseWriter, r *http.Request) { + current, ok := s.requireCapability(w, r, identity.CapabilityCatalogEdit, true) + if !ok { + return + } + var request struct { + SpecID string `json:"spec_id"` + SpecValueID string `json:"spec_value_id"` + ExpectedProductRevision int64 `json:"expected_product_revision"` + ExpectedSourceRevision int64 `json:"expected_source_revision"` + ExpectedSourceSelectionRevision int64 `json:"expected_source_selection_revision"` + } + if err := decodeJSON(r.Body, &request); err != nil || request.SpecID == "" || request.SpecValueID == "" || request.ExpectedProductRevision < 1 || request.ExpectedSourceRevision < 1 || request.ExpectedSourceSelectionRevision < 0 { + s.writeAPIError(w, r, http.StatusBadRequest, apiCodeInvalidJSON) + return + } + result, err := s.store.SelectSpecSource(r.Context(), current.UserID, catalog.SelectSpecSourceCommand{ProductID: r.PathValue("id"), SpecID: request.SpecID, SpecValueID: request.SpecValueID, ExpectedProductRevision: request.ExpectedProductRevision, ExpectedSourceRevision: request.ExpectedSourceRevision, ExpectedSourceSelectionRevision: request.ExpectedSourceSelectionRevision}) + if err != nil { + s.writeCatalogError(w, r, err) + return + } + writeJSON(w, http.StatusOK, result) +} diff --git a/internal/webapp/spec_numeric_publication_test.go b/internal/webapp/spec_numeric_publication_test.go new file mode 100644 index 0000000..2305e89 --- /dev/null +++ b/internal/webapp/spec_numeric_publication_test.go @@ -0,0 +1,477 @@ +package webapp + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "sync/atomic" + "testing" + + "prods/internal/catalog" + "prods/internal/identity" + "prods/internal/publishing" + "prods/internal/specnumeric" + "prods/internal/storage/sqlite" +) + +// The real publisher stages and activates normally; only its background wake-up +// is paused so the test can observe the admitted revision before activation. +type numericPublicationKey struct{} +type numericPublicationRepository struct { + *sqlite.Store + beforeActivate func() + specRefreshUnavailable *atomic.Bool +} + +func (r *numericPublicationRepository) RebuildSpecSearchProjection(ctx context.Context, active []publishing.ActivePublication) error { + if r.specRefreshUnavailable != nil && r.specRefreshUnavailable.Load() { + return errors.New("test fixture holds the previous spec projection") + } + return r.Store.RebuildSpecSearchProjection(ctx, active) +} + +func (r *numericPublicationRepository) ClaimPublicationIntent(ctx context.Context) (publishing.Intent, bool, error) { + if ctx.Value(numericPublicationKey{}) == nil { + return publishing.Intent{}, false, nil + } + return r.Store.ClaimPublicationIntent(ctx) +} +func (r *numericPublicationRepository) ProcessSpecNumericProfileWork(ctx context.Context, limit int) (bool, error) { + if ctx.Value(numericPublicationKey{}) == nil { + return false, nil + } + return r.Store.ProcessSpecNumericProfileWork(ctx, limit) +} + +type numericPublicationFixture struct { + store *sqlite.Store + engine *publishing.Engine + actor, productID, valueID, root string + ctx context.Context +} + +func newNumericPublicationFixture(t testing.TB) numericPublicationFixture { + return newNumericPublicationFixtureForBackend(t, "") +} + +func newNumericPublicationFixtureForBackend(t testing.TB, backend string) numericPublicationFixture { + return newNumericPublicationFixtureWithConfig(t, publishing.Config{SpecSearchBackend: backend}, nil) +} + +func newNumericPublicationFixtureWithConfig(t testing.TB, config publishing.Config, specRefreshUnavailable *atomic.Bool) numericPublicationFixture { + t.Helper() + root := t.TempDir() + store, err := sqlite.Create(filepath.Join(root, "prods.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { store.Close() }) + hash, err := identity.HashPassword("ownerpass1") + if err != nil { + t.Fatal(err) + } + owner, err := store.CompleteInstallation(t.Context(), sqlite.Installation{OwnerEmail: "owner@example.test", PasswordHash: hash, DefaultLocale: "en-US", SupportedLocales: []string{"en-US", "zh-TW"}, TimeZone: "UTC"}) + if err != nil { + t.Fatal(err) + } + category, err := store.CreateCategory(t.Context(), owner.ID, catalog.Category{Name: "Numeric", Slug: "numeric"}) + if err != nil { + t.Fatal(err) + } + spec, err := store.CreateSpecDefinition(t.Context(), owner.ID, catalog.SpecDefinition{Name: "Operating voltage", Filterable: true}) + if err != nil { + t.Fatal(err) + } + set, err := store.CreateSpecSet(t.Context(), owner.ID, catalog.SpecSet{Name: "Numeric", SpecIDs: []string{spec.ID}}) + if err != nil { + t.Fatal(err) + } + if err = store.SetCategorySpecSet(t.Context(), owner.ID, category.ID, set.ID, category.Revision); err != nil { + t.Fatal(err) + } + profile := specnumeric.Profile{Quantity: "voltage", SemanticRole: specnumeric.RoleOperatingSupport, AllowedShapes: []specnumeric.Shape{specnumeric.ShapeIntervalSet}, AllowedOperators: []specnumeric.Operator{specnumeric.OperatorContainsPoint}, CanonicalUnit: "V", NumericFormat: specnumeric.NumericFormatDecimalDot, SchemaVersion: specnumeric.SchemaVersion, ParserVersion: specnumeric.ParserVersion, UnitRegistryVersion: specnumeric.UnitRegistryVersion} + if _, err = store.ConfigureSpecNumericProfile(t.Context(), owner.ID, spec.ID, spec.Revision, profile); err != nil { + t.Fatal(err) + } + product, err := store.CreateProduct(t.Context(), owner.ID, catalog.Product{PartNumber: "NUMERIC-PUBLIC", CategoryID: category.ID, Status: catalog.Published, Specification: "Shared specification"}) + if err != nil { + t.Fatal(err) + } + value, err := store.SaveSpecValue(t.Context(), owner.ID, product.ID, spec.ID, "3–5V", "en-US", product.Revision) + if err != nil { + t.Fatal(err) + } + config.Root = filepath.Join(root, "generated") + config.BaseURL = "http://catalog.example.test" + engine, err := publishing.NewEngine(t.Context(), &numericPublicationRepository{Store: store, specRefreshUnavailable: specRefreshUnavailable}, config) + if err != nil { + t.Fatal(err) + } + t.Cleanup(engine.Close) + f := numericPublicationFixture{store, engine, owner.ID, product.ID, value.ID, root, context.WithValue(t.Context(), numericPublicationKey{}, true)} + f.adopt(t) + f.drain(t) + return f +} +func (f numericPublicationFixture) adopt(t testing.TB) { + t.Helper() + c, err := f.store.SpecNormalizationContext(t.Context(), f.valueID) + if err != nil { + t.Fatal(err) + } + result := specnumeric.Normalize(c.Raw, c.Profile) + if result.Candidate == nil { + t.Fatalf("parse: %+v", result) + } + encoded, err := specnumeric.MarshalValue(*result.Candidate) + if err != nil { + t.Fatal(err) + } + if _, err = f.store.AdoptManualSpecValue(t.Context(), f.actor, catalog.AdoptSpecCommand{SpecValueID: f.valueID, Expected: c.Expected, ValueJSON: encoded}); err != nil { + t.Fatal(err) + } +} +func (f numericPublicationFixture) drain(t testing.TB) { + t.Helper() + for n := 0; n < 30; n++ { + worked, err := f.engine.ProcessOne(f.ctx) + if err != nil { + t.Fatal(err) + } + if !worked { + return + } + } + t.Fatal("publication work did not drain") +} +func (f numericPublicationFixture) view(t testing.TB) publishing.PublicView { + t.Helper() + views, err := f.engine.Views() + if err != nil { + t.Fatal(err) + } + if len(views) != 1 { + t.Fatalf("views=%d", len(views)) + } + return views[0] +} +func TestNumericFactsStayOnActiveRevisionUntilActivation(t *testing.T) { + f := newNumericPublicationFixture(t) + before := f.view(t) + if len(before.NumericFacts) != 1 { + t.Fatalf("adopted facts=%+v", before.NumericFacts) + } + c, err := f.store.SpecNormalizationContext(t.Context(), f.valueID) + if err != nil { + t.Fatal(err) + } + if _, err = f.store.SaveSpecValue(t.Context(), f.actor, f.productID, c.SpecID, "6–9V", "zh-TW", c.Expected.ProductRevision); err != nil { + t.Fatal(err) + } + f.adopt(t) + hits, err := f.engine.Search(t.Context(), "NUMERIC-PUBLIC") + if err != nil { + t.Fatal(err) + } + if len(hits) != 1 || !reflect.DeepEqual(before.NumericFacts, hits[0].NumericFacts) || hits[0].Revision != before.Revision { + t.Fatal("working facts leaked into active search") + } + source, err := f.store.PublicationSource(t.Context(), f.productID) + if err != nil { + t.Fatal(err) + } + if reflect.DeepEqual(source.NumericFacts, before.NumericFacts) { + t.Fatal("working source did not change") + } + f.drain(t) + after := f.view(t) + if !reflect.DeepEqual(after.NumericFacts, source.NumericFacts) || after.Revision <= before.Revision { + t.Fatal("activation did not install frozen facts") + } + if !reflect.DeepEqual(after.ForLocale("zh-TW").NumericFacts, after.NumericFacts) { + t.Fatal("locale changed shared facts") + } +} +func TestAdoptedNumericFactsPersistInPublicActivation(t *testing.T) { + f := newNumericPublicationFixture(t) + view := f.view(t) + source, err := f.store.PublicationSource(t.Context(), f.productID) + if err != nil { + t.Fatal(err) + } + if len(source.NumericFacts) != 1 || !reflect.DeepEqual(source.NumericFacts, view.NumericFacts) { + t.Fatalf("source/view facts: %+v / %+v", source.NumericFacts, view.NumericFacts) + } + // Site-wide activation must persist the same numeric definition and facts. + epoch, config, err := f.store.PublicSiteRouteConfig(t.Context()) + if err != nil { + t.Fatal(err) + } + _, workingRevision, err := f.store.WorkingWebsiteConfiguration(t.Context()) + if err != nil { + t.Fatal(err) + } + if _, err = f.engine.PublishSiteRoutes(t.Context(), publishing.SiteRouteRequest{ActorID: f.actor, ExpectedEpoch: epoch, ExpectedWorkingRevision: workingRevision, Config: config}); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(f.view(t).NumericFacts, view.NumericFacts) { + t.Fatal("site activation changed facts") + } + f.engine.Close() + if err = f.store.Close(); err != nil { + t.Fatal(err) + } + reopened, err := sqlite.OpenReady(filepath.Join(f.root, "prods.db")) + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + active, err := reopened.ActivePublications(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(active) != 1 || !reflect.DeepEqual(active[0].View.NumericFacts, view.NumericFacts) || !reflect.DeepEqual(active[0].View.NumericDefinitions, view.NumericDefinitions) { + t.Fatalf("lost persisted facts/profile: %+v", active) + } + encoded, err := json.Marshal(active[0].View) + if err != nil { + t.Fatal(err) + } + var public map[string]json.RawMessage + if err = json.Unmarshal(encoded, &public); err != nil { + t.Fatal(err) + } + if len(public["numeric_facts"]) == 0 || len(public["numeric_definitions"]) == 0 { + t.Fatal("numeric snapshot not serialized") + } +} + +func TestSharedSpecificationDoesNotUseLocaleFallback(t *testing.T) { + f := newNumericPublicationFixture(t) + view := f.view(t) + view.Localizations = map[string]publishing.LocalizedContent{"zh-TW": {Name: "Localized name", Description: "Localized description", Features: "Localized features", Specification: "Legacy translated specification"}} + localized := view.ForLocale("zh-TW") + if localized.Specification != "Shared specification" { + t.Fatalf("specification=%q", localized.Specification) + } + if localized.Name != "Localized name" || localized.Description != "Localized description" || localized.Features != "Localized features" { + t.Fatal("approved content translations stopped resolving") + } + if _, ok := localized.FieldLocalization["specification"]; ok { + t.Fatal("shared specification reports locale fallback") + } +} + +func TestSharedSpecManifestRejectsOldUnitAndRestages(t *testing.T) { + f := newNumericPublicationFixture(t) + active, err := f.store.ActivePublications(t.Context()) + if err != nil { + t.Fatal(err) + } + old := active[0] + directory := filepath.Join(f.root, "generated", "units", old.ArtifactID) + body, err := os.ReadFile(filepath.Join(directory, "manifest.json")) + if err != nil { + t.Fatal(err) + } + var manifest map[string]any + if err = json.Unmarshal(body, &manifest); err != nil { + t.Fatal(err) + } + delete(manifest, "render_contract_version") + body, err = json.Marshal(manifest) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(body) + hash := hex.EncodeToString(sum[:]) + if err = os.WriteFile(filepath.Join(directory, "manifest.json"), body, 0600); err != nil { + t.Fatal(err) + } + if err = os.WriteFile(filepath.Join(directory, "prepared"), []byte(hash), 0600); err != nil { + t.Fatal(err) + } + db, err := sql.Open("sqlite", filepath.Join(f.root, "prods.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err = db.Exec(`UPDATE public_activations SET manifest_hash=? WHERE product_id=?`, hash, f.productID); err != nil { + t.Fatal(err) + } + // Simulate the new source revision/intent supplied by the migration using an + // ordinary controlled edit; the legacy activation and all files remain intact. + p, err := f.store.Product(t.Context(), f.productID) + if err != nil { + t.Fatal(err) + } + if _, err = f.store.UpdateProduct(t.Context(), f.actor, p.Revision, p); err != nil { + t.Fatal(err) + } + if err = f.engine.Reconcile(t.Context()); err != nil { + t.Fatal(err) + } + views, err := f.engine.Views() + if err != nil { + t.Fatal(err) + } + if len(views) != 0 { + t.Fatal("incompatible unit admitted") + } + for _, suffix := range []string{"", ".json", ".md"} { + recorder := httptest.NewRecorder() + served := f.engine.ServePath(recorder, httptest.NewRequest("GET", old.Route+suffix, nil)) + if served && recorder.Code == 200 { + t.Fatalf("incompatible format admitted: %s", suffix) + } + } + f.drain(t) + next, err := f.store.ActivePublications(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(next) != 1 || next[0].ArtifactID == old.ArtifactID || next[0].PublicRevision <= old.PublicRevision { + t.Fatalf("restage=%+v", next) + } + body, err = os.ReadFile(filepath.Join(f.root, "generated", "units", next[0].ArtifactID, "manifest.json")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(body), `"render_contract_version":"shared-specs-1"`) { + t.Fatalf("manifest=%s", body) + } +} + +func TestNumericProfileChangeRejectsStagedFactsBeforeWorkerRuns(t *testing.T) { + f := newNumericPublicationFixture(t) + before := f.view(t) + f.engine.Close() + repository := &numericPublicationRepository{Store: f.store} + engine, err := publishing.NewEngine(t.Context(), repository, publishing.Config{Root: filepath.Join(f.root, "generated"), BaseURL: "http://catalog.example.test"}) + if err != nil { + t.Fatal(err) + } + defer engine.Close() + f.engine = engine + c, err := f.store.SpecNormalizationContext(t.Context(), f.valueID) + if err != nil { + t.Fatal(err) + } + if _, err = f.store.SaveSpecValue(t.Context(), f.actor, f.productID, c.SpecID, "6–9V", "en-US", c.Expected.ProductRevision); err != nil { + t.Fatal(err) + } + f.adopt(t) + repository.beforeActivate = func() { + definitions, err := f.store.ListSpecDefinitions(t.Context()) + if err != nil { + t.Fatal(err) + } + var revision int64 + for _, d := range definitions { + if d.ID == c.SpecID { + revision = d.Revision + } + } + profile := c.Profile + profile.DeclaredInputUnit = "V" + if _, err = f.store.ConfigureSpecNumericProfile(t.Context(), f.actor, c.SpecID, revision, profile); err != nil { + t.Fatal(err) + } + } + // Older pending intents may be superseded first; stop immediately after the + // actual staging/activation hook, before the next call can advance profile work. + for n := 0; repository.beforeActivate != nil && n < 10; n++ { + if _, err = engine.ProcessOne(f.ctx); err != nil { + t.Fatal(err) + } + } + if repository.beforeActivate != nil { + t.Fatal("candidate never staged") + } + got := f.view(t) + if got.Revision != before.Revision || !reflect.DeepEqual(got.NumericFacts, before.NumericFacts) { + t.Fatal("stale staged numeric profile activated before durable worker") + } + source, err := f.store.PublicationSource(t.Context(), f.productID) + if err != nil { + t.Fatal(err) + } + if len(source.NumericFacts) != 0 || len(source.NumericDefinitions) != 1 { + t.Fatal("profile mismatch must invalidate working eligibility immediately") + } + f.drain(t) + if len(f.view(t).NumericFacts) != 0 { + t.Fatal("new profile revision retained stale numeric facts") + } +} + +func (r *numericPublicationRepository) ActivatePublication(ctx context.Context, intent publishing.Intent, activation publishing.ActivePublication, coordinator publishing.VisibilityCoordinator) (bool, error) { + if r.beforeActivate != nil { + hook := r.beforeActivate + r.beforeActivate = nil + hook() + } + return r.Store.ActivatePublication(ctx, intent, activation, coordinator) +} + +func TestSharedSpecUnresolvedSourceRemainsPublishable(t *testing.T) { + f := newNumericPublicationFixture(t) + c, err := f.store.SpecNormalizationContext(t.Context(), f.valueID) + if err != nil { + t.Fatal(err) + } + db, err := sql.Open("sqlite", filepath.Join(f.root, "prods.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err = db.Exec(`INSERT INTO product_spec_values(id,product_id,spec_id,raw_value,source_locale,source_origin_locale,source_revision,active,created_by,updated_by,created_at,updated_at) + SELECT 'spv_legacy_public',product_id,spec_id,'6–8V','zh-TW','zh-TW',1,active,created_by,updated_by,created_at,updated_at FROM product_spec_values WHERE id=?`, f.valueID); err != nil { + t.Fatal(err) + } + if _, err = db.Exec(`DELETE FROM product_spec_sources WHERE product_id=? AND spec_id=?`, f.productID, c.SpecID); err != nil { + t.Fatal(err) + } + p, err := f.store.Product(t.Context(), f.productID) + if err != nil { + t.Fatal(err) + } + if _, err = f.store.UpdateProduct(t.Context(), f.actor, p.Revision, p); err != nil { + t.Fatal(err) + } + f.drain(t) + view := f.view(t) + if len(view.Specifications) != 0 || len(view.NumericFacts) != 0 || len(view.NumericDefinitions) != 1 { + t.Fatalf("unresolved source guessed or definition lost: %+v", view) + } + var count int + if err = db.QueryRow(`SELECT COUNT(*) FROM product_spec_values WHERE product_id=?`, f.productID).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 2 { + t.Fatal("legacy Raw sources lost") + } + p, err = f.store.Product(t.Context(), f.productID) + if err != nil { + t.Fatal(err) + } + if _, err = f.store.SelectSpecSource(t.Context(), f.actor, catalog.SelectSpecSourceCommand{ProductID: f.productID, SpecID: c.SpecID, SpecValueID: "spv_legacy_public", ExpectedProductRevision: p.Revision, ExpectedSourceRevision: 1}); err != nil { + t.Fatal(err) + } + f.drain(t) + view = f.view(t) + if len(view.Specifications) != 1 || view.Specifications[0].RawValue != "6–8V" || len(view.NumericFacts) != 0 { + t.Fatalf("selected Raw without normalization: %+v", view.Specifications) + } + if !reflect.DeepEqual(view.Specifications, view.ForLocale("en-US").Specifications) { + t.Fatal("locale selected structured Raw") + } +} diff --git a/internal/webapp/spec_numeric_test.go b/internal/webapp/spec_numeric_test.go new file mode 100644 index 0000000..24756be --- /dev/null +++ b/internal/webapp/spec_numeric_test.go @@ -0,0 +1,214 @@ +package webapp + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "prods/internal/catalog" + "prods/internal/publishing" + "prods/internal/site" + "prods/internal/specnumeric" + "strings" + "testing" +) + +func numericHTTPFixture(t *testing.T) (numericPublicationFixture, *httptest.Server, *http.Client, string) { + t.Helper() + f := newNumericPublicationFixture(t) + app, _, err := New(f.store, Config{BaseURL: "http://catalog.example.test", WorkDir: t.TempDir(), PublicDir: filepath.Join(f.root, "generated")}) + if err != nil { + t.Fatal(err) + } + app.publisher.Close() + app.publisher = f.engine + t.Cleanup(app.Close) + server := httptest.NewServer(app) + t.Cleanup(server.Close) + client := newCookieClient(t) + csrf := loginNormalAdmin(t, client, server.URL, "owner@example.test", "ownerpass1") + return f, server, client, csrf +} +func TestNumericCommandUsesServerValidatedAdoption(t *testing.T) { + f, server, client, csrf := numericHTTPFixture(t) + response := postAdminJSON(t, client, server.URL+"/admin/api/spec-values/"+f.valueID+"/normalized", csrf, `{"source":"automatic","status":"current"}`) + defer response.Body.Close() + if response.StatusCode != http.StatusBadRequest { + t.Fatalf("client trust markers status=%d", response.StatusCode) + } +} +func TestNumericCommandAdoptsValidatedValue(t *testing.T) { + f, server, client, csrf := numericHTTPFixture(t) + response, err := client.Get(server.URL + "/admin/api/spec-values/" + f.valueID + "/normalization-context") + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != 200 { + t.Fatalf("context status=%d", response.StatusCode) + } + var c catalog.SpecNormalizationContext + if err = json.NewDecoder(response.Body).Decode(&c); err != nil { + t.Fatal(err) + } + if c.SpecValueID != f.valueID || c.SourceSelection.SpecValueID != f.valueID || c.SourceSelection.Revision < 1 { + t.Fatalf("context=%+v", c) + } + value, err := specnumeric.MarshalValue(*specnumeric.Normalize(c.Raw, c.Profile).Candidate) + if err != nil { + t.Fatal(err) + } + unsupported, _ := json.Marshal(map[string]any{"expected": c.Expected, "value_json": strings.Replace(value, `"unit":"V"`, `"unit":"A"`, 1)}) + rejected := postAdminJSON(t, client, server.URL+"/admin/api/spec-values/"+f.valueID+"/normalized", csrf, string(unsupported)) + if body := responseBody(t, rejected); rejected.StatusCode != http.StatusUnprocessableEntity { + t.Fatalf("unsupported manual unit status=%d body=%s", rejected.StatusCode, body) + } + body, _ := json.Marshal(map[string]any{"expected": c.Expected, "value_json": value}) + response = postAdminJSON(t, client, server.URL+"/admin/api/spec-values/"+f.valueID+"/normalized", csrf, string(body)) + defer response.Body.Close() + if response.StatusCode != 201 { + data, _ := io.ReadAll(response.Body) + t.Fatalf("adoption status=%d body=%s", response.StatusCode, data) + } + var outcome catalog.NormalizationOutcome + if err = json.NewDecoder(response.Body).Decode(&outcome); err != nil { + t.Fatal(err) + } + if outcome.NormalizedValueID == "" || outcome.ProductRevision <= c.Expected.ProductRevision || outcome.SelectionRevision <= c.Expected.SelectionRevision { + t.Fatalf("outcome=%+v", outcome) + } + // A stale command remains a conflict even when its payload is invalid. + body, _ = json.Marshal(map[string]any{"expected": c.Expected, "value_json": "{}"}) + response = postAdminJSON(t, client, server.URL+"/admin/api/spec-values/"+f.valueID+"/normalized", csrf, string(body)) + defer response.Body.Close() + if response.StatusCode != 409 { + t.Fatalf("stale status=%d", response.StatusCode) + } +} +func TestPublicSpecSearchRejectsUnsupportedOperator(t *testing.T) { + f, server, _, _ := numericHTTPFixture(t) + c, err := f.store.SpecNormalizationContext(t.Context(), f.valueID) + if err != nil { + t.Fatal(err) + } + body := `{"predicates":[{"spec_id":"` + c.SpecID + `","operator":"approximately","value":"4","unit":"V"}],"page":1,"page_size":20}` + response, err := http.Post(server.URL+"/api/search/specs", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != 422 { + t.Fatalf("unsupported status=%d", response.StatusCode) + } + // A known operator rejected by the frozen profile must retain the typed + // unsupported classification through the publishing engine. + body = strings.Replace(body, `"operator":"approximately"`, `"operator":"contains_point"`, 1) + body = strings.Replace(body, `"unit":"V"`, `"unit":"A"`, 1) + response, err = http.Post(server.URL+"/api/search/specs", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusUnprocessableEntity { + t.Fatalf("unsupported scoped unit status=%d", response.StatusCode) + } + +} +func TestPublicSpecSearchReturnsFrozenRows(t *testing.T) { + f, server, _, _ := numericHTTPFixture(t) + request := specSearchRequest(t, f, "4") + body, _ := json.Marshal(map[string]any{"predicates": request.Predicates, "page": 1, "page_size": 20}) + response, err := http.Post(server.URL+"/api/search/specs", "application/json", strings.NewReader(string(body))) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + data, _ := io.ReadAll(response.Body) + if response.StatusCode != 200 { + t.Fatalf("search status=%d body=%s", response.StatusCode, data) + } + var result publishing.SpecSearchResult + if err = json.Unmarshal(data, &result); err != nil { + t.Fatal(err) + } + if result.Total != 1 || len(result.Rows) != 1 || result.Rows[0].ID != f.productID { + t.Fatalf("result=%+v", result) + } + if result.Rows[0].Language != f.view(t).DefaultLocale { + t.Fatalf("search locale=%s", result.Rows[0].Language) + } + for _, private := range []string{"updated_by", "normalizer_version", "failure_reason", "source_origin_locale"} { + if strings.Contains(string(data), `"`+private+`"`) { + t.Fatalf("private field %s leaked", private) + } + } +} + +// The engine must also enforce locale admission: an HTTP policy snapshot can +// precede activation, and an omitted locale is not an explicit old default. +func TestPublicSpecSearchUsesAdmittedLocalePolicy(t *testing.T) { + f := newNumericPublicationFixture(t) + request := specSearchRequest(t, f, "4") + website, err := f.store.WebsiteState(t.Context()) + if err != nil { + t.Fatal(err) + } + working, err := f.store.SaveWebsiteLocalization(t.Context(), f.actor, website.WorkingRevision, site.WebsiteLocalization{DefaultLocale: "zh-TW", EnabledLocales: []string{"zh-TW"}, ContentEditingEnabled: true}) + if err != nil { + t.Fatal(err) + } + result, err := f.engine.SearchSpecs(t.Context(), request) + if err != nil { + t.Fatal(err) + } + if result.Total != 1 || result.Rows[0].Language != "en-US" { + t.Fatal("working locale affected active search") + } + epoch, config, err := f.store.PublicSiteRouteConfig(t.Context()) + if err != nil { + t.Fatal(err) + } + if _, err = f.engine.PublishSiteRoutes(t.Context(), publishing.SiteRouteRequest{ActorID: f.actor, ExpectedEpoch: epoch, ExpectedWorkingRevision: working.WorkingRevision, Config: config}); err != nil { + t.Fatal(err) + } + result, err = f.engine.SearchSpecs(t.Context(), request) + if err != nil { + t.Fatal(err) + } + if result.Total != 1 || result.Rows[0].Language != "zh-TW" { + t.Fatal("omitted locale did not resolve newly admitted default") + } + request.Locale = "en-US" + if _, err = f.engine.SearchSpecs(t.Context(), request); !errors.Is(err, publishing.ErrInvalidSpecSearch) { + t.Fatalf("revoked locale admitted: %v", err) + } +} + +func TestPublicSpecSearchRejectsNullPointOperand(t *testing.T) { + f, server, _, _ := numericHTTPFixture(t) + request := specSearchRequest(t, f, "4") + body := `{"predicates":[{"spec_id":"` + request.Predicates[0].SpecID + `","operator":"contains_point","value":"4","lower":null,"unit":"V"}],"page":1,"page_size":20}` + response, err := http.Post(server.URL+"/api/search/specs", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + if body := responseBody(t, response); response.StatusCode != http.StatusBadRequest { + t.Fatalf("forbidden null point operand status=%d body=%s", response.StatusCode, body) + } +} + +func TestPublicSpecSearchRejectsNullRangeOperand(t *testing.T) { + f, server, _, _ := numericHTTPFixture(t) + request := specSearchRequest(t, f, "4") + body := `{"predicates":[{"spec_id":"` + request.Predicates[0].SpecID + `","operator":"contains_range","lower":"3","upper":"5","value":null,"unit":"V"}],"page":1,"page_size":20}` + response, err := http.Post(server.URL+"/api/search/specs", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatal(err) + } + // Reject the wire shape before consulting this profile's allowed operators. + if body := responseBody(t, response); response.StatusCode != http.StatusBadRequest { + t.Fatalf("forbidden null range operand status=%d body=%s", response.StatusCode, body) + } +} diff --git a/internal/webapp/spec_search.go b/internal/webapp/spec_search.go new file mode 100644 index 0000000..c6e97b0 --- /dev/null +++ b/internal/webapp/spec_search.go @@ -0,0 +1,105 @@ +package webapp + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "slices" + + "prods/internal/publishing" + "prods/internal/specnumeric" +) + +// Public search is a bounded read command, independent of Admin sessions. +func (s *Server) publicSpecSearch(w http.ResponseWriter, r *http.Request) { + var request struct { + Predicates []specSearchPredicate `json:"predicates"` + CategoryID string `json:"category_id"` + ManufacturerID string `json:"manufacturer_id"` + Locale string `json:"locale"` + Page int `json:"page"` + PageSize int `json:"page_size"` + } + if err := decodeJSON(r.Body, &request); err != nil || len(request.Predicates) < 1 || len(request.Predicates) > 16 || request.Page < 1 || request.PageSize < 1 || request.PageSize > 100 { + s.writeAPIError(w, r, http.StatusBadRequest, apiCodeInvalidJSON) + return + } + predicates := make([]specnumeric.Predicate, len(request.Predicates)) + for i, wire := range request.Predicates { + predicate, err := wire.predicate() + if err != nil { + s.writeAPIError(w, r, http.StatusBadRequest, apiCodeInvalidJSON) + return + } + predicates[i] = predicate + } + if s.publisher == nil { + s.writeAPIError(w, r, http.StatusServiceUnavailable, apiCodeServiceUnavailable) + return + } + website, err := s.store.WebsiteState(r.Context()) + if err != nil { + s.internalAPIError(w, r, err) + return + } + active := website.ActiveLocalization + if request.Locale != "" && !slices.Contains(active.EnabledLocales, request.Locale) { + s.writeAPIError(w, r, http.StatusBadRequest, apiCodeValidationFailed) + return + } + result, err := s.publisher.SearchSpecs(r.Context(), publishing.SpecSearchRequest{Predicates: predicates, CategoryID: request.CategoryID, ManufacturerID: request.ManufacturerID, Locale: request.Locale, Page: request.Page, PageSize: request.PageSize}) + switch { + case errors.Is(err, publishing.ErrUnsupportedSpecSearch): + s.writeAPIError(w, r, http.StatusUnprocessableEntity, apiCodeValidationFailed) + case errors.Is(err, publishing.ErrInvalidSpecSearch): + s.writeAPIError(w, r, http.StatusBadRequest, apiCodeInvalidJSON) + case errors.Is(err, publishing.ErrPublicUnavailable): + s.writeAPIError(w, r, http.StatusServiceUnavailable, apiCodeServiceUnavailable) + case err != nil: + s.internalAPIError(w, r, err) + default: + writeJSON(w, http.StatusOK, result) + } +} + +// Preserve key presence until the closed operand union has been validated. +// A pointer-only DTO cannot distinguish a forbidden null key from absence. +type specSearchPredicate struct { + SpecID string `json:"spec_id"` + Operator specnumeric.Operator `json:"operator"` + Value json.RawMessage `json:"value"` + Lower json.RawMessage `json:"lower"` + Upper json.RawMessage `json:"upper"` + Unit string `json:"unit"` +} + +func (wire specSearchPredicate) predicate() (specnumeric.Predicate, error) { + present := func(value json.RawMessage) bool { + return len(value) > 0 && !bytes.Equal(bytes.TrimSpace(value), []byte("null")) + } + invalid := false + switch wire.Operator { + case specnumeric.OperatorBetween, specnumeric.OperatorContainsRange, specnumeric.OperatorOverlapsRange: + invalid = wire.Value != nil || !present(wire.Lower) || !present(wire.Upper) + case specnumeric.OperatorContainsPoint, specnumeric.OperatorEqual, specnumeric.OperatorLess, specnumeric.OperatorLessEqual, specnumeric.OperatorGreater, specnumeric.OperatorGreaterEqual: + invalid = wire.Lower != nil || wire.Upper != nil || !present(wire.Value) + } + if invalid { + return specnumeric.Predicate{}, errors.New("invalid predicate operand fields") + } + predicate := specnumeric.Predicate{SpecID: wire.SpecID, Operator: wire.Operator, Unit: wire.Unit} + for _, operand := range []struct { + encoded json.RawMessage + target **specnumeric.Decimal + }{ + {wire.Value, &predicate.Value}, {wire.Lower, &predicate.Lower}, {wire.Upper, &predicate.Upper}, + } { + if len(operand.encoded) > 0 { + if err := json.Unmarshal(operand.encoded, operand.target); err != nil { + return specnumeric.Predicate{}, err + } + } + } + return predicate, nil +} diff --git a/internal/webapp/spec_search_benchmark_test.go b/internal/webapp/spec_search_benchmark_test.go new file mode 100644 index 0000000..0112b4f --- /dev/null +++ b/internal/webapp/spec_search_benchmark_test.go @@ -0,0 +1,312 @@ +package webapp + +import ( + "fmt" + "os" + "path/filepath" + "reflect" + "strconv" + "testing" + + "prods/internal/catalog" + "prods/internal/publishing" + "prods/internal/specnumeric" + "prods/internal/storage/sqlite" +) + +// This fixture uses real controlled writes, adopted facts, publication units, +// file-backed SQLite and the selected production candidate provider. The +// inherited repository pauses publication wake-ups; ProcessBatch explicitly +// drains setup work. Once timed, the published corpus is immutable. +type specBenchmarkCorpus struct { + numericPublicationFixture + queries []specBenchmarkQuery + products int +} + +type specBenchmarkQuery struct { + name string + request publishing.SpecSearchRequest +} + +func specBenchmarkProducts(t testing.TB) int { + t.Helper() + if raw := os.Getenv("PRODS_BENCH_PRODUCTS"); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil || n < 1 { + t.Fatalf("PRODS_BENCH_PRODUCTS must be positive: %q", raw) + } + return n + } + return 1000 +} + +func newSpecBenchmarkCorpus(t testing.TB, backend string) *specBenchmarkCorpus { + return newSpecBenchmarkCorpusWithCount(t, backend, specBenchmarkProducts(t), true) +} + +// Worker benchmarks preserve the same controlled corpus writes but leave the +// initial publication backlog undrained. Explicit setup work is never timed. +func newSpecBenchmarkCorpusWithCount(t testing.TB, backend string, products int, drain bool) *specBenchmarkCorpus { + t.Helper() + f := newNumericPublicationFixtureForBackend(t, backend) + seed, err := f.store.Product(t.Context(), f.productID) + if err != nil { + t.Fatal(err) + } + support, err := f.store.CreateSpecDefinition(t.Context(), f.actor, catalog.SpecDefinition{Name: "Benchmark support", Filterable: true}) + if err != nil { + t.Fatal(err) + } + nominal, err := f.store.CreateSpecDefinition(t.Context(), f.actor, catalog.SpecDefinition{Name: "Benchmark nominal", Filterable: true}) + if err != nil { + t.Fatal(err) + } + profile := specnumeric.Profile{Quantity: "voltage", SemanticRole: specnumeric.RoleOperatingSupport, AllowedShapes: []specnumeric.Shape{specnumeric.ShapeIntervalSet, specnumeric.ShapeDiscreteSet}, AllowedOperators: []specnumeric.Operator{specnumeric.OperatorContainsPoint, specnumeric.OperatorOverlapsRange}, CanonicalUnit: "V", NumericFormat: specnumeric.NumericFormatDecimalDot, SchemaVersion: specnumeric.SchemaVersion, ParserVersion: specnumeric.ParserVersion, UnitRegistryVersion: specnumeric.UnitRegistryVersion} + if _, err = f.store.ConfigureSpecNumericProfile(t.Context(), f.actor, support.ID, support.Revision, profile); err != nil { + t.Fatal(err) + } + profile.SemanticRole = specnumeric.RoleNominal + profile.AllowedShapes = []specnumeric.Shape{specnumeric.ShapeScalar, specnumeric.ShapeTolerance} + profile.AllowedOperators = []specnumeric.Operator{specnumeric.OperatorBetween} + if _, err = f.store.ConfigureSpecNumericProfile(t.Context(), f.actor, nominal.ID, nominal.Revision, profile); err != nil { + t.Fatal(err) + } + set, err := f.store.CreateSpecSet(t.Context(), f.actor, catalog.SpecSet{Name: "Benchmark", SpecIDs: []string{support.ID, nominal.ID}}) + if err != nil { + t.Fatal(err) + } + category, err := f.store.Category(t.Context(), seed.CategoryID) + if err != nil { + t.Fatal(err) + } + if err = f.store.SetCategorySpecSet(t.Context(), f.actor, category.ID, set.ID, category.Revision); err != nil { + t.Fatal(err) + } + f.drain(t) + for i := 0; i < products; i++ { + var p catalog.Product + if i == 0 { + p, err = f.store.Product(t.Context(), seed.ID) + if err == nil { + p.PartNumber = fmt.Sprintf("BENCH-%06d", i) + p, err = f.store.UpdateProduct(t.Context(), f.actor, p.Revision, p) + } + } else { + p, err = f.store.CreateProduct(t.Context(), f.actor, catalog.Product{PartNumber: fmt.Sprintf("BENCH-%06d", i), CategoryID: category.ID, Status: catalog.Published, Specification: "Benchmark shared specification"}) + } + if err != nil { + t.Fatal(err) + } + n := i % 100 + supportRaw := fmt.Sprintf("%d–%d V", n, n+2) + if i%2 == 1 { + supportRaw = fmt.Sprintf("%d / %d V", n, n+2) + } + nominalRaw := fmt.Sprintf("%d V", i%25) + if i%2 == 1 { + nominalRaw = fmt.Sprintf("%d V ±0.5 V", i%25) + } + for _, value := range []struct{ id, raw string }{{support.ID, supportRaw}, {nominal.ID, nominalRaw}} { + p, err = f.store.Product(t.Context(), p.ID) + if err != nil { + t.Fatal(err) + } + v, saveErr := f.store.SaveSpecValue(t.Context(), f.actor, p.ID, value.id, value.raw, "en-US", p.Revision) + if saveErr != nil { + t.Fatal(saveErr) + } + adopter := f + adopter.valueID = v.ID + adopter.adopt(t) + } + } + if drain { + if _, err = f.engine.ProcessBatch(f.ctx, 0); err != nil { + t.Fatal(err) + } + views, err := f.engine.Views() + if err != nil || len(views) != products { + t.Fatalf("corpus views=%d err=%v", len(views), err) + } + for _, view := range views { + if len(view.NumericFacts) != 2 { + t.Fatalf("corpus product %s facts=%d", view.PartNumber, len(view.NumericFacts)) + } + } + } + decimal := func(s string) *specnumeric.Decimal { + d, err := specnumeric.ParseDecimal(s) + if err != nil { + t.Fatal(err) + } + return &d + } + request := func(predicates ...specnumeric.Predicate) publishing.SpecSearchRequest { + return publishing.SpecSearchRequest{Predicates: predicates, Locale: "en-US", Page: 1, PageSize: 50} + } + c := &specBenchmarkCorpus{numericPublicationFixture: f, products: products, queries: []specBenchmarkQuery{ + {"selective-point", request(specnumeric.Predicate{SpecID: support.ID, Operator: specnumeric.OperatorContainsPoint, Value: decimal("42"), Unit: "V"})}, + {"and-ranges", request(specnumeric.Predicate{SpecID: support.ID, Operator: specnumeric.OperatorOverlapsRange, Lower: decimal("40"), Upper: decimal("60"), Unit: "V"}, specnumeric.Predicate{SpecID: nominal.ID, Operator: specnumeric.OperatorBetween, Lower: decimal("10"), Upper: decimal("15"), Unit: "V"})}, + {"broad-page-2", request(specnumeric.Predicate{SpecID: support.ID, Operator: specnumeric.OperatorOverlapsRange, Lower: decimal("0"), Upper: decimal("101"), Unit: "V"})}, + }} + c.queries[2].request.Page = 2 + if drain { + assertSpecProviderReady(t, c.engine, backend) + } + t.Cleanup(func() { c.engine.Close(); c.store.Close() }) + return c +} + +func (c *specBenchmarkCorpus) reopen(t testing.TB, backend string) { + t.Helper() + c.engine.Close() + if err := c.store.Close(); err != nil { + t.Fatal(err) + } + c.open(t, backend) +} + +func (c *specBenchmarkCorpus) open(t testing.TB, backend string) { + t.Helper() + store, err := sqlite.OpenReady(filepath.Join(c.root, "prods.db")) + if err != nil { + t.Fatal(err) + } + c.store = store + engine, err := publishing.NewEngine(t.Context(), &numericPublicationRepository{Store: store}, publishing.Config{Root: filepath.Join(c.root, "generated"), BaseURL: "http://catalog.example.test", SpecSearchBackend: backend}) + if err != nil { + t.Fatal(err) + } + c.engine = engine +} + +func specBenchmarkSearch(t testing.TB, engine *publishing.Engine, backend string, request publishing.SpecSearchRequest) publishing.SpecSearchResult { + t.Helper() + before := engine.SpecSearchDiagnostics() + result, err := engine.SearchSpecs(t.Context(), request) + if err != nil { + t.Fatal(err) + } + assertSpecProviderQueries(t, engine, backend, before, 1, 0) + return result +} + +type specBenchmarkPage struct { + IDs []string + Total, Page, PageSize int +} + +// Pair the literal same persisted products, including every page and one past +// the end. Timing is not started until ordered IDs, Total and pages agree. +func (c *specBenchmarkCorpus) parity(t testing.TB) { + t.Helper() + var baseline [][]specBenchmarkPage + for _, backend := range []string{"bleve", "sqlite"} { + c.reopen(t, backend) + assertSpecProviderReady(t, c.engine, backend) + var pages [][]specBenchmarkPage + for _, query := range c.queries { + request := query.request + request.Page = 1 + var results []specBenchmarkPage + for { + result := specBenchmarkSearch(t, c.engine, backend, request) + page := specBenchmarkPage{Total: result.Total, Page: result.Page, PageSize: result.PageSize, IDs: []string{}} + for _, row := range result.Rows { + page.IDs = append(page.IDs, row.ID) + } + results = append(results, page) + if len(result.Rows) == 0 { + break + } + request.Page++ + } + pages = append(pages, results) + } + if baseline == nil { + baseline = pages + } else if !reflect.DeepEqual(baseline, pages) { + t.Fatal("paired provider ordered IDs/Total/pages differ") + } + } + t.Logf("paired parity products=%d totals=%d/%d/%d pages=%d/%d/%d; zero fallback", c.products, baseline[0][0].Total, baseline[1][0].Total, baseline[2][0].Total, len(baseline[0]), len(baseline[1]), len(baseline[2])) +} + +func BenchmarkSpecSearchBackends(b *testing.B) { + c := newSpecBenchmarkCorpus(b, "bleve") + c.parity(b) + for _, backend := range []string{"bleve", "sqlite"} { + c.reopen(b, backend) + for _, query := range c.queries { + b.Run(backend+"/"+query.name, func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + specBenchmarkSearch(b, c.engine, backend, query.request) + } + }) + } + } +} + +func BenchmarkSpecSearchRebuild(b *testing.B) { + c := newSpecBenchmarkCorpus(b, "bleve") + c.parity(b) + for _, backend := range []string{"bleve", "sqlite"} { + c.reopen(b, backend) + b.Run(backend, func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if err := c.engine.RebuildSpecSearchIndex(b.Context()); err != nil { + b.Fatal(err) + } + b.StopTimer() + assertSpecProviderReady(b, c.engine, backend) + specBenchmarkSearch(b, c.engine, backend, c.queries[0].request) + b.StartTimer() + } + }) + } +} + +// Reopen includes Store.OpenReady, Engine.NewEngine reconciliation (including +// projection repair), and first query. Close is excluded; OS caches are kept. +func BenchmarkSpecSearchReopen(b *testing.B) { + c := newSpecBenchmarkCorpus(b, "bleve") + c.parity(b) + for _, backend := range []string{"bleve", "sqlite"} { + b.Run(backend, func(b *testing.B) { + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + b.StopTimer() + c.engine.Close() + if err := c.store.Close(); err != nil { + b.Fatal(err) + } + b.StartTimer() + c.open(b, backend) + specBenchmarkSearch(b, c.engine, backend, c.queries[0].request) + } + }) + } +} + +// Run a precompiled test binary in a fresh process per backend. This entry +// never opens the other provider or runs paired parity; RSS includes real +// corpus construction/publication and the selected engine's startup/cache. +func BenchmarkSpecSearchRSS(b *testing.B) { + backend := os.Getenv("PRODS_BENCH_RSS_BACKEND") + if backend != "bleve" && backend != "sqlite" { + b.Skip("set PRODS_BENCH_RSS_BACKEND=bleve or sqlite") + } + c := newSpecBenchmarkCorpus(b, backend) + b.ResetTimer() + for i := 0; i < b.N; i++ { + for _, query := range c.queries { + specBenchmarkSearch(b, c.engine, backend, query.request) + } + } +} diff --git a/internal/webapp/spec_search_lifecycle_test.go b/internal/webapp/spec_search_lifecycle_test.go new file mode 100644 index 0000000..b1c608f --- /dev/null +++ b/internal/webapp/spec_search_lifecycle_test.go @@ -0,0 +1,190 @@ +package webapp + +import ( + "database/sql" + "errors" + "path/filepath" + "reflect" + "sync/atomic" + "testing" + + "github.com/blevesearch/bleve/v2" + "github.com/blevesearch/bleve/v2/index/scorch" + "prods/internal/platform" + "prods/internal/publishing" + "prods/internal/storage/sqlite" +) + +func assertCommittedSpecActivation(t testing.TB, store *sqlite.Store, productID string) publishing.ActivePublication { + t.Helper() + active, err := store.ActivePublications(t.Context()) + if err != nil { + t.Fatal(err) + } + if len(active) != 1 || active[0].ProductID != productID || active[0].VisibilityGeneration <= 0 { + t.Fatalf("expected one committed activation with durable generation: %+v", active) + } + return active[0] +} + +func assertSpecProviderReady(t testing.TB, engine *publishing.Engine, backend string) { + t.Helper() + d := engine.SpecSearchDiagnostics() + if d.Backend != backend || !d.Ready || d.SourceDigest == "" || d.LastError != "" { + t.Fatalf("selected provider not ready: %+v", d) + } +} + +func assertSpecProviderQueries(t testing.TB, engine *publishing.Engine, backend string, before publishing.SpecSearchDiagnostics, candidates, fallbacks uint64) { + t.Helper() + after := engine.SpecSearchDiagnostics() + if after.Backend != backend || after.CandidateQueries != before.CandidateQueries+candidates || after.FallbackQueries != before.FallbackQueries+fallbacks { + t.Fatalf("provider query evidence: before=%+v after=%+v", before, after) + } +} + +func TestSpecSearchBothBackendsReopenAndRebuildPublishedFacts(t *testing.T) { + for _, backend := range []string{"bleve", "sqlite"} { + t.Run(backend, func(t *testing.T) { + f := newNumericPublicationFixtureForBackend(t, backend) + published := assertCommittedSpecActivation(t, f.store, f.productID) + assertSpecProviderReady(t, f.engine, backend) + digest := f.engine.SpecSearchDiagnostics().SourceDigest + publishedRequest := specSearchRequest(t, f, "4") + workingRequest := specSearchRequest(t, f, "8") + setSpecSearchRaw(t, f, "7–9V") + working, err := f.store.PublicationSource(t.Context(), f.productID) + if err != nil { + t.Fatal(err) + } + if working.Product.Revision <= published.PublicRevision || reflect.DeepEqual(working.NumericFacts, published.View.NumericFacts) { + t.Fatal("fixture must retain distinct unactivated working facts") + } + f.engine.Close() + if err = f.store.Close(); err != nil { + t.Fatal(err) + } + if backend == "bleve" { + // Open the actual persisted index while the publisher is closed. + index, err := bleve.Open(filepath.Join(f.root, "search", "specs", "index")) + if err != nil { + t.Fatal(err) + } + advanced, advancedErr := index.Advanced() + _, isScorch := advanced.(*scorch.Scorch) + count, countErr := index.DocCount() + closeErr := index.Close() + if advancedErr != nil || !isScorch || countErr != nil || count != 1 || closeErr != nil { + t.Fatalf("persisted Scorch: type=%T docs=%d errors=%v/%v/%v", advanced, count, advancedErr, countErr, closeErr) + } + } + store, err := sqlite.OpenReady(filepath.Join(f.root, "prods.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { store.Close() }) + engine, err := publishing.NewEngine(t.Context(), &numericPublicationRepository{Store: store}, publishing.Config{Root: filepath.Join(f.root, "generated"), BaseURL: "http://catalog.example.test", SpecSearchBackend: backend}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(engine.Close) + if got := assertCommittedSpecActivation(t, store, f.productID); got.VisibilityGeneration != published.VisibilityGeneration || got.PublicRevision != published.PublicRevision { + t.Fatalf("reopen replaced committed activation: %+v", got) + } + assertPublished := func() { + t.Helper() + assertSpecProviderReady(t, engine, backend) + before := engine.SpecSearchDiagnostics() + if before.SourceDigest != digest { + t.Fatalf("unactivated working content changed committed digest: %+v", before) + } + got, err := engine.SearchSpecs(t.Context(), publishedRequest) + if err != nil || got.Total != 1 || len(got.Rows) != 1 || got.Rows[0].ID != f.productID || got.Rows[0].Revision != published.PublicRevision || !reflect.DeepEqual(got.Rows[0].NumericFacts, published.View.NumericFacts) { + t.Fatalf("published A lost: %+v err=%v", got, err) + } + got, err = engine.SearchSpecs(t.Context(), workingRequest) + if err != nil || got.Total != 0 || len(got.Rows) != 0 { + t.Fatalf("working B leaked: %+v err=%v", got, err) + } + assertSpecProviderQueries(t, engine, backend, before, 2, 0) + } + assertPublished() + if err = engine.RebuildSpecSearchIndex(t.Context()); err != nil { + t.Fatal(err) + } + assertPublished() + }) + } +} + +func TestSpecSearchSiteEpochFallbackUsesCurrentFacts(t *testing.T) { + for _, backend := range []string{"bleve", "sqlite"} { + t.Run(backend, func(t *testing.T) { + var holdPrevious atomic.Bool + providerConfig := publishing.Config{SpecSearchBackend: backend, ResourceGate: platform.NewResourceGate(func(path string) (platform.ResourceStats, error) { + if holdPrevious.Load() && filepath.Base(path) == "specs" && filepath.Base(filepath.Dir(path)) == "search" { + return platform.ResourceStats{}, errors.New("test fixture holds the previous Scorch snapshot") + } + return platform.ResourceStats{VolumeID: "fixture", TotalBytes: 1 << 40, FreeBytes: 1 << 40}, nil + })} + f := newNumericPublicationFixtureWithConfig(t, providerConfig, &holdPrevious) + old := assertCommittedSpecActivation(t, f.store, f.productID) + request := specSearchRequest(t, f, "4") + request.CategoryID = old.View.CategoryID + assertSpecProviderReady(t, f.engine, backend) + before := f.engine.SpecSearchDiagnostics() + // Block derived refresh after the real provider is ready. Bleve's + // failed admission may invalidate its handle; SQLite's stale binding + // is pinned below. Authority activation and candidate reads stay real. + holdPrevious.Store(true) + epoch, config, err := f.store.PublicSiteRouteConfig(t.Context()) + if err != nil { + t.Fatal(err) + } + _, workingRevision, err := f.store.WorkingWebsiteConfiguration(t.Context()) + if err != nil { + t.Fatal(err) + } + if _, err = f.engine.PublishSiteRoutes(t.Context(), publishing.SiteRouteRequest{ActorID: f.actor, ExpectedEpoch: epoch, ExpectedWorkingRevision: workingRevision, Config: config}); err != nil { + t.Fatal(err) + } + current := assertCommittedSpecActivation(t, f.store, f.productID) + if current.SiteEpoch <= old.SiteEpoch || !reflect.DeepEqual(current.View.NumericFacts, old.View.NumericFacts) { + t.Fatalf("site-wide activation did not retain facts at new epoch: old=%+v current=%+v", old, current) + } + if backend == "sqlite" { + // Make the real rebuildable projection retain the prior epoch. + // The normal repository read must reject its obsolete binding. + db, err := sql.Open("sqlite", filepath.Join(f.root, "prods.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err = db.Exec(`UPDATE public_spec_projection_products SET site_epoch=? WHERE product_id=?`, old.SiteEpoch, f.productID); err != nil { + t.Fatal(err) + } + } else if d := f.engine.SpecSearchDiagnostics(); d.Ready || (d.SourceDigest != before.SourceDigest && d.LastError == "") { + // Refresh failure may close the adapter handle, retaining its + // old disk index but clearing the digest. Both states must deny + // candidate readiness against the newly committed epoch. + t.Fatalf("previous Scorch snapshot unexpectedly current: %+v", d) + } + got, err := f.engine.SearchSpecs(t.Context(), request) + if err != nil || got.Total != 1 || len(got.Rows) != 1 || got.Rows[0].SiteEpoch != current.SiteEpoch || got.Rows[0].CategoryID != request.CategoryID || !reflect.DeepEqual(got.Rows[0].NumericFacts, current.View.NumericFacts) { + t.Fatalf("stale candidate changed active scope/facts: %+v err=%v", got, err) + } + assertSpecProviderQueries(t, f.engine, backend, before, 0, 1) + holdPrevious.Store(false) + if err = f.engine.RebuildSpecSearchIndex(t.Context()); err != nil { + t.Fatal(err) + } + assertSpecProviderReady(t, f.engine, backend) + before = f.engine.SpecSearchDiagnostics() + got, err = f.engine.SearchSpecs(t.Context(), request) + if err != nil || got.Total != 1 || len(got.Rows) != 1 || got.Rows[0].SiteEpoch != current.SiteEpoch { + t.Fatalf("rebuilt current epoch missing: %+v err=%v", got, err) + } + assertSpecProviderQueries(t, f.engine, backend, before, 1, 0) + }) + } +} diff --git a/internal/webapp/spec_search_test.go b/internal/webapp/spec_search_test.go new file mode 100644 index 0000000..c7d6fb6 --- /dev/null +++ b/internal/webapp/spec_search_test.go @@ -0,0 +1,379 @@ +package webapp + +import ( + "database/sql" + "errors" + "path/filepath" + "reflect" + "testing" + + "prods/internal/catalog" + "prods/internal/publishing" + "prods/internal/specnumeric" + "prods/internal/storage/sqlite" +) + +func specSearchRequest(t testing.TB, f numericPublicationFixture, point string) publishing.SpecSearchRequest { + t.Helper() + c, err := f.store.SpecNormalizationContext(t.Context(), f.valueID) + if err != nil { + t.Fatal(err) + } + value, err := specnumeric.ParseDecimal(point) + if err != nil { + t.Fatal(err) + } + return publishing.SpecSearchRequest{Predicates: []specnumeric.Predicate{{SpecID: c.SpecID, Operator: specnumeric.OperatorContainsPoint, Value: &value, Unit: "V"}}, Page: 1, PageSize: 20} +} + +func setSpecSearchRaw(t testing.TB, f numericPublicationFixture, raw string) { + t.Helper() + c, err := f.store.SpecNormalizationContext(t.Context(), f.valueID) + if err != nil { + t.Fatal(err) + } + if _, err = f.store.SaveSpecValue(t.Context(), f.actor, f.productID, c.SpecID, raw, "en-US", c.Expected.ProductRevision); err != nil { + t.Fatal(err) + } + f.adopt(t) +} + +func TestSpecSearchRestartAndProjectionRebuildUsePublishedFacts(t *testing.T) { + f := newNumericPublicationFixtureForBackend(t, "sqlite") + published := f.view(t) + publishedRequest := specSearchRequest(t, f, "4") + workingRequest := specSearchRequest(t, f, "8") + setSpecSearchRaw(t, f, "7–9V") + working, err := f.store.PublicationSource(t.Context(), f.productID) + if err != nil { + t.Fatal(err) + } + if len(working.NumericFacts) != 1 || reflect.DeepEqual(working.NumericFacts, published.NumericFacts) || working.Product.Revision <= published.Revision { + t.Fatalf("fixture did not retain distinct unactivated working facts: %+v", working) + } + f.engine.Close() + if err = f.store.Close(); err != nil { + t.Fatal(err) + } + store, err := sqlite.OpenReady(filepath.Join(f.root, "prods.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { store.Close() }) + engine, err := publishing.NewEngine(t.Context(), &numericPublicationRepository{Store: store}, publishing.Config{Root: filepath.Join(f.root, "generated"), BaseURL: "http://catalog.example.test", SpecSearchBackend: "sqlite"}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(engine.Close) + assertPublishedFacts := func() { + t.Helper() + got, err := engine.SearchSpecs(t.Context(), publishedRequest) + if err != nil { + t.Fatal(err) + } + if got.Total != 1 || len(got.Rows) != 1 || got.Rows[0].ID != f.productID || got.Rows[0].Revision != published.Revision || !reflect.DeepEqual(got.Rows[0].NumericFacts, published.NumericFacts) { + t.Fatalf("persisted published facts lost: %+v", got) + } + got, err = engine.SearchSpecs(t.Context(), workingRequest) + if err != nil { + t.Fatal(err) + } + if got.Total != 0 || len(got.Rows) != 0 { + t.Fatalf("unactivated working facts leaked: %+v", got) + } + } + assertPublishedFacts() + db, err := sql.Open("sqlite", filepath.Join(f.root, "prods.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err = db.Exec(`DELETE FROM public_spec_projection_fields WHERE product_id=?`, f.productID); err != nil { + t.Fatal(err) + } + if _, err = db.Exec(`DELETE FROM public_spec_projection_products WHERE product_id=?`, f.productID); err != nil { + t.Fatal(err) + } + assertSpecSearchProjectionCounts(t, db, f.productID, 0, 0) + if err = engine.Reconcile(t.Context()); err != nil { + t.Fatal(err) + } + assertSpecSearchProjectionCounts(t, db, f.productID, 1, 1) + assertPublishedFacts() +} + +func TestSpecSearchRevocationRemovesRowsAndCount(t *testing.T) { + for _, backend := range []string{"bleve", "sqlite"} { + t.Run(backend, func(t *testing.T) { + f := newNumericPublicationFixtureForBackend(t, backend) + assertCommittedSpecActivation(t, f.store, f.productID) + assertSpecProviderReady(t, f.engine, backend) + diagnostics := f.engine.SpecSearchDiagnostics() + request := specSearchRequest(t, f, "4") + before, err := f.engine.SearchSpecs(t.Context(), request) + if err != nil { + t.Fatal(err) + } + if before.Total != 1 || len(before.Rows) != 1 || before.Rows[0].ID != f.productID { + t.Fatalf("published match missing before Hide: %+v", before) + } + assertSpecProviderQueries(t, f.engine, backend, diagnostics, 1, 0) + db, err := sql.Open("sqlite", filepath.Join(f.root, "prods.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + assertSpecSearchProjectionCounts(t, db, f.productID, 1, 1) + product, err := f.store.Product(t.Context(), f.productID) + if err != nil { + t.Fatal(err) + } + if err = f.engine.Revoke(t.Context(), publishing.RevokeRequest{ActorID: f.actor, ProductID: f.productID, ExpectedRevision: product.Revision}); err != nil { + t.Fatal(err) + } + // Read the physical rows immediately after successful Hide, before any + // query or worker could mask stale projection records. + assertSpecSearchProjectionCounts(t, db, f.productID, 0, 0) + after, err := f.engine.SearchSpecs(t.Context(), request) + if err != nil { + t.Fatal(err) + } + if after.Total != 0 || len(after.Rows) != 0 { + t.Fatalf("revoked product retained rows/count: %+v", after) + } + + }) + } +} + +func assertSpecSearchProjectionCounts(t *testing.T, db *sql.DB, productID string, wantProducts, wantFields int) { + t.Helper() + var products, fields int + if err := db.QueryRow(`SELECT + (SELECT COUNT(*) FROM public_spec_projection_products WHERE product_id=?), + (SELECT COUNT(*) FROM public_spec_projection_fields WHERE product_id=?)`, productID, productID).Scan(&products, &fields); err != nil { + t.Fatal(err) + } + if products != wantProducts || fields != wantFields { + t.Fatalf("physical spec projection products/fields=%d/%d, want %d/%d", products, fields, wantProducts, wantFields) + } +} + +func TestSpecSearchPreservesIntervalGap(t *testing.T) { + f := newNumericPublicationFixture(t) + setSpecSearchRaw(t, f, "3–5 V, 7–9 V") + f.drain(t) + got, err := f.engine.SearchSpecs(t.Context(), specSearchRequest(t, f, "6")) + if err != nil { + t.Fatal(err) + } + if got.Total != 0 || len(got.Rows) != 0 { + t.Fatalf("gap matched: %+v", got) + } +} + +func TestSpecSearchReturnsSupportedPoint(t *testing.T) { + f := newNumericPublicationFixture(t) + got, err := f.engine.SearchSpecs(t.Context(), specSearchRequest(t, f, "4")) + if err != nil { + t.Fatal(err) + } + if got.Total != 1 || len(got.Rows) != 1 || got.Rows[0].ID != f.productID || got.Rows[0].Revision != f.view(t).Revision { + t.Fatalf("supported point: %+v", got) + } + // Activation must produce both the marker and its actual Spec-ID row. + snapshot, err := f.store.SpecSearchCandidates(t.Context(), []string{got.Rows[0].NumericFacts[0].SpecID}, publishing.SpecProjectionVersion) + if err != nil { + t.Fatal(err) + } + if len(snapshot.Inventory) != 1 || len(snapshot.Inventory[0].SpecIDs) != 1 || len(snapshot.Candidates) != 1 { + t.Fatalf("projection: %+v", snapshot) + } +} + +func TestSpecSearchPaginatesAfterExactMatch(t *testing.T) { + f := newNumericPublicationFixture(t) + c, err := f.store.SpecNormalizationContext(t.Context(), f.valueID) + if err != nil { + t.Fatal(err) + } + original, err := f.store.Product(t.Context(), f.productID) + if err != nil { + t.Fatal(err) + } + var want string + for _, part := range []string{"ZZ-MATCH-1", "ZZ-MATCH-2"} { + product, err := f.store.CreateProduct(t.Context(), f.actor, catalog.Product{PartNumber: part, CategoryID: original.CategoryID, Status: catalog.Published}) + if err != nil { + t.Fatal(err) + } + value, err := f.store.SaveSpecValue(t.Context(), f.actor, product.ID, c.SpecID, "7–9V", "en-US", product.Revision) + if err != nil { + t.Fatal(err) + } + next := f + next.productID = product.ID + next.valueID = value.ID + next.adopt(t) + if want == "" { + want = product.ID + } + } + f.drain(t) + request := specSearchRequest(t, f, "8") + request.PageSize = 1 + got, err := f.engine.SearchSpecs(t.Context(), request) + if err != nil { + t.Fatal(err) + } + if got.Total != 2 || len(got.Rows) != 1 || got.Rows[0].ID != want { + t.Fatalf("pagination before oracle: %+v", got) + } +} + +func TestSpecSearchFallsBackWhenFieldProjectionMissing(t *testing.T) { + f := newNumericPublicationFixtureForBackend(t, "sqlite") + db, err := sql.Open("sqlite", filepath.Join(f.root, "prods.db")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err = db.Exec(`DELETE FROM public_spec_projection_fields WHERE product_id=?`, f.productID); err != nil { + t.Fatal(err) + } + var markers int + if err = db.QueryRow(`SELECT COUNT(*) FROM public_spec_projection_products WHERE product_id=?`, f.productID).Scan(&markers); err != nil || markers != 1 { + t.Fatalf("marker=%d err=%v", markers, err) + } + got, err := f.engine.SearchSpecs(t.Context(), specSearchRequest(t, f, "4")) + if err != nil { + t.Fatal(err) + } + if got.Total != 1 || len(got.Rows) != 1 || got.Rows[0].ID != f.productID { + t.Fatalf("missing field hid match: %+v", got) + } +} + +func TestSpecSearchKeepsActiveFactsUntilNextActivation(t *testing.T) { + f := newNumericPublicationFixture(t) + request := specSearchRequest(t, f, "4") + before := f.view(t) + setSpecSearchRaw(t, f, "7–9V") + got, err := f.engine.SearchSpecs(t.Context(), request) + if err != nil { + t.Fatal(err) + } + if got.Total != 1 || len(got.Rows) != 1 || got.Rows[0].Revision != before.Revision { + t.Fatalf("working facts leaked: %+v", got) + } + f.drain(t) + got, err = f.engine.SearchSpecs(t.Context(), request) + if err != nil { + t.Fatal(err) + } + if got.Total != 0 { + t.Fatalf("old facts survived activation: %+v", got) + } +} + +func TestSpecSearchAbsentDefinitionIsUnknown(t *testing.T) { + f := newNumericPublicationFixture(t) + request := specSearchRequest(t, f, "4") + request.Predicates[0].SpecID = "spec_absent" + got, err := f.engine.SearchSpecs(t.Context(), request) + if err != nil { + t.Fatal(err) + } + if got.Total != 0 || len(got.Rows) != 0 { + t.Fatalf("missing definition established Match: %+v", got) + } +} + +func TestSpecSearchRejectsIncompatibleScopedDefinitions(t *testing.T) { + f := newNumericPublicationFixture(t) + c, err := f.store.SpecNormalizationContext(t.Context(), f.valueID) + if err != nil { + t.Fatal(err) + } + category, err := f.store.CreateCategory(t.Context(), f.actor, catalog.Category{Name: "Other scope", Slug: "other-scope"}) + if err != nil { + t.Fatal(err) + } + set, err := f.store.CreateSpecSet(t.Context(), f.actor, catalog.SpecSet{Name: "Other scope", SpecIDs: []string{c.SpecID}}) + if err != nil { + t.Fatal(err) + } + if err = f.store.SetCategorySpecSet(t.Context(), f.actor, category.ID, set.ID, category.Revision); err != nil { + t.Fatal(err) + } + product, err := f.store.CreateProduct(t.Context(), f.actor, catalog.Product{PartNumber: "OTHER-SCOPE", CategoryID: category.ID, Status: catalog.Published}) + if err != nil { + t.Fatal(err) + } + value, err := f.store.SaveSpecValue(t.Context(), f.actor, product.ID, c.SpecID, "3–5V", "en-US", product.Revision) + if err != nil { + t.Fatal(err) + } + other := f + other.productID = product.ID + other.valueID = value.ID + other.adopt(t) + f.drain(t) + definitions, err := f.store.ListSpecDefinitions(t.Context()) + if err != nil { + t.Fatal(err) + } + var revision int64 + for _, definition := range definitions { + if definition.ID == c.SpecID { + revision = definition.Revision + } + } + profile := c.Profile + profile.DeclaredInputUnit = "V" + if _, err = f.store.ConfigureSpecNumericProfile(t.Context(), f.actor, c.SpecID, revision, profile); err != nil { + t.Fatal(err) + } + request := specSearchRequest(t, f, "4") + // A working definition change alone cannot invalidate the frozen query. + before, err := f.engine.SearchSpecs(t.Context(), request) + if err != nil || before.Total != 2 { + t.Fatalf("working profile changed active query: %+v, %v", before, err) + } + var unchanged publishing.PublicView + for n := 0; n < 10; n++ { + if _, err = f.engine.ProcessOne(f.ctx); err != nil { + t.Fatal(err) + } + views, err := f.engine.Views() + if err != nil { + t.Fatal(err) + } + changed := 0 + for _, view := range views { + if view.NumericDefinitions[0].Profile.DeclaredInputUnit == "V" { + changed++ + } else { + unchanged = view + } + } + if changed == 1 { + break + } + if n == 9 { + t.Fatal("did not reach a partial profile rollout") + } + } + if _, err = f.engine.SearchSpecs(t.Context(), request); !errors.Is(err, publishing.ErrPublicUnavailable) { + t.Fatalf("mixed definitions error=%v", err) + } + // The incompatible definition is outside this exact frozen category scope. + request.CategoryID = unchanged.CategoryID + got, err := f.engine.SearchSpecs(t.Context(), request) + if err != nil { + t.Fatal(err) + } + if got.Total != 1 || len(got.Rows) != 1 || got.Rows[0].ID != unchanged.ID { + t.Fatalf("outside definition contaminated scope: %+v", got) + } +} diff --git a/internal/webapp/spec_worker_benchmark_test.go b/internal/webapp/spec_worker_benchmark_test.go new file mode 100644 index 0000000..0f016f6 --- /dev/null +++ b/internal/webapp/spec_worker_benchmark_test.go @@ -0,0 +1,613 @@ +package webapp + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "reflect" + "slices" + "strconv" + "sync" + "sync/atomic" + "testing" + "time" + + "prods/internal/catalog" + "prods/internal/publishing" + "prods/internal/specnumeric" + "prods/internal/storage/sqlite" +) + +// This wrapper gates only real background work, not startup reconciliation or +// provider IO. Cancellation releases the gate so Engine.Close cannot deadlock. +type specWorkerBenchmarkRepository struct { + *sqlite.Store + entered, release chan struct{} + once sync.Once + claims, activations, profileCalls, profileProgress atomic.Int64 +} + +func (r *specWorkerBenchmarkRepository) wait(ctx context.Context) error { + r.once.Do(func() { close(r.entered) }) + select { + case <-ctx.Done(): + return ctx.Err() + case <-r.release: + return nil + } +} + +func (r *specWorkerBenchmarkRepository) ClaimPublicationIntent(ctx context.Context) (publishing.Intent, bool, error) { + if err := r.wait(ctx); err != nil { + return publishing.Intent{}, false, err + } + i, found, err := r.Store.ClaimPublicationIntent(ctx) + if found && err == nil { + r.claims.Add(1) + } + return i, found, err +} + +func (r *specWorkerBenchmarkRepository) ProcessSpecNumericProfileWork(ctx context.Context, limit int) (bool, error) { + if err := r.wait(ctx); err != nil { + return false, err + } + r.profileCalls.Add(1) + worked, err := r.Store.ProcessSpecNumericProfileWork(ctx, limit) + if worked && err == nil { + r.profileProgress.Add(1) + } + return worked, err +} + +func (r *specWorkerBenchmarkRepository) ActivatePublication(ctx context.Context, i publishing.Intent, a publishing.ActivePublication, c publishing.VisibilityCoordinator) (bool, error) { + activated, err := r.Store.ActivatePublication(ctx, i, a, c) + if activated && err == nil { + r.activations.Add(1) + } + return activated, err +} + +func specWorkerBenchmarkProducts(t testing.TB) int { + t.Helper() + if raw := os.Getenv("PRODS_BENCH_WORKER_PRODUCTS"); raw != "" { + n, err := strconv.Atoi(raw) + if err != nil || n < 1 { + t.Fatalf("PRODS_BENCH_WORKER_PRODUCTS must be positive: %q", raw) + } + return n + } + return 200 +} + +type specWorkerDurableCounts struct { + Pending, Processing, Failed, Completed, Superseded int + ProfilePending, ProfileCompleted, ProfileFailed int +} + +func specWorkerCounts(t testing.TB, db *sql.DB) specWorkerDurableCounts { + t.Helper() + var c specWorkerDurableCounts + err := db.QueryRowContext(t.Context(), `SELECT + (SELECT COUNT(*) FROM publication_intents WHERE status='pending'), + (SELECT COUNT(*) FROM publication_intents WHERE status='processing'), + (SELECT COUNT(*) FROM publication_intents WHERE status='failed'), + (SELECT COUNT(*) FROM publication_intents WHERE status='completed'), + (SELECT COUNT(*) FROM publication_intents WHERE status='superseded'), + (SELECT COUNT(*) FROM spec_numeric_profile_work WHERE status='pending'), + (SELECT COUNT(*) FROM spec_numeric_profile_work WHERE status='completed'), + (SELECT COUNT(*) FROM spec_numeric_profile_work WHERE status NOT IN ('pending','completed'))`).Scan( + &c.Pending, &c.Processing, &c.Failed, &c.Completed, &c.Superseded, &c.ProfilePending, &c.ProfileCompleted, &c.ProfileFailed) + if err != nil { + t.Fatal(err) + } + return c +} + +func specWorkerReadDB(t testing.TB, root string) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite", filepath.Join(root, "prods.db")) + if err != nil { + t.Fatal(err) + } + db.SetMaxOpenConns(1) + return db +} + +// Used only on a closed, owned test fixture. The exact derived search root is +// excluded for BOTH providers; DB, history and all generated units are copied. +// This is not a production Backup/Restore implementation or claim. +func specWorkerCopy(t testing.TB, source, target string) { + t.Helper() + err := filepath.WalkDir(source, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(source, path) + if err != nil { + return err + } + if rel == filepath.Join("search", "specs") { + return filepath.SkipDir + } + info, err := entry.Info() + if err != nil { + return err + } + out := filepath.Join(target, rel) + if entry.IsDir() { + return os.MkdirAll(out, 0700) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("unexpected fixture file %s", path) + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(out, data, info.Mode().Perm()) + }) + if err != nil { + t.Fatal(err) + } +} + +func specWorkerFixtureDigest(t testing.TB, root string) string { + t.Helper() + h := sha256.New() + err := filepath.WalkDir(root, func(path string, e fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + if rel == filepath.Join("search", "specs") { + return filepath.SkipDir + } + if e.IsDir() { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + sum := sha256.Sum256(data) + fmt.Fprintf(h, "%s\x00%x\n", filepath.ToSlash(rel), sum) + return nil + }) + if err != nil { + t.Fatal(err) + } + return hex.EncodeToString(h.Sum(nil)) +} + +type specWorkerBaseline struct { + root, digest, actor string + products int + queries []specBenchmarkQuery + sources map[string]publishing.Source + active []publishing.ActivePublication + counts specWorkerDurableCounts + nominal catalog.SpecDefinition + profile specnumeric.Profile +} + +func newSpecWorkerBaseline(b *testing.B, fanout bool) specWorkerBaseline { + b.Helper() + c := newSpecBenchmarkCorpusWithCount(b, "bleve", specWorkerBenchmarkProducts(b), fanout) + baseline := specWorkerBaseline{root: b.TempDir(), actor: c.actor, products: c.products, queries: c.queries, sources: map[string]publishing.Source{}} + definitions, err := c.store.ListSpecDefinitions(b.Context()) + if err != nil { + b.Fatal(err) + } + for _, d := range definitions { + if d.ID == c.queries[1].request.Predicates[1].SpecID { + baseline.nominal = d + } + } + active, err := c.store.ActivePublications(b.Context()) + if err != nil { + b.Fatal(err) + } + baseline.active = active + // IDs come from actual DB rows; every source is retained for starting-state + // equality and for checking exactly the one profile-work revision increment. + db := specWorkerReadDB(b, c.root) + rows, err := db.QueryContext(b.Context(), `SELECT id FROM products ORDER BY id`) + if err != nil { + b.Fatal(err) + } + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + b.Fatal(err) + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + b.Fatal(err) + } + rows.Close() + for _, id := range ids { + s, err := c.store.PublicationSource(b.Context(), id) + if err != nil { + b.Fatal(err) + } + if len(s.NumericFacts) != 2 || len(s.NumericDefinitions) != 2 || len(s.Specs) != 2 { + b.Fatalf("baseline %s incomplete source", id) + } + baseline.sources[id] = s + for _, definition := range s.NumericDefinitions { + if definition.SpecID == baseline.nominal.ID { + baseline.profile = definition.Profile + } + } + } + baseline.counts = specWorkerCounts(b, db) + db.Close() + if len(ids) != baseline.products || baseline.nominal.ID == "" || baseline.profile.DeclaredInputUnit != "" { + b.Fatal("invalid worker baseline") + } + if fanout { + if len(active) != baseline.products || baseline.counts.Pending != 0 || baseline.counts.ProfilePending != 0 { + b.Fatal("fanout baseline not converged") + } + } else if len(active) != 1 || baseline.counts.Pending == 0 || baseline.counts.ProfilePending != 0 { + b.Fatal("initial baseline must contain one old seed activation and a real backlog") + } + c.engine.Close() + if err := c.store.Close(); err != nil { + b.Fatal(err) + } + specWorkerCopy(b, c.root, baseline.root) + baseline.digest = specWorkerFixtureDigest(b, baseline.root) + b.Logf("closed baseline products=%d seed_active=%d counts=%+v fixture_sha256=%s; excluded=search/specs; setup uses paused fixture ProcessOne/ProcessBatch outside timer", baseline.products, len(active), baseline.counts, baseline.digest) + return baseline +} + +type specWorkerRun struct { + store *sqlite.Store + engine *publishing.Engine + repository *specWorkerBenchmarkRepository + db *sql.DB + root string +} + +func (f *specWorkerRun) close() { f.engine.Close(); f.db.Close(); f.store.Close() } + +func (base specWorkerBaseline) restore(b *testing.B, backend string) *specWorkerRun { + b.Helper() + root := b.TempDir() + specWorkerCopy(b, base.root, root) + if specWorkerFixtureDigest(b, root) != base.digest { + b.Fatal("restored fixture differs from closed baseline") + } + store, err := sqlite.OpenReady(filepath.Join(root, "prods.db")) + if err != nil { + b.Fatal(err) + } + r := &specWorkerBenchmarkRepository{Store: store, entered: make(chan struct{}), release: make(chan struct{})} + engine, err := publishing.NewEngine(b.Context(), r, publishing.Config{Root: filepath.Join(root, "generated"), BaseURL: "http://catalog.example.test", SpecSearchBackend: backend}) + if err != nil { + store.Close() + b.Fatal(err) + } + f := &specWorkerRun{store: store, engine: engine, repository: r, db: specWorkerReadDB(b, root), root: root} + b.Cleanup(f.close) + select { + case <-r.entered: + case <-time.After(30 * time.Second): + b.Fatal("background worker did not enter test gate") + } + assertSpecProviderReady(b, engine, backend) + if got := specWorkerCounts(b, f.db); got != base.counts { + b.Fatalf("startup changed durable work: %+v != %+v", got, base.counts) + } + active, err := store.ActivePublications(b.Context()) + if err != nil || !reflect.DeepEqual(active, base.active) { + b.Fatalf("starting activations differ: %v", err) + } + for id, expected := range base.sources { + got, err := store.PublicationSource(b.Context(), id) + if err != nil || !reflect.DeepEqual(got, expected) { + b.Fatalf("starting source %s differs: %v", id, err) + } + } + if r.claims.Load() != 0 || r.profileCalls.Load() != 0 { + b.Fatal("background work escaped gate") + } + return f +} + +// One durable snapshot and selected-provider diagnostics every 2ms, bounded at +// five minutes. That SQL/diagnostic polling and up-to-2ms sleep are INCLUDED in +// the worker elapsed time. Ready binds diagnostics to the current active digest. +func (f *specWorkerRun) converge(b *testing.B, backend string) (specWorkerDurableCounts, int) { + b.Helper() + deadline := time.Now().Add(5 * time.Minute) + for polls := 1; ; polls++ { + counts := specWorkerCounts(b, f.db) + if counts.Failed != 0 || counts.ProfileFailed != 0 { + b.Fatalf("unexpected durable failure: %+v", counts) + } + d := f.engine.SpecSearchDiagnostics() + if counts.Pending == 0 && counts.Processing == 0 && counts.ProfilePending == 0 && d.Backend == backend && d.Ready && d.LastError == "" && d.SourceDigest != "" { + return counts, polls + } + if time.Now().After(deadline) { + b.Fatalf("worker convergence timed out: %+v diagnostics=%+v", counts, d) + } + time.Sleep(2 * time.Millisecond) + } +} + +func specWorkerVerifyUnit(b *testing.B, root string, a publishing.ActivePublication) { + b.Helper() + unit := filepath.Join(root, "generated", "units", a.ArtifactID) + manifest, err := os.ReadFile(filepath.Join(unit, "manifest.json")) + if err != nil { + b.Fatal(err) + } + sum := sha256.Sum256(manifest) + if hex.EncodeToString(sum[:]) != a.ManifestHash { + b.Fatal("unit manifest hash differs") + } + prepared, err := os.ReadFile(filepath.Join(unit, "prepared")) + if err != nil || string(prepared) != a.ManifestHash { + b.Fatal("unit not durably prepared") + } + var m struct { + ProductID string `json:"product_id"` + Revision int64 `json:"revision"` + SiteEpoch int64 `json:"site_epoch"` + RenderContractVersion string `json:"render_contract_version"` + Files map[string]string `json:"files"` + } + if err := json.Unmarshal(manifest, &m); err != nil { + b.Fatal(err) + } + if m.ProductID != a.ProductID || m.Revision != a.PublicRevision || m.SiteEpoch != a.SiteEpoch || m.RenderContractVersion != "shared-specs-1" || len(m.Files) != 4+8*len(a.View.SupportedLocales) { + b.Fatal("incomplete or mismatched core/locale generated unit") + } + for _, name := range []string{"index.html", "product.json", "product.jsonld", "product.md"} { + if m.Files[name] == "" { + b.Fatalf("core generated file missing: %s", name) + } + } + for path, expected := range m.Files { + body, err := os.ReadFile(filepath.Join(unit, path)) + if err != nil { + b.Fatal(err) + } + sum := sha256.Sum256(body) + if hex.EncodeToString(sum[:]) != expected { + b.Fatalf("generated file hash differs: %s", path) + } + } +} + +func (base specWorkerBaseline) verify(b *testing.B, f *specWorkerRun, backend string, changed *catalog.SpecNumericProfile) [][]specBenchmarkPage { + b.Helper() + active, err := f.store.ActivePublications(b.Context()) + if err != nil || len(active) != base.products { + b.Fatalf("final active=%d error=%v", len(active), err) + } + inventory, err := f.store.SpecSearchCandidates(b.Context(), nil, publishing.SpecProjectionVersion) + if err != nil || len(inventory.Inventory) != base.products { + b.Fatalf("projection inventory=%d error=%v", len(inventory.Inventory), err) + } + bindings := map[string]publishing.SpecCandidateBinding{} + for _, binding := range inventory.Inventory { + if _, duplicate := bindings[binding.ProductID]; duplicate { + b.Fatal("duplicate actual projection binding") + } + bindings[binding.ProductID] = binding + } + for _, a := range active { + source, err := f.store.PublicationSource(b.Context(), a.ProductID) + if err != nil { + b.Fatal(err) + } + expected := base.sources[a.ProductID] + increment := int64(0) + facts := 2 + if changed != nil { + increment, facts = 1, 1 + } + if source.Product.Revision != expected.Product.Revision+increment || a.SourceRevision != source.Product.Revision || a.PublicRevision != source.Product.Revision || a.View.Revision != source.Product.Revision || a.VisibilityGeneration <= 0 || a.SiteEpoch != source.SiteEpoch { + b.Fatalf("final product/source/public binding invalid: %s", a.ProductID) + } + if len(source.NumericFacts) != facts || !reflect.DeepEqual(a.View.NumericFacts, source.NumericFacts) || !reflect.DeepEqual(a.View.NumericDefinitions, source.NumericDefinitions) || len(source.NumericDefinitions) != 2 { + b.Fatalf("final frozen numeric facts/definitions differ: %s", a.ProductID) + } + for _, d := range a.View.NumericDefinitions { + if changed != nil && d.SpecID == base.nominal.ID { + if d.SpecSemanticVersion != changed.SpecSemanticVersion || !reflect.DeepEqual(d.Profile, changed.Profile) { + b.Fatal("nominal definition did not converge") + } + } else { + found := false + for _, old := range expected.NumericDefinitions { + found = found || reflect.DeepEqual(old, d) + } + if !found { + b.Fatal("unchanged numeric definition changed") + } + } + } + if changed != nil { + for _, fact := range a.View.NumericFacts { + if fact.SpecID == base.nominal.ID { + b.Fatal("stale nominal fact leaked into active view") + } + found := false + for _, old := range expected.NumericFacts { + found = found || reflect.DeepEqual(old, fact) + } + if !found { + b.Fatal("unchanged support fact changed") + } + } + } else if !reflect.DeepEqual(expected.NumericFacts, source.NumericFacts) { + b.Fatal("initial facts changed") + } + if len(a.View.Specifications) != len(source.Specs) || len(source.Specs) != 2 || !reflect.DeepEqual(source.Specs, expected.Specs) { + b.Fatal("raw shared sources missing") + } + for _, s := range source.Specs { + var valueID string + if err := f.db.QueryRowContext(b.Context(), `SELECT v.id FROM product_spec_sources ps JOIN product_spec_values v + ON v.id=ps.spec_value_id AND v.product_id=ps.product_id AND v.spec_id=ps.spec_id + WHERE ps.product_id=? AND ps.spec_id=? AND v.active=1`, a.ProductID, s.ID).Scan(&valueID); err != nil { + b.Fatal(err) + } + c, err := f.store.SpecNormalizationContext(b.Context(), valueID) + if err != nil { + b.Fatal(err) + } + if c.ProductID != a.ProductID || c.SpecID != s.ID || c.SourceSelection.SpecValueID != valueID || c.SourceSelection.Revision <= 0 || c.Expected.SourceRevision != s.SourceRevision || c.Raw != s.RawValue || c.SourceLocale != s.Language || c.Selection.SelectedNormalizedValueID == "" { + b.Fatal("actual source/normalization selection binding differs") + } + var status string + var rawRevision, semantic, profileRevision int64 + if err := f.db.QueryRowContext(b.Context(), `SELECT status,source_revision,spec_semantic_version,profile_revision FROM normalized_values WHERE id=? AND spec_value_id=?`, c.Selection.SelectedNormalizedValueID, valueID).Scan(&status, &rawRevision, &semantic, &profileRevision); err != nil { + b.Fatal(err) + } + wantStatus, versionIncrement := "current", int64(0) + if changed != nil && s.ID == base.nominal.ID { + wantStatus, versionIncrement = "stale", 1 + } + if status != wantStatus || rawRevision != c.Expected.SourceRevision || semantic+versionIncrement != c.Expected.SpecSemanticVersion || profileRevision+versionIncrement != c.Expected.ProfileRevision { + b.Fatal("selected normalized fact status/version binding differs") + } + found := false + for _, v := range a.View.Specifications { + found = found || (v.ID == s.ID && v.SourceRevision == s.SourceRevision && v.RawValue == s.RawValue && v.Language == s.Language) + } + if !found { + b.Fatal("shared source binding differs") + } + } + want := publishing.SpecCandidateBinding{ProductID: a.ProductID, PublicRevision: a.PublicRevision, SourceRevision: a.SourceRevision, SiteEpoch: a.SiteEpoch, VisibilityGeneration: a.VisibilityGeneration, ProjectionVersion: publishing.SpecProjectionVersion, SpecIDs: publishing.SpecProjectionIDs(a.View)} + if !reflect.DeepEqual(bindings[a.ProductID], want) { + b.Fatal("actual SQLite projection fields/revision binding differs") + } + specWorkerVerifyUnit(b, f.root, a) + } + if changed != nil { + var current, stale int + if err := f.db.QueryRowContext(b.Context(), `SELECT SUM(n.status='current'),SUM(n.status='stale') FROM normalized_values n JOIN product_spec_values v ON v.id=n.spec_value_id WHERE v.spec_id=?`, base.nominal.ID).Scan(¤t, &stale); err != nil { + b.Fatal(err) + } + if current != 0 || stale != base.products { + b.Fatalf("nominal history current=%d stale=%d", current, stale) + } + } + assertSpecProviderReady(b, f.engine, backend) + var pages [][]specBenchmarkPage + for q, query := range base.queries { + request := query.request + request.Page = 1 + var results []specBenchmarkPage + var wantIDs []string + // Independent fixed workload oracle, expressed from the synthetic raw + // generator rather than trusting either candidate provider's own Total. + for i := 0; i < base.products; i++ { + n := i % 100 + matches := q == 2 || q == 0 && (i%2 == 0 && n <= 42 && n+2 >= 42 || i%2 == 1 && (n == 42 || n+2 == 42)) || q == 1 && changed == nil && n <= 60 && n+2 >= 40 && i%25 >= 10 && i%25 <= 15 + if matches { + part := fmt.Sprintf("BENCH-%06d", i) + for id, source := range base.sources { + if source.Product.PartNumber == part { + wantIDs = append(wantIDs, id) + } + } + } + } + var allIDs []string + for { + result := specBenchmarkSearch(b, f.engine, backend, request) + page := specBenchmarkPage{Total: result.Total, Page: result.Page, PageSize: result.PageSize, IDs: []string{}} + for _, row := range result.Rows { + page.IDs = append(page.IDs, row.ID) + } + results = append(results, page) + allIDs = append(allIDs, page.IDs...) + if result.Total != len(wantIDs) { + b.Fatalf("%s MATCH count=%d want=%d", query.name, result.Total, len(wantIDs)) + } + if len(result.Rows) == 0 { + break + } + request.Page++ + } + if !slices.Equal(allIDs, wantIDs) { + b.Fatalf("%s ordered MATCH IDs differ from workload oracle", query.name) + } + pages = append(pages, results) + } + return pages +} + +func benchmarkSpecWorker(b *testing.B, fanout bool) { + base := newSpecWorkerBaseline(b, fanout) + var paired [][]specBenchmarkPage + for _, backend := range []string{"bleve", "sqlite"} { + b.Run(backend, func(b *testing.B) { + b.StopTimer() + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + f := base.restore(b, backend) + var changed *catalog.SpecNumericProfile + profile := base.profile + profile.DeclaredInputUnit = "V" + b.StartTimer() + if fanout { + configured, err := f.store.ConfigureSpecNumericProfile(b.Context(), base.actor, base.nominal.ID, base.nominal.Revision, profile) + if err != nil { + b.Fatal(err) + } + changed = &configured + } + close(f.repository.release) + counts, polls := f.converge(b, backend) + b.StopTimer() + pages := base.verify(b, f, backend, changed) + if paired == nil { + paired = pages + } else if !reflect.DeepEqual(paired, pages) { + b.Fatal("paired worker ordered IDs/Total/pages differ") + } + if f.repository.activations.Load() != int64(base.products) { + b.Fatal("worker did not activate every workload product") + } + if fanout && (f.repository.profileProgress.Load() != int64(base.products/100+1) || counts.ProfileCompleted != base.counts.ProfileCompleted+1) { + b.Fatal("real bounded profile progress did not complete") + } + b.Logf("iteration=%d products=%d baseline_sha256=%s starting_active=%d starting_counts=%+v final_counts=%+v claims=%d activations=%d profile_calls=%d profile_progress=%d full_units=%d polls=%d poll_interval=2ms totals=%d/%d/%d candidate_queries=%d fallback=%d digest=%s", i, base.products, base.digest, len(base.active), base.counts, counts, f.repository.claims.Load(), f.repository.activations.Load(), f.repository.profileCalls.Load(), f.repository.profileProgress.Load(), len(base.sources), polls, pages[0][0].Total, pages[1][0].Total, pages[2][0].Total, f.engine.SpecSearchDiagnostics().CandidateQueries, f.engine.SpecSearchDiagnostics().FallbackQueries, f.engine.SpecSearchDiagnostics().SourceDigest) + b.ReportMetric(float64(base.products), "products/op") + b.ReportMetric(float64(f.repository.activations.Load()), "activations/op") + b.ReportMetric(float64(polls), "polls/op") + f.close() + } + }) + } +} + +// Initial timer: entered gate + verified identical starting sources/activations +// and startup-ready provider -> release -> durable queues empty AND Ready. +func BenchmarkSpecWorkerInitialPublication(b *testing.B) { benchmarkSpecWorker(b, false) } + +// Fanout timer additionally includes the real profile configuration command. +// No readoption, manual drain, ProcessBatch or forced rebuild occurs in timing. +func BenchmarkSpecWorkerProfileFanout(b *testing.B) { benchmarkSpecWorker(b, true) } diff --git a/internal/webapp/static/admin/admin.js b/internal/webapp/static/admin/admin.js index 60ecffa..282ee73 100644 --- a/internal/webapp/static/admin/admin.js +++ b/internal/webapp/static/admin/admin.js @@ -77,7 +77,7 @@ var sbe = EU((Ci, $i) => { function NU() { return H_ || (H_ = 1, LS.exports = TU()), LS.exports; } - var N = NU(), kS = { exports: {} }, Ut = {}; + var _ = NU(), kS = { exports: {} }, Ut = {}; var U_; function _U() { if (U_) return Ut; @@ -118,7 +118,7 @@ var sbe = EU((Ci, $i) => { var I = M.prototype = new $(); I.constructor = M, C(I, R.prototype), I.isPureReactComponent = !0; var D = Array.isArray; - function _() { + function N() { } var P = { H: null, A: null, T: null, S: null }, T = Object.prototype.hasOwnProperty; function A(k, B, J) { @@ -154,7 +154,7 @@ var sbe = EU((Ci, $i) => { case "rejected": throw k.reason; default: - switch (typeof k.status == "string" ? k.then(_, _) : (k.status = "pending", k.then( + switch (typeof k.status == "string" ? k.then(N, N) : (k.status = "pending", k.then( function(B) { k.status === "pending" && (k.status = "fulfilled", k.value = B); }, @@ -285,7 +285,7 @@ var sbe = EU((Ci, $i) => { J.types = B !== null ? B.types : null, P.T = J; try { var re = k(), le = P.S; - le !== null && le(J, re), typeof re == "object" && re !== null && typeof re.then == "function" && re.then(_, K); + le !== null && le(J, re), typeof re == "object" && re !== null && typeof re.then == "function" && re.then(N, K); } catch (ie) { K(ie); } finally { @@ -522,7 +522,7 @@ var sbe = EU((Ci, $i) => { V !== null && U(I, V.startTime - H); } } - var D = !1, _ = -1, P = 5, T = -1; + var D = !1, N = -1, P = 5, T = -1; function A() { return C ? !0 : !(e.unstable_now() - T < P); } @@ -533,7 +533,7 @@ var sbe = EU((Ci, $i) => { var V = !0; try { e: { - b = !1, S && (S = !1, R(_), _ = -1), y = !0; + b = !1, S && (S = !1, R(N), N = -1), y = !0; var ee = v; try { t: { @@ -587,7 +587,7 @@ var sbe = EU((Ci, $i) => { E(z, 0); }; function U(H, V) { - _ = E(function() { + N = E(function() { H(e.unstable_now()); }, V); } @@ -661,7 +661,7 @@ var sbe = EU((Ci, $i) => { startTime: ee, expirationTime: K, sortIndex: -1 - }, ee > X ? (H.sortIndex = ee, t(m, H), n(f) === null && H === n(m) && (S ? (R(_), _ = -1) : S = !0, U(I, ee - X))) : (H.sortIndex = K, t(f, H), b || y || (b = !0, D || (D = !0, O()))), H; + }, ee > X ? (H.sortIndex = ee, t(m, H), n(f) === null && H === n(m) && (S ? (R(N), N = -1) : S = !0, U(I, ee - X))) : (H.sortIndex = K, t(f, H), b || y || (b = !0, D || (D = !0, O()))), H; }, e.unstable_shouldYield = A, e.unstable_wrapCallback = function(H) { var V = v; return function() { @@ -1034,7 +1034,7 @@ var sbe = EU((Ci, $i) => { } return null; } - var _ = Object.assign, P = /* @__PURE__ */ Symbol.for("react.element"), T = /* @__PURE__ */ Symbol.for("react.transitional.element"), A = /* @__PURE__ */ Symbol.for("react.portal"), z = /* @__PURE__ */ Symbol.for("react.fragment"), O = /* @__PURE__ */ Symbol.for("react.strict_mode"), L = /* @__PURE__ */ Symbol.for("react.profiler"), F = /* @__PURE__ */ Symbol.for("react.consumer"), U = /* @__PURE__ */ Symbol.for("react.context"), H = /* @__PURE__ */ Symbol.for("react.forward_ref"), V = /* @__PURE__ */ Symbol.for("react.suspense"), ee = /* @__PURE__ */ Symbol.for("react.suspense_list"), X = /* @__PURE__ */ Symbol.for("react.memo"), K = /* @__PURE__ */ Symbol.for("react.lazy"), G = /* @__PURE__ */ Symbol.for("react.activity"), W = /* @__PURE__ */ Symbol.for("react.legacy_hidden"), Q = /* @__PURE__ */ Symbol.for("react.memo_cache_sentinel"), k = /* @__PURE__ */ Symbol.for("react.view_transition"), B = /* @__PURE__ */ Symbol.for("react.recoverable"), J = Symbol.iterator; + var N = Object.assign, P = /* @__PURE__ */ Symbol.for("react.element"), T = /* @__PURE__ */ Symbol.for("react.transitional.element"), A = /* @__PURE__ */ Symbol.for("react.portal"), z = /* @__PURE__ */ Symbol.for("react.fragment"), O = /* @__PURE__ */ Symbol.for("react.strict_mode"), L = /* @__PURE__ */ Symbol.for("react.profiler"), F = /* @__PURE__ */ Symbol.for("react.consumer"), U = /* @__PURE__ */ Symbol.for("react.context"), H = /* @__PURE__ */ Symbol.for("react.forward_ref"), V = /* @__PURE__ */ Symbol.for("react.suspense"), ee = /* @__PURE__ */ Symbol.for("react.suspense_list"), X = /* @__PURE__ */ Symbol.for("react.memo"), K = /* @__PURE__ */ Symbol.for("react.lazy"), G = /* @__PURE__ */ Symbol.for("react.activity"), W = /* @__PURE__ */ Symbol.for("react.legacy_hidden"), Q = /* @__PURE__ */ Symbol.for("react.memo_cache_sentinel"), k = /* @__PURE__ */ Symbol.for("react.view_transition"), B = /* @__PURE__ */ Symbol.for("react.recoverable"), J = Symbol.iterator; function re(o) { return o === null || typeof o != "object" ? null : (o = J && o[J] || o["@@iterator"], typeof o == "function" ? o : null); } @@ -2026,7 +2026,7 @@ Error generating stack: ` + h.message + ` o.hasOwnProperty(ae) && (d = o[ae], this[ae] = d ? d(w) : w[ae]); return this.isDefaultPrevented = (w.defaultPrevented != null ? w.defaultPrevented : w.returnValue === !1) ? gp : V1, this.isPropagationStopped = V1, this; } - return _(i.prototype, { + return N(i.prototype, { preventDefault: function() { this.defaultPrevented = !0; var d = this.nativeEvent; @@ -2050,7 +2050,7 @@ Error generating stack: ` + h.message + ` }, defaultPrevented: 0, isTrusted: 0 - }, vp = io(Di), ju = _({}, Di, { view: 0, detail: 0 }), Q4 = io(ju), Cb, $b, Fu, bp = _({}, ju, { + }, vp = io(Di), ju = N({}, Di, { view: 0, detail: 0 }), Q4 = io(ju), Cb, $b, Fu, bp = N({}, ju, { screenX: 0, screenY: 0, clientX: 0, @@ -2073,15 +2073,15 @@ Error generating stack: ` + h.message + ` movementY: function(o) { return "movementY" in o ? o.movementY : $b; } - }), K1 = io(bp), Y4 = _({}, bp, { dataTransfer: 0 }), Z4 = io(Y4), J4 = _({}, ju, { relatedTarget: 0 }), wb = io(J4), e6 = _({}, Di, { + }), K1 = io(bp), Y4 = N({}, bp, { dataTransfer: 0 }), Z4 = io(Y4), J4 = N({}, ju, { relatedTarget: 0 }), wb = io(J4), e6 = N({}, Di, { animationName: 0, elapsedTime: 0, pseudoElement: 0 - }), t6 = io(e6), n6 = _({}, Di, { + }), t6 = io(e6), n6 = N({}, Di, { clipboardData: function(o) { return "clipboardData" in o ? o.clipboardData : window.clipboardData; } - }), r6 = io(n6), o6 = _({}, Di, { data: 0 }), G1 = io(o6), a6 = { + }), r6 = io(n6), o6 = N({}, Di, { data: 0 }), G1 = io(o6), a6 = { Esc: "Escape", Spacebar: " ", Left: "ArrowLeft", @@ -2144,7 +2144,7 @@ Error generating stack: ` + h.message + ` function Eb() { return l6; } - var c6 = _({}, ju, { + var c6 = N({}, ju, { key: function(o) { if (o.key) { var i = a6[o.key] || o.key; @@ -2170,7 +2170,7 @@ Error generating stack: ` + h.message + ` which: function(o) { return o.type === "keypress" ? hp(o) : o.type === "keydown" || o.type === "keyup" ? o.keyCode : 0; } - }), u6 = io(c6), d6 = _({}, bp, { + }), u6 = io(c6), d6 = N({}, bp, { pointerId: 0, width: 0, height: 0, @@ -2181,7 +2181,7 @@ Error generating stack: ` + h.message + ` twist: 0, pointerType: 0, isPrimary: 0 - }), X1 = io(d6), f6 = _({}, Di, { submitter: 0 }), m6 = io(f6), p6 = _({}, ju, { + }), X1 = io(d6), f6 = N({}, Di, { submitter: 0 }), m6 = io(f6), p6 = N({}, ju, { touches: 0, targetTouches: 0, changedTouches: 0, @@ -2190,11 +2190,11 @@ Error generating stack: ` + h.message + ` ctrlKey: 0, shiftKey: 0, getModifierState: Eb - }), h6 = io(p6), g6 = _({}, Di, { + }), h6 = io(p6), g6 = N({}, Di, { propertyName: 0, elapsedTime: 0, pseudoElement: 0 - }), v6 = io(g6), b6 = _({}, bp, { + }), v6 = io(g6), b6 = N({}, bp, { deltaX: function(o) { return "deltaX" in o ? o.deltaX : "wheelDeltaX" in o ? -o.wheelDeltaX : 0; }, @@ -2203,7 +2203,7 @@ Error generating stack: ` + h.message + ` }, deltaZ: 0, deltaMode: 0 - }), y6 = io(b6), S6 = _({}, Di, { + }), y6 = io(b6), S6 = N({}, Di, { newState: 0, oldState: 0, source: 0 @@ -3584,7 +3584,7 @@ Error generating stack: ` + h.message + ` ut.flags = ut.flags & -65537 | 128; case 0: if (ut = $t.payload, Pe = typeof ut == "function" ? ut.call(Yt, Ye, Pe) : ut, Pe == null) break e; - Ye = _({}, Ye, Pe); + Ye = N({}, Ye, Pe); break e; case 2: Bi = !0; @@ -4690,7 +4690,7 @@ Error generating stack: ` + h.message + ` useEffectEvent: lT }; function yy(o, i, d, h) { - i = o.memoizedState, d = d(h, i), d = d == null ? i : _({}, i, d), o.memoizedState = d, o.lanes === 0 && (o.updateQueue.baseState = d); + i = o.memoizedState, d = d(h, i), d = d == null ? i : N({}, i, d), o.memoizedState = d, o.lanes === 0 && (o.updateQueue.baseState = d); } var Sy = { enqueueSetState: function(o, i, d) { @@ -4723,7 +4723,7 @@ Error generating stack: ` + h.message + ` h !== "ref" && (d[h] = i[h]); } if (o = o.defaultProps) { - d === i && (d = _({}, d)); + d === i && (d = N({}, d)); for (var x in o) d[x] === void 0 && (d[x] = o[x]); } @@ -10380,7 +10380,7 @@ Error generating stack: ` + h.message + ` this._scope = document.documentElement, this._selector = "::view-transition-" + o + "(" + i + ")"; } Ys.prototype.animate = function(o, i) { - return i = typeof i == "number" ? { duration: i } : _({}, i), i.pseudoElement = this._selector, this._scope.animate(o, i); + return i = typeof i == "number" ? { duration: i } : N({}, i), i.pseudoElement = this._selector, this._scope.animate(o, i); }, Ys.prototype.getAnimations = function() { for (var o = this._scope, i = this._selector, d = o.getAnimations({ subtree: !0 }), h = [], x = 0; x < d.length; x++) { var w = d[x].effect; @@ -11060,7 +11060,7 @@ Error generating stack: ` + h.message + ` case "script": w = Pc(o); } - if (!(Qo.has(w) || (o = _( + if (!(Qo.has(w) || (o = N( { rel: "preload", href: i === "image" && d && d.imageSrcSet ? void 0 : o, @@ -11089,7 +11089,7 @@ Error generating stack: ` + h.message + ` case "script": w = Pc(o); } - if (!Qo.has(w) && (o = _({ rel: "modulepreload", href: o }, i), Qo.set(w, o), d.querySelector(x) === null)) { + if (!Qo.has(w) && (o = N({ rel: "modulepreload", href: o }, i), Qo.set(w, o), d.querySelector(x) === null)) { switch (h) { case "audioworklet": case "paintworklet": @@ -11118,7 +11118,7 @@ Error generating stack: ` + h.message + ` )) ae.loading = 5; else { - o = _( + o = N( { rel: "stylesheet", href: o, "data-precedence": i }, d ), (d = Qo.get(w)) && TS(o, d); @@ -11145,7 +11145,7 @@ Error generating stack: ` + h.message + ` var d = Ec; if (d && o) { var h = Er(d).hoistableScripts, x = Pc(o), w = h.get(x); - w || (w = d.querySelector(Sd(x)), w || (o = _({ src: o, async: !0 }, i), (i = Qo.get(x)) && NS(o, i), w = d.createElement("script"), Wn(w), qr(w, "link", o), d.head.appendChild(w)), w = { + w || (w = d.querySelector(Sd(x)), w || (o = N({ src: o, async: !0 }, i), (i = Qo.get(x)) && NS(o, i), w = d.createElement("script"), Wn(w), qr(w, "link", o), d.head.appendChild(w)), w = { type: "script", instance: w, count: 1, @@ -11158,7 +11158,7 @@ Error generating stack: ` + h.message + ` var d = Ec; if (d && o) { var h = Er(d).hoistableScripts, x = Pc(o), w = h.get(x); - w || (w = d.querySelector(Sd(x)), w || (o = _({ src: o, async: !0, type: "module" }, i), (i = Qo.get(x)) && NS(o, i), w = d.createElement("script"), Wn(w), qr(w, "link", o), d.head.appendChild(w)), w = { + w || (w = d.querySelector(Sd(x)), w || (o = N({ src: o, async: !0, type: "module" }, i), (i = Qo.get(x)) && NS(o, i), w = d.createElement("script"), Wn(w), qr(w, "link", o), d.head.appendChild(w)), w = { type: "script", instance: w, count: 1, @@ -11236,7 +11236,7 @@ Error generating stack: ` + h.message + ` return 'link[rel="stylesheet"][' + o + "]"; } function y_(o) { - return _({}, o, { + return N({}, o, { "data-precedence": o.precedence, precedence: null }); @@ -11272,7 +11272,7 @@ Error generating stack: ` + h.message + ` ); if (h) return i.instance = h, Wn(h), h; - var x = _({}, d, { + var x = N({}, d, { "data-href": d.href, "data-precedence": d.precedence, href: null, @@ -11296,7 +11296,7 @@ Error generating stack: ` + h.message + ` case "script": return w = Pc(d.src), (x = o.querySelector( Sd(w) - )) ? (i.instance = x, Wn(x), x) : (h = d, (x = Qo.get(w)) && (h = _({}, d), NS(h, x)), o = o.ownerDocument || o, x = o.createElement("script"), Wn(x), qr(x, "link", h), o.head.appendChild(x), i.instance = x); + )) ? (i.instance = x, Wn(x), x) : (h = d, (x = Qo.get(w)) && (h = N({}, d), NS(h, x)), o = o.ownerDocument || o, x = o.createElement("script"), Wn(x), qr(x, "link", h), o.head.appendChild(x), i.instance = x); case "void": return null; default: @@ -13443,7 +13443,7 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho ...E }, $ = `${c}_${p.prefix}`, M = gm($), I = `${q8}-${M}`; R._tokenKey = F8(R, $); - const [D, _] = C2(E, p.key, { + const [D, N] = C2(E, p.key, { prefix: p.prefix, ignore: p.ignore, unitless: p.unitless, @@ -13451,7 +13451,7 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho hashPriority: s, hashCls: p.hashed ? I : void 0 }); - return D._hashId = M, W8(p.key), [D, I, R, _, p.key]; + return D._hashId = M, W8(p.key), [D, I, R, N, p.key]; }, ([, , , , E]) => { G8(E, r); }, ([, , , E, R]) => { @@ -13678,7 +13678,7 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho return lq(ug("", null, null, null, [""], e = sq(e), 0, [0], e)); } function ug(e, t, n, r, a, s, c, u, f) { - for (var m = 0, p = 0, g = c, v = 0, y = 0, b = 0, S = 1, C = 1, E = 1, R = 0, $ = 0, M = "", I = a, D = s, _ = r, P = M; C; ) + for (var m = 0, p = 0, g = c, v = 0, y = 0, b = 0, S = 1, C = 1, E = 1, R = 0, $ = 0, M = "", I = a, D = s, N = r, P = M; C; ) switch (b = $, $ = va()) { // ( case 40: @@ -13745,9 +13745,9 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho P += ";"; // { rule/at-rule default: - if (Vf(_ = bI(P, t, n, m, p, a, u, M, I = [], D = [], g, s), s), $ === 123) + if (Vf(N = bI(P, t, n, m, p, a, u, M, I = [], D = [], g, s), s), $ === 123) if (p === 0) - ug(P, t, _, _, I, s, g, u, D); + ug(P, t, N, N, I, s, g, u, D); else { switch (v) { // c(ontainer) @@ -13763,7 +13763,7 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho case 109: case 115: } - p ? ug(e, _, _, r && Vf(bI(e, _, _, 0, 0, a, u, M, a, I = [], g, D), D), a, D, g, u, r ? I : D) : ug(P, _, _, _, [""], D, 0, u, D); + p ? ug(e, N, N, r && Vf(bI(e, N, N, 0, 0, a, u, M, a, I = [], g, D), D), a, D, g, u, r ? I : D) : ug(P, N, N, N, [""], D, 0, u, D); } } m = p = y = 0, S = E = 1, M = P = "", g = c; @@ -14146,10 +14146,10 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho Object.keys(R).forEach(($) => { const M = R[$]; if (typeof M == "object" && M && ($ !== "animationName" || !M._keyframe) && !Sq(M)) { - let I = !1, D = $.trim(), _ = !1; - (n || r) && s ? D.startsWith("@") ? I = !0 : D === "&" ? D = CI("", s, f) : D = CI($, s, f) : n && !s && (D === "&" || D === "") && (D = "", _ = !0); + let I = !1, D = $.trim(), N = !1; + (n || r) && s ? D.startsWith("@") ? I = !0 : D === "&" ? D = CI("", s, f) : D = CI($, s, f) : n && !s && (D === "&" || D === "") && (D = "", N = !0); const [P, T] = I0(M, t, { - root: _, + root: N, injectHash: I, parentSelectors: [...a, D] }); @@ -14158,14 +14158,14 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho ...T }, g += `${D}${P}`; } else { - let I = function(_, P) { - const T = _.replace(/[A-Z]/g, (z) => `-${z.toLowerCase()}`); + let I = function(N, P) { + const T = N.replace(/[A-Z]/g, (z) => `-${z.toLowerCase()}`); let A = P; - !Z8[_] && typeof A == "number" && A !== 0 && (A = `${A}px`), _ === "animationName" && P?._keyframe && (y(P), A = P.getName(s)), g += `${T}:${A};`; + !Z8[N] && typeof A == "number" && A !== 0 && (A = `${A}px`), N === "animationName" && P?._keyframe && (y(P), A = P.getName(s)), g += `${T}:${A};`; }; const D = M?.value ?? M; - typeof M == "object" && M?.[I2] && Array.isArray(D) ? D.forEach((_) => { - I($, _); + typeof M == "object" && M?.[I2] && Array.isArray(D) ? D.forEach((N) => { + I($, N); }) : B8(D) && I($, D); } }); @@ -14215,8 +14215,8 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho path: n.join("-"), transformers: g, linters: v - }), D = JS(M, S || !1), _ = M2(C, D); - return [D, _, I, c, u]; + }), D = JS(M, S || !1), N = M2(C, D); + return [D, N, I, c, u]; }, // Remove cache if no need (R, $) => { @@ -14230,25 +14230,25 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho (R) => { const [$, M, I, , D] = R; if (E && $ !== N2) { - let _ = { + let N = { mark: wi, prepend: b ? !1 : "queue", attachTo: p, priority: D }; - _ = zE(_, s); + N = zE(N, s); const P = [], T = []; Object.keys(I).forEach((z) => { z.startsWith("@layer") ? P.push(z) : T.push(z); }), P.forEach((z) => { hl(JS(I[z], S || !1), `_layer-${z}`, { - ..._, + ...N, prepend: !0 }); }); - const A = hl($, M, _); + const A = hl($, M, N); A[gl] = y.instanceId, T.forEach((z) => { - hl(JS(I[z], S || !1), `_effect-${z}`, _); + hl(JS(I[z], S || !1), `_effect-${z}`, N); }); } } @@ -14684,12 +14684,12 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho var I = xo(xo({}, S), {}, { unitless: M, prefixToken: E - }), D = p(v, y, b, I), _ = m(C, b, I); + }), D = p(v, y, b, I), N = m(C, b, I); return function(P) { var T = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : P, A = D(P, T), z = S?.extraCssVarPrefixCls, O = typeof z == "function" ? z({ prefixCls: P, rootCls: T - }) : z, L = _(O != null && O.length ? [T].concat(Nt(O)) : T); + }) : z, L = N(O != null && O.length ? [T].concat(Nt(O)) : T); return [A, L]; }; } @@ -14709,10 +14709,10 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho return D.nonce; } }, function() { - var _ = PI(v, I, y), P = EI(v, I, _, { + var N = PI(v, I, y), P = EI(v, I, N, { deprecatedTokens: b?.deprecatedTokens }); - return _ && Object.keys(_).forEach(function(T) { + return N && Object.keys(N).forEach(function(T) { P[C(T)] = P[T], delete P[T]; }), P; }), M?.key; @@ -14723,7 +14723,7 @@ In order to be iterable, non-array objects must have a [Symbol.iterator]() metho name: "antd" }; return function(I) { - var D = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : I, _ = r(), P = _.theme, T = _.realToken, A = _.hashId, z = _.token, O = _.cssVar, L = _.zeroRuntime, F = l.useMemo(function() { + var D = arguments.length > 1 && arguments[1] !== void 0 ? arguments[1] : I, N = r(), P = N.theme, T = N.realToken, A = N.hashId, z = N.token, O = N.cssVar, L = N.zeroRuntime, F = l.useMemo(function() { return L; }, []); if (F) @@ -16768,7 +16768,7 @@ ${s} onLeaveEnd: $, onVisibleChanged: M }) { - const [I, D] = l.useState(), [_, P] = kU(Fa), [T, A] = l.useState([null, null]), z = _(), O = l.useRef(!1), L = l.useRef(null); + const [I, D] = l.useState(), [N, P] = kU(Fa), [T, A] = l.useState([null, null]), z = N(), O = l.useRef(!1), L = l.useRef(null); function F() { return n(); } @@ -16777,7 +16777,7 @@ ${s} P(Fa), A([null, null]); } const V = Tt((le) => { - const ie = _(); + const ie = N(); if (ie === Fa) return; const Y = F(); @@ -16851,7 +16851,7 @@ ${s} }); const re = T[1]; return [ - _, + N, W, J, I ?? t, @@ -16889,8 +16889,8 @@ ${s} function C() { return Sa(S.current); } - const [E, R, $, M, I] = qW(b, c, C, a), D = E(), _ = l.useRef(M); - M && (_.current = !0); + const [E, R, $, M, I] = qW(b, c, C, a), D = E(), N = l.useRef(M); + M && (N.current = !0); const P = l.useMemo(() => { const z = {}; return Object.defineProperties(z, { @@ -16924,7 +16924,7 @@ ${s} else if (D === Fa) M ? z = m({ ...O - }, S) : !u && _.current && g ? z = m({ + }, S) : !u && N.current && g ? z = m({ ...O, className: g }, S) : f || !u && !g ? z = m({ @@ -17375,7 +17375,7 @@ ${s} styles: M, classNames: I, ...D - } = e, _ = s ?? a, [P, T] = l.useState(!1), A = l.useRef(null); + } = e, N = s ?? a, [P, T] = l.useState(!1), A = l.useRef(null); l.useImperativeHandle(t, () => ({ nativeElement: A.current })); @@ -17462,10 +17462,10 @@ ${s} }) : null, /* @__PURE__ */ l.createElement("div", { className: ne(`${k}-section`, ce.section), style: Z.section - }, tn(_) ? /* @__PURE__ */ l.createElement("div", { + }, tn(N) ? /* @__PURE__ */ l.createElement("div", { className: ne(`${k}-title`, ce.title), style: Z.title - }, _) : null, tn(n) ? /* @__PURE__ */ l.createElement("div", { + }, N) : null, tn(n) ? /* @__PURE__ */ l.createElement("div", { className: ne(`${k}-description`, ce.description), style: Z.description }, n) : null), tn(R) ? /* @__PURE__ */ l.createElement("div", { @@ -17562,15 +17562,15 @@ ${s} } b != null && b === document.body && kh(b) && !kh(document.documentElement) || b != null && kh(b, p) && y.push(b); } - const S = (r = (n = window.visualViewport) == null ? void 0 : n.width) != null ? r : innerWidth, C = (s = (a = window.visualViewport) == null ? void 0 : a.height) != null ? s : innerHeight, { scrollX: E, scrollY: R } = window, { height: $, width: M, top: I, right: D, bottom: _, left: P } = e.getBoundingClientRect(), { top: T, right: A, bottom: z, left: O } = ((H) => { + const S = (r = (n = window.visualViewport) == null ? void 0 : n.width) != null ? r : innerWidth, C = (s = (a = window.visualViewport) == null ? void 0 : a.height) != null ? s : innerHeight, { scrollX: E, scrollY: R } = window, { height: $, width: M, top: I, right: D, bottom: N, left: P } = e.getBoundingClientRect(), { top: T, right: A, bottom: z, left: O } = ((H) => { const V = window.getComputedStyle(H); return { top: parseFloat(V.scrollMarginTop) || 0, right: parseFloat(V.scrollMarginRight) || 0, bottom: parseFloat(V.scrollMarginBottom) || 0, left: parseFloat(V.scrollMarginLeft) || 0 }; })(e); - let L = u === "start" || u === "nearest" ? I - T : u === "end" ? _ + z : I + $ / 2 - T + z, F = f === "center" ? P + M / 2 - O + A : f === "end" ? D + A : P - O; + let L = u === "start" || u === "nearest" ? I - T : u === "end" ? N + z : I + $ / 2 - T + z, F = f === "center" ? P + M / 2 - O + A : f === "end" ? D + A : P - O; const U = []; for (let H = 0; H < y.length; H++) { const V = y[H], { height: ee, width: X, top: K, right: G, bottom: W, left: Q } = V.getBoundingClientRect(); - if (c === "if-needed" && I >= 0 && P >= 0 && _ <= C && D <= S && (V === v && !kh(V) || I >= K && _ <= W && P >= Q && D <= G)) return U; + if (c === "if-needed" && I >= 0 && P >= 0 && N <= C && D <= S && (V === v && !kh(V) || I >= K && N <= W && P >= Q && D <= G)) return U; const k = getComputedStyle(V), B = parseInt(k.borderLeftWidth, 10), J = parseInt(k.borderTopWidth, 10), re = parseInt(k.borderRightWidth, 10), le = parseInt(k.borderBottomWidth, 10); let ie = 0, Y = 0; const q = "offsetWidth" in V ? V.offsetWidth - V.clientWidth - B - re : 0, te = "offsetHeight" in V ? V.offsetHeight - V.clientHeight - J - le : 0, de = "offsetWidth" in V ? V.offsetWidth === 0 ? 0 : X / V.offsetWidth : 0, fe = "offsetHeight" in V ? V.offsetHeight === 0 ? 0 : ee / V.offsetHeight : 0; @@ -18134,7 +18134,7 @@ html body { forceRender: M, getPopupContainer: I, autoDestroy: D, - portal: _, + portal: N, children: P, zIndex: T, onMouseEnter: A, @@ -18163,7 +18163,7 @@ html body { if (!te) return null; const oe = {}; - return W && (W.includes("height") && k ? oe.height = k : W.includes("minHeight") && k && (oe.minHeight = k), W.includes("width") && Q ? oe.width = Q : W.includes("minWidth") && Q && (oe.minWidth = Q)), m || (oe.pointerEvents = "none"), /* @__PURE__ */ l.createElement(_, { + return W && (W.includes("height") && k ? oe.height = k : W.includes("minHeight") && k && (oe.minHeight = k), W.includes("width") && Q ? oe.width = Q : W.includes("minWidth") && Q && (oe.minWidth = Q)), m || (oe.pointerEvents = "none"), /* @__PURE__ */ l.createElement(N, { open: M || J, getContainer: I && (() => I(u)), autoDestroy: D, @@ -18293,13 +18293,13 @@ html body { clientHeight: v, offsetWidth: y, clientWidth: b - } = r, S = Nd(c), C = Nd(u), E = Nd(f), R = Nd(m), $ = Sm(Math.round(p.width / y * 1e3) / 1e3), M = Sm(Math.round(p.height / g * 1e3) / 1e3), I = (y - b - E - R) * $, D = (g - v - S - C) * M, _ = S * M, P = C * M, T = E * $, A = R * $; + } = r, S = Nd(c), C = Nd(u), E = Nd(f), R = Nd(m), $ = Sm(Math.round(p.width / y * 1e3) / 1e3), M = Sm(Math.round(p.height / g * 1e3) / 1e3), I = (y - b - E - R) * $, D = (g - v - S - C) * M, N = S * M, P = C * M, T = E * $, A = R * $; let z = 0, O = 0; if (a === "clip") { const V = Nd(s); z = V * $, O = V * M; } - const L = p.x + T - z, F = p.y + _ - O, U = L + p.width + 2 * z - T - A - I, H = F + p.height + 2 * O - _ - P - D; + const L = p.x + T - z, F = p.y + N - O, U = L + p.width + 2 * z - T - A - I, H = F + p.height + 2 * O - N - P - D; n.left = Math.max(n.left, L), n.top = Math.max(n.top, F), n.right = Math.min(n.right, U), n.bottom = Math.min(n.bottom, H); }), n; } @@ -18359,7 +18359,7 @@ html body { var E = Je, R = xt; const $ = t, M = $.ownerDocument, I = Hm($), { position: D - } = I.getComputedStyle($), _ = $.style.left, P = $.style.top, T = $.style.right, A = $.style.bottom, z = $.style.overflow, O = $.style.overflowX, L = $.style.overflowY, F = { + } = I.getComputedStyle($), N = $.style.left, P = $.style.top, T = $.style.right, A = $.style.bottom, z = $.style.overflow, O = $.style.overflowX, L = $.style.overflowY, F = { ...a[r], ...s }, U = M.createElement("div"); @@ -18412,7 +18412,7 @@ html body { const pe = te === fe, oe = ZI(q, g), ce = ZI(Y, g), Z = te === de ? ce : oe, me = pe ? ce : Z; $.style.left = "auto", $.style.top = "auto", $.style.right = "0", $.style.bottom = "0"; const xe = $.getBoundingClientRect(); - $.style.left = _, $.style.top = P, $.style.right = T, $.style.bottom = A, $.style.overflow = z, $.style.overflowX = O, $.style.overflowY = L, $.parentElement?.removeChild(U); + $.style.left = N, $.style.top = P, $.style.right = T, $.style.bottom = A, $.style.overflow = z, $.style.overflowX = O, $.style.overflowY = L, $.parentElement?.removeChild(U); const Me = Sm(Math.round(re / parseFloat(X) * 1e3) / 1e3), ve = Sm(Math.round(J / parseFloat(ee) * 1e3) / 1e3); if (Me === 0 || ve === 0 || ou(n) && !km(n)) return; @@ -18617,11 +18617,11 @@ html body { className: I, style: D }) => { - const _ = ne(S, I, y, { + const N = ne(S, I, y, { [`${S}-visible`]: C }); return /* @__PURE__ */ se.createElement("div", { - className: _, + className: N, style: { "--arrow-x": `${p?.x || 0}px`, "--arrow-y": `${p?.y || 0}px`, @@ -18654,7 +18654,7 @@ html body { M, I, D, - _, + N, P, // scaleX - not used in UniqueProvider , @@ -18726,7 +18726,7 @@ html body { height: ee.offsetHeight }), arrowPos: { - x: _, + x: N, y: P }, align: T, @@ -18747,7 +18747,7 @@ html body { offsetX: $, offsetY: M, arrowPos: { - x: _, + x: N, y: P }, popupSize: m, @@ -18783,7 +18783,7 @@ html body { maskClosable: I = !0, // Portal getPopupContainer: D, - forceRender: _, + forceRender: N, autoDestroy: P, // Popup popup: T, @@ -18952,7 +18952,7 @@ html body { ye.current && un.has("contextMenu") ? ot(!1) : (He(gt), ot(!0)), gt.preventDefault(), Ue.onContextMenu?.(gt, ...rn); }); const qn = l.useRef(!1); - qn.current ||= _ || Oe || St; + qn.current ||= N || Oe || St; const Kn = { ...Ue, ...Ee @@ -18996,7 +18996,7 @@ html body { maskMotion: re, onVisibleChanged: Qn, onPrepare: Un, - forceRender: _, + forceRender: N, autoDestroy: Y, getPopupContainer: D, onEsc: nt, @@ -19210,7 +19210,7 @@ html body { iconPrefixCls: M, theme: I, componentDisabled: D, - segmented: _, + segmented: N, statistic: P, spin: T, calendar: A, @@ -19307,7 +19307,7 @@ html body { getPrefixCls: Gt, iconPrefixCls: Bt, theme: un, - segmented: _, + segmented: N, statistic: P, spin: T, calendar: A, @@ -19600,7 +19600,7 @@ html body { pauseOnHover: I = !0, // Function onClick: D, - onMouseEnter: _, + onMouseEnter: N, onMouseLeave: P, onClose: T } = e, [A, z] = l.useState(0), O = `${n}-notice`, [L, F, U] = r9(y), H = Tt(() => { @@ -19610,7 +19610,7 @@ html body { I && (M ? K() : V || X()); }, [M, V, K, X, I]); function Q(Z) { - ee(!0), I && K(), _?.(Z); + ee(!0), I && K(), N?.(Z); } function k(Z) { ee(!1), I && !M && X(), P?.(Z); @@ -19805,13 +19805,13 @@ html body { })), [t]), C = typeof f == "function" ? f(m) : f, [E, { offset: R, threshold: $ - }] = t9(u), [M, I] = l.useState(!1), D = E && (M || S.length <= $), _ = l.useMemo(() => { + }] = t9(u), [M, I] = l.useState(!1), D = E && (M || S.length <= $), N = l.useMemo(() => { if (!(!E || D)) return { offset: R, threshold: $ }; - }, [D, R, E, $]), [P, T] = l.useState(0), A = l.useRef(null), [z, O, L, F, U] = e9(t, _, P), H = !!t.length; + }, [D, R, E, $]), [P, T] = l.useState(0), A = l.useRef(null), [z, O, L, F, U] = e9(t, N, P), H = !!t.length; l.useEffect(() => { const ee = A.current; if (!ee) @@ -19902,33 +19902,33 @@ html body { } = e, [S, C] = l.useState([]), [E, R] = l.useState({}), $ = l.useRef(!1); l.useImperativeHandle(t, () => ({ open: (D) => { - C((_) => { - let P = [..._]; + C((N) => { + let P = [...N]; const T = P.findIndex((z) => z.key === D.key), A = { ...D }; - return T >= 0 ? (A.times = (_[T]?.times ?? 0) + 1, P[T] = A) : (A.times = 0, P.push(A)), s && s > 0 && P.length > s && (P = P.slice(-s)), P; + return T >= 0 ? (A.times = (N[T]?.times ?? 0) + 1, P[T] = A) : (A.times = 0, P.push(A)), s && s > 0 && P.length > s && (P = P.slice(-s)), P; }); }, close: (D) => { - C((_) => _.filter((P) => P.key !== D)); + C((N) => N.filter((P) => P.key !== D)); }, destroy: () => { C([]); } })), l.useEffect(() => { const D = {}; - S.forEach((_) => { - const P = _.placement ?? "topRight"; - D[P] = D[P] || [], D[P].push(_); - }), Object.keys(E).forEach((_) => { - D[_] = D[_] || []; + S.forEach((N) => { + const P = N.placement ?? "topRight"; + D[P] = D[P] || [], D[P].push(N); + }), Object.keys(E).forEach((N) => { + D[N] = D[N] || []; }), R(D); }, [S]); const M = Tt((D) => { - R((_) => { + R((N) => { const P = { - ..._ + ...N }; return (P[D] || []).length || delete P[D], P; }); @@ -19939,7 +19939,7 @@ html body { return null; const I = Object.keys(E); return /* @__PURE__ */ Va.createPortal(/* @__PURE__ */ l.createElement(l.Fragment, null, I.map((D) => { - const _ = /* @__PURE__ */ l.createElement(Lk, { + const N = /* @__PURE__ */ l.createElement(Lk, { key: D, configList: E[D], placement: D, @@ -19957,12 +19957,12 @@ html body { }, onAllRemoved: M }); - return b ? /* @__PURE__ */ l.cloneElement(b(_, { + return b ? /* @__PURE__ */ l.cloneElement(b(N, { prefixCls: n, key: D }), { key: D - }) : _; + }) : N; })), r); }), f9 = () => document.body; let iM = 0; @@ -19999,7 +19999,7 @@ html body { closable: s, duration: c, showProgress: u - }, [$, M] = l.useState(), I = l.useRef(null), [D, _] = l.useState([]), P = /* @__PURE__ */ l.createElement(d9, { + }, [$, M] = l.useState(), I = l.useRef(null), [D, N] = l.useState([]), P = /* @__PURE__ */ l.createElement(d9, { container: $, ref: I, prefixCls: r, @@ -20016,20 +20016,20 @@ html body { renderNotifications: E }), T = Tt((z) => { const O = m9(R, z); - (O.key === null || O.key === void 0) && (O.key = `rc-notification-${iM}`, iM += 1), _((L) => [...L, { + (O.key === null || O.key === void 0) && (O.key = `rc-notification-${iM}`, iM += 1), N((L) => [...L, { type: "open", config: O }]); }), A = l.useMemo(() => ({ open: T, close: (z) => { - _((O) => [...O, { + N((O) => [...O, { type: "close", key: z }]); }, destroy: () => { - _((z) => [...z, { + N((z) => [...z, { type: "destroy" }]); } @@ -20049,7 +20049,7 @@ html body { I.current?.destroy(); break; } - }), _((z) => { + }), N((z) => { const O = z.filter((L) => !D.includes(L)); return O.length === z.length ? z : O; })); @@ -20370,7 +20370,7 @@ html body { colorIconHover: M, colorBgTextHover: I, colorBgTextActive: D - } = e, _ = `${t}-notice`; + } = e, N = `${t}-notice`; return { ...Hk(e, { width: c, @@ -20383,27 +20383,27 @@ html body { }, typeStyle: !0 }), - [`${_}-section`]: { + [`${N}-section`]: { display: "flex", flexDirection: "column", flex: "auto", gap: y, minWidth: 0 }, - [`${_}-description`]: { + [`${N}-description`]: { color: f, fontSize: a }, - [`${_}-closable`]: { - [`${_}-title, ${_}-description`]: { + [`${N}-closable`]: { + [`${N}-title, ${N}-description`]: { paddingInlineEnd: b }, - [`${_}-title + ${_}-description`]: { + [`${N}-title + ${N}-description`]: { paddingInlineEnd: 0 } }, // ============================ Close ============================= - [`${_}-close`]: { + [`${N}-close`]: { position: "absolute", top: S, insetInlineEnd: C, @@ -20428,7 +20428,7 @@ html body { ...Lo(e) }, // =========================== Progress =========================== - [`${_}-progress`]: { + [`${N}-progress`]: { position: "absolute", bottom: 0, display: "block", @@ -20457,7 +20457,7 @@ html body { } }, // ============================ Action ============================ - [`${_}-actions`]: { + [`${N}-actions`]: { float: "right", marginTop: v } @@ -20874,9 +20874,9 @@ html body { styles: b } = qt("message"), S = t || p("message"), C = `${S}-notice`, E = rr(S), [R, $] = JE(S, E), [M, I] = Jt([y, u], [b, f], { props: e - }), D = eR(a, s), _ = a ? `${C}-icon-${a}` : void 0, P = { + }), D = eR(a, s), N = a ? `${C}-icon-${a}` : void 0, P = { wrapper: ne(a && `${S}-${a}`, M.wrapper), - icon: ne(_, M.icon), + icon: ne(N, M.icon), title: M.title }, T = { wrapper: I.wrapper, @@ -20963,12 +20963,12 @@ html body { message: E } = l.useContext(Pt), R = r || b("message"), $ = yt(E?.style), [M, I] = Jt([E?.classNames, f], [E?.styles, $, m], { props: e - }), D = () => g9(n ?? r7), _ = () => ne({ + }), D = () => g9(n ?? r7), N = () => ne({ [`${R}-rtl`]: u ?? S === "rtl" }), P = () => n7(R, p), T = h9(v, a7), [A, z] = p9({ prefixCls: R, style: D, - className: _, + className: N, motion: P, // closable=false requires-no closeIcon closable: !1, @@ -21015,7 +21015,7 @@ html body { onClose: M, classNames: I = {}, styles: D = {}, - ..._ + ...N } = f; let P = E; Hn(P) || (uM += 1, P = `antd-message-${uM}`); @@ -21028,7 +21028,7 @@ html body { props: T }), O = eR(C, S), L = C ? `${y}-icon-${C}` : void 0; return tR((F) => (m({ - ..._, + ...N, key: P, icon: O, title: b, @@ -21155,14 +21155,14 @@ html body { colorSource: a } = e, s = l.useRef(null), { getPrefixCls: c - } = l.useContext(Pt), u = c(), [f] = Ln(u, "wave"), [m, p] = l.useState(null), [g, v] = l.useState([]), [y, b] = l.useState(0), [S, C] = l.useState(0), [E, R] = l.useState(0), [$, M] = l.useState(0), [I, D] = l.useState(!1), _ = { + } = l.useContext(Pt), u = c(), [f] = Ln(u, "wave"), [m, p] = l.useState(null), [g, v] = l.useState([]), [y, b] = l.useState(0), [S, C] = l.useState(0), [E, R] = l.useState(0), [$, M] = l.useState(0), [I, D] = l.useState(!1), N = { left: y, top: S, width: E, height: $, borderRadius: g.map((A) => `${A}px`).join(" ") }; - m && (_[f("color")] = m); + m && (N[f("color")] = m); function P() { const A = getComputedStyle(n); p(m7(n, a)); @@ -21213,7 +21213,7 @@ html body { className: ne(t, A, { "wave-quick": T }), - style: _ + style: N })); }, h7 = (e, t) => { const { @@ -21368,7 +21368,7 @@ html body { l.useImperativeHandle(t, () => ({ nativeElement: I.current })); - const D = xr(g), _ = l.useMemo(() => D.map((P, T) => { + const D = xr(g), N = l.useMemo(() => D.map((P, T) => { const A = P?.key || `${E}-item-${T}`; return /* @__PURE__ */ l.createElement(S7, { key: A, @@ -21382,7 +21382,7 @@ html body { ref: I, className: $, ...y - }, _); + }, N); }), Qk = /* @__PURE__ */ l.createContext(void 0), x7 = (e) => { const { getPrefixCls: t, @@ -23022,7 +23022,7 @@ html body { htmlType: M = "button", classNames: I, styles: D, - style: _, + style: N, autoInsertSpace: P, autoFocus: T, ...A @@ -23081,7 +23081,7 @@ html body { disabled: oe, loading: me, iconPlacement: Oe - }, Ze = yt(V), he = yt(_), [ye, Qe] = Jt([n ? void 0 : ee, I], [n ? void 0 : X, Ze, D, he], { + }, Ze = yt(V), he = yt(N), [ye, Qe] = Jt([n ? void 0 : ee, I], [n ? void 0 : X, Ze, D, he], { props: Ue }), Je = ne(te, de, fe, { [`${te}-${k}`]: k !== "default" && k !== "square" && k, @@ -23315,7 +23315,7 @@ html body { width: M, height: I, classNames: D, - styles: _, + styles: N, isFixedPos: P, focusTrap: T } = e, { @@ -23333,18 +23333,18 @@ html body { const U = u ? /* @__PURE__ */ se.createElement("div", { className: ne(`${n}-footer`, D?.footer), style: { - ..._?.footer + ...N?.footer } }, u) : null, H = s ? /* @__PURE__ */ se.createElement("div", { className: ne(`${n}-header`, D?.header), style: { - ..._?.header + ...N?.header } }, /* @__PURE__ */ se.createElement("div", { className: ne(`${n}-title`, D?.title), id: c, style: { - ..._?.title + ...N?.title } }, s)) : null, V = l.useMemo(() => typeof f == "object" && f !== null ? f : f ? { closeIcon: m ?? /* @__PURE__ */ se.createElement("span", { @@ -23357,15 +23357,15 @@ html body { }, ee, { className: ne(`${n}-close`, D?.close), disabled: X, - style: _?.close + style: N?.close }), V.closeIcon) : null, G = /* @__PURE__ */ se.createElement("div", { className: ne(`${n}-container`, D?.container), - style: _?.container + style: N?.container }, K, H, /* @__PURE__ */ se.createElement("div", Ug({ className: ne(`${n}-body`, D?.body), style: { ...v, - ..._?.body + ...N?.body } }, y), g), U); return /* @__PURE__ */ se.createElement("div", { @@ -23524,7 +23524,7 @@ html body { rootClassName: M, rootStyle: I, classNames: D, - styles: _ + styles: N } = e, P = l.useRef(null), T = l.useRef(null), A = l.useRef(null), [z, O] = l.useState(r), [L, F] = l.useState(!1), U = Ya(); function H() { v0(T.current, document.activeElement) || (P.current = document.activeElement); @@ -23569,7 +23569,7 @@ html body { const k = { zIndex: n, ...s, - ..._?.wrapper, + ...N?.wrapper, display: z ? null : "none" }; return /* @__PURE__ */ l.createElement("div", rm({ @@ -23584,7 +23584,7 @@ html body { style: { zIndex: n, ...R, - ..._?.mask + ...N?.mask }, maskProps: $, className: D?.mask @@ -24246,8 +24246,8 @@ html body { const T = I[P], A = Array.isArray(T) ? T : [T]; D[P] = A.map(S.bind(null, P)); }); - const _ = new qg(D); - _.messages(s.messages), g.rule.options && (g.rule.options.messages = s.messages, g.rule.options.error = s.error), _.validate(g.value, g.rule.options || s, (P) => { + const N = new qg(D); + N.messages(s.messages), g.rule.options && (g.rule.options.messages = s.messages, g.rule.options.error = s.error), N.validate(g.value, g.rule.options || s, (P) => { const T = []; M && M.length && T.push(...M), P && P.length && T.push(...P), v(T.length ? T : null); }); @@ -24835,16 +24835,16 @@ html body { return $[r] = (...I) => { this.touched = !0, this.dirty = !0, this.triggerMetaEvent(); const D = this.getValue(); - let _; - s ? _ = s(...I) : _ = iG(u, ...I), c && (_ = c(_, D, y(!0))), _ !== D && b({ + let N; + s ? N = s(...I) : N = iG(u, ...I), c && (N = c(N, D, y(!0))), N !== D && b({ type: "updateValue", namePath: g, - value: _ + value: N }), E && E(...I); }, C$(p || []).forEach((I) => { const D = $[I]; - $[I] = (..._) => { - D && D(..._); + $[I] = (...N) => { + D && D(...N); const { rules: P } = this.props; @@ -24930,26 +24930,26 @@ html body { getFieldValue: E } = c, R = () => E(p || []) || [], $ = { add: (I, D) => { - const _ = R(); - D >= 0 && D <= _.length ? (m.keys = [...m.keys.slice(0, D), m.id, ...m.keys.slice(D)], S([..._.slice(0, D), I, ..._.slice(D)])) : (m.keys = [...m.keys, m.id], S([..._, I])), m.id += 1; + const N = R(); + D >= 0 && D <= N.length ? (m.keys = [...m.keys.slice(0, D), m.id, ...m.keys.slice(D)], S([...N.slice(0, D), I, ...N.slice(D)])) : (m.keys = [...m.keys, m.id], S([...N, I])), m.id += 1; }, remove: (I) => { - const D = R(), _ = new Set(Array.isArray(I) ? I : [I]); - _.size <= 0 || (m.keys = m.keys.filter((P, T) => !_.has(T)), S(D.filter((P, T) => !_.has(T)))); + const D = R(), N = new Set(Array.isArray(I) ? I : [I]); + N.size <= 0 || (m.keys = m.keys.filter((P, T) => !N.has(T)), S(D.filter((P, T) => !N.has(T)))); }, move(I, D) { if (I === D) return; - const _ = R(); - I < 0 || I >= _.length || D < 0 || D >= _.length || (m.keys = IM(m.keys, I, D), S(IM(_, I, D))); + const N = R(); + I < 0 || I >= N.length || D < 0 || D >= N.length || (m.keys = IM(m.keys, I, D), S(IM(N, I, D))); } }; let M = b || []; return Array.isArray(M) || (M = []), n(M.map((I, D) => { - let _ = m.keys[D]; - return _ === void 0 && (m.keys[D] = m.id, _ = m.keys[D], m.id += 1), { + let N = m.keys[D]; + return N === void 0 && (m.keys[D] = m.id, N = m.keys[D], m.id += 1), { name: D, - key: _, + key: N, isListField: !0 }; }), $, C); @@ -25534,9 +25534,9 @@ html body { rule: { warningOnly: D }, - errors: _ + errors: N }) => { - D ? I.push(..._) : M.push(..._); + D ? I.push(...N) : M.push(...N); }), M.length ? Promise.reject({ name: E, errors: M, @@ -25688,7 +25688,7 @@ html body { useSubscribe: M, setInitialValues: I, setCallbacks: D, - setValidateMessages: _, + setValidateMessages: N, setPreserve: P, destroyForm: T } = $.getInternalHooks(ul); @@ -25697,7 +25697,7 @@ html body { nativeElement: E.current })), l.useEffect(() => (R.registerForm(n, $), () => { R.unregisterForm(n); - }), [R, $, n]), _({ + }), [R, $, n]), N({ ...R.validateMessages, ...m }), D({ @@ -26407,7 +26407,7 @@ html body { style: R, classNames: $, styles: M - } = qt("skeleton"), I = S("skeleton", n), [D, _] = Gm(I), P = { + } = qt("skeleton"), I = S("skeleton", n), [D, N] = Gm(I), P = { ...e, avatar: p, title: g, @@ -26473,7 +26473,7 @@ html body { [`${I}-active`]: y, [`${I}-rtl`]: C === "rtl", [`${I}-round`]: b - }, z.root, E, a, s, D, _); + }, z.root, E, a, s, D, N); return /* @__PURE__ */ se.createElement("div", { ref: L, className: X, @@ -27035,7 +27035,7 @@ html body { okButtonProps: M, cancelButtonProps: I, destroyOnHidden: D, - destroyOnClose: _, + destroyOnClose: N, panelRef: P = null, closable: T, mask: A, @@ -27155,7 +27155,7 @@ html body { }, styles: St, panelRef: Ze, - destroyOnHidden: D ?? _, + destroyOnHidden: D ?? N, modalRender: Oe, // Focusable focusTriggerAfterClose: me.focusTriggerAfterClose, @@ -27306,7 +27306,7 @@ html body { const M = u ?? c === "confirm", I = l.useMemo(() => { const V = g?.autoFocusButton || p; return V || V === null ? V : "ok"; - }, [p, g?.autoFocusButton]), [D] = wo("Modal"), _ = m || D, P = Tr(r, M ? _?.okText : _?.justOkText), T = Tr(a, _?.cancelText), { + }, [p, g?.autoFocusButton]), [D] = wo("Modal"), N = m || D, P = Tr(r, M ? N?.okText : N?.justOkText), T = Tr(a, N?.cancelText), { closable: A } = b, { onClose: z @@ -27371,14 +27371,14 @@ html body { body: f, mask: r, ...g - }, _ = sn(e, ["bodyStyle", "maskStyle"]), P = ne($, `${$}-${e.type}`, { + }, N = sn(e, ["bodyStyle", "maskStyle"]), P = ne($, `${$}-${e.type}`, { [`${$}-rtl`]: a === "rtl" }, e.className), T = l.useMemo(() => { const O = h$(y, b); return O.closable ?? (O.closable = !1), O; }, [y, b]), [, A] = ro(), z = l.useMemo(() => n !== void 0 ? n : A.zIndexPopupBase + QE, [n, A]); return /* @__PURE__ */ l.createElement(Rj, { - ..._, + ...N, className: P, wrapClassName: ne({ [`${$}-centered`]: !!e.centered @@ -27664,11 +27664,11 @@ html body { l.useEffect(() => { if (S(!0), typeof ResizeObserver < "u") { const M = new ResizeObserver((D) => { - const _ = D[0].target; - g(_.offsetHeight + 8), y(_.offsetWidth); + const N = D[0].target; + g(N.offsetHeight + 8), y(N.offsetWidth); }), I = setInterval(() => { - const D = a ? `.${a(E)}` : `.${E}-dropdown`, _ = m.current?.querySelector(D); - _ && (clearInterval(I), M.observe(_)); + const D = a ? `.${a(E)}` : `.${E}-dropdown`, N = m.current?.querySelector(D); + N && (clearInterval(I), M.observe(N)); }, 10); return () => { clearInterval(I), M.disconnect(); @@ -27820,7 +27820,7 @@ html body { onPopupMouseEnter: M, onPopupMouseDown: I, onPopupBlur: D, - ..._ + ...N } = e, P = `${n}-dropdown`; let T = c; S && (T = S(c)); @@ -27833,7 +27833,7 @@ html body { const U = l.useRef(null); return l.useImperativeHandle(t, () => ({ getPopupElement: () => U.current?.popupElement - })), /* @__PURE__ */ l.createElement($v, M$({}, _, { + })), /* @__PURE__ */ l.createElement($v, M$({}, N, { showAction: $ ? ["click"] : [], hideAction: $ ? ["click"] : [], popupPlacement: v || (g === "rtl" ? "bottomRight" : "bottomLeft"), @@ -28038,7 +28038,7 @@ html body { id: M, classNames: I, styles: D, - open: _, + open: N, activeDescendantId: P, role: T, disabled: A @@ -28059,7 +28059,7 @@ html body { } = B, { value: re } = B.currentTarget; - J === "Enter" && v === "tags" && !_ && !O.current && b && b(re), r?.(B); + J === "Enter" && v === "tags" && !N && !O.current && b && b(re), r?.(B); }, V = (B) => { S?.(), a?.(B); }, ee = () => { @@ -28108,12 +28108,12 @@ html body { onCompositionEnd: X, // Accessibility attributes role: T || "combobox", - "aria-expanded": _ || !1, + "aria-expanded": N || !1, "aria-haspopup": "listbox", - "aria-owns": _ ? `${M}_list` : void 0, + "aria-owns": N ? `${M}_list` : void 0, "aria-autocomplete": "list", - "aria-controls": _ ? `${M}_list` : void 0, - "aria-activedescendant": _ ? P : void 0 + "aria-controls": N ? `${M}_list` : void 0, + "aria-activedescendant": N ? P : void 0 }; if (/* @__PURE__ */ l.isValidElement($)) { const B = $.props || {}, J = { @@ -28196,7 +28196,7 @@ html body { showSearch: g, classNames: v, styles: y - } = Ol(), b = l.useContext(mR), [S, C] = l.useState(!1), E = u === "combobox", R = s[0], $ = l.useMemo(() => E && a && !S && m ? a : g ? r : "", [E, a, S, m, r, g]), [M, I, D, _] = l.useMemo(() => { + } = Ol(), b = l.useContext(mR), [S, C] = l.useState(!1), E = u === "combobox", R = s[0], $ = l.useMemo(() => E && a && !S && m ? a : g ? r : "", [E, a, S, m, r, g]), [M, I, D, N] = l.useMemo(() => { let z, O, L; if (R && b?.flattenOptions) { const U = b.flattenOptions.find((H) => H.value === R.value); @@ -28207,7 +28207,7 @@ html body { l.useEffect(() => { E && C(!1); }, [E, a]); - const P = R && R.label !== null && R.label !== void 0 && String(R.label).trim() !== "", A = !(E && f?.input) ? R ? _ ? /* @__PURE__ */ l.createElement("div", { + const P = R && R.label !== null && R.label !== void 0 && String(R.label).trim() !== "", A = !(E && f?.input) ? R ? N ? /* @__PURE__ */ l.createElement("div", { className: ne(`${n}-content-value`, M), style: { ...$ ? { @@ -28220,9 +28220,9 @@ html body { show: !$ }) : null; return /* @__PURE__ */ l.createElement("div", { - className: ne(`${n}-content`, P && `${n}-content-has-value`, $ && `${n}-content-has-search-value`, _ && `${n}-content-has-option-style`, v?.content), + className: ne(`${n}-content`, P && `${n}-content-has-value`, $ && `${n}-content-has-search-value`, N && `${n}-content-has-option-style`, v?.content), style: y?.content, - title: _ ? void 0 : D + title: N ? void 0 : D }, A, /* @__PURE__ */ l.createElement(Hj, O$({ ref: t }, e, { @@ -28251,8 +28251,8 @@ html body { component: S = "div", ...C } = e, E = c && !y; - function R(_) { - f(m, _); + function R(N) { + f(m, N); } l.useEffect(() => () => { R(null); @@ -28282,9 +28282,9 @@ html body { }), $); return c && (D = /* @__PURE__ */ l.createElement(Co, { onResize: ({ - offsetWidth: _ + offsetWidth: N }) => { - R(_); + R(N); }, disabled: u }, D)), D; @@ -28366,7 +28366,7 @@ html body { itemComponent: E, onVisibleChange: R, ...$ - } = e, M = f === "full", I = CX(), [D, _] = kc(I, null), P = D || 0, [T, A] = kc(I, /* @__PURE__ */ new Map()), [z, O] = kc(I, 0), [L, F] = kc(I, 0), [U, H] = kc(I, 0), [V, ee] = kc(I, 0), [X, K] = l.useState(null), [G, W] = l.useState(null), Q = l.useMemo(() => G === null && M ? Number.MAX_SAFE_INTEGER : G || 0, [G, D]), [k, B] = l.useState(!1), J = `${n}-item`, re = Math.max(z, L), le = g === Wj, ie = r.length && le, Y = g === Vj, q = ie || typeof g == "number" && r.length > g, te = l.useMemo(() => { + } = e, M = f === "full", I = CX(), [D, N] = kc(I, null), P = D || 0, [T, A] = kc(I, /* @__PURE__ */ new Map()), [z, O] = kc(I, 0), [L, F] = kc(I, 0), [U, H] = kc(I, 0), [V, ee] = kc(I, 0), [X, K] = l.useState(null), [G, W] = l.useState(null), Q = l.useMemo(() => G === null && M ? Number.MAX_SAFE_INTEGER : G || 0, [G, D]), [k, B] = l.useState(!1), J = `${n}-item`, re = Math.max(z, L), le = g === Wj, ie = r.length && le, Y = g === Vj, q = ie || typeof g == "number" && r.length > g, te = l.useMemo(() => { let ze = r; return ie ? D === null && M ? ze = r : ze = r.slice(0, Math.min(r.length, P / u)) : typeof g == "number" && (ze = r.slice(0, g)), ze; }, [r, u, D, g, ie]), de = l.useMemo(() => ie ? r.slice(Q + 1) : r.slice(te.length), [r, te, ie, Q]), fe = l.useCallback((ze, Oe) => typeof c == "function" ? c(ze) : (c && ze?.[c]) ?? Oe, [c]), pe = l.useCallback(a || ((ze) => ze), [a]); @@ -28374,7 +28374,7 @@ html body { G === ze && (Oe === void 0 || Oe === X) || (W(ze), Ge || (B(ze < r.length - 1), R?.(ze)), Oe !== void 0 && K(Oe)); } function ce(ze, Oe) { - _(Oe.clientWidth); + N(Oe.clientWidth); } function Z(ze, Oe) { A((Ge) => { @@ -28564,7 +28564,7 @@ html body { } = Ol(), I = `${r}-selection-item`; let D = s; !v && c === "multiple" && b !== !1 && (D = ""); - const _ = p && D || "", P = p && !m, T = f ?? "×", A = C ?? ((ee) => `+ ${ee.length} ...`), z = S, O = (ee) => { + const N = p && D || "", P = p && !m, T = f ?? "×", A = C ?? ((ee) => `+ ${ee.length} ...`), z = S, O = (ee) => { y(ee); }, L = (ee) => { u?.(ee); @@ -28625,7 +28625,7 @@ html body { prefixCls: `${r}-content`, className: $?.content, style: M?.content, - prefix: !a.length && !_ && /* @__PURE__ */ l.createElement(Uj, null), + prefix: !a.length && !N && /* @__PURE__ */ l.createElement(Uj, null), data: a, renderItem: H, renderRest: V, @@ -28634,7 +28634,7 @@ html body { disabled: m, readOnly: !P }, t, { - value: _ || "", + value: N || "", syncWidth: !0 })), itemKey: PX, @@ -28742,7 +28742,7 @@ html body { // Events onMouseDown: I, onClearMouseDown: D, - onInputKeyDown: _, + onInputKeyDown: N, onSelectorRemove: P, // Token handling tokenWithEnter: T, @@ -28761,7 +28761,7 @@ html body { const { which: Y } = ie, q = K.current instanceof HTMLTextAreaElement; - if (!q && O && (Y === vt.UP || Y === vt.DOWN) && ie.preventDefault(), _ && _(ie), q && !O && ~[vt.UP, vt.DOWN, vt.LEFT, vt.RIGHT].indexOf(Y)) + if (!q && O && (Y === vt.UP || Y === vt.DOWN) && ie.preventDefault(), N && N(ie), q && !O && ~[vt.UP, vt.DOWN, vt.LEFT, vt.RIGHT].indexOf(Y)) return; !(ie.ctrlKey || ie.altKey || ie.metaKey) && _X(Y) && L(!0); }); @@ -28891,7 +28891,7 @@ html body { loading: I, // Customize Input getInputElement: D, - getRawInputElement: _, + getRawInputElement: N, // Open open: P, defaultOpen: T, @@ -28943,7 +28943,7 @@ html body { scrollTo: (pt) => Ee.current?.scrollTo(pt), nativeElement: Sa(ke.current) })); - const We = zX(Me, D, _), it = l.useMemo(() => { + const We = zX(Me, D, N), it = l.useMemo(() => { if ($ !== "combobox") return F; const pt = v[0]?.value; @@ -29306,10 +29306,10 @@ html body { deltaY: M, shiftKey: I } = R; - let D = $, _ = M; - (b.current === "sx" || !b.current && I && M && !$) && (D = M, _ = 0, b.current = "sx"); - const P = Math.abs(D), T = Math.abs(_); - b.current === null && (b.current = s && P > T ? "x" : "y"), b.current === "y" ? v(R, _) : y(R, D); + let D = $, N = M; + (b.current === "sx" || !b.current && I && M && !$) && (D = M, N = 0, b.current = "sx"); + const P = Math.abs(D), T = Math.abs(N); + b.current === null && (b.current = s && P > T ? "x" : "y"), b.current === "y" ? v(R, N) : y(R, D); } function E(R) { e && (p.current = R.detail === m.current); @@ -29509,13 +29509,13 @@ html body { }), R = e.current.clientHeight; let $ = S < 0, M = v, I = null; if (R && S >= 0) { - let D = 0, _ = 0, P = 0; + let D = 0, N = 0, P = 0; const T = Math.min(t.length - 1, S); for (let z = 0; z <= T; z += 1) { const O = a(t[z]); - _ = D; + N = D; const L = n.get(O); - P = _ + (L === void 0 ? r : L), D = P; + P = N + (L === void 0 ? r : L), D = P; } let A = C === "top" ? E : R - E; for (let z = T; z >= 0; z -= 1) { @@ -29529,7 +29529,7 @@ html body { } switch (C) { case "top": - I = _ - E; + I = N - E; break; case "bottom": I = P - R + E; @@ -29538,7 +29538,7 @@ html body { const { scrollTop: z } = e.current, O = z + R; - _ < z ? M = "top" : P > O && (M = "bottom"); + N < z ? M = "top" : P > O && (M = "bottom"); } } I !== null && u(I), I !== p.lastTop && ($ = !0); @@ -29601,11 +29601,11 @@ html body { style: v, thumbStyle: y, showScrollBar: b - } = e, [S, C] = l.useState(!1), [E, R] = l.useState(null), [$, M] = l.useState(null), I = !r, D = l.useRef(null), _ = l.useRef(null), [P, T] = l.useState(b), A = l.useRef(void 0), z = () => { + } = e, [S, C] = l.useState(!1), [E, R] = l.useState(null), [$, M] = l.useState(null), I = !r, D = l.useRef(null), N = l.useRef(null), [P, T] = l.useState(b), A = l.useRef(void 0), z = () => { b === !0 || b === !1 || (clearTimeout(A.current), T(!0), A.current = setTimeout(() => { T(!1); }, 3e3)); - }, O = s - g || 0, L = g - p || 0, F = l.useMemo(() => a === 0 || O === 0 ? 0 : a / O * L, [a, O, L]), U = (B) => !!B && _.current?.contains(B), H = (B) => { + }, O = s - g || 0, L = g - p || 0, F = l.useMemo(() => a === 0 || O === 0 ? 0 : a / O * L, [a, O, L]), U = (B) => !!B && N.current?.contains(B), H = (B) => { const J = D.current; if (!J) return; @@ -29644,7 +29644,7 @@ html body { l.useEffect(() => { const B = (le) => { le.preventDefault(); - }, J = D.current, re = _.current; + }, J = D.current, re = N.current; return J.addEventListener("touchstart", B, { passive: !1 }), re.addEventListener("touchstart", X, { @@ -29738,7 +29738,7 @@ html body { onMouseDown: V, onMouseMove: z }, /* @__PURE__ */ l.createElement("div", { - ref: _, + ref: N, className: ne(`${W}-thumb`, { [`${W}-thumb-moving`]: S }), @@ -29790,14 +29790,14 @@ html body { styles: M, showScrollBar: I = "optional", ...D - } = e, _ = l.useCallback(($e) => typeof p == "function" ? p($e) : $e?.[p], [p]), [P, T, A, z] = HX(_), O = !!(g !== !1 && a && s), L = l.useMemo(() => Object.values(A.maps).reduce(($e, Re) => $e + Re, 0), [A.id, A.maps]), F = O && f && (Math.max(s * f.length, L) > a || !!y), U = v === "rtl", H = ne(n, { + } = e, N = l.useCallback(($e) => typeof p == "function" ? p($e) : $e?.[p], [p]), [P, T, A, z] = HX(N), O = !!(g !== !1 && a && s), L = l.useMemo(() => Object.values(A.maps).reduce(($e, Re) => $e + Re, 0), [A.id, A.maps]), F = O && f && (Math.max(s * f.length, L) > a || !!y), U = v === "rtl", H = ne(n, { [`${n}-rtl`]: U }, r), V = f || XX, ee = l.useRef(null), X = l.useRef(null), K = l.useRef(null), [G, W] = l.useState(0), [Q, k] = l.useState(0), [B, J] = l.useState(!1), re = () => { J(!0); }, le = () => { J(!1); }, ie = { - getKey: _ + getKey: N }; function Y($e) { W((Re) => { @@ -29810,7 +29810,7 @@ html body { const q = l.useRef({ start: 0, end: V.length - }), te = l.useRef(void 0), [de] = kX(V, _); + }), te = l.useRef(void 0), [de] = kX(V, N); te.current = de; const { scrollHeight: fe, @@ -29835,7 +29835,7 @@ html body { let $e = 0, Re, Fe, Ne; const Ke = V.length; for (let tt = 0; tt < Ke; tt += 1) { - const st = V[tt], xt = _(st), zt = A.get(xt), Gt = $e + (zt === void 0 ? s : zt); + const st = V[tt], xt = N(st), zt = A.get(xt), Gt = $e + (zt === void 0 ? s : zt); Gt >= G && Re === void 0 && (Re = tt, Fe = $e), Gt > G + a && Ne === void 0 && (Ne = tt), $e = Gt; } return Re === void 0 && (Re = 0, Fe = 0, Ne = Math.ceil(a / s)), Ne === void 0 && (Ne = V.length - 1), Ne = Math.min(Ne + 1, V.length - 1), { @@ -29849,7 +29849,7 @@ html body { const $e = A.getRecord(); if ($e.size === 1) { const Re = Array.from($e.keys())[0], Fe = $e.get(Re), Ne = V[pe]; - if (Ne && Fe === void 0 && _(Ne) === Re) { + if (Ne && Fe === void 0 && N(Ne) === Re) { const st = A.get(Re) - s; Y((xt) => xt + st); } @@ -29941,7 +29941,7 @@ html body { }, [Z.width, y]); const St = () => { Me.current?.delayHidden(), ve.current?.delayHidden(); - }, ft = FX(V, _, A, s), mt = KX(ee, V, A, s, _, ft, () => T(!0), Y, St); + }, ft = FX(V, N, A, s), mt = KX(ee, V, A, s, N, ft, () => T(!0), Y, St); l.useImperativeHandle(t, () => ({ nativeElement: K.current, getScrollInfo: Ge, @@ -30077,7 +30077,7 @@ html body { fieldNames: M, virtual: I, direction: D, - listHeight: _, + listHeight: N, listItemHeight: P, optionRender: T, classNames: A, @@ -30219,7 +30219,7 @@ html body { itemKey: "key", ref: F, data: L, - height: _, + height: N, itemHeight: P, fullHeight: !1, onMouseDown: H, @@ -30457,7 +30457,7 @@ html body { optionRender: M, children: I, defaultActiveFirstOption: D, - menuItemSelectedIcon: _, + menuItemSelectedIcon: N, virtual: P, direction: T, listHeight: A = 200, @@ -30636,7 +30636,7 @@ html body { onActiveValue: Be, defaultActiveFirstOption: ft, onSelect: Te, - menuItemSelectedIcon: _, + menuItemSelectedIcon: N, rawValues: it, fieldNames: pe, virtual: P !== !1 && E !== !1, @@ -30648,7 +30648,7 @@ html body { optionRender: M, classNames: ee, styles: X - }), [V, me, ye, Be, ft, Te, _, it, pe, P, E, T, A, z, de, M, ee, X]); + }), [V, me, ye, Be, ft, Te, N, it, pe, P, E, T, A, z, de, M, ee, X]); return /* @__PURE__ */ l.createElement(mR.Provider, { value: Fe }, /* @__PURE__ */ l.createElement(AX, H$({}, K, { @@ -30870,7 +30870,7 @@ html body { classNames: E, styles: R, image: $ - } = qt("empty"), M = y("empty", a), [I, D] = fQ(M), _ = yt(C), P = yt(m), [T, A] = Jt([E, p], [R, _, g, P], { + } = qt("empty"), M = y("empty", a), [I, D] = fQ(M), N = yt(C), P = yt(m), [T, A] = Jt([E, p], [R, N, g, P], { props: e }), [z] = wo("Empty"), O = typeof c < "u" ? c : z?.description, L = typeof O == "string" ? O : "empty", F = s ?? $ ?? Zj; let U = null; @@ -31694,7 +31694,7 @@ html body { colorPrimaryHover: $, colorPrimary: M, controlOutline: I - } = e, D = f * 2, _ = r * 2, P = Math.min(s - D, s - _), T = Math.min(c - D, c - _), A = Math.min(u - D, u - _), z = Math.floor(f / 2); + } = e, D = f * 2, N = r * 2, P = Math.min(s - D, s - N), T = Math.min(c - D, c - N), A = Math.min(u - D, u - N), z = Math.floor(f / 2); return { lineWidthFocus: a === 0 ? 0 : r, INTERNAL_FIXED_ITEM_MARGIN: z, @@ -31938,12 +31938,12 @@ html body { open: P, showSearch: T }) => M(P && T ? Tr(g, v, /* @__PURE__ */ l.createElement(SR, null)) : Tr(t, /* @__PURE__ */ l.createElement(yR, null))); - const D = Tr(a, s, y ? /* @__PURE__ */ l.createElement(bR, null) : null), _ = Tr(c, u, /* @__PURE__ */ l.createElement(Ps, null)); + const D = Tr(a, s, y ? /* @__PURE__ */ l.createElement(bR, null) : null), N = Tr(c, u, /* @__PURE__ */ l.createElement(Ps, null)); return { clearIcon: $, suffixIcon: I, itemIcon: D, - removeIcon: _ + removeIcon: N }; }, [e, t, n, r, a, s, c, u, f, m, p, g, v, y, b, S, C, E]); } @@ -31981,7 +31981,7 @@ html body { style: M, allowClear: I, variant: D, - popupStyle: _, + popupStyle: N, dropdownStyle: P, transitionName: T, tagRender: A, @@ -32076,7 +32076,7 @@ html body { [`${ve}-dropdown-${ke}`]: ke === "rtl" }, s, ze, We, it), un = { ...Bt.popup?.root, - ..._ ?? P + ...N ?? P }, Nn = ne({ [`${ve}-lg`]: Ne === "large", [`${ve}-sm`]: Ne === "small", @@ -32339,8 +32339,8 @@ html body { classNames: $, styles: M, ...I - } = e, D = Ya(E), _ = l.useRef(null); - l.useImperativeHandle(t, () => _.current); + } = e, D = Ya(E), N = l.useRef(null); + l.useImperativeHandle(t, () => N.current); const P = { ...I }; @@ -32379,7 +32379,7 @@ html body { action: n, builtinPlacements: LQ, popupPlacement: p, - ref: _, + ref: N, popupAlign: g, getPopupContainer: b, onOpenChange: u, @@ -32973,7 +32973,7 @@ html body { mouseLeaveDelay: M, rootClassName: I, styles: D, - classNames: _, + classNames: N, onOpenChange: P, // Legacy overlayInnerStyle: T, @@ -33024,7 +33024,7 @@ html body { destroyOnHidden: de, mouseEnterDelay: re, mouseLeaveDelay: le - }, be = yt(K), Ee = yt(A), [Se, et] = Jt([G, _], [W, be, D, Ee], { + }, be = yt(K), Ee = yt(A), [Se, et] = Jt([G, N], [W, be, D, Ee], { props: ke }), We = H("tooltip", n), it = H(); let ze = ce; @@ -33321,11 +33321,11 @@ html body { style: M, classNames: I, styles: D, - arrow: _, + arrow: N, trigger: P, mouseEnterDelay: T, mouseLeaveDelay: A - } = qt("popover"), z = m ?? T ?? 0.1, O = p ?? A ?? 0.1, L = R("popover", n), [F, U] = sF(L), H = R(), V = ER(C, _), ee = u || P || "hover", X = { + } = qt("popover"), z = m ?? T ?? 0.1, O = p ?? A ?? 0.1, L = R("popover", n), [F, U] = sF(L), H = R(), V = ER(C, N), ee = u || P || "hover", X = { ...e, placement: c, trigger: ee, @@ -33507,10 +33507,10 @@ html body { onVisibleChange: $, disabled: M, ...I - } = e, [D, _] = se.useState(), P = "visible" in e ? b : D, T = s ? `${r}-${s}` : a, A = se.useRef(null), z = se.useRef(null), O = se.useRef(null); + } = e, [D, N] = se.useState(), P = "visible" in e ? b : D, T = s ? `${r}-${s}` : a, A = se.useRef(null), z = se.useRef(null), O = se.useRef(null); se.useImperativeHandle(t, () => A.current); const L = (k) => { - _(k), $?.(k); + N(k), $?.(k); }; aY({ visible: P, @@ -33523,7 +33523,7 @@ html body { const { onOverlayClick: B } = e; - _(!1), B && B(k); + N(!1), B && B(k); }, U = () => /* @__PURE__ */ se.createElement(iY, { ref: z, overlay: E, @@ -33733,8 +33733,8 @@ html body { elements: E, key2element: R, element2key: $ - } = C, M = R.get(t), I = fY(M, E), D = $.get(I), _ = uY(e, c(D, !0).length === 1, n, b); - if (!_ && b !== Md && b !== zd) + } = C, M = R.get(t), I = fY(M, E), D = $.get(I), N = uY(e, c(D, !0).length === 1, n, b); + if (!N && b !== Md && b !== zd) return; (nz.includes(b) || [Md, zd].includes(b)) && y.preventDefault(); const P = (T) => { @@ -33748,21 +33748,21 @@ html body { }); } }; - if ([Md, zd].includes(b) || _.sibling || !I) { + if ([Md, zd].includes(b) || N.sibling || !I) { let T; !I || e === "inline" ? T = a.current : T = dY(I); let A; const z = PR(T, E); - b === Md ? A = z[0] : b === zd ? A = z[z.length - 1] : A = rz(T, E, I, _.offset), P(A); - } else if (_.inlineTrigger) + b === Md ? A = z[0] : b === zd ? A = z[z.length - 1] : A = rz(T, E, I, N.offset), P(A); + } else if (N.inlineTrigger) f(D); - else if (_.offset > 0) + else if (N.offset > 0) f(D, !0), v(), p.current = Ft(() => { C = Gg(S, r); const T = I.getAttribute("aria-controls"), A = document.getElementById(T), z = rz(A, C.elements); P(z); }, 5); - else if (_.offset < 0) { + else if (N.offset < 0) { const T = c(D, !0), A = T[T.length - 2], z = R.get(A); f(A, !1), P(z); } @@ -33927,7 +33927,7 @@ html body { // Icon itemIcon: D, // Select - selectedKeys: _, + selectedKeys: N, // Active onActive: P } = l.useContext($a), { @@ -33951,7 +33951,7 @@ html body { }, V = u || D, { active: ee, ...X - } = yF(a, L, g, v), K = _.includes(a), G = SF(U.length), W = (re) => { + } = yF(a, L, g, v), K = N.includes(a), G = SF(U.length), W = (re) => { if (L) return; const le = H(re); @@ -34168,7 +34168,7 @@ html body { } : { ...xY, ...b - }, _ = $Y[f], P = $F(f, R, $), T = l.useRef(P); + }, N = $Y[f], P = $F(f, R, $), T = l.useRef(P); f !== "inline" && (T.current = P); const A = { ...T.current, @@ -34188,7 +34188,7 @@ html body { stretch: f === "horizontal" ? "minWidth" : null, getPopupContainer: p, builtinPlacements: D, - popupPlacement: _, + popupPlacement: N, popupVisible: M, popup: r, popupStyle: a, @@ -34291,7 +34291,7 @@ html body { onMouseLeave: M, onTitleClick: I, onTitleMouseEnter: D, - onTitleMouseLeave: _, + onTitleMouseLeave: N, popupRender: P, ...T } = e, A = pF(f), { @@ -34320,7 +34320,7 @@ html body { } = l.useContext(vF), J = Cu(), re = `${z}-submenu`, le = F || p, ie = l.useRef(null), Y = l.useRef(null), q = y ?? ee, te = b ?? X, de = L.includes(f), fe = !U && de, pe = B(V, f), { active: oe, ...ce - } = yF(f, le, D, _), [Z, me] = l.useState(!1), xe = (he) => { + } = yF(f, le, D, N), [Z, me] = l.useState(!1), xe = (he) => { le || me(he); }, Me = (he) => { xe(!0), $?.({ @@ -34624,7 +34624,7 @@ html body { openKeys: I, // Active activeKey: D, - defaultActiveFirst: _, + defaultActiveFirst: N, // Selection selectable: P = !0, multiple: T = !1, @@ -34692,7 +34692,7 @@ html body { l.useEffect(() => { nt(Qe ? Js : fe.slice(he + 1).map((ht) => ht.key)); }, [he, Qe]); - const [He, $e] = Rn(D || _ && fe[0]?.key, D), Re = Qf((ht) => { + const [He, $e] = Rn(D || N && fe[0]?.key, D), Re = Qf((ht) => { $e(ht); }), Fe = Qf(() => { $e(void 0); @@ -35110,7 +35110,7 @@ html body { ...$ } = e, { siderHook: M - } = l.useContext(wF), [I, D] = Rn(c, e.collapsed), [_, P] = l.useState(!1), T = (te, de) => { + } = l.useContext(wF), [I, D] = Rn(c, e.collapsed), [N, P] = l.useState(!1), T = (te, de) => { D(te), S?.(te, de); }, A = { ...e, @@ -35172,7 +35172,7 @@ html body { }, Y = ne(U, `${U}-${u}`, { [`${U}-collapsed`]: !!I, [`${U}-has-trigger`]: m && a !== null && !Q, - [`${U}-below`]: !!_, + [`${U}-below`]: !!N, [`${U}-zero-width`]: Number.parseFloat(W) === 0 }, r, z.root, H, V), q = l.useMemo(() => ({ siderCollapsed: I @@ -35190,7 +35190,7 @@ html body { }, /* @__PURE__ */ l.createElement("div", { className: ne(`${U}-children`, z.body), style: O.body - }, s), m || _ && Q ? le : null)); + }, s), m || N && Q ? le : null)); }); var LY = { icon: { tag: "svg", attrs: { viewBox: "64 64 896 896", focusable: "false" }, children: [{ tag: "path", attrs: { d: "M176 511a56 56 0 10112 0 56 56 0 10-112 0zm280 0a56 56 0 10112 0 56 56 0 10-112 0zm280 0a56 56 0 10112 0 56 56 0 10-112 0z" } }] }, name: "ellipsis", theme: "outlined" }; function aw() { @@ -35263,13 +35263,13 @@ html body { }; let M = a; typeof a > "u" ? M = f ? n : "" : a === !1 && (M = ""); - const I = g === !1 ? void 0 : g, D = I && I.title !== void 0 ? I.title : M, _ = { + const I = g === !1 ? void 0 : g, D = I && I.title !== void 0 ? I.title : M, N = { ...I ?? null, title: D }; - C ? (_.open = I?.open ?? E, _.onOpenChange = (A) => { + C ? (N.open = I?.open ?? E, N.onOpenChange = (A) => { R(A), I?.onOpenChange?.(A); - }) : (_.title = null, _.open = !1); + }) : (N.title = null, N.open = !1); const P = xr(n).length; let T = /* @__PURE__ */ l.createElement(Jm, { ...sn(e, ["title", "icon", "danger"]), @@ -35302,7 +35302,7 @@ html body { return O(U); } : O(I?.classNames); T = /* @__PURE__ */ l.createElement(na, { - ..._, + ...N, placement: A, classNames: L }, T); @@ -35422,7 +35422,7 @@ html body { itemDisabledColor: I, // Danger dangerItemColor: D, - dangerItemHoverColor: _, + dangerItemHoverColor: N, dangerItemSelectedColor: P, dangerItemActiveBg: T, dangerItemSelectedBg: A, @@ -35498,7 +35498,7 @@ html body { color: D, [`&${n}-item:hover`]: { [`&:not(${n}-item-selected):not(${n}-submenu-selected)`]: { - color: _ + color: N } }, [`&${n}-item:active`]: { @@ -36176,7 +36176,7 @@ html body { controlHeightSM: M, fontSizeLG: I, colorTextLightSolid: D, - colorErrorHover: _ + colorErrorHover: N } = e, P = e.activeBarWidth ?? 0, T = e.activeBarBorderWidth ?? p, A = e.itemMarginInline ?? e.marginXXS, z = new En(D).setA(0.65).toRgbString(); return { dropdownWidth: 160, @@ -36258,7 +36258,7 @@ html body { darkItemHoverBg: "transparent", darkGroupTitleColor: z, darkItemHoverColor: D, - darkDangerItemHoverColor: _, + darkDangerItemHoverColor: N, darkDangerItemSelectedColor: D, darkDangerItemActiveBg: n, // internal @@ -36284,7 +36284,7 @@ html body { darkDangerItemSelectedColor: M, darkDangerItemActiveBg: I, popupBg: D, - darkPopupBg: _ + darkPopupBg: N } = a, P = a.calc(u).div(7).mul(5).equal(), T = Kt(a, { menuArrowSize: P, menuHorizontalHeight: a.calc(c).mul(1.15).equal(), @@ -36299,7 +36299,7 @@ html body { itemSelectedColor: v, subMenuItemSelectedColor: v, itemBg: p, - popupBg: _, + popupBg: N, subMenuItemBg: g, itemActiveBg: "transparent", itemSelectedBg: y, @@ -36434,7 +36434,7 @@ html body { menu: I } = l.useContext(Pt), { getPrefixCls: D, - getPopupContainer: _, + getPopupContainer: N, direction: P, className: T, style: A, @@ -36497,7 +36497,7 @@ html body { }, /* @__PURE__ */ l.createElement(Yg.Provider, { value: q }, /* @__PURE__ */ l.createElement($u, { - getPopupContainer: _, + getPopupContainer: N, overflowedIndicator: /* @__PURE__ */ l.createElement(jv, null), overflowedIndicatorPopupClassName: ne(B, `${B}-${u}`, E), classNames: { @@ -36894,7 +36894,7 @@ html body { transitionName: M, classNames: I, styles: D, - destroyPopupOnHide: _, + destroyPopupOnHide: N, destroyOnHidden: P } = e, { getPrefixCls: T, @@ -36996,7 +36996,7 @@ html body { ...G, zIndex: ve }, - autoDestroy: P ?? _ + autoDestroy: P ?? N }, te); return ve && (ke = /* @__PURE__ */ l.createElement(wv.Provider, { value: qe @@ -37381,7 +37381,7 @@ html body { colorTextDisabled: M, colorBgContainerDisabled: I, buttonCheckedBgDisabled: D, - buttonCheckedColorDisabled: _, + buttonCheckedColorDisabled: N, colorPrimary: P, colorPrimaryHover: T, colorPrimaryActive: A, @@ -37574,7 +37574,7 @@ html body { } }, [`&-disabled${r}-button-wrapper-checked`]: { - color: _, + color: N, backgroundColor: D, borderColor: c, boxShadow: "none" @@ -37659,9 +37659,9 @@ html body { classNames: M, styles: I, checked: D, - ..._ + ...N } = e, P = a("radio", b), T = (n?.optionType || r) === "button", A = T ? `${P}-button` : P, z = rr(P), [O, L] = FF(P, z), F = { - ..._ + ...N }, U = l.useContext(kr), H = "checked" in e; let V = D; n && (F.name = n.name, F.onChange = y, V = e.value === n.value, F.disabled = F.disabled ?? n.disabled), (H || n) && (F.checked = V), F.disabled = F.disabled ?? U; @@ -37726,7 +37726,7 @@ html body { block: M = !1, onChange: I, onMouseEnter: D, - onMouseLeave: _, + onMouseLeave: N, onFocus: P, onBlur: T, orientation: A, @@ -37780,7 +37780,7 @@ html body { }), style: b, onMouseEnter: D, - onMouseLeave: _, + onMouseLeave: N, onFocus: P, onBlur: T, id: S, @@ -37836,8 +37836,8 @@ html body { colorBgContainer: M, inputFontSize: I, inputFontSizeLG: D, - inputFontSizeSM: _ - } = e, P = I || n, T = _ || P, A = D || f, z = Math.round((t - P * r) / 2 * 10) / 10 - a, O = Math.round((c - T * r) / 2 * 10) / 10 - a, L = Math.ceil((u - A * m) / 2 * 10) / 10 - a; + inputFontSizeSM: N + } = e, P = I || n, T = N || P, A = D || f, z = Math.round((t - P * r) / 2 * 10) / 10 - a, O = Math.round((c - T * r) / 2 * 10) / 10 - a, L = Math.ceil((u - A * m) / 2 * 10) / 10 - a; return { lineWidthFocus: s === 0 ? 0 : a, paddingBlock: Math.max(z, 0), @@ -39018,7 +39018,7 @@ html body { popupStyle: E, classNames: R, styles: $ - } = e, [M, I] = l.useState(!1), [D, _] = l.useState(null), { + } = e, [M, I] = l.useState(!1), [D, N] = l.useState(null), { icon: P = "More", popupRender: T } = u, A = `${r}-more-popup`, z = `${n}-dropdown`, O = D !== null ? `${A}-${D}` : null, L = s?.dropdownAriaLabel; @@ -39078,7 +39078,7 @@ html body { k = (k + W + B) % B; const re = Q[k]; if (!re.disabled) { - _(re.key); + N(re.key); return; } } @@ -39111,7 +39111,7 @@ html body { const W = document.getElementById(O); W?.scrollIntoView && W.scrollIntoView(!1); }, [O, D]), l.useEffect(() => { - M || _(null); + M || N(null); }, [M]); const X = { marginInlineStart: g @@ -39178,7 +39178,7 @@ html body { styles: M, classNames: I, tabCount: D, - currentPosition: _ + currentPosition: N } = e, P = `${t}-tab`, T = OR(p, f, y, u); function A(U) { u || b(U); @@ -39230,7 +39230,7 @@ html body { overflow: "hidden", opacity: 0 } - }, `Tab ${_} of ${D}`), m && /* @__PURE__ */ l.createElement("span", { + }, `Tab ${N} of ${D}`), m && /* @__PURE__ */ l.createElement("span", { className: `${P}-icon` }, m), c && O), T && /* @__PURE__ */ l.createElement("button", { type: "button", @@ -39303,7 +39303,7 @@ html body { } = e, { prefixCls: $, tabs: M - } = l.useContext(DR), I = l.useRef(null), D = l.useRef(null), _ = l.useRef(null), P = l.useRef(null), T = l.useRef(null), A = l.useRef(null), z = l.useRef(null), O = g === "top" || g === "bottom", [L, F] = Sz(0, (Re, Fe) => { + } = l.useContext(DR), I = l.useRef(null), D = l.useRef(null), N = l.useRef(null), P = l.useRef(null), T = l.useRef(null), A = l.useRef(null), z = l.useRef(null), O = g === "top" || g === "bottom", [L, F] = Sz(0, (Re, Fe) => { O && S && S({ direction: Re > Fe ? "left" : "right" }); @@ -39489,7 +39489,7 @@ html body { ye(); }, [M.map((Re) => Re.key).join("_")]); const Qe = JF(() => { - const Re = Uc(I), Fe = Uc(D), Ne = Uc(_); + const Re = Uc(I), Fe = Uc(D), Ne = Uc(N); ee([Re[0] - Fe[0] - Ne[0], Re[1] - Fe[1] - Ne[1]]); const Ke = Uc(z); W(Ke); @@ -39576,7 +39576,7 @@ html body { popupStyle: R?.popup, tabMoving: !!me })), /* @__PURE__ */ l.createElement(Ez, { - ref: _, + ref: N, position: "right", extra: f, prefixCls: $ @@ -39653,7 +39653,7 @@ html body { removeOnLeave: !!(s ?? M), leavedClassName: `${b}-hidden` }, r.tabPaneMotion), ({ - style: _, + style: N, className: P }, T) => /* @__PURE__ */ l.createElement(DZ, ev({}, I, { prefixCls: b, @@ -39664,7 +39664,7 @@ html body { style: { ...f, ...R, - ..._ + ...N }, className: ne(m, $, P), ref: T @@ -39721,7 +39721,7 @@ html body { onTabClick: M, onTabScroll: I, getPopupContainer: D, - popupClassName: _, + popupClassName: N, indicator: P, classNames: T, styles: A, @@ -39762,7 +39762,7 @@ html body { extra: b, style: y, getPopupContainer: D, - popupClassName: ne(_, T?.popup), + popupClassName: ne(N, T?.popup), indicator: P, styles: A, classNames: T @@ -40796,7 +40796,7 @@ html body { classNames: M, styles: I, destroyInactiveTabPane: D, - destroyOnHidden: _, + destroyOnHidden: N, tabPlacement: P, tabPosition: T, ...A @@ -40885,7 +40885,7 @@ html body { prefixCls: K, animated: ie, indicator: Y, - destroyOnHidden: _ ?? D, + destroyOnHidden: N ?? D, tabPosition: q }); }), tp = eJ; @@ -41291,7 +41291,7 @@ html body { defaultActiveTabKey: M, tabBarExtraContent: I, hoverable: D, - tabProps: _ = {}, + tabProps: N = {}, classNames: P, styles: T, ...A @@ -41318,7 +41318,7 @@ html body { }, title: !1 }, R), q = $ !== void 0, te = { - ..._, + ...N, [q ? "activeKey" : "defaultActiveKey"]: q ? $ : M, tabBarExtraContent: I }; @@ -41404,7 +41404,7 @@ html body { styles: S } = qt("cardMeta"), E = `${g("card", n)}-meta`, R = yt(y), $ = yt(u), [M, I] = Jt([b, f], [S, R, m, $], { props: e - }), D = ne(E, r, v, M.root), _ = { + }), D = ne(E, r, v, M.root), N = { ...I.root }, P = ne(`${E}-avatar`, M.avatar), T = ne(`${E}-title`, M.title), A = ne(`${E}-description`, M.description), z = ne(`${E}-section`, M.section), O = tn(a) ? /* @__PURE__ */ l.createElement("div", { className: P, @@ -41425,7 +41425,7 @@ html body { ref: H, ...p, className: D, - style: _ + style: N }, O, U); }), Ot = fJ; Ot.Grid = nB; @@ -41759,54 +41759,54 @@ html body { selectable: $, treeId: M, ...I - } = e, D = o2(M, t), _ = se.useContext(LR), { + } = e, D = o2(M, t), N = se.useContext(LR), { classNames: P, styles: T - } = _ || {}, A = se.useContext(gJ), z = se.useRef(null), [O, L] = se.useState(!1), F = !!(_.disabled || e.disabled || A.nodeDisabled?.(E)), U = se.useMemo(() => !_.checkable || e.checkable === !1 ? !1 : _.checkable, [_.checkable, e.checkable]), H = (Se) => { - F || _.onNodeSelect(Se, vr(e)); + } = N || {}, A = se.useContext(gJ), z = se.useRef(null), [O, L] = se.useState(!1), F = !!(N.disabled || e.disabled || A.nodeDisabled?.(E)), U = se.useMemo(() => !N.checkable || e.checkable === !1 ? !1 : N.checkable, [N.checkable, e.checkable]), H = (Se) => { + F || N.onNodeSelect(Se, vr(e)); }, V = (Se) => { - F || !U || e.disableCheckbox || _.onNodeCheck(Se, vr(e), !v); - }, ee = se.useMemo(() => typeof $ == "boolean" ? $ : _.selectable, [$, _.selectable]), X = (Se) => { - _.onNodeClick(Se, vr(e)), ee ? H(Se) : V(Se); + F || !U || e.disableCheckbox || N.onNodeCheck(Se, vr(e), !v); + }, ee = se.useMemo(() => typeof $ == "boolean" ? $ : N.selectable, [$, N.selectable]), X = (Se) => { + N.onNodeClick(Se, vr(e)), ee ? H(Se) : V(Se); }, K = (Se) => { - _.onNodeDoubleClick(Se, vr(e)); + N.onNodeDoubleClick(Se, vr(e)); }, G = (Se) => { - _.onNodeMouseEnter(Se, vr(e)); + N.onNodeMouseEnter(Se, vr(e)); }, W = (Se) => { - _.onNodeMouseLeave(Se, vr(e)); + N.onNodeMouseLeave(Se, vr(e)); }, Q = (Se) => { - _.onNodeContextMenu(Se, vr(e)); - }, k = se.useMemo(() => !!(_.draggable && (!_.draggable.nodeDraggable || _.draggable.nodeDraggable(E))), [_.draggable, E]), B = (Se) => { - Se.stopPropagation(), L(!0), _.onNodeDragStart(Se, e); + N.onNodeContextMenu(Se, vr(e)); + }, k = se.useMemo(() => !!(N.draggable && (!N.draggable.nodeDraggable || N.draggable.nodeDraggable(E))), [N.draggable, E]), B = (Se) => { + Se.stopPropagation(), L(!0), N.onNodeDragStart(Se, e); try { Se.dataTransfer.setData("text/plain", ""); } catch { } }, J = (Se) => { - Se.preventDefault(), Se.stopPropagation(), _.onNodeDragEnter(Se, e); + Se.preventDefault(), Se.stopPropagation(), N.onNodeDragEnter(Se, e); }, re = (Se) => { - Se.preventDefault(), Se.stopPropagation(), _.onNodeDragOver(Se, e); + Se.preventDefault(), Se.stopPropagation(), N.onNodeDragOver(Se, e); }, le = (Se) => { - Se.stopPropagation(), _.onNodeDragLeave(Se, e); + Se.stopPropagation(), N.onNodeDragLeave(Se, e); }, ie = (Se) => { - Se.stopPropagation(), L(!1), _.onNodeDragEnd(Se, e); + Se.stopPropagation(), L(!1), N.onNodeDragEnd(Se, e); }, Y = (Se) => { - Se.preventDefault(), Se.stopPropagation(), L(!1), _.onNodeDrop(Se, e); + Se.preventDefault(), Se.stopPropagation(), L(!1), N.onNodeDrop(Se, e); }, q = (Se) => { - b || _.onNodeExpand(Se, vr(e)); + b || N.onNodeExpand(Se, vr(e)); }, te = se.useMemo(() => { const { children: Se - } = to(_.keyEntities, t) || {}; + } = to(N.keyEntities, t) || {}; return !!(Se || []).length; - }, [_.keyEntities, t]), de = se.useMemo(() => aB(u, _.loadData, te, e.loaded), [u, _.loadData, te, e.loaded]); + }, [N.keyEntities, t]), de = se.useMemo(() => aB(u, N.loadData, te, e.loaded), [u, N.loadData, te, e.loaded]); se.useEffect(() => { - b || typeof _.loadData == "function" && p && !de && !e.loaded && _.onNodeLoad(vr(e)); - }, [b, _.loadData, _.onNodeLoad, p, de, e]); - const fe = se.useMemo(() => _.draggable?.icon ? /* @__PURE__ */ se.createElement("span", { - className: `${_.prefixCls}-draggable-icon` - }, _.draggable.icon) : null, [_.draggable]), pe = (Se) => { - const et = e.switcherIcon || _.switcherIcon; + b || typeof N.loadData == "function" && p && !de && !e.loaded && N.onNodeLoad(vr(e)); + }, [b, N.loadData, N.onNodeLoad, p, de, e]); + const fe = se.useMemo(() => N.draggable?.icon ? /* @__PURE__ */ se.createElement("span", { + className: `${N.prefixCls}-draggable-icon` + }, N.draggable.icon) : null, [N.draggable]), pe = (Se) => { + const et = e.switcherIcon || N.switcherIcon; return typeof et == "function" ? et({ ...e, isLeaf: Se @@ -41815,14 +41815,14 @@ html body { if (de) { const et = pe(!0); return et !== !1 ? /* @__PURE__ */ se.createElement("span", { - className: ne(`${_.prefixCls}-switcher`, `${_.prefixCls}-switcher-noop`, P?.itemSwitcher), + className: ne(`${N.prefixCls}-switcher`, `${N.prefixCls}-switcher-noop`, P?.itemSwitcher), style: T?.itemSwitcher }, et) : null; } const Se = pe(!1); return Se !== !1 ? /* @__PURE__ */ se.createElement("span", { onClick: q, - className: ne(`${_.prefixCls}-switcher`, `${_.prefixCls}-switcher_${p ? Tz : Nz}`, P?.itemSwitcher), + className: ne(`${N.prefixCls}-switcher`, `${N.prefixCls}-switcher_${p ? Tz : Nz}`, P?.itemSwitcher), style: T?.itemSwitcher }, Se) : null; }, ce = se.useMemo(() => { @@ -41830,10 +41830,10 @@ html body { return null; const Se = typeof U != "boolean" ? U : null; return /* @__PURE__ */ se.createElement("span", { - className: ne(`${_.prefixCls}-checkbox`, { - [`${_.prefixCls}-checkbox-checked`]: v, - [`${_.prefixCls}-checkbox-indeterminate`]: !v && y, - [`${_.prefixCls}-checkbox-disabled`]: F || e.disableCheckbox + className: ne(`${N.prefixCls}-checkbox`, { + [`${N.prefixCls}-checkbox-checked`]: v, + [`${N.prefixCls}-checkbox-indeterminate`]: !v && y, + [`${N.prefixCls}-checkbox-disabled`]: F || e.disableCheckbox }), onClick: V, role: "checkbox", @@ -41842,37 +41842,37 @@ html body { "aria-labelledby": D }, Se); }, [U, v, y, F, e.disableCheckbox, D]), Z = se.useMemo(() => de ? null : p ? Tz : Nz, [de, p]), me = se.useMemo(() => /* @__PURE__ */ se.createElement("span", { - className: ne(P?.itemIcon, `${_.prefixCls}-iconEle`, `${_.prefixCls}-icon__${Z || "docu"}`, { - [`${_.prefixCls}-icon_loading`]: b + className: ne(P?.itemIcon, `${N.prefixCls}-iconEle`, `${N.prefixCls}-icon__${Z || "docu"}`, { + [`${N.prefixCls}-icon_loading`]: b }), style: T?.itemIcon - }), [_.prefixCls, Z, b]), xe = se.useMemo(() => { - const Se = !!_.draggable; - return !e.disabled && Se && _.dragOverNodeKey === t ? _.dropIndicatorRender({ - dropPosition: _.dropPosition, - dropLevelOffset: _.dropLevelOffset, - indent: _.indent, - prefixCls: _.prefixCls, - direction: _.direction + }), [N.prefixCls, Z, b]), xe = se.useMemo(() => { + const Se = !!N.draggable; + return !e.disabled && Se && N.dragOverNodeKey === t ? N.dropIndicatorRender({ + dropPosition: N.dropPosition, + dropLevelOffset: N.dropLevelOffset, + indent: N.indent, + prefixCls: N.prefixCls, + direction: N.direction }) : null; - }, [_.dropPosition, _.dropLevelOffset, _.indent, _.prefixCls, _.direction, _.draggable, _.dragOverNodeKey, _.dropIndicatorRender]), Me = se.useMemo(() => { + }, [N.dropPosition, N.dropLevelOffset, N.indent, N.prefixCls, N.direction, N.draggable, N.dragOverNodeKey, N.dropIndicatorRender]), Me = se.useMemo(() => { const { title: Se = CJ - } = e, et = `${_.prefixCls}-node-content-wrapper`; + } = e, et = `${N.prefixCls}-node-content-wrapper`; let We; - if (_.showIcon) { - const ze = e.icon || _.icon; + if (N.showIcon) { + const ze = e.icon || N.icon; We = ze ? /* @__PURE__ */ se.createElement("span", { - className: ne(P?.itemIcon, `${_.prefixCls}-iconEle`, `${_.prefixCls}-icon__customize`), + className: ne(P?.itemIcon, `${N.prefixCls}-iconEle`, `${N.prefixCls}-icon__customize`), style: T?.itemIcon }, typeof ze == "function" ? ze(e) : ze) : me; - } else _.loadData && b && (We = me); + } else N.loadData && b && (We = me); let it; - return typeof Se == "function" ? it = Se(E) : _.titleRender ? it = _.titleRender(E) : it = Se, /* @__PURE__ */ se.createElement("span", { + return typeof Se == "function" ? it = Se(E) : N.titleRender ? it = N.titleRender(E) : it = Se, /* @__PURE__ */ se.createElement("span", { ref: z, title: typeof Se == "string" ? Se : "", className: ne(et, `${et}-${Z || "normal"}`, { - [`${_.prefixCls}-node-selected`]: !F && (g || O) + [`${N.prefixCls}-node-selected`]: !F && (g || O) }), onMouseEnter: G, onMouseLeave: W, @@ -41880,15 +41880,15 @@ html body { onClick: X, onDoubleClick: K }, We, /* @__PURE__ */ se.createElement("span", { - className: ne(`${_.prefixCls}-title`, P?.itemTitle), + className: ne(`${N.prefixCls}-title`, P?.itemTitle), style: T?.itemTitle }, it), xe); - }, [_.prefixCls, _.showIcon, e, _.icon, me, _.titleRender, E, Z, G, W, Q, X, K]), ve = ur(I, { + }, [N.prefixCls, N.showIcon, e, N.icon, me, N.titleRender, E, Z, G, W, Q, X, K]), ve = ur(I, { aria: !0, data: !0 }), { level: qe - } = to(_.keyEntities, t) || {}, ke = m[m.length - 1], be = !F && k, Ee = _.draggingNodeKey === t; + } = to(N.keyEntities, t) || {}, ke = m[m.length - 1], be = !F && k, Ee = N.draggingNodeKey === t; return /* @__PURE__ */ se.createElement("div", dw({ ref: S, role: "treeitem", @@ -41897,24 +41897,24 @@ html body { "aria-selected": ee && !F ? g : void 0, "aria-checked": U && !F ? y ? "mixed" : v : void 0, "aria-disabled": F, - className: ne(n, `${_.prefixCls}-treenode`, P?.item, { - [`${_.prefixCls}-treenode-disabled`]: F, - [`${_.prefixCls}-treenode-switcher-${p ? "open" : "close"}`]: !u, - [`${_.prefixCls}-treenode-checkbox-checked`]: v, - [`${_.prefixCls}-treenode-checkbox-indeterminate`]: y, - [`${_.prefixCls}-treenode-selected`]: g, - [`${_.prefixCls}-treenode-loading`]: b, - [`${_.prefixCls}-treenode-active`]: C, - [`${_.prefixCls}-treenode-leaf-last`]: ke, - [`${_.prefixCls}-treenode-draggable`]: k, + className: ne(n, `${N.prefixCls}-treenode`, P?.item, { + [`${N.prefixCls}-treenode-disabled`]: F, + [`${N.prefixCls}-treenode-switcher-${p ? "open" : "close"}`]: !u, + [`${N.prefixCls}-treenode-checkbox-checked`]: v, + [`${N.prefixCls}-treenode-checkbox-indeterminate`]: y, + [`${N.prefixCls}-treenode-selected`]: g, + [`${N.prefixCls}-treenode-loading`]: b, + [`${N.prefixCls}-treenode-active`]: C, + [`${N.prefixCls}-treenode-leaf-last`]: ke, + [`${N.prefixCls}-treenode-draggable`]: k, dragging: Ee, - "drop-target": _.dropTargetKey === t, - "drop-container": _.dropContainerKey === t, + "drop-target": N.dropTargetKey === t, + "drop-container": N.dropContainerKey === t, "drag-over": !F && a, "drag-over-gap-top": !F && s, "drag-over-gap-bottom": !F && c, - "filter-node": _.filterTreeNode?.(vr(e)), - [`${_.prefixCls}-treenode-leaf`]: de + "filter-node": N.filterTreeNode?.(vr(e)), + [`${N.prefixCls}-treenode-leaf`]: de }), style: { ...r, @@ -41929,7 +41929,7 @@ html body { onDragEnd: k ? ie : void 0, onMouseMove: R }, ve), /* @__PURE__ */ se.createElement(yJ, { - prefixCls: _.prefixCls, + prefixCls: N.prefixCls, level: qe, isStart: f, isEnd: m @@ -41997,7 +41997,7 @@ html body { ref: D, className: ne(`${b}-treenode-motion`, M), style: I - }, s.map((_) => { + }, s.map((N) => { const { data: { ...P @@ -42006,13 +42006,13 @@ html body { key: A, isStart: z, isEnd: O - } = _; + } = N; delete P.children; const L = sm(A, p); return /* @__PURE__ */ l.createElement(Nm, lm({}, P, L, { title: T, active: m, - data: _.data, + data: N.data, key: A, isStart: z, isEnd: O @@ -42119,7 +42119,7 @@ html body { virtual: M, scrollWidth: I, focusable: D, - activeItem: _, + activeItem: N, tabIndex: P, onKeyDown: T, onFocus: A, @@ -42201,7 +42201,7 @@ html body { ref: ee, role: "tree", tabIndex: D !== !1 && !y ? P : void 0, - "aria-activedescendant": _ ? o2(V, _.key) : void 0, + "aria-activedescendant": N ? o2(V, N.key) : void 0, onKeyDown: T, onFocus: A, onBlur: z, @@ -42224,7 +42224,7 @@ html body { const ve = sm(Me, de); return /* @__PURE__ */ l.createElement(wJ, tv({}, oe, ve, { title: ce, - active: !!_ && Z === _.key, + active: !!N && Z === N.key, pos: pe, data: fe.data, isStart: me, @@ -42295,30 +42295,30 @@ html body { if (!C.includes(R)) for (let A = 0; A < S && TJ(E); A += 1) E = E.parent, D += 1; - const _ = t.data, P = E.node; + const N = t.data, P = E.node; let T = !0; return NJ(E) && E.level === 0 && g < v + y / 2 && s({ - dragNode: _, + dragNode: N, dropNode: P, dropPosition: -1 }) && E.key === n.eventKey ? I = -1 : ($.children || []).length && C.includes(M) ? s({ - dragNode: _, + dragNode: N, dropNode: P, dropPosition: 0 }) ? I = 0 : T = !1 : D === 0 ? S > -1.5 ? s({ - dragNode: _, + dragNode: N, dropNode: P, dropPosition: 1 }) ? I = 1 : T = !1 : s({ - dragNode: _, + dragNode: N, dropNode: P, dropPosition: 0 }) ? I = 0 : s({ - dragNode: _, + dragNode: N, dropNode: P, dropPosition: 1 }) ? I = 1 : T = !1 : s({ - dragNode: _, + dragNode: N, dropNode: P, dropPosition: 1 }) ? I = 1 : T = !1, { @@ -43323,7 +43323,7 @@ html body { icon: M, switcherIcon: I, draggable: D, - checkable: _, + checkable: N, checkStrictly: P, disabled: T, motion: A, @@ -43358,7 +43358,7 @@ html body { switcherIcon: I, draggable: B, draggingNodeKey: r, - checkable: _, + checkable: N, checkStrictly: P, disabled: T, keyEntities: n, @@ -43403,7 +43403,7 @@ html body { data: t, disabled: T, selectable: R, - checkable: !!_, + checkable: !!N, motion: A, dragging: r !== null, height: L, @@ -43682,7 +43682,7 @@ html body { getPrefixCls: M, direction: I, className: D, - style: _, + style: N, classNames: P, styles: T } = qt("checkbox"), A = l.useContext(uB), { @@ -43712,7 +43712,7 @@ html body { indeterminate: a, disabled: L, checked: H - }, J = yt(_), re = yt(g), [le, ie] = Jt([P, v], [T, J, y, re], { + }, J = yt(N), re = yt(g), [le, ie] = Jt([P, v], [T, J, y, re], { props: B }), Y = ne(`${K}-wrapper`, { [`${K}-rtl`]: I === "rtl", @@ -43775,8 +43775,8 @@ html body { const G = $.findIndex((Q) => Q.value === X), W = $.findIndex((Q) => Q.value === K); return G - W; })); - }, _ = v("checkbox", s), P = `${_}-group`, T = rr(_), [A, z] = cB(_, T), O = sn(g, ["value", "disabled"]), L = Array.isArray($) && $.length > 0 ? $.map((H) => /* @__PURE__ */ l.createElement(dB, { - prefixCls: _, + }, N = v("checkbox", s), P = `${N}-group`, T = rr(N), [A, z] = cB(N, T), O = sn(g, ["value", "disabled"]), L = Array.isArray($) && $.length > 0 ? $.map((H) => /* @__PURE__ */ l.createElement(dB, { + prefixCls: N, key: H.value.toString(), disabled: "disabled" in H ? H.disabled : g.disabled, value: H.value, @@ -43834,19 +43834,19 @@ html body { style: S, ...C } = e, E = n("col", c), R = n(), [$, M] = qG(E), [I] = Ln(R, "col"), D = {}; - let _ = {}; + let N = {}; tF.forEach((A) => { let z = {}; const O = e[A]; - lr(O) ? z.span = O : It(O) && (z = O || {}), delete C[A], _ = { - ..._, + lr(O) ? z.span = O : It(O) && (z = O || {}), delete C[A], N = { + ...N, [`${E}-${A}-${z.span}`]: Hn(z.span), [`${E}-${A}-order-${z.order}`]: z.order || z.order === 0, [`${E}-${A}-offset-${z.offset}`]: z.offset || z.offset === 0, [`${E}-${A}-push-${z.push}`]: z.push || z.push === 0, [`${E}-${A}-pull-${z.pull}`]: z.pull || z.pull === 0, [`${E}-rtl`]: r === "rtl" - }, (z.flex || z.flex === 0) && (_[`${E}-${A}-flex`] = !0, D[I(`${A}-flex`)] = Oz(z.flex)); + }, (z.flex || z.flex === 0) && (N[`${E}-${A}-flex`] = !0, D[I(`${A}-flex`)] = Oz(z.flex)); }); const P = ne(E, { [`${E}-${u}`]: u !== void 0, @@ -43854,7 +43854,7 @@ html body { [`${E}-offset-${m}`]: m, [`${E}-push-${p}`]: p, [`${E}-pull-${g}`]: g - }, v, _, $, M), T = {}; + }, v, N, $, M), T = {}; if (a?.[0]) { const A = lr(a[0]) ? `${a[0] / 2}px` : `calc(${a[0]} / 2)`; T.paddingInline = A; @@ -43930,12 +43930,12 @@ html body { const T = lr($[0]) ? `${$[0] / -2}px` : `calc(${$[0]} / -2)`; I.marginInline = T; } - const [D, _] = $; - I.rowGap = _; + const [D, N] = $; + I.rowGap = N; const P = l.useMemo(() => ({ - gutter: [D, _], + gutter: [D, N], wrap: m - }), [D, _, m]); + }), [D, N, m]); return /* @__PURE__ */ l.createElement(fB.Provider, { value: P }, /* @__PURE__ */ l.createElement("div", { @@ -44405,7 +44405,7 @@ html body { prefix: M, suffix: I, stringMode: D, - parser: _, + parser: N, formatter: P, precision: T, decimalSeparator: A, @@ -44442,11 +44442,11 @@ html body { return T >= 0 ? T : Math.max(sl(Be), sl(p)); }, [T, p]), pe = l.useCallback((Be) => { const at = String(Be); - if (_) - return _(at); + if (N) + return N(at); let Te = at; return A && (Te = Te.replace(A, ".")), Te.replace(/[^\w.-]+/g, ""); - }, [_, A]), oe = l.useRef(""), ce = l.useCallback((Be, at) => { + }, [N, A]), oe = l.useRef(""), ce = l.useCallback((Be, at) => { if (P) return P(Be, { userTyping: at, @@ -44486,7 +44486,7 @@ html body { } O?.(Be), it(() => { let at = Be; - _ || (at = Be.replace(/。/g, ".")), at !== Be && ze(at); + N || (at = Be.replace(/。/g, ".")), at !== Be && ze(at); }); }, Oe = () => { re.current = !0; @@ -44798,7 +44798,7 @@ html body { handleActiveBg: M, inputAffixPadding: I, borderRadiusSM: D, - controlWidth: _, + controlWidth: N, handleBorderColor: P, filledHandleBg: T, lineHeightLG: A, @@ -44815,7 +44815,7 @@ html body { [L("input-padding-block")]: ue(R), [L("input-padding-inline")]: ue(E), display: "inline-flex", - width: _, + width: N, margin: 0, paddingBlock: 0, borderRadius: a, @@ -45106,7 +45106,7 @@ html body { ...I } = e, { direction: D, - className: _, + className: N, style: P, styles: T, classNames: A @@ -45133,7 +45133,7 @@ html body { ref: n, mode: M, disabled: O, - className: ne(C, r, ie.root, _, U, cu(c, y, X), { + className: ne(C, r, ie.root, N, U, cu(c, y, X), { [`${c}-${Q}`]: k, [`${c}-lg`]: W === "large", [`${c}-sm`]: W === "small", @@ -45266,9 +45266,9 @@ html body { classNames: M, dataAttrs: I, styles: D, - components: _, + components: N, onClear: P - } = e, T = r ?? n, A = _?.affixWrapper || "span", z = _?.groupWrapper || "span", O = _?.wrapper || "span", L = _?.groupAddon || "span", F = l.useRef(null), U = (X) => { + } = e, T = r ?? n, A = N?.affixWrapper || "span", z = N?.groupWrapper || "span", O = N?.wrapper || "span", L = N?.groupAddon || "span", F = l.useRef(null), U = (X) => { F.current?.contains(X.target) && b?.(); }, H = cee(e); let V = /* @__PURE__ */ l.cloneElement(T, { @@ -45436,7 +45436,7 @@ html body { styles: M, onCompositionStart: I, onCompositionEnd: D, - ..._ + ...N } = e, [P, T] = l.useState(!1), A = l.useRef(!1), z = l.useRef(!1), O = l.useRef(null), L = l.useRef(null), F = (te) => { O.current && TE(O.current, te); }, { @@ -45538,7 +45538,7 @@ html body { ...M?.count } }, X), b) : null; - return /* @__PURE__ */ se.createElement(yB, rv({}, _, { + return /* @__PURE__ */ se.createElement(yB, rv({}, N, { prefixCls: m, className: ne(v, ie), handleReset: le, @@ -45629,7 +45629,7 @@ html body { l.useImperativeHandle(t, () => ({ textArea: E.current })); - const [R, $] = l.useMemo(() => s && typeof s == "object" ? [s.minRows, s.maxRows] : [], [s]), M = !!s, [I, D] = l.useState(Ix), [_, P] = l.useState(), T = () => { + const [R, $] = l.useMemo(() => s && typeof s == "object" ? [s.minRows, s.maxRows] : [], [s]), M = !!s, [I, D] = l.useState(Ix), [N, P] = l.useState(), T = () => { D(Nx); }; Vt(() => { @@ -45652,7 +45652,7 @@ html body { l.useEffect(() => z, []); const F = { ...f, - ...M ? _ : null + ...M ? N : null }; return (I === Nx || I === _x) && (F.overflowY = "hidden", F.overflowX = "hidden"), /* @__PURE__ */ l.createElement(Co, { onResize: O, @@ -45702,7 +45702,7 @@ html body { onClear: M, onPressEnter: I, readOnly: D, - autoSize: _, + autoSize: N, onKeyDown: P, ...T }, A) => { @@ -45759,7 +45759,7 @@ html body { }, Q))); const pe = (ce) => { $?.(ce), ee()?.style.height && U(!0); - }, oe = !_ && !g && !s; + }, oe = !N && !g && !s; return /* @__PURE__ */ se.createElement(yB, { ref: H, value: K, @@ -45793,7 +45793,7 @@ html body { readOnly: D, onClear: M }, /* @__PURE__ */ se.createElement(hee, Sw({}, T, { - autoSize: _, + autoSize: N, maxLength: c, onKeyDown: Y, onChange: ie, @@ -45850,7 +45850,7 @@ html body { } = e, { getPrefixCls: I, direction: D, - allowClear: _, + allowClear: N, autoComplete: P, className: T, style: A, @@ -45881,7 +45881,7 @@ html body { de(), E?.(Me); }, ce = (ie || m) && /* @__PURE__ */ se.createElement(se.Fragment, null, m, ie && Y), Z = kk({ allowClear: p, - contextAllowClear: _, + contextAllowClear: N, componentName: "Input" }), [me, xe] = Xa("input", $, r); return /* @__PURE__ */ se.createElement(uee, { @@ -46019,13 +46019,13 @@ html body { if (f) { let D; v === "label" && (D = M), v === "content" && (D = I); - const _ = D ? { + const N = D ? { ...s, ...D } : s; return /* @__PURE__ */ se.createElement(S, { colSpan: r, - style: _, + style: N, className: ne(a, { [`${t}-item-${v}`]: v === "label" || v === "content", [R.label]: R.label && v === "label", @@ -46075,10 +46075,10 @@ html body { styles: M, classNames: I }, D) => { - const _ = $ ?? D; + const N = $ ?? D; if (typeof a == "string") return /* @__PURE__ */ l.createElement(Mx, { - key: `${s}-${_}`, + key: `${s}-${N}`, className: b, style: S, classNames: I, @@ -46120,7 +46120,7 @@ html body { } }; return [/* @__PURE__ */ l.createElement(Mx, { - key: `label-${_}`, + key: `label-${N}`, className: b, style: S, classNames: I, @@ -46133,7 +46133,7 @@ html body { label: g, type: "label" }), /* @__PURE__ */ l.createElement(Mx, { - key: `content-${_}`, + key: `content-${N}`, className: b, style: S, classNames: I, @@ -46403,7 +46403,7 @@ html body { getPrefixCls: M, direction: I, className: D, - style: _, + style: N, classNames: P, styles: T } = qt("descriptions"), A = M("descriptions", n), z = Zm(), O = l.useMemo(() => lr(s) ? s : V$(z, s) ?? V$(z, bee) ?? 3, [z, s]), L = See(z, E, m), F = dr(y), U = Cee(O, L), [H, V] = Tee(A), ee = { @@ -46411,7 +46411,7 @@ html body { column: O, items: L, size: F - }, X = yt(_), K = yt(v), [G, W] = Jt([P, R], [T, X, C, K], { + }, X = yt(N), K = yt(v), [G, W] = Jt([P, R], [T, X, C, K], { props: ee }), Q = l.useMemo(() => ({ labelStyle: b, @@ -46588,7 +46588,7 @@ html body { vertical: M, wrap: I = !1, classNames: D, - styles: _, + styles: N, ...P } = e, [T, A] = Array.isArray(m) ? m : [m, m], z = Uz(A), O = Uz(T), L = qz(A), F = qz(T), U = xr(y, { keepEmpty: !0 @@ -46597,7 +46597,7 @@ html body { size: m, orientation: H, align: ee - }, k = yt(c), B = yt($), [J, re] = Jt([u, D], [f, k, _, B], { + }, k = yt(c), B = yt($), [J, re] = Jt([u, D], [f, k, N, B], { props: Q }), le = ne(K, s, G, `${K}-${H}`, { [`${K}-rtl`]: r === "rtl", @@ -46661,7 +46661,7 @@ html body { placement: M, getPopupContainer: I, href: D, - icon: _ = /* @__PURE__ */ l.createElement(jv, null), + icon: N = /* @__PURE__ */ l.createElement(jv, null), title: P, buttonsRender: T = (Y) => Y, mouseEnterDelay: A, @@ -46709,7 +46709,7 @@ html body { }, g), re = /* @__PURE__ */ l.createElement(Ve, { type: s, danger: c, - icon: _ + icon: N }), [le, ie] = T([J, re]); return /* @__PURE__ */ l.createElement(lt.Compact, { className: B, @@ -47268,9 +47268,9 @@ html body { key: D }) => { I[D] = (I[D] || 0) + 1; - }), R.map((D, _) => ({ + }), R.map((D, N) => ({ ...D, - key: I[D.key] > 1 ? `${D.key}-fallback-${_}` : D.key + key: I[D.key] > 1 ? `${D.key}-fallback-${N}` : D.key })); }, [R]), M = {}; return s && (M.id = `${s}_help`), /* @__PURE__ */ l.createElement(oo, { @@ -47281,14 +47281,14 @@ html body { }, (I) => { const { className: D, - style: _ + style: N } = I; return /* @__PURE__ */ l.createElement("div", { ...M, className: ne(p, D, f?.help, y, g, a, v), style: { ...m?.help, - ..._ + ...N } }, /* @__PURE__ */ l.createElement(qE, { keys: $, @@ -47338,7 +47338,7 @@ html body { form: M, colon: I, labelAlign: D, - labelWrap: _, + labelWrap: N, labelCol: P, wrapperCol: T, layout: A = "horizontal", @@ -47353,7 +47353,7 @@ html body { styles: X, tooltip: K, ...G - } = e, W = dr(R), Q = l.useContext(mk), k = l.useMemo(() => O !== void 0 ? O : s !== void 0 ? s : !0, [O, s]), B = I ?? c, J = D ?? y, re = _ ?? b, le = { + } = e, W = dr(R), Q = l.useContext(mk), k = l.useMemo(() => O !== void 0 ? O : s !== void 0 ? s : !0, [O, s]), B = I ?? c, J = D ?? y, re = N ?? b, le = { ...v, ...K }, ie = r("form", S), Y = rr(ie), [q, te] = UR(ie, Y), de = { @@ -47534,7 +47534,7 @@ html body { ...ee } = C; return ee; - }, [C]), D = l.useRef(null), [_, P] = l.useState(0); + }, [C]), D = l.useRef(null), [N, P] = l.useState(0); Vt(() => { m && D.current ? P(D.current.clientHeight) : P(0); }, [m]); @@ -47566,7 +47566,7 @@ html body { }, m) : null, F = z || L ? /* @__PURE__ */ l.createElement("div", { className: `${S}-additional`, style: v ? { - minHeight: v + _ + minHeight: v + N } : {} }, z, L) : null, U = f && f.mark === "pro_table_render" && f.render ? f.render(e, { input: T, @@ -47623,8 +47623,8 @@ html body { [`${M}-wrap`]: !!y }); let D = t; - const _ = s === !0 || b !== !1 && s !== !1; - _ && !m && typeof t == "string" && t.trim() && (D = t.replace(/[:|:]\s*$/, "")); + const N = s === !0 || b !== !1 && s !== !1; + N && !m && typeof t == "string" && t.trim() && (D = t.replace(/[:|:]\s*$/, "")); const T = Dee(f, E); if (T) { const U = /* @__PURE__ */ l.createElement(na, { @@ -47649,7 +47649,7 @@ html body { const F = ne(S?.label, { [`${e}-item-required`]: c, [`${e}-item-required-mark-${L}`]: L, - [`${e}-item-no-colon`]: !_ + [`${e}-item-no-colon`]: !N }); return /* @__PURE__ */ l.createElement(jR, { ...R, @@ -47736,7 +47736,7 @@ html body { } = e, M = `${t}-item`, { requiredMark: I, layout: D - } = l.useContext(Ga), _ = E || D, P = _ === "vertical", T = l.useRef(null), A = ov(c), z = ov(u), O = Hn(s), L = !!(O || A.length || z.length), F = !!T.current && km(T.current), [U, H] = l.useState(null); + } = l.useContext(Ga), N = E || D, P = N === "vertical", T = l.useRef(null), A = ov(c), z = ov(u), O = Hn(s), L = !!(O || A.length || z.length), F = !!T.current && km(T.current), [U, H] = l.useState(null); Vt(() => { if (L && T.current) { const G = getComputedStyle(T.current); @@ -47758,7 +47758,7 @@ html body { [`${M}-is-validating`]: X === "validating", [`${M}-hidden`]: g, // Layout - [`${M}-${_}`]: _ + [`${M}-${N}`]: N }); return /* @__PURE__ */ l.createElement("div", { className: K, @@ -47877,7 +47877,7 @@ html body { name: R } = l.useContext(Ga), $ = Gee(f), M = Ct($), I = l.useContext(pj), { validateTrigger: D - } = l.useContext($l), _ = Hn(y) ? y : D, P = Hn(t), T = E("form", s), A = rr(T), [z, O] = UR(T, A); + } = l.useContext($l), N = Hn(y) ? y : D, P = Hn(t), T = E("form", s), A = rr(T), [z, O] = UR(T, A); h2(); const L = l.useContext(wm), F = l.useRef(null), [U, H] = Xee({}), [V, ee] = $E(() => Kz()), X = (J) => { const re = L?.getKey(J.name); @@ -47939,7 +47939,7 @@ html body { ...e, messageVariables: B, trigger: v, - validateTrigger: _, + validateTrigger: N, onMetaChange: X }, (J, re, le) => { const ie = am(t).length && re ? re.name : [], Y = zF(ie, R), q = m !== void 0 ? m : u?.some((fe) => { @@ -47966,7 +47966,7 @@ html body { const ce = []; (S || G.length > 0) && ce.push(`${Y}_help`), e.extra && ce.push(`${Y}_extra`), fe["aria-describedby"] = ce.join(" "); } - G.length > 0 && (fe["aria-invalid"] = "true"), q && (fe["aria-required"] = "true"), Ri($) && (fe.ref = Q(ie, $)), new Set([].concat(Nt(am(v)), Nt(am(_)))).forEach((ce) => { + G.length > 0 && (fe["aria-invalid"] = "true"), q && (fe["aria-required"] = "true"), Ri($) && (fe.ref = Q(ie, $)), new Set([].concat(Nt(am(v)), Nt(am(N)))).forEach((ce) => { fe[ce] = (...Z) => { te[ce]?.(...Z), $.props[ce]?.(...Z); }; @@ -48194,7 +48194,7 @@ html body { classNames: M, styles: I, className: D, - style: _, + style: N, ...P } = e, { classNames: T, @@ -48207,7 +48207,7 @@ html body { ...e, length: r, variant: H - }, ee = yt(L), X = yt(_), [K, G] = Jt([T, M], [A, ee, I, X], { + }, ee = yt(L), X = yt(N), [K, G] = Jt([T, M], [A, ee, I, X], { props: V }), W = ur(P, { aria: !0, @@ -48361,7 +48361,7 @@ html body { classNames: R, styles: $, iconRender: M - } = qt("inputPassword"), [I] = Xa("inputPassword", y, e.bordered, "input"), [D] = wo("global"), _ = l.useContext(kr), P = n ?? _, T = { + } = qt("inputPassword"), [I] = Xa("inputPassword", y, e.bordered, "input"), [D] = wo("global"), N = l.useContext(kr), P = n ?? N, T = { ...e, disabled: P, variant: I @@ -48488,7 +48488,7 @@ html body { ...I } = e, { direction: D, - getPrefixCls: _, + getPrefixCls: N, className: P, style: T, classNames: A, @@ -48504,7 +48504,7 @@ html body { button: { _default: "root" } - }), k = l.useRef(!1), B = _("input-search", n), J = _("input", r), [re, le] = Nte(B), { + }), k = l.useRef(!1), B = N("input-search", n), J = N("input", r), [re, le] = Nte(B), { compactSize: ie } = Ts(B, D), Y = dr((Se) => s ?? ie ?? Se), q = l.useRef(null), te = (Se) => { Se?.target && Se.type === "click" && v && v(Se.target.value, Se, { @@ -48719,14 +48719,14 @@ html body { allowClear: M, autoComplete: I, className: D, - style: _, + style: N, classNames: P, styles: T } = qt("textArea"), A = l.useContext(kr), z = s ?? A, { status: O, hasFeedback: L, feedbackIcon: F - } = l.useContext(jr), U = Ym(O, c), H = yt(_), V = yt(g), [ee, X] = Jt([P, f], [T, H, v, V], { + } = l.useContext(jr), U = Ym(O, c), H = yt(N), V = yt(g), [ee, X] = Jt([P, f], [T, H, v, V], { props: e }), K = l.useRef(null); l.useImperativeHandle(t, () => ({ @@ -48857,11 +48857,11 @@ html body { [`${E}-rtl`]: n === "rtl" }, S, c, u, $, M), D = l.useMemo(() => ({ siderHook: { - addSider: (_) => { - a((P) => [].concat(Nt(P), [_])); + addSider: (N) => { + a((P) => [].concat(Nt(P), [N])); }, - removeSider: (_) => { - a((P) => P.filter((T) => T !== _)); + removeSider: (N) => { + a((P) => P.filter((T) => T !== N)); } } }), []); @@ -48966,7 +48966,7 @@ html body { }), M = `${u}-options`; if (!p && !c) return null; - let I = null, D = null, _ = null; + let I = null, D = null, N = null; return p && g && (I = g({ disabled: f, size: a, @@ -48979,7 +48979,7 @@ html body { label: S(P), value: P })) - })), c && (s && (_ = typeof s == "boolean" ? /* @__PURE__ */ se.createElement("button", { + })), c && (s && (N = typeof s == "boolean" ? /* @__PURE__ */ se.createElement("button", { type: "button", onClick: R, onKeyUp: R, @@ -48998,7 +48998,7 @@ html body { onKeyUp: R, onBlur: E, "aria-label": n.page - }), n.page, _)), /* @__PURE__ */ se.createElement("li", { + }), n.page, N)), /* @__PURE__ */ se.createElement("li", { className: M }, I, D); }, Ad = (e) => { @@ -49079,7 +49079,7 @@ html body { style: M, totalBoundaryShowSizeChanger: I = 50, disabled: D, - simple: _, + simple: N, showTotal: P, showSizeChanger: T = f > I, sizeChangerRender: A, @@ -49207,9 +49207,9 @@ html body { page: -1, className: a?.item, style: s?.item - }, ye = W - 1 > 0 ? W - 1 : 0, Qe = W + 1 < Ue ? W + 1 : Ue, Je = S && S.goButton, ot = typeof _ == "object" ? _.readOnly : !_; + }, ye = W - 1 > 0 ? W - 1 : 0, Qe = W + 1 < Ue ? W + 1 : Ue, Je = S && S.goButton, ot = typeof N == "object" ? N.readOnly : !N; let nt = Je, St = null; - _ && (Je && (typeof Je == "boolean" ? nt = /* @__PURE__ */ se.createElement("button", { + N && (Je && (typeof Je == "boolean" ? nt = /* @__PURE__ */ se.createElement("button", { type: "button", onClick: We, onKeyUp: We @@ -49314,7 +49314,7 @@ html body { let Be = et(Qe); if (Be) { let Te, He; - _ ? (Te = !ce, He = oe ? 0 : null) : (Te = !ce || !Ue, He = Te ? null : 0), Be = /* @__PURE__ */ se.createElement("li", { + N ? (Te = !ce, He = oe ? 0 : null) : (Te = !ce || !Ue, He = Te ? null : 0), Be = /* @__PURE__ */ se.createElement("li", { title: E ? $.next_page : null, onClick: me, tabIndex: He, @@ -49330,14 +49330,14 @@ html body { [`${t}-start`]: y === "start", [`${t}-center`]: y === "center", [`${t}-end`]: y === "end", - [`${t}-simple`]: _, + [`${t}-simple`]: N, [`${t}-disabled`]: D }); return /* @__PURE__ */ se.createElement("ul", fs({ className: at, style: M, ref: V - }, ze), Oe, mt, _ ? St : Ze, Be, /* @__PURE__ */ se.createElement(Wte, { + }, ze), Oe, mt, N ? St : Ze, Be, /* @__PURE__ */ se.createElement(Wte, { locale: $, rootPrefixCls: t, disabled: D, @@ -50035,14 +50035,14 @@ html body { direction: M, showSizeChanger: I, className: D, - style: _, + style: N, classNames: P, styles: T, totalBoundaryShowSizeChanger: A } = qt("pagination"), z = $("pagination", n), [O, L] = rne(z), F = dr(u), U = F === "small" || !!(E && !F && m), [H, V] = Xa("input"), ee = { ...e, size: F - }, X = yt(_), K = yt(c), [G, W] = Jt([P, S], [T, X, b, K], { + }, X = yt(N), K = yt(c), [G, W] = Jt([P, S], [T, X, b, K], { props: ee }), [Q] = wo("Pagination", pk), k = { ...Q, @@ -50506,7 +50506,7 @@ html body { direction: M, indicator: I, className: D, - style: _, + style: N, classNames: P, styles: T } = qt("spin"), A = $("spin", n), [z, O] = hne(A), [L, F] = l.useState(() => r && !bne(r, a)), U = vne(L, S); @@ -50530,7 +50530,7 @@ html body { fullscreen: y, children: v, percent: U - }, X = yt(_), [K, G] = Jt([P, C], [T, X, E], { + }, X = yt(N), [K, G] = Jt([P, C], [T, X, E], { props: ee }), W = b ?? I ?? FB, Q = typeof v < "u", k = Q || y, B = /* @__PURE__ */ l.createElement(l.Fragment, null, /* @__PURE__ */ l.createElement(lne, { className: ne(K.indicator), @@ -50623,19 +50623,19 @@ html body { } = l.useContext(WR), { getPrefixCls: y, list: b - } = l.useContext(Pt), S = (_) => ne(b?.item?.classNames?.[_], f?.[_]), C = (_) => ({ - ...b?.item?.styles?.[_], - ...c?.[_] + } = l.useContext(Pt), S = (N) => ne(b?.item?.classNames?.[N], f?.[N]), C = (N) => ({ + ...b?.item?.styles?.[N], + ...c?.[N] }), E = () => { - const _ = xr(r); - return _.some(ba) && _.length > 1; + const N = xr(r); + return N.some(ba) && N.length > 1; }, R = () => v === "vertical" ? !!s : !E(), $ = y("list", n), M = a && a.length > 0 && /* @__PURE__ */ se.createElement("ul", { className: ne(`${$}-item-action`, S("actions")), key: "actions", style: C("actions") - }, a.map((_, P) => /* @__PURE__ */ se.createElement("li", { + }, a.map((N, P) => /* @__PURE__ */ se.createElement("li", { key: `${$}-item-action-${P}` - }, _, P !== a.length - 1 && /* @__PURE__ */ se.createElement("em", { + }, N, P !== a.length - 1 && /* @__PURE__ */ se.createElement("em", { className: `${$}-item-action-split` })))), I = g ? "div" : "li", D = /* @__PURE__ */ se.createElement(I, { ...p, @@ -50773,7 +50773,7 @@ html body { emptyTextPadding: M, metaMarginBottom: I, avatarMarginRight: D, - titleMarginBottom: _, + titleMarginBottom: N, descriptionFontSize: P } = e; return { @@ -50914,7 +50914,7 @@ html body { marginBlockEnd: I, [`${t}-item-meta-title`]: { marginBlockStart: 0, - marginBlockEnd: _, + marginBlockEnd: N, color: b, fontSize: e.fontSizeLG, lineHeight: e.lineHeightLG @@ -51008,7 +51008,7 @@ html body { renderItem: $, locale: M, ...I - } = e, D = It(n) ? n : {}, [_, P] = l.useState(D.defaultCurrent || 1), [T, A] = l.useState(D.defaultPageSize || 10), { + } = e, D = It(n) ? n : {}, [N, P] = l.useState(D.defaultCurrent || 1), [T, A] = l.useState(D.defaultPageSize || 10), { getPrefixCls: z, direction: O, className: L, @@ -51054,7 +51054,7 @@ html body { [`${W}-rtl`]: O === "rtl" }, L, c, u, Q, k), Y = `${W}-container`, q = Sl(H, { total: y.length, - current: _, + current: N, pageSize: T }, n || {}), te = Math.ceil(q.total / q.pageSize); q.current = Math.min(q.current, te); @@ -51359,7 +51359,7 @@ html body { style: S, classNames: C, styles: E - } = qt("modal"), R = y(), $ = t || y("modal"), M = rr(R), [I, D] = Ej($, M), _ = yt(S), P = yt(m), [T, A] = Jt([C, p], [E, _, g, P], { + } = qt("modal"), R = y(), $ = t || y("modal"), M = rr(R), [I, D] = Ej($, M), N = yt(S), P = yt(m), [T, A] = Jt([C, p], [E, N, g, P], { props: e }), z = `${$}-confirm`; let O = {}; @@ -51581,11 +51581,11 @@ html body { style: M, classNames: I, styles: D, - arrow: _, + arrow: N, trigger: P, mouseEnterDelay: T, mouseLeaveDelay: A - } = qt("popconfirm"), [z, O] = Rn(e.defaultOpen ?? !1, e.open), L = ER(v, _), F = a || P || "click", U = S ?? T ?? 0.1, H = C ?? A ?? 0.1, V = (ie) => { + } = qt("popconfirm"), [z, O] = Rn(e.defaultOpen ?? !1, e.open), L = ER(v, N), F = a || P || "click", U = S ?? T ?? 0.1, H = C ?? A ?? 0.1, V = (ie) => { O(ie), m?.(ie); }, ee = () => { V(!1); @@ -51797,7 +51797,7 @@ html body { } = { ...Une, ...e - }, R = Yf / 2, $ = Ya(t), M = `${$}-gradient`, I = R - c / 2, D = Math.PI * 2 * I, _ = f > 0 ? 90 + f / 2 : -90, P = D * ((360 - f) / 360), { + }, R = Yf / 2, $ = Ya(t), M = `${$}-gradient`, I = R - c / 2, D = Math.PI * 2 * I, N = f > 0 ? 90 + f / 2 : -90, P = D * ((360 - f) / 360), { count: T, gap: A } = typeof s == "object" ? s : { @@ -51809,10 +51809,10 @@ html body { } = Vne({ id: $, loading: C - }), ee = Ox(D, P, 0, 100, _, f, m, p, U, c), X = qne(), K = () => { + }), ee = Ox(D, P, 0, 100, N, f, m, p, U, c), X = qne(), K = () => { let W = 0; return z.map((Q, k) => { - const B = O[k] || O[O.length - 1], J = Ox(D, P, W, Q, _, f, m, B, U, c); + const B = O[k] || O[O.length - 1], J = Ox(D, P, W, Q, N, f, m, B, U, c); return W += Q, /* @__PURE__ */ l.createElement(Wne, { key: k, color: B, @@ -51839,7 +51839,7 @@ html body { const W = Math.round(T * (z[0] / 100)), Q = 100 / T; let k = 0; return new Array(T).fill(null).map((B, J) => { - const re = J <= W - 1 ? O[0] : p, le = re && typeof re == "object" ? `url(#${M})` : void 0, ie = Ox(D, P, k, Q, _, f, m, re, "butt", c, A); + const re = J <= W - 1 ? O[0] : p, le = re && typeof re == "object" ? `url(#${M})` : void 0, ie = Ox(D, P, k, Q, N, f, m, re, "butt", c, A); return k += (P - ie.strokeDashoffset + A) * 100 / P, /* @__PURE__ */ l.createElement("circle", { key: J, className: ne(`${n}-circle-path`, r.track), @@ -51950,7 +51950,7 @@ html body { return m; if (g === "dashboard") return 75; - }, [m, g]), _ = Gne(e), P = l.useMemo(() => { + }, [m, g]), N = Gne(e), P = l.useMemo(() => { const U = (f ?? u) || g === "dashboard" && "bottom" || void 0, H = C === "rtl"; switch (U) { case "start": @@ -51967,7 +51967,7 @@ html body { [`${t}-circle-gradient`]: T }, n.body), O = /* @__PURE__ */ l.createElement(Kne, { steps: S, - percent: S ? _[1] : _, + percent: S ? N[1] : N, strokeWidth: M, railWidth: M, strokeColor: S ? A[1] : A, @@ -52301,7 +52301,7 @@ html body { backgroundColor: E || void 0, borderRadius: R, height: I - }, _ = `${t}-track`, P = f && typeof f != "string" ? lre(f, a) : { + }, N = `${t}-track`, P = f && typeof f != "string" ? lre(f, a) : { [Pw]: f, background: f }, T = { @@ -52330,13 +52330,13 @@ html body { ...r.rail } }, /* @__PURE__ */ l.createElement("div", { - className: ne(_, n.track), + className: ne(N, n.track), style: { ...T, ...r.track } }, C === "inner" && p), A !== void 0 && /* @__PURE__ */ l.createElement("div", { - className: ne(_, `${_}-success`, n.track), + className: ne(N, `${N}-success`, n.track), style: { ...z, ...r.track @@ -52408,10 +52408,10 @@ html body { return new En(ie).isLight(); } return !1; - }, [f]), _ = l.useMemo(() => { + }, [f]), N = l.useMemo(() => { const ie = iv(e); return Number.parseInt(ie !== void 0 ? (ie ?? 0)?.toString() : (m ?? 0)?.toString(), 10); - }, [m, e.success]), P = l.useMemo(() => !dre.includes(y) && _ >= 100 ? "success" : y || "normal", [y, _]), { + }, [m, e.success]), P = l.useMemo(() => !dre.includes(y) && N >= 100 ? "success" : y || "normal", [y, N]), { getPrefixCls: T, direction: A, className: z, @@ -52442,7 +52442,7 @@ html body { style: W.indicator, title: typeof Y == "string" ? Y : void 0 }, Y); - }, [g, m, _, P, v, U, b, Q, D, $, R, k, G.indicator, W.indicator]), J = { + }, [g, m, N, P, v, U, b, Q, D, $, R, k, G.indicator, W.indicator]), J = { ...e, classNames: G, styles: W @@ -52484,7 +52484,7 @@ html body { style: W.root, className: le, role: "progressbar", - "aria-valuenow": _, + "aria-valuenow": N, "aria-valuemin": 0, "aria-valuemax": 100, ...sn(E, ["railColor", "trailColor", "strokeWidth", "width", "gapDegree", "gapPosition", "gapPlacement", "strokeLinecap", "success"]) @@ -52518,8 +52518,8 @@ html body { }, b) => { const [S, C] = Rn(r ?? !1, n); function E(I, D) { - let _ = S; - return a || (_ = I, C(_), m?.(_, D)), _; + let N = S; + return a || (N = I, C(N), m?.(N, D)), N; } function R(I) { I.which === vt.LEFT ? E(!1, I) : I.which === vt.RIGHT && E(!0, I), p?.(I); @@ -52877,13 +52877,13 @@ html body { direction: M, className: I, style: D, - classNames: _, + classNames: N, styles: P } = qt("switch"), T = l.useContext(kr), A = (a ?? T) || s, z = $("switch", n), [O, L] = bre(z), F = dr(r), U = { ...e, size: F, disabled: A - }, H = yt(D), V = yt(f), [ee, X] = Jt([_, S], [P, H, b, V], { + }, H = yt(D), V = yt(f), [ee, X] = Jt([N, S], [P, H, b, V], { props: U }), K = /* @__PURE__ */ l.createElement("div", { className: ne(`${z}-handle`, ee.indicator), @@ -53132,7 +53132,7 @@ html body { fixedStartShadow: M, fixedEndShadow: I, offsetFixedStartShadow: D, - offsetFixedEndShadow: _, + offsetFixedEndShadow: N, zIndex: P, zIndexReverse: T, // Private @@ -53150,7 +53150,7 @@ html body { return [!1, !1]; const [ce, Z] = oe, me = (K && M && ce) - // For precision, we not show shadow by default which has better user experience. D >= 1, xe = (G && I && Z - ce) - // Same as above - _ > 1; + N > 1; return [me, xe]; }); K && (X.insetInlineStart = R, X["--z-offset"] = P, X["--z-offset-reverse"] = T), G && (X.insetInlineEnd = $, X["--z-offset"] = P, X["--z-offset-reverse"] = T); @@ -53352,8 +53352,8 @@ html body { } = a, b = c === "nest", S = c === "row" && (!p || p(e)), C = S || b, E = u && u.has(t), R = f && e && e[f], $ = Tt(m), M = g?.(e, n), I = M?.onClick, D = (T, ...A) => { v && C && m(e, T), I?.(T, ...A); }; - let _; - typeof y == "string" ? _ = y : typeof y == "function" && (_ = y(e, n, r)); + let N; + typeof y == "string" ? N = y : typeof y == "function" && (N = y(e, n, r)); const P = Gv(s); return { ...a, @@ -53367,7 +53367,7 @@ html body { expandable: C, rowProps: { ...M, - className: ne(_, M?.className), + className: ne(N, M?.className), onClick: D } }; @@ -53484,14 +53484,14 @@ html body { record: u, onExpand: E }))); - const _ = { + const N = { ...t.onCell?.(u, a) }; let P; if (c) { const { rowSpan: T = 1 - } = _; + } = N; if (R && T && n < c) { P = T; let A = T; @@ -53499,14 +53499,14 @@ html body { const O = s[z]; $.has(O) && (A += 1); } - _.rowSpan = A; + N.rowSpan = A; } } return { key: M, fixedInfo: I, appendCellNode: D, - additionalCellProps: _, + additionalCellProps: N, originRowSpan: P }; } @@ -53535,7 +53535,7 @@ html body { rowProps: I, // Misc expanded: D, - rowSupportExpand: _ + rowSupportExpand: N } = S, P = l.useRef(!1); P.current ||= D; const T = nH(R, s, c, p), A = /* @__PURE__ */ l.createElement(g, sv({}, I, { @@ -53582,7 +53582,7 @@ html body { })); })); let z; - if (_ && (M || P.current || D)) { + if (N && (M || P.current || D)) { const O = $(s, c, p + 1, D); z = /* @__PURE__ */ l.createElement(tH, { expanded: D, @@ -53691,7 +53691,7 @@ html body { colSpan: z, sticky: O }; - }, [c.length, y, b]), I = a(["body", "wrapper"], "tbody"), D = a(["body", "row"], "tr"), _ = a(["body", "cell"], "td"), P = a(["body", "cell"], "th"); + }, [c.length, y, b]), I = a(["body", "wrapper"], "tbody"), D = a(["body", "row"], "tr"), N = a(["body", "cell"], "td"), P = a(["body", "cell"], "th"); let T; t.length ? T = E.map((z, O) => { const { @@ -53710,7 +53710,7 @@ html body { index: O, renderIndex: U, rowComponent: D, - cellComponent: _, + cellComponent: N, scopeCellComponent: P, indent: F, expandedRowInfo: M @@ -53720,7 +53720,7 @@ html body { className: `${r}-placeholder`, prefixCls: r, component: D, - cellComponent: _, + cellComponent: N, colSpan: c.length, isEmpty: !0 }, p); @@ -53823,10 +53823,10 @@ html body { ...I } = e, { prefixCls: D, - scrollbarSize: _, + scrollbarSize: N, isSticky: P, getComponent: T - } = Mr(Xr, ["prefixCls", "scrollbarSize", "isSticky", "getComponent"]), A = T(["header", "table"], "table"), z = P && !v ? 0 : _, O = l.useRef(null), L = l.useCallback((G) => { + } = Mr(Xr, ["prefixCls", "scrollbarSize", "isSticky", "getComponent"]), A = T(["header", "table"], "table"), z = P && !v ? 0 : N, O = l.useRef(null), L = l.useCallback((G) => { g0(t, G), g0(O, G); }, []); l.useEffect(() => { @@ -54175,8 +54175,8 @@ html body { () => iH(I), // eslint-disable-next-line react-hooks/exhaustive-deps [I, v, C] - ), [_, P] = Qre(D, C, E); - return [I, _, P]; + ), [N, P] = Qre(D, C, E); + return [I, N, P]; } function Zre(e, t, n) { const r = qre(e), { @@ -54309,27 +54309,27 @@ html body { }, []); const R = () => { C(!1); - }, $ = (_) => { - _.persist(), b.current.delta = _.pageX - v.scrollLeft, b.current.x = 0, C(!0), _.preventDefault(); - }, M = (_) => { + }, $ = (N) => { + N.persist(), b.current.delta = N.pageX - v.scrollLeft, b.current.x = 0, C(!0), N.preventDefault(); + }, M = (N) => { const { buttons: P - } = _ || window?.event; + } = N || window?.event; if (!S || P === 0) { S && C(!1); return; } - let T = b.current.x + _.pageX - b.current.x - b.current.delta; + let T = b.current.x + N.pageX - b.current.x - b.current.delta; const A = c === "rtl"; T = Math.max(A ? p - m : 0, Math.min(A ? 0 : m - p, T)), (!A || Math.abs(T) + Math.abs(p) < m) && (r({ scrollLeft: T / m * (f + 2) - }), b.current.x = _.pageX); + }), b.current.x = N.pageX); }, I = () => { Ft.cancel(E.current), E.current = Ft(() => { if (!n.current) return; - const _ = fA(n.current).top, P = _ + n.current.offsetHeight, T = s === window ? document.documentElement.scrollTop + window.innerHeight : fA(s).top + s.clientHeight; - P - lI() <= T || _ >= T - a ? y((A) => ({ + const N = fA(n.current).top, P = N + n.current.offsetHeight, T = s === window ? document.documentElement.scrollTop + window.innerHeight : fA(s).top + s.clientHeight; + P - lI() <= T || N >= T - a ? y((A) => ({ ...A, isHiddenScrollBar: !0 })) : y((A) => ({ @@ -54337,10 +54337,10 @@ html body { isHiddenScrollBar: !1 })); }); - }, D = (_) => { + }, D = (N) => { y((P) => ({ ...P, - scrollLeft: _ / f * m || 0 + scrollLeft: N / f * m || 0 })); }; return l.useImperativeHandle(t, () => ({ @@ -54350,25 +54350,25 @@ html body { document.body.removeEventListener(mA, R), document.body.removeEventListener(pA, M); }), [p, S]), l.useEffect(() => { if (n.current) { - const _ = []; + const N = []; let P = Sa(n.current); for (; P; ) - _.push(P), P = P.parentElement; - return _.forEach((T) => { + N.push(P), P = P.parentElement; + return N.forEach((T) => { T.addEventListener(qc, I, !1); }), window.addEventListener(hA, I, !1), window.addEventListener(qc, I, !1), s.addEventListener(qc, I, !1), () => { - _.forEach((T) => { + N.forEach((T) => { T.removeEventListener(qc, I); }), window.removeEventListener(hA, I), window.removeEventListener(qc, I), s.removeEventListener(qc, I); }; } }, [s]), l.useEffect(() => { - v.isHiddenScrollBar || y((_) => { + v.isHiddenScrollBar || y((N) => { const P = n.current; return P ? { - ..._, + ...N, scrollLeft: P.scrollLeft / P.scrollWidth * P.clientWidth - } : _; + } : N; }); }, [v.isHiddenScrollBar]), f <= m || !p || v.isHiddenScrollBar ? null : /* @__PURE__ */ l.createElement("div", { style: { @@ -54432,7 +54432,7 @@ html body { components: M, emptyText: I, onRow: D, - onHeaderRow: _, + onHeaderRow: N, // Measure Row measureRowRender: P, // Events @@ -54595,7 +54595,7 @@ html body { colWidths: Ge, columCount: oe.length, stickyOffsets: Ue, - onHeaderRow: _, + onHeaderRow: N, fixHeader: Ze, scroll: g }, en = l.useMemo(() => ee ? null : typeof I == "function" ? I() : I, [ee, I]), pn = /* @__PURE__ */ l.createElement(Ure, { @@ -54875,11 +54875,11 @@ html body { additionalCellProps: I, originRowSpan: D } = rH(t, n, r, a, s), { - style: _, + style: N, colSpan: P = 1, rowSpan: T = 1 } = I, A = r - 1, z = foe(A, P, E), O = P > 1 ? C - z : 0, L = { - ..._, + ...N, ...m, flex: `0 0 ${z}px`, width: `${z}px`, @@ -54946,7 +54946,7 @@ html body { styles: $ } = Mr(Xr, ["prefixCls", "flattenColumns", "fixColumn", "componentWidth", "scrollX", "classNames", "styles"]), { getComponent: M - } = Mr(QR, ["getComponent"]), I = eH(p, s, r, g), D = M(["body", "row"], "div"), _ = M(["body", "cell"], "div"), { + } = Mr(QR, ["getComponent"]), I = eH(p, s, r, g), D = M(["body", "row"], "div"), N = M(["body", "cell"], "div"), { rowSupportExpand: P, expanded: T, rowProps: A, @@ -54970,7 +54970,7 @@ html body { display: T ? void 0 : "none" } }, /* @__PURE__ */ l.createElement(Ru, { - component: _, + component: N, prefixCls: S, className: ne(K, { [`${K}-fixed`]: C @@ -54999,7 +54999,7 @@ html body { key: X, className: R?.body?.cell, style: $?.body?.cell, - component: _, + component: N, rowInfo: I, column: ee, colIndex: X, @@ -55140,11 +55140,11 @@ html body { getHeight: B }); }); - }, _ = l.useMemo(() => ({ + }, N = l.useMemo(() => ({ columnsOffset: M }), [M]), P = `${f}-tbody`, T = S(["body", "wrapper"]), A = {}; return v && (A.position = "sticky", A.bottom = 0, typeof v == "object" && v.offsetScroll && (A.bottom = v.offsetScroll)), /* @__PURE__ */ l.createElement(cH.Provider, { - value: _ + value: N }, /* @__PURE__ */ l.createElement(Av, { fullHeight: !1, ref: E, @@ -55270,7 +55270,7 @@ html body { prefixCls: M, data: I, pageData: D, - getRecordByKey: _, + getRecordByKey: N, getRowKey: P, expandType: T, childrenColumnName: A, @@ -55280,11 +55280,11 @@ html body { if (n) { const fe = /* @__PURE__ */ new Map(); de.forEach((pe) => { - let oe = _(pe); + let oe = N(pe); !oe && X.current.has(pe) && (oe = X.current.get(pe)), fe.set(pe, oe); }), X.current = fe; } - }, [_, n]); + }, [N, n]); l.useEffect(() => { K(ee); }, [ee, K]); @@ -55332,18 +55332,18 @@ html body { const ie = l.useCallback((de, fe) => { let pe, oe; K(de), n ? (pe = de, oe = de.map((ce) => X.current.get(ce))) : (pe = [], oe = [], de.forEach((ce) => { - const Z = _(ce); + const Z = N(ce); Z !== void 0 && (pe.push(ce), oe.push(Z)); })), V(pe), u?.(pe, oe, { type: fe }); - }, [V, _, u, n]), Y = l.useCallback((de, fe, pe, oe) => { + }, [V, N, u, n]), Y = l.useCallback((de, fe, pe, oe) => { if (f) { - const ce = pe.map(_); - f(_(de), fe, ce, oe); + const ce = pe.map(N); + f(N(de), fe, ce, oe); } ie(pe, "single"); - }, [f, _, ie]), q = l.useMemo(() => !S || R ? null : (S === !0 ? [Ow, Lw, kw] : S).map((fe) => { + }, [f, N, ie]), q = l.useMemo(() => !S || R ? null : (S === !0 ? [Ow, Lw, kw] : S).map((fe) => { let pe; return fe === Ow ? pe = { key: "all", @@ -55394,7 +55394,7 @@ html body { pe.has(Ge) || (pe.add(Ge), ze.push(Ge)); }); const Oe = Array.from(pe); - m?.(!ce, Oe.map(_), ze.map(_)), ie(Oe, "all"), U(null); + m?.(!ce, Oe.map(N), ze.map(N)), ie(Oe, "all"), U(null); }; let xe, Me; if (b !== "radio") { @@ -55492,7 +55492,7 @@ html body { } = nt, ft = oe.indexOf(Ue), mt = re.size > 0 && oe.some((Be) => re.has(Be)); if (St && $ && mt) { const Be = F(ft, oe, pe), at = Array.from(pe); - v?.(!Ze, at.map(_), Be.map(_)), ie(at, "multiple"); + v?.(!Ze, at.map(N), Be.map(N)), ie(at, "multiple"); } else { const Be = B; if ($) { @@ -56272,7 +56272,7 @@ html body { disabled: M, motion: I, style: D, - rootClassName: _, + rootClassName: N, rootStyle: P, classNames: T, styles: A, @@ -56335,7 +56335,7 @@ html body { ...s, ...D }, - rootClassName: ne(X.root, _), + rootClassName: ne(X.root, N), rootStyle: K.root, classNames: X, styles: K, @@ -56453,7 +56453,7 @@ html body { showIcon: $ = !0, expandAction: M = "click", ...I - } = s, D = S("tree", E), _ = ne(`${D}-directory`, { + } = s, D = S("tree", E), N = ne(`${D}-directory`, { [`${D}-directory-rtl`]: C === "rtl" }, R); return /* @__PURE__ */ l.createElement(fH, { @@ -56464,7 +56464,7 @@ html body { showIcon: $, expandAction: M, prefixCls: D, - className: _, + className: N, defaultExpandParent: r, expandedKeys: g, selectedKeys: m, @@ -56583,7 +56583,7 @@ html body { // Deprecated filterDropdownOpen: $, onFilterDropdownOpenChange: M - } = r, [I, D] = l.useState(!1), _ = l.useContext(wR), P = !!(p && (p.filteredKeys?.length || p.forceFiltered)), T = (Z) => { + } = r, [I, D] = l.useState(!1), N = l.useContext(wR), P = !!(p && (p.filteredKeys?.length || p.forceFiltered)), T = (Z) => { D(Z), R.onOpenChange?.(Z), M?.(Z); }, A = R.open ?? $ ?? // deprecated I, z = p?.filteredKeys, [O, L] = P9(Fx(z)), F = ({ @@ -56798,7 +56798,7 @@ html body { } }, Z); })(); - if (_) + if (N) return /* @__PURE__ */ l.createElement("div", { className: `${n}-column` }, /* @__PURE__ */ l.createElement("span", { @@ -57117,10 +57117,10 @@ html body { const { cancelSort: I, triggerAsc: D, - triggerDesc: _ + triggerDesc: N } = s || {}; let P = I; - $ === Bx ? P = _ : $ === Sg && (P = D); + $ === Bx ? P = N : $ === Sg && (P = D); const T = It(S) ? { title: P, ...S @@ -57238,9 +57238,9 @@ html body { } = e, m = r ?? n, [p, g] = l.useState(() => Yw(m, !0)), v = (R, $) => { const M = []; return R.forEach((I, D) => { - const _ = Tu(D, $); - if (M.push($s(I, _)), Array.isArray(I.children)) { - const P = v(I.children, _); + const N = Tu(D, $); + if (M.push($s(I, N)), Array.isArray(I.children)) { + const P = v(I.children, N); M.push.apply(M, Nt(P)); } }), M; @@ -57248,21 +57248,21 @@ html body { let R = !0; const $ = Yw(m, !1); if (!$.length) { - const _ = v(m); + const N = v(m); return p.filter(({ key: P - }) => _.includes(P)); + }) => N.includes(P)); } const M = []; - function I(_) { - R ? M.push(_) : M.push({ - ..._, + function I(N) { + R ? M.push(N) : M.push({ + ...N, sortOrder: null }); } let D = null; - return $.forEach((_) => { - D === null ? (I(_), _.sortOrder && (_.multiplePriority === !1 ? R = !1 : D = !0)) : (D && _.multiplePriority !== !1 || (R = !1), I(_)); + return $.forEach((N) => { + D === null ? (I(N), N.sortOrder && (N.multiplePriority === !1 ? R = !1 : D = !0)) : (D && N.multiplePriority !== !1 || (R = !1), I(N)); }), M; }, [m, p]), b = l.useMemo(() => { const R = y.map(({ @@ -57621,7 +57621,7 @@ html body { colorTextDisabled: M, tableFilterDropdownBg: I, tableFilterDropdownHeight: D, - controlItemBgHover: _, + controlItemBgHover: N, controlItemBgActive: P, boxShadowSecondary: T, filterDropdownMenuBg: A, @@ -57692,7 +57692,7 @@ html body { padding: 0 }, [`${F}-treenode ${F}-node-content-wrapper:hover`]: { - backgroundColor: _ + backgroundColor: N }, [`${F}-treenode-checkbox-checked ${F}-node-content-wrapper`]: { "&, &:hover": { @@ -58500,11 +58500,11 @@ html body { colorIconHover: M, opacityLoading: I, controlInteractiveSize: D - } = e, _ = new En(a).onBackground(n).toHexString(), P = new En(s).onBackground(n).toHexString(), T = new En(t).onBackground(n).toHexString(), A = new En($), z = new En(M), O = D / 2 - R, L = O * 2 + R * 3; + } = e, N = new En(a).onBackground(n).toHexString(), P = new En(s).onBackground(n).toHexString(), T = new En(t).onBackground(n).toHexString(), A = new En($), z = new En(M), O = D / 2 - R, L = O * 2 + R * 3; return { headerBg: T, headerColor: r, - headerSortActiveBg: _, + headerSortActiveBg: N, headerSortHoverBg: P, bodySortBg: T, rowHoverBg: T, @@ -58525,7 +58525,7 @@ html body { cellFontSizeMD: S, cellFontSizeSM: S, headerSplitColor: g, - fixedHeaderSortActiveBg: _, + fixedHeaderSortActiveBg: N, headerFilterHoverBg: s, filterDropdownMenuBg: n, filterDropdownBg: n, @@ -58564,7 +58564,7 @@ html body { borderColor: M, footerBg: I, footerColor: D, - headerBorderRadius: _, + headerBorderRadius: N, cellFontSize: P, cellFontSizeMD: T, cellFontSizeSM: A, @@ -58579,7 +58579,7 @@ html body { } = e, X = Kt(e, { tableFontSize: P, tableBg: r, - tableRadius: _, + tableRadius: N, tablePaddingVertical: b, tablePaddingHorizontal: S, tablePaddingVerticalMiddle: C, @@ -58657,7 +58657,7 @@ html body { onChange: M, getPopupContainer: I, loading: D, - expandIcon: _, + expandIcon: N, expandable: P, expandedRowRender: T, expandIconColumnIndex: A, @@ -58814,7 +58814,7 @@ html body { }, Oe), en = (rt, wt, ct) => ne({ [`${We}-row-selected`]: $n.has(Be(rt, wt)) }, Ct(S) ? S(rt, wt, ct) : S); - he.__PARENT_RENDER_ICON__ = he.expandIcon, he.expandIcon = he.expandIcon || _ || woe(Ee), Qe === "nest" && he.expandIconColumnIndex === void 0 ? he.expandIconColumnIndex = Oe ? 1 : 0 : he.expandIconColumnIndex > 0 && Oe && (he.expandIconColumnIndex -= 1), lr(he.indentSize) || (he.indentSize = lr(z) ? z : 15); + he.__PARENT_RENDER_ICON__ = he.expandIcon, he.expandIcon = he.expandIcon || N || woe(Ee), Qe === "nest" && he.expandIconColumnIndex === void 0 ? he.expandIconColumnIndex = Oe ? 1 : 0 : he.expandIconColumnIndex > 0 && Oe && (he.expandIconColumnIndex -= 1), lr(he.indentSize) || (he.indentSize = lr(z) ? z : 15); const pn = l.useCallback((rt) => un(Xt(xt(Re(rt)))), [Re, xt, Xt]); let zn, Qn; if (v !== !1 && ht?.total) { @@ -59115,10 +59115,10 @@ html body { } = e, { getPrefixCls: y, tag: b - } = l.useContext(Pt), S = l.useContext(kr), C = g ?? S, E = (_) => { - C || (f?.(!s), m?.(_)); - }, R = (_) => { - p?.(_), !(_.defaultPrevented || C) && _.key === " " && (_.preventDefault(), _.repeat || f?.(!s)); + } = l.useContext(Pt), S = l.useContext(kr), C = g ?? S, E = (N) => { + C || (f?.(!s), m?.(N)); + }, R = (N) => { + p?.(N), !(N.defaultPrevented || C) && N.key === " " && (N.preventDefault(), N.repeat || f?.(!s)); }, $ = y("tag", n), [M, I] = t1($), D = ne($, `${$}-checkable`, { [`${$}-checkable-checked`]: s, [`${$}-checkable-disabled`]: C @@ -59161,7 +59161,7 @@ html body { style: $, classNames: M, styles: I - } = qt("tag"), D = C("tag", r), _ = `${D}-checkable-group`, P = rr(D), [T, A] = t1(D, P), z = yt($), O = yt(c), [L, F] = Jt([M, u], [I, z, f, O], { + } = qt("tag"), D = C("tag", r), N = `${D}-checkable-group`, P = rr(D), [T, A] = t1(D, P), z = yt($), O = yt(c), [L, F] = Jt([M, u], [I, z, f, O], { props: e }), U = l.useMemo(() => Array.isArray(p) ? p.map((G) => It(G) ? G : { value: G, @@ -59184,16 +59184,16 @@ html body { }); return /* @__PURE__ */ se.createElement("div", { ...K, - className: ne(_, R, a, { - [`${_}-disabled`]: m, - [`${_}-rtl`]: E === "rtl" + className: ne(N, R, a, { + [`${N}-disabled`]: m, + [`${N}-rtl`]: E === "rtl" }, T, A, s, L.root), style: F.root, id: n, ref: X }, U.map((G) => /* @__PURE__ */ se.createElement(CH, { key: G.value, - className: ne(`${_}-item`, L.item, G.className), + className: ne(`${N}-item`, L.item, G.className), style: { ...F.item, ...G.style @@ -59301,7 +59301,7 @@ html body { className: M, variant: I, style: D, - classNames: _, + classNames: N, styles: P } = qt("tag"), [T, A, z, O, L] = oie(e, I), F = z || O, U = l.useContext(kr), H = v ?? U, { tag: V @@ -59310,7 +59310,7 @@ html body { color: A, variant: T, disabled: H - }, W = yt(D), Q = yt(s), [k, B] = Jt([_, C], [P, W, S, Q], { + }, W = yt(D), Q = yt(s), [k, B] = Jt([N, C], [P, W, S, Q], { props: G }), J = l.useMemo(() => { let me = B.root; @@ -59814,7 +59814,7 @@ html body { M(U.value.replace(/[\n\r]/g, "")); }, D = () => { E.current = !0; - }, _ = () => { + }, N = () => { E.current = !1; }, P = ({ keyCode: U @@ -59850,7 +59850,7 @@ html body { onKeyDown: P, onKeyUp: A, onCompositionStart: D, - onCompositionEnd: _, + onCompositionEnd: N, onBlur: z, "aria-label": n, rows: 1, @@ -60117,17 +60117,17 @@ html body { measureDeps: u, miscDeps: f, onEllipsis: m - } = e, p = l.useMemo(() => xr(r), [r]), g = l.useMemo(() => Bie(p), [r]), v = l.useMemo(() => a(p, !1), [r].concat(Nt(u))), [y, b] = l.useState(null), S = l.useRef(null), C = l.useRef(null), E = l.useRef(null), R = l.useRef(null), $ = l.useRef(null), [M, I] = l.useState(!1), [D, _] = l.useState(qx), [P, T] = l.useState(0), [A, z] = l.useState(null); + } = e, p = l.useMemo(() => xr(r), [r]), g = l.useMemo(() => Bie(p), [r]), v = l.useMemo(() => a(p, !1), [r].concat(Nt(u))), [y, b] = l.useState(null), S = l.useRef(null), C = l.useRef(null), E = l.useRef(null), R = l.useRef(null), $ = l.useRef(null), [M, I] = l.useState(!1), [D, N] = l.useState(qx), [P, T] = l.useState(0), [A, z] = l.useState(null); Vt(() => { - _(t && n && g ? Wx : qx); + N(t && n && g ? Wx : qx); }, [n, r, s, t, p].concat(Nt(u))), Vt(() => { if (D === Wx) { - _(Vx); + N(Vx); const U = C.current && getComputedStyle(C.current).whiteSpace; z(U); } else if (D === Vx) { const U = !!E.current?.isExceed(); - _(U ? Kx : IA), b(U ? [0, g] : null), I(U); + N(U ? Kx : IA), b(U ? [0, g] : null), I(U); const H = E.current?.getHeight() || 0, V = s === 1 ? 0 : R.current?.getHeight() || 0, ee = $.current?.getHeight() || 0, X = Math.max( H, // height of rows with ellipsis @@ -60243,7 +60243,7 @@ html body { onMouseEnter: E, onMouseLeave: R, ...$ - } = e, [M] = wo("Text"), I = l.useRef(null), D = l.useRef(null), [_, P, T, A] = wH(n, s, c, u, e), z = sn($, MA), [O, L] = Hx(v), [F, U] = Rn(!1, L.editing), { + } = e, [M] = wo("Text"), I = l.useRef(null), D = l.useRef(null), [N, P, T, A] = wH(n, s, c, u, e), z = sn($, MA), [O, L] = Hx(v), [F, U] = Rn(!1, L.editing), { triggerType: H = ["icon"] } = L, V = (He) => { He && L.onStart?.(), U(He); @@ -60330,7 +60330,7 @@ html body { maxLength: L.maxLength, autoSize: L.autoSize, enterIcon: L.enterIcon, - classNames: _, + classNames: N, styles: P }); const ft = () => { @@ -60341,7 +60341,7 @@ html body { return He ? /* @__PURE__ */ l.createElement("button", { type: "button", key: "expand", - className: ne(`${T}-${xe ? "collapse" : "expand"}`, _.action), + className: ne(`${T}-${xe ? "collapse" : "expand"}`, N.action), style: P.action, onClick: (Re) => Ge(Re, { expanded: !xe @@ -60362,7 +60362,7 @@ html body { }, /* @__PURE__ */ l.createElement("button", { type: "button", ref: D, - className: ne(`${T}-edit`, _.action), + className: ne(`${T}-edit`, N.action), style: P.action, onClick: X, "aria-label": Ne, @@ -60379,13 +60379,13 @@ html body { onCopy: re, loading: J, iconOnly: !tn(p), - className: _.action, + className: N.action, style: P.action }) : null, at = (He) => { const $e = He && ft(), Re = mt(), Fe = Be(); return !$e && !Re && !Fe ? null : /* @__PURE__ */ l.createElement("span", { key: "operations", - className: ne(`${T}-actions`, _.actions, { + className: ne(`${T}-actions`, N.actions, { [`${T}-actions-start`]: k === "start" }), style: P.actions, @@ -60422,7 +60422,7 @@ html body { [`${T}-ellipsis-multiple-line`]: Oe, [`${T}-link`]: S === "a" }, r), - classNames: _, + classNames: N, styles: P, prefixCls: T, style: { @@ -60878,7 +60878,7 @@ html body { }), D = b ? { directory: "directory", webkitdirectory: "webkitdirectory" - } : {}, _ = s ? {} : { + } : {}, N = s ? {} : { onClick: S ? this.onClick : () => { }, onKeyDown: S ? this.onKeyDown : () => { @@ -60889,7 +60889,7 @@ html body { onDragOver: this.onFileDragOver, tabIndex: R ? void 0 : "0" }; - return /* @__PURE__ */ se.createElement(t, uv({}, _, { + return /* @__PURE__ */ se.createElement(t, uv({}, N, { className: I, role: R ? void 0 : "button", style: f @@ -61577,7 +61577,7 @@ html body { extra: M, onPreview: I, onDownload: D, - onClose: _ + onClose: N }, P) => { const { status: T @@ -61622,7 +61622,7 @@ html body { } const H = ne(`${e}-list-item`, `${e}-list-item-${A}`, r?.item), V = typeof u.linkProps == "string" ? JSON.parse(u.linkProps) : u.linkProps, ee = (Ct(S) ? S(u) : S) ? g( (Ct(R) ? R(u) : R) || /* @__PURE__ */ l.createElement(fie, null), - () => _(u), + () => N(u), e, s.removeFile, // acceptUploadDisabled is true, only remove icon will follow Upload disabled prop @@ -61696,7 +61696,7 @@ html body { }, v ? v(te, u, f, { download: D.bind(null, u), preview: I.bind(null, u), - remove: _.bind(null, u) + remove: N.bind(null, u) }) : te); }), Pse = (e, t) => { const { @@ -61724,7 +61724,7 @@ html body { appendAction: M, appendActionVisible: I = !0, itemRender: D, - disabled: _, + disabled: N, classNames: P, styles: T } = e, [, A] = GE(), [z, O] = l.useState(!1), L = ["picture-card", "picture-circle"].includes(n); @@ -61763,7 +61763,7 @@ html body { J(), /* @__PURE__ */ l.isValidElement(B) && B.props.onClick?.(q); }, className: `${re}-list-item-action`, - disabled: ie ? _ : !1 + disabled: ie ? N : !1 }; return /* @__PURE__ */ l.isValidElement(B) ? /* @__PURE__ */ l.createElement(Ve, { ...Y, @@ -61867,7 +61867,7 @@ html body { type: M = "select", children: I, style: D, - itemRender: _, + itemRender: N, maxCount: P, data: T = {}, multiple: A = !1, @@ -62070,7 +62070,7 @@ html body { progress: G, appendAction: $e, appendActionVisible: Re, - itemRender: _, + itemRender: N, disabled: X }) : $e, Be = ne(Oe, $, U, Ge, Ue, Me, We.root, { [`${be}-rtl`]: xe === "rtl", @@ -62194,11 +62194,11 @@ html body { }, g.fromString = function(C) { var E = C.indexOf("("), R = C.lastIndexOf(")"), $ = C.substring(0, E), M = C.substring(E + 1, R).split(","), I = C.substring(R + 1); if (I.indexOf("@") === 0) - var D = /@(.+?)(?::(\d+))?(?::(\d+))?$/.exec(I, ""), _ = D[1], P = D[2], T = D[3]; + var D = /@(.+?)(?::(\d+))?(?::(\d+))?$/.exec(I, ""), N = D[1], P = D[2], T = D[3]; return new g({ functionName: $, args: M || void 0, - fileName: _, + fileName: N, lineNumber: P || void 0, columnNumber: T || void 0 }); @@ -62367,7 +62367,7 @@ html body { return t; }, Dse = ({ client: e, children: t }) => (l.useEffect(() => (e.mount(), () => { e.unmount(); - }), [e]), /* @__PURE__ */ N.jsx(TH.Provider, { + }), [e]), /* @__PURE__ */ _.jsx(TH.Provider, { value: e, children: t })), Ose = { @@ -62728,12 +62728,12 @@ html body { } Promise.resolve(R).then(b).catch((M) => { if (f()) return; - const I = e.retry ?? (i1() ? 0 : 3), D = e.retryDelay ?? Zse, _ = typeof D == "function" ? D(n, M) : D, P = I === !0 || typeof I == "number" && n < I || typeof I == "function" && I(n, M); + const I = e.retry ?? (i1() ? 0 : 3), D = e.retryDelay ?? Zse, N = typeof D == "function" ? D(n, M) : D, P = I === !0 || typeof I == "number" && n < I || typeof I == "function" && I(n, M); if (t || !P) { S(M); return; } - n++, e.onFail?.(n, M), Hse(_).then(() => v() ? void 0 : C()).then(() => { + n++, e.onFail?.(n, M), Hse(N).then(() => v() ? void 0 : C()).then(() => { t ? S(M) : E(); }); }); @@ -63258,8 +63258,8 @@ html body { const n = this.#t, r = this.options, a = this.#r, s = this.#a, c = this.#o, u = e !== n ? e.state : this.#n, { state: f } = e; let m = { ...f }, p = !1, g; if (t._optimisticResults) { - const I = this.hasListeners(), D = !I && WA(e, t), _ = I && VA(e, n, t, r); - (D || _) && (m = { + const I = this.hasListeners(), D = !I && WA(e, t), N = I && VA(e, n, t, r); + (D || N) && (m = { ...m, ...OH(f.data, e.options) }), t._optimisticResults === "isRestoring" && (m.fetchStatus = "idle"); @@ -64504,7 +64504,7 @@ html body { function tb() { if (eD) return Jx; eD = 1; - var e = typeof Map == "function" && Map.prototype, t = Object.getOwnPropertyDescriptor && e ? Object.getOwnPropertyDescriptor(Map.prototype, "size") : null, n = e && t && typeof t.get == "function" ? t.get : null, r = e && Map.prototype.forEach, a = typeof Set == "function" && Set.prototype, s = Object.getOwnPropertyDescriptor && a ? Object.getOwnPropertyDescriptor(Set.prototype, "size") : null, c = a && s && typeof s.get == "function" ? s.get : null, u = a && Set.prototype.forEach, f = typeof WeakMap == "function" && WeakMap.prototype, m = f ? WeakMap.prototype.has : null, p = typeof WeakSet == "function" && WeakSet.prototype, g = p ? WeakSet.prototype.has : null, v = typeof WeakRef == "function" && WeakRef.prototype, y = v ? WeakRef.prototype.deref : null, b = Boolean.prototype.valueOf, S = Object.prototype.toString, C = Function.prototype.toString, E = String.prototype.match, R = String.prototype.slice, $ = String.prototype.replace, M = String.prototype.toUpperCase, I = String.prototype.toLowerCase, D = RegExp.prototype.test, _ = Array.prototype.concat, P = Array.prototype.join, T = Array.prototype.slice, A = Math.floor, z = typeof BigInt == "function" ? BigInt.prototype.valueOf : null, O = Object.getOwnPropertySymbols, L = typeof Symbol == "function" && typeof Symbol.iterator == "symbol" ? Symbol.prototype.toString : null, F = typeof Symbol == "function" && typeof Symbol.iterator == "object", U = typeof Symbol == "function" && Symbol.toStringTag && (typeof Symbol.toStringTag === F || !0) ? Symbol.toStringTag : null, H = Object.prototype.propertyIsEnumerable, V = (typeof Reflect == "function" ? Reflect.getPrototypeOf : Object.getPrototypeOf) || ([].__proto__ === Array.prototype ? function(he) { + var e = typeof Map == "function" && Map.prototype, t = Object.getOwnPropertyDescriptor && e ? Object.getOwnPropertyDescriptor(Map.prototype, "size") : null, n = e && t && typeof t.get == "function" ? t.get : null, r = e && Map.prototype.forEach, a = typeof Set == "function" && Set.prototype, s = Object.getOwnPropertyDescriptor && a ? Object.getOwnPropertyDescriptor(Set.prototype, "size") : null, c = a && s && typeof s.get == "function" ? s.get : null, u = a && Set.prototype.forEach, f = typeof WeakMap == "function" && WeakMap.prototype, m = f ? WeakMap.prototype.has : null, p = typeof WeakSet == "function" && WeakSet.prototype, g = p ? WeakSet.prototype.has : null, v = typeof WeakRef == "function" && WeakRef.prototype, y = v ? WeakRef.prototype.deref : null, b = Boolean.prototype.valueOf, S = Object.prototype.toString, C = Function.prototype.toString, E = String.prototype.match, R = String.prototype.slice, $ = String.prototype.replace, M = String.prototype.toUpperCase, I = String.prototype.toLowerCase, D = RegExp.prototype.test, N = Array.prototype.concat, P = Array.prototype.join, T = Array.prototype.slice, A = Math.floor, z = typeof BigInt == "function" ? BigInt.prototype.valueOf : null, O = Object.getOwnPropertySymbols, L = typeof Symbol == "function" && typeof Symbol.iterator == "symbol" ? Symbol.prototype.toString : null, F = typeof Symbol == "function" && typeof Symbol.iterator == "object", U = typeof Symbol == "function" && Symbol.toStringTag && (typeof Symbol.toStringTag === F || !0) ? Symbol.toStringTag : null, H = Object.prototype.propertyIsEnumerable, V = (typeof Reflect == "function" ? Reflect.getPrototypeOf : Object.getPrototypeOf) || ([].__proto__ === Array.prototype ? function(he) { return he.__proto__; } : null); function ee(he, ye) { @@ -64599,7 +64599,7 @@ html body { } if (Y(ye)) { var xt = Ze(ye, He); - return !("cause" in Error.prototype) && "cause" in ye && !H.call(ye, "cause") ? "{ [" + String(ye) + "] " + P.call(_.call("[cause]: " + He(ye.cause), xt), ", ") + " }" : xt.length === 0 ? "[" + String(ye) + "]" : "{ [" + String(ye) + "] " + P.call(xt, ", ") + " }"; + return !("cause" in Error.prototype) && "cause" in ye && !H.call(ye, "cause") ? "{ [" + String(ye) + "] " + P.call(N.call("[cause]: " + He(ye.cause), xt), ", ") + " }" : xt.length === 0 ? "[" + String(ye) + "]" : "{ [" + String(ye) + "] " + P.call(xt, ", ") + " }"; } if (typeof ye == "object" && St) { if (G && typeof ye[G] == "function" && X) @@ -64638,7 +64638,7 @@ html body { if (typeof globalThis < "u" && ye === globalThis || typeof F_ < "u" && ye === F_) return "{ [object globalThis] }"; if (!le(ye) && !ie(ye)) { - var Bt = Ze(ye, He), Sn = V ? V(ye) === Object.prototype : ye instanceof Object || ye.constructor === Object, un = ye instanceof Object ? "" : "null prototype", Nn = !Sn && U && Object(ye) === ye && U in ye ? R.call(Z(ye), 8, -1) : un ? "Object" : "", ht = Sn || typeof ye.constructor != "function" ? "" : ye.constructor.name ? ye.constructor.name + " " : "", Dt = ht + (Nn || un ? "[" + P.call(_.call([], Nn || [], un || []), ": ") + "] " : ""); + var Bt = Ze(ye, He), Sn = V ? V(ye) === Object.prototype : ye instanceof Object || ye.constructor === Object, un = ye instanceof Object ? "" : "null prototype", Nn = !Sn && U && Object(ye) === ye && U in ye ? R.call(Z(ye), 8, -1) : un ? "Object" : "", ht = Sn || typeof ye.constructor != "function" ? "" : ye.constructor.name ? ye.constructor.name + " " : "", Dt = ht + (Nn || un ? "[" + P.call(N.call([], Nn || [], un || []), ": ") + "] " : ""); return Bt.length === 0 ? Dt + "{}" : Te ? Dt + "{" + Ue(Bt, Te) + "}" : Dt + "{ " + P.call(Bt, ", ") + " }"; } return String(ye); @@ -65226,12 +65226,12 @@ html body { return M; } } - })() : M, D = cue()(), _ = pue(), P = i3(), T = a3(), A = s3(), z = d1(), O = {}, L = typeof Uint8Array > "u" || !_ ? e : _(Uint8Array), F = { + })() : M, D = cue()(), N = pue(), P = i3(), T = a3(), A = s3(), z = d1(), O = {}, L = typeof Uint8Array > "u" || !N ? e : N(Uint8Array), F = { __proto__: null, "%AggregateError%": typeof AggregateError > "u" ? e : AggregateError, "%Array%": Array, "%ArrayBuffer%": typeof ArrayBuffer > "u" ? e : ArrayBuffer, - "%ArrayIteratorPrototype%": D && _ ? _([][Symbol.iterator]()) : e, + "%ArrayIteratorPrototype%": D && N ? N([][Symbol.iterator]()) : e, "%AsyncFromSyncIteratorPrototype%": e, "%AsyncFunction%": O, "%AsyncGenerator%": O, @@ -65263,10 +65263,10 @@ html body { "%Int32Array%": typeof Int32Array > "u" ? e : Int32Array, "%isFinite%": isFinite, "%isNaN%": isNaN, - "%IteratorPrototype%": D && _ ? _(_([][Symbol.iterator]())) : e, + "%IteratorPrototype%": D && N ? N(N([][Symbol.iterator]())) : e, "%JSON%": typeof JSON == "object" ? JSON : e, "%Map%": typeof Map > "u" ? e : Map, - "%MapIteratorPrototype%": typeof Map > "u" || !D || !_ ? e : _((/* @__PURE__ */ new Map())[Symbol.iterator]()), + "%MapIteratorPrototype%": typeof Map > "u" || !D || !N ? e : N((/* @__PURE__ */ new Map())[Symbol.iterator]()), "%Math%": Math, "%Number%": Number, "%Object%": t, @@ -65280,10 +65280,10 @@ html body { "%Reflect%": typeof Reflect > "u" ? e : Reflect, "%RegExp%": RegExp, "%Set%": typeof Set > "u" ? e : Set, - "%SetIteratorPrototype%": typeof Set > "u" || !D || !_ ? e : _((/* @__PURE__ */ new Set())[Symbol.iterator]()), + "%SetIteratorPrototype%": typeof Set > "u" || !D || !N ? e : N((/* @__PURE__ */ new Set())[Symbol.iterator]()), "%SharedArrayBuffer%": typeof SharedArrayBuffer > "u" ? e : SharedArrayBuffer, "%String%": String, - "%StringIteratorPrototype%": D && _ ? _(""[Symbol.iterator]()) : e, + "%StringIteratorPrototype%": D && N ? N(""[Symbol.iterator]()) : e, "%Symbol%": D ? Symbol : e, "%SyntaxError%": c, "%ThrowTypeError%": I, @@ -65310,11 +65310,11 @@ html body { "%Math.sign%": S, "%Reflect.getPrototypeOf%": T }; - if (_) + if (N) try { null.error; } catch (ie) { - var U = _(_(ie)); + var U = N(N(ie)); F["%Error.prototype%"] = U; } var H = function ie(Y) { @@ -65330,7 +65330,7 @@ html body { te && (q = te.prototype); } else if (Y === "%AsyncIteratorPrototype%") { var de = ie("%AsyncGenerator%"); - de && _ && (q = _(de.prototype)); + de && N && (q = N(de.prototype)); } return F[Y] = q, q; }, V = { @@ -65753,7 +65753,7 @@ html body { return c(v(V, { plainObjects: O }), V.length - 1); } return V; - }, _ = function(T, A) { + }, N = function(T, A) { if (a(T)) { for (var z = [], O = 0; O < T.length; O += 1) z[z.length] = A(T[O]); @@ -65772,7 +65772,7 @@ html body { isOverflow: u, isRegExp: M, markOverflow: c, - maybeMap: _, + maybeMap: N, merge: b }, jC; } @@ -65819,7 +65819,7 @@ html body { strictNullHandling: !1 }, g = function(C) { return typeof C == "string" || typeof C == "number" || typeof C == "boolean" || typeof C == "symbol" || typeof C == "bigint"; - }, v = {}, y = function S(C, E, R, $, M, I, D, _, P, T, A, z, O, L, F, U, H, V, ee, X) { + }, v = {}, y = function S(C, E, R, $, M, I, D, N, P, T, A, z, O, L, F, U, H, V, ee, X) { var K = C; if (X > ee) throw new RangeError("Input depth exceeded depth option of " + ee); @@ -65860,13 +65860,13 @@ html body { var le = Object.keys(K); re = A ? le.sort(A) : le; } - var ie = _ ? String(E).replace(/\./g, "%2E") : String(E), Y = $ && s(K) && K.length === 1 ? ie + "[]" : ie; + var ie = N ? String(E).replace(/\./g, "%2E") : String(E), Y = $ && s(K) && K.length === 1 ? ie + "[]" : ie; if (M && s(K) && K.length === 0 && Object.keys(K).length === 0) return Y + "[]"; for (var q = 0; q < re.length; ++q) { var te = re[q], de = typeof te == "object" && te && typeof te.value < "u" ? te.value : K[te]; if (!(D && de === null)) { - var fe = z && _ ? String(te).replace(/\./g, "%2E") : String(te), pe = s(K) ? typeof R == "function" ? R(Y, fe) : Y : Y + (z ? "." + fe : "[" + fe + "]"); + var fe = z && N ? String(te).replace(/\./g, "%2E") : String(te), pe = s(K) ? typeof R == "function" ? R(Y, fe) : Y : Y + (z ? "." + fe : "[" + fe + "]"); V.set(C, W); var oe = e(); oe.set(v, V), u(J, S( @@ -65877,7 +65877,7 @@ html body { M, I, D, - _, + N, R === "comma" && U && s(K) ? null : P, T, A, @@ -65947,7 +65947,7 @@ html body { var I = []; if (typeof E != "object" || E === null) return ""; - var D = a[R.arrayFormat], _ = D === "comma" && R.commaRoundTrip; + var D = a[R.arrayFormat], N = D === "comma" && R.commaRoundTrip; $ || ($ = Object.keys(E)), R.sort && $.sort(R.sort); for (var P = e(), T = 0; T < $.length; ++T) { var A = $[T]; @@ -65959,7 +65959,7 @@ html body { z, O, D, - _, + N, R.allowEmptyArrays, R.strictNullHandling, R.skipNulls, @@ -66043,19 +66043,19 @@ html body { $[I].indexOf("utf8=") === 0 && ($[I] === u ? D = "utf-8" : $[I] === c && (D = "iso-8859-1"), M = I, I = $.length); for (I = 0; I < $.length; ++I) if (I !== M) { - var _ = $[I], P = _.indexOf("]="), T = P === -1 ? _.indexOf("=") : P + 1, A, z; - if (T === -1 ? (A = S.decoder(_, r.decoder, D, "key"), z = S.strictNullHandling ? null : "") : (A = S.decoder(_.slice(0, T), r.decoder, D, "key"), A !== null && (z = e.maybeMap( + var N = $[I], P = N.indexOf("]="), T = P === -1 ? N.indexOf("=") : P + 1, A, z; + if (T === -1 ? (A = S.decoder(N, r.decoder, D, "key"), z = S.strictNullHandling ? null : "") : (A = S.decoder(N.slice(0, T), r.decoder, D, "key"), A !== null && (z = e.maybeMap( s( - _.slice(T + 1), + N.slice(T + 1), S, n(C[A]) ? C[A].length : 0 ), function(L) { return S.decoder(L, r.decoder, D, "value"); } - ))), z && S.interpretNumericEntities && D === "iso-8859-1" && (z = a(String(z))), _.indexOf("[]=") > -1 && (z = n(z) ? [z] : z), S.comma && n(z) && z.length > S.arrayLimit && (z = e.combine([], z, S.arrayLimit, S.plainObjects, S.throwOnLimitExceeded)), A !== null) { + ))), z && S.interpretNumericEntities && D === "iso-8859-1" && (z = a(String(z))), N.indexOf("[]=") > -1 && (z = n(z) ? [z] : z), S.comma && n(z) && z.length > S.arrayLimit && (z = e.combine([], z, S.arrayLimit, S.plainObjects, S.throwOnLimitExceeded)), A !== null) { var O = t.call(C, A); - O && (S.duplicates === "combine" || _.indexOf("[]=") > -1) ? C[A] = e.combine( + O && (S.duplicates === "combine" || N.indexOf("[]=") > -1) ? C[A] = e.combine( C[A], z, S.arrayLimit, @@ -66083,7 +66083,7 @@ html body { ); else { I = S.plainObjects ? { __proto__: null } : {}; - var _ = D.charAt(0) === "[" && D.charAt(D.length - 1) === "]" ? D.slice(1, -1) : D, P = S.decodeDotInKeys ? _.replace(/%2E/g, ".") : _, T = parseInt(P, 10), A = !isNaN(T) && D !== P && String(T) === P && T >= 0 && S.parseArrays; + var N = D.charAt(0) === "[" && D.charAt(D.length - 1) === "]" ? D.slice(1, -1) : D, P = S.decodeDotInKeys ? N.replace(/%2E/g, ".") : N, T = parseInt(P, 10), A = !isNaN(T) && D !== P && String(T) === P && T >= 0 && S.parseArrays; if (!S.parseArrays && P === "") I = { 0: $ }; else if (A && T < S.arrayLimit) @@ -66108,9 +66108,9 @@ html body { E[E.length] = $; } for (var M = C.length, I = R, D = 0; I >= 0 && D < S.depth; ) { - for (var _ = 1, P = I + 1, T = -1; P < M && T < 0; ) { + for (var N = 1, P = I + 1, T = -1; P < M && T < 0; ) { var A = C.charCodeAt(P); - A === 91 ? _ += 1 : A === 93 && (_ -= 1, _ === 0 && (T = P)), P += 1; + A === 91 ? N += 1 : A === 93 && (N -= 1, N === 0 && (T = P)), P += 1; } if (T < 0) return E[E.length] = "[" + C.slice(I) + "]", E; @@ -66807,19 +66807,19 @@ html body { let L = this._config.newline; L || (O = this._config.quoteChar || '"', L = this._handle.guessLineEndings(T, O)), T = [...T.split(L).slice(z)].join(L); } - this.isFirstChunk && _(this._config.beforeFirstChunk) && (O = this._config.beforeFirstChunk(T)) !== void 0 && (T = O), this.isFirstChunk = !1, this._halted = !1; + this.isFirstChunk && N(this._config.beforeFirstChunk) && (O = this._config.beforeFirstChunk(T)) !== void 0 && (T = O), this.isFirstChunk = !1, this._halted = !1; var z = this._partialLine + T, O = (this._partialLine = "", this._handle.parse(z, this._baseIndex, !this._finished)); if (!this._handle.paused() && !this._handle.aborted()) { if (T = O.meta.cursor, z = (this._finished || (this._partialLine = z.substring(T - this._baseIndex), this._baseIndex = T), O && O.data && (this._rowCount += O.data.length), this._finished || this._config.preview && this._rowCount >= this._config.preview), s) r.postMessage({ results: O, workerId: f.WORKER_ID, finished: z }); - else if (_(this._config.chunk) && !A) { + else if (N(this._config.chunk) && !A) { if (this._config.chunk(O, this._handle), this._handle.paused() || this._handle.aborted()) return void (this._halted = !0); this._completeResults = O = void 0; } - return this._config.step || this._config.chunk || (this._completeResults.data = this._completeResults.data.concat(O.data), this._completeResults.errors = this._completeResults.errors.concat(O.errors), this._completeResults.meta = O.meta), this._completed || !z || !_(this._config.complete) || O && O.meta.aborted || (this._config.complete(this._completeResults, this._input), this._completed = !0), z || O && O.meta.paused || this._nextChunk(), O; + return this._config.step || this._config.chunk || (this._completeResults.data = this._completeResults.data.concat(O.data), this._completeResults.errors = this._completeResults.errors.concat(O.errors), this._completeResults.meta = O.meta), this._completed || !z || !N(this._config.complete) || O && O.meta.aborted || (this._config.complete(this._completeResults, this._input), this._completed = !0), z || O && O.meta.paused || this._nextChunk(), O; } this._halted = !0; }, this._sendError = function(T) { - _(this._config.error) ? this._config.error(T) : s && this._config.error && r.postMessage({ workerId: f.WORKER_ID, error: T, finished: !1 }); + N(this._config.error) ? this._config.error(T) : s && this._config.error && r.postMessage({ workerId: f.WORKER_ID, error: T, finished: !1 }); }; } function g(P) { @@ -66943,10 +66943,10 @@ html body { function re(le, ie, Y, q) { le = { type: le, code: ie, message: Y }, q !== void 0 && (le.row = q), Q.errors.push(le); } - _(P.step) && (O = P.step, P.step = function(le) { + N(P.step) && (O = P.step, P.step = function(le) { Q = le, J() ? B() : (B(), Q.data.length !== 0 && (ee += le.data.length, P.preview && ee > P.preview ? A.abort() : (Q.data = Q.data[0], O(Q, V)))); }), this.parse = function(le, ie, Y) { - var q = P.quoteChar || '"', q = (P.newline || (P.newline = this.guessLineEndings(le, q)), z = !1, P.delimiter ? _(P.delimiter) && (P.delimiter = P.delimiter(le), Q.meta.delimiter = P.delimiter) : ((q = ((te, de, fe, pe, oe) => { + var q = P.quoteChar || '"', q = (P.newline || (P.newline = this.guessLineEndings(le, q)), z = !1, P.delimiter ? N(P.delimiter) && (P.delimiter = P.delimiter(le), Q.meta.delimiter = P.delimiter) : ((q = ((te, de, fe, pe, oe) => { var ce, Z, me, xe; oe = oe || [",", " ", "|", ";", f.RECORD_SEP, f.UNIT_SEP]; for (var Me = 0; Me < oe.length; Me++) { @@ -66959,13 +66959,13 @@ html body { }, this.paused = function() { return K; }, this.pause = function() { - K = !0, A.abort(), T = _(P.chunk) ? "" : T.substring(A.getCharIndex()); + K = !0, A.abort(), T = N(P.chunk) ? "" : T.substring(A.getCharIndex()); }, this.resume = function() { V.streamer._halted ? (K = !1, V.streamer.parseChunk(T, !0)) : setTimeout(V.resume, 3); }, this.aborted = function() { return G; }, this.abort = function() { - G = !0, A.abort(), Q.meta.aborted = !0, _(P.complete) && P.complete(Q), T = ""; + G = !0, A.abort(), Q.meta.aborted = !0, N(P.complete) && P.complete(Q), T = ""; }, this.guessLineEndings = function(te, q) { te = te.substring(0, 1048576); var q = new RegExp(C(q) + "([^]*?)" + C(q), "gm"), Y = (te = te.replace(q, "")).split("\r"), q = te.split(` @@ -66991,7 +66991,7 @@ html body { var X = 0, K = !1; this.parse = function(G, W, Q) { if (typeof G != "string") throw new Error("Input must be a string"); - var k = G.length, B = T.length, J = A.length, re = z.length, le = _(O), ie = [], Y = [], q = [], te = X = 0; + var k = G.length, B = T.length, J = A.length, re = z.length, le = N(O), ie = [], Y = [], q = [], te = X = 0; if (!G) return ke(); if (F || F !== !1 && G.indexOf(V) === -1) { for (var de = G.split(A), fe = 0; fe < de.length; fe++) { @@ -67054,7 +67054,7 @@ html body { let it = !1; for (let ze = 0; ze < Se.length; ze++) { let Oe = m(Se[ze]); - if (et[Oe = _(P.transformHeader) ? P.transformHeader(Oe, ze) : Oe]) { + if (et[Oe = N(P.transformHeader) ? P.transformHeader(Oe, ze) : Oe]) { let Ge, Ue = et[Oe]; for (; Ge = Oe + "_" + Ue, Ue++, We.has(Ge); ) ; We.add(Ge), Se[ze] = Ge, et[Oe]++, it = !0, (U = U === null ? {} : U)[Ge] = Oe; @@ -67081,16 +67081,16 @@ html body { var O = { abort: function() { z = !0, $(T.workerId, { data: [], errors: [], meta: { aborted: !0 } }); }, pause: M, resume: M }; - if (_(A.userStep)) { + if (N(A.userStep)) { for (var L = 0; L < T.results.data.length && (A.userStep({ data: T.results.data[L], errors: T.results.errors, meta: T.results.meta }, O), !z); L++) ; delete T.results; - } else _(A.userChunk) && (A.userChunk(T.results, O, T.file), delete T.results); + } else N(A.userChunk) && (A.userChunk(T.results, O, T.file), delete T.results); } T.finished && !z && $(T.workerId, T.results); } function $(P, T) { var A = c[P]; - _(A.userComplete) && A.userComplete(T), A.terminate(), delete c[P]; + N(A.userComplete) && A.userComplete(T), A.terminate(), delete c[P]; } function M() { throw new Error("Not implemented."); @@ -67106,24 +67106,24 @@ html body { P.apply(T, arguments); }; } - function _(P) { + function N(P) { return typeof P == "function"; } return f.parse = function(P, T) { var A = (T = T || {}).dynamicTyping || !1; - if (_(A) && (T.dynamicTypingFunction = A, A = {}), T.dynamicTyping = A, T.transform = !!_(T.transform) && T.transform, T.downloadTimeout !== void 0) { + if (N(A) && (T.dynamicTypingFunction = A, A = {}), T.dynamicTyping = A, T.transform = !!N(T.transform) && T.transform, T.downloadTimeout !== void 0) { var A = parseInt(T.downloadTimeout); if (isNaN(A)) throw new Error("Config downloadTimeout value (" + T.downloadTimeout + ") not parsable by parseInt(val)."); T.downloadTimeout = A; } - if (!T.worker || !f.WORKERS_SUPPORTED) return A = null, f.NODE_STREAM_INPUT, typeof P == "string" ? (P = m(P), A = new (T.download ? g : y)(T)) : P.readable === !0 && _(P.read) && _(P.on) ? A = new b(T) : (r.File && P instanceof File || P instanceof Object) && (A = new v(T)), A.stream(P); + if (!T.worker || !f.WORKERS_SUPPORTED) return A = null, f.NODE_STREAM_INPUT, typeof P == "string" ? (P = m(P), A = new (T.download ? g : y)(T)) : P.readable === !0 && N(P.read) && N(P.on) ? A = new b(T) : (r.File && P instanceof File || P instanceof Object) && (A = new v(T)), A.stream(P); (A = (() => { var z; return !!f.WORKERS_SUPPORTED && (z = (() => { var O = r.URL || r.webkitURL || null, L = n.toString(); return f.BLOB_URL || (f.BLOB_URL = O.createObjectURL(new Blob(["var global = (function() { if (typeof self !== 'undefined') { return self; } if (typeof window !== 'undefined') { return window; } if (typeof global !== 'undefined') { return global; } return {}; })(); global.IS_PAPA_WORKER=true; ", "(", L, ")();"], { type: "text/javascript" }))); })(), (z = new r.Worker(z)).onmessage = R, z.id = u++, c[z.id] = z); - })()).userStep = T.step, A.userChunk = T.chunk, A.userComplete = T.complete, A.userError = T.error, T.step = _(T.step), T.chunk = _(T.chunk), T.complete = _(T.complete), T.error = _(T.error), delete T.worker, A.postMessage({ input: P, config: T, workerId: A.id }); + })()).userStep = T.step, A.userChunk = T.chunk, A.userComplete = T.complete, A.userError = T.error, T.step = N(T.step), T.chunk = N(T.chunk), T.complete = N(T.complete), T.error = N(T.error), delete T.worker, A.postMessage({ input: P, config: T, workerId: A.id }); }, f.unparse = function(P, T) { var A = !1, z = !0, O = ",", L = `\r `, F = '"', U = F + F, H = !1, V = null, ee = !1, X = ((() => { @@ -67423,14 +67423,14 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid text: typeof ((R = e?.title) == null ? void 0 : R.text) > "u" ? Vr.title.text : ($ = e?.title) == null ? void 0 : $.text }, disableRouteChangeHandler: e?.disableRouteChangeHandler ?? m ?? Vr.disableRouteChangeHandler - }, D = e?.disableTelemetry ?? t ?? !1, _ = { + }, D = e?.disableTelemetry ?? t ?? !1, N = { clientConfig: ((M = e?.reactQuery) == null ? void 0 : M.clientConfig) ?? a ?? {}, devtoolConfig: s ?? {} }; return { optionsWithDefaults: I, disableTelemetryWithDefault: D, - reactQueryWithDefaults: _ + reactQueryWithDefaults: N }; }, "handleRefineOptions"), xi = /* @__PURE__ */ De((e, t = []) => { if (!e) @@ -68032,13 +68032,13 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid var y, b, S; const { resources: C, resource: E, identifier: R } = Bo({ resource: e - }), $ = F3(), M = Is(), { mutate: I } = g1(), D = U3(), _ = x1(), { keys: P } = ao(), T = Si( + }), $ = F3(), M = Is(), { mutate: I } = g1(), D = U3(), N = x1(), { keys: P } = ao(), T = Si( R, g, C ), A = u, z = t, O = r, L = Cfe({ pagination: n - }), F = L.mode === "server", U = _({ resource: E, meta: A }), H = { + }), F = L.mode === "server", U = N({ resource: E, meta: A }), H = { meta: U, filters: z, hasPagination: F, @@ -68170,13 +68170,13 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid id: M, mutationMode: I, undoableTimeout: D, - resource: _, + resource: N, onCancel: P, meta: T, dataProviderName: A, values: z }) => { - const { resource: O, identifier: L } = s(_), F = S({ + const { resource: O, identifier: L } = s(N), F = S({ resource: O, meta: T }), U = I ?? u, H = D ?? f; @@ -68219,10 +68219,10 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid id: M, resource: I, mutationMode: D, - dataProviderName: _, + dataProviderName: N, meta: P }) => { - const { identifier: T } = s(I), { gqlMutation: A, gqlQuery: z, ...O } = P ?? {}, L = E().data(Si(T, _, a)).resource(T), F = D ?? u; + const { identifier: T } = s(I), { gqlMutation: A, gqlQuery: z, ...O } = P ?? {}, L = E().data(Si(T, N, a)).resource(T), F = D ?? u; await c.cancelQueries({ queryKey: L.get() }); @@ -68265,11 +68265,11 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid }, onSettled: (M, I, { id: D, - resource: _, + resource: N, dataProviderName: P, invalidates: T = ["list", "many"] }) => { - const { identifier: A } = s(_); + const { identifier: A } = s(N); b({ resource: A, dataProviderName: Si( @@ -68286,7 +68286,7 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid onSuccess: (M, { id: I, resource: D, - successNotification: _, + successNotification: N, dataProviderName: P, meta: T }, A) => { @@ -68301,7 +68301,7 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid c.removeQueries({ queryKey: H.action("one").get() }); - const V = typeof _ == "function" ? _(M, I, O) : _; + const V = typeof N == "function" ? N(M, I, O) : N; y(V, { key: `${I}-${O}-notification`, description: p("notifications.success", "Success"), @@ -68346,14 +68346,14 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid queryKey: H.action("one").get() }); }, - onError: (M, { id: I, resource: D, errorNotification: _ }, P) => { + onError: (M, { id: I, resource: D, errorNotification: N }, P) => { const { identifier: T } = s(D); if (P) for (const A of P.previousQueries) c.setQueryData(A[0], A[1]); if (M.message !== "mutationCancelled") { n(M); - const A = C.singular(T), z = typeof _ == "function" ? _(M, I, T) : _; + const A = C.singular(T), z = typeof N == "function" ? N(M, I, T) : N; y(z, { key: `${I}-${T}-notification`, message: p( @@ -68954,7 +68954,7 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid overtimeOptions: y } = {}) { var b, S, C, E, R, $, M, I, D; - const { syncWithLocation: _ } = Efe(), P = r ?? _, T = Wfe(f), A = x1(), z = Ja(), O = (t?.mode || "server") === "server", L = (n?.mode || "server") === "server", F = e?.mode !== "off", U = e?.currentPage, H = e?.pageSize, V = g, { parsedCurrentPage: ee, parsedPageSize: X, parsedSorter: K, parsedFilters: G } = ufe(((b = z.params) == null ? void 0 : b.search) ?? "?"), W = t?.initial, Q = t?.permanent ?? fme, k = n?.initial, B = n?.permanent ?? mme, J = t?.defaultBehavior ?? "merge"; + const { syncWithLocation: N } = Efe(), P = r ?? N, T = Wfe(f), A = x1(), z = Ja(), O = (t?.mode || "server") === "server", L = (n?.mode || "server") === "server", F = e?.mode !== "off", U = e?.currentPage, H = e?.pageSize, V = g, { parsedCurrentPage: ee, parsedPageSize: X, parsedSorter: K, parsedFilters: G } = ufe(((b = z.params) == null ? void 0 : b.search) ?? "?"), W = t?.initial, Q = t?.permanent ?? fme, k = n?.initial, B = n?.permanent ?? mme, J = t?.defaultBehavior ?? "merge"; let re, le, ie, Y; P ? (re = ((S = z?.params) == null ? void 0 : S.currentPage) || ee || U || 1, le = ((C = z?.params) == null ? void 0 : C.pageSize) || X || H || 10, ie = ((E = z?.params) == null ? void 0 : E.sorters) || (K.length ? K : k), Y = ((R = z?.params) == null ? void 0 : R.filters) || (G.length ? G : W)) : (re = U || 1, le = H || 10, ie = k, Y = W); const q = Ra(), { resource: te, identifier: de } = Bo({ @@ -73055,7 +73055,7 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid } }; function uhe({ locale: e, onError: t, onMessage: n }) { - const r = che[e], a = Iu(), s = bs({ resource: "roles", pagination: { mode: "off" }, queryOptions: { retry: !1 } }), c = bs({ resource: "users", pagination: { mode: "off" }, queryOptions: { retry: !1 } }), u = s.result.data, f = c.result.data, [m, p] = l.useState(), [g, v] = l.useState(), [y, b] = l.useState(!1), [S, C] = l.useState(), [E, R] = l.useState([]), [$, M] = l.useState(!1), [I, D] = l.useState(), [_] = we.useForm(), [P] = we.useForm(), [T] = we.useForm(), A = s.query.error ?? c.query.error; + const r = che[e], a = Iu(), s = bs({ resource: "roles", pagination: { mode: "off" }, queryOptions: { retry: !1 } }), c = bs({ resource: "users", pagination: { mode: "off" }, queryOptions: { retry: !1 } }), u = s.result.data, f = c.result.data, [m, p] = l.useState(), [g, v] = l.useState(), [y, b] = l.useState(!1), [S, C] = l.useState(), [E, R] = l.useState([]), [$, M] = l.useState(!1), [I, D] = l.useState(), [N] = we.useForm(), [P] = we.useForm(), [T] = we.useForm(), A = s.query.error ?? c.query.error; l.useEffect(() => { A && t(A); }, [A]); @@ -73067,7 +73067,7 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid m?.user.id === k && (p(void 0), v(void 0)); }, U = async (k) => { try { - await ls.createRole(k), _.resetFields(), n(r.createdRole(k.name)), await z(tl.roles); + await ls.createRole(k), N.resetFields(), n(r.createdRole(k.name)), await z(tl.roles); } catch (B) { t(B); } @@ -73131,26 +73131,26 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid M(!1); } }; - return /* @__PURE__ */ N.jsxs(lt, { direction: "vertical", size: "large", className: "panel-stack", children: [ - m && /* @__PURE__ */ N.jsx( + return /* @__PURE__ */ _.jsxs(lt, { direction: "vertical", size: "large", className: "panel-stack", children: [ + m && /* @__PURE__ */ _.jsx( Lt, { type: "warning", showIcon: !0, message: r.oneTimeLink, - description: /* @__PURE__ */ N.jsxs(lt, { direction: "vertical", className: "panel-stack", children: [ - /* @__PURE__ */ N.jsx(_t.Text, { children: r.bearerDescription(m.user.email) }), - /* @__PURE__ */ N.jsx( + description: /* @__PURE__ */ _.jsxs(lt, { direction: "vertical", className: "panel-stack", children: [ + /* @__PURE__ */ _.jsx(_t.Text, { children: r.bearerDescription(m.user.email) }), + /* @__PURE__ */ _.jsx( dt, { value: m.set_password_url, readOnly: !0, - addonAfter: /* @__PURE__ */ N.jsx(Ve, { type: "link", onClick: () => { + addonAfter: /* @__PURE__ */ _.jsx(Ve, { type: "link", onClick: () => { navigator.clipboard.writeText(m.set_password_url); }, children: r.copy }) } ), - g && /* @__PURE__ */ N.jsx( + g && /* @__PURE__ */ _.jsx( Lt, { showIcon: !0, @@ -73159,8 +73159,8 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid description: g.status === "unknown" ? r.unknownDescription : g.error_message || r.durableAt(g.completed_at ?? g.created_at) } ), - /* @__PURE__ */ N.jsxs(lt, { wrap: !0, children: [ - /* @__PURE__ */ N.jsx( + /* @__PURE__ */ _.jsxs(lt, { wrap: !0, children: [ + /* @__PURE__ */ _.jsx( Lr, { title: r.sendConfirm, @@ -73168,7 +73168,7 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid onConfirm: () => { W(); }, - children: /* @__PURE__ */ N.jsx( + children: /* @__PURE__ */ _.jsx( Ve, { type: "primary", @@ -73179,35 +73179,35 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid ) } ), - /* @__PURE__ */ N.jsx(Ve, { size: "small", onClick: () => { + /* @__PURE__ */ _.jsx(Ve, { size: "small", onClick: () => { p(void 0), v(void 0); }, children: r.dismiss }) ] }) ] }) } ), - /* @__PURE__ */ N.jsx(Ot, { title: r.newRole, children: /* @__PURE__ */ N.jsxs(we, { form: _, layout: "vertical", onFinish: (k) => { + /* @__PURE__ */ _.jsx(Ot, { title: r.newRole, children: /* @__PURE__ */ _.jsxs(we, { form: N, layout: "vertical", onFinish: (k) => { U(k); }, children: [ - /* @__PURE__ */ N.jsxs("div", { className: "form-grid", children: [ - /* @__PURE__ */ N.jsx(we.Item, { name: "name", label: r.roleName, rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(dt, {}) }), - /* @__PURE__ */ N.jsx(we.Item, { name: "capabilities", label: r.capabilities, rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(kt, { mode: "multiple", options: lhe.map((k) => ({ value: k, label: k })) }) }) + /* @__PURE__ */ _.jsxs("div", { className: "form-grid", children: [ + /* @__PURE__ */ _.jsx(we.Item, { name: "name", label: r.roleName, rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(dt, {}) }), + /* @__PURE__ */ _.jsx(we.Item, { name: "capabilities", label: r.capabilities, rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(kt, { mode: "multiple", options: lhe.map((k) => ({ value: k, label: k })) }) }) ] }), - /* @__PURE__ */ N.jsx(Ve, { type: "primary", htmlType: "submit", children: r.createRole }) + /* @__PURE__ */ _.jsx(Ve, { type: "primary", htmlType: "submit", children: r.createRole }) ] }) }), - /* @__PURE__ */ N.jsx(Ot, { title: r.newUser, children: /* @__PURE__ */ N.jsxs(we, { form: P, layout: "vertical", onFinish: (k) => { + /* @__PURE__ */ _.jsx(Ot, { title: r.newUser, children: /* @__PURE__ */ _.jsxs(we, { form: P, layout: "vertical", onFinish: (k) => { H(k); }, children: [ - /* @__PURE__ */ N.jsxs("div", { className: "form-grid three-columns", children: [ - /* @__PURE__ */ N.jsx(we.Item, { name: "email", label: r.email, rules: [{ required: !0, type: "email" }], children: /* @__PURE__ */ N.jsx(dt, {}) }), - /* @__PURE__ */ N.jsx(we.Item, { name: "display_name", label: r.displayName, rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(dt, {}) }), - /* @__PURE__ */ N.jsx(we.Item, { name: "role_id", label: r.role, rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(kt, { options: u.filter((k) => k.status === "active").map((k) => ({ value: k.id, label: k.name })) }) }) + /* @__PURE__ */ _.jsxs("div", { className: "form-grid three-columns", children: [ + /* @__PURE__ */ _.jsx(we.Item, { name: "email", label: r.email, rules: [{ required: !0, type: "email" }], children: /* @__PURE__ */ _.jsx(dt, {}) }), + /* @__PURE__ */ _.jsx(we.Item, { name: "display_name", label: r.displayName, rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(dt, {}) }), + /* @__PURE__ */ _.jsx(we.Item, { name: "role_id", label: r.role, rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(kt, { options: u.filter((k) => k.status === "active").map((k) => ({ value: k.id, label: k.name })) }) }) ] }), - /* @__PURE__ */ N.jsx(Ve, { type: "primary", htmlType: "submit", children: r.createUser }) + /* @__PURE__ */ _.jsx(Ve, { type: "primary", htmlType: "submit", children: r.createUser }) ] }) }), - /* @__PURE__ */ N.jsx(Ot, { title: r.usersRoles, extra: /* @__PURE__ */ N.jsx(Ve, { onClick: () => { + /* @__PURE__ */ _.jsx(Ot, { title: r.usersRoles, extra: /* @__PURE__ */ _.jsx(Ve, { onClick: () => { O(); - }, children: r.refresh }), children: /* @__PURE__ */ N.jsx( + }, children: r.refresh }), children: /* @__PURE__ */ _.jsx( In, { rowKey: "id", @@ -73217,24 +73217,24 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid expandable: { expandedRowRender: (k) => { const B = u.find((J) => J.id === k.role_id); - return /* @__PURE__ */ N.jsx(lt, { wrap: !0, children: B?.capabilities.map((J) => /* @__PURE__ */ N.jsx(Cn, { children: J }, J)) }); + return /* @__PURE__ */ _.jsx(lt, { wrap: !0, children: B?.capabilities.map((J) => /* @__PURE__ */ _.jsx(Cn, { children: J }, J)) }); } }, columns: [ { title: r.email, dataIndex: "email" }, { title: r.displayName, dataIndex: "display_name" }, { title: r.role, render: (k, B) => u.find((J) => J.id === B.role_id)?.name ?? B.role_id }, - { title: r.status, render: (k, B) => /* @__PURE__ */ N.jsx(Cn, { color: B.status === "active" ? "green" : "default", children: B.status === "active" ? r.active : r.disabledStatus }) }, + { title: r.status, render: (k, B) => /* @__PURE__ */ _.jsx(Cn, { color: B.status === "active" ? "green" : "default", children: B.status === "active" ? r.active : r.disabledStatus }) }, { title: r.authRevision, dataIndex: "auth_revision" }, { title: r.actions, - render: (k, B) => /* @__PURE__ */ N.jsxs(lt, { wrap: !0, children: [ - /* @__PURE__ */ N.jsx(Ve, { size: "small", onClick: () => V(B), children: r.changeRole }), - /* @__PURE__ */ N.jsx(Ve, { size: "small", onClick: () => { + render: (k, B) => /* @__PURE__ */ _.jsxs(lt, { wrap: !0, children: [ + /* @__PURE__ */ _.jsx(Ve, { size: "small", onClick: () => V(B), children: r.changeRole }), + /* @__PURE__ */ _.jsx(Ve, { size: "small", onClick: () => { Q(B); }, children: r.history }), - B.status === "active" ? /* @__PURE__ */ N.jsxs(N.Fragment, { children: [ - /* @__PURE__ */ N.jsx( + B.status === "active" ? /* @__PURE__ */ _.jsxs(_.Fragment, { children: [ + /* @__PURE__ */ _.jsx( Lr, { title: r.newPasswordConfirm, @@ -73242,13 +73242,13 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid onConfirm: () => { X(B); }, - children: /* @__PURE__ */ N.jsx(Ve, { size: "small", children: r.newPasswordLink }) + children: /* @__PURE__ */ _.jsx(Ve, { size: "small", children: r.newPasswordLink }) } ), - /* @__PURE__ */ N.jsx(Lr, { title: r.disableConfirm, onConfirm: () => { + /* @__PURE__ */ _.jsx(Lr, { title: r.disableConfirm, onConfirm: () => { G(B); - }, children: /* @__PURE__ */ N.jsx(Ve, { size: "small", danger: !0, children: r.disable }) }) - ] }) : /* @__PURE__ */ N.jsx( + }, children: /* @__PURE__ */ _.jsx(Ve, { size: "small", danger: !0, children: r.disable }) }) + ] }) : /* @__PURE__ */ _.jsx( Lr, { title: r.reactivateConfirm, @@ -73256,7 +73256,7 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid onConfirm: () => { K(B); }, - children: /* @__PURE__ */ N.jsx(Ve, { size: "small", type: "primary", children: r.reactivate }) + children: /* @__PURE__ */ _.jsx(Ve, { size: "small", type: "primary", children: r.reactivate }) } ) ] }) @@ -73264,7 +73264,7 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid ] } ) }), - /* @__PURE__ */ N.jsxs( + /* @__PURE__ */ _.jsxs( $r, { open: S !== void 0, @@ -73276,7 +73276,7 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid }, destroyOnHidden: !0, children: [ - /* @__PURE__ */ N.jsx( + /* @__PURE__ */ _.jsx( Lt, { className: "bottom-gap", @@ -73286,7 +73286,7 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid description: r.evidenceDescription } ), - /* @__PURE__ */ N.jsx( + /* @__PURE__ */ _.jsx( In, { rowKey: "id", @@ -73299,7 +73299,7 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid { title: r.recipient, dataIndex: "recipient_email" }, { title: r.status, - render: (k, B) => /* @__PURE__ */ N.jsx(Cn, { color: B.status === "accepted" ? "green" : B.status === "failed" ? "red" : B.status === "unknown" ? "orange" : "blue", children: B.status === "accepted" ? r.acceptedTitle : B.status === "failed" ? r.failedTitle : B.status === "unknown" ? r.unknownTitle : r.inProgress }) + render: (k, B) => /* @__PURE__ */ _.jsx(Cn, { color: B.status === "accepted" ? "green" : B.status === "failed" ? "red" : B.status === "unknown" ? "orange" : "blue", children: B.status === "accepted" ? r.acceptedTitle : B.status === "failed" ? r.failedTitle : B.status === "unknown" ? r.unknownTitle : r.inProgress }) }, { title: r.result, render: (k, B) => B.error_message || (B.completed_at ? r.completed : r.inProgress) } ] @@ -73308,7 +73308,7 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid ] } ), - /* @__PURE__ */ N.jsxs( + /* @__PURE__ */ _.jsxs( $r, { open: I !== void 0, @@ -73320,7 +73320,7 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid }, destroyOnHidden: !0, children: [ - /* @__PURE__ */ N.jsx( + /* @__PURE__ */ _.jsx( Lt, { className: "bottom-gap", @@ -73330,9 +73330,9 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid description: r.accessDescription } ), - /* @__PURE__ */ N.jsx(we, { form: T, layout: "vertical", onFinish: (k) => { + /* @__PURE__ */ _.jsx(we, { form: T, layout: "vertical", onFinish: (k) => { ee(k); - }, children: /* @__PURE__ */ N.jsx(we.Item, { name: "role_id", label: r.role, rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(kt, { options: u.filter((k) => k.status === "active").map((k) => ({ value: k.id, label: k.name })) }) }) }) + }, children: /* @__PURE__ */ _.jsx(we.Item, { name: "role_id", label: r.role, rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(kt, { options: u.filter((k) => k.status === "active").map((k) => ({ value: k.id, label: k.name })) }) }) }) ] } ) @@ -73906,7 +73906,7 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid } }; function mhe({ locale: e, onError: t }) { - const n = fhe[e], [r, a] = l.useState([]), [s, c] = l.useState([]), [u, f] = l.useState([]), [m, p] = l.useState(), [g, v] = l.useState(!1), [y, b] = l.useState(), [S, C] = l.useState(!1), [E, R] = l.useState([]), [$, M] = l.useState(""), [I, D] = l.useState(!1), [_, P] = l.useState(), [T, A] = l.useState(""), [z, O] = l.useState([]), [L, F] = l.useState(), [U, H] = l.useState(!1), [V, ee] = l.useState(!1), X = async () => { + const n = fhe[e], [r, a] = l.useState([]), [s, c] = l.useState([]), [u, f] = l.useState([]), [m, p] = l.useState(), [g, v] = l.useState(!1), [y, b] = l.useState(), [S, C] = l.useState(!1), [E, R] = l.useState([]), [$, M] = l.useState(""), [I, D] = l.useState(!1), [N, P] = l.useState(), [T, A] = l.useState(""), [z, O] = l.useState([]), [L, F] = l.useState(), [U, H] = l.useState(!1), [V, ee] = l.useState(!1), X = async () => { ee(!0); try { await w1("/admin/api/exports/rfqs.csv", "prods-rfqs.csv"), ga.success(n.exportDownloaded); @@ -74008,13 +74008,13 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid t(te); } }, Y = async () => { - if (!(!_ || !T)) { + if (!(!N || !T)) { H(!0); try { - await Wt(`/admin/api/rfqs/${encodeURIComponent(_.ID)}/deliveries`, { - expected_revision: _.Revision, + await Wt(`/admin/api/rfqs/${encodeURIComponent(N.ID)}/deliveries`, { + expected_revision: N.Revision, delivery_key: T - }), await le(_), G(), ga.success(n.deliveryQueued), A(ys("mail")); + }), await le(N), G(), ga.success(n.deliveryQueued), A(ys("mail")); } catch (q) { t(q); } finally { @@ -74022,38 +74022,38 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid } } }; - return /* @__PURE__ */ N.jsxs(lt, { direction: "vertical", size: "large", className: "panel-stack", children: [ - /* @__PURE__ */ N.jsx( + return /* @__PURE__ */ _.jsxs(lt, { direction: "vertical", size: "large", className: "panel-stack", children: [ + /* @__PURE__ */ _.jsx( Lt, { message: n.assignmentNotice, type: "info", showIcon: !0, - action: g ? /* @__PURE__ */ N.jsx(Ve, { size: "small", onClick: J, children: n.defaultRecipients }) : void 0 + action: g ? /* @__PURE__ */ _.jsx(Ve, { size: "small", onClick: J, children: n.defaultRecipients }) : void 0 } ), - /* @__PURE__ */ N.jsx( + /* @__PURE__ */ _.jsx( Ot, { title: n.savedRFQs, - extra: /* @__PURE__ */ N.jsxs(lt, { children: [ - /* @__PURE__ */ N.jsx(Ve, { loading: V, onClick: () => { + extra: /* @__PURE__ */ _.jsxs(lt, { children: [ + /* @__PURE__ */ _.jsx(Ve, { loading: V, onClick: () => { X(); }, children: n.exportCSV }), - /* @__PURE__ */ N.jsx(Ve, { onClick: () => { + /* @__PURE__ */ _.jsx(Ve, { onClick: () => { K(); }, children: n.refresh }) ] }), - children: /* @__PURE__ */ N.jsx( + children: /* @__PURE__ */ _.jsx( Jr, { dataSource: r, locale: { emptyText: n.noRFQs }, - renderItem: (q) => /* @__PURE__ */ N.jsx( + renderItem: (q) => /* @__PURE__ */ _.jsx( Jr.Item, { actions: g ? [ - /* @__PURE__ */ N.jsx( + /* @__PURE__ */ _.jsx( kt, { "aria-label": n.updateStatus(q.ID), @@ -74069,11 +74069,11 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid }, "status" ), - /* @__PURE__ */ N.jsx(Ve, { disabled: q.PrivacyState === "anonymized", onClick: () => B(q), children: n.recipients }, "recipients"), - /* @__PURE__ */ N.jsx(Ve, { disabled: !q.Recipients?.length && q.PrivacyState !== "anonymized", onClick: () => { + /* @__PURE__ */ _.jsx(Ve, { disabled: q.PrivacyState === "anonymized", onClick: () => B(q), children: n.recipients }, "recipients"), + /* @__PURE__ */ _.jsx(Ve, { disabled: !q.Recipients?.length && q.PrivacyState !== "anonymized", onClick: () => { ie(q); }, children: q.PrivacyState === "anonymized" ? n.deliveryHistory : n.sendHistory }, "send"), - q.PrivacyState === "anonymized" ? null : /* @__PURE__ */ N.jsx( + q.PrivacyState === "anonymized" ? null : /* @__PURE__ */ _.jsx( Lr, { title: n.anonymizeConfirm, @@ -74081,31 +74081,31 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid okText: n.anonymize, okButtonProps: { danger: !0 }, onConfirm: () => k(q), - children: /* @__PURE__ */ N.jsx(Ve, { danger: !0, loading: L === q.ID, children: n.anonymize }) + children: /* @__PURE__ */ _.jsx(Ve, { danger: !0, loading: L === q.ID, children: n.anonymize }) }, "anonymize" ) ] : void 0, - children: /* @__PURE__ */ N.jsx( + children: /* @__PURE__ */ _.jsx( Jr.Item.Meta, { - title: /* @__PURE__ */ N.jsxs(lt, { children: [ - /* @__PURE__ */ N.jsx("span", { children: `${q.ID} — ${q.Name || n.anonymizedRFQ}` }), - /* @__PURE__ */ N.jsx(Cn, { children: n.status[q.Status] }), - q.PrivacyState === "anonymized" ? /* @__PURE__ */ N.jsx(Cn, { color: "default", children: n.personalDataRemoved }) : null, - /* @__PURE__ */ N.jsxs(_t.Text, { type: "secondary", children: [ + title: /* @__PURE__ */ _.jsxs(lt, { children: [ + /* @__PURE__ */ _.jsx("span", { children: `${q.ID} — ${q.Name || n.anonymizedRFQ}` }), + /* @__PURE__ */ _.jsx(Cn, { children: n.status[q.Status] }), + q.PrivacyState === "anonymized" ? /* @__PURE__ */ _.jsx(Cn, { color: "default", children: n.personalDataRemoved }) : null, + /* @__PURE__ */ _.jsxs(_t.Text, { type: "secondary", children: [ n.revision, " ", q.Revision ] }) ] }), - description: /* @__PURE__ */ N.jsxs(xn, { size: "small", column: { xs: 1, sm: 2 }, children: [ - /* @__PURE__ */ N.jsx(xn.Item, { label: n.email, children: q.Email || n.removed }), - /* @__PURE__ */ N.jsx(xn.Item, { label: n.created, children: new Date(q.CreatedAt).toLocaleString(e) }), - /* @__PURE__ */ N.jsx(xn.Item, { label: n.items, span: 2, children: q.Items?.map((te) => te.product_id || te.requested || te.raw_query).join(", ") }), - /* @__PURE__ */ N.jsx(xn.Item, { label: n.recipients, span: 2, children: q.Recipients?.length ? q.Recipients.map((te) => te.kind === "user" ? `${te.display_name || te.email} (${n.user})` : `${te.email} (${n.external})`).join(", ") : n.unassigned }), - q.GeneralMessage ? /* @__PURE__ */ N.jsx(xn.Item, { label: n.message, span: 2, children: q.GeneralMessage }) : null, - q.PrivacyAt ? /* @__PURE__ */ N.jsx(xn.Item, { label: n.privacyProcessed, span: 2, children: new Date(q.PrivacyAt).toLocaleString(e) }) : null + description: /* @__PURE__ */ _.jsxs(xn, { size: "small", column: { xs: 1, sm: 2 }, children: [ + /* @__PURE__ */ _.jsx(xn.Item, { label: n.email, children: q.Email || n.removed }), + /* @__PURE__ */ _.jsx(xn.Item, { label: n.created, children: new Date(q.CreatedAt).toLocaleString(e) }), + /* @__PURE__ */ _.jsx(xn.Item, { label: n.items, span: 2, children: q.Items?.map((te) => te.product_id || te.requested || te.raw_query).join(", ") }), + /* @__PURE__ */ _.jsx(xn.Item, { label: n.recipients, span: 2, children: q.Recipients?.length ? q.Recipients.map((te) => te.kind === "user" ? `${te.display_name || te.email} (${n.user})` : `${te.email} (${n.external})`).join(", ") : n.unassigned }), + q.GeneralMessage ? /* @__PURE__ */ _.jsx(xn.Item, { label: n.message, span: 2, children: q.GeneralMessage }) : null, + q.PrivacyAt ? /* @__PURE__ */ _.jsx(xn.Item, { label: n.privacyProcessed, span: 2, children: new Date(q.PrivacyAt).toLocaleString(e) }) : null ] }) } ) @@ -74115,9 +74115,9 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid ) } ), - /* @__PURE__ */ N.jsx(Ot, { title: n.adminLog, extra: /* @__PURE__ */ N.jsx(Ve, { onClick: () => { + /* @__PURE__ */ _.jsx(Ot, { title: n.adminLog, extra: /* @__PURE__ */ _.jsx(Ve, { onClick: () => { G(); - }, children: n.refresh }), children: /* @__PURE__ */ N.jsx( + }, children: n.refresh }), children: /* @__PURE__ */ _.jsx( In, { rowKey: "id", @@ -74126,15 +74126,15 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid scroll: { x: 900 }, columns: [ { title: n.time, dataIndex: "created_at", render: (q) => q ? new Date(q).toLocaleString(e) : "—" }, - { title: n.action, dataIndex: "action", render: (q) => /* @__PURE__ */ N.jsx(Cn, { children: q }) }, + { title: n.action, dataIndex: "action", render: (q) => /* @__PURE__ */ _.jsx(Cn, { children: q }) }, { title: n.target, render: (q, te) => `${te.target_type}:${te.target_id}` }, { title: n.actor, dataIndex: "actor_id", render: (q) => q || n.system }, { title: n.result, dataIndex: "result" }, - { title: n.details, dataIndex: "details", render: (q) => /* @__PURE__ */ N.jsx(_t.Text, { code: !0, children: typeof q == "string" ? q : JSON.stringify(q) }) } + { title: n.details, dataIndex: "details", render: (q) => /* @__PURE__ */ _.jsx(_t.Text, { code: !0, children: typeof q == "string" ? q : JSON.stringify(q) }) } ] } ) }), - /* @__PURE__ */ N.jsx( + /* @__PURE__ */ _.jsx( $r, { title: S ? n.defaultRecipientsTitle : n.recipientsTitle(y?.ID), @@ -74147,11 +74147,11 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid }, confirmLoading: I, destroyOnHidden: !0, - children: /* @__PURE__ */ N.jsxs(lt, { direction: "vertical", className: "panel-stack", children: [ - /* @__PURE__ */ N.jsx(Lt, { message: n.assignmentOnly, type: "warning", showIcon: !0 }), - /* @__PURE__ */ N.jsxs("div", { children: [ - /* @__PURE__ */ N.jsx(_t.Text, { strong: !0, children: n.systemUsers }), - /* @__PURE__ */ N.jsx( + children: /* @__PURE__ */ _.jsxs(lt, { direction: "vertical", className: "panel-stack", children: [ + /* @__PURE__ */ _.jsx(Lt, { message: n.assignmentOnly, type: "warning", showIcon: !0 }), + /* @__PURE__ */ _.jsxs("div", { children: [ + /* @__PURE__ */ _.jsx(_t.Text, { strong: !0, children: n.systemUsers }), + /* @__PURE__ */ _.jsx( kt, { mode: "multiple", @@ -74162,9 +74162,9 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid } ) ] }), - /* @__PURE__ */ N.jsxs("div", { children: [ - /* @__PURE__ */ N.jsx(_t.Text, { strong: !0, children: n.externalEmails }), - /* @__PURE__ */ N.jsx( + /* @__PURE__ */ _.jsxs("div", { children: [ + /* @__PURE__ */ _.jsx(_t.Text, { strong: !0, children: n.externalEmails }), + /* @__PURE__ */ _.jsx( dt.TextArea, { rows: 4, @@ -74177,21 +74177,21 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid ] }) } ), - /* @__PURE__ */ N.jsx( + /* @__PURE__ */ _.jsx( $r, { - title: n.deliveryTitle(_?.ID), - open: !!_, + title: n.deliveryTitle(N?.ID), + open: !!N, onCancel: () => P(void 0), onOk: () => { Y(); }, okText: n.sendNow, confirmLoading: U, - okButtonProps: { disabled: !_?.Recipients?.length }, + okButtonProps: { disabled: !N?.Recipients?.length }, width: 760, - children: /* @__PURE__ */ N.jsxs(lt, { direction: "vertical", className: "panel-stack", children: [ - /* @__PURE__ */ N.jsx( + children: /* @__PURE__ */ _.jsxs(lt, { direction: "vertical", className: "panel-stack", children: [ + /* @__PURE__ */ _.jsx( Lt, { type: "warning", @@ -74199,23 +74199,23 @@ For more information, see https://refine.dev/docs/advanced-tutorials/data-provid message: n.sendWarning } ), - /* @__PURE__ */ N.jsxs(xn, { size: "small", column: 1, children: [ - /* @__PURE__ */ N.jsx(xn.Item, { label: n.recipients, children: _?.Recipients?.map((q) => q.email).join(", ") || n.none }), - /* @__PURE__ */ N.jsx(xn.Item, { label: n.rfqRevision, children: _?.Revision }), - /* @__PURE__ */ N.jsx(xn.Item, { label: n.message, children: _?.GeneralMessage || "—" }) + /* @__PURE__ */ _.jsxs(xn, { size: "small", column: 1, children: [ + /* @__PURE__ */ _.jsx(xn.Item, { label: n.recipients, children: N?.Recipients?.map((q) => q.email).join(", ") || n.none }), + /* @__PURE__ */ _.jsx(xn.Item, { label: n.rfqRevision, children: N?.Revision }), + /* @__PURE__ */ _.jsx(xn.Item, { label: n.message, children: N?.GeneralMessage || "—" }) ] }), - /* @__PURE__ */ N.jsx(Ot, { size: "small", title: n.deliveryHistory, extra: /* @__PURE__ */ N.jsx(Ve, { size: "small", onClick: () => _ && void le(_), children: n.refresh }), children: /* @__PURE__ */ N.jsx( + /* @__PURE__ */ _.jsx(Ot, { size: "small", title: n.deliveryHistory, extra: /* @__PURE__ */ _.jsx(Ve, { size: "small", onClick: () => N && void le(N), children: n.refresh }), children: /* @__PURE__ */ _.jsx( Jr, { dataSource: z, locale: { emptyText: n.noAttempts }, - renderItem: (q) => /* @__PURE__ */ N.jsx(Jr.Item, { children: /* @__PURE__ */ N.jsx( + renderItem: (q) => /* @__PURE__ */ _.jsx(Jr.Item, { children: /* @__PURE__ */ _.jsx( Jr.Item.Meta, { - title: /* @__PURE__ */ N.jsxs(lt, { children: [ - /* @__PURE__ */ N.jsx(Cn, { children: n.attemptStatus[q.status] ?? q.status }), - /* @__PURE__ */ N.jsx("span", { children: new Date(q.created_at).toLocaleString(e) }), - /* @__PURE__ */ N.jsx(_t.Text, { type: "secondary", children: q.id }) + title: /* @__PURE__ */ _.jsxs(lt, { children: [ + /* @__PURE__ */ _.jsx(Cn, { children: n.attemptStatus[q.status] ?? q.status }), + /* @__PURE__ */ _.jsx("span", { children: new Date(q.created_at).toLocaleString(e) }), + /* @__PURE__ */ _.jsx(_t.Text, { type: "secondary", children: q.id }) ] }), description: q.recipients.map((te) => `${te.email}: ${te.status}${te.error_class ? ` (${te.error_class})` : ""}`).join(", ") } @@ -75743,7 +75743,7 @@ Please change the parent to to to to to to { if (f && f(I), I.defaultPrevented) return; I.preventDefault(); - let D = I.nativeEvent.submitter, _ = D?.getAttribute("formmethod") || c, P = () => C(D || I.currentTarget, { + let D = I.nativeEvent.submitter, N = D?.getAttribute("formmethod") || c, P = () => C(D || I.currentTarget, { fetcherKey: t, - method: _, + method: N, navigate: n, replace: a, state: s, @@ -76224,10 +76224,10 @@ Please change the parent to window.removeEventListener("focus", p); - }, [n, c]), c ? /* @__PURE__ */ N.jsx(Lt, { type: "warning", title: "Session ended", description: /* @__PURE__ */ N.jsx("a", { href: "/admin/login", children: "Sign in again" }) }) : a ? /* @__PURE__ */ N.jsxs(lt, { direction: "vertical", children: [ - /* @__PURE__ */ N.jsx(Lt, { type: "error", title: a.message }), - /* @__PURE__ */ N.jsx(Ve, { onClick: () => m((p) => p + 1), children: "Retry" }) - ] }) : n ? /* @__PURE__ */ N.jsx(B4.Provider, { value: n, children: /* @__PURE__ */ N.jsx(Lge, { basename: "/admin", children: /* @__PURE__ */ N.jsx( + }, [n, c]), c ? /* @__PURE__ */ _.jsx(Lt, { type: "warning", title: "Session ended", description: /* @__PURE__ */ _.jsx("a", { href: "/admin/login", children: "Sign in again" }) }) : a ? /* @__PURE__ */ _.jsxs(lt, { direction: "vertical", children: [ + /* @__PURE__ */ _.jsx(Lt, { type: "error", title: a.message }), + /* @__PURE__ */ _.jsx(Ve, { onClick: () => m((p) => p + 1), children: "Retry" }) + ] }) : n ? /* @__PURE__ */ _.jsx(B4.Provider, { value: n, children: /* @__PURE__ */ _.jsx(Lge, { basename: "/admin", children: /* @__PURE__ */ _.jsx( Mme, { dataProvider: SE, @@ -76238,7 +76238,7 @@ Please change the parent to to /* @__PURE__ */ N.jsx(Cn, { color: E === "succeeded" ? "green" : E === "failed" ? "red" : "blue", children: E }) + render: (E) => /* @__PURE__ */ _.jsx(Cn, { color: E === "succeeded" ? "green" : E === "failed" ? "red" : "blue", children: E }) }, { title: r.started, @@ -76624,11 +76624,11 @@ Please change the parent to /* @__PURE__ */ N.jsxs(lt, { direction: "vertical", size: 0, children: [ - /* @__PURE__ */ N.jsx(_t.Text, { type: R.content_verified ? "success" : void 0, children: R.content_verified ? r.verified : "—" }), - /* @__PURE__ */ N.jsx(_t.Text, { type: R.read_only_applied ? "success" : "secondary", children: R.read_only_applied ? r.protected : "—" }), - R.error_message && /* @__PURE__ */ N.jsx(_t.Text, { type: "danger", children: R.error_message }), - R.warning_message && /* @__PURE__ */ N.jsx(_t.Text, { type: "warning", children: R.warning_message }) + render: (E, R) => /* @__PURE__ */ _.jsxs(lt, { direction: "vertical", size: 0, children: [ + /* @__PURE__ */ _.jsx(_t.Text, { type: R.content_verified ? "success" : void 0, children: R.content_verified ? r.verified : "—" }), + /* @__PURE__ */ _.jsx(_t.Text, { type: R.read_only_applied ? "success" : "secondary", children: R.read_only_applied ? r.protected : "—" }), + R.error_message && /* @__PURE__ */ _.jsx(_t.Text, { type: "danger", children: R.error_message }), + R.warning_message && /* @__PURE__ */ _.jsx(_t.Text, { type: "warning", children: R.warning_message }) ] }) }, { @@ -76637,49 +76637,49 @@ Please change the parent to R.backup_id && (s?.retention.reasons[R.backup_id] ?? []).join(", ") || "—" } ]; - return /* @__PURE__ */ N.jsxs(lt, { direction: "vertical", size: "middle", className: "panel-stack", children: [ - s?.due && /* @__PURE__ */ N.jsx(Lt, { type: "warning", showIcon: !0, message: r.overdue }), - s?.contains_sensitive_data && /* @__PURE__ */ N.jsx(Lt, { type: "warning", showIcon: !0, message: r.sensitive }), - !!s?.external_requirements?.length && /* @__PURE__ */ N.jsx(Lt, { type: "info", showIcon: !0, message: r.external, description: s?.external_requirements?.join(", ") }), - /* @__PURE__ */ N.jsxs( + return /* @__PURE__ */ _.jsxs(lt, { direction: "vertical", size: "middle", className: "panel-stack", children: [ + s?.due && /* @__PURE__ */ _.jsx(Lt, { type: "warning", showIcon: !0, message: r.overdue }), + s?.contains_sensitive_data && /* @__PURE__ */ _.jsx(Lt, { type: "warning", showIcon: !0, message: r.sensitive }), + !!s?.external_requirements?.length && /* @__PURE__ */ _.jsx(Lt, { type: "info", showIcon: !0, message: r.external, description: s?.external_requirements?.join(", ") }), + /* @__PURE__ */ _.jsxs( Ot, { title: r.title, - extra: /* @__PURE__ */ N.jsxs(lt, { children: [ - /* @__PURE__ */ N.jsx(Ve, { onClick: () => { + extra: /* @__PURE__ */ _.jsxs(lt, { children: [ + /* @__PURE__ */ _.jsx(Ve, { onClick: () => { y(); }, loading: u, children: r.refresh }), - /* @__PURE__ */ N.jsx(Ve, { type: "primary", onClick: () => { + /* @__PURE__ */ _.jsx(Ve, { type: "primary", onClick: () => { S(); }, loading: m, children: r.run }) ] }), children: [ - s?.next_run_utc && /* @__PURE__ */ N.jsxs(_t.Paragraph, { children: [ + s?.next_run_utc && /* @__PURE__ */ _.jsxs(_t.Paragraph, { children: [ r.next, ": ", new Date(s.next_run_utc).toLocaleString(e) ] }), - /* @__PURE__ */ N.jsx(we, { form: a, layout: "vertical", onFinish: (E) => { + /* @__PURE__ */ _.jsx(we, { form: a, layout: "vertical", onFinish: (E) => { b(E); - }, children: /* @__PURE__ */ N.jsxs(Ot, { type: "inner", title: r.schedule, children: [ - /* @__PURE__ */ N.jsxs(lt, { wrap: !0, align: "start", children: [ - /* @__PURE__ */ N.jsx(we.Item, { name: "enabled", label: r.enabled, valuePropName: "checked", children: /* @__PURE__ */ N.jsx(KB, {}) }), - /* @__PURE__ */ N.jsx(we.Item, { name: "local_time", label: r.localTime, rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(dt, { type: "time" }) }), - /* @__PURE__ */ N.jsx(we.Item, { label: r.timeZone, children: /* @__PURE__ */ N.jsx(dt, { value: s?.settings.time_zone, readOnly: !0 }) }), + }, children: /* @__PURE__ */ _.jsxs(Ot, { type: "inner", title: r.schedule, children: [ + /* @__PURE__ */ _.jsxs(lt, { wrap: !0, align: "start", children: [ + /* @__PURE__ */ _.jsx(we.Item, { name: "enabled", label: r.enabled, valuePropName: "checked", children: /* @__PURE__ */ _.jsx(KB, {}) }), + /* @__PURE__ */ _.jsx(we.Item, { name: "local_time", label: r.localTime, rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(dt, { type: "time" }) }), + /* @__PURE__ */ _.jsx(we.Item, { label: r.timeZone, children: /* @__PURE__ */ _.jsx(dt, { value: s?.settings.time_zone, readOnly: !0 }) }), [ ["retention_daily", r.daily], ["retention_weekly", r.weekly], ["retention_monthly", r.monthly], ["retention_pre_upgrade", r.preUpgrade], ["retention_pre_restore", r.preRestore] - ].map(([E, R]) => /* @__PURE__ */ N.jsx(we.Item, { name: E, label: R, rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(Jo, { min: 1, max: 3660, precision: 0 }) }, E)) + ].map(([E, R]) => /* @__PURE__ */ _.jsx(we.Item, { name: E, label: R, rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(Jo, { min: 1, max: 3660, precision: 0 }) }, E)) ] }), - /* @__PURE__ */ N.jsx("div", { children: /* @__PURE__ */ N.jsx(Ve, { htmlType: "submit", loading: u, children: r.save }) }) + /* @__PURE__ */ _.jsx("div", { children: /* @__PURE__ */ _.jsx(Ve, { htmlType: "submit", loading: u, children: r.save }) }) ] }) }) ] } ), - /* @__PURE__ */ N.jsx(Ot, { title: r.history, children: /* @__PURE__ */ N.jsx( + /* @__PURE__ */ _.jsx(Ot, { title: r.history, children: /* @__PURE__ */ _.jsx( In, { rowKey: "id", @@ -77221,7 +77221,7 @@ Please change the parent to { + const u = ave[e], [f, m] = l.useState(), [p, g] = l.useState([]), [v, y] = l.useState(), [b, S] = l.useState([]), [C, E] = l.useState([]), [R, $] = l.useState([]), [M, I] = l.useState([]), [D, N] = l.useState("external"), [P, T] = l.useState(), [A, z] = l.useState(!1), [O, L] = l.useState("external"), [F, U] = l.useState(), [H, V] = l.useState(!1), [ee] = we.useForm(), [X] = we.useForm(), [K] = we.useForm(), G = async () => { if (t) try { const [Y, q, te, de, fe, pe] = await Promise.all([ @@ -77352,26 +77352,26 @@ Please change the parent to /* @__PURE__ */ N.jsx(Jr.Item, { children: /* @__PURE__ */ N.jsx( + renderItem: (Y) => /* @__PURE__ */ _.jsx(Jr.Item, { children: /* @__PURE__ */ _.jsx( Jr.Item.Meta, { title: p.find((q) => q.id === Y.value.spec_id)?.name ?? Y.value.spec_id, - description: /* @__PURE__ */ N.jsxs(lt, { wrap: !0, children: [ - /* @__PURE__ */ N.jsx(_t.Text, { children: Y.value.raw_value }), - /* @__PURE__ */ N.jsx(Cn, { color: Y.value.active ? "green" : "default", children: Y.value.active ? u.active : u.inactive }), - /* @__PURE__ */ N.jsxs(Cn, { children: [ + description: /* @__PURE__ */ _.jsxs(lt, { wrap: !0, children: [ + /* @__PURE__ */ _.jsx(_t.Text, { children: Y.value.raw_value }), + /* @__PURE__ */ _.jsx(Cn, { color: Y.value.active ? "green" : "default", children: Y.value.active ? u.active : u.inactive }), + /* @__PURE__ */ _.jsxs(Cn, { children: [ u.sourceRevision, Y.value.source_revision ] }), - Y.normalized?.map((q) => /* @__PURE__ */ N.jsxs(Cn, { color: q.status === "current" ? "blue" : "default", children: [ + Y.normalized?.map((q) => /* @__PURE__ */ _.jsxs(Cn, { color: q.status === "current" ? "blue" : "default", children: [ q.source, ":", q.status @@ -77381,42 +77381,42 @@ Please change the parent to { + /* @__PURE__ */ _.jsxs(we, { form: ee, layout: "inline", onFinish: (Y) => { Q(Y); }, children: [ - /* @__PURE__ */ N.jsx(we.Item, { name: "spec_id", rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(kt, { placeholder: u.specification, style: { width: 220 }, options: W.map((Y) => ({ value: Y.id, label: `${Y.name}${Y.preferred_unit ? ` (${Y.preferred_unit})` : ""}` })) }) }), - /* @__PURE__ */ N.jsx(we.Item, { name: "raw_value", rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(dt, { placeholder: u.rawValue }) }), - /* @__PURE__ */ N.jsx(we.Item, { name: "source_locale", children: /* @__PURE__ */ N.jsx(dt, { placeholder: u.localeOptional }) }), - /* @__PURE__ */ N.jsx(Ve, { type: "primary", htmlType: "submit", disabled: !W.length, children: u.saveValue }) + /* @__PURE__ */ _.jsx(we.Item, { name: "spec_id", rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(kt, { placeholder: u.specification, style: { width: 220 }, options: W.map((Y) => ({ value: Y.id, label: `${Y.name}${Y.preferred_unit ? ` (${Y.preferred_unit})` : ""}` })) }) }), + /* @__PURE__ */ _.jsx(we.Item, { name: "raw_value", rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(dt, { placeholder: u.rawValue }) }), + /* @__PURE__ */ _.jsx(we.Item, { name: "source_locale", children: /* @__PURE__ */ _.jsx(dt, { placeholder: u.localeOptional }) }), + /* @__PURE__ */ _.jsx(Ve, { type: "primary", htmlType: "submit", disabled: !W.length, children: u.saveValue }) ] }) ] }), - /* @__PURE__ */ N.jsxs(Ot, { title: u.images, size: "small", children: [ - /* @__PURE__ */ N.jsx( + /* @__PURE__ */ _.jsxs(Ot, { title: u.images, size: "small", children: [ + /* @__PURE__ */ _.jsx( Jr, { dataSource: R, locale: { emptyText: u.noImages }, - renderItem: (Y) => /* @__PURE__ */ N.jsx(Jr.Item, { actions: [ - /* @__PURE__ */ N.jsx(Ve, { size: "small", disabled: Y.primary, onClick: () => { + renderItem: (Y) => /* @__PURE__ */ _.jsx(Jr.Item, { actions: [ + /* @__PURE__ */ _.jsx(Ve, { size: "small", disabled: Y.primary, onClick: () => { re(Y, !0); }, children: u.setPrimary }, "primary"), - /* @__PURE__ */ N.jsx(Lr, { title: u.removeImageConfirm, onConfirm: () => { + /* @__PURE__ */ _.jsx(Lr, { title: u.removeImageConfirm, onConfirm: () => { le(Y); - }, children: /* @__PURE__ */ N.jsx(Ve, { size: "small", danger: !0, children: u.remove }) }, "delete") - ], children: /* @__PURE__ */ N.jsx( + }, children: /* @__PURE__ */ _.jsx(Ve, { size: "small", danger: !0, children: u.remove }) }, "delete") + ], children: /* @__PURE__ */ _.jsx( Jr.Item.Meta, { - title: /* @__PURE__ */ N.jsxs(lt, { children: [ - Y.primary && /* @__PURE__ */ N.jsx(Cn, { color: "blue", children: u.primary }), - /* @__PURE__ */ N.jsx("span", { children: Y.alt_text || u.altFallback }) + title: /* @__PURE__ */ _.jsxs(lt, { children: [ + Y.primary && /* @__PURE__ */ _.jsx(Cn, { color: "blue", children: u.primary }), + /* @__PURE__ */ _.jsx("span", { children: Y.alt_text || u.altFallback }) ] }), - description: /* @__PURE__ */ N.jsxs(lt, { wrap: !0, children: [ - /* @__PURE__ */ N.jsxs(Cn, { children: [ + description: /* @__PURE__ */ _.jsxs(lt, { wrap: !0, children: [ + /* @__PURE__ */ _.jsxs(Cn, { children: [ u.order, " ", Y.sort_order ] }), - Y.external_url ? /* @__PURE__ */ N.jsx("a", { href: Y.external_url, target: "_blank", rel: "noreferrer", children: u.externalImage }) : /* @__PURE__ */ N.jsxs("span", { children: [ + Y.external_url ? /* @__PURE__ */ _.jsx("a", { href: Y.external_url, target: "_blank", rel: "noreferrer", children: u.externalImage }) : /* @__PURE__ */ _.jsxs("span", { children: [ u.managedAsset, " ", Y.asset_id @@ -77426,77 +77426,77 @@ Please change the parent to { + /* @__PURE__ */ _.jsxs(we, { form: K, layout: "vertical", initialValues: { sort_order: R.length + 1, primary: R.length === 0 }, onFinish: (Y) => { J(Y); }, children: [ - /* @__PURE__ */ N.jsx(we.Item, { label: u.source, children: /* @__PURE__ */ N.jsx(El.Group, { optionType: "button", value: O, onChange: (Y) => { + /* @__PURE__ */ _.jsx(we.Item, { label: u.source, children: /* @__PURE__ */ _.jsx(El.Group, { optionType: "button", value: O, onChange: (Y) => { L(Y.target.value), U(void 0); }, options: [{ label: u.externalURL, value: "external" }, { label: u.uploadImage, value: "upload" }] }) }), - /* @__PURE__ */ N.jsxs("div", { className: "form-grid three-columns", children: [ - O === "external" ? /* @__PURE__ */ N.jsx(we.Item, { name: "external_url", label: u.externalHTTP, rules: [{ required: !0 }, { type: "url" }], children: /* @__PURE__ */ N.jsx(dt, {}) }) : /* @__PURE__ */ N.jsx(we.Item, { label: u.imageTypes, required: !0, children: /* @__PURE__ */ N.jsxs(lt, { children: [ - /* @__PURE__ */ N.jsx(Pl, { accept: ".jpg,.jpeg,.png,.webp,image/jpeg,image/png,image/webp", maxCount: 1, showUploadList: !1, beforeUpload: (Y) => (B(Y), !1), children: /* @__PURE__ */ N.jsx(Ve, { loading: H, children: u.chooseUpload }) }), - F && /* @__PURE__ */ N.jsx(_t.Text, { children: F.original_filename }) + /* @__PURE__ */ _.jsxs("div", { className: "form-grid three-columns", children: [ + O === "external" ? /* @__PURE__ */ _.jsx(we.Item, { name: "external_url", label: u.externalHTTP, rules: [{ required: !0 }, { type: "url" }], children: /* @__PURE__ */ _.jsx(dt, {}) }) : /* @__PURE__ */ _.jsx(we.Item, { label: u.imageTypes, required: !0, children: /* @__PURE__ */ _.jsxs(lt, { children: [ + /* @__PURE__ */ _.jsx(Pl, { accept: ".jpg,.jpeg,.png,.webp,image/jpeg,image/png,image/webp", maxCount: 1, showUploadList: !1, beforeUpload: (Y) => (B(Y), !1), children: /* @__PURE__ */ _.jsx(Ve, { loading: H, children: u.chooseUpload }) }), + F && /* @__PURE__ */ _.jsx(_t.Text, { children: F.original_filename }) ] }) }), - /* @__PURE__ */ N.jsx(we.Item, { name: "alt_text", label: u.altText, extra: u.altHelp, children: /* @__PURE__ */ N.jsx(dt, {}) }), - /* @__PURE__ */ N.jsx(we.Item, { name: "sort_order", label: u.sortOrder, rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(Jo, { min: 0, precision: 0 }) }), - /* @__PURE__ */ N.jsx(we.Item, { name: "primary", valuePropName: "checked", children: /* @__PURE__ */ N.jsx(no, { children: u.primaryImage }) }) + /* @__PURE__ */ _.jsx(we.Item, { name: "alt_text", label: u.altText, extra: u.altHelp, children: /* @__PURE__ */ _.jsx(dt, {}) }), + /* @__PURE__ */ _.jsx(we.Item, { name: "sort_order", label: u.sortOrder, rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(Jo, { min: 0, precision: 0 }) }), + /* @__PURE__ */ _.jsx(we.Item, { name: "primary", valuePropName: "checked", children: /* @__PURE__ */ _.jsx(no, { children: u.primaryImage }) }) ] }), - /* @__PURE__ */ N.jsx(Ve, { type: "primary", htmlType: "submit", children: u.addImage }) + /* @__PURE__ */ _.jsx(Ve, { type: "primary", htmlType: "submit", children: u.addImage }) ] }) ] }), - /* @__PURE__ */ N.jsxs(Ot, { title: u.documents, size: "small", children: [ - /* @__PURE__ */ N.jsx( + /* @__PURE__ */ _.jsxs(Ot, { title: u.documents, size: "small", children: [ + /* @__PURE__ */ _.jsx( Jr, { dataSource: C, locale: { emptyText: u.noDocuments }, - renderItem: (Y) => /* @__PURE__ */ N.jsx(Jr.Item, { children: /* @__PURE__ */ N.jsx( + renderItem: (Y) => /* @__PURE__ */ _.jsx(Jr.Item, { children: /* @__PURE__ */ _.jsx( Jr.Item.Meta, { title: Y.label, - description: /* @__PURE__ */ N.jsxs(lt, { wrap: !0, children: [ - /* @__PURE__ */ N.jsx(Cn, { children: M.find((q) => q.id === Y.document_type_id)?.name ?? Y.document_type_id }), - Y.external_url && /* @__PURE__ */ N.jsx("a", { href: Y.external_url, target: "_blank", rel: "noreferrer", children: Y.external_url }), - Y.asset_id && /* @__PURE__ */ N.jsx("a", { href: `/assets/${Y.asset_id}`, target: "_blank", rel: "noreferrer", children: u.localPDF }) + description: /* @__PURE__ */ _.jsxs(lt, { wrap: !0, children: [ + /* @__PURE__ */ _.jsx(Cn, { children: M.find((q) => q.id === Y.document_type_id)?.name ?? Y.document_type_id }), + Y.external_url && /* @__PURE__ */ _.jsx("a", { href: Y.external_url, target: "_blank", rel: "noreferrer", children: Y.external_url }), + Y.asset_id && /* @__PURE__ */ _.jsx("a", { href: `/assets/${Y.asset_id}`, target: "_blank", rel: "noreferrer", children: u.localPDF }) ] }) } ) }) } ), - /* @__PURE__ */ N.jsxs(we, { form: X, layout: "vertical", initialValues: { sort_order: C.length + 1 }, onFinish: (Y) => { + /* @__PURE__ */ _.jsxs(we, { form: X, layout: "vertical", initialValues: { sort_order: C.length + 1 }, onFinish: (Y) => { ie(Y); }, children: [ - /* @__PURE__ */ N.jsx(we.Item, { label: u.source, children: /* @__PURE__ */ N.jsx( + /* @__PURE__ */ _.jsx(we.Item, { label: u.source, children: /* @__PURE__ */ _.jsx( El.Group, { optionType: "button", value: D, onChange: (Y) => { - _(Y.target.value), T(void 0); + N(Y.target.value), T(void 0); }, options: [{ label: u.externalURL, value: "external" }, { label: u.uploadPDF, value: "upload" }] } ) }), - /* @__PURE__ */ N.jsxs("div", { className: "form-grid three-columns", children: [ - /* @__PURE__ */ N.jsx(we.Item, { name: "label", label: u.label, rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(dt, {}) }), - /* @__PURE__ */ N.jsx(we.Item, { name: "document_type_id", label: u.documentType, rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(kt, { options: M.filter((Y) => Y.status === "active").map((Y) => ({ value: Y.id, label: Y.name })) }) }), - D === "external" ? /* @__PURE__ */ N.jsx(we.Item, { name: "external_url", label: u.externalHTTP, rules: [{ required: !0 }, { type: "url" }], children: /* @__PURE__ */ N.jsx(dt, {}) }) : /* @__PURE__ */ N.jsx(we.Item, { label: "PDF", required: !0, children: /* @__PURE__ */ N.jsxs(lt, { children: [ - /* @__PURE__ */ N.jsx( + /* @__PURE__ */ _.jsxs("div", { className: "form-grid three-columns", children: [ + /* @__PURE__ */ _.jsx(we.Item, { name: "label", label: u.label, rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(dt, {}) }), + /* @__PURE__ */ _.jsx(we.Item, { name: "document_type_id", label: u.documentType, rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(kt, { options: M.filter((Y) => Y.status === "active").map((Y) => ({ value: Y.id, label: Y.name })) }) }), + D === "external" ? /* @__PURE__ */ _.jsx(we.Item, { name: "external_url", label: u.externalHTTP, rules: [{ required: !0 }, { type: "url" }], children: /* @__PURE__ */ _.jsx(dt, {}) }) : /* @__PURE__ */ _.jsx(we.Item, { label: "PDF", required: !0, children: /* @__PURE__ */ _.jsxs(lt, { children: [ + /* @__PURE__ */ _.jsx( Pl, { accept: ".pdf,application/pdf", maxCount: 1, showUploadList: !1, beforeUpload: (Y) => (k(Y), !1), - children: /* @__PURE__ */ N.jsx(Ve, { loading: A, children: u.chooseUpload }) + children: /* @__PURE__ */ _.jsx(Ve, { loading: A, children: u.chooseUpload }) } ), - P && /* @__PURE__ */ N.jsx(_t.Text, { children: P.original_filename }) + P && /* @__PURE__ */ _.jsx(_t.Text, { children: P.original_filename }) ] }) }), - /* @__PURE__ */ N.jsx(we.Item, { name: "language", label: u.language, children: /* @__PURE__ */ N.jsx(dt, { placeholder: "en-US" }) }), - /* @__PURE__ */ N.jsx(we.Item, { name: "sort_order", label: u.sortOrder, rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(Jo, { min: 0, precision: 0 }) }) + /* @__PURE__ */ _.jsx(we.Item, { name: "language", label: u.language, children: /* @__PURE__ */ _.jsx(dt, { placeholder: "en-US" }) }), + /* @__PURE__ */ _.jsx(we.Item, { name: "sort_order", label: u.sortOrder, rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(Jo, { min: 0, precision: 0 }) }) ] }), - /* @__PURE__ */ N.jsx(Ve, { type: "primary", htmlType: "submit", children: u.addDocument }) + /* @__PURE__ */ _.jsx(Ve, { type: "primary", htmlType: "submit", children: u.addDocument }) ] }) ] }) ] }) }); @@ -77636,14 +77636,14 @@ Please change the parent to { - m.setFieldsValue(n ?? {}); + m.setFieldsValue({ name: n?.name, description: n?.description, features: n?.features }); }, [m, n]); const p = async (g) => { f(!0); try { const v = await Sr(`/admin/api/products/${e}/translations/${encodeURIComponent(t)}`, { expected_revision: r, - translation: g + translation: { name: g.name, description: g.description, features: g.features } }); s(v); } catch (v) { @@ -77652,20 +77652,19 @@ Please change the parent to { + return /* @__PURE__ */ _.jsxs(we, { form: m, layout: "vertical", onFinish: (g) => { p(g); }, children: [ - /* @__PURE__ */ N.jsx(we.Item, { name: "name", label: a.name, children: /* @__PURE__ */ N.jsx(dt, {}) }), - /* @__PURE__ */ N.jsx(we.Item, { name: "description", label: a.description, children: /* @__PURE__ */ N.jsx(dt.TextArea, { rows: 3 }) }), - /* @__PURE__ */ N.jsx(we.Item, { name: "features", label: a.features, children: /* @__PURE__ */ N.jsx(dt.TextArea, { rows: 3 }) }), - /* @__PURE__ */ N.jsx(we.Item, { name: "specification", label: a.specification, children: /* @__PURE__ */ N.jsx(dt.TextArea, { rows: 3 }) }), - /* @__PURE__ */ N.jsx(Ve, { type: "primary", htmlType: "submit", loading: u, children: a.save }) + /* @__PURE__ */ _.jsx(we.Item, { name: "name", label: a.name, children: /* @__PURE__ */ _.jsx(dt, {}) }), + /* @__PURE__ */ _.jsx(we.Item, { name: "description", label: a.description, children: /* @__PURE__ */ _.jsx(dt.TextArea, { rows: 3 }) }), + /* @__PURE__ */ _.jsx(we.Item, { name: "features", label: a.features, children: /* @__PURE__ */ _.jsx(dt.TextArea, { rows: 3 }) }), + /* @__PURE__ */ _.jsx(Ve, { type: "primary", htmlType: "submit", loading: u, children: a.save }) ] }); } function cve({ productID: e, content: t, locales: n, text: r, onSaved: a, onError: s }) { const [c, u] = l.useState(!1), [f] = we.useForm(), m = zu(n); l.useEffect(() => { - f.setFieldsValue({ source_locale: t.source_locale, source_locales: t.source_locales }); + f.setFieldsValue({ source_locale: t.source_locale, source_locales: { name: t.source_locales.name, description: t.source_locales.description, features: t.source_locales.features } }); }, [t, f]); const p = async (g) => { u(!0); @@ -77673,7 +77672,7 @@ Please change the parent to to { + return /* @__PURE__ */ _.jsxs(we, { form: f, layout: "vertical", onFinish: (g) => { p(g); }, children: [ - /* @__PURE__ */ N.jsx(Lt, { className: "bottom-gap", type: "info", showIcon: !0, message: r.sourceHelp }), - /* @__PURE__ */ N.jsx(we.Item, { name: "source_locale", label: r.source, rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(kt, { options: m }) }), - /* @__PURE__ */ N.jsx(Ot, { size: "small", title: r.perField, children: ["name", "description", "features", "specification"].map((g) => /* @__PURE__ */ N.jsx(we.Item, { name: ["source_locales", g], label: r[g], rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(kt, { options: m }) }, g)) }), - /* @__PURE__ */ N.jsx(Ve, { className: "top-gap", type: "primary", htmlType: "submit", loading: c, children: r.saveSource }) + /* @__PURE__ */ _.jsx(Lt, { className: "bottom-gap", type: "info", showIcon: !0, message: r.sourceHelp }), + /* @__PURE__ */ _.jsx(we.Item, { name: "source_locale", label: r.source, rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(kt, { options: m }) }), + /* @__PURE__ */ _.jsx(Ot, { size: "small", title: r.perField, children: ["name", "description", "features"].map((g) => /* @__PURE__ */ _.jsx(we.Item, { name: ["source_locales", g], label: r[g], rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(kt, { options: m }) }, g)) }), + /* @__PURE__ */ _.jsx(Ve, { className: "top-gap", type: "primary", htmlType: "submit", loading: c, children: r.saveSource }) ] }); } function uve({ product: e, settings: t, locale: n, onClose: r, onError: a, onSaved: s }) { @@ -77712,20 +77711,20 @@ Please change the parent to { p(b), s(); }; - return /* @__PURE__ */ N.jsx($r, { open: !!e, title: `${c.title} · ${e?.part_number ?? ""}`, footer: null, onCancel: r, width: 800, destroyOnHidden: !0, children: u || !m || !e ? /* @__PURE__ */ N.jsx(wu, {}) : /* @__PURE__ */ N.jsx(tp, { items: [ + return /* @__PURE__ */ _.jsx($r, { open: !!e, title: `${c.title} · ${e?.part_number ?? ""}`, footer: null, onCancel: r, width: 800, destroyOnHidden: !0, children: u || !m || !e ? /* @__PURE__ */ _.jsx(wu, {}) : /* @__PURE__ */ _.jsx(tp, { items: [ { key: "source-locales", label: c.perField, - children: /* @__PURE__ */ N.jsx(cve, { productID: e.id, content: m, locales: v, text: c, onError: a, onSaved: y }) + children: /* @__PURE__ */ _.jsx(cve, { productID: e.id, content: m, locales: v, text: c, onError: a, onSaved: y }) }, ...v.filter((b) => b !== m.source_locale).map((b) => { const S = m.translations?.find((C) => C.locale === b); return { key: b, label: Es(b), - children: /* @__PURE__ */ N.jsxs(N.Fragment, { children: [ - /* @__PURE__ */ N.jsx(Lt, { className: "bottom-gap", type: "info", showIcon: !0, message: `${c.source}: ${Es(m.source_locale)}`, description: c.empty }), - /* @__PURE__ */ N.jsx(lve, { productID: e.id, locale: b, item: S, revision: m.product_revision, text: c, onError: a, onSaved: y }) + children: /* @__PURE__ */ _.jsxs(_.Fragment, { children: [ + /* @__PURE__ */ _.jsx(Lt, { className: "bottom-gap", type: "info", showIcon: !0, message: `${c.source}: ${Es(m.source_locale)}`, description: c.empty }), + /* @__PURE__ */ _.jsx(lve, { productID: e.id, locale: b, item: S, revision: m.product_revision, text: c, onError: a, onSaved: y }) ] }) }; }) @@ -78389,7 +78388,7 @@ Please change the parent to to { - _(be.target.checked), f(1); + N(be.target.checked), f(1); }, children: r.includeArchived } ), - /* @__PURE__ */ N.jsx(Ve, { disabled: !a["catalog.export"], loading: ee, onClick: () => { + /* @__PURE__ */ _.jsx(Ve, { disabled: !a["catalog.export"], loading: ee, onClick: () => { de(); }, children: r.export }), - /* @__PURE__ */ N.jsx(Ve, { onClick: () => { + /* @__PURE__ */ _.jsx(Ve, { onClick: () => { pe(); }, children: r.refresh }), - /* @__PURE__ */ N.jsx(Ve, { type: "primary", disabled: !s || !le, onClick: Z, children: r.newProduct }) + /* @__PURE__ */ _.jsx(Ve, { type: "primary", disabled: !s || !le, onClick: Z, children: r.newProduct }) ] }), - children: /* @__PURE__ */ N.jsx( + children: /* @__PURE__ */ _.jsx( In, { rowKey: "id", @@ -78572,7 +78571,7 @@ Please change the parent to be || /* @__PURE__ */ N.jsx(_t.Text, { type: "secondary", children: "—" }) + render: (be) => be || /* @__PURE__ */ _.jsx(_t.Text, { type: "secondary", children: "—" }) }, { title: r.manufacturer, @@ -78584,16 +78583,16 @@ Please change the parent to /* @__PURE__ */ N.jsxs(lt, { children: [ - /* @__PURE__ */ N.jsx( + render: (be, Ee) => /* @__PURE__ */ _.jsxs(lt, { children: [ + /* @__PURE__ */ _.jsx( Cn, { color: Ee.record_state === "archived" ? "default" : "blue", children: Ee.record_state === "archived" ? r.archivedStatus : r.current } ), - /* @__PURE__ */ N.jsx(Cn, { color: Ee.status === "published" ? "green" : "gold", children: Ee.status === "published" ? r.publishedStatus : r.hidden }), - /* @__PURE__ */ N.jsxs(_t.Text, { type: "secondary", children: [ + /* @__PURE__ */ _.jsx(Cn, { color: Ee.status === "published" ? "green" : "gold", children: Ee.status === "published" ? r.publishedStatus : r.hidden }), + /* @__PURE__ */ _.jsxs(_t.Text, { type: "secondary", children: [ "r", Ee.revision ] }) @@ -78602,8 +78601,8 @@ Please change the parent to /* @__PURE__ */ N.jsxs(lt, { children: [ - /* @__PURE__ */ N.jsx( + render: (be, Ee) => /* @__PURE__ */ _.jsxs(lt, { children: [ + /* @__PURE__ */ _.jsx( Ve, { size: "small", @@ -78612,7 +78611,7 @@ Please change the parent to to to xe(Ee), children: r.clone }), - c && s && Ee.record_state !== "archived" && Ee.status === "hidden" && /* @__PURE__ */ N.jsx( + /* @__PURE__ */ _.jsx(Ve, { size: "small", disabled: !s || !le, onClick: () => xe(Ee), children: r.clone }), + c && s && Ee.record_state !== "archived" && Ee.status === "hidden" && /* @__PURE__ */ _.jsx( Lr, { title: r.publishConfirm, @@ -78639,27 +78638,27 @@ Please change the parent to { ke(Ee); }, - children: /* @__PURE__ */ N.jsx(Ve, { size: "small", type: "primary", children: r.publish }) + children: /* @__PURE__ */ _.jsx(Ve, { size: "small", type: "primary", children: r.publish }) } ), - c && Ee.record_state !== "archived" && Ee.status === "published" && /* @__PURE__ */ N.jsx( + c && Ee.record_state !== "archived" && Ee.status === "published" && /* @__PURE__ */ _.jsx( Lr, { title: r.hideConfirm, onConfirm: () => { qe(Ee, "hide"); }, - children: /* @__PURE__ */ N.jsx(Ve, { size: "small", children: r.hide }) + children: /* @__PURE__ */ _.jsx(Ve, { size: "small", children: r.hide }) } ), - c && Ee.record_state !== "archived" && /* @__PURE__ */ N.jsx( + c && Ee.record_state !== "archived" && /* @__PURE__ */ _.jsx( Lr, { title: r.archiveConfirm, onConfirm: () => { qe(Ee, "archive"); }, - children: /* @__PURE__ */ N.jsx(Ve, { size: "small", danger: !0, children: r.archive }) + children: /* @__PURE__ */ _.jsx(Ve, { size: "small", danger: !0, children: r.archive }) } ) ] }) @@ -78669,7 +78668,7 @@ Please change the parent to to to to to to to to to to to { + /* @__PURE__ */ _.jsx(we.Item, { name: "description", label: r.description, children: /* @__PURE__ */ _.jsx(dt.TextArea, { rows: 3 }) }), + /* @__PURE__ */ _.jsx(we.Item, { name: "features", label: r.features, children: /* @__PURE__ */ _.jsx(dt.TextArea, { rows: 3 }) }), + /* @__PURE__ */ _.jsx(we.Item, { name: "specification", label: r.specification, children: /* @__PURE__ */ _.jsx(dt.TextArea, { rows: 3 }) }), + /* @__PURE__ */ _.jsx(Ve, { onClick: () => { ve(); }, children: r.preview }) ] @@ -78831,7 +78830,7 @@ Please change the parent to to to to { + }, [y]), N = async (z) => { if (s) { M(!0); try { @@ -79536,7 +79535,7 @@ Please change the parent to 0 ? /* @__PURE__ */ N.jsx(lt, { direction: "vertical", size: 0, children: L.map((F) => /* @__PURE__ */ N.jsx("span", { children: F }, F)) }) : "—"; + return L.length > 0 ? /* @__PURE__ */ _.jsx(lt, { direction: "vertical", size: 0, children: L.map((F) => /* @__PURE__ */ _.jsx("span", { children: F }, F)) }) : "—"; } } ], A = [ @@ -79550,11 +79549,11 @@ Please change the parent to /* @__PURE__ */ N.jsxs(lt, { direction: "vertical", size: 0, children: [ - O.status === "Critical" && /* @__PURE__ */ N.jsx(_t.Text, { type: "danger", children: n.unavailable }), - /* @__PURE__ */ N.jsx("span", { children: `${Vc(O.free_bytes)} free / ${Vc(O.total_bytes)} total (${Vc(O.reserved_bytes)} reserved)` }), - O.reason && /* @__PURE__ */ N.jsx(_t.Text, { type: "secondary", children: O.reason }), - /* @__PURE__ */ N.jsxs(_t.Text, { type: "secondary", children: [ + render: (z, O) => /* @__PURE__ */ _.jsxs(lt, { direction: "vertical", size: 0, children: [ + O.status === "Critical" && /* @__PURE__ */ _.jsx(_t.Text, { type: "danger", children: n.unavailable }), + /* @__PURE__ */ _.jsx("span", { children: `${Vc(O.free_bytes)} free / ${Vc(O.total_bytes)} total (${Vc(O.reserved_bytes)} reserved)` }), + O.reason && /* @__PURE__ */ _.jsx(_t.Text, { type: "secondary", children: O.reason }), + /* @__PURE__ */ _.jsxs(_t.Text, { type: "secondary", children: [ n.admissionFloor, ": ", Vc(O.admission_floor_bytes), @@ -79563,7 +79562,7 @@ Please change the parent to to new Date(z).toLocaleString(e) } ]; - return /* @__PURE__ */ N.jsxs(Ot, { title: n.title, extra: /* @__PURE__ */ N.jsx(Ve, { onClick: () => { + return /* @__PURE__ */ _.jsxs(Ot, { title: n.title, extra: /* @__PURE__ */ _.jsx(Ve, { onClick: () => { D(); }, loading: S, children: n.refresh }), children: [ - g?.status === "update_available" && g.download_url && /* @__PURE__ */ N.jsx( + g?.status === "update_available" && g.download_url && /* @__PURE__ */ _.jsx( Lt, { showIcon: !0, type: "info", message: c0[e].title, description: c0[e].description(g.latest_version ?? ""), - action: /* @__PURE__ */ N.jsx(Ve, { type: "primary", href: g.download_url, target: "_blank", rel: "noopener noreferrer", children: c0[e].download }), + action: /* @__PURE__ */ _.jsx(Ve, { type: "primary", href: g.download_url, target: "_blank", rel: "noopener noreferrer", children: c0[e].download }), style: { marginBottom: 16 } } ), - r && /* @__PURE__ */ N.jsx( + r && /* @__PURE__ */ _.jsx( Lt, { showIcon: !0, @@ -79606,8 +79605,8 @@ Please change the parent to to to { - _(!1); - }, children: n.disableMaintenance }) : /* @__PURE__ */ N.jsx(Lr, { title: n.confirmMaintenance, onConfirm: () => _(!0), okText: n.enableMaintenance, children: /* @__PURE__ */ N.jsx(Ve, { danger: !0, loading: $, children: n.enableMaintenance }) }) + s?.active ? /* @__PURE__ */ _.jsx(Ve, { loading: $, onClick: () => { + N(!1); + }, children: n.disableMaintenance }) : /* @__PURE__ */ _.jsx(Lr, { title: n.confirmMaintenance, onConfirm: () => N(!0), okText: n.enableMaintenance, children: /* @__PURE__ */ _.jsx(Ve, { danger: !0, loading: $, children: n.enableMaintenance }) }) ] }) }), - /* @__PURE__ */ N.jsx(Ot, { size: "small", type: "inner", title: n.runtimeLog, style: { marginBottom: 24 }, children: /* @__PURE__ */ N.jsxs(lt, { direction: "vertical", style: { width: "100%" }, children: [ - /* @__PURE__ */ N.jsxs(lt, { wrap: !0, children: [ - /* @__PURE__ */ N.jsx( + /* @__PURE__ */ _.jsx(Ot, { size: "small", type: "inner", title: n.runtimeLog, style: { marginBottom: 24 }, children: /* @__PURE__ */ _.jsxs(lt, { direction: "vertical", style: { width: "100%" }, children: [ + /* @__PURE__ */ _.jsxs(lt, { wrap: !0, children: [ + /* @__PURE__ */ _.jsx( kt, { "aria-label": n.logGeneration, @@ -79645,7 +79644,7 @@ Please change the parent to { @@ -79657,19 +79656,19 @@ Please change the parent to to to to to to to { c(void 0); }, - children: /* @__PURE__ */ N.jsx(Ve, { children: r.choose }) + children: /* @__PURE__ */ _.jsx(Ve, { children: r.choose }) } ) }), - /* @__PURE__ */ N.jsx(we.Item, { name: "sheet", label: r.sheet, children: /* @__PURE__ */ N.jsx(dt, { placeholder: r.sheetPlaceholder }) }), - /* @__PURE__ */ N.jsx(we.Item, { name: "header_row", label: r.headerRow, rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(Jo, { min: 1, precision: 0 }) }), - /* @__PURE__ */ N.jsx(we.Item, { name: "identity_mode", label: r.identityMode, rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(kt, { options: [ + /* @__PURE__ */ _.jsx(we.Item, { name: "sheet", label: r.sheet, children: /* @__PURE__ */ _.jsx(dt, { placeholder: r.sheetPlaceholder }) }), + /* @__PURE__ */ _.jsx(we.Item, { name: "header_row", label: r.headerRow, rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(Jo, { min: 1, precision: 0 }) }), + /* @__PURE__ */ _.jsx(we.Item, { name: "identity_mode", label: r.identityMode, rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(kt, { options: [ { value: "part_number", label: r.partNumber }, { value: "manufacturer_part_number", label: r.manufacturerPart } ] }) }), - /* @__PURE__ */ N.jsx(we.Item, { name: "source_locale_override", label: r.sourceLocaleOverride, extra: r.sourceLocaleHelp, children: /* @__PURE__ */ N.jsx(kt, { allowClear: !0, options: yve }) }) + /* @__PURE__ */ _.jsx(we.Item, { name: "source_locale_override", label: r.sourceLocaleOverride, extra: r.sourceLocaleHelp, children: /* @__PURE__ */ _.jsx(kt, { allowClear: !0, options: yve }) }) ] }), - /* @__PURE__ */ N.jsx(we.List, { name: "mappings", children: ($, { add: M, remove: I }) => /* @__PURE__ */ N.jsxs(lt, { direction: "vertical", className: "panel-stack", children: [ - $.map((D) => /* @__PURE__ */ N.jsxs(lt, { wrap: !0, align: "baseline", children: [ - /* @__PURE__ */ N.jsx(we.Item, { ...D, name: [D.name, "source_index"], label: r.columnIndex, rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(Jo, { min: 0, precision: 0 }) }), - /* @__PURE__ */ N.jsx(we.Item, { ...D, name: [D.name, "source_name"], label: r.headerName, rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(dt, {}) }), - /* @__PURE__ */ N.jsx(we.Item, { ...D, name: [D.name, "target"], label: r.productField, rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(kt, { options: a, style: { width: 190 } }) }), - /* @__PURE__ */ N.jsx(Ve, { danger: !0, onClick: () => I(D.name), children: r.remove }) + /* @__PURE__ */ _.jsx(we.List, { name: "mappings", children: ($, { add: M, remove: I }) => /* @__PURE__ */ _.jsxs(lt, { direction: "vertical", className: "panel-stack", children: [ + $.map((D) => /* @__PURE__ */ _.jsxs(lt, { wrap: !0, align: "baseline", children: [ + /* @__PURE__ */ _.jsx(we.Item, { ...D, name: [D.name, "source_index"], label: r.columnIndex, rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(Jo, { min: 0, precision: 0 }) }), + /* @__PURE__ */ _.jsx(we.Item, { ...D, name: [D.name, "source_name"], label: r.headerName, rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(dt, {}) }), + /* @__PURE__ */ _.jsx(we.Item, { ...D, name: [D.name, "target"], label: r.productField, rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(kt, { options: a, style: { width: 190 } }) }), + /* @__PURE__ */ _.jsx(Ve, { danger: !0, onClick: () => I(D.name), children: r.remove }) ] }, D.key)), - /* @__PURE__ */ N.jsx(Ve, { onClick: () => M({ source_index: $.length, source_name: "", target: "" }), children: r.addMapping }) + /* @__PURE__ */ _.jsx(Ve, { onClick: () => M({ source_index: $.length, source_name: "", target: "" }), children: r.addMapping }) ] }) }), - /* @__PURE__ */ N.jsx(Ve, { type: "primary", htmlType: "submit", loading: g, disabled: !s, className: "top-gap", children: r.validate }) + /* @__PURE__ */ _.jsx(Ve, { type: "primary", htmlType: "submit", loading: g, disabled: !s, className: "top-gap", children: r.validate }) ] } ) }), - u && /* @__PURE__ */ N.jsx( + u && /* @__PURE__ */ _.jsx( Ot, { title: r.importTitle(u.id), - extra: /* @__PURE__ */ N.jsxs(lt, { children: [ - /* @__PURE__ */ N.jsx(Ve, { onClick: () => { + extra: /* @__PURE__ */ _.jsxs(lt, { children: [ + /* @__PURE__ */ _.jsx(Ve, { onClick: () => { b(); }, children: r.refresh }), - d0.has(u.status) && /* @__PURE__ */ N.jsx(Ve, { danger: !0, onClick: () => { + d0.has(u.status) && /* @__PURE__ */ _.jsx(Ve, { danger: !0, onClick: () => { E(); }, children: r.cancel }), - u.status === "preview_ready" && m?.fully_scanned && m.fully_validated && /* @__PURE__ */ N.jsx(Ve, { type: "primary", onClick: () => { + u.status === "preview_ready" && m?.fully_scanned && m.fully_validated && /* @__PURE__ */ _.jsx(Ve, { type: "primary", onClick: () => { C(); }, children: r.commit }), - m && /* @__PURE__ */ N.jsx(Ve, { href: `/admin/api/imports/${u.id}/report`, target: "_blank", children: r.download }) + m && /* @__PURE__ */ _.jsx(Ve, { href: `/admin/api/imports/${u.id}/report`, target: "_blank", children: r.download }) ] }), - children: /* @__PURE__ */ N.jsxs(lt, { direction: "vertical", className: "panel-stack", children: [ - /* @__PURE__ */ N.jsxs(lt, { wrap: !0, children: [ - /* @__PURE__ */ N.jsx(Cn, { color: u.status === "failed" ? "red" : u.status === "committed" ? "green" : "blue", children: u.status }), - /* @__PURE__ */ N.jsx("span", { children: u.phase }), - /* @__PURE__ */ N.jsx("span", { children: u.original_filename }) + children: /* @__PURE__ */ _.jsxs(lt, { direction: "vertical", className: "panel-stack", children: [ + /* @__PURE__ */ _.jsxs(lt, { wrap: !0, children: [ + /* @__PURE__ */ _.jsx(Cn, { color: u.status === "failed" ? "red" : u.status === "committed" ? "green" : "blue", children: u.status }), + /* @__PURE__ */ _.jsx("span", { children: u.phase }), + /* @__PURE__ */ _.jsx("span", { children: u.original_filename }) ] }), - d0.has(u.status) && /* @__PURE__ */ N.jsx(VR, { percent: R, status: "active" }), - u.error_message && /* @__PURE__ */ N.jsx(Lt, { type: "error", message: u.error_message, showIcon: !0 }), - /* @__PURE__ */ N.jsxs(lt, { wrap: !0, children: [ - /* @__PURE__ */ N.jsxs(Cn, { children: [ + d0.has(u.status) && /* @__PURE__ */ _.jsx(VR, { percent: R, status: "active" }), + u.error_message && /* @__PURE__ */ _.jsx(Lt, { type: "error", message: u.error_message, showIcon: !0 }), + /* @__PURE__ */ _.jsxs(lt, { wrap: !0, children: [ + /* @__PURE__ */ _.jsxs(Cn, { children: [ r.rows, " ", m?.total_rows ?? u.total_rows ] }), - /* @__PURE__ */ N.jsxs(Cn, { color: "green", children: [ + /* @__PURE__ */ _.jsxs(Cn, { color: "green", children: [ r.create, " ", m?.create_count ?? u.create_count ] }), - /* @__PURE__ */ N.jsxs(Cn, { color: "blue", children: [ + /* @__PURE__ */ _.jsxs(Cn, { color: "blue", children: [ r.update, " ", m?.update_count ?? u.update_count ] }), - /* @__PURE__ */ N.jsxs(Cn, { children: [ + /* @__PURE__ */ _.jsxs(Cn, { children: [ r.unchanged, " ", m?.no_change_count ?? u.no_change_count ] }), - /* @__PURE__ */ N.jsxs(Cn, { color: (m?.issues?.length ?? u.failed_count) > 0 ? "red" : "default", children: [ + /* @__PURE__ */ _.jsxs(Cn, { color: (m?.issues?.length ?? u.failed_count) > 0 ? "red" : "default", children: [ r.issues, " ", m?.issues?.length ?? u.failed_count ] }) ] }), - m && /* @__PURE__ */ N.jsx( + m && /* @__PURE__ */ _.jsx( In, { rowKey: ($, M) => `${$.sheet}-${$.row}-${$.column}-${$.code}-${M}`, @@ -80501,7 +80499,7 @@ Please change the parent to to { + extra: /* @__PURE__ */ _.jsx(Ve, { loading: s, onClick: () => { f(); }, children: u.refresh }), - children: /* @__PURE__ */ N.jsx( + children: /* @__PURE__ */ _.jsx( In, { rowKey: "id", @@ -80711,33 +80709,33 @@ Please change the parent to !!(p.error_message || p.outputs?.length), - expandedRowRender: (p) => /* @__PURE__ */ N.jsxs(lt, { direction: "vertical", className: "panel-stack", children: [ - p.error_message && /* @__PURE__ */ N.jsx(Lt, { showIcon: !0, type: "error", message: p.error_message }), - p.outputs?.length ? /* @__PURE__ */ N.jsxs(lt, { wrap: !0, children: [ - /* @__PURE__ */ N.jsx(_t.Text, { type: "secondary", children: u.outputs }), - p.outputs.map((g) => /* @__PURE__ */ N.jsx(Cn, { children: g }, g)) + expandedRowRender: (p) => /* @__PURE__ */ _.jsxs(lt, { direction: "vertical", className: "panel-stack", children: [ + p.error_message && /* @__PURE__ */ _.jsx(Lt, { showIcon: !0, type: "error", message: p.error_message }), + p.outputs?.length ? /* @__PURE__ */ _.jsxs(lt, { wrap: !0, children: [ + /* @__PURE__ */ _.jsx(_t.Text, { type: "secondary", children: u.outputs }), + p.outputs.map((g) => /* @__PURE__ */ _.jsx(Cn, { children: g }, g)) ] }) : null ] }) }, columns: [ - { title: u.type, dataIndex: "kind", render: (p) => /* @__PURE__ */ N.jsx(Cn, { children: p }) }, + { title: u.type, dataIndex: "kind", render: (p) => /* @__PURE__ */ _.jsx(Cn, { children: p }) }, { title: u.jobTarget, - render: (p, g) => /* @__PURE__ */ N.jsxs(lt, { direction: "vertical", size: 0, children: [ - /* @__PURE__ */ N.jsx(_t.Text, { code: !0, children: g.id }), - /* @__PURE__ */ N.jsxs(_t.Text, { type: "secondary", children: [ + render: (p, g) => /* @__PURE__ */ _.jsxs(lt, { direction: "vertical", size: 0, children: [ + /* @__PURE__ */ _.jsx(_t.Text, { code: !0, children: g.id }), + /* @__PURE__ */ _.jsxs(_t.Text, { type: "secondary", children: [ g.target_type && g.target_id ? `${g.target_type}: ${g.target_id}` : g.label || "—", g.desired_revision ? ` · ${u.revision} ${g.desired_revision}` : "" ] }) ] }) }, - { title: u.status, dataIndex: "status", render: (p) => /* @__PURE__ */ N.jsx(Cn, { color: $ve(p), children: p }) }, + { title: u.status, dataIndex: "status", render: (p) => /* @__PURE__ */ _.jsx(Cn, { color: $ve(p), children: p }) }, { title: u.stage, dataIndex: "stage" }, { title: u.progress, width: 150, render: (p, g) => wve(g) }, { title: u.updated, dataIndex: "updated_at", render: (p) => new Date(p).toLocaleString(e) }, { title: u.action, - render: (p, g) => (g.kind === "publication" || g.kind === "search_submission") && g.retryable ? /* @__PURE__ */ N.jsx(Ve, { onClick: () => { + render: (p, g) => (g.kind === "publication" || g.kind === "search_submission") && g.retryable ? /* @__PURE__ */ _.jsx(Ve, { onClick: () => { m(g); }, children: u.retry }) : "—" } @@ -80774,7 +80772,7 @@ Please change the parent to to to to to { + }, N = async () => { if (!s || !y) return; const P = { ...s.working.category_listing_profiles ?? {} }; delete P[y], await I({ ...s.working, category_listing_profiles: P }, "removed"), a.setFieldsValue(f0); }; - return /* @__PURE__ */ N.jsx(wu, { spinning: S, children: /* @__PURE__ */ N.jsx(lt, { direction: "vertical", size: "large", className: "panel-stack", children: /* @__PURE__ */ N.jsxs(Ot, { title: r.title, children: [ - /* @__PURE__ */ N.jsx(_t.Paragraph, { type: "secondary", children: r.help }), - /* @__PURE__ */ N.jsxs(we, { form: a, layout: "vertical", initialValues: f0, onFinish: (P) => { + return /* @__PURE__ */ _.jsx(wu, { spinning: S, children: /* @__PURE__ */ _.jsx(lt, { direction: "vertical", size: "large", className: "panel-stack", children: /* @__PURE__ */ _.jsxs(Ot, { title: r.title, children: [ + /* @__PURE__ */ _.jsx(_t.Paragraph, { type: "secondary", children: r.help }), + /* @__PURE__ */ _.jsxs(we, { form: a, layout: "vertical", initialValues: f0, onFinish: (P) => { D(P); }, children: [ - /* @__PURE__ */ N.jsx(we.Item, { label: r.category, required: !0, children: /* @__PURE__ */ N.jsx( + /* @__PURE__ */ _.jsx(we.Item, { label: r.category, required: !0, children: /* @__PURE__ */ _.jsx( kt, { showSearch: !0, @@ -81099,8 +81097,8 @@ Please change the parent to P.status === "active" && P.system_key !== "root").map((P) => ({ value: P.id, label: P.name })) } ) }), - g ? null : /* @__PURE__ */ N.jsx(Lt, { type: "info", showIcon: !0, message: r.noSpecSet }), - /* @__PURE__ */ N.jsx( + g ? null : /* @__PURE__ */ _.jsx(Lt, { type: "info", showIcon: !0, message: r.noSpecSet }), + /* @__PURE__ */ _.jsx( we.Item, { name: "visible_columns", @@ -81109,18 +81107,18 @@ Please change the parent to T?.includes("part_number") ? Promise.resolve() : Promise.reject(new Error(r.requiredPart)) } ], - children: /* @__PURE__ */ N.jsx(kt, { mode: "multiple", options: [...GL.map(([P, T]) => ({ value: P, label: T })), ...R] }) + children: /* @__PURE__ */ _.jsx(kt, { mode: "multiple", options: [...GL.map(([P, T]) => ({ value: P, label: T })), ...R] }) } ), - /* @__PURE__ */ N.jsxs(lt, { align: "start", wrap: !0, children: [ - /* @__PURE__ */ N.jsx(we.Item, { name: "default_sort", label: r.sort, rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(kt, { style: { minWidth: 220 }, options: GL.filter(([P]) => ["part_number", "name", "manufacturer", "brand", "lifecycle"].includes(P)).map(([P, T]) => ({ value: P, label: T })) }) }), - /* @__PURE__ */ N.jsx(we.Item, { name: "default_sort_direction", label: r.direction, rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(kt, { style: { minWidth: 160 }, options: [{ value: "asc", label: r.asc }, { value: "desc", label: r.desc }] }) }) + /* @__PURE__ */ _.jsxs(lt, { align: "start", wrap: !0, children: [ + /* @__PURE__ */ _.jsx(we.Item, { name: "default_sort", label: r.sort, rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(kt, { style: { minWidth: 220 }, options: GL.filter(([P]) => ["part_number", "name", "manufacturer", "brand", "lifecycle"].includes(P)).map(([P, T]) => ({ value: P, label: T })) }) }), + /* @__PURE__ */ _.jsx(we.Item, { name: "default_sort_direction", label: r.direction, rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(kt, { style: { minWidth: 160 }, options: [{ value: "asc", label: r.asc }, { value: "desc", label: r.desc }] }) }) ] }), - /* @__PURE__ */ N.jsx(we.Item, { name: "mobile_key_specs", label: r.mobile, children: /* @__PURE__ */ N.jsx(kt, { mode: "multiple", maxCount: 5, options: M }) }), - /* @__PURE__ */ N.jsxs(lt, { children: [ - /* @__PURE__ */ N.jsx(Ve, { type: "primary", htmlType: "submit", disabled: !y, children: r.save }), - /* @__PURE__ */ N.jsx(Ve, { onClick: () => { - _(); + /* @__PURE__ */ _.jsx(we.Item, { name: "mobile_key_specs", label: r.mobile, children: /* @__PURE__ */ _.jsx(kt, { mode: "multiple", maxCount: 5, options: M }) }), + /* @__PURE__ */ _.jsxs(lt, { children: [ + /* @__PURE__ */ _.jsx(Ve, { type: "primary", htmlType: "submit", disabled: !y, children: r.save }), + /* @__PURE__ */ _.jsx(Ve, { onClick: () => { + N(); }, disabled: !y || !s?.working.category_listing_profiles?.[y], children: r.remove }) ] }) ] }) @@ -81615,7 +81613,7 @@ Please change the parent to r.key === t && r.locale === n); } function Mve({ locale: e, onError: t, onMessage: n }) { - const r = Ive[e], [a, s] = l.useState(), [c, u] = l.useState("en-US"), [f, m] = l.useState(), [p, g] = l.useState(""), [v, y] = l.useState(), [b, S] = l.useState(""), [C, E] = l.useState(!1), [R, $] = l.useState(), [M, I] = l.useState(), [D, _] = l.useState(!1), [P, T] = l.useState("desktop"), A = async () => { + const r = Ive[e], [a, s] = l.useState(), [c, u] = l.useState("en-US"), [f, m] = l.useState(), [p, g] = l.useState(""), [v, y] = l.useState(), [b, S] = l.useState(""), [C, E] = l.useState(!1), [R, $] = l.useState(), [M, I] = l.useState(), [D, N] = l.useState(!1), [P, T] = l.useState("desktop"), A = async () => { try { const Q = await Rt("/admin/api/website/public-copy"); s(Q), u( @@ -81692,19 +81690,19 @@ Please change the parent to { if (R) { - _(!0); + N(!0); try { const Q = new FormData(); Q.append("file", R, R.name), I(await Rt("/admin/api/website/public-copy/import/preview", { method: "POST", body: Q })); } catch (Q) { t(Q); } finally { - _(!1); + N(!1); } } }, W = async () => { if (M?.fully_validated) { - _(!0); + N(!0); try { const Q = await Wt("/admin/api/website/public-copy/import/commit", { expected_working_revision: M.working_revision, @@ -81714,21 +81712,21 @@ Please change the parent to to to to to /* @__PURE__ */ N.jsx(Cn, { children: Q }) + render: (Q) => /* @__PURE__ */ _.jsx(Cn, { children: Q }) } ] } ) ] }) }), - F ? /* @__PURE__ */ N.jsx(Ot, { title: `${F.key} · ${c}`, children: /* @__PURE__ */ N.jsxs(lt, { direction: "vertical", size: "middle", className: "panel-stack", children: [ - /* @__PURE__ */ N.jsxs(xn, { size: "small", column: { xs: 1, sm: 2 }, children: [ - /* @__PURE__ */ N.jsx(xn.Item, { label: r.description, children: F.description }), - /* @__PURE__ */ N.jsx(xn.Item, { label: r.kind, children: F.value_kind }), - /* @__PURE__ */ N.jsx(xn.Item, { label: `${r.placeholders} (${r.required})`, children: F.required_placeholders.join(", ") || r.none }), - /* @__PURE__ */ N.jsx(xn.Item, { label: `${r.placeholders} (${r.allowed})`, children: F.allowed_placeholders.join(", ") || r.none }), - /* @__PURE__ */ N.jsx(xn.Item, { label: r.sample, span: 2, children: F.sample ? JSON.stringify(F.sample) : r.none }), - /* @__PURE__ */ N.jsx(xn.Item, { label: r.official, span: 2, children: /* @__PURE__ */ N.jsx(_t.Text, { code: !0, children: ee ?? "—" }) }), - /* @__PURE__ */ N.jsx(xn.Item, { label: r.whereUsed, span: 2, children: (F.where_used ?? []).map((Q) => /* @__PURE__ */ N.jsxs("div", { children: [ - /* @__PURE__ */ N.jsx("strong", { children: Q.surface }), + F ? /* @__PURE__ */ _.jsx(Ot, { title: `${F.key} · ${c}`, children: /* @__PURE__ */ _.jsxs(lt, { direction: "vertical", size: "middle", className: "panel-stack", children: [ + /* @__PURE__ */ _.jsxs(xn, { size: "small", column: { xs: 1, sm: 2 }, children: [ + /* @__PURE__ */ _.jsx(xn.Item, { label: r.description, children: F.description }), + /* @__PURE__ */ _.jsx(xn.Item, { label: r.kind, children: F.value_kind }), + /* @__PURE__ */ _.jsx(xn.Item, { label: `${r.placeholders} (${r.required})`, children: F.required_placeholders.join(", ") || r.none }), + /* @__PURE__ */ _.jsx(xn.Item, { label: `${r.placeholders} (${r.allowed})`, children: F.allowed_placeholders.join(", ") || r.none }), + /* @__PURE__ */ _.jsx(xn.Item, { label: r.sample, span: 2, children: F.sample ? JSON.stringify(F.sample) : r.none }), + /* @__PURE__ */ _.jsx(xn.Item, { label: r.official, span: 2, children: /* @__PURE__ */ _.jsx(_t.Text, { code: !0, children: ee ?? "—" }) }), + /* @__PURE__ */ _.jsx(xn.Item, { label: r.whereUsed, span: 2, children: (F.where_used ?? []).map((Q) => /* @__PURE__ */ _.jsxs("div", { children: [ + /* @__PURE__ */ _.jsx("strong", { children: Q.surface }), " · ", Q.section, " — ", Q.purpose, - /* @__PURE__ */ N.jsx("br", {}), - /* @__PURE__ */ N.jsx(_t.Text, { type: "secondary", children: Q.safe_context }) + /* @__PURE__ */ _.jsx("br", {}), + /* @__PURE__ */ _.jsx(_t.Text, { type: "secondary", children: Q.safe_context }) ] }, `${Q.surface}-${Q.section}`)) }) ] }), - !V && /* @__PURE__ */ N.jsx(Lt, { type: "info", showIcon: !0, message: r.noOverride }), - /* @__PURE__ */ N.jsx(_t.Text, { strong: !0, children: r.override }), - /* @__PURE__ */ N.jsx( + !V && /* @__PURE__ */ _.jsx(Lt, { type: "info", showIcon: !0, message: r.noOverride }), + /* @__PURE__ */ _.jsx(_t.Text, { strong: !0, children: r.override }), + /* @__PURE__ */ _.jsx( dt.TextArea, { autoSize: { minRows: 3, maxRows: 10 }, @@ -81816,9 +81814,9 @@ Please change the parent to S(Q.target.value) } ), - /* @__PURE__ */ N.jsxs(lt, { children: [ - /* @__PURE__ */ N.jsx(_t.Text, { strong: !0, children: r.contextPreview }), - /* @__PURE__ */ N.jsx( + /* @__PURE__ */ _.jsxs(lt, { children: [ + /* @__PURE__ */ _.jsx(_t.Text, { strong: !0, children: r.contextPreview }), + /* @__PURE__ */ _.jsx( kt, { value: P, @@ -81827,7 +81825,7 @@ Please change the parent to to { + /* @__PURE__ */ _.jsxs(lt, { children: [ + /* @__PURE__ */ _.jsx(Ve, { type: "primary", loading: C, onClick: () => { U(); }, children: r.save }), - /* @__PURE__ */ N.jsx(Lr, { title: r.resetConfirm, onConfirm: () => { + /* @__PURE__ */ _.jsx(Lr, { title: r.resetConfirm, onConfirm: () => { H(); - }, children: /* @__PURE__ */ N.jsx(Ve, { disabled: !V || C, children: r.reset }) }) + }, children: /* @__PURE__ */ _.jsx(Ve, { disabled: !V || C, children: r.reset }) }) ] }) - ] }) }) : /* @__PURE__ */ N.jsx(Ot, { children: /* @__PURE__ */ N.jsx(Ha, { description: r.selectEntry }) }), - /* @__PURE__ */ N.jsx(Ot, { title: r.exchangeTitle, children: /* @__PURE__ */ N.jsxs(lt, { direction: "vertical", size: "middle", className: "panel-stack", children: [ - /* @__PURE__ */ N.jsx(_t.Paragraph, { children: r.exchangeHelp }), - /* @__PURE__ */ N.jsxs(lt, { wrap: !0, children: [ - /* @__PURE__ */ N.jsx(Ve, { onClick: () => { + ] }) }) : /* @__PURE__ */ _.jsx(Ot, { children: /* @__PURE__ */ _.jsx(Ha, { description: r.selectEntry }) }), + /* @__PURE__ */ _.jsx(Ot, { title: r.exchangeTitle, children: /* @__PURE__ */ _.jsxs(lt, { direction: "vertical", size: "middle", className: "panel-stack", children: [ + /* @__PURE__ */ _.jsx(_t.Paragraph, { children: r.exchangeHelp }), + /* @__PURE__ */ _.jsxs(lt, { wrap: !0, children: [ + /* @__PURE__ */ _.jsx(Ve, { onClick: () => { K("csv"); }, children: r.exportCSV }), - /* @__PURE__ */ N.jsx(Ve, { onClick: () => { + /* @__PURE__ */ _.jsx(Ve, { onClick: () => { K("xlsx"); }, children: r.exportXLSX }), - /* @__PURE__ */ N.jsx( + /* @__PURE__ */ _.jsx( Pl, { accept: ".csv,.xlsx,text/csv,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", @@ -81869,13 +81867,13 @@ Please change the parent to { $(void 0), I(void 0); }, - children: /* @__PURE__ */ N.jsx(Ve, { children: r.chooseExchange }) + children: /* @__PURE__ */ _.jsx(Ve, { children: r.chooseExchange }) } ), - /* @__PURE__ */ N.jsx(Ve, { type: "primary", disabled: !R, loading: D, onClick: () => { + /* @__PURE__ */ _.jsx(Ve, { type: "primary", disabled: !R, loading: D, onClick: () => { G(); }, children: r.validateExchange }), - /* @__PURE__ */ N.jsx( + /* @__PURE__ */ _.jsx( Ve, { type: "primary", @@ -81888,8 +81886,8 @@ Please change the parent to to 0 && /* @__PURE__ */ N.jsx( + M.issues.length > 0 && /* @__PURE__ */ _.jsx( In, { size: "small", @@ -81913,7 +81911,7 @@ Please change the parent to to ({ value: T.id, label: T.name })) : f.map((T) => ({ value: T.id, label: T.name })), P = v?.receipt?.results.filter((T) => m0(T.status)) ?? []; - return /* @__PURE__ */ N.jsxs(lt, { direction: "vertical", size: "large", className: "panel-stack", children: [ - /* @__PURE__ */ N.jsx(Lt, { showIcon: !0, type: "info", message: r.title, description: r.help }), - /* @__PURE__ */ N.jsxs(Ot, { title: r.selection, children: [ - /* @__PURE__ */ N.jsx( + }, D = E === "change_category" || E === "change_lifecycle", N = E === "change_category" ? c.map((T) => ({ value: T.id, label: T.name })) : f.map((T) => ({ value: T.id, label: T.name })), P = v?.receipt?.results.filter((T) => m0(T.status)) ?? []; + return /* @__PURE__ */ _.jsxs(lt, { direction: "vertical", size: "large", className: "panel-stack", children: [ + /* @__PURE__ */ _.jsx(Lt, { showIcon: !0, type: "info", message: r.title, description: r.help }), + /* @__PURE__ */ _.jsxs(Ot, { title: r.selection, children: [ + /* @__PURE__ */ _.jsx( In, { size: "small", @@ -82315,10 +82313,10 @@ Please change the parent to { + /* @__PURE__ */ _.jsxs(we, { form: C, layout: "inline", initialValues: { action: "publish" }, onFinish: (T) => { $(T); }, children: [ - /* @__PURE__ */ N.jsx(we.Item, { name: "action", label: r.action, rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx( + /* @__PURE__ */ _.jsx(we.Item, { name: "action", label: r.action, rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx( kt, { style: { width: 210 }, @@ -82326,26 +82324,26 @@ Please change the parent to C.setFieldValue("target_id", void 0) } ) }), - D && /* @__PURE__ */ N.jsx(we.Item, { name: "target_id", label: r.target, rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(kt, { showSearch: !0, optionFilterProp: "label", style: { width: 240 }, options: _ }) }), - /* @__PURE__ */ N.jsx(Ve, { htmlType: "submit", type: "primary", loading: b, children: r.preflight }) + D && /* @__PURE__ */ _.jsx(we.Item, { name: "target_id", label: r.target, rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(kt, { showSearch: !0, optionFilterProp: "label", style: { width: 240 }, options: N }) }), + /* @__PURE__ */ _.jsx(Ve, { htmlType: "submit", type: "primary", loading: b, children: r.preflight }) ] }) ] }), - v && /* @__PURE__ */ N.jsx( + v && /* @__PURE__ */ _.jsx( Ot, { title: `${r.actions[v.preview.action]} · ${v.preview.run_id}`, - extra: v.status !== "completed" && /* @__PURE__ */ N.jsx(Lr, { title: r.confirm, onConfirm: () => { + extra: v.status !== "completed" && /* @__PURE__ */ _.jsx(Lr, { title: r.confirm, onConfirm: () => { M(); - }, children: /* @__PURE__ */ N.jsx(Ve, { type: "primary", loading: b, children: r.execute }) }), - children: /* @__PURE__ */ N.jsxs(lt, { direction: "vertical", size: "middle", className: "panel-stack", children: [ - /* @__PURE__ */ N.jsxs(xn, { size: "small", column: { xs: 1, sm: 3 }, children: [ - /* @__PURE__ */ N.jsx(xn.Item, { label: r.selection, children: v.preview.selection_count }), - /* @__PURE__ */ N.jsx(xn.Item, { label: r.eligible, children: v.preview.eligible_count }), - /* @__PURE__ */ N.jsx(xn.Item, { label: r.state, children: v.status }) + }, children: /* @__PURE__ */ _.jsx(Ve, { type: "primary", loading: b, children: r.execute }) }), + children: /* @__PURE__ */ _.jsxs(lt, { direction: "vertical", size: "middle", className: "panel-stack", children: [ + /* @__PURE__ */ _.jsxs(xn, { size: "small", column: { xs: 1, sm: 3 }, children: [ + /* @__PURE__ */ _.jsx(xn.Item, { label: r.selection, children: v.preview.selection_count }), + /* @__PURE__ */ _.jsx(xn.Item, { label: r.eligible, children: v.preview.eligible_count }), + /* @__PURE__ */ _.jsx(xn.Item, { label: r.state, children: v.status }) ] }), - /* @__PURE__ */ N.jsx(_t.Text, { children: r.previewSummary(v.preview.eligible_count, v.preview.selection_count) }), - v.preview.warning && /* @__PURE__ */ N.jsx(Lt, { type: "warning", showIcon: !0, message: v.preview.warning }), - /* @__PURE__ */ N.jsx( + /* @__PURE__ */ _.jsx(_t.Text, { children: r.previewSummary(v.preview.eligible_count, v.preview.selection_count) }), + v.preview.warning && /* @__PURE__ */ _.jsx(Lt, { type: "warning", showIcon: !0, message: v.preview.warning }), + /* @__PURE__ */ _.jsx( In, { size: "small", @@ -82356,13 +82354,13 @@ Please change the parent to `${A.record_state} / ${A.publishing_state}` }, - { title: r.eligible, render: (T, A) => /* @__PURE__ */ N.jsx(Cn, { color: A.eligible ? "green" : "red", children: A.eligible ? r.yes : r.no }) }, + { title: r.eligible, render: (T, A) => /* @__PURE__ */ _.jsx(Cn, { color: A.eligible ? "green" : "red", children: A.eligible ? r.yes : r.no }) }, { title: r.message, dataIndex: "message" } ] } ), - v.receipt && /* @__PURE__ */ N.jsxs(N.Fragment, { children: [ - /* @__PURE__ */ N.jsx( + v.receipt && /* @__PURE__ */ _.jsxs(_.Fragment, { children: [ + /* @__PURE__ */ _.jsx( In, { size: "small", @@ -82371,13 +82369,13 @@ Please change the parent to /* @__PURE__ */ N.jsx(Cn, { color: T === "succeeded" ? "green" : m0(T) ? "red" : "blue", children: T }) }, + { title: r.result, dataIndex: "status", render: (T) => /* @__PURE__ */ _.jsx(Cn, { color: T === "succeeded" ? "green" : m0(T) ? "red" : "blue", children: T }) }, { title: r.revision, dataIndex: "result_revision" }, { title: r.message, dataIndex: "message" } ] } ), - /* @__PURE__ */ N.jsxs(Ve, { disabled: P.length === 0, onClick: () => { + /* @__PURE__ */ _.jsxs(Ve, { disabled: P.length === 0, onClick: () => { I(); }, children: [ r.retryUnresolved, @@ -82617,16 +82615,16 @@ Please change the parent to { + extra: /* @__PURE__ */ _.jsx(Ve, { onClick: () => { g(); }, loading: c, children: r.refresh }), - children: /* @__PURE__ */ N.jsxs(lt, { direction: "vertical", size: "large", className: "panel-stack", children: [ - /* @__PURE__ */ N.jsx(Lt, { type: "info", showIcon: !0, message: r.explanation }), - /* @__PURE__ */ N.jsxs( + children: /* @__PURE__ */ _.jsxs(lt, { direction: "vertical", size: "large", className: "panel-stack", children: [ + /* @__PURE__ */ _.jsx(Lt, { type: "info", showIcon: !0, message: r.explanation }), + /* @__PURE__ */ _.jsxs( we, { form: f, @@ -82635,16 +82633,16 @@ Please change the parent to to to { + return /* @__PURE__ */ _.jsx($r, { open: !!e, title: `${c.title} · ${e?.name ?? ""}`, footer: null, onCancel: r, width: 760, destroyOnHidden: !0, children: u || !m || !e ? /* @__PURE__ */ _.jsx(wu, {}) : /* @__PURE__ */ _.jsx(tp, { items: [ + { key: "source", label: c.perField, children: /* @__PURE__ */ _.jsxs(we, { form: y, layout: "vertical", onFinish: ($) => { R($); }, children: [ - /* @__PURE__ */ N.jsx(Lt, { className: "bottom-gap", type: "info", showIcon: !0, message: c.sourceHelp }), - /* @__PURE__ */ N.jsx(we.Item, { name: "source_locale", label: c.source, rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(kt, { disabled: !n, options: C }) }), - /* @__PURE__ */ N.jsx(Ot, { size: "small", title: c.perField, children: ["name", "description"].map(($) => /* @__PURE__ */ N.jsx(we.Item, { name: ["source_locales", $], label: c[$], rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(kt, { disabled: !n, options: C }) }, $)) }), - n ? /* @__PURE__ */ N.jsx(Ve, { type: "primary", htmlType: "submit", loading: u, children: c.saveSource }) : null + /* @__PURE__ */ _.jsx(Lt, { className: "bottom-gap", type: "info", showIcon: !0, message: c.sourceHelp }), + /* @__PURE__ */ _.jsx(we.Item, { name: "source_locale", label: c.source, rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(kt, { disabled: !n, options: C }) }), + /* @__PURE__ */ _.jsx(Ot, { size: "small", title: c.perField, children: ["name", "description"].map(($) => /* @__PURE__ */ _.jsx(we.Item, { name: ["source_locales", $], label: c[$], rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(kt, { disabled: !n, options: C }) }, $)) }), + n ? /* @__PURE__ */ _.jsx(Ve, { type: "primary", htmlType: "submit", loading: u, children: c.saveSource }) : null ] }) }, ...S.filter(($) => $ !== m.source_locale).map(($) => ({ key: $, label: Es($), - children: /* @__PURE__ */ N.jsx(Fve, { target: e, content: m, locale: $, item: m.translations?.find((M) => M.locale === $), text: c, canEdit: n, onSaved: E, onError: a }) + children: /* @__PURE__ */ _.jsx(Fve, { target: e, content: m, locale: $, item: m.translations?.find((M) => M.locale === $), text: c, canEdit: n, onSaved: E, onError: a }) })) ] }) }); } @@ -82819,13 +82817,13 @@ Please change the parent to { + return /* @__PURE__ */ _.jsxs(we, { form: p, layout: "vertical", onFinish: (v) => { g(v); }, children: [ - /* @__PURE__ */ N.jsx(Lt, { className: "bottom-gap", type: "info", showIcon: !0, message: a.empty }), - /* @__PURE__ */ N.jsx(we.Item, { name: "name", label: a.name, children: /* @__PURE__ */ N.jsx(dt, { disabled: !s }) }), - /* @__PURE__ */ N.jsx(we.Item, { name: "description", label: a.description, children: /* @__PURE__ */ N.jsx(dt.TextArea, { disabled: !s, rows: 4 }) }), - s ? /* @__PURE__ */ N.jsx(Ve, { type: "primary", htmlType: "submit", loading: f, children: a.save }) : null + /* @__PURE__ */ _.jsx(Lt, { className: "bottom-gap", type: "info", showIcon: !0, message: a.empty }), + /* @__PURE__ */ _.jsx(we.Item, { name: "name", label: a.name, children: /* @__PURE__ */ _.jsx(dt, { disabled: !s }) }), + /* @__PURE__ */ _.jsx(we.Item, { name: "description", label: a.description, children: /* @__PURE__ */ _.jsx(dt.TextArea, { disabled: !s, rows: 4 }) }), + s ? /* @__PURE__ */ _.jsx(Ve, { type: "primary", htmlType: "submit", loading: f, children: a.save }) : null ] }); } const U4 = { @@ -83381,7 +83379,7 @@ Please change the parent to { K && t(K); }, [K]); @@ -83422,11 +83420,11 @@ Please change the parent to { - if (!(!E || !_)) { + if (!(!E || !N)) { O(!0); try { const Z = await U.validateFields(); - await fa.updateCategory(E, Z), n(r.updated(E.name, _.affected_products.length)), R(void 0), P(void 0), await G(gi.category); + await fa.updateCategory(E, Z), n(r.updated(E.name, N.affected_products.length)), R(void 0), P(void 0), await G(gi.category); } catch (Z) { t(Z); } finally { @@ -83476,26 +83474,26 @@ Please change the parent to { + children: /* @__PURE__ */ _.jsxs(lt, { direction: "vertical", size: "large", className: "panel-stack", children: [ + s ? /* @__PURE__ */ _.jsx(Ot, { title: r.newCategory, children: /* @__PURE__ */ _.jsxs(we, { form: L, layout: "inline", onFinish: (Z) => { B(Z); }, children: [ - /* @__PURE__ */ N.jsx(we.Item, { name: "name", rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(dt, { placeholder: r.name }) }), - /* @__PURE__ */ N.jsx(we.Item, { name: "slug", rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(dt, { placeholder: r.slug }) }), - /* @__PURE__ */ N.jsx(we.Item, { name: "parent_id", children: /* @__PURE__ */ N.jsx(kt, { allowClear: !0, showSearch: !0, optionFilterProp: "label", placeholder: r.parent, style: { minWidth: 180 }, options: y.filter((Z) => Z.status === "active").map((Z) => ({ value: Z.id, label: Z.name })) }) }), - /* @__PURE__ */ N.jsx(Ve, { type: "primary", htmlType: "submit", children: r.create }) + /* @__PURE__ */ _.jsx(we.Item, { name: "name", rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(dt, { placeholder: r.name }) }), + /* @__PURE__ */ _.jsx(we.Item, { name: "slug", rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(dt, { placeholder: r.slug }) }), + /* @__PURE__ */ _.jsx(we.Item, { name: "parent_id", children: /* @__PURE__ */ _.jsx(kt, { allowClear: !0, showSearch: !0, optionFilterProp: "label", placeholder: r.parent, style: { minWidth: 180 }, options: y.filter((Z) => Z.status === "active").map((Z) => ({ value: Z.id, label: Z.name })) }) }), + /* @__PURE__ */ _.jsx(Ve, { type: "primary", htmlType: "submit", children: r.create }) ] }) }) : null, - /* @__PURE__ */ N.jsx(Ot, { title: r.categoryData, extra: /* @__PURE__ */ N.jsx(Ve, { onClick: () => { + /* @__PURE__ */ _.jsx(Ot, { title: r.categoryData, extra: /* @__PURE__ */ _.jsx(Ve, { onClick: () => { W(); - }, children: r.refresh }), children: /* @__PURE__ */ N.jsx( + }, children: r.refresh }), children: /* @__PURE__ */ _.jsx( In, { rowKey: "id", @@ -83506,16 +83504,16 @@ Please change the parent to y.find((xe) => xe.id === me.parent_id)?.name ?? me.parent_id ?? r.root }, - { title: r.status, render: (Z, me) => /* @__PURE__ */ N.jsx(Cn, { color: me.status === "active" ? "green" : "default", children: me.status === "active" ? r.active : r.disabled }) }, + { title: r.status, render: (Z, me) => /* @__PURE__ */ _.jsx(Cn, { color: me.status === "active" ? "green" : "default", children: me.status === "active" ? r.active : r.disabled }) }, { title: r.revision, dataIndex: "revision" }, { title: r.actions, - render: (Z, me) => me.system_key === "root" ? null : /* @__PURE__ */ N.jsxs(lt, { children: [ - s ? /* @__PURE__ */ N.jsx(Ve, { size: "small", onClick: () => ie(me), children: r.edit }) : null, - /* @__PURE__ */ N.jsx(Ve, { size: "small", onClick: () => D({ type: "category", id: me.id, name: me.name }), children: r.translate }), - s && !me.system_key && me.status !== "disabled" ? /* @__PURE__ */ N.jsx(Lr, { title: r.disableCategoryConfirm, onConfirm: () => { + render: (Z, me) => me.system_key === "root" ? null : /* @__PURE__ */ _.jsxs(lt, { children: [ + s ? /* @__PURE__ */ _.jsx(Ve, { size: "small", onClick: () => ie(me), children: r.edit }) : null, + /* @__PURE__ */ _.jsx(Ve, { size: "small", onClick: () => D({ type: "category", id: me.id, name: me.name }), children: r.translate }), + s && !me.system_key && me.status !== "disabled" ? /* @__PURE__ */ _.jsx(Lr, { title: r.disableCategoryConfirm, onConfirm: () => { re(me); - }, children: /* @__PURE__ */ N.jsx(Ve, { size: "small", danger: !0, children: r.disable }) }) : null + }, children: /* @__PURE__ */ _.jsx(Ve, { size: "small", danger: !0, children: r.disable }) }) : null ] }) } ] @@ -83526,9 +83524,9 @@ Please change the parent to to { + s ? /* @__PURE__ */ _.jsxs(we, { form: F, layout: "inline", onFinish: (Z) => { J(Z); }, children: [ - /* @__PURE__ */ N.jsx(we.Item, { name: "name", rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(dt, { placeholder: r.name }) }), - /* @__PURE__ */ N.jsx(we.Item, { name: "slug", children: /* @__PURE__ */ N.jsx(dt, { placeholder: r.optionalSlug }) }), - /* @__PURE__ */ N.jsx(Ve, { type: "primary", htmlType: "submit", children: r.create }) + /* @__PURE__ */ _.jsx(we.Item, { name: "name", rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(dt, { placeholder: r.name }) }), + /* @__PURE__ */ _.jsx(we.Item, { name: "slug", children: /* @__PURE__ */ _.jsx(dt, { placeholder: r.optionalSlug }) }), + /* @__PURE__ */ _.jsx(Ve, { type: "primary", htmlType: "submit", children: r.create }) ] }) : null ] }) }), - /* @__PURE__ */ N.jsx(Ot, { title: r.kinds[u], extra: /* @__PURE__ */ N.jsx(Ve, { onClick: () => { + /* @__PURE__ */ _.jsx(Ot, { title: r.kinds[u], extra: /* @__PURE__ */ _.jsx(Ve, { onClick: () => { Q(); - }, children: r.refresh }), children: /* @__PURE__ */ N.jsx( + }, children: r.refresh }), children: /* @__PURE__ */ _.jsx( In, { rowKey: "id", @@ -83557,16 +83555,16 @@ Please change the parent to Z || "—" }, - { title: r.status, render: (Z, me) => /* @__PURE__ */ N.jsx(Cn, { color: me.status === "active" ? "green" : "default", children: me.status === "active" ? r.active : r.disabled }) }, + { title: r.status, render: (Z, me) => /* @__PURE__ */ _.jsx(Cn, { color: me.status === "active" ? "green" : "default", children: me.status === "active" ? r.active : r.disabled }) }, { title: r.revision, dataIndex: "revision" }, { title: r.actions, - render: (Z, me) => /* @__PURE__ */ N.jsxs(lt, { children: [ - s ? /* @__PURE__ */ N.jsx(Ve, { size: "small", onClick: () => te(me), children: r.edit }) : null, - /* @__PURE__ */ N.jsx(Ve, { size: "small", onClick: () => D({ type: "dictionary", id: me.id, name: me.name }), children: r.translate }), - s && me.status !== "disabled" ? /* @__PURE__ */ N.jsx(Lr, { title: r.disableValueConfirm, onConfirm: () => { + render: (Z, me) => /* @__PURE__ */ _.jsxs(lt, { children: [ + s ? /* @__PURE__ */ _.jsx(Ve, { size: "small", onClick: () => te(me), children: r.edit }) : null, + /* @__PURE__ */ _.jsx(Ve, { size: "small", onClick: () => D({ type: "dictionary", id: me.id, name: me.name }), children: r.translate }), + s && me.status !== "disabled" ? /* @__PURE__ */ _.jsx(Lr, { title: r.disableValueConfirm, onConfirm: () => { le(me); - }, children: /* @__PURE__ */ N.jsx(Ve, { size: "small", danger: !0, children: r.disable }) }) : null + }, children: /* @__PURE__ */ _.jsx(Ve, { size: "small", danger: !0, children: r.disable }) }) : null ] }) } ] @@ -83577,34 +83575,34 @@ Please change the parent to { + children: /* @__PURE__ */ _.jsxs(lt, { direction: "vertical", size: "large", className: "panel-stack", children: [ + s ? /* @__PURE__ */ _.jsx(Ot, { title: r.newSpec, children: /* @__PURE__ */ _.jsxs(we, { form: V, layout: "inline", initialValues: { filterable: !1, semantic_version: 1 }, onFinish: (Z) => { pe(Z); }, children: [ - /* @__PURE__ */ N.jsx(we.Item, { name: "name", rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(dt, { placeholder: r.name }) }), - /* @__PURE__ */ N.jsx(we.Item, { name: "preferred_unit", children: /* @__PURE__ */ N.jsx(dt, { placeholder: r.preferredUnit }) }), - /* @__PURE__ */ N.jsx(we.Item, { name: "semantic_version", rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(Jo, { min: 1, precision: 0 }) }), - /* @__PURE__ */ N.jsx(we.Item, { name: "filterable", valuePropName: "checked", children: /* @__PURE__ */ N.jsx(no, { children: r.filterable }) }), - /* @__PURE__ */ N.jsx(Ve, { type: "primary", htmlType: "submit", children: r.create }) + /* @__PURE__ */ _.jsx(we.Item, { name: "name", rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(dt, { placeholder: r.name }) }), + /* @__PURE__ */ _.jsx(we.Item, { name: "preferred_unit", children: /* @__PURE__ */ _.jsx(dt, { placeholder: r.preferredUnit }) }), + /* @__PURE__ */ _.jsx(we.Item, { name: "semantic_version", rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(Jo, { min: 1, precision: 0 }) }), + /* @__PURE__ */ _.jsx(we.Item, { name: "filterable", valuePropName: "checked", children: /* @__PURE__ */ _.jsx(no, { children: r.filterable }) }), + /* @__PURE__ */ _.jsx(Ve, { type: "primary", htmlType: "submit", children: r.create }) ] }) }) : null, - s ? /* @__PURE__ */ N.jsx(Ot, { title: r.newSpecSet, children: /* @__PURE__ */ N.jsxs(we, { form: ee, layout: "inline", onFinish: (Z) => { + s ? /* @__PURE__ */ _.jsx(Ot, { title: r.newSpecSet, children: /* @__PURE__ */ _.jsxs(we, { form: ee, layout: "inline", onFinish: (Z) => { oe(Z); }, children: [ - /* @__PURE__ */ N.jsx(we.Item, { name: "name", rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(dt, { placeholder: r.name }) }), - /* @__PURE__ */ N.jsx(we.Item, { name: "spec_ids", rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(kt, { mode: "multiple", placeholder: r.specifications, style: { minWidth: 320 }, options: S.filter((Z) => Z.status === "active").map((Z) => ({ value: Z.id, label: Z.name })) }) }), - /* @__PURE__ */ N.jsx(Ve, { type: "primary", htmlType: "submit", children: r.create }) + /* @__PURE__ */ _.jsx(we.Item, { name: "name", rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(dt, { placeholder: r.name }) }), + /* @__PURE__ */ _.jsx(we.Item, { name: "spec_ids", rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(kt, { mode: "multiple", placeholder: r.specifications, style: { minWidth: 320 }, options: S.filter((Z) => Z.status === "active").map((Z) => ({ value: Z.id, label: Z.name })) }) }), + /* @__PURE__ */ _.jsx(Ve, { type: "primary", htmlType: "submit", children: r.create }) ] }) }) : null, - s ? /* @__PURE__ */ N.jsx(Ot, { title: r.assignSpecSet, children: /* @__PURE__ */ N.jsxs(we, { form: X, layout: "inline", onFinish: (Z) => { + s ? /* @__PURE__ */ _.jsx(Ot, { title: r.assignSpecSet, children: /* @__PURE__ */ _.jsxs(we, { form: X, layout: "inline", onFinish: (Z) => { ce(Z); }, children: [ - /* @__PURE__ */ N.jsx(we.Item, { name: "category_id", rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(kt, { placeholder: r.category, style: { minWidth: 220 }, options: y.filter((Z) => Z.status === "active" && !Z.system_key).map((Z) => ({ value: Z.id, label: Z.name })) }) }), - /* @__PURE__ */ N.jsx(we.Item, { name: "spec_set_id", rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(kt, { placeholder: r.specSet, style: { minWidth: 220 }, options: C.filter((Z) => Z.status === "active").map((Z) => ({ value: Z.id, label: Z.name })) }) }), - /* @__PURE__ */ N.jsx(Ve, { type: "primary", htmlType: "submit", children: r.assign }) + /* @__PURE__ */ _.jsx(we.Item, { name: "category_id", rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(kt, { placeholder: r.category, style: { minWidth: 220 }, options: y.filter((Z) => Z.status === "active" && !Z.system_key).map((Z) => ({ value: Z.id, label: Z.name })) }) }), + /* @__PURE__ */ _.jsx(we.Item, { name: "spec_set_id", rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(kt, { placeholder: r.specSet, style: { minWidth: 220 }, options: C.filter((Z) => Z.status === "active").map((Z) => ({ value: Z.id, label: Z.name })) }) }), + /* @__PURE__ */ _.jsx(Ve, { type: "primary", htmlType: "submit", children: r.assign }) ] }) }) : null, - /* @__PURE__ */ N.jsxs(Ot, { title: r.definitions, extra: /* @__PURE__ */ N.jsx(Ve, { onClick: () => { + /* @__PURE__ */ _.jsxs(Ot, { title: r.definitions, extra: /* @__PURE__ */ _.jsx(Ve, { onClick: () => { k(); }, children: r.refresh }), children: [ - /* @__PURE__ */ N.jsx( + /* @__PURE__ */ _.jsx( In, { rowKey: "id", @@ -83620,7 +83618,7 @@ Please change the parent to to { q(); @@ -83657,10 +83655,10 @@ Please change the parent to P(void 0), children: [ - /* @__PURE__ */ N.jsx(we.Item, { name: "name", label: r.name, rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(dt, { disabled: E?.system_key === "uncategorized" }) }), - /* @__PURE__ */ N.jsx(we.Item, { name: "slug", label: r.slug, rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(dt, {}) }), - /* @__PURE__ */ N.jsx(we.Item, { name: "parent_id", label: r.parent, children: /* @__PURE__ */ N.jsx( + /* @__PURE__ */ _.jsxs(we, { form: U, layout: "vertical", onValuesChange: () => P(void 0), children: [ + /* @__PURE__ */ _.jsx(we.Item, { name: "name", label: r.name, rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(dt, { disabled: E?.system_key === "uncategorized" }) }), + /* @__PURE__ */ _.jsx(we.Item, { name: "slug", label: r.slug, rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(dt, {}) }), + /* @__PURE__ */ _.jsx(we.Item, { name: "parent_id", label: r.parent, children: /* @__PURE__ */ _.jsx( kt, { disabled: E?.system_key === "uncategorized", @@ -83670,14 +83668,14 @@ Please change the parent to { + /* @__PURE__ */ _.jsx(Ve, { onClick: () => { Y(); }, children: r.previewAffected }), - _ ? /* @__PURE__ */ N.jsx(YL, { impact: _, locale: e }) : null + N ? /* @__PURE__ */ _.jsx(YL, { impact: N, locale: e }) : null ] } ), - /* @__PURE__ */ N.jsxs( + /* @__PURE__ */ _.jsxs( $r, { open: !!$, @@ -83692,18 +83690,18 @@ Please change the parent to A(void 0), children: [ - /* @__PURE__ */ N.jsx(we.Item, { name: "name", label: r.name, rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(dt, {}) }), - /* @__PURE__ */ N.jsx(we.Item, { name: "slug", label: r.slug, children: /* @__PURE__ */ N.jsx(dt, {}) }) + /* @__PURE__ */ _.jsxs(we, { form: H, layout: "vertical", onValuesChange: () => A(void 0), children: [ + /* @__PURE__ */ _.jsx(we.Item, { name: "name", label: r.name, rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(dt, {}) }), + /* @__PURE__ */ _.jsx(we.Item, { name: "slug", label: r.slug, children: /* @__PURE__ */ _.jsx(dt, {}) }) ] }), - /* @__PURE__ */ N.jsx(Ve, { onClick: () => { + /* @__PURE__ */ _.jsx(Ve, { onClick: () => { de(); }, children: r.previewAffected }), - T ? /* @__PURE__ */ N.jsx(YL, { impact: T, locale: e }) : null + T ? /* @__PURE__ */ _.jsx(YL, { impact: T, locale: e }) : null ] } ), - /* @__PURE__ */ N.jsx( + /* @__PURE__ */ _.jsx( jve, { target: I, @@ -83720,14 +83718,14 @@ Please change the parent to /* @__PURE__ */ N.jsxs("li", { children: [ + return /* @__PURE__ */ _.jsxs("div", { className: "top-gap", children: [ + /* @__PURE__ */ _.jsx("strong", { children: n.affected(e.affected_products.length) }), + e.affected_products.length ? /* @__PURE__ */ _.jsx("ul", { children: e.affected_products.map((r) => /* @__PURE__ */ _.jsxs("li", { children: [ r.part_number, ": ", r.current_route || n.notPublic, r.proposed_route && r.proposed_route !== r.current_route ? ` → ${r.proposed_route}` : "" - ] }, r.product_id)) }) : /* @__PURE__ */ N.jsx("p", { children: n.noChanges }) + ] }, r.product_id)) }) : /* @__PURE__ */ _.jsx("p", { children: n.noChanges }) ] }); } const Hve = { @@ -83865,17 +83863,17 @@ Please change the parent to { + return /* @__PURE__ */ _.jsx(Ot, { title: r.title, loading: !s && u, children: /* @__PURE__ */ _.jsxs(lt, { direction: "vertical", size: "middle", className: "panel-stack", children: [ + /* @__PURE__ */ _.jsx(_t.Paragraph, { children: r.explanation }), + /* @__PURE__ */ _.jsx(Lt, { type: "info", showIcon: !0, message: s?.trusted_proxy_configured ? r.proxyConfigured : r.proxy }), + /* @__PURE__ */ _.jsxs(we, { form: a, layout: "vertical", onFinish: (v) => { g(v); }, children: [ - /* @__PURE__ */ N.jsxs(lt, { wrap: !0, align: "start", children: [ - /* @__PURE__ */ N.jsx(we.Item, { name: "rfq_limit", label: r.limit, rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(Jo, { min: 1, max: 1e4, precision: 0 }) }), - /* @__PURE__ */ N.jsx(we.Item, { name: "rfq_window_seconds", label: r.window, rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(Jo, { min: 1, max: 86400, precision: 0 }) }) + /* @__PURE__ */ _.jsxs(lt, { wrap: !0, align: "start", children: [ + /* @__PURE__ */ _.jsx(we.Item, { name: "rfq_limit", label: r.limit, rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(Jo, { min: 1, max: 1e4, precision: 0 }) }), + /* @__PURE__ */ _.jsx(we.Item, { name: "rfq_window_seconds", label: r.window, rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(Jo, { min: 1, max: 86400, precision: 0 }) }) ] }), - /* @__PURE__ */ N.jsx("div", { children: /* @__PURE__ */ N.jsx(Ve, { type: "primary", htmlType: "submit", loading: u, children: r.save }) }) + /* @__PURE__ */ _.jsx("div", { children: /* @__PURE__ */ _.jsx(Ve, { type: "primary", htmlType: "submit", loading: u, children: r.save }) }) ] }) ] }) }); } @@ -85152,8 +85150,8 @@ Please change the parent to to to to { + const r = Zve[e], a = Xve(e), s = Yve[e], [c, u] = l.useState(), [f, m] = l.useState(), [p, g] = l.useState(), [v, y] = l.useState([]), [b, S] = l.useState(), [C, E] = l.useState(), [R, $] = l.useState(""), [M, I] = l.useState(), [D, N] = l.useState(""), [P, T] = l.useState(!1), [A, z] = l.useState(!1), [O, L] = l.useState(!1), [F, U] = l.useState(!1), [H] = we.useForm(), [V] = we.useForm(), [ee] = we.useForm(), [X] = we.useForm(), K = async () => { L(!0); try { const [oe, ce, Z, me] = await Promise.all([ @@ -85421,9 +85419,9 @@ Please change the parent to to { + action: /* @__PURE__ */ _.jsx(Ve, { onClick: () => { de(!1); }, loading: O, children: r.reenableCSS }) } - ) : /* @__PURE__ */ N.jsx(Lr, { title: r.disableCSSConfirm, onConfirm: () => { + ) : /* @__PURE__ */ _.jsx(Lr, { title: r.disableCSSConfirm, onConfirm: () => { de(!0); - }, children: /* @__PURE__ */ N.jsx(Ve, { danger: !0, loading: O, children: r.disableCSS }) }), - /* @__PURE__ */ N.jsx(_t.Paragraph, { type: "secondary", children: r.workingHelp }), - /* @__PURE__ */ N.jsx( + }, children: /* @__PURE__ */ _.jsx(Ve, { danger: !0, loading: O, children: r.disableCSS }) }), + /* @__PURE__ */ _.jsx(_t.Paragraph, { type: "secondary", children: r.workingHelp }), + /* @__PURE__ */ _.jsx( we, { form: ee, @@ -85458,8 +85456,8 @@ Please change the parent to { W(oe); }, - children: /* @__PURE__ */ N.jsxs(Ot, { size: "small", title: r.localization, children: [ - /* @__PURE__ */ N.jsx( + children: /* @__PURE__ */ _.jsxs(Ot, { size: "small", title: r.localization, children: [ + /* @__PURE__ */ _.jsx( Lt, { className: "bottom-gap", @@ -85469,16 +85467,16 @@ Please change the parent to to _(oe.target.value), placeholder: "https://www.example.com/", "aria-label": a.sourceURL }), - /* @__PURE__ */ N.jsx(Ve, { onClick: () => { + /* @__PURE__ */ _.jsxs(Ot, { size: "small", title: a.title, children: [ + /* @__PURE__ */ _.jsx(_t.Paragraph, { type: "secondary", children: a.help }), + /* @__PURE__ */ _.jsx(Lt, { type: "info", showIcon: !0, message: a.exactSource }), + /* @__PURE__ */ _.jsxs(lt.Compact, { block: !0, children: [ + /* @__PURE__ */ _.jsx(dt, { type: "url", value: D, onChange: (oe) => N(oe.target.value), placeholder: "https://www.example.com/", "aria-label": a.sourceURL }), + /* @__PURE__ */ _.jsx(Ve, { onClick: () => { le(); }, disabled: !D.trim() || A, loading: O, children: a.generate }) ] }), - A && /* @__PURE__ */ N.jsx(Lt, { type: "warning", showIcon: !0, message: r.dirtyCapture, description: r.dirtyCaptureDescription, action: /* @__PURE__ */ N.jsx(Ve, { onClick: () => { + A && /* @__PURE__ */ _.jsx(Lt, { type: "warning", showIcon: !0, message: r.dirtyCapture, description: r.dirtyCaptureDescription, action: /* @__PURE__ */ _.jsx(Ve, { onClick: () => { K(); }, disabled: O, children: r.reloadWorking }) }), - C && /* @__PURE__ */ N.jsxs(lt, { direction: "vertical", className: "panel-stack", children: [ - /* @__PURE__ */ N.jsx(xn, { size: "small", column: 2, items: [ + C && /* @__PURE__ */ _.jsxs(lt, { direction: "vertical", className: "panel-stack", children: [ + /* @__PURE__ */ _.jsx(xn, { size: "small", column: 2, items: [ { key: "source", label: a.sourceURL, children: C.source_url }, { key: "expires", label: a.expires, children: new Date(C.expires_at).toLocaleString(e) } ] }), - /* @__PURE__ */ N.jsx(_t.Title, { level: 5, children: a.prompt }), - /* @__PURE__ */ N.jsx(dt.TextArea, { value: C.prompt, readOnly: !0, autoSize: { minRows: 10, maxRows: 18 } }), - /* @__PURE__ */ N.jsx(Ve, { onClick: () => { + /* @__PURE__ */ _.jsx(_t.Title, { level: 5, children: a.prompt }), + /* @__PURE__ */ _.jsx(dt.TextArea, { value: C.prompt, readOnly: !0, autoSize: { minRows: 10, maxRows: 18 } }), + /* @__PURE__ */ _.jsx(Ve, { onClick: () => { ie(); }, children: a.copyPrompt }), - /* @__PURE__ */ N.jsx(_t.Title, { level: 5, children: a.pasteResult }), - /* @__PURE__ */ N.jsx(dt.TextArea, { value: R, onChange: (oe) => { + /* @__PURE__ */ _.jsx(_t.Title, { level: 5, children: a.pasteResult }), + /* @__PURE__ */ _.jsx(dt.TextArea, { value: R, onChange: (oe) => { $(oe.target.value), I(void 0), T(!1); }, autoSize: { minRows: 10, maxRows: 22 }, placeholder: "{ ... }" }), - /* @__PURE__ */ N.jsx(Ve, { type: "primary", onClick: () => { + /* @__PURE__ */ _.jsx(Ve, { type: "primary", onClick: () => { Y(); }, disabled: !R.trim() || A, loading: O, children: a.validate }) ] }), - M && /* @__PURE__ */ N.jsxs(lt, { direction: "vertical", className: "panel-stack", children: [ - /* @__PURE__ */ N.jsx(xn, { size: "small", items: [{ key: "status", label: a.status, children: M.status }] }), - M.status === "unavailable" && /* @__PURE__ */ N.jsx(Lt, { type: "warning", showIcon: !0, message: a.unavailable }), - (M.limitations ?? []).map((oe) => /* @__PURE__ */ N.jsx(Lt, { type: "warning", showIcon: !0, message: a.limitations, description: oe }, oe)), - /* @__PURE__ */ N.jsx( + M && /* @__PURE__ */ _.jsxs(lt, { direction: "vertical", className: "panel-stack", children: [ + /* @__PURE__ */ _.jsx(xn, { size: "small", items: [{ key: "status", label: a.status, children: M.status }] }), + M.status === "unavailable" && /* @__PURE__ */ _.jsx(Lt, { type: "warning", showIcon: !0, message: a.unavailable }), + (M.limitations ?? []).map((oe) => /* @__PURE__ */ _.jsx(Lt, { type: "warning", showIcon: !0, message: a.limitations, description: oe }, oe)), + /* @__PURE__ */ _.jsx( In, { rowKey: "field", @@ -85545,93 +85543,93 @@ Please change the parent to /* @__PURE__ */ N.jsx(_t.Text, { code: !0, children: JSON.stringify(oe, null, 2) }) }, - { title: a.proposed, dataIndex: "after", render: (oe) => /* @__PURE__ */ N.jsx(_t.Text, { code: !0, children: JSON.stringify(oe, null, 2) }) } + { title: a.current, dataIndex: "before", render: (oe) => /* @__PURE__ */ _.jsx(_t.Text, { code: !0, children: JSON.stringify(oe, null, 2) }) }, + { title: a.proposed, dataIndex: "after", render: (oe) => /* @__PURE__ */ _.jsx(_t.Text, { code: !0, children: JSON.stringify(oe, null, 2) }) } ] } ), - M.proposed_configuration && /* @__PURE__ */ N.jsxs(N.Fragment, { children: [ - /* @__PURE__ */ N.jsx(no, { checked: P, onChange: (oe) => T(oe.target.checked), children: a.rights }), - /* @__PURE__ */ N.jsxs(lt, { children: [ - /* @__PURE__ */ N.jsx(Ve, { type: "primary", onClick: () => { + M.proposed_configuration && /* @__PURE__ */ _.jsxs(_.Fragment, { children: [ + /* @__PURE__ */ _.jsx(no, { checked: P, onChange: (oe) => T(oe.target.checked), children: a.rights }), + /* @__PURE__ */ _.jsxs(lt, { children: [ + /* @__PURE__ */ _.jsx(Ve, { type: "primary", onClick: () => { q(); }, disabled: !P || A || M.working_revision !== f?.working_revision, loading: O, children: a.apply }), - M.working_revision !== f?.working_revision && /* @__PURE__ */ N.jsx(Ve, { onClick: () => { + M.working_revision !== f?.working_revision && /* @__PURE__ */ _.jsx(Ve, { onClick: () => { Q(); }, loading: F, children: F ? `${a.preview}…` : a.preview }) ] }) ] }) ] }) ] }), - /* @__PURE__ */ N.jsxs(we, { form: V, layout: "vertical", onFinish: (oe) => { + /* @__PURE__ */ _.jsxs(we, { form: V, layout: "vertical", onFinish: (oe) => { G(oe); }, onValuesChange: () => z(!0), children: [ - /* @__PURE__ */ N.jsxs(Ot, { size: "small", title: r.organization, children: [ - /* @__PURE__ */ N.jsx(we.Item, { name: ["organization", "display_name"], label: r.displayName, rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(dt, {}) }), - /* @__PURE__ */ N.jsxs(lt, { wrap: !0, align: "start", children: [ - /* @__PURE__ */ N.jsx(we.Item, { name: ["organization", "legal_name"], label: r.legalName, children: /* @__PURE__ */ N.jsx(dt, {}) }), - /* @__PURE__ */ N.jsx(we.Item, { name: ["organization", "official_website"], label: r.officialWebsite, rules: [{ type: "url" }], children: /* @__PURE__ */ N.jsx(dt, {}) }), - /* @__PURE__ */ N.jsx(we.Item, { name: ["organization", "privacy_url"], label: r.privacyURL, rules: [{ type: "url" }], children: /* @__PURE__ */ N.jsx(dt, {}) }), - /* @__PURE__ */ N.jsx(we.Item, { name: ["organization", "terms_url"], label: r.termsURL, rules: [{ type: "url" }], children: /* @__PURE__ */ N.jsx(dt, {}) }) + /* @__PURE__ */ _.jsxs(Ot, { size: "small", title: r.organization, children: [ + /* @__PURE__ */ _.jsx(we.Item, { name: ["organization", "display_name"], label: r.displayName, rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(dt, {}) }), + /* @__PURE__ */ _.jsxs(lt, { wrap: !0, align: "start", children: [ + /* @__PURE__ */ _.jsx(we.Item, { name: ["organization", "legal_name"], label: r.legalName, children: /* @__PURE__ */ _.jsx(dt, {}) }), + /* @__PURE__ */ _.jsx(we.Item, { name: ["organization", "official_website"], label: r.officialWebsite, rules: [{ type: "url" }], children: /* @__PURE__ */ _.jsx(dt, {}) }), + /* @__PURE__ */ _.jsx(we.Item, { name: ["organization", "privacy_url"], label: r.privacyURL, rules: [{ type: "url" }], children: /* @__PURE__ */ _.jsx(dt, {}) }), + /* @__PURE__ */ _.jsx(we.Item, { name: ["organization", "terms_url"], label: r.termsURL, rules: [{ type: "url" }], children: /* @__PURE__ */ _.jsx(dt, {}) }) ] }), - /* @__PURE__ */ N.jsx(lt, { wrap: !0, align: "start", children: [ + /* @__PURE__ */ _.jsx(lt, { wrap: !0, align: "start", children: [ ["primary_logo_asset_id", r.primaryLogo], ["dark_logo_asset_id", r.darkLogo], ["favicon_asset_id", r.favicon], ["social_image_asset_id", r.socialImage] - ].map(([oe, ce]) => /* @__PURE__ */ N.jsxs(lt, { direction: "vertical", children: [ - /* @__PURE__ */ N.jsx(we.Item, { name: ["organization", oe], label: `${ce} ${r.assetID}`, children: /* @__PURE__ */ N.jsx(dt, { readOnly: !0, allowClear: !0 }) }), - /* @__PURE__ */ N.jsx(Pl, { accept: ".jpg,.jpeg,.png,.webp,.svg", showUploadList: !1, beforeUpload: (Z) => (k(Z, oe), !1), children: /* @__PURE__ */ N.jsxs(Ve, { loading: O, children: [ + ].map(([oe, ce]) => /* @__PURE__ */ _.jsxs(lt, { direction: "vertical", children: [ + /* @__PURE__ */ _.jsx(we.Item, { name: ["organization", oe], label: `${ce} ${r.assetID}`, children: /* @__PURE__ */ _.jsx(dt, { readOnly: !0, allowClear: !0 }) }), + /* @__PURE__ */ _.jsx(Pl, { accept: ".jpg,.jpeg,.png,.webp,.svg", showUploadList: !1, beforeUpload: (Z) => (k(Z, oe), !1), children: /* @__PURE__ */ _.jsxs(Ve, { loading: O, children: [ r.upload, " ", ce ] }) }) ] }, oe)) }), - /* @__PURE__ */ N.jsx(we.List, { name: ["organization", "contact_links"], children: (oe, { add: ce, remove: Z }) => /* @__PURE__ */ N.jsxs(lt, { direction: "vertical", className: "panel-stack", children: [ - oe.map((me) => /* @__PURE__ */ N.jsxs(lt, { wrap: !0, align: "start", children: [ - /* @__PURE__ */ N.jsx(we.Item, { ...me, name: [me.name, "id"], label: r.contactID, rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(dt, {}) }), - /* @__PURE__ */ N.jsx(we.Item, { ...me, name: [me.name, "label"], label: r.label, rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(dt, {}) }), - /* @__PURE__ */ N.jsx(we.Item, { ...me, name: [me.name, "url"], label: r.url, rules: [{ required: !0 }, { type: "url" }], children: /* @__PURE__ */ N.jsx(dt, {}) }), - /* @__PURE__ */ N.jsx(we.Item, { ...me, name: [me.name, "sort_order"], label: r.order, children: /* @__PURE__ */ N.jsx(Jo, {}) }), - /* @__PURE__ */ N.jsx(Ve, { danger: !0, onClick: () => Z(me.name), children: r.removeContact }) + /* @__PURE__ */ _.jsx(we.List, { name: ["organization", "contact_links"], children: (oe, { add: ce, remove: Z }) => /* @__PURE__ */ _.jsxs(lt, { direction: "vertical", className: "panel-stack", children: [ + oe.map((me) => /* @__PURE__ */ _.jsxs(lt, { wrap: !0, align: "start", children: [ + /* @__PURE__ */ _.jsx(we.Item, { ...me, name: [me.name, "id"], label: r.contactID, rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(dt, {}) }), + /* @__PURE__ */ _.jsx(we.Item, { ...me, name: [me.name, "label"], label: r.label, rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(dt, {}) }), + /* @__PURE__ */ _.jsx(we.Item, { ...me, name: [me.name, "url"], label: r.url, rules: [{ required: !0 }, { type: "url" }], children: /* @__PURE__ */ _.jsx(dt, {}) }), + /* @__PURE__ */ _.jsx(we.Item, { ...me, name: [me.name, "sort_order"], label: r.order, children: /* @__PURE__ */ _.jsx(Jo, {}) }), + /* @__PURE__ */ _.jsx(Ve, { danger: !0, onClick: () => Z(me.name), children: r.removeContact }) ] }, me.key)), - /* @__PURE__ */ N.jsx(Ve, { onClick: () => ce({ id: "", label: "", url: "", sort_order: oe.length * 10 }), children: r.addContact }) + /* @__PURE__ */ _.jsx(Ve, { onClick: () => ce({ id: "", label: "", url: "", sort_order: oe.length * 10 }), children: r.addContact }) ] }) }) ] }), - /* @__PURE__ */ N.jsx(Ot, { size: "small", title: r.navigation, children: /* @__PURE__ */ N.jsx(we.List, { name: "navigation", children: (oe, { add: ce, remove: Z }) => /* @__PURE__ */ N.jsxs(lt, { direction: "vertical", className: "panel-stack", children: [ - oe.map((me) => /* @__PURE__ */ N.jsxs(lt, { wrap: !0, align: "start", children: [ - /* @__PURE__ */ N.jsx(we.Item, { ...me, name: [me.name, "id"], label: r.id, rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(dt, {}) }), - /* @__PURE__ */ N.jsx(we.Item, { ...me, name: [me.name, "parent_id"], label: r.parentID, children: /* @__PURE__ */ N.jsx(dt, {}) }), - /* @__PURE__ */ N.jsx(we.Item, { ...me, name: [me.name, "label"], label: r.label, rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(dt, {}) }), - /* @__PURE__ */ N.jsx(Jve, { fieldName: me.name, copy: s, urlLabel: r.url, newWindowLabel: r.newWindow }), - /* @__PURE__ */ N.jsx(we.Item, { ...me, name: [me.name, "sort_order"], label: r.order, children: /* @__PURE__ */ N.jsx(Jo, {}) }), - /* @__PURE__ */ N.jsx(we.Item, { ...me, name: [me.name, "visible"], valuePropName: "checked", children: /* @__PURE__ */ N.jsx(no, { children: r.visible }) }), - /* @__PURE__ */ N.jsx(Ve, { danger: !0, onClick: () => Z(me.name), children: r.removeLink }) + /* @__PURE__ */ _.jsx(Ot, { size: "small", title: r.navigation, children: /* @__PURE__ */ _.jsx(we.List, { name: "navigation", children: (oe, { add: ce, remove: Z }) => /* @__PURE__ */ _.jsxs(lt, { direction: "vertical", className: "panel-stack", children: [ + oe.map((me) => /* @__PURE__ */ _.jsxs(lt, { wrap: !0, align: "start", children: [ + /* @__PURE__ */ _.jsx(we.Item, { ...me, name: [me.name, "id"], label: r.id, rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(dt, {}) }), + /* @__PURE__ */ _.jsx(we.Item, { ...me, name: [me.name, "parent_id"], label: r.parentID, children: /* @__PURE__ */ _.jsx(dt, {}) }), + /* @__PURE__ */ _.jsx(we.Item, { ...me, name: [me.name, "label"], label: r.label, rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(dt, {}) }), + /* @__PURE__ */ _.jsx(Jve, { fieldName: me.name, copy: s, urlLabel: r.url, newWindowLabel: r.newWindow }), + /* @__PURE__ */ _.jsx(we.Item, { ...me, name: [me.name, "sort_order"], label: r.order, children: /* @__PURE__ */ _.jsx(Jo, {}) }), + /* @__PURE__ */ _.jsx(we.Item, { ...me, name: [me.name, "visible"], valuePropName: "checked", children: /* @__PURE__ */ _.jsx(no, { children: r.visible }) }), + /* @__PURE__ */ _.jsx(Ve, { danger: !0, onClick: () => Z(me.name), children: r.removeLink }) ] }, me.key)), - /* @__PURE__ */ N.jsx(Ve, { onClick: () => ce({ id: "", label: "", url: "/", target_type: "link", presentation: "direct", sort_order: oe.length * 10, visible: !0, open_new_window: !1 }), children: r.addLink }) + /* @__PURE__ */ _.jsx(Ve, { onClick: () => ce({ id: "", label: "", url: "/", target_type: "link", presentation: "direct", sort_order: oe.length * 10, visible: !0, open_new_window: !1 }), children: r.addLink }) ] }) }) }), - /* @__PURE__ */ N.jsxs(Ot, { size: "small", title: r.themeSEO, children: [ - /* @__PURE__ */ N.jsxs(lt, { wrap: !0, align: "start", children: [ - /* @__PURE__ */ N.jsx(we.Item, { name: ["theme", "primary_color"], label: r.primaryColor, rules: [{ required: !0 }, { pattern: /^#[0-9a-fA-F]{6}$/ }], children: /* @__PURE__ */ N.jsx(dt, { type: "color" }) }), - /* @__PURE__ */ N.jsx(we.Item, { name: ["theme", "secondary_color"], label: r.secondaryColor, rules: [{ required: !0 }, { pattern: /^#[0-9a-fA-F]{6}$/ }], children: /* @__PURE__ */ N.jsx(dt, { type: "color" }) }), - /* @__PURE__ */ N.jsx(we.Item, { name: ["theme", "font_family"], label: r.fontFamily, rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(dt, {}) }), - /* @__PURE__ */ N.jsx(we.Item, { name: ["theme", "content_width_px"], label: r.contentWidth, rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(Jo, { min: 640, max: 1920 }) }) + /* @__PURE__ */ _.jsxs(Ot, { size: "small", title: r.themeSEO, children: [ + /* @__PURE__ */ _.jsxs(lt, { wrap: !0, align: "start", children: [ + /* @__PURE__ */ _.jsx(we.Item, { name: ["theme", "primary_color"], label: r.primaryColor, rules: [{ required: !0 }, { pattern: /^#[0-9a-fA-F]{6}$/ }], children: /* @__PURE__ */ _.jsx(dt, { type: "color" }) }), + /* @__PURE__ */ _.jsx(we.Item, { name: ["theme", "secondary_color"], label: r.secondaryColor, rules: [{ required: !0 }, { pattern: /^#[0-9a-fA-F]{6}$/ }], children: /* @__PURE__ */ _.jsx(dt, { type: "color" }) }), + /* @__PURE__ */ _.jsx(we.Item, { name: ["theme", "font_family"], label: r.fontFamily, rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(dt, {}) }), + /* @__PURE__ */ _.jsx(we.Item, { name: ["theme", "content_width_px"], label: r.contentWidth, rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(Jo, { min: 640, max: 1920 }) }) ] }), - /* @__PURE__ */ N.jsx(we.Item, { name: ["theme", "custom_css_enabled"], valuePropName: "checked", children: /* @__PURE__ */ N.jsx(no, { children: r.applyCSS }) }), - /* @__PURE__ */ N.jsx(we.Item, { name: ["theme", "custom_css"], label: r.customCSS, children: /* @__PURE__ */ N.jsx(dt.TextArea, { rows: 6 }) }), - /* @__PURE__ */ N.jsx(we.Item, { name: ["seo", "default_title"], label: r.defaultTitle, children: /* @__PURE__ */ N.jsx(dt, {}) }), - /* @__PURE__ */ N.jsx(we.Item, { name: ["seo", "default_description"], label: r.defaultDescription, children: /* @__PURE__ */ N.jsx(dt.TextArea, { rows: 3 }) }) + /* @__PURE__ */ _.jsx(we.Item, { name: ["theme", "custom_css_enabled"], valuePropName: "checked", children: /* @__PURE__ */ _.jsx(no, { children: r.applyCSS }) }), + /* @__PURE__ */ _.jsx(we.Item, { name: ["theme", "custom_css"], label: r.customCSS, children: /* @__PURE__ */ _.jsx(dt.TextArea, { rows: 6 }) }), + /* @__PURE__ */ _.jsx(we.Item, { name: ["seo", "default_title"], label: r.defaultTitle, children: /* @__PURE__ */ _.jsx(dt, {}) }), + /* @__PURE__ */ _.jsx(we.Item, { name: ["seo", "default_description"], label: r.defaultDescription, children: /* @__PURE__ */ _.jsx(dt.TextArea, { rows: 3 }) }) ] }), - /* @__PURE__ */ N.jsxs(lt, { children: [ - /* @__PURE__ */ N.jsx(Ve, { type: "primary", htmlType: "submit", loading: O, children: r.saveWorking }), - /* @__PURE__ */ N.jsx(Ve, { onClick: () => { + /* @__PURE__ */ _.jsxs(lt, { children: [ + /* @__PURE__ */ _.jsx(Ve, { type: "primary", htmlType: "submit", loading: O, children: r.saveWorking }), + /* @__PURE__ */ _.jsx(Ve, { onClick: () => { Q(); }, disabled: !f, loading: F, children: F ? `${r.previewWorking}…` : r.previewWorking }) ] }) ] }) ] }), - /* @__PURE__ */ N.jsx(Ot, { title: r.searchExposure, loading: !p && O, children: /* @__PURE__ */ N.jsxs(lt, { direction: "vertical", className: "panel-stack", children: [ - /* @__PURE__ */ N.jsx( + /* @__PURE__ */ _.jsx(Ot, { title: r.searchExposure, loading: !p && O, children: /* @__PURE__ */ _.jsxs(lt, { direction: "vertical", className: "panel-stack", children: [ + /* @__PURE__ */ _.jsx( Lt, { showIcon: !0, @@ -85640,18 +85638,18 @@ Please change the parent to { + p && /* @__PURE__ */ _.jsxs(we, { form: X, layout: "vertical", onFinish: (oe) => { te(oe); }, children: [ - /* @__PURE__ */ N.jsxs(Ot, { size: "small", title: r.indexNowTitle, children: [ - /* @__PURE__ */ N.jsx(we.Item, { name: "indexnow_enabled", valuePropName: "checked", children: /* @__PURE__ */ N.jsx(no, { disabled: !p.indexnow_available, children: r.enableIndexNow }) }), - !p.indexnow_available && /* @__PURE__ */ N.jsx(Lt, { showIcon: !0, type: "warning", message: r.indexNowUnavailable }), - p.indexnow_key_url && /* @__PURE__ */ N.jsx(xn, { size: "small", items: [{ key: "key", label: r.keyURL, children: /* @__PURE__ */ N.jsx(_t.Text, { copyable: !0, children: p.indexnow_key_url }) }] }) + /* @__PURE__ */ _.jsxs(Ot, { size: "small", title: r.indexNowTitle, children: [ + /* @__PURE__ */ _.jsx(we.Item, { name: "indexnow_enabled", valuePropName: "checked", children: /* @__PURE__ */ _.jsx(no, { disabled: !p.indexnow_available, children: r.enableIndexNow }) }), + !p.indexnow_available && /* @__PURE__ */ _.jsx(Lt, { showIcon: !0, type: "warning", message: r.indexNowUnavailable }), + p.indexnow_key_url && /* @__PURE__ */ _.jsx(xn, { size: "small", items: [{ key: "key", label: r.keyURL, children: /* @__PURE__ */ _.jsx(_t.Text, { copyable: !0, children: p.indexnow_key_url }) }] }) ] }), - /* @__PURE__ */ N.jsxs(Ot, { size: "small", title: r.googleTitle, children: [ - /* @__PURE__ */ N.jsx(we.Item, { name: "google_enabled", valuePropName: "checked", children: /* @__PURE__ */ N.jsx(no, { disabled: !p.google_available, children: r.enableGoogle }) }), - !p.google_available && /* @__PURE__ */ N.jsx(Lt, { showIcon: !0, type: "warning", message: r.googleUnavailable }), - /* @__PURE__ */ N.jsx( + /* @__PURE__ */ _.jsxs(Ot, { size: "small", title: r.googleTitle, children: [ + /* @__PURE__ */ _.jsx(we.Item, { name: "google_enabled", valuePropName: "checked", children: /* @__PURE__ */ _.jsx(no, { disabled: !p.google_available, children: r.enableGoogle }) }), + !p.google_available && /* @__PURE__ */ _.jsx(Lt, { showIcon: !0, type: "warning", message: r.googleUnavailable }), + /* @__PURE__ */ _.jsx( we.Item, { name: "google_site_url", @@ -85660,36 +85658,36 @@ Please change the parent to ({ validator(ce, Z) { return !oe("google_enabled") || Z?.trim() ? Promise.resolve() : Promise.reject(new Error(r.sitePropertyRequired)); } })], - children: /* @__PURE__ */ N.jsx(dt, { disabled: !p.google_available }) + children: /* @__PURE__ */ _.jsx(dt, { disabled: !p.google_available }) } ) ] }), - /* @__PURE__ */ N.jsx(Ve, { type: "primary", htmlType: "submit", loading: O, children: r.saveSearch }) + /* @__PURE__ */ _.jsx(Ve, { type: "primary", htmlType: "submit", loading: O, children: r.saveSearch }) ] }) ] }) }), - /* @__PURE__ */ N.jsxs(Ot, { title: r.publicRoutes, loading: !c && O, children: [ - c && /* @__PURE__ */ N.jsx(xn, { size: "small", column: 3, items: [ + /* @__PURE__ */ _.jsxs(Ot, { title: r.publicRoutes, loading: !c && O, children: [ + c && /* @__PURE__ */ _.jsx(xn, { size: "small", column: 3, items: [ { key: "epoch", label: r.activeEpoch, children: c.current_epoch }, { key: "prefix", label: r.activePrefix, children: c.config.product_prefix }, { key: "pattern", label: r.activePattern, children: c.config.url_pattern } ] }), - /* @__PURE__ */ N.jsx(_t.Paragraph, { type: "secondary", children: r.routesHelp }), - /* @__PURE__ */ N.jsxs(we, { form: H, layout: "inline", onFinish: (oe) => { + /* @__PURE__ */ _.jsx(_t.Paragraph, { type: "secondary", children: r.routesHelp }), + /* @__PURE__ */ _.jsxs(we, { form: H, layout: "inline", onFinish: (oe) => { B(oe); }, onValuesChange: () => S(void 0), children: [ - /* @__PURE__ */ N.jsx(we.Item, { name: "product_prefix", label: r.productPrefix, rules: [{ required: !0 }, { pattern: /^\/[A-Za-z0-9._-]+$/, message: r.safeSegment }], children: /* @__PURE__ */ N.jsx(dt, { placeholder: "/products" }) }), - /* @__PURE__ */ N.jsx(we.Item, { name: "url_pattern", label: r.pattern, rules: [{ required: !0 }], children: /* @__PURE__ */ N.jsx(kt, { style: { width: 190 }, options: [ + /* @__PURE__ */ _.jsx(we.Item, { name: "product_prefix", label: r.productPrefix, rules: [{ required: !0 }, { pattern: /^\/[A-Za-z0-9._-]+$/, message: r.safeSegment }], children: /* @__PURE__ */ _.jsx(dt, { placeholder: "/products" }) }), + /* @__PURE__ */ _.jsx(we.Item, { name: "url_pattern", label: r.pattern, rules: [{ required: !0 }], children: /* @__PURE__ */ _.jsx(kt, { style: { width: 190 }, options: [ { value: "compact", label: r.compact }, { value: "manufacturer", label: r.manufacturerPath }, { value: "brand", label: r.brandPath }, { value: "category", label: r.categoryPath } ] }) }), - /* @__PURE__ */ N.jsx(Ve, { htmlType: "submit", loading: O, children: r.previewSite }) + /* @__PURE__ */ _.jsx(Ve, { htmlType: "submit", loading: O, children: r.previewSite }) ] }) ] }), - b && /* @__PURE__ */ N.jsxs(Ot, { title: r.candidateEpoch(b.candidate_epoch), children: [ - pe ? /* @__PURE__ */ N.jsx(Lt, { type: "success", showIcon: !0, message: r.routesReady(b.affected, b.working_revision) }) : /* @__PURE__ */ N.jsx(Lt, { type: "error", showIcon: !0, message: r.cannotPublish, description: r.cannotPublishDescription }), - fe.length > 0 && /* @__PURE__ */ N.jsx( + b && /* @__PURE__ */ _.jsxs(Ot, { title: r.candidateEpoch(b.candidate_epoch), children: [ + pe ? /* @__PURE__ */ _.jsx(Lt, { type: "success", showIcon: !0, message: r.routesReady(b.affected, b.working_revision) }) : /* @__PURE__ */ _.jsx(Lt, { type: "error", showIcon: !0, message: r.cannotPublish, description: r.cannotPublishDescription }), + fe.length > 0 && /* @__PURE__ */ _.jsx( In, { rowKey: (oe) => `${oe.product_id}:${oe.reason}:${oe.route ?? ""}`, @@ -85703,11 +85701,11 @@ Please change the parent to { + /* @__PURE__ */ _.jsx(Lr, { title: r.publishConfirm, onConfirm: () => { J(); - }, disabled: !pe, children: /* @__PURE__ */ N.jsx(Ve, { type: "primary", disabled: !pe, loading: O, children: r.publishWebsite }) }) + }, disabled: !pe, children: /* @__PURE__ */ _.jsx(Ve, { type: "primary", disabled: !pe, loading: O, children: r.publishWebsite }) }) ] }), - /* @__PURE__ */ N.jsx(Ot, { title: r.publishedVersions, children: /* @__PURE__ */ N.jsx( + /* @__PURE__ */ _.jsx(Ot, { title: r.publishedVersions, children: /* @__PURE__ */ _.jsx( In, { rowKey: "version", @@ -85720,9 +85718,9 @@ Please change the parent to new Date(oe).toLocaleString(e) }, { title: r.action, - render: (oe, ce) => /* @__PURE__ */ N.jsx(Lr, { title: r.restoreConfirm(ce.version), onConfirm: () => { + render: (oe, ce) => /* @__PURE__ */ _.jsx(Lr, { title: r.restoreConfirm(ce.version), onConfirm: () => { re(ce.version); - }, children: /* @__PURE__ */ N.jsx(Ve, { disabled: O, children: r.restoreWorking }) }) + }, children: /* @__PURE__ */ _.jsx(Ve, { disabled: O, children: r.restoreWorking }) }) } ] } @@ -85981,104 +85979,104 @@ Please change the parent to gv[_].every((P) => f.capabilities[P])), y = v.includes(g) ? g : "", b = (_) => { - r(void 0), s(void 0), p(`/${_}`); - }, [S, C] = l.useState(), E = tbe[e], R = (_) => { - s(void 0), r(_); - }, $ = (_) => { - s(obe(_)); + const [e, t] = l.useState(abe), [n, r] = l.useState(), [a, s] = l.useState(), [c, u] = l.useState(!1), f = A1(), m = ra(), p = hv(), g = m.pathname.replace(/^\//, "") || "catalog", v = Object.keys(gv).filter((N) => gv[N].every((P) => f.capabilities[P])), y = v.includes(g) ? g : "", b = (N) => { + r(void 0), s(void 0), p(`/${N}`); + }, [S, C] = l.useState(), E = tbe[e], R = (N) => { + s(void 0), r(N); + }, $ = (N) => { + s(obe(N)); }, M = async () => { try { await H4.logout({}); - } catch (_) { - $(_); + } catch (N) { + $(N); } }; l.useEffect(() => { document.documentElement.lang = e, window.localStorage.setItem("prods-admin-locale", e), ahe(e); }, [e]), l.useEffect(() => { - let _ = !1; + let N = !1; if (f.capabilities["system.manage"]) return Rt("/admin/api/system/health").then((P) => { - _ || (C(P), P.status === "Critical" && b("health")); + N || (C(P), P.status === "Critical" && b("health")); }).catch(() => { }), () => { - _ = !0; + N = !0; }; }, []); const I = [ { key: "catalog", label: E.catalog, - content: /* @__PURE__ */ N.jsx(mve, { locale: e, onError: $, onMessage: R }) + content: /* @__PURE__ */ _.jsx(mve, { locale: e, onError: $, onMessage: R }) }, { key: "taxonomy", label: E.taxonomy, - content: /* @__PURE__ */ N.jsx(Bve, { locale: e, onError: $, onMessage: R }) + content: /* @__PURE__ */ _.jsx(Bve, { locale: e, onError: $, onMessage: R }) }, { key: "imports", label: E.imports, - content: /* @__PURE__ */ N.jsx(xve, { locale: e, onError: $, onMessage: R }) + content: /* @__PURE__ */ _.jsx(xve, { locale: e, onError: $, onMessage: R }) }, { key: "product-bulk", label: E.productBulk, - content: /* @__PURE__ */ N.jsx(Ave, { locale: e, onError: $, onMessage: R }) + content: /* @__PURE__ */ _.jsx(Ave, { locale: e, onError: $, onMessage: R }) }, { key: "jobs", label: E.jobs, - content: /* @__PURE__ */ N.jsx(Rve, { locale: e, onError: $, onMessage: R }) + content: /* @__PURE__ */ _.jsx(Rve, { locale: e, onError: $, onMessage: R }) }, { key: "website", label: E.website, - content: /* @__PURE__ */ N.jsx(ebe, { locale: e, onError: $, onMessage: R }) + content: /* @__PURE__ */ _.jsx(ebe, { locale: e, onError: $, onMessage: R }) }, { key: "listing-profiles", label: E.listingProfiles, - content: /* @__PURE__ */ N.jsx(_ve, { locale: e, onError: $, onMessage: R }) + content: /* @__PURE__ */ _.jsx(_ve, { locale: e, onError: $, onMessage: R }) }, { key: "public-copy", label: E.publicCopy, - content: /* @__PURE__ */ N.jsx(Mve, { locale: e, onError: $, onMessage: R }) + content: /* @__PURE__ */ _.jsx(Mve, { locale: e, onError: $, onMessage: R }) }, { key: "access", label: E.access, - content: /* @__PURE__ */ N.jsx(uhe, { locale: e, onError: $, onMessage: R }) + content: /* @__PURE__ */ _.jsx(uhe, { locale: e, onError: $, onMessage: R }) }, { key: "activity", label: E.activity, - content: /* @__PURE__ */ N.jsx(mhe, { locale: e, onError: $ }) + content: /* @__PURE__ */ _.jsx(mhe, { locale: e, onError: $ }) }, { key: "settings", label: E.settings, - content: /* @__PURE__ */ N.jsx(Lve, { locale: e, onError: $, onMessage: R }) + content: /* @__PURE__ */ _.jsx(Lve, { locale: e, onError: $, onMessage: R }) }, { key: "health", label: E.health, - content: /* @__PURE__ */ N.jsx(vve, { locale: e, onError: $ }) + content: /* @__PURE__ */ _.jsx(vve, { locale: e, onError: $ }) }, { key: "backups", label: E.backups, - content: /* @__PURE__ */ N.jsx(ove, { locale: e, onError: $, onMessage: R }) + content: /* @__PURE__ */ _.jsx(ove, { locale: e, onError: $, onMessage: R }) }, { key: "traffic", label: E.traffic, - content: /* @__PURE__ */ N.jsx(Uve, { locale: e, onError: $, onMessage: R }) + content: /* @__PURE__ */ _.jsx(Uve, { locale: e, onError: $, onMessage: R }) } - ].filter((_) => v.includes(_.key)), D = I.find((_) => _.key === y); - return /* @__PURE__ */ N.jsx( + ].filter((N) => v.includes(N.key)), D = I.find((N) => N.key === y); + return /* @__PURE__ */ _.jsx( Fo, { locale: g4(e), @@ -86089,8 +86087,8 @@ Please change the parent to to ({ key: _, label: P })), - onClick: ({ key: _ }) => { - b(_), window.matchMedia("(max-width: 991px)").matches && u(!0); + items: I.map(({ key: N, label: P }) => ({ key: N, label: P })), + onClick: ({ key: N }) => { + b(N), window.matchMedia("(max-width: 991px)").matches && u(!0); } } ) ] } ), - /* @__PURE__ */ N.jsxs(Ua, { className: "admin-main", children: [ - /* @__PURE__ */ N.jsx(Ua.Header, { className: "admin-header", children: /* @__PURE__ */ N.jsxs("div", { className: "header-content", children: [ - /* @__PURE__ */ N.jsxs(lt, { size: "small", children: [ - /* @__PURE__ */ N.jsx( + /* @__PURE__ */ _.jsxs(Ua, { className: "admin-main", children: [ + /* @__PURE__ */ _.jsx(Ua.Header, { className: "admin-header", children: /* @__PURE__ */ _.jsxs("div", { className: "header-content", children: [ + /* @__PURE__ */ _.jsxs(lt, { size: "small", children: [ + /* @__PURE__ */ _.jsx( Ve, { className: "sidebar-toggle", type: "text", "aria-label": nbe[e], - onClick: () => u((_) => !_), + onClick: () => u((N) => !N), children: "☰" } ), - /* @__PURE__ */ N.jsx(_t.Title, { level: 4, className: "section-title", children: D?.label ?? "Admin" }) + /* @__PURE__ */ _.jsx(_t.Title, { level: 4, className: "section-title", children: D?.label ?? "Admin" }) ] }), - /* @__PURE__ */ N.jsxs(lt, { className: "header-actions", children: [ - /* @__PURE__ */ N.jsx( + /* @__PURE__ */ _.jsxs(lt, { className: "header-actions", children: [ + /* @__PURE__ */ _.jsx( kt, { "aria-label": E.language, @@ -86146,14 +86144,14 @@ Please change the parent to { + /* @__PURE__ */ _.jsx(Ve, { onClick: () => { M(); }, children: E.signOut }) ] }) ] }) }), - /* @__PURE__ */ N.jsx(Ua.Content, { className: "content", children: /* @__PURE__ */ N.jsxs(lt, { direction: "vertical", size: "middle", className: "panel-stack", children: [ - n && /* @__PURE__ */ N.jsx(Lt, { closable: !0, type: "success", showIcon: !0, message: n, onClose: () => r(void 0) }), - a && /* @__PURE__ */ N.jsx( + /* @__PURE__ */ _.jsx(Ua.Content, { className: "content", children: /* @__PURE__ */ _.jsxs(lt, { direction: "vertical", size: "middle", className: "panel-stack", children: [ + n && /* @__PURE__ */ _.jsx(Lt, { closable: !0, type: "success", showIcon: !0, message: n, onClose: () => r(void 0) }), + a && /* @__PURE__ */ _.jsx( Lt, { closable: !0, @@ -86164,17 +86162,17 @@ Please change the parent to s(void 0) } ), - S?.status === "Warning" && /* @__PURE__ */ N.jsx( + S?.status === "Warning" && /* @__PURE__ */ _.jsx( Lt, { type: "warning", showIcon: !0, message: E.healthWarning, description: E.healthWarningDescription, - action: /* @__PURE__ */ N.jsx(Ve, { size: "small", onClick: () => b("health"), children: E.health }) + action: /* @__PURE__ */ _.jsx(Ve, { size: "small", onClick: () => b("health"), children: E.health }) } ), - !y && /* @__PURE__ */ N.jsx(Lt, { type: "warning", title: rbe[e] }), + !y && /* @__PURE__ */ _.jsx(Lt, { type: "warning", title: rbe[e] }), D?.content ] }) }) ] }) @@ -86183,8 +86181,8 @@ Please change the parent to >(); - useEffect(() => { form.setFieldsValue(item ?? {}); }, [form, item]); - const submit = async (values: Omit) => { + const [form] = Form.useForm>(); + useEffect(() => { form.setFieldsValue({ name: item?.name, description: item?.description, features: item?.features }); }, [form, item]); + const submit = async (values: Pick) => { setSaving(true); try { const content = await putJSON(`/admin/api/products/${productID}/translations/${encodeURIComponent(locale)}`, { expected_revision: revision, - translation: values, + translation: { name: values.name, description: values.description, features: values.features }, }); onSaved(content); } catch (error) { onError(error); } finally { setSaving(false); } @@ -122,7 +122,6 @@ function LocaleForm({ productID, locale, item, revision, text, onSaved, onError - ); @@ -136,7 +135,7 @@ function SourceLocaleForm({ productID, content, locales, text, onSaved, onError const [form] = Form.useForm<{ source_locale: string; source_locales: Record }>(); const options = localeSelectOptions(locales); useEffect(() => { - form.setFieldsValue({ source_locale: content.source_locale, source_locales: content.source_locales }); + form.setFieldsValue({ source_locale: content.source_locale, source_locales: { name: content.source_locales.name, description: content.source_locales.description, features: content.source_locales.features } }); }, [content, form]); const submit = async (values: { source_locale: string; source_locales: Record }) => { setSaving(true); @@ -144,7 +143,7 @@ function SourceLocaleForm({ productID, content, locales, text, onSaved, onError const next = await putJSON(`/admin/api/products/${productID}/content/source-locales`, { expected_revision: content.product_revision, source_locale: values.source_locale, - source_locales: values.source_locales, + source_locales: { name: values.source_locales.name, description: values.source_locales.description, features: values.source_locales.features }, }); onSaved(next); } catch (error) { onError(error); } finally { setSaving(false); } @@ -154,7 +153,7 @@ function SourceLocaleForm({ productID, content, locales, text, onSaved, onError diff --git a/web/ui/src/admin/types.ts b/web/ui/src/admin/types.ts index abb37a6..927feec 100644 --- a/web/ui/src/admin/types.ts +++ b/web/ui/src/admin/types.ts @@ -33,7 +33,6 @@ export type ProductTranslation = { name?: string; description?: string; features?: string; - specification?: string; revision: number; updated_by?: string; updated_at?: string;