From aced6e08f987be193c081cdd8396f25ae26e3787 Mon Sep 17 00:00:00 2001 From: Alex Chang Date: Wed, 14 Oct 2026 02:51:26 -0400 Subject: [PATCH 1/2] feat: add exact multi-spec search backend --- LICENSE_POLICY.md | 12 +- THIRD_PARTY_NOTICES.md | 592 ++++ cmd/prods/main.go | 4 +- cmd/prods/migration_test.go | 10 + go.mod | 26 + go.sum | 52 + internal/catalog/content_locale.go | 2 +- internal/catalog/content_locale_test.go | 13 +- internal/catalog/maintenance.go | 26 +- internal/catalog/product.go | 5 +- internal/catalog/spec_numeric.go | 83 + internal/hostconfig/config.go | 46 +- internal/hostconfig/config_test.go | 86 + internal/importing/importing.go | 33 +- internal/publishing/drain_test.go | 28 +- internal/publishing/protocol.go | 137 +- internal/publishing/spec_bleve.go | 583 ++++ internal/publishing/spec_bleve_coarse.go | 315 ++ internal/publishing/spec_bleve_test.go | 149 + internal/publishing/spec_search.go | 325 ++ internal/publishing/spec_search_backend.go | 181 ++ .../publishing/spec_search_backend_test.go | 214 ++ internal/publishing/view.go | 33 +- internal/publishing/worker_refresh_test.go | 170 + internal/specnumeric/decimal.go | 128 + internal/specnumeric/normalize.go | 378 +++ internal/specnumeric/normalize_test.go | 160 + internal/specnumeric/predicate.go | 252 ++ internal/specnumeric/predicate_test.go | 175 + internal/specnumeric/profile.go | 84 + internal/specnumeric/units.go | 52 + internal/specnumeric/units_test.go | 44 + internal/specnumeric/value.go | 350 ++ internal/specnumeric/value_test.go | 111 + internal/storage/sqlite/catalog_store.go | 19 +- internal/storage/sqlite/export_store.go | 2 +- internal/storage/sqlite/export_store_test.go | 43 + internal/storage/sqlite/import_store.go | 15 +- internal/storage/sqlite/import_store_test.go | 6 +- internal/storage/sqlite/installation_test.go | 23 +- internal/storage/sqlite/migrations.go | 76 +- internal/storage/sqlite/migrations_test.go | 4 + .../sqlite/product_translation_store.go | 10 +- .../sqlite/product_translation_store_test.go | 7 + internal/storage/sqlite/publication_store.go | 90 +- .../sqlite/source_locale_update_test.go | 2 +- .../storage/sqlite/spec_document_store.go | 46 +- internal/storage/sqlite/spec_numeric_store.go | 362 +++ .../storage/sqlite/spec_numeric_store_test.go | 574 ++++ internal/storage/sqlite/spec_numeric_work.go | 93 + .../storage/sqlite/spec_numeric_work_test.go | 98 + internal/storage/sqlite/spec_search_store.go | 111 + .../storage/sqlite/spec_search_store_test.go | 60 + .../sqlite/spec_shared_migration_test.go | 145 + internal/storage/sqlite/store.go | 9 +- .../webapp/content_translation_routes_test.go | 12 +- internal/webapp/maintenance.go | 23 - internal/webapp/maintenance_test.go | 14 + internal/webapp/server.go | 34 +- internal/webapp/spec_numeric.go | 167 + .../webapp/spec_numeric_publication_test.go | 477 +++ internal/webapp/spec_numeric_test.go | 214 ++ internal/webapp/spec_search.go | 105 + internal/webapp/spec_search_benchmark_test.go | 312 ++ internal/webapp/spec_search_lifecycle_test.go | 190 ++ internal/webapp/spec_search_test.go | 379 +++ internal/webapp/spec_worker_benchmark_test.go | 613 ++++ internal/webapp/static/admin/admin.js | 2884 ++++++++--------- licenses/audit.json | 18 +- web/ui/src/admin/ImportPanel.tsx | 1 - web/ui/src/admin/ProductTranslationsModal.tsx | 15 +- web/ui/src/admin/types.ts | 1 - 72 files changed, 10506 insertions(+), 1607 deletions(-) create mode 100644 internal/catalog/spec_numeric.go create mode 100644 internal/publishing/spec_bleve.go create mode 100644 internal/publishing/spec_bleve_coarse.go create mode 100644 internal/publishing/spec_bleve_test.go create mode 100644 internal/publishing/spec_search.go create mode 100644 internal/publishing/spec_search_backend.go create mode 100644 internal/publishing/spec_search_backend_test.go create mode 100644 internal/publishing/worker_refresh_test.go create mode 100644 internal/specnumeric/decimal.go create mode 100644 internal/specnumeric/normalize.go create mode 100644 internal/specnumeric/normalize_test.go create mode 100644 internal/specnumeric/predicate.go create mode 100644 internal/specnumeric/predicate_test.go create mode 100644 internal/specnumeric/profile.go create mode 100644 internal/specnumeric/units.go create mode 100644 internal/specnumeric/units_test.go create mode 100644 internal/specnumeric/value.go create mode 100644 internal/specnumeric/value_test.go create mode 100644 internal/storage/sqlite/export_store_test.go create mode 100644 internal/storage/sqlite/spec_numeric_store.go create mode 100644 internal/storage/sqlite/spec_numeric_store_test.go create mode 100644 internal/storage/sqlite/spec_numeric_work.go create mode 100644 internal/storage/sqlite/spec_numeric_work_test.go create mode 100644 internal/storage/sqlite/spec_search_store.go create mode 100644 internal/storage/sqlite/spec_search_store_test.go create mode 100644 internal/storage/sqlite/spec_shared_migration_test.go create mode 100644 internal/webapp/spec_numeric.go create mode 100644 internal/webapp/spec_numeric_publication_test.go create mode 100644 internal/webapp/spec_numeric_test.go create mode 100644 internal/webapp/spec_search.go create mode 100644 internal/webapp/spec_search_benchmark_test.go create mode 100644 internal/webapp/spec_search_lifecycle_test.go create mode 100644 internal/webapp/spec_search_test.go create mode 100644 internal/webapp/spec_worker_benchmark_test.go 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/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/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/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/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/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; From 0cb70c98ff690d1f8f0f790bc8a03f51249a7da0 Mon Sep 17 00:00:00 2001 From: Alex Chang Date: Wed, 14 Oct 2026 02:51:41 -0400 Subject: [PATCH 2/2] release: prepare v0.7.0 backend documentation and sample data --- README-zh_CN.md | 15 +- README-zh_TW.md | 15 +- README.md | 15 +- VERSION | 2 +- docs/en/operations.md | 163 ++++++++++++++++++ docs/zh-CN/operations.md | 163 ++++++++++++++++++ docs/zh-TW/operations.md | 163 ++++++++++++++++++ internal/distribution/embedded_sample_test.go | 4 +- .../distribution/prods-sample-data-v1.json.gz | Bin 48450 -> 48450 bytes internal/webapp/installer_test.go | 6 +- sample-data/README.md | 7 +- sample-data/prods-sample-data-v1.json | 2 +- sample-data/prods-sample-data-v1.json.sha256 | 2 +- 13 files changed, 541 insertions(+), 16 deletions(-) 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/VERSION b/VERSION index ca46cd2..8b20e48 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v0.6.8 +v0.7.0 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/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 7ad6d99e620a3ff37ef10df0cb60db4d8f3540b8..dc662475143f8ff232e4a597af1773ed998c130e 100644 GIT binary patch literal 48450 zcmYIvbyyT{)HWbU>e8SnrF02`fOMC1NH-GF9SaLcNJxj|(%qd)DAEm5!qS~fF3awI z{J!_Su6O>L>v`@ub7roS_nD!K!Nd6PzAEf&tlX>{?ocKxvJHs zfyua^)F)f11@6JFN%D;x74EghB<)!hKq-$Io$)JU-NZ|gJt!)p6xYxZ!f0LsY?W0@_?q65>KO-gj5V1t9n{#}}MK|n8W z?~iCfF?$K79f^n5(c7%##`Mn%gA2AUliW3}=-s2nqsH--%b3f`qs!#NN;94sGq4kS z;bBm{MlhXfk0oB68w%;{`e_DgLS~^O3u8tLlW#vSq`ORhP{?Xb9vwfJKZ|?3#zl>; zM`X-GyBthp!(##K+a{3I<+pZ}DbB9|pI^;Y;%m zUr_3oAd!#vt%B&YhuO;$Oi_P?!=<~&&Ed|~Mz3=aEd4SDARz+rMIYX6-y?S&FIUug z;-I-sp!?0^`K8_C&F$m;P99UAs~w(?Vx1qCvU@sFzF43;;9b@v`lfc!WeqF_KLG

e5}-@eUi=y2;=za(1mA11MIq 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 literal 48450 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 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/sample-data/README.md b/sample-data/README.md index 2e55c8b..7c5d168 100644 --- a/sample-data/README.md +++ b/sample-data/README.md @@ -8,10 +8,13 @@ The v1 installer payload contains the complete synthetic catalog needed to explo All referenced IDs must exist in the same payload, IDs must be unique, category parents must precede children, SpecSet members must reference declared specifications, each category has at most one SpecSet, and every Product specification value must be a member of that Product category's assigned SpecSet. The installer validates these relationships again inside the same SQLite transaction that creates the Owner and Ready marker, so an invalid reference rolls back the entire installation. -Generate a payload locally: +The current application release is `v0.7.0`; schema v1, the dataset revision and synthetic seed are unchanged. Sample Raw specification values do not automatically create numeric profiles or adopted numeric search facts. Configure profiles and use the controlled normalization/adoption commands described in [Operations](../docs/en/operations.md#numeric-specification-search-backend) before expecting numeric matches. + +Generate a payload locally into a new path (the generator refuses to overwrite an existing file): ```sh -go run ./cmd/prods-sample -release-version v0.6.8 -output /tmp/prods-sample-data-v1.json +sample_dir="$(mktemp -d)" +go run ./cmd/prods-sample -release-version v0.7.0 -output "$sample_dir/prods-sample-data-v1.json" ``` Before publishing, `scripts/build-release.sh` regenerates the payload and checksum, then compares them byte-for-byte with the files in this directory. It also regenerates deterministic gzip bytes and compares them with the file embedded by the Go package. A stale payload, schema, checksum, embedded gzip, version, or nondeterministic generator stops the release. diff --git a/sample-data/prods-sample-data-v1.json b/sample-data/prods-sample-data-v1.json index 2cf1234..cfc81e2 100644 --- a/sample-data/prods-sample-data-v1.json +++ b/sample-data/prods-sample-data-v1.json @@ -1,6 +1,6 @@ { "schema_version": 1, - "release_version": "v0.6.8", + "release_version": "v0.7.0", "dataset_version": "a01-r1", "seed": 20260916, "source_statement": "Deterministic synthetic electronic-component data; no customer records, third-party catalog copy, or production assets.", diff --git a/sample-data/prods-sample-data-v1.json.sha256 b/sample-data/prods-sample-data-v1.json.sha256 index d88f556..1d32eef 100644 --- a/sample-data/prods-sample-data-v1.json.sha256 +++ b/sample-data/prods-sample-data-v1.json.sha256 @@ -1,2 +1,2 @@ -52209b197e40e17ca9fd857960362cc9199ca436f613a73e49cb0a2f159cf93b prods-sample-data-v1.json +b03ae8b133f4d6fe1e81e2f7d178a11c322249ca5e7a78258cafc40d179fd072 prods-sample-data-v1.json dd7dd1c05202094b2af71665bb7fee339e51754f88ca32259a1fdbda610f4d6d schema-v1.json