From 53942993a8af3e341ffc77d2699cc7f258de3abf Mon Sep 17 00:00:00 2001
From: Codewriter90x
Date: Thu, 30 Jul 2026 14:48:23 +0200
Subject: [PATCH] Resolve reliability and adoption audit backlog
---
.github/ISSUE_TEMPLATE/data_correction.yml | 2 +-
.github/ISSUE_TEMPLATE/missing-coordinate.yml | 2 +-
.github/ISSUE_TEMPLATE/wrong-locality.yml | 2 +-
.github/ISSUE_TEMPLATE/wrong-postal-code.yml | 2 +-
.github/workflows/data-pipeline.yml | 17 +-
.github/workflows/pages.yml | 8 +
.github/workflows/release.yml | 42 +-
.github/workflows/source-freshness.yml | 68 ++
.gitignore | 2 +
DATASET_PIPELINE.md | 22 +-
DATA_LICENSE.md | 2 +-
LICENSE | 35 +-
NOTICE.md | 2 +-
README.md | 12 +-
ROADMAP.md | 6 +-
SCHEMA.md | 5 +
docs/SOURCE_BACKED_CORRECTION_EXAMPLE.md | 79 +++
docs/TYPED_CONTRACT_MIGRATION.md | 28 +
pyproject.toml | 20 +-
release/v2.0.0.md | 4 +-
reports/determinism.json | 1 +
reports/reconciliation-backlog.json | 625 ++++++++++++++++++
requirements.txt | 1 +
schemas/italian_locations-v4.schema.json | 102 +++
scripts/build_dataset.py | 15 +-
scripts/build_geography.py | 97 ++-
scripts/build_pages.py | 121 +---
scripts/build_release.py | 1 -
scripts/check_determinism.py | 2 +-
scripts/dataset_common.py | 3 +-
scripts/export_formats.py | 6 +-
scripts/export_sql.py | 1 -
scripts/export_typed_json.py | 113 ++++
scripts/legacy_comparison.py | 1 -
scripts/normalize_legacy.py | 27 +-
scripts/pages_model.py | 108 +++
scripts/project_metadata.py | 1 -
scripts/reconcile_sources.py | 3 +-
scripts/reconciliation_backlog.py | 124 ++++
scripts/source_data.py | 1 -
scripts/typed_contract.py | 33 +
scripts/update_sources.py | 174 +++++
scripts/validate_dataset.py | 3 +-
scripts/validate_milestone1.py | 1 -
scripts/validate_release.py | 1 -
scripts/validate_site.py | 158 +++++
scripts/validators/common.py | 1 -
scripts/validators/coordinates.py | 1 -
scripts/validators/formats.py | 2 +-
scripts/validators/provenance.py | 19 +-
scripts/validators/schema.py | 1 -
scripts/validators/territory.py | 1 +
site/assets/app.js | 73 +-
site/assets/styles.css | 15 +
site/index.html | 13 +-
tests/test_coordinates.py | 1 -
tests/test_integrity.py | 17 +-
tests/test_pages.py | 20 +-
tests/test_quality_gate.py | 1 -
tests/test_release.py | 3 +-
tests/test_schema.py | 1 -
tests/test_source_updates.py | 78 +++
tests/test_typed_contract.py | 63 ++
63 files changed, 2172 insertions(+), 221 deletions(-)
create mode 100644 .github/workflows/source-freshness.yml
create mode 100644 docs/SOURCE_BACKED_CORRECTION_EXAMPLE.md
create mode 100644 docs/TYPED_CONTRACT_MIGRATION.md
create mode 100644 reports/reconciliation-backlog.json
create mode 100644 schemas/italian_locations-v4.schema.json
create mode 100644 scripts/export_typed_json.py
create mode 100644 scripts/pages_model.py
create mode 100644 scripts/reconciliation_backlog.py
create mode 100644 scripts/typed_contract.py
create mode 100644 scripts/update_sources.py
create mode 100644 scripts/validate_site.py
create mode 100644 tests/test_source_updates.py
create mode 100644 tests/test_typed_contract.py
diff --git a/.github/ISSUE_TEMPLATE/data_correction.yml b/.github/ISSUE_TEMPLATE/data_correction.yml
index 4870aed..dd9bc71 100644
--- a/.github/ISSUE_TEMPLATE/data_correction.yml
+++ b/.github/ISSUE_TEMPLATE/data_correction.yml
@@ -13,7 +13,7 @@ body:
id: location_id
attributes:
label: Identificativo del record
- description: location_id oppure legacy_uuid.
+ description: location_id del record corrente.
placeholder: IT-COM-058091
validations:
required: true
diff --git a/.github/ISSUE_TEMPLATE/missing-coordinate.yml b/.github/ISSUE_TEMPLATE/missing-coordinate.yml
index 52c169a..de40242 100644
--- a/.github/ISSUE_TEMPLATE/missing-coordinate.yml
+++ b/.github/ISSUE_TEMPLATE/missing-coordinate.yml
@@ -14,7 +14,7 @@ body:
id: location_id
attributes:
label: Identificativo
- description: "`location_id` o `legacy_uuid`."
+ description: "`location_id` del record corrente."
validations:
required: true
- type: input
diff --git a/.github/ISSUE_TEMPLATE/wrong-locality.yml b/.github/ISSUE_TEMPLATE/wrong-locality.yml
index 7e4d16d..ff04c08 100644
--- a/.github/ISSUE_TEMPLATE/wrong-locality.yml
+++ b/.github/ISSUE_TEMPLATE/wrong-locality.yml
@@ -14,7 +14,7 @@ body:
id: location_id
attributes:
label: Identificativo
- description: "`location_id` o `legacy_uuid` del record."
+ description: "`location_id` del record corrente."
placeholder: IT-LOC-e6b0bc3e-1acf-5509-8923-238575fc9025
validations:
required: true
diff --git a/.github/ISSUE_TEMPLATE/wrong-postal-code.yml b/.github/ISSUE_TEMPLATE/wrong-postal-code.yml
index 15a809c..94f2b49 100644
--- a/.github/ISSUE_TEMPLATE/wrong-postal-code.yml
+++ b/.github/ISSUE_TEMPLATE/wrong-postal-code.yml
@@ -14,7 +14,7 @@ body:
id: location_id
attributes:
label: Identificativo
- description: "`location_id` o `legacy_uuid`."
+ description: "`location_id` del record corrente."
validations:
required: true
- type: input
diff --git a/.github/workflows/data-pipeline.yml b/.github/workflows/data-pipeline.yml
index 2d6c89c..905037a 100644
--- a/.github/workflows/data-pipeline.yml
+++ b/.github/workflows/data-pipeline.yml
@@ -35,13 +35,19 @@ jobs:
- name: Lint Python sources and tests
run: ruff check scripts tests
- - name: Type-check the validation boundary
+ - name: Type-check all maintained Python scripts
run: mypy
- - name: Enforce validator test coverage
+ - name: Enforce maintained-script test coverage
run: |
coverage run -m unittest discover -s tests
+ coverage run --append scripts/build_dataset.py
+ coverage run --append scripts/build_pages.py
+ coverage run --append scripts/build_release.py
+ coverage run --append scripts/validate_release.py
+ coverage run --append scripts/build_geography.py --check
coverage report
+ coverage xml
python-compatibility:
name: Python ${{ matrix.python-version }}
@@ -109,9 +115,15 @@ jobs:
- name: Test Pages search and map logic
run: node --test tests/pages_core.test.mjs
+ - name: Verify committed geographic base
+ run: python scripts/build_geography.py --check
+
- name: Build deterministic GitHub Pages site
run: python scripts/build_pages.py
+ - name: Validate Pages accessibility, links and budgets
+ run: python scripts/validate_site.py dist/pages
+
- name: Build versioned release assets
run: python scripts/build_release.py
@@ -135,6 +147,7 @@ jobs:
reports/release-diff.json
reports/build-metadata.json
reports/legacy-comparison.json
+ reports/reconciliation-backlog.json
reports/export-manifest.json
reports/determinism.json
reports/quality-validation.json
diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml
index d257023..d19121c 100644
--- a/.github/workflows/pages.yml
+++ b/.github/workflows/pages.yml
@@ -8,6 +8,7 @@ on:
- "data/italian_locations.csv"
- "project.json"
- "scripts/build_pages.py"
+ - "scripts/build_geography.py"
- "scripts/project_metadata.py"
- "site/**"
- "tests/pages_core.test.mjs"
@@ -21,6 +22,7 @@ on:
- "data/italian_locations.csv"
- "project.json"
- "scripts/build_pages.py"
+ - "scripts/build_geography.py"
- "scripts/project_metadata.py"
- "site/**"
- "tests/pages_core.test.mjs"
@@ -61,9 +63,15 @@ jobs:
- name: Test deterministic Pages build
run: python -m unittest discover -s tests -p "test_pages.py" -v
+ - name: Verify committed geographic base
+ run: python scripts/build_geography.py --check
+
- name: Build GitHub Pages artifact
run: python scripts/build_pages.py --output _site
+ - name: Validate accessibility, local links and performance budgets
+ run: python scripts/validate_site.py _site
+
- name: Configure GitHub Pages
uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index e7d28b7..b3f4ec1 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -19,8 +19,18 @@ jobs:
- name: Check out tagged commit
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
+ fetch-depth: 0
persist-credentials: false
+ - name: Require tagged commit to be integrated in the default branch
+ env:
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ run: |
+ git fetch --no-tags origin "${DEFAULT_BRANCH}"
+ git merge-base --is-ancestor \
+ "${GITHUB_SHA}" \
+ "origin/${DEFAULT_BRANCH}"
+
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
@@ -104,7 +114,7 @@ jobs:
! gh release view "${GITHUB_REF_NAME}"
--repo "${GITHUB_REPOSITORY}"
- - name: Create GitHub release
+ - name: Create draft GitHub release
env:
GH_TOKEN: ${{ github.token }}
run: |
@@ -113,6 +123,7 @@ jobs:
gh release create "${GITHUB_REF_NAME}" \
--repo "${GITHUB_REPOSITORY}" \
--verify-tag \
+ --draft \
--prerelease \
--title "Italian Cities ${GITHUB_REF_NAME}" \
--notes-file "release/${GITHUB_REF_NAME}.md"
@@ -120,11 +131,12 @@ jobs:
gh release create "${GITHUB_REF_NAME}" \
--repo "${GITHUB_REPOSITORY}" \
--verify-tag \
+ --draft \
--title "Italian Cities ${GITHUB_REF_NAME}" \
--notes-file "release/${GITHUB_REF_NAME}.md"
fi
- - name: Upload immutable release assets
+ - name: Upload release assets to the draft
env:
GH_TOKEN: ${{ github.token }}
run: |
@@ -139,3 +151,29 @@ jobs:
"dist/${GITHUB_REF_NAME}/italian_locations.sql" \
"dist/${GITHUB_REF_NAME}/SHA256SUMS" \
--repo "${GITHUB_REPOSITORY}"
+
+ - name: Verify draft assets before publication
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: |
+ expected_assets="$(
+ find "dist/${GITHUB_REF_NAME}" -maxdepth 1 -type f \
+ -exec basename {} \; |
+ LC_ALL=C sort
+ )"
+ published_assets="$(
+ gh release view "${GITHUB_REF_NAME}" \
+ --repo "${GITHUB_REPOSITORY}" \
+ --json assets \
+ --jq '.assets[].name' |
+ LC_ALL=C sort
+ )"
+ test "${expected_assets}" = "${published_assets}"
+
+ - name: Publish the complete release
+ env:
+ GH_TOKEN: ${{ github.token }}
+ run: >-
+ gh release edit "${GITHUB_REF_NAME}"
+ --repo "${GITHUB_REPOSITORY}"
+ --draft=false
diff --git a/.github/workflows/source-freshness.yml b/.github/workflows/source-freshness.yml
new file mode 100644
index 0000000..9597c61
--- /dev/null
+++ b/.github/workflows/source-freshness.yml
@@ -0,0 +1,68 @@
+name: Source freshness
+
+on:
+ schedule:
+ - cron: "17 6 * * 1"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ issues: write
+
+jobs:
+ check:
+ name: Check canonical upstream sources
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
+ with:
+ python-version: "3.12"
+ cache: pip
+
+ - name: Install runtime dependencies
+ run: python -m pip install -r requirements.txt
+
+ - name: Compare declared snapshots with upstream artifacts
+ run: >-
+ python scripts/update_sources.py
+ --check
+ --report "${RUNNER_TEMP}/source-freshness.json"
+
+ - name: Preserve freshness evidence
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: source-freshness
+ path: ${{ runner.temp }}/source-freshness.json
+ retention-days: 30
+
+ - name: Open one tracking issue when an update is available
+ env:
+ GH_TOKEN: ${{ github.token }}
+ REPORT: ${{ runner.temp }}/source-freshness.json
+ run: |
+ updates="$(python -c 'import json, os; print(", ".join(json.load(open(os.environ["REPORT"]))["updates_available"]))')"
+ if [ -z "${updates}" ]; then
+ exit 0
+ fi
+ existing="$(
+ gh issue list \
+ --repo "${GITHUB_REPOSITORY}" \
+ --state open \
+ --search '"Upstream source update available" in:title' \
+ --json number \
+ --jq 'length'
+ )"
+ if [ "${existing}" -eq 0 ]; then
+ gh issue create \
+ --repo "${GITHUB_REPOSITORY}" \
+ --title "Upstream source update available" \
+ --label "source-update" \
+ --body "The scheduled freshness check detected changes for: ${updates}. Review the workflow artifact and use scripts/update_sources.py to stage a dated snapshot. No source or generated dataset was modified automatically."
+ fi
diff --git a/.gitignore b/.gitignore
index b520462..c8c9b26 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,7 @@
sources/cache/*
!sources/cache/.gitkeep
+.DS_Store
+.venv/
__pycache__/
*.pyc
*.tmp
diff --git a/DATASET_PIPELINE.md b/DATASET_PIPELINE.md
index 0779a35..70207e7 100644
--- a/DATASET_PIPELINE.md
+++ b/DATASET_PIPELINE.md
@@ -72,6 +72,8 @@ canonica. `scripts/export_sql.py` crea lo script SQLite-compatible.
- `reports/build-metadata.json`: fonti, qualità, readiness e statistiche;
- `reports/legacy-comparison.json`: confronto storico limitato e deterministico;
- `reports/release-diff.json`: confronto logico con v1.1.0;
+- `reports/reconciliation-backlog.json`: segmenti da revisionare per regione,
+ provincia e codice territoriale sorgente;
- `reports/export-manifest.json`: digest degli export;
- `reports/determinism.json`: firme di due build;
- `reports/quality-validation.json`: gate strutturali separati dalla readiness.
@@ -114,7 +116,25 @@ a commit SHA immutabili.
## Aggiornamento fonti
-1. acquisire un nuovo snapshot dalla stessa origine autorizzata;
+Controllare senza modificare il repository:
+
+```bash
+python scripts/update_sources.py --check
+```
+
+Per preparare esplicitamente un nuovo snapshot in un percorso datato:
+
+```bash
+python scripts/update_sources.py \
+ --download geonames_postal_codes \
+ --output sources/cache/IT-new.zip
+```
+
+Lo script rifiuta di sovrascrivere file esistenti e non modifica il manifest.
+Il workflow settimanale `Source freshness` apre una sola issue quando rileva
+un checksum upstream differente; non esegue commit né rigenera il dataset.
+
+1. acquisire o preparare un nuovo snapshot dalla stessa origine autorizzata;
2. conservarlo sotto un percorso datato;
3. aggiornare manifest, checksum, timestamp e riferimento;
4. modificare la trasformazione se il formato cambia;
diff --git a/DATA_LICENSE.md b/DATA_LICENSE.md
index d9d223c..defbad8 100644
--- a/DATA_LICENSE.md
+++ b/DATA_LICENSE.md
@@ -1,6 +1,6 @@
# Dataset licensing
-Il candidato v2 combina esclusivamente due fonti canoniche attribuite:
+La release v2 combina esclusivamente due fonti canoniche attribuite:
| Componente | Licenza |
| --- | --- |
diff --git a/LICENSE b/LICENSE
index 3aa992d..f74ed29 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,28 +1,37 @@
-Italian Cities licensing notice
+Italian Cities multi-license notice
Repository code and original documentation
------------------------------------------
-The repository's original code and documentation are offered under CC0 1.0
-Universal to the extent that the repository owner holds the applicable rights.
-The complete legal text is available at:
+The repository's original code and documentation are dedicated under CC0 1.0
+Universal to the extent that the repository owner holds the applicable
+rights. The complete legal text is available at:
LICENSES/CC0-1.0.txt
Dataset and third-party inputs
------------------------------
-The CC0 dedication above does not apply to third-party source material or
-prove that legacy geographic and postal values may be relicensed under CC0.
-The canonical dataset combines:
+The CC0 dedication above does not apply to third-party source material or to
+the canonical dataset derived from it. Italian Cities v2 combines:
-- legacy values whose upstream provenance and rights are not fully known;
-- ISTAT-derived classifications and codes that require attribution under
- CC BY 4.0.
+- ISTAT municipality classifications and codes, licensed under CC BY 4.0;
+- GeoNames Postal Codes for Italy, licensed under CC BY 4.0.
-The dataset is therefore distributed with provisional licensing status and
-must not be represented as entirely CC0. Read DATA_LICENSE.md, NOTICE.md and
-DATA_SOURCES.md before reuse or redistribution.
+Reuse of the canonical dataset therefore requires attribution to both ISTAT
+and GeoNames under CC BY 4.0. It must not be represented as entirely CC0.
+Read DATA_LICENSE.md, NOTICE.md and DATA_SOURCES.md before reuse or
+redistribution.
+
+Historical material
+-------------------
+
+Files under legacy/ are retained only as a historical archive and bounded
+comparison input. They do not contribute to the v2 canonical dataset,
+derived formats, GitHub Pages or release assets. Their upstream provenance
+and rights have not been independently established by the current pipeline;
+their presence does not extend the repository CC0 dedication to their
+contents.
No warranty
-----------
diff --git a/NOTICE.md b/NOTICE.md
index 8c22ffb..c5251b6 100644
--- a/NOTICE.md
+++ b/NOTICE.md
@@ -13,6 +13,6 @@ determined or estimated algorithmically; the source `accuracy` field is
preserved by this project.
Historical files under `legacy/` do not contribute to the v2 canonical
-dataset, derived formats, release candidate or GitHub Pages data. They are
+dataset, derived formats, published release or GitHub Pages data. They are
used only for a bounded investigative comparison, which cannot establish
their original provenance.
diff --git a/README.md b/README.md
index 0a5b03e..738fd59 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,11 @@ coordinate, costruito con una pipeline **clean room**.
Il tag e la prerelease
[`v2.0.0`](https://github.com/Codewriter90x/Italian_Cities/releases/tag/v2.0.0)
-sono pubblicati con asset immutabili e checksum SHA-256.
+sono pubblicati con tag protetto, attestazioni e checksum SHA-256. Gli asset
+della v2.0.0 non sono retroattivamente immutabili; il workflow applica il
+processo draft–upload–publish alle release future. L'impostazione GitHub
+**Immutable releases** è attiva dal 30 luglio 2026 e si applica soltanto alle
+release pubblicate dopo l'attivazione.
## Fonti canoniche
@@ -191,11 +195,14 @@ anche senza CAP.
## Licenze e attribuzione
+- Il codice e la documentazione originali del repository sono dedicati CC0
+ 1.0 nei limiti dei diritti detenuti.
- © Istituto nazionale di statistica (ISTAT), dati riutilizzati secondo
CC BY 4.0.
- GeoNames Postal Codes, © GeoNames, CC BY 4.0,
.
+Il dataset canonico derivato non è CC0 e richiede entrambe le attribuzioni.
GeoNames non è una fonte ufficiale di Poste Italiane. Vedere
[`DATA_LICENSE.md`](DATA_LICENSE.md), [`NOTICE.md`](NOTICE.md) e
[`DATA_SOURCES.md`](DATA_SOURCES.md).
@@ -206,3 +213,6 @@ Ogni aggiornamento richiede uno snapshot immutabile, checksum, licenza,
attribuzione, build deterministica e una PR verde. Per una correzione aprire
un [issue form](https://github.com/Codewriter90x/Italian_Cities/issues/new/choose)
indicando record, fonte, data e licenza.
+
+Un esempio completo del processo è disponibile in
+[`docs/SOURCE_BACKED_CORRECTION_EXAMPLE.md`](docs/SOURCE_BACKED_CORRECTION_EXAMPLE.md).
diff --git a/ROADMAP.md b/ROADMAP.md
index 21153fd..42926ff 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -4,7 +4,7 @@ Italian Cities evolves by improving provenance and reproducibility before
claiming broader coverage. The roadmap is public, but dates are intentionally
not promised until the required sources and licences are available.
-## Current: v2.0.0 clean-room prerelease candidate
+## Current: v2.0.0 clean-room prerelease
The current line replaces the unresolved legacy source with:
@@ -18,8 +18,8 @@ The current line replaces the unresolved legacy source with:
- searchable GitHub Pages with shareable filters, ISTAT regional boundaries
and an accessible coverage table.
-It remains a prerelease because GeoNames is not Poste Italiane and CAP and
-coordinates are not operationally certified.
+It is published as a prerelease because GeoNames is not Poste Italiane and
+CAP and coordinates are not operationally certified.
## Next: v2.1.0 — reviewed reconciliation
diff --git a/SCHEMA.md b/SCHEMA.md
index aea4d82..d7d580c 100644
--- a/SCHEMA.md
+++ b/SCHEMA.md
@@ -125,3 +125,8 @@ foglio `Dataset Info` con attribuzione e warning.
CSV, JSON, XLSX, SQLite e SQL devono avere lo stesso ordine di campi, record e
digest semantico. Due build consecutive devono essere identiche.
+
+La tabella e gli asset v2 originali restano invariati. Una preview separata
+del contratto tipizzato della prossima major può essere generata senza
+sovrascriverli; è descritta in
+[`docs/TYPED_CONTRACT_MIGRATION.md`](docs/TYPED_CONTRACT_MIGRATION.md).
diff --git a/docs/SOURCE_BACKED_CORRECTION_EXAMPLE.md b/docs/SOURCE_BACKED_CORRECTION_EXAMPLE.md
new file mode 100644
index 0000000..a2b6546
--- /dev/null
+++ b/docs/SOURCE_BACKED_CORRECTION_EXAMPLE.md
@@ -0,0 +1,79 @@
+# Example: source-backed data correction
+
+Generated CSV, JSON, XLSX, SQLite and SQL files are never edited directly.
+This example shows the evidence and review trail required for a correction.
+It is procedural and does not assert that a real Italian Cities record is
+wrong.
+
+## 1. Identify the canonical record
+
+Record the stable identifier and current values from the generated CSV:
+
+```text
+location_id: IT-COM-000000
+field: postal_code
+current value: 00000
+proposed value: 00001
+```
+
+Do not use row numbers: sorting and source snapshots can change them.
+
+## 2. Attach reusable evidence
+
+The issue must provide:
+
+- a public source URL or exact official publication reference;
+- the source publisher;
+- its reference date;
+- the licence and required attribution;
+- the exact source record, page or table supporting the change;
+- whether the old value is wrong, obsolete or merely incomplete.
+
+A search result, personal observation or another generated aggregator is not
+sufficient evidence. Public Nominatim must not be queried systematically.
+
+## 3. Reproduce the correction in the pipeline
+
+The pull request updates a declared source snapshot or a narrowly scoped,
+reviewable transformation. It must not patch `data/*.csv` directly.
+
+For an upstream snapshot change:
+
+```bash
+python scripts/update_sources.py \
+ --download geonames_postal_codes \
+ --output sources/cache/IT-YYYY-MM-DD.zip
+```
+
+After inspecting the staged file, update `sources/manifest.json` explicitly
+with its dated path, SHA-256, reference date, licence and attribution. Then:
+
+```bash
+python scripts/build_dataset.py
+python scripts/check_determinism.py
+python scripts/validate_dataset.py
+```
+
+## 4. Review the complete impact
+
+The pull request must explain:
+
+- which source records changed;
+- all generated rows affected by the transformation;
+- changes in `reports/release-diff.json`;
+- changes in `reports/reconciliation-backlog.json`;
+- whether coverage or operational-readiness claims changed.
+
+A correction is rejected if it silently promotes an ambiguous match, removes
+provenance, changes unrelated records or cannot be reproduced from the
+declared evidence.
+
+## 5. Acceptance checklist
+
+- [ ] issue form includes source, date, licence and attribution;
+- [ ] no generated file was manually edited;
+- [ ] source checksum validation passes;
+- [ ] schema, integrity, territory and coordinate tests pass;
+- [ ] two builds are deterministic;
+- [ ] all export formats remain semantically equivalent;
+- [ ] reviewer can trace the canonical row back to source record IDs.
diff --git a/docs/TYPED_CONTRACT_MIGRATION.md b/docs/TYPED_CONTRACT_MIGRATION.md
new file mode 100644
index 0000000..b4ce68d
--- /dev/null
+++ b/docs/TYPED_CONTRACT_MIGRATION.md
@@ -0,0 +1,28 @@
+# Typed export contract migration
+
+The published v2.0.0 assets remain byte-for-byte unchanged. In schema 3.0.0,
+CSV, JSON, SQLite and SQL expose source columns as strings and use an empty
+string for missing values.
+
+The next major schema is designed as `4.0.0`:
+
+| Field | Schema 3 | Schema 4 |
+| --- | --- | --- |
+| `latitude`, `longitude` | string, `""` when missing | number or `null` |
+| `coordinate_accuracy` | string, `""` when missing | integer or `null` |
+| identifiers and CAP | string | string |
+
+Consumers can test the preview without changing committed release assets:
+
+```bash
+python scripts/export_typed_json.py \
+ --output dist/italian_locations.typed.json \
+ --sqlite-output dist/italian_locations.typed.sqlite
+```
+
+The preview SQLite table uses `REAL`, `INTEGER` and `NULL` directly. The
+original v2 JSON, SQLite table and SQL script stay unchanged.
+
+The default JSON/table contract will switch only in a new major release. That
+release must include a migration note, cross-format type assertions and a new
+immutable release bundle; v2 assets must never be overwritten.
diff --git a/pyproject.toml b/pyproject.toml
index 310a877..c977ffa 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -3,23 +3,17 @@ target-version = "py311"
line-length = 88
[tool.ruff.lint]
-select = ["E4", "E7", "E9", "F"]
+select = ["B", "C4", "E4", "E7", "E9", "F", "I", "SIM", "UP"]
[tool.ruff.lint.per-file-ignores]
"tests/*.py" = ["E402"]
[tool.mypy]
python_version = "3.11"
-files = [
- "scripts/build_dataset.py",
- "scripts/legacy_comparison.py",
- "scripts/reconcile_sources.py",
- "scripts/source_data.py",
- "scripts/validate_dataset.py",
- "scripts/validators",
-]
+files = ["scripts"]
check_untyped_defs = true
disallow_untyped_defs = true
+ignore_missing_imports = true
no_implicit_optional = true
strict_equality = true
warn_redundant_casts = true
@@ -29,9 +23,13 @@ follow_imports = "silent"
[tool.coverage.run]
branch = true
-source = ["scripts/validators"]
+source = ["scripts"]
+omit = [
+ "scripts/normalize_legacy.py",
+ "scripts/validate_milestone1.py",
+]
[tool.coverage.report]
-fail_under = 75
+fail_under = 80
show_missing = true
skip_covered = true
diff --git a/release/v2.0.0.md b/release/v2.0.0.md
index 3dabf2b..0fa4928 100644
--- a/release/v2.0.0.md
+++ b/release/v2.0.0.md
@@ -18,7 +18,7 @@ GeoNames coordinates may be estimated algorithmically; the original
`structural_quality` is `passed`, while
`operational_data_readiness` is `experimental_non_official`.
-## Candidate statistics
+## Published statistics
- 7,894 ISTAT municipalities;
- 18,415 GeoNames source records;
@@ -55,4 +55,4 @@ The prepared bundle contains:
- `italian_locations.sql`
- `SHA256SUMS`
-Published after explicit maintainer approval.
+Published on 30 July 2026 after explicit maintainer approval.
diff --git a/reports/determinism.json b/reports/determinism.json
index 249c1ae..995ed5f 100644
--- a/reports/determinism.json
+++ b/reports/determinism.json
@@ -17,6 +17,7 @@
"localities_csv": "8071405388fdf96f4f46013d3ff83e3fd4768ea05d77629f6fdc99ab1c950747",
"municipalities_csv": "905af234a403be1750d6e697de04d812766ceb829a62aceaa8796970b461bfd6",
"postal_codes_csv": "2059b5e36af6a21a6f73efd36945b67185500b5e2c15eb599c0fd1a8139efe9a",
+ "reconciliation_backlog": "8c3a13633cb1d21362581bb64239ccf90d6113b819d6d495bb95909c8081eca8",
"release_diff_report": "ed57759aabe4e79b6c7542eccaaa27248a9c39d74e5ca7edfb666901fd73d610"
},
"semantic_sha256": {
diff --git a/reports/reconciliation-backlog.json b/reports/reconciliation-backlog.json
new file mode 100644
index 0000000..995f252
--- /dev/null
+++ b/reports/reconciliation-backlog.json
@@ -0,0 +1,625 @@
+{
+ "dataset_version": "v2.0.0",
+ "missing_municipality_match_by_region": [
+ {
+ "name": "Sardegna",
+ "records": 103
+ },
+ {
+ "name": "Calabria",
+ "records": 100
+ },
+ {
+ "name": "Lombardia",
+ "records": 79
+ },
+ {
+ "name": "Trentino-Alto Adige/Südtirol",
+ "records": 32
+ },
+ {
+ "name": "Piemonte",
+ "records": 22
+ },
+ {
+ "name": "Veneto",
+ "records": 19
+ },
+ {
+ "name": "Emilia-Romagna",
+ "records": 12
+ },
+ {
+ "name": "Marche",
+ "records": 6
+ },
+ {
+ "name": "Friuli-Venezia Giulia",
+ "records": 5
+ },
+ {
+ "name": "Toscana",
+ "records": 5
+ },
+ {
+ "name": "Liguria",
+ "records": 4
+ },
+ {
+ "name": "Sicilia",
+ "records": 3
+ },
+ {
+ "name": "Puglia",
+ "records": 2
+ },
+ {
+ "name": "Abruzzo",
+ "records": 1
+ },
+ {
+ "name": "Campania",
+ "records": 1
+ },
+ {
+ "name": "Lazio",
+ "records": 1
+ },
+ {
+ "name": "Umbria",
+ "records": 1
+ }
+ ],
+ "policy": {
+ "automatic_fuzzy_promotion": false,
+ "evidence_required": [
+ "source",
+ "reference_date",
+ "license",
+ "reproducible_transformation"
+ ],
+ "public_nominatim_bulk_geocoding": false
+ },
+ "review_order": [
+ {
+ "priority": 1,
+ "reason": "Historical source codes need explicit aliases.",
+ "segment": "noncurrent_source_province_codes"
+ },
+ {
+ "priority": 2,
+ "reason": "Municipalities remain canonical but lack CAP/coordinates.",
+ "segment": "municipalities_without_geonames_postal_match"
+ },
+ {
+ "priority": 3,
+ "reason": "Review high-volume segments using licensed evidence.",
+ "segment": "highest_unreconciled_regions"
+ }
+ ],
+ "schema_version": "3.0.0",
+ "status": "review_required",
+ "summary": {
+ "municipalities": 7894,
+ "municipalities_without_geonames_postal_match": 396,
+ "noncurrent_source_province_codes": [
+ "SU"
+ ],
+ "reconciliation_outcomes": {
+ "unmatched_no_parent": 10270
+ },
+ "unreconciled_localities": 10270
+ },
+ "unreconciled_by_province": [
+ {
+ "name": "Bolzano/Bozen (BZ)",
+ "records": 639
+ },
+ {
+ "name": "Trento (TN)",
+ "records": 301
+ },
+ {
+ "name": "Salerno (SA)",
+ "records": 249
+ },
+ {
+ "name": "Modena (MO)",
+ "records": 241
+ },
+ {
+ "name": "Reggio Calabria (RC)",
+ "records": 240
+ },
+ {
+ "name": "Perugia (PG)",
+ "records": 232
+ },
+ {
+ "name": "Firenze (FI)",
+ "records": 199
+ },
+ {
+ "name": "Udine (UD)",
+ "records": 185
+ },
+ {
+ "name": "Messina (ME)",
+ "records": 184
+ },
+ {
+ "name": "Parma (PR)",
+ "records": 176
+ },
+ {
+ "name": "Bologna (BO)",
+ "records": 173
+ },
+ {
+ "name": "Treviso (TV)",
+ "records": 168
+ },
+ {
+ "name": "Brescia (BS)",
+ "records": 167
+ },
+ {
+ "name": "Cosenza (CS)",
+ "records": 167
+ },
+ {
+ "name": "Lucca (LU)",
+ "records": 158
+ },
+ {
+ "name": "Verona (VR)",
+ "records": 157
+ },
+ {
+ "name": "Roma (RM)",
+ "records": 155
+ },
+ {
+ "name": "Cuneo (CN)",
+ "records": 147
+ },
+ {
+ "name": "Torino (TO)",
+ "records": 146
+ },
+ {
+ "name": "Sassari (SS)",
+ "records": 142
+ },
+ {
+ "name": "L'Aquila (AQ)",
+ "records": 141
+ },
+ {
+ "name": "Vicenza (VI)",
+ "records": 136
+ },
+ {
+ "name": "Grosseto (GR)",
+ "records": 133
+ },
+ {
+ "name": "Venezia (VE)",
+ "records": 133
+ },
+ {
+ "name": "Caserta (CE)",
+ "records": 131
+ },
+ {
+ "name": "Genova (GE)",
+ "records": 125
+ },
+ {
+ "name": "Arezzo (AR)",
+ "records": 122
+ },
+ {
+ "name": "Napoli (NA)",
+ "records": 122
+ },
+ {
+ "name": "Pisa (PI)",
+ "records": 122
+ },
+ {
+ "name": "Belluno (BL)",
+ "records": 121
+ },
+ {
+ "name": "Teramo (TE)",
+ "records": 119
+ },
+ {
+ "name": "Reggio nell'Emilia (RE)",
+ "records": 117
+ },
+ {
+ "name": "Padova (PD)",
+ "records": 113
+ },
+ {
+ "name": "Pistoia (PT)",
+ "records": 111
+ },
+ {
+ "name": "Milano (MI)",
+ "records": 110
+ },
+ {
+ "name": "Forlì-Cesena (FC)",
+ "records": 108
+ },
+ {
+ "name": "Mantova (MN)",
+ "records": 105
+ },
+ {
+ "name": "Pesaro e Urbino (PU)",
+ "records": 105
+ },
+ {
+ "name": "Siena (SI)",
+ "records": 101
+ },
+ {
+ "name": "Ancona (AN)",
+ "records": 99
+ },
+ {
+ "name": "Piacenza (PC)",
+ "records": 99
+ },
+ {
+ "name": "Ferrara (FE)",
+ "records": 98
+ },
+ {
+ "name": "Bergamo (BG)",
+ "records": 96
+ },
+ {
+ "name": "Como (CO)",
+ "records": 95
+ },
+ {
+ "name": "Rieti (RI)",
+ "records": 95
+ },
+ {
+ "name": "La Spezia (SP)",
+ "records": 93
+ },
+ {
+ "name": "Ravenna (RA)",
+ "records": 92
+ },
+ {
+ "name": "Terni (TR)",
+ "records": 91
+ },
+ {
+ "name": "Monza e della Brianza (MB)",
+ "records": 90
+ },
+ {
+ "name": "Frosinone (FR)",
+ "records": 87
+ },
+ {
+ "name": "Massa-Carrara (MS)",
+ "records": 87
+ },
+ {
+ "name": "Varese (VA)",
+ "records": 87
+ },
+ {
+ "name": "Avellino (AV)",
+ "records": 86
+ },
+ {
+ "name": "Alessandria (AL)",
+ "records": 84
+ },
+ {
+ "name": "Catania (CT)",
+ "records": 82
+ },
+ {
+ "name": "Pordenone (PN)",
+ "records": 81
+ },
+ {
+ "name": "Catanzaro (CZ)",
+ "records": 80
+ },
+ {
+ "name": "Sondrio (SO)",
+ "records": 77
+ },
+ {
+ "name": "Rovigo (RO)",
+ "records": 76
+ },
+ {
+ "name": "Sud Sardegna (SU)",
+ "records": 76
+ },
+ {
+ "name": "Latina (LT)",
+ "records": 75
+ },
+ {
+ "name": "Nuoro (NU)",
+ "records": 74
+ },
+ {
+ "name": "Palermo (PA)",
+ "records": 70
+ },
+ {
+ "name": "Trapani (TP)",
+ "records": 70
+ },
+ {
+ "name": "Asti (AT)",
+ "records": 67
+ },
+ {
+ "name": "Lecce (LE)",
+ "records": 66
+ },
+ {
+ "name": "Potenza (PZ)",
+ "records": 65
+ },
+ {
+ "name": "Rimini (RN)",
+ "records": 63
+ },
+ {
+ "name": "Chieti (CH)",
+ "records": 62
+ },
+ {
+ "name": "Macerata (MC)",
+ "records": 62
+ },
+ {
+ "name": "Livorno (LI)",
+ "records": 61
+ },
+ {
+ "name": "Pavia (PV)",
+ "records": 61
+ },
+ {
+ "name": "Valle d'Aosta/Vallée d'Aoste (AO)",
+ "records": 61
+ },
+ {
+ "name": "Cremona (CR)",
+ "records": 59
+ },
+ {
+ "name": "Vibo Valentia (VV)",
+ "records": 59
+ },
+ {
+ "name": "Verbano-Cusio-Ossola (VB)",
+ "records": 57
+ },
+ {
+ "name": "Savona (SV)",
+ "records": 53
+ },
+ {
+ "name": "Bari (BA)",
+ "records": 48
+ },
+ {
+ "name": "Biella (BI)",
+ "records": 48
+ },
+ {
+ "name": "Ascoli Piceno (AP)",
+ "records": 47
+ },
+ {
+ "name": "Lecco (LC)",
+ "records": 47
+ },
+ {
+ "name": "Imperia (IM)",
+ "records": 44
+ },
+ {
+ "name": "Pescara (PE)",
+ "records": 43
+ },
+ {
+ "name": "Viterbo (VT)",
+ "records": 42
+ },
+ {
+ "name": "Benevento (BN)",
+ "records": 38
+ },
+ {
+ "name": "Foggia (FG)",
+ "records": 36
+ },
+ {
+ "name": "Prato (PO)",
+ "records": 36
+ },
+ {
+ "name": "Gorizia (GO)",
+ "records": 35
+ },
+ {
+ "name": "Novara (NO)",
+ "records": 34
+ },
+ {
+ "name": "Oristano (OR)",
+ "records": 31
+ },
+ {
+ "name": "Fermo (FM)",
+ "records": 30
+ },
+ {
+ "name": "Isernia (IS)",
+ "records": 27
+ },
+ {
+ "name": "Vercelli (VC)",
+ "records": 27
+ },
+ {
+ "name": "Brindisi (BR)",
+ "records": 22
+ },
+ {
+ "name": "Lodi (LO)",
+ "records": 22
+ },
+ {
+ "name": "Taranto (TA)",
+ "records": 21
+ },
+ {
+ "name": "Cagliari (CA)",
+ "records": 20
+ },
+ {
+ "name": "Siracusa (SR)",
+ "records": 20
+ },
+ {
+ "name": "Trieste (TS)",
+ "records": 20
+ },
+ {
+ "name": "Crotone (KR)",
+ "records": 19
+ },
+ {
+ "name": "Matera (MT)",
+ "records": 17
+ },
+ {
+ "name": "Agrigento (AG)",
+ "records": 16
+ },
+ {
+ "name": "Ragusa (RG)",
+ "records": 16
+ },
+ {
+ "name": "Campobasso (CB)",
+ "records": 11
+ },
+ {
+ "name": "Enna (EN)",
+ "records": 9
+ },
+ {
+ "name": "Caltanissetta (CL)",
+ "records": 5
+ },
+ {
+ "name": "Barletta-Andria-Trani (BT)",
+ "records": 2
+ }
+ ],
+ "unreconciled_by_region": [
+ {
+ "name": "Emilia-Romagna",
+ "records": 1167
+ },
+ {
+ "name": "Toscana",
+ "records": 1130
+ },
+ {
+ "name": "Lombardia",
+ "records": 1016
+ },
+ {
+ "name": "Trentino-Alto Adige/Südtirol",
+ "records": 940
+ },
+ {
+ "name": "Veneto",
+ "records": 904
+ },
+ {
+ "name": "Campania",
+ "records": 626
+ },
+ {
+ "name": "Piemonte",
+ "records": 610
+ },
+ {
+ "name": "Calabria",
+ "records": 565
+ },
+ {
+ "name": "Sicilia",
+ "records": 472
+ },
+ {
+ "name": "Lazio",
+ "records": 454
+ },
+ {
+ "name": "Abruzzo",
+ "records": 365
+ },
+ {
+ "name": "Marche",
+ "records": 343
+ },
+ {
+ "name": "Sardegna",
+ "records": 343
+ },
+ {
+ "name": "Umbria",
+ "records": 323
+ },
+ {
+ "name": "Friuli-Venezia Giulia",
+ "records": 321
+ },
+ {
+ "name": "Liguria",
+ "records": 315
+ },
+ {
+ "name": "Puglia",
+ "records": 195
+ },
+ {
+ "name": "Basilicata",
+ "records": 82
+ },
+ {
+ "name": "Valle d'Aosta/Vallée d'Aoste",
+ "records": 61
+ },
+ {
+ "name": "Molise",
+ "records": 38
+ }
+ ]
+}
diff --git a/requirements.txt b/requirements.txt
index dd5bc83..ead0044 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1 +1,2 @@
XlsxWriter==3.2.9
+truststore==0.10.4
diff --git a/schemas/italian_locations-v4.schema.json b/schemas/italian_locations-v4.schema.json
new file mode 100644
index 0000000..7f18a21
--- /dev/null
+++ b/schemas/italian_locations-v4.schema.json
@@ -0,0 +1,102 @@
+{
+ "$id": "https://codewriter90x.github.io/Italian_Cities/schemas/italian_locations-v4.schema.json",
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "properties": {
+ "fields": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "metadata": {
+ "additionalProperties": true,
+ "properties": {
+ "contract_status": {
+ "const": "next_major_preview"
+ },
+ "schema_version": {
+ "const": "4.0.0"
+ }
+ },
+ "required": [
+ "schema_version",
+ "contract_status"
+ ],
+ "type": "object"
+ },
+ "rows": {
+ "items": {
+ "$ref": "#/$defs/location"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "metadata",
+ "fields",
+ "rows"
+ ],
+ "title": "Italian Cities typed JSON preview",
+ "type": "object",
+ "$defs": {
+ "location": {
+ "additionalProperties": false,
+ "properties": {
+ "candidate_municipality_ids": { "type": "string" },
+ "coordinate_accuracy": { "type": ["integer", "null"] },
+ "coordinate_verification": { "type": "string" },
+ "country_code": { "type": "string" },
+ "country_name": { "type": "string" },
+ "latitude": { "type": ["number", "null"] },
+ "location_id": { "type": "string" },
+ "location_kind": { "type": "string" },
+ "location_postal_id": { "type": "string" },
+ "longitude": { "type": ["number", "null"] },
+ "municipality_istat_code": { "type": "string" },
+ "name": { "type": "string" },
+ "normalized_name": { "type": "string" },
+ "parent_municipality_id": { "type": "string" },
+ "postal_code": { "type": "string" },
+ "postal_code_status": { "type": "string" },
+ "province_code": { "type": "string" },
+ "province_name": { "type": "string" },
+ "reconciliation_confidence": { "type": "string" },
+ "reconciliation_method": { "type": "string" },
+ "reconciliation_outcome": { "type": "string" },
+ "region_name": { "type": "string" },
+ "source_ids": { "type": "string" },
+ "source_record_ids": { "type": "string" },
+ "source_reference_dates": { "type": "string" }
+ },
+ "required": [
+ "location_postal_id",
+ "location_id",
+ "name",
+ "normalized_name",
+ "location_kind",
+ "municipality_istat_code",
+ "parent_municipality_id",
+ "candidate_municipality_ids",
+ "postal_code",
+ "postal_code_status",
+ "province_code",
+ "province_name",
+ "region_name",
+ "country_code",
+ "country_name",
+ "latitude",
+ "longitude",
+ "coordinate_verification",
+ "coordinate_accuracy",
+ "reconciliation_outcome",
+ "reconciliation_method",
+ "reconciliation_confidence",
+ "source_ids",
+ "source_record_ids",
+ "source_reference_dates"
+ ],
+ "type": "object"
+ }
+ }
+}
diff --git a/scripts/build_dataset.py b/scripts/build_dataset.py
index fb72937..5c6b7c3 100644
--- a/scripts/build_dataset.py
+++ b/scripts/build_dataset.py
@@ -35,6 +35,7 @@
STRUCTURAL_QUALITY,
)
from reconcile_sources import reconcile
+from reconciliation_backlog import build_backlog
from source_data import (
DEFAULT_GEONAMES,
DEFAULT_ISTAT,
@@ -44,11 +45,11 @@
validate_declared_sources,
)
-
DEFAULT_PREVIOUS_RELEASE = ROOT / PREVIOUS_RELEASE["canonical_path"]
DEFAULT_DIFF_REPORT = REPORTS_DIR / "release-diff.json"
DEFAULT_LEGACY_REPORT = REPORTS_DIR / "legacy-comparison.json"
DEFAULT_BUILD_REPORT = REPORTS_DIR / "build-metadata.json"
+DEFAULT_RECONCILIATION_REPORT = REPORTS_DIR / "reconciliation-backlog.json"
def parse_args() -> argparse.Namespace:
@@ -69,6 +70,11 @@ def parse_args() -> argparse.Namespace:
parser.add_argument(
"--build-report", type=Path, default=DEFAULT_BUILD_REPORT
)
+ parser.add_argument(
+ "--reconciliation-report",
+ type=Path,
+ default=DEFAULT_RECONCILIATION_REPORT,
+ )
parser.add_argument(
"--no-exports",
action="store_true",
@@ -222,6 +228,13 @@ def build_all(args: argparse.Namespace) -> dict[str, Any]:
row["coordinate_accuracy"] or "missing"
for row in unique_location_rows
)
+ write_json(
+ args.reconciliation_report,
+ build_backlog(
+ cast(list[dict[str, str]], model["municipalities"]),
+ cast(list[dict[str, str]], model["localities"]),
+ ),
+ )
build_report = {
"dataset_version": DATASET_VERSION,
"schema_version": SCHEMA_VERSION,
diff --git a/scripts/build_geography.py b/scripts/build_geography.py
index 646f20b..84e0674 100644
--- a/scripts/build_geography.py
+++ b/scripts/build_geography.py
@@ -11,12 +11,6 @@
from pathlib import Path
from typing import Any
-import shapefile
-from pyproj import Transformer
-from shapely.geometry import mapping, shape
-from shapely.ops import transform
-
-
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_SOURCE = ROOT / "sources" / "cache" / "Limiti01012026_g.zip"
DEFAULT_OUTPUT = ROOT / "site" / "assets" / "italy-regions.geojson"
@@ -61,6 +55,11 @@ def extract_region_layer(source: Path, destination: Path) -> Path:
def build_geography(source: Path, output: Path) -> dict[str, Any]:
+ import shapefile
+ from pyproj import Transformer
+ from shapely.geometry import mapping, shape
+ from shapely.ops import transform
+
source = source.resolve()
output = output.resolve()
if not source.is_file():
@@ -130,23 +129,97 @@ def build_geography(source: Path, output: Path) -> dict[str, Any]:
}
+def check_geography(
+ source: Path,
+ output: Path,
+ *,
+ require_rebuild: bool = False,
+) -> dict[str, Any]:
+ if not output.is_file():
+ raise FileNotFoundError(f"committed geography is missing: {output}")
+ payload = json.loads(output.read_text(encoding="utf-8"))
+ if payload.get("type") != "FeatureCollection":
+ raise ValueError(f"{output}: expected a GeoJSON FeatureCollection")
+ provenance = payload.get("source", {})
+ expected_provenance = {
+ "reference_date": SOURCE_REFERENCE_DATE,
+ "url": SOURCE_URL,
+ "archive_sha256": SOURCE_SHA256,
+ "layer": f"{SHAPEFILE_ROOT}.shp",
+ "license": "CC BY 4.0",
+ }
+ for field, expected in expected_provenance.items():
+ if provenance.get(field) != expected:
+ raise ValueError(
+ f"{output}: source.{field} is {provenance.get(field)!r}; "
+ f"expected {expected!r}"
+ )
+ features = payload.get("features")
+ if not isinstance(features, list) or len(features) != 20:
+ raise ValueError(f"{output}: expected exactly 20 region features")
+ region_codes = [
+ feature.get("properties", {}).get("region_code")
+ for feature in features
+ ]
+ if region_codes != sorted(region_codes) or len(set(region_codes)) != 20:
+ raise ValueError(f"{output}: region codes must be unique and sorted")
+
+ rebuild_status = "skipped_cache_not_present"
+ if source.is_file():
+ try:
+ with tempfile.TemporaryDirectory() as directory:
+ candidate = Path(directory) / output.name
+ build_geography(source, candidate)
+ if candidate.read_bytes() != output.read_bytes():
+ raise ValueError(
+ f"{output}: committed geography differs from a clean rebuild"
+ )
+ rebuild_status = "passed"
+ except ModuleNotFoundError:
+ if require_rebuild:
+ raise
+ rebuild_status = "skipped_geography_dependencies_not_installed"
+ return {
+ "status": "passed",
+ "output": str(output.resolve().relative_to(ROOT)),
+ "features": len(features),
+ "sha256": sha256(output),
+ "source_rebuild": rebuild_status,
+ }
+
+
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", type=Path, default=DEFAULT_SOURCE)
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
+ parser.add_argument(
+ "--check",
+ action="store_true",
+ help=(
+ "Validate the committed GeoJSON and compare it with a clean "
+ "rebuild when the declared source archive is available."
+ ),
+ )
+ parser.add_argument(
+ "--require-rebuild",
+ action="store_true",
+ help="Fail unless --check can rebuild from the declared source archive.",
+ )
return parser.parse_args()
def main() -> None:
args = parse_args()
- print(
- json.dumps(
- build_geography(args.source, args.output),
- ensure_ascii=False,
- indent=2,
- sort_keys=True,
+ report = (
+ check_geography(
+ args.source,
+ args.output,
+ require_rebuild=args.require_rebuild,
)
+ if args.check
+ else build_geography(args.source, args.output)
)
+ print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
if __name__ == "__main__":
diff --git a/scripts/build_pages.py b/scripts/build_pages.py
index 5f1efe0..e943a2f 100644
--- a/scripts/build_pages.py
+++ b/scripts/build_pages.py
@@ -4,17 +4,22 @@
from __future__ import annotations
import argparse
-import csv
import hashlib
import html
import json
import re
import shutil
-import unicodedata
from pathlib import Path
from typing import Any
from urllib.parse import quote_plus
+from pages_model import (
+ WEB_FIELDS,
+ calculate_stats,
+ format_integer,
+ read_locations,
+ slugify,
+)
from project_metadata import (
BUILD_DATE,
DATASET_VERSION,
@@ -26,7 +31,6 @@
STRUCTURAL_QUALITY,
)
-
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
SOURCE_SITE = REPOSITORY_ROOT / "site"
CANONICAL_DATA = REPOSITORY_ROOT / "data" / "italian_locations.csv"
@@ -40,26 +44,6 @@
f"{REPOSITORY_URL}/releases/download/{RELEASE_VERSION}"
)
-WEB_FIELDS = (
- "location_id",
- "name",
- "normalized_name",
- "location_kind",
- "municipality_istat_code",
- "postal_code",
- "postal_code_status",
- "province_code",
- "province_name",
- "region_name",
- "latitude",
- "longitude",
- "coordinate_verification",
- "coordinate_accuracy",
- "reconciliation_outcome",
- "reconciliation_confidence",
-)
-
-
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
@@ -68,89 +52,6 @@ def sha256(path: Path) -> str:
return digest.hexdigest()
-def read_locations(path: Path) -> list[dict[str, Any]]:
- with path.open(encoding="utf-8", newline="") as stream:
- reader = csv.DictReader(stream)
- missing = set(WEB_FIELDS) - set(reader.fieldnames or [])
- if missing:
- raise ValueError(f"Canonical dataset is missing fields: {sorted(missing)}")
-
- rows: list[dict[str, Any]] = []
- for source in reader:
- latitude = source["latitude"].strip()
- longitude = source["longitude"].strip()
- if bool(latitude) != bool(longitude):
- raise ValueError(
- f"Coordinate pair is incomplete for {source['location_id']}"
- )
-
- row = {field: source[field] for field in WEB_FIELDS}
- row["latitude"] = float(latitude) if latitude else None
- row["longitude"] = float(longitude) if longitude else None
- rows.append(row)
-
- return rows
-
-
-def calculate_stats(rows: list[dict[str, Any]]) -> dict[str, Any]:
- unique_locations = {
- row["location_id"]: row for row in reversed(rows)
- }
- location_rows = list(unique_locations.values())
- with_coordinates = [
- row
- for row in location_rows
- if row["latitude"] is not None and row["longitude"] is not None
- ]
- latitudes = [row["latitude"] for row in with_coordinates]
- longitudes = [row["longitude"] for row in with_coordinates]
-
- return {
- "total_locations": len(location_rows),
- "postal_code_relations": len(rows),
- "municipalities": sum(
- row["location_kind"] == "municipality" for row in location_rows
- ),
- "unclassified_localities": sum(
- row["location_kind"] != "municipality" for row in location_rows
- ),
- "unique_postal_codes": len(
- {row["postal_code"] for row in rows if row["postal_code"]}
- ),
- "with_coordinates": len(with_coordinates),
- "missing_coordinates": len(location_rows) - len(with_coordinates),
- "geonames_place_match_coordinates": sum(
- row["coordinate_verification"] == "geonames_place_match"
- for row in location_rows
- ),
- "geonames_estimated_coordinates": sum(
- row["coordinate_verification"] == "geonames_estimated"
- for row in location_rows
- ),
- "missing_postal_code_municipalities": sum(
- row["postal_code_status"] == "missing" for row in rows
- ),
- "provinces": len({row["province_code"] for row in rows}),
- "regions": len({row["region_name"] for row in rows}),
- "bounds": {
- "min_latitude": min(latitudes),
- "max_latitude": max(latitudes),
- "min_longitude": min(longitudes),
- "max_longitude": max(longitudes),
- },
- }
-
-
-def format_integer(value: int) -> str:
- return f"{value:,}".replace(",", ".")
-
-
-def slugify(value: str) -> str:
- normalized = unicodedata.normalize("NFKD", value)
- ascii_value = normalized.encode("ascii", "ignore").decode("ascii")
- return re.sub(r"[^a-z0-9]+", "-", ascii_value.casefold()).strip("-")
-
-
def release_asset_url(name: str) -> str:
return f"{RELEASE_DOWNLOAD_ROOT}/{name}"
@@ -234,6 +135,7 @@ def structured_data(stats: dict[str, Any]) -> str:
),
("italian_locations.sqlite", "application/vnd.sqlite3"),
("italian_locations.sql", "application/sql"),
+ ("SHA256SUMS", "text/plain"),
)
],
},
@@ -427,14 +329,17 @@ def build_information_pages(
non ufficiale.
Download della release {version}
- Gli asset pubblicati sono immutabili e accompagnati da checksum
- SHA-256.
+ Gli asset sono accompagnati da checksum SHA-256. La v2.0.0
+ pubblicata precede l'abilitazione dell'immutabilità GitHub del 30
+ luglio 2026; le release future usano il flusso
+ draft–upload–publish.
Documentazione
diff --git a/scripts/build_release.py b/scripts/build_release.py
index 7c9c80a..9773c04 100644
--- a/scripts/build_release.py
+++ b/scripts/build_release.py
@@ -17,7 +17,6 @@
from export_sql import export_sql
from project_metadata import DATASET_VERSION
-
RELEASE_VERSION = DATASET_VERSION
DEFAULT_OUTPUT = ROOT / "dist" / RELEASE_VERSION
RELEASE_ASSETS = {
diff --git a/scripts/check_determinism.py b/scripts/check_determinism.py
index 740d6ee..12b044c 100644
--- a/scripts/check_determinism.py
+++ b/scripts/check_determinism.py
@@ -21,7 +21,6 @@
)
from project_metadata import QUALITY_GATE_VERSION
-
DEFAULT_REPORT = REPORTS_DIR / "determinism.json"
BYTE_STABLE_PATHS = {
"municipalities_csv": GENERATED_PATHS["municipalities"],
@@ -33,6 +32,7 @@
"build_metadata": REPORTS_DIR / "build-metadata.json",
"legacy_comparison": REPORTS_DIR / "legacy-comparison.json",
"release_diff_report": REPORTS_DIR / "release-diff.json",
+ "reconciliation_backlog": REPORTS_DIR / "reconciliation-backlog.json",
"export_manifest": REPORTS_DIR / "export-manifest.json",
}
diff --git a/scripts/dataset_common.py b/scripts/dataset_common.py
index 32350a8..ddb46f2 100644
--- a/scripts/dataset_common.py
+++ b/scripts/dataset_common.py
@@ -9,9 +9,8 @@
import os
import re
import unicodedata
+from collections.abc import Iterable
from pathlib import Path
-from typing import Iterable
-
ROOT = Path(__file__).resolve().parents[1]
DATA_DIR = ROOT / "data"
diff --git a/scripts/export_formats.py b/scripts/export_formats.py
index f108161..68be847 100644
--- a/scripts/export_formats.py
+++ b/scripts/export_formats.py
@@ -4,17 +4,16 @@
from __future__ import annotations
import argparse
-from datetime import datetime, timezone
import json
import os
import re
import sqlite3
import tempfile
import zipfile
+from datetime import UTC, datetime
from pathlib import Path
import xlsxwriter
-
from dataset_common import (
GENERATED_PATHS,
ITALIAN_LOCATION_FIELDS,
@@ -34,7 +33,6 @@
version_number,
)
-
FIXED_XLSX_TIMESTAMP = f"{BUILD_DATE}T00:00:00Z"
DEFAULT_REPORT = REPORTS_DIR / "export-manifest.json"
GEONAMES_ATTRIBUTION = (
@@ -227,7 +225,7 @@ def export_xlsx(
"author": "Codewriter90x",
"comments": "Generated; do not edit manually.",
"created": datetime.fromisoformat(BUILD_DATE).replace(
- tzinfo=timezone.utc
+ tzinfo=UTC
),
}
)
diff --git a/scripts/export_sql.py b/scripts/export_sql.py
index 72c045c..375f49d 100644
--- a/scripts/export_sql.py
+++ b/scripts/export_sql.py
@@ -15,7 +15,6 @@
)
from project_metadata import DATASET_VERSION, SCHEMA_VERSION, version_number
-
DEFAULT_OUTPUT = Path("dist") / DATASET_VERSION / "italian_locations.sql"
INSERT_BATCH_SIZE = 250
diff --git a/scripts/export_typed_json.py b/scripts/export_typed_json.py
new file mode 100644
index 0000000..58b4723
--- /dev/null
+++ b/scripts/export_typed_json.py
@@ -0,0 +1,113 @@
+#!/usr/bin/env python3
+"""Build the opt-in typed JSON contract planned for the next major release."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sqlite3
+from pathlib import Path
+
+from dataset_common import (
+ GENERATED_PATHS,
+ ITALIAN_LOCATION_FIELDS,
+ read_csv_rows,
+ write_json,
+)
+from project_metadata import DATASET_VERSION
+from typed_contract import (
+ TYPED_CONTRACT_VERSION,
+ sqlite_column_definition,
+ typed_row,
+)
+
+
+def export_typed_json(canonical: Path, output: Path) -> dict[str, object]:
+ rows = read_csv_rows(canonical, ITALIAN_LOCATION_FIELDS)
+ payload = {
+ "metadata": {
+ "source_dataset_version": DATASET_VERSION,
+ "schema_version": TYPED_CONTRACT_VERSION,
+ "contract_status": "next_major_preview",
+ "null_policy": "JSON null for missing numeric values",
+ },
+ "fields": list(ITALIAN_LOCATION_FIELDS),
+ "rows": [typed_row(row) for row in rows],
+ }
+ write_json(output, payload)
+ return {
+ "status": "exported",
+ "schema_version": TYPED_CONTRACT_VERSION,
+ "rows": len(rows),
+ "output": str(output),
+ }
+
+
+def export_typed_sqlite(canonical: Path, output: Path) -> dict[str, object]:
+ rows = read_csv_rows(canonical, ITALIAN_LOCATION_FIELDS)
+ output.parent.mkdir(parents=True, exist_ok=True)
+ connection = sqlite3.connect(output)
+ try:
+ columns = ", ".join(
+ sqlite_column_definition(field)
+ for field in ITALIAN_LOCATION_FIELDS
+ )
+ connection.execute(
+ f'CREATE TABLE "italian_locations" ({columns}, '
+ 'PRIMARY KEY ("location_postal_id")) WITHOUT ROWID'
+ )
+ quoted_fields = ", ".join(
+ f'"{field}"' for field in ITALIAN_LOCATION_FIELDS
+ )
+ placeholders = ", ".join("?" for _ in ITALIAN_LOCATION_FIELDS)
+ converted_rows = [typed_row(row) for row in rows]
+ connection.executemany(
+ f'INSERT INTO "italian_locations" ({quoted_fields}) '
+ f"VALUES ({placeholders})",
+ [
+ tuple(row[field] for field in ITALIAN_LOCATION_FIELDS)
+ for row in converted_rows
+ ],
+ )
+ connection.commit()
+ finally:
+ connection.close()
+ return {
+ "status": "exported",
+ "schema_version": TYPED_CONTRACT_VERSION,
+ "rows": len(rows),
+ "output": str(output),
+ }
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "--canonical",
+ type=Path,
+ default=GENERATED_PATHS["italian_locations"],
+ )
+ parser.add_argument("--output", type=Path, required=True)
+ parser.add_argument(
+ "--sqlite-output",
+ type=Path,
+ help="Optional typed SQLite preview output.",
+ )
+ return parser.parse_args()
+
+
+def main() -> None:
+ args = parse_args()
+ report: dict[str, object] = {
+ "json": export_typed_json(args.canonical, args.output)
+ }
+ if args.sqlite_output:
+ report["sqlite"] = export_typed_sqlite(
+ args.canonical,
+ args.sqlite_output,
+ )
+ print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/legacy_comparison.py b/scripts/legacy_comparison.py
index a1f40b0..ff70ae3 100644
--- a/scripts/legacy_comparison.py
+++ b/scripts/legacy_comparison.py
@@ -10,7 +10,6 @@
from dataset_common import normalize_name, sha256_file
-
LEGACY_FIELDS = (
"legacy_uuid",
"name",
diff --git a/scripts/normalize_legacy.py b/scripts/normalize_legacy.py
index e8bb1b3..0fb1131 100644
--- a/scripts/normalize_legacy.py
+++ b/scripts/normalize_legacy.py
@@ -16,7 +16,6 @@
from collections import Counter
from pathlib import Path
-
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_LEGACY = ROOT / "legacy/2023-05-02-original/Italian Cities.csv"
DEFAULT_ISTAT = (
@@ -107,13 +106,13 @@ def load_istat_municipalities(path: Path) -> dict[tuple[str, str], dict[str, str
]
sheet_root = ET.fromstring(archive.read("xl/worksheets/sheet1.xml"))
- parsed_rows: list[list[str | None]] = []
- for row in sheet_root.findall(".//x:sheetData/x:row", ISTAT_NS):
- values: list[str | None] = [None] * 27
- for cell in row.findall("x:c", ISTAT_NS):
+ parsed_rows: list[list[str]] = []
+ for sheet_row in sheet_root.findall(".//x:sheetData/x:row", ISTAT_NS):
+ values = [""] * 27
+ for cell in sheet_row.findall("x:c", ISTAT_NS):
value_node = cell.find("x:v", ISTAT_NS)
- if value_node is None:
- value = None
+ if value_node is None or value_node.text is None:
+ value = ""
elif cell.get("t") == "s":
value = shared_strings[int(value_node.text)]
else:
@@ -122,14 +121,14 @@ def load_istat_municipalities(path: Path) -> dict[tuple[str, str], dict[str, str
parsed_rows.append(values)
municipalities: dict[tuple[str, str], dict[str, str]] = {}
- for row in parsed_rows[1:]:
+ for parsed_row in parsed_rows[1:]:
record = {
- "istat_code": row[4] or "",
- "bilingual_name": row[5] or "",
- "italian_name": row[6] or "",
- "region_name": row[10] or "",
- "province_name": row[11] or "",
- "province_code": row[14] or "",
+ "istat_code": parsed_row[4],
+ "bilingual_name": parsed_row[5],
+ "italian_name": parsed_row[6],
+ "region_name": parsed_row[10],
+ "province_name": parsed_row[11],
+ "province_code": parsed_row[14],
}
for name in (record["italian_name"], record["bilingual_name"]):
if name:
diff --git a/scripts/pages_model.py b/scripts/pages_model.py
new file mode 100644
index 0000000..295e5cd
--- /dev/null
+++ b/scripts/pages_model.py
@@ -0,0 +1,108 @@
+"""Data loading and aggregation for the static Pages build."""
+
+from __future__ import annotations
+
+import csv
+import re
+import unicodedata
+from pathlib import Path
+from typing import Any
+
+WEB_FIELDS = (
+ "location_id",
+ "name",
+ "normalized_name",
+ "location_kind",
+ "municipality_istat_code",
+ "postal_code",
+ "postal_code_status",
+ "province_code",
+ "province_name",
+ "region_name",
+ "latitude",
+ "longitude",
+ "coordinate_verification",
+ "coordinate_accuracy",
+ "reconciliation_outcome",
+ "reconciliation_confidence",
+)
+
+
+def read_locations(path: Path) -> list[dict[str, Any]]:
+ with path.open(encoding="utf-8", newline="") as stream:
+ reader = csv.DictReader(stream)
+ missing = set(WEB_FIELDS) - set(reader.fieldnames or [])
+ if missing:
+ raise ValueError(
+ f"Canonical dataset is missing fields: {sorted(missing)}"
+ )
+
+ rows: list[dict[str, Any]] = []
+ for source in reader:
+ latitude = source["latitude"].strip()
+ longitude = source["longitude"].strip()
+ if bool(latitude) != bool(longitude):
+ raise ValueError(
+ f"Coordinate pair is incomplete for {source['location_id']}"
+ )
+ row = {field: source[field] for field in WEB_FIELDS}
+ row["latitude"] = float(latitude) if latitude else None
+ row["longitude"] = float(longitude) if longitude else None
+ rows.append(row)
+ return rows
+
+
+def calculate_stats(rows: list[dict[str, Any]]) -> dict[str, Any]:
+ unique_locations = {row["location_id"]: row for row in reversed(rows)}
+ location_rows = list(unique_locations.values())
+ with_coordinates = [
+ row
+ for row in location_rows
+ if row["latitude"] is not None and row["longitude"] is not None
+ ]
+ latitudes = [row["latitude"] for row in with_coordinates]
+ longitudes = [row["longitude"] for row in with_coordinates]
+ return {
+ "total_locations": len(location_rows),
+ "postal_code_relations": len(rows),
+ "municipalities": sum(
+ row["location_kind"] == "municipality" for row in location_rows
+ ),
+ "unclassified_localities": sum(
+ row["location_kind"] != "municipality" for row in location_rows
+ ),
+ "unique_postal_codes": len(
+ {row["postal_code"] for row in rows if row["postal_code"]}
+ ),
+ "with_coordinates": len(with_coordinates),
+ "missing_coordinates": len(location_rows) - len(with_coordinates),
+ "geonames_place_match_coordinates": sum(
+ row["coordinate_verification"] == "geonames_place_match"
+ for row in location_rows
+ ),
+ "geonames_estimated_coordinates": sum(
+ row["coordinate_verification"] == "geonames_estimated"
+ for row in location_rows
+ ),
+ "missing_postal_code_municipalities": sum(
+ row["postal_code_status"] == "missing" for row in rows
+ ),
+ "provinces": len({row["province_code"] for row in rows}),
+ "regions": len({row["region_name"] for row in rows}),
+ "bounds": {
+ "min_latitude": min(latitudes),
+ "max_latitude": max(latitudes),
+ "min_longitude": min(longitudes),
+ "max_longitude": max(longitudes),
+ },
+ }
+
+
+def format_integer(value: int) -> str:
+ return f"{value:,}".replace(",", ".")
+
+
+def slugify(value: str) -> str:
+ normalized = unicodedata.normalize("NFKD", value)
+ ascii_value = normalized.encode("ascii", "ignore").decode("ascii")
+ return re.sub(r"[^a-z0-9]+", "-", ascii_value.casefold()).strip("-")
diff --git a/scripts/project_metadata.py b/scripts/project_metadata.py
index 4304d7a..b5b0ee9 100644
--- a/scripts/project_metadata.py
+++ b/scripts/project_metadata.py
@@ -9,7 +9,6 @@
from pathlib import Path
from typing import Any
-
ROOT = Path(__file__).resolve().parents[1]
PROJECT_METADATA_PATH = ROOT / "project.json"
SEMVER = re.compile(r"^v\d+\.\d+\.\d+$")
diff --git a/scripts/reconcile_sources.py b/scripts/reconcile_sources.py
index 44b034b..60aa015 100644
--- a/scripts/reconcile_sources.py
+++ b/scripts/reconcile_sources.py
@@ -6,11 +6,10 @@
import hashlib
import uuid
from collections import Counter, defaultdict
-from typing import Iterable
+from collections.abc import Iterable
from dataset_common import normalize_name
-
LOCATION_NAMESPACE = uuid.uuid5(
uuid.NAMESPACE_URL,
"https://github.com/Codewriter90x/Italian_Cities/v2/location",
diff --git a/scripts/reconciliation_backlog.py b/scripts/reconciliation_backlog.py
new file mode 100644
index 0000000..037957e
--- /dev/null
+++ b/scripts/reconciliation_backlog.py
@@ -0,0 +1,124 @@
+#!/usr/bin/env python3
+"""Build a deterministic, evidence-first reconciliation backlog."""
+
+from __future__ import annotations
+
+import argparse
+import json
+from collections import Counter
+from pathlib import Path
+
+from dataset_common import (
+ GENERATED_PATHS,
+ LOCALITY_FIELDS,
+ MUNICIPALITY_FIELDS,
+ REPORTS_DIR,
+ read_csv_rows,
+ write_json,
+)
+from project_metadata import DATASET_VERSION, SCHEMA_VERSION
+
+DEFAULT_OUTPUT = REPORTS_DIR / "reconciliation-backlog.json"
+
+
+def ranked(counter: Counter[str]) -> list[dict[str, object]]:
+ return [
+ {"name": name, "records": count}
+ for name, count in sorted(
+ counter.items(),
+ key=lambda item: (-item[1], item[0]),
+ )
+ ]
+
+
+def build_backlog(
+ municipalities: list[dict[str, str]],
+ localities: list[dict[str, str]],
+) -> dict[str, object]:
+ municipality_province_codes = {
+ row["province_code"] for row in municipalities
+ }
+ noncurrent_codes = sorted(
+ {
+ row["province_code"]
+ for row in localities
+ if row["province_code"] not in municipality_province_codes
+ }
+ )
+ missing_postal = [
+ row
+ for row in municipalities
+ if row["coordinate_verification"] == "missing"
+ ]
+ outcomes = Counter(row["reconciliation_outcome"] for row in localities)
+ return {
+ "dataset_version": DATASET_VERSION,
+ "schema_version": SCHEMA_VERSION,
+ "status": "review_required",
+ "policy": {
+ "automatic_fuzzy_promotion": False,
+ "public_nominatim_bulk_geocoding": False,
+ "evidence_required": [
+ "source",
+ "reference_date",
+ "license",
+ "reproducible_transformation",
+ ],
+ },
+ "summary": {
+ "municipalities": len(municipalities),
+ "unreconciled_localities": len(localities),
+ "municipalities_without_geonames_postal_match": len(missing_postal),
+ "noncurrent_source_province_codes": noncurrent_codes,
+ "reconciliation_outcomes": dict(sorted(outcomes.items())),
+ },
+ "unreconciled_by_region": ranked(
+ Counter(row["region_name"] for row in localities)
+ ),
+ "unreconciled_by_province": ranked(
+ Counter(
+ f"{row['province_name']} ({row['province_code']})"
+ for row in localities
+ )
+ ),
+ "missing_municipality_match_by_region": ranked(
+ Counter(row["region_name"] for row in missing_postal)
+ ),
+ "review_order": [
+ {
+ "priority": 1,
+ "segment": "noncurrent_source_province_codes",
+ "reason": "Historical source codes need explicit aliases.",
+ },
+ {
+ "priority": 2,
+ "segment": "municipalities_without_geonames_postal_match",
+ "reason": "Municipalities remain canonical but lack CAP/coordinates.",
+ },
+ {
+ "priority": 3,
+ "segment": "highest_unreconciled_regions",
+ "reason": "Review high-volume segments using licensed evidence.",
+ },
+ ],
+ }
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT)
+ return parser.parse_args()
+
+
+def main() -> None:
+ args = parse_args()
+ report = build_backlog(
+ read_csv_rows(GENERATED_PATHS["municipalities"], MUNICIPALITY_FIELDS),
+ read_csv_rows(GENERATED_PATHS["localities"], LOCALITY_FIELDS),
+ )
+ write_json(args.output, report)
+ print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/source_data.py b/scripts/source_data.py
index e2d1613..df3fdc5 100644
--- a/scripts/source_data.py
+++ b/scripts/source_data.py
@@ -15,7 +15,6 @@
from dataset_common import ROOT, SOURCE_MANIFEST, normalize_name, sha256_file
-
DEFAULT_ISTAT = (
ROOT
/ "sources/snapshots/istat/Elenco-comuni-italiani-2026-02-21.xlsx"
diff --git a/scripts/typed_contract.py b/scripts/typed_contract.py
new file mode 100644
index 0000000..dcf9774
--- /dev/null
+++ b/scripts/typed_contract.py
@@ -0,0 +1,33 @@
+"""Typed next-major representation for consumer-facing exports."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from dataset_common import ITALIAN_LOCATION_FIELDS
+
+TYPED_CONTRACT_VERSION = "4.0.0"
+NULLABLE_NUMBER_FIELDS = frozenset({"latitude", "longitude"})
+NULLABLE_INTEGER_FIELDS = frozenset({"coordinate_accuracy"})
+
+
+def typed_row(row: dict[str, str]) -> dict[str, Any]:
+ result: dict[str, Any] = {}
+ for field in ITALIAN_LOCATION_FIELDS:
+ value = row[field]
+ if field in NULLABLE_NUMBER_FIELDS:
+ result[field] = float(value) if value else None
+ elif field in NULLABLE_INTEGER_FIELDS:
+ result[field] = int(value) if value else None
+ else:
+ result[field] = value
+ return result
+
+
+def sqlite_column_definition(field: str) -> str:
+ quoted = f'"{field}"'
+ if field in NULLABLE_NUMBER_FIELDS:
+ return f"{quoted} REAL"
+ if field in NULLABLE_INTEGER_FIELDS:
+ return f"{quoted} INTEGER"
+ return f"{quoted} TEXT NOT NULL"
diff --git a/scripts/update_sources.py b/scripts/update_sources.py
new file mode 100644
index 0000000..95d370b
--- /dev/null
+++ b/scripts/update_sources.py
@@ -0,0 +1,174 @@
+#!/usr/bin/env python3
+"""Check canonical upstream snapshots and stage explicit source downloads."""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+import ssl
+import tempfile
+from datetime import UTC, datetime
+from pathlib import Path
+from typing import Any, BinaryIO
+from urllib.request import Request, urlopen
+
+import truststore
+from dataset_common import ROOT, SOURCE_MANIFEST, sha256_file, write_json
+from source_data import CANONICAL_SOURCE_IDS, load_manifest, source_by_id
+
+USER_AGENT = (
+ "Italian_Cities source freshness checker "
+ "(https://github.com/Codewriter90x/Italian_Cities)"
+)
+TLS_CONTEXT = truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
+
+
+def download(url: str, destination: Path) -> dict[str, str]:
+ request = Request(url, headers={"User-Agent": USER_AGENT})
+ digest = hashlib.sha256()
+ with urlopen(
+ request,
+ timeout=90,
+ context=TLS_CONTEXT,
+ ) as response: # noqa: S310
+ last_modified = response.headers.get("Last-Modified", "")
+ etag = response.headers.get("ETag", "")
+ with destination.open("wb") as output:
+ stream: BinaryIO = response
+ for chunk in iter(lambda: stream.read(1024 * 1024), b""):
+ digest.update(chunk)
+ output.write(chunk)
+ if destination.stat().st_size == 0:
+ raise ValueError(f"{url}: upstream returned an empty artifact")
+ return {
+ "sha256": digest.hexdigest(),
+ "last_modified": last_modified,
+ "etag": etag,
+ }
+
+
+def inspect_source(source: dict[str, Any]) -> dict[str, Any]:
+ with tempfile.TemporaryDirectory(prefix="italian-cities-source-") as folder:
+ candidate = Path(folder) / "candidate"
+ remote = download(str(source["url"]), candidate)
+ declared_sha256 = str(source["sha256"])
+ return {
+ "id": source["id"],
+ "url": source["url"],
+ "declared_path": source["path"],
+ "declared_sha256": declared_sha256,
+ "remote_sha256": remote["sha256"],
+ "remote_last_modified": remote["last_modified"],
+ "remote_etag": remote["etag"],
+ "status": (
+ "current"
+ if remote["sha256"] == declared_sha256
+ else "update_available"
+ ),
+ }
+
+
+def check_sources(manifest_path: Path = SOURCE_MANIFEST) -> dict[str, Any]:
+ manifest = load_manifest(manifest_path)
+ sources = [
+ inspect_source(source_by_id(manifest, source_id))
+ for source_id in CANONICAL_SOURCE_IDS
+ ]
+ changed = [source["id"] for source in sources if source["status"] != "current"]
+ return {
+ "checked_at": datetime.now(UTC).isoformat(),
+ "manifest": str(manifest_path.resolve().relative_to(ROOT)),
+ "status": "updates_available" if changed else "current",
+ "updates_available": changed,
+ "sources": sources,
+ }
+
+
+def stage_download(
+ source_id: str,
+ output: Path,
+ manifest_path: Path = SOURCE_MANIFEST,
+) -> dict[str, Any]:
+ manifest = load_manifest(manifest_path)
+ if source_id not in CANONICAL_SOURCE_IDS:
+ raise ValueError(
+ f"{source_id!r} is not a canonical source: {CANONICAL_SOURCE_IDS}"
+ )
+ if output.exists():
+ raise FileExistsError(
+ f"refusing to overwrite {output}; choose a new dated path"
+ )
+ output.parent.mkdir(parents=True, exist_ok=True)
+ source = source_by_id(manifest, source_id)
+ with tempfile.NamedTemporaryFile(
+ prefix=f"{source_id}-",
+ dir=output.parent,
+ delete=False,
+ ) as handle:
+ temporary = Path(handle.name)
+ try:
+ remote = download(str(source["url"]), temporary)
+ os.replace(temporary, output)
+ finally:
+ if temporary.exists():
+ temporary.unlink()
+ return {
+ "status": "staged",
+ "source_id": source_id,
+ "output": str(output),
+ "sha256": sha256_file(output),
+ "remote_last_modified": remote["last_modified"],
+ "remote_etag": remote["etag"],
+ "next_steps": [
+ "inspect the staged artifact",
+ "update sources/manifest.json explicitly",
+ "rebuild and review every generated diff",
+ ],
+ }
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description=__doc__)
+ action = parser.add_mutually_exclusive_group(required=True)
+ action.add_argument(
+ "--check",
+ action="store_true",
+ help="Download canonical upstream artifacts to a temporary directory.",
+ )
+ action.add_argument(
+ "--download",
+ choices=CANONICAL_SOURCE_IDS,
+ metavar="SOURCE_ID",
+ help="Stage one upstream artifact without editing the manifest.",
+ )
+ parser.add_argument("--manifest", type=Path, default=SOURCE_MANIFEST)
+ parser.add_argument(
+ "--output",
+ type=Path,
+ help="New, non-existing path required by --download.",
+ )
+ parser.add_argument(
+ "--report",
+ type=Path,
+ help="Optional JSON report path.",
+ )
+ return parser.parse_args()
+
+
+def main() -> None:
+ args = parse_args()
+ if args.download:
+ if args.output is None:
+ raise SystemExit("--output is required with --download")
+ report = stage_download(args.download, args.output, args.manifest)
+ else:
+ report = check_sources(args.manifest)
+ if args.report:
+ write_json(args.report, report)
+ print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/validate_dataset.py b/scripts/validate_dataset.py
index afeb8f7..c6ebdab 100644
--- a/scripts/validate_dataset.py
+++ b/scripts/validate_dataset.py
@@ -4,8 +4,8 @@
from __future__ import annotations
import argparse
-from collections import Counter
import json
+from collections import Counter
from pathlib import Path
from check_determinism import DEFAULT_REPORT as DETERMINISM_REPORT
@@ -51,7 +51,6 @@
validate_territories,
)
-
DEFAULT_REPORT = REPORTS_DIR / "quality-validation.json"
QUALITY_CHECK_NAMES = (
"schema_columns",
diff --git a/scripts/validate_milestone1.py b/scripts/validate_milestone1.py
index c00a56d..8aa087e 100644
--- a/scripts/validate_milestone1.py
+++ b/scripts/validate_milestone1.py
@@ -30,7 +30,6 @@
normalize_name,
)
-
ROOT = Path(__file__).resolve().parents[1]
DEFAULT_REPORT = ROOT / "reports/milestone1-validation.json"
diff --git a/scripts/validate_release.py b/scripts/validate_release.py
index 4b4a92b..15e3684 100644
--- a/scripts/validate_release.py
+++ b/scripts/validate_release.py
@@ -25,7 +25,6 @@
)
from project_metadata import DATASET_VERSION, SCHEMA_VERSION
-
EXPECTED_ASSETS = {*RELEASE_ASSETS, SQL_ASSET, CHECKSUM_ASSET}
diff --git a/scripts/validate_site.py b/scripts/validate_site.py
new file mode 100644
index 0000000..38c374c
--- /dev/null
+++ b/scripts/validate_site.py
@@ -0,0 +1,158 @@
+#!/usr/bin/env python3
+"""Validate built Pages links, accessibility basics and performance budgets."""
+
+from __future__ import annotations
+
+import argparse
+import json
+from html.parser import HTMLParser
+from pathlib import Path
+from urllib.parse import unquote, urlparse
+
+MAX_HOMEPAGE_BYTES = 30_000
+MAX_SEARCH_DATA_BYTES = 4_200_000
+MAX_FIRST_PARTY_CODE_BYTES = 80_000
+
+
+class PageInspector(HTMLParser):
+ def __init__(self) -> None:
+ super().__init__(convert_charrefs=True)
+ self.ids: list[str] = []
+ self.links: list[str] = []
+ self.controls: list[tuple[str, str]] = []
+ self.label_targets: set[str] = set()
+ self.images_without_alt: list[str] = []
+ self.button_texts: list[str] = []
+ self._current_button: list[str] | None = None
+
+ def handle_starttag(
+ self,
+ tag: str,
+ attrs: list[tuple[str, str | None]],
+ ) -> None:
+ values = dict(attrs)
+ element_id = values.get("id")
+ if element_id:
+ self.ids.append(element_id)
+ if tag == "a" and values.get("href"):
+ self.links.append(values["href"] or "")
+ if tag in {"input", "select", "textarea"} and element_id:
+ self.controls.append((tag, element_id))
+ if tag == "label" and values.get("for"):
+ self.label_targets.add(values["for"] or "")
+ if tag == "img" and "alt" not in values:
+ self.images_without_alt.append(values.get("src", "") or "")
+ if tag == "button":
+ self._current_button = []
+
+ def handle_data(self, data: str) -> None:
+ if self._current_button is not None:
+ self._current_button.append(data)
+
+ def handle_endtag(self, tag: str) -> None:
+ if tag == "button" and self._current_button is not None:
+ self.button_texts.append("".join(self._current_button).strip())
+ self._current_button = None
+
+
+def resolve_local_link(site: Path, page: Path, href: str) -> Path | None:
+ parsed = urlparse(href)
+ if parsed.scheme or parsed.netloc or href.startswith(("mailto:", "#")):
+ return None
+ path = unquote(parsed.path)
+ if path.startswith("/Italian_Cities/"):
+ target = site / path.removeprefix("/Italian_Cities/")
+ elif path.startswith("/"):
+ return None
+ else:
+ target = page.parent / path
+ if not path or path.endswith("/"):
+ target /= "index.html"
+ return target.resolve()
+
+
+def validate_site(site: Path) -> dict[str, object]:
+ site = site.resolve()
+ errors: list[str] = []
+ html_files = sorted(site.rglob("*.html"))
+ if not html_files:
+ errors.append("site contains no HTML pages")
+ for page in html_files:
+ inspector = PageInspector()
+ inspector.feed(page.read_text(encoding="utf-8"))
+ duplicate_ids = sorted(
+ identifier
+ for identifier in set(inspector.ids)
+ if inspector.ids.count(identifier) > 1
+ )
+ if duplicate_ids:
+ errors.append(f"{page}: duplicate ids {duplicate_ids}")
+ missing_labels = sorted(
+ control_id
+ for _, control_id in inspector.controls
+ if control_id not in inspector.label_targets
+ )
+ if missing_labels:
+ errors.append(f"{page}: controls without labels {missing_labels}")
+ if inspector.images_without_alt:
+ errors.append(
+ f"{page}: images without alt {inspector.images_without_alt}"
+ )
+ if any(not text for text in inspector.button_texts):
+ errors.append(f"{page}: button without accessible text")
+ for href in inspector.links:
+ target = resolve_local_link(site, page, href)
+ if target is not None and not target.is_file():
+ errors.append(f"{page}: broken local link {href!r}")
+
+ homepage = site / "index.html"
+ locations = site / "assets/locations.json"
+ code_bytes = sum(
+ path.stat().st_size
+ for path in (site / "assets").iterdir()
+ if path.suffix in {".css", ".js", ".mjs"}
+ )
+ budgets = {
+ "homepage_bytes": homepage.stat().st_size,
+ "search_data_bytes": locations.stat().st_size,
+ "first_party_code_bytes": code_bytes,
+ }
+ limits = {
+ "homepage_bytes": MAX_HOMEPAGE_BYTES,
+ "search_data_bytes": MAX_SEARCH_DATA_BYTES,
+ "first_party_code_bytes": MAX_FIRST_PARTY_CODE_BYTES,
+ }
+ for name, value in budgets.items():
+ if value > limits[name]:
+ errors.append(f"{name} exceeds budget: {value} > {limits[name]}")
+ app_source = (site / "assets/app.js").read_text(encoding="utf-8")
+ if "IntersectionObserver" not in app_source:
+ errors.append("search data is not guarded by lazy loading")
+ if 'id="map-fallback-body"' not in homepage.read_text(encoding="utf-8"):
+ errors.append("canvas map lacks the accessible tabular fallback")
+
+ report: dict[str, object] = {
+ "status": "passed" if not errors else "failed",
+ "pages": len(html_files),
+ "budgets": budgets,
+ "limits": limits,
+ "errors": errors,
+ }
+ if errors:
+ raise ValueError(json.dumps(report, ensure_ascii=False, indent=2))
+ return report
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("site", type=Path)
+ return parser.parse_args()
+
+
+def main() -> None:
+ report = validate_site(parse_args().site)
+ print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/validators/common.py b/scripts/validators/common.py
index 9da97fd..f9483e0 100644
--- a/scripts/validators/common.py
+++ b/scripts/validators/common.py
@@ -5,7 +5,6 @@
import csv
from pathlib import Path
-
QualityChecks = dict[str, dict[str, object]]
diff --git a/scripts/validators/coordinates.py b/scripts/validators/coordinates.py
index 585b2da..1b55279 100644
--- a/scripts/validators/coordinates.py
+++ b/scripts/validators/coordinates.py
@@ -7,7 +7,6 @@
from validators.common import QualityChecks, add_quality_error
-
ITALY_BOUNDS = {
"latitude_min": 35.0,
"latitude_max": 48.0,
diff --git a/scripts/validators/formats.py b/scripts/validators/formats.py
index 42f154f..4f712b6 100644
--- a/scripts/validators/formats.py
+++ b/scripts/validators/formats.py
@@ -23,8 +23,8 @@
SCHEMA_VERSION,
STRUCTURAL_QUALITY,
)
-from validators.common import QualityChecks, add_quality_error
+from validators.common import QualityChecks, add_quality_error
SHEET_NS = {"x": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
DOC_REL_NS = {
diff --git a/scripts/validators/provenance.py b/scripts/validators/provenance.py
index 9f0a495..a7d1504 100644
--- a/scripts/validators/provenance.py
+++ b/scripts/validators/provenance.py
@@ -4,7 +4,6 @@
from validators.common import QualityChecks, add_quality_error
-
CANONICAL_SOURCES = {
"istat_municipalities",
"geonames_postal_codes",
@@ -69,11 +68,13 @@ def validate_verification_and_provenance(
"provenance_completeness",
f"{label}: ambiguous match lacks candidate ids",
)
- if row.get("reconciliation_outcome") == "unmatched_no_parent":
- if row.get("parent_municipality_id"):
- add_quality_error(
- errors,
- checks,
- "clean_room_isolation",
- f"{label}: unmatched locality has a parent municipality",
- )
+ if (
+ row.get("reconciliation_outcome") == "unmatched_no_parent"
+ and row.get("parent_municipality_id")
+ ):
+ add_quality_error(
+ errors,
+ checks,
+ "clean_room_isolation",
+ f"{label}: unmatched locality has a parent municipality",
+ )
diff --git a/scripts/validators/schema.py b/scripts/validators/schema.py
index 786be96..c8da088 100644
--- a/scripts/validators/schema.py
+++ b/scripts/validators/schema.py
@@ -6,7 +6,6 @@
from validators.common import QualityChecks, add_quality_error
-
POSTAL_CODE_STATUSES = {
"geonames_matched",
"geonames_ambiguous",
diff --git a/scripts/validators/territory.py b/scripts/validators/territory.py
index 1bdfb00..d1b33dd 100644
--- a/scripts/validators/territory.py
+++ b/scripts/validators/territory.py
@@ -5,6 +5,7 @@
import re
from dataset_common import normalize_name
+
from validators.common import QualityChecks, add_quality_error
diff --git a/site/assets/app.js b/site/assets/app.js
index 19dfb65..d74c220 100644
--- a/site/assets/app.js
+++ b/site/assets/app.js
@@ -9,9 +9,11 @@ import {
const state = {
rows: [],
+ mapRows: [],
boundaries: null,
stats: null,
selected: null,
+ loadPromise: null,
filters: {
query: "",
province: "",
@@ -28,6 +30,8 @@ const elements = {
kind: document.querySelector("#kind"),
coordinateStatus: document.querySelector("#coordinate-status"),
reset: document.querySelector("#reset-search"),
+ copyFilterLink: document.querySelector("#copy-filter-link"),
+ copyStatus: document.querySelector("#copy-status"),
resultCount: document.querySelector("#result-count"),
results: document.querySelector("#result-list"),
canvas: document.querySelector("#coverage-map"),
@@ -50,6 +54,11 @@ function populateStats(stats) {
}
function populateProvinces(rows) {
+ const currentProvinceCodes = new Set(
+ rows
+ .filter((row) => row.location_kind === "municipality")
+ .map((row) => row.province_code),
+ );
const provinces = [
...new Map(
rows.map((row) => [
@@ -62,7 +71,9 @@ function populateProvinces(rows) {
for (const [code, label] of provinces) {
const option = document.createElement("option");
option.value = code;
- option.textContent = label;
+ option.textContent = currentProvinceCodes.has(code)
+ ? label
+ : `${label} · sigla sorgente non corrente`;
elements.province.append(option);
}
}
@@ -233,7 +244,7 @@ function drawMap() {
const bounds = state.stats.bounds;
drawBoundaries(context, width, height, bounds);
- for (const row of state.rows) {
+ for (const row of state.mapRows) {
if (row.latitude === null || row.longitude === null) continue;
const point = projectCoordinates(
row.longitude,
@@ -304,6 +315,31 @@ function updateFilters() {
window.history.replaceState(null, "", url);
}
+async function copyCurrentFilterLink() {
+ const url = window.location.href;
+ try {
+ if (navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(url);
+ } else {
+ const temporary = document.createElement("textarea");
+ temporary.value = url;
+ temporary.setAttribute("readonly", "");
+ temporary.style.position = "fixed";
+ temporary.style.opacity = "0";
+ document.body.append(temporary);
+ temporary.select();
+ if (!document.execCommand("copy")) {
+ throw new Error("copy command unavailable");
+ }
+ temporary.remove();
+ }
+ elements.copyStatus.textContent = "Link dei filtri copiato.";
+ } catch {
+ elements.copyStatus.textContent =
+ "Copia non disponibile: seleziona l’indirizzo dalla barra del browser.";
+ }
+}
+
function resetSearch() {
elements.form.reset();
state.selected = null;
@@ -337,6 +373,11 @@ async function loadDataset() {
boundaryResponse.json(),
]);
state.rows = inflateRows(payload.fields, payload.rows);
+ state.mapRows = [
+ ...new Map(
+ [...state.rows].reverse().map((row) => [row.location_id, row]),
+ ).values(),
+ ];
state.stats = payload.stats;
state.boundaries = boundaries;
populateStats(payload.stats);
@@ -357,13 +398,39 @@ async function loadDataset() {
}
}
+function ensureDatasetLoaded() {
+ if (!state.loadPromise) {
+ elements.status.textContent = "Caricamento del dataset…";
+ elements.status.dataset.state = "loading";
+ state.loadPromise = loadDataset();
+ }
+ return state.loadPromise;
+}
+
elements.form.addEventListener("input", updateFilters);
+elements.form.addEventListener("focusin", ensureDatasetLoaded, { once: true });
elements.form.addEventListener("submit", (event) => event.preventDefault());
elements.reset.addEventListener("click", resetSearch);
+elements.copyFilterLink.addEventListener("click", copyCurrentFilterLink);
window.addEventListener("resize", drawMap);
window.addEventListener("popstate", () => {
applyFiltersFromUrl();
renderResults();
});
-loadDataset();
+if (window.location.search || ["#search", "#map"].includes(window.location.hash)) {
+ ensureDatasetLoaded();
+} else if ("IntersectionObserver" in window) {
+ const observer = new IntersectionObserver(
+ (entries) => {
+ if (entries.some((entry) => entry.isIntersecting)) {
+ observer.disconnect();
+ ensureDatasetLoaded();
+ }
+ },
+ { rootMargin: "240px" },
+ );
+ observer.observe(elements.form);
+} else {
+ ensureDatasetLoaded();
+}
diff --git a/site/assets/styles.css b/site/assets/styles.css
index 626f169..5420c3c 100644
--- a/site/assets/styles.css
+++ b/site/assets/styles.css
@@ -347,6 +347,13 @@ a:focus-visible {
margin-block: 1.25rem 0.75rem;
}
+.search-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.9rem;
+ justify-content: flex-end;
+}
+
.search-toolbar button {
padding: 0;
color: var(--cyan);
@@ -355,6 +362,14 @@ a:focus-visible {
cursor: pointer;
}
+.copy-status {
+ min-height: 1.5rem;
+ margin: 0;
+ color: var(--muted);
+ font-size: 0.82rem;
+ text-align: right;
+}
+
.result-list {
display: grid;
gap: 0.6rem;
diff --git a/site/index.html b/site/index.html
index dfe7627..2f483fb 100644
--- a/site/index.html
+++ b/site/index.html
@@ -213,8 +213,14 @@ Cerca comuni, CAP e province
+
@@ -305,6 +311,9 @@ Scarica CSV, JSON, XLSX, SQLite o SQL.
SQL {{DATASET_VERSION}}
+
+ SHA256SUMS
+
Note della release
@@ -352,7 +361,7 @@ Come usare correttamente Italian Cities
- Caricamento del dataset…
+ La ricerca interattiva verrà caricata quando raggiungi questa sezione.