diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66dece0..79b0d53 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,4 +32,20 @@ jobs: with: node-version: "20" - name: ESLint (map app JS) - run: npx --yes eslint@9 scripts/_lib/assets/app.js + run: npx --yes eslint@9 scripts/_lib/assets/app.js scripts/_lib/assets/query_core.mjs + + mcp: + # The remote MCP server (mcp/, a Bunny Edge Script): the bundle must build + # and the tests pass. Tests that need a stage-04 build (wiki/) skip here. + runs-on: ubuntu-latest + defaults: + run: + working-directory: mcp + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + - run: npm ci + - run: mkdir -p dist && npm run build + - run: npm test diff --git a/CLAUDE.md b/CLAUDE.md index c1e5217..ca71a59 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6245,19 +6245,65 @@ expected storage zone. Full runbook, the .env layout and the reason beta is blocked at robots.txt rather than with a noindex header (it would leak into production through the cross-host canonical): [docs/deploy.md](docs/deploy.md). -## WebMCP tools - -The end of [scripts/_lib/assets/app.js](scripts/_lib/assets/app.js) registers -four read-mostly WebMCP tools (`search_appellations`, `filter_appellations`, -`get_appellation`, `show_appellation`) through `document.modelContext`, only -where the browser exposes the API. Chrome ships it as an origin trial -(Chrome 149–162, ends 2027-03-30): the token lives in the repo-root `.env` as -`WEBMCP_ORIGIN_TRIAL_TOKEN` (registered for https://openwinemap.com with -subdomain matching, so www and beta share it) and stage 04 emits it as -`` straight after `` on every map -page (`_origin_trial_meta` in map_template.py). Chrome reissues the token on -renewal — swap the `.env` value and rebuild. Unset ⇒ no tag. Calls are counted -as the `WebMCP Tool` Plausible event (see docs/analytics.md). +## WebMCP tools and the MCP server + +The map answers agents through two surfaces built on **one query core**, +[scripts/_lib/assets/query_core.mjs](scripts/_lib/assets/query_core.mjs): a +dependency-free ES module (search normalisation and scoring, grape / style / +region / scheme resolution, `search`, `filter`, `full`, `facets`, and the shared +`TOOL_DEFS` schemas). Stage 04 inlines it into app.js at the `__OWM_query_core__` +token (`export ` stripped; `_load_query_core_inline` refuses imports or build +tokens), and the app's omnisearch, tree filter and label helpers (`grapeName`, +`regionLabel`, `countryLabel`, `expandGrapeSet`, …) are aliases of the `QC` +instance — change search semantics in the core, never in app.js. + +- **WebMCP** (end of app.js): `search_appellations`, `filter_appellations`, + `get_appellation`, `list_facets` (adapters over `QC`) plus `show_appellation` + (opens the panel), registered through `document.modelContext` only where the + browser exposes it. Chrome ships it as an origin trial (Chrome 149–162, ends + 2027-03-30): the token lives in the repo-root `.env` as + `WEBMCP_ORIGIN_TRIAL_TOKEN` (registered for https://openwinemap.com with + subdomain matching, so www and beta share it) and stage 04 emits it as + `` straight after `` on every + map page (`_origin_trial_meta`). Chrome reissues the token on renewal — swap + the `.env` value and rebuild. Counted as the `WebMCP Tool` Plausible event. +- **Remote MCP server** ([mcp/](mcp/), https://mcp.openwinemap.com/mcp): a + Bunny standalone Edge Script built on `@modelcontextprotocol/server` + (`createMcpHandler`, stateless; serves protocol 2026-07-28 natively and + 2025-era clients through the SDK's stateless fallback). Same four read tools + plus a `locale` argument (en / fr / es / nl). Data: stage 04 writes + `wiki/data/mcp/.json` — the startup records plus the core's lookup + tables (`QUERY_CONTEXT_KEYS` / `QUERY_CONTEXT_FORMAT` in map_template.py, + pinned against `mcp/src/context.js` by `tests/test_mcp_query_context.py`); + the script caches it per isolate and revalidates by ETag every 5 min, and + `get_appellation` fetches the panel JSON `/data/d//.json`. + Counted server-side as the `MCP Tool` Plausible event. Build / deploy: + `.venv/bin/python scripts/deploy_mcp.py deploy` (esbuild bundle → Bunny + compute API → publish with the git SHA → smoke test in all three protocol + modes); the site deploy (`deploy.sh`) must go first when the context format + changes. Tests: `npm test` in mcp/ (core anchors, protocol conformance per + mode, context store), `.venv/bin/python mcp/test/parity_webmcp.py` (the page's + WebMCP tools and the server answer the same calls identically, headless + Chromium). Plan, decisions and spike results: + [docs/plan-mcp-server.md](docs/plan-mcp-server.md). +- **The map inside the chat** (MCP Apps): a fifth tool, `show_on_map` + {slugs}, carries `_meta.ui.resourceUri` → `ui://open-wine-map/map`, a + self-contained MapLibre view ([mcp/view/](mcp/view/), built into + `dist/view.html` by `npm run build:view`) that draws the requested + appellations straight from the site's pmtiles (`TILE_ORIGIN`, default the + site; Bunny already answers them cross-origin, `Range` preflight included) + over the CARTO basemap (`CARTO_KEY` script variable). The tile URLs carry the + site's own `?v=` content fingerprint, handed over in the query context's + `tiles` key and the tool result — the files are browser-cached for 30 days, + so a deploy must change the URL. With ≤ 10 + appellations each has its own colour and a legend list: hover a row or a + polygon to light it, click a row to zoom to it (the site button then opens + its page), the colour square hides it. Its text result and the view's site + button link the set as **`/?aocs=a,b,c`** (any locale home, ≤ 200 slugs): + app.js opens that stack in the panel, highlights all of it and frames the + map on it; the query stays in the URL until the reader opens something else. + Host check: `.venv/bin/python mcp/test/apps_host_check.py [--shots DIR] + [--live MCP_URL]`. Plan and phases: [docs/plan-mcp-apps.md](docs/plan-mcp-apps.md). ## Analytics diff --git a/docs/analytics.md b/docs/analytics.md index 428f2a4..1581298 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -61,7 +61,9 @@ option is inert, and a build with no `localhost` entry loads nothing there. | `Grape Scope Toggled` | `scope` (`main` / `all`), `locale` | the "main grape only" checkbox that appears as a chip next to the grape chips once a grape is picked in the search box (2026-09-23; before that, a permanent checkbox in the Grapes facet). Not persisted: it ends with the last grape chip. | | `Omnisearch Used` | `result_count`, `had_match`, `groups` (a/g/r/s/c — appellations / grapes / regions / styles / classifications; `c` and the classification count in `result_count` since the 2026-09-25 build, so a query that matched only a classification — an aging / Prädikat / selection tier — no longer logs `had_match=false`), `query_len`, `locale` | 1 s after typing stops in the omnisearch | | `Omnisearch Result Picked` | `type` (`grape` / `region` / `style` / `classification`), `locale` | a non-appellation suggestion is picked (appellation picks fire `Appellation Opened`) | -| `WebMCP Tool` | `tool` (`search_appellations` / `filter_appellations` / `get_appellation` / `show_appellation`), `locale` | a browser agent called one of the page's WebMCP tools (`document.modelContext`, end of app.js; registered only where the browser exposes the API). Arguments are never sent — the query text stays private, as for the search box. | +| `WebMCP Tool` | `tool` (`search_appellations` / `filter_appellations` / `get_appellation` / `list_facets` / `show_appellation`), `locale` | a browser agent called one of the page's WebMCP tools (`document.modelContext`, end of app.js; registered only where the browser exposes the API). Arguments are never sent — the query text stays private, as for the search box. | +| `MCP Address Copied` | `locale` | the copy button next to the MCP server address in the About dialog. | +| `MCP Tool` | `tool` (`search_appellations` / `filter_appellations` / `get_appellation` / `list_facets`), `locale` | a client of the remote MCP server (`mcp.openwinemap.com/mcp`, mcp/) called a tool. Sent server-side by the Bunny Edge Script through Plausible's events API (`PLAUSIBLE_HOST` / `PLAUSIBLE_DOMAIN` script variables; unset → nothing sent), with the client's User-Agent and forwarded IP so visitor counting works as for page events. Arguments are never sent. Its page URL is `https://mcp.openwinemap.com/mcp`. | | `Search Used` | `result_count`, `had_match`, `query_len`, `locale` | the legacy sidebar search (superseded by the omnisearch in June 2026) | | `Theme Changed` | `theme`, `locale` | light / dark toggle | | `Feedback Flagged` | `slug`, `aspect` (`boundary` / `grapes` / `facts` / `name` / `sources` / `other`), `country`, `kind`, `geom_source`, `view_mode` (`simple` / `advanced` — since the 2026-09-22 build; `geom_source` is the record's *advanced* provenance, so without the mode a boundary flag on a French appellation cannot be attributed to the commune outline or the parcellaire), `lod` (`footprint` / `parcels` / `zone` — what was on screen for this record: below the detail zoom a French parcel-level record draws its generalised vineyard footprint, from z12 its parcels, every other record one polygon at every zoom), `zoom` (the map zoom at the tap, one decimal), `via` (`card` / `stub-help`), `locale` | one tap on an aspect chip in the **"Report a mistake" section at the bottom of every appellation card** (2026-09). No account, no form. Pressed chips are remembered per browser (localStorage) so a revisit does not re-fire. A `slug × aspect` breakdown is the curator queue; flags ÷ `Appellation Viewed` is a per-record trust score. | diff --git a/docs/plan-mcp-apps.md b/docs/plan-mcp-apps.md new file mode 100644 index 0000000..b75e5bf --- /dev/null +++ b/docs/plan-mcp-apps.md @@ -0,0 +1,237 @@ +# Plan — the map inside the chat (MCP Apps) + +Status (2026-10-04): live on the production server (mcp.openwinemap.com). +Phase 0 was checked in Claude by Boris ("mcp server worked"), his two +follow-ups landed the same day (see "After the first live check"), and +phases 1 and 4 followed (see "Production"). Phase 2's shared paint module is +still open. Builds on the live MCP server (docs/plan-mcp-server.md). + +## Production + +- **Phase 1 needed no CDN rule.** The production pull zone already answers + `/map-data/*.pmtiles` cross-origin: `Access-Control-Allow-Origin: *` on the + ranged GET (206) and a 200 OPTIONS preflight allowing `Range`. pmtiles 3 + sends only `Range` and reads `Content-Length` (safelisted), plus `ETag` and + `Content-Range` only when exposed (Bunny sends no ETag; `Content-Range` is + read on a 416 alone), so nothing more needs exposing. `TILE_ORIGIN` defaults + to the site; no proxy. +- **Versioned tile URLs.** The files are served `max-age=2592000`, so a + widget fetching the bare path would mix cached ranges of an old file with + fresh ones after a deploy. Stage 04 now writes the map's own fingerprinted + URLs (`/map-data/.pmtiles?v=`) into the query context as + `tiles`; `show_on_map` passes them in its result and the view uses them + (bare paths only while the site predates the key). Additive, so the + context format stays 1. +- `CARTO_KEY` set on the production script (the site's default key, which + works from the hosts' sandbox origins); llms.txt names `show_on_map`. + +Decisions (Boris, 2026-10-04): option B (widget); CARTO basemap; `show_on_map` +as its own tool with a strong hint (tool description + server instructions); +personal use first (no ChatGPT directory review yet). + +## Phase 0 results + +- `show_on_map` + view live on the spike (`owm-mcp-spike`, id 94565, + https://owm-mcp-spike.bunny.run/mcp; script variables TILE_ORIGIN = the + spike, TILE_PROXY=1, CARTO_KEY). Edge bundle 1.8 MB, view 1.46 MB. +- `mcp/test/apps_host_check.py`: a local MCP Apps host (the SDK's AppBridge, + view on its own origin under the spec's reference CSP). With a Claude-like + `worker-src 'self' blob:` the view draws the appellations (light and dark); + with the spec's literal CSP it draws only the basemap — the browser refuses + "Creating a worker from 'blob:…'" under `script-src`. So the widget depends + on hosts allowing blob: workers, as Claude documents; the fallback (option A) + stays relevant for a host that does not. `--live URL` renders the view as a + deployed endpoint serves it: same result against the spike. +- The CARTO key worked from a foreign origin (localhost); the real sandbox + origin is the open question for the Claude check. +- Bunny: a standalone script runs after the cache by default and then does + not see the client's `Range` header (the proxy answered 200 with the whole + 41 MB file). The spike's pull zone runs the script before the cache + (`EdgeScriptExecutionPhase: 2`), after which Range is passed through (206). + Production will not proxy: the tiles get CORS from the site's own CDN + (phase 1). The proxy responses are `no-store` so the CDN never caches a 206. +- Harness lesson: a host must connect its bridge before the view loads — the + view sends `ui/initialize` as its script runs, before the iframe's `load`. + +## After the first live check + +Boris, 2026-10-04: the site link opened the bare homepage, not the same +selection; and with two appellations it was not clear which polygon was which. + +- **Same selection on the site**: `/?aocs=a,b,c` (any locale home, ≤ 200 slugs) + opens the set as one panel stack, highlights all of it and frames the map on + it (app.js, the landing IIFE). `show_on_map` links a set that way (a single + appellation keeps its entity page; the cap was 50 until the 51 Alsace grands + crus fell back to the homepage — 200 is the tool's own maximum, ≤ 10 KB of + URL, and the CDN takes 16 KB) + and its text result adds an "All of them on the map" line. Needs a site + deploy before it works from production. +- **Which is which**: up to 10 appellations get their own colour (Tableau 10), + more fall back to the kind colours. The title ("2 appellations ▾") opens a + legend list — open by default for a multi-appellation result. Hovering a row + or a polygon lights it and dims the others; clicking a row (or a polygon) + zooms to it and turns the site button into " ↗"; the colour square + hides / shows it (a hidden one never dims the rest, and drops out of the + site link); "Show all" resets. `apps_host_check.py` exercises the list, + hover, hide, pin and the links. Dark mode also styles MapLibre's controls. + +## Goal + +When an assistant answers from the Open Wine Map MCP server, it can show the +appellations **on a live map inside the conversation** — the real polygons, +framed, highlighted — instead of only a link. Hosts that do not support it keep +today's text results (plus the map link). + +Target hosts: those that implement MCP Apps — Claude (web, desktop), ChatGPT, +VS Code, Goose. Le Chat is not listed and is out of scope (text fallback). + +## What the spec allows (verified 2026-10-04) + +MCP Apps is the official MCP extension for interactive UIs (SEP-1865; stable +spec `2026-01-26`, extension framework locked in protocol `2026-07-28`). The +spec and SDK are in `github.com/modelcontextprotocol/ext-apps`; the SDK is +`@modelcontextprotocol/ext-apps` 2.0.3 (peers: our `@modelcontextprotocol/server` v2). + +- A tool declares `_meta.ui.resourceUri: "ui://…"`; the server serves that + resource via `resources/read` as one HTML5 document, + `mimeType: "text/html;profile=mcp-app"`. The host renders it in a sandboxed + iframe (web hosts: through a sandbox proxy on a separate origin — Claude: + a hash of the server URL under `claudemcpcontent.com`; ChatGPT: + `*.web-sandbox.oaiusercontent.com`). +- The server negotiates: the host advertises the extension + `io.modelcontextprotocol/ui` with `mimeTypes`; the server SHOULD attach UI + only then, and every UI tool MUST still return meaningful text content. +- Data reaches the view by `postMessage` JSON-RPC: `ui/notifications/tool-input` + and `ui/notifications/tool-result` (SDK: `app.ontoolinput` / `app.ontoolresult`); + the view can call server tools (`app.callServerTool`), open links + (`app.openLink`), ask for fullscreen (`app.requestDisplayMode`). Tools can be + app-only (`_meta.ui.visibility: ["app"]`). +- **Allowed domains** — `_meta.ui.csp` on the resource, enforced by the host, + never loosened: + + | field | CSP directive(s) | default when omitted | + |---|---|---| + | `connectDomains` | `connect-src` (fetch / XHR / WebSocket) | none (`'self'` only) | + | `resourceDomains` | `script-src`, `style-src`, `img-src`, `font-src`, `media-src` | none | + | `frameDomains` | `frame-src` (nested iframes) | `'none'` | + | `baseUriDomains` | `base-uri` | `'self'` | + + The reference CSP is `default-src 'none'` and **has no `worker-src`**, so by + the letter of the spec a `blob:` worker falls back to `script-src` and is + blocked. In practice Claude's sandbox sends `worker-src 'self' + https://www.claudeusercontent.com blob:`, and TomTom's maps MCP server runs + MapLibre in MCP Apps on Claude, ChatGPT and VS Code with the worker inlined + as a `blob:` URL (PR tomtom-international/tomtom-maps-mcp#255, MapLibre 6, + +~460 KB per app). Hosts MAY restrict further and SHOULD warn users about + declared external domains. +- ChatGPT: apps declaring `frame_domains` (nested iframes) "are subject to + stricter review and are likely to be rejected for broad distribution unless + iframe content is core to the use case". + +## The two options + +### A. Embedded view — a nested iframe of our own map + +The view is a thin HTML shell that frames +`https://www.openwinemap.com/embed//`. + +- csp: `frameDomains: ["https://www.openwinemap.com"]`; nothing else. +- Site work: an embed mode in app.js (no sidebar / chrome, accept a slug list, + highlight + fit); `/embed/*` must be frameable — today every page sends + `X-Frame-Options: SAMEORIGIN` (deploy.py security headers), so an edge-rule + exception for `/embed/*` with `Content-Security-Policy: frame-ancestors` + listing the host sandbox origins (an open-ended, host-specific list) or `*`. +- \+ Full fidelity for free: the real map, styles, LOD, panel, our own CSP and + workers (the inner frame is our origin, so no worker or CORS question). +- − Depends on each host honouring `frameDomains`; ChatGPT reviews it strictly; + three frames deep (host → sandbox → view → our page); relaxing framing on our + origin; the host's theme / size signals stop at the shell. + +### B. MapLibre widget — the map rendered in the view itself + +The view is one self-contained HTML file: MapLibre GL + pmtiles + the MCP Apps +SDK + a small renderer, with MapLibre's worker inlined as a `blob:` URL (the +TomTom pattern). It draws our existing vector tiles over the basemap. + +- csp: `connectDomains` and `resourceDomains`: `https://www.openwinemap.com` + (the two `.pmtiles`, fetched with HTTP Range) and the basemap host(s). +- Site work: CORS on `/map-data/*.pmtiles` (Bunny edge rule: + `Access-Control-Allow-Origin: *`, `Access-Control-Expose-Headers: + Content-Range, Content-Length, ETag`, OPTIONS answered) — the tiles are + public, read-only, already served to every visitor. No framing change. +- Basemap: CARTO raster is keyed per host (`CARTO_BASEMAP_KEYS`) and a key may + be restricted to our domains — the sandbox origin is not ours. Either a CARTO + key allowed from any origin, or the staged key-free OpenFreeMap vector style + (scripts/_lib/vendor/openfreemap-*.json; needs its tile/glyph/sprite hosts in + the csp). Our style draws no text labels, so the appellation layers need no + glyphs. +- \+ One frame; no reliance on `frameDomains` (no ChatGPT review penalty); the + view gets host theme (light / dark) and size natively; proven in production + by TomTom on the same hosts. +- − A second, small renderer to keep in step with the map's styling (paint + colours by kind, outlines, the overview / detail crossfade); depends on hosts + allowing `blob:` workers (Claude, ChatGPT, VS Code do; the spec does not + promise it); ~1.3 MB inlined HTML (MapLibre ~0.8 MB + worker ~0.46 MB) inside + the edge bundle — fine against the 10 MB limit, to be measured against the + 500 ms startup. + +### Recommendation: B, the widget + +It works on the hosts that matter without asking them to allow a nested frame +of a third-party origin, needs no framing exception on the site, and has a +production precedent on exactly those hosts. A's real advantage — full +fidelity — matters less in a chat-sized view, where the job is "these +appellations, here, highlighted"; the full map stays one click away +(`app.openLink`). Keep A as the fallback if a host turns out to block `blob:` +workers. + +## Design (option B) + +- **Tool**: a new `show_on_map` {`slugs`: up to 200, `locale`?} with + `_meta.ui.resourceUri: "ui://open-wine-map/map"`. The data tools stay + text-only, so a map appears when the model decides to show one, not on every + search. Its text result is the map link(s) — the fallback for hosts without + MCP Apps, and for Le Chat. The result's `structuredContent` carries what the + view needs: per slug name, kind, country, bbox, page URL, plus the union bbox. +- **View**: fit the union bbox; draw the overview and detail layers from the + live pmtiles, filtered to the requested slugs (others faint or hidden); + click a polygon → name + "Open on Open Wine Map" (`app.openLink`); a + fullscreen button (`requestDisplayMode`); host theme → light / dark paint. +- **Styling in step**: the paint expressions (fill colours by kind, outline + ramps) move from `map_template.py` / `app.js` into a shared module consumed + by both the map and the widget build, so a colour change cannot drift. +- **Build**: `mcp/view/` → one HTML file (esbuild + inlined worker, the TomTom + `setWorkerUrl(blob)` pattern) → embedded as a string in the edge bundle. +- **Server**: `registerAppResource` / `registerAppTool` from + `@modelcontextprotocol/ext-apps/server`, gated by `getUiCapability` — hosts + without the extension get `show_on_map` as a plain text tool. +- **Analytics**: `MCP Tool` already counts `show_on_map` calls; a view-loaded + ping is not possible without a `connectDomains` entry for Plausible — skip. + +## Phases + +| # | work | exit criterion | +|---|---|---| +| 0 | spike on the throw-away `owm-mcp-spike` script: `show_on_map` + a minimal view (MapLibre, inlined worker, one pmtiles source proxied with CORS by the spike script itself, so the production site is untouched) | renders Priorat on Claude (Boris's account) and in the ext-apps reference host; worker and tiles load under the host CSP; bundle size and cold start measured | +| 1 | CORS on `/map-data/*` (deploy.py, idempotent edge rule); basemap decision | pmtiles load from a foreign origin with Range; no change for the site | +| 2 | shared paint module; the view proper (both LOD sources, highlight, click, fullscreen, theme) | headless test of the view with a stubbed host (`postMessage`) | +| 3 | server: app resource + `show_on_map`, capability-gated; text fallback | protocol tests: UI host gets `_meta.ui`, plain host gets text | +| 4 | deploy, docs (CLAUDE.md, mcp/README, About / llms.txt mention), live checks on Claude and ChatGPT | the map shows in both; Le Chat still gets the link | + +## Open decisions + +1. Basemap in the widget: CARTO with an unrestricted key, or switch the widget + (only) to the key-free OpenFreeMap vector style? +2. `show_on_map` as its own tool (recommended) or a UI on `get_appellation` / + `filter_appellations`? +3. ChatGPT: personal use via developer mode only, or aim for the app directory + (review, which the widget option keeps open)? + +## Sources + +- MCP Apps spec (stable): https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx +- MCP Apps overview: https://blog.modelcontextprotocol.io/tags/apps/ +- TomTom MapLibre-in-MCP-Apps (inlined blob worker, Claude CSP): https://github.com/tomtom-international/tomtom-maps-mcp/pull/255 +- ChatGPT Apps SDK, custom UX / frame domains: https://developers.openai.com/apps-sdk/build/custom-ux +- MCP App resource metadata (CSP fields, ChatGPT widget fields): https://sunpeak.ai/blogs/mcp-app-resource-metadata +- MCP App CSP explained: https://dev.to/cptrodgers/mcp-app-csp-explained-why-your-widget-wont-render-9n1 diff --git a/docs/plan-mcp-server.md b/docs/plan-mcp-server.md new file mode 100644 index 0000000..5e1f783 --- /dev/null +++ b/docs/plan-mcp-server.md @@ -0,0 +1,265 @@ +# Plan — a remote MCP server for Open Wine Map + +Status (2026-10-04): live. Branch `feat/mcp-server` (from `origin/main` a619b99). + +| phase | state | +|---|---| +| 0 spike | done — see "SDK findings" | +| 1 shared query core | done — `scripts/_lib/assets/query_core.mjs`, inlined into app.js; WebMCP is an adapter; `list_facets` added; schemas hardened; country-qualified schemes (`it:docg`) now match (they never did) | +| 2 query context | done — `wiki/data/mcp/.json`, 3.7 MB each; contract test | +| 3 server + tests | done — `mcp/`; 14 node tests, parity 0 differences (page WebMCP vs server, 3 locales), headless UI check clean, full pytest green | +| 4 deploy | done — site deployed (snapshot `snapshots/openwinemap-deployed-20261004T134415Z`); script `owm-mcp` (id 94571, `BUNNY_MCP_SCRIPT_ID`) at https://mcp.openwinemap.com/mcp — CNAME in the Bunny DNS zone, free certificate, Force SSL; variables DATA_ORIGIN / PLAUSIBLE_HOST / PLAUSIBLE_DOMAIN. Smoke (all three modes) green against the live data. The deploy smoke waits for the release's SHA (`1.0.0+`): right after a publish, isolates of the previous release still answer | +| 5 discovery | About dialog sentence (4 locales) + `llms.txt` "For AI agents" section + docs live; open: `MCP Tool` goal in Plausible (the 48 events of 2026-10-04 are all deploy-window test traffic), Le Chat live test, MCP Registry entry, delete spike `owm-mcp-spike` (id 94565) | + +## Why + +The map registers four WebMCP tools in the page (`search_appellations`, +`filter_appellations`, `get_appellation`, `show_appellation`; end of +`scripts/_lib/assets/app.js`), with the origin-trial token live since the +2026-10-04 deploy. WebMCP only reaches an agent that runs *inside* Chrome on +the page. Chat assistants that only fetch URLs (Le Chat, Claude, ChatGPT) +cannot see those tools — a Le Chat session on 2026-10-04 probed +`/.well-known/*` paths, found nothing and fell back to page scraping. + +Their path today is `llms.txt` + the server-rendered entity pages: enough to +look up one appellation, useless for structured questions ("every DOCG with +Nebbiolo in Piemonte") because filtering exists only in the JS app. A remote +MCP server gives those clients the same search / filter / lookup tools. Le Chat +takes a remote Streamable HTTP server as a Custom MCP Connector (paste the URL; +no auth needed). + +Expect modest use: nothing discovers an MCP server automatically yet (the +`.well-known/mcp/server-cards.json` proposal, SEP-2127, is still open), so a +user adds the URL by hand. + +## Facts the design rests on (verified 2026-10-04) + +- **MCP protocol**: the current revision is **2026-07-28**, and it is + stateless by design — no `initialize` / `notifications/initialized` + handshake, protocol version and client capabilities in every request's + `_meta` (and the `MCP-Protocol-Version` header), a mandatory + `server/discover` RPC, `resultType` on every result, `ttlMs` / `cacheScope` + on `tools/list`, required `Mcp-Method` / `Mcp-Name` request headers, no + sessions, no `ping`. Clients in the field may still speak **2025-11-25** / + 2025-06-18 (with the handshake), so the server must answer both. +- **Bunny Edge Scripting** (standalone script = its own origin, gets a linked + pull zone): Deno/V8 runtime; 30 s CPU / request, 128 MB active memory, + 50 subrequests, 10 MB script, 500 ms startup; $0.20 per million requests + + $0.02 per 1,000 s CPU. Code is uploaded as **one string** (`POST + /compute/script/{id}/code {Code}`, then `POST /compute/script/{id}/publish + {Note}`; `AccessKey` header) → the script must be a single bundled file. + A standalone script runs only on cache miss unless "run script before cache" + is on. +- **Data already published** per locale: the startup blob + `data/aocs...js` (en: 2,955 records; `aocs` 3.4 MB + + `grapes_info` 0.7 MB raw) and one panel JSON per slug + `data/d//.json` (median 5 KB, max 33 KB). + +## Architecture + +``` +MCP client ──POST /mcp──▶ mcp.openwinemap.com (Bunny standalone Edge Script) + │ official MCP TS SDK, stateless, + │ WebStandardStreamableHTTPServerTransport + │ query core (shared with app.js) + ├─ GET www.openwinemap.com/data/mcp/.json (once per isolate, revalidated) + └─ GET www.openwinemap.com/data/d//.json (get_appellation) +``` + +### 1. One query core, two adapters + +The search and filter logic must not exist twice. Extract it from app.js into +`scripts/_lib/assets/query_core.mjs`, a dependency-free ES module of pure +functions over a data context: + +- `searchNormalize`, record search forms, `searchScore` (prefix 100 / + substring 80, per form) — exactly today's semantics. +- grape resolution (name / alias / slug → VIVC sibling set), style resolution + (slug / label → descendants), region matching (key, label, region search + forms), scheme / traditional-term matching on `class_key`. +- `search(ctx, args)`, `filter(ctx, args)`, `brief(ctx, slug)`, + `full(ctx, slug, panel)` — the bodies of today's WebMCP tools. + +app.js gets the module inlined at build time (`_render_app_js` replaces a +`__OWM_query_core__` token with the file, `export ` stripped, asserted +single-occurrence); the UI's omnisearch and tree filter keep calling the same +functions. The edge bundle imports the same file. WebMCP and MCP tools become +thin adapters, so their outputs are identical by construction. + +### 2. Data context emitted by stage 04 + +`wiki/data/mcp/.json`, stable path (not content-hashed — the script +must find it), with a `format_version`: + +- per record: slug, name, name_latin, search_forms, country, country_aliases, + region, kind, class_key, class_label, styles, grapes_principal, grapes_all, + is_sub_denomination, parent_slug, is_wine, cancelled, promoted; +- the lookup tables the core needs: grape search index, VIVC siblings, style + descendants / labels / simple buckets, region labels + region search terms, + country labels, terroir sub-section labels. + +A contract test pins the field list (the STARTUP_AOCS_FIELDS lesson: a missing +field fails silently). The script caches the parsed context per isolate and +revalidates with `If-None-Match` at most every few minutes; a deploy purges the +CDN, so new data reaches the script within that window. + +### 3. Protocol: the official SDK, bundled + +Use the official TypeScript SDK's web-standard Streamable HTTP server +transport in stateless mode, bundled with esbuild into one file. With two live +protocol revisions (2026-07-28 and the handshake-based 2025-11-25) the SDK's +version negotiation is the part not worth hand-writing. **Gate:** phase 0 +proves the bundle fits Bunny (size, 500 ms startup, cold-start latency) and +that Le Chat and the MCP Inspector both connect. Fallback if it does not fit: a +hand-written dual-version JSON-RPC handler (initialize + server/discover, +tools/list, tools/call), tested against the SDK client. + +### 4. Tools + +| tool | inputs | notes | +|---|---|---| +| `search_appellations` | query, country?, limit?, locale? | as WebMCP | +| `filter_appellations` | country?, region?, style?, grape?, main_grape_only?, scheme?, include_sub_denominations?, include_spirits?, limit?, locale? | as WebMCP | +| `get_appellation` | slug, locale? | as WebMCP; fetches the panel JSON | +| `list_facets` *(proposed)* | locale? | countries, regions, styles, schemes with counts, so an agent knows valid filter values; added to WebMCP too | + +`show_appellation` has no server meaning; every result already carries the +map URL. Schema hardening, applied to the WebMCP definitions in the same +change: `limit` with `minimum` / `maximum`, `locale` as an enum +(en / fr / es / nl), `country` with a two-letter pattern, string `maxLength`; +annotations `readOnlyHint`, `idempotentHint`, `openWorldHint: false`; an +`outputSchema` + `structuredContent` beside the text content. Results keep the +attribution fields the panel shows (specification URL, Wikipedia URL + +CC BY-SA 4.0 where a fact is wiki-sourced, machine-translation note). + +### 5. HTTP behaviour + +POST `/mcp` only (GET → 405; no server-to-client stream); `Cache-Control: +no-store` on every response; permissive CORS (public read-only data, browser +clients such as the Inspector); request body and query-length caps; result +caps as in WebMCP (100 / 200). No authentication. + +## Repository layout + +``` +mcp/ + package.json pinned SDK + esbuild (dev); scripts: build, test + src/server.js createHandler({fetchData}) → (Request) => Response + src/edge.js Bunny entry: BunnySDK.net.http.serve(handler) + test/*.test.js node --test (Node 20; no Deno locally) + dist/edge.js build output, gitignored +scripts/_lib/assets/query_core.mjs shared with app.js +scripts/deploy_mcp.py build → set code → publish +``` + +## Deploy + +- One-time setup (needs Boris; outward-facing): create the standalone script + with a linked pull zone (dashboard, or `POST /compute/script` with + `CreateLinkedPullZone`), add hostname `mcp.openwinemap.com` to that pull + zone, DNS CNAME, free SSL; script env var `DATA_ORIGIN=https://www.openwinemap.com`. + New `.env` key `BUNNY_MCP_SCRIPT_ID`; the existing `BUNNY_API_KEY` covers + the API calls. +- `scripts/deploy_mcp.py`: `npm run build` in `mcp/`, upload the bundle, publish + with the git SHA as release note, smoke-test (`server/discover`, a legacy + `initialize`, `tools/list`, one `tools/call`). Rollback = republish an + earlier release (`POST /compute/script/{id}/publish/{uuid}`). +- Order: the data deploy (`deploy.sh`) goes first; the script tolerates an + older `format_version` for one release. + +## Discovery, docs, analytics + +- `llms.txt`: an "MCP server" line with the URL; About dialog: one sentence + (four locales via gettext); a short docs page with connector steps for + Le Chat, Claude and ChatGPT; CLAUDE.md section. +- Optional: an entry in the official MCP Registry. Skip `.well-known` server + cards until SEP-2127 lands. +- Optional: server-side Plausible event `MCP Tool` {tool, locale, + protocol_version}, sent with `waitUntil`; arguments never sent (same rule as + `WebMCP Tool`); documented in docs/analytics.md. + +## Verification + +- `node --test`: query core against the built `wiki/` data (Montsant and + Priorat among the anchors; a Greek romanised query; a grape synonym; a + scheme term); protocol conformance with the SDK client over a local HTTP + wrapper, once per protocol revision. +- Parity: Playwright on `scripts/serve.py` with an init script that captures + `document.modelContext.registerTool`, then the same queries through the + WebMCP tools and the MCP handler must return identical JSON. +- No UI regression from the extraction: omnisearch and tree filter checked + headlessly; the golden comparator shows only the app bundles changing. +- pytest contract test for `data/mcp/.json`. +- Live: MCP Inspector against the deployed URL, then a Le Chat custom + connector session repeating the Priorat question. + +## Phases + +| # | work | exit criterion | +|---|---|---| +| 0 | spike: bundle the SDK server, deploy to a throw-away Bunny script | cold start and size within limits; Le Chat + Inspector connect on both protocol revisions — else switch to the hand-written handler | +| 1 | extract `query_core.mjs`; WebMCP becomes an adapter; schema hardening | omnisearch / WebMCP behaviour unchanged (headless); tests green | +| 2 | stage 04 emits `data/mcp/.json` | contract test; sizes logged | +| 3 | `mcp/` server + tests | conformance + parity green locally | +| 4 | `deploy_mcp.py`, one-time Bunny setup | smoke test passes on `mcp.openwinemap.com` | +| 5 | llms.txt / About / docs / optional analytics + registry | live Le Chat session answers from the tools | + +## Risks + +- **Spec churn**: two revisions in the field; the SDK carries the + negotiation. Pin the SDK version, test both revisions in CI. +- **Bundle vs Bunny limits**: unproven — hence phase 0 first. +- **app.js regression** from the extraction: the live search depends on it; + covered by the headless checks. +- **Abuse**: read-only, cheap per call; caps on inputs and results; Bunny + pull-zone rate limiting available if needed. + +## Decisions (Boris, 2026-10-04) + +1. Hostname `mcp.openwinemap.com`; DNS is on Bunny (delegated from Hover), so + the record can be added through the Bunny API with the script setup. +2. Shared query core. +3. `list_facets`: yes, in both WebMCP and MCP. +4. Server-side `MCP Tool` analytics event: yes, tool + locale only. +5. Phase 0's throw-away script: created through the Bunny API. + +## SDK findings (phase 0) + +`@modelcontextprotocol/server` 2.3.0 (2026-10-02) ships `createMcpHandler( +factory, {legacy: 'stateless', responseMode: 'json'})`, a web-standard +`{fetch(Request)}` handler that serves 2026-07-28 requests natively and +2025-era requests through a per-request stateless fallback (GET / DELETE → +405). `fromJsonSchema()` takes a plain JSON Schema, so the WebMCP schemas are +reused verbatim; the `validators/cf-worker` provider avoids `new Function` +code generation in isolates that forbid it. + +Spike result (2026-10-04, script `owm-mcp-spike`, id 94565, +https://owm-mcp-spike.bunny.run/mcp — throw-away, delete after phase 4): + +- bundle 319 KB minified (zod 852 KB + SDK 568 KB unminified inputs) — far + under the 10 MB limit; startup fine. +- official client, all three modes against the live edge: legacy handshake + → 2025-11-25; pinned → 2026-07-28; auto → 2026-07-28. First connect + 1.2 s (cold isolate + TLS), warm 55–75 ms. The panel subrequest to + www.openwinemap.com works from the edge. +- MCP Inspector CLI (independent client) lists the tools. +- POST responses are not served from the CDN cache (two `tools/call` with + different arguments → different results). Bunny rewrites the response + `Cache-Control` to `no-cache` and adds `cdn-cachedat`; harmless. +- The legacy (2025) path answers as an SSE stream (the SDK's stateless + fallback ignores `responseMode`); valid for 2025 clients. +- Open: a Le Chat custom-connector session against the spike URL (needs + Boris's account). + +## Sources + +- MCP versioning / current revision 2026-07-28: https://modelcontextprotocol.io/docs/learn/versioning +- MCP 2026-07-28 changelog: https://modelcontextprotocol.io/specification/latest/changelog +- SDK web-standard transport: https://ts.sdk.modelcontextprotocol.io/v2/classes/_modelcontextprotocol_server.server_streamableHttp.WebStandardStreamableHTTPServerTransport.html +- Le Chat MCP connectors: https://docs.mistral.ai/le-chat/knowledge-integrations/connectors/mcp-connectors +- Bunny Edge Scripting limits: https://bunny.net/docs/scripting/limits +- Bunny Edge Scripting pricing: https://bunny.net/docs/scripting/pricing +- Bunny standalone scripts: https://bunny.net/docs/scripting/standalone/overview +- Bunny compute API (OpenAPI): https://core-api-public-docs.b-cdn.net/docs/v3/compute.json +- MCP server cards proposal (SEP-2127): https://mcpplaygroundonline.com/blog/mcp-server-cards-well-known-discovery +- WebMCP explainer (rejects static manifests): https://github.com/webmachinelearning/webmcp diff --git a/eslint.config.js b/eslint.config.js index dc6c6ac..6de25fe 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -18,6 +18,18 @@ const owmTokens = Object.fromEntries( [...new Set(appJs.match(/__OWM_\w+?__/g) || [])].map((t) => [t, "readonly"]), ); +// query_core.mjs is inlined at the `__OWM_query_core__` token; its top-level +// declarations are app.js globals. +const queryCoreGlobals = { + createQueryCore: "readonly", + TOOL_DEFS: "readonly", + QueryError: "readonly", + searchNormalize: "readonly", + toTitleCase: "readonly", + setIntersects: "readonly", + schemeKey: "readonly", +}; + const browserGlobals = { window: "readonly", document: "readonly", @@ -43,12 +55,24 @@ const browserGlobals = { }; module.exports = [ + { + files: ["scripts/_lib/assets/query_core.mjs"], + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + globals: { Intl: "readonly" }, + }, + rules: { + "no-undef": "error", + "no-unused-vars": ["warn", { caughtErrors: "none", argsIgnorePattern: "^_" }], + }, + }, { files: ["scripts/_lib/assets/app.js"], languageOptions: { ecmaVersion: 2022, sourceType: "script", - globals: { ...browserGlobals, ...owmTokens }, + globals: { ...browserGlobals, ...owmTokens, ...queryCoreGlobals }, }, rules: { "no-undef": "error", diff --git a/locale/en/LC_MESSAGES/messages.po b/locale/en/LC_MESSAGES/messages.po index 8b40f0b..069f8f6 100644 --- a/locale/en/LC_MESSAGES/messages.po +++ b/locale/en/LC_MESSAGES/messages.po @@ -4,7 +4,7 @@ msgid "" msgstr "" "Project-Id-Version: open-wine-map\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-10-03 09:59+0200\n" +"POT-Creation-Date: 2026-10-04 16:41+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: en\n" @@ -27,20 +27,20 @@ msgstr "Prädikat" msgid "Sélection" msgstr "Selection" -#: scripts/_lib/map_template.py:46 +#: scripts/_lib/map_template.py:52 msgid "Open Wine Map — carte des appellations viticoles d'Europe" msgstr "Open Wine Map — interactive map of Europe's wine appellations" -#: scripts/_lib/map_template.py:49 +#: scripts/_lib/map_template.py:55 #, python-brace-format msgid "{name}, carte du vignoble" msgstr "{name} wine map" -#: scripts/_lib/map_template.py:50 +#: scripts/_lib/map_template.py:56 msgid "carte des appellations viticoles" msgstr "Map of wine appellations" -#: scripts/_lib/map_template.py:52 +#: scripts/_lib/map_template.py:58 msgid "" "Carte interactive des appellations viticoles d'Europe : cépages, styles " "et terroir, d'après les registres officiels." @@ -48,306 +48,306 @@ msgstr "" "Interactive map of Europe's wine appellations: grape varieties, styles " "and terroir, from the official registers." -#: scripts/_lib/map_template.py:55 +#: scripts/_lib/map_template.py:61 msgid "Chargement…" msgstr "Loading…" -#: scripts/_lib/map_template.py:56 +#: scripts/_lib/map_template.py:62 msgid "Recherche" msgstr "Search" -#: scripts/_lib/map_template.py:57 +#: scripts/_lib/map_template.py:63 msgid "nom d'appellation…" msgstr "appellation name…" -#: scripts/_lib/map_template.py:58 +#: scripts/_lib/map_template.py:64 msgid "Recherche d'appellation…" msgstr "Search appellations…" -#: scripts/_lib/map_template.py:60 +#: scripts/_lib/map_template.py:66 msgid "Rechercher une appellation, un cépage, une région…" msgstr "Search an appellation, grape or region…" -#: scripts/_lib/map_template.py:61 +#: scripts/_lib/map_template.py:67 msgid "Cépage principal uniquement" msgstr "Main grape only" -#: scripts/_lib/map_template.py:62 +#: scripts/_lib/map_template.py:68 #, python-brace-format msgid "Aucun résultat pour « {q} »" msgstr "No results for “{q}”" -#: scripts/_lib/map_template.py:63 +#: scripts/_lib/map_template.py:69 msgid "IGP masquée · s'affiche à la sélection" msgstr "Hidden PGI · shown when selected" -#: scripts/_lib/map_template.py:64 +#: scripts/_lib/map_template.py:70 msgid "Eau-de-vie masquée · s'affiche à la sélection" msgstr "Hidden spirit · shown when selected" -#: scripts/_lib/map_template.py:65 +#: scripts/_lib/map_template.py:71 msgid "Options" msgstr "Options" -#: scripts/_lib/map_template.py:66 +#: scripts/_lib/map_template.py:72 msgid "Filtres actifs" msgstr "Active filters" -#: scripts/_lib/map_template.py:67 +#: scripts/_lib/map_template.py:73 msgid "Tout sélectionner" msgstr "Select all" -#: scripts/_lib/map_template.py:69 +#: scripts/_lib/map_template.py:75 #, python-brace-format msgid "{p} appellations · {s} dénominations géographiques complémentaires" msgstr "{p} appellations · {s} complementary geographic designations" -#: scripts/_lib/map_template.py:71 +#: scripts/_lib/map_template.py:77 #, python-brace-format msgid "{p} appellations" msgstr "{p} appellations" -#: scripts/_lib/map_template.py:72 +#: scripts/_lib/map_template.py:78 #, python-brace-format msgid "Ouvrir la fiche de {name}" msgstr "Open the {name} details" -#: scripts/_lib/map_template.py:73 +#: scripts/_lib/map_template.py:79 msgid "Ouvrir la fiche" msgstr "Open details" -#: scripts/_lib/map_template.py:74 +#: scripts/_lib/map_template.py:80 msgid "Inclure les spiritueux" msgstr "Include spirits" -#: scripts/_lib/map_template.py:75 +#: scripts/_lib/map_template.py:81 msgid "Style de vin" msgstr "Wine style" -#: scripts/_lib/map_template.py:76 +#: scripts/_lib/map_template.py:82 msgid "Classement" msgstr "Classification" -#: scripts/_lib/map_template.py:77 +#: scripts/_lib/map_template.py:83 msgid "Cépages principaux" msgstr "Principal grape varieties" -#: scripts/_lib/map_template.py:78 +#: scripts/_lib/map_template.py:84 msgid "Cépages accessoires" msgstr "Accessory grape varieties" -#: scripts/_lib/map_template.py:79 scripts/_lib/map_template.py:299 -#: scripts/_lib/map_template.py:440 +#: scripts/_lib/map_template.py:85 scripts/_lib/map_template.py:312 +#: scripts/_lib/map_template.py:453 msgid "Cépages" msgstr "Grape varieties" -#: scripts/_lib/map_template.py:80 +#: scripts/_lib/map_template.py:86 msgid "Région" msgstr "Region" -#: scripts/_lib/map_template.py:81 +#: scripts/_lib/map_template.py:87 msgid "Appellation" msgstr "Appellation" -#: scripts/_lib/map_template.py:82 +#: scripts/_lib/map_template.py:88 msgid "Type" msgstr "Type" -#: scripts/_lib/map_template.py:83 +#: scripts/_lib/map_template.py:89 msgid "Origine protégée (AOP, AOC, DOC, DO…)" msgstr "Protected origin (PDO, AOC, DOC, DO…)" -#: scripts/_lib/map_template.py:84 +#: scripts/_lib/map_template.py:90 msgid "Indication géographique (IGP, IGT, Landwein…)" msgstr "Geographical indication (PGI, IGT, Landwein…)" -#: scripts/_lib/map_template.py:85 +#: scripts/_lib/map_template.py:91 msgid "AOP" msgstr "PDO" -#: scripts/_lib/map_template.py:86 +#: scripts/_lib/map_template.py:92 msgid "IGP" msgstr "PGI" -#: scripts/_lib/map_template.py:87 +#: scripts/_lib/map_template.py:93 msgid "IG spiritueux" msgstr "spirit-drink GI" -#: scripts/_lib/map_template.py:88 +#: scripts/_lib/map_template.py:94 msgid "Type d'appellation" msgstr "Appellation type" -#: scripts/_lib/map_template.py:89 +#: scripts/_lib/map_template.py:95 msgid "AOP (régime britannique)" msgstr "PDO (UK scheme)" -#: scripts/_lib/map_template.py:90 +#: scripts/_lib/map_template.py:96 msgid "IGP (régime britannique)" msgstr "PGI (UK scheme)" -#: scripts/_lib/map_template.py:91 +#: scripts/_lib/map_template.py:97 msgid "AOC (Suisse)" msgstr "AOC (Switzerland)" -#: scripts/_lib/map_template.py:92 +#: scripts/_lib/map_template.py:98 msgid "Source" msgstr "Source" -#: scripts/_lib/map_template.py:93 +#: scripts/_lib/map_template.py:99 msgid "Vue" msgstr "View" -#: scripts/_lib/map_template.py:94 +#: scripts/_lib/map_template.py:100 msgid "Simple" msgstr "Simple" -#: scripts/_lib/map_template.py:95 +#: scripts/_lib/map_template.py:101 msgid "Avancée" msgstr "Advanced" -#: scripts/_lib/map_template.py:96 +#: scripts/_lib/map_template.py:102 msgid "Thème" msgstr "Theme" -#: scripts/_lib/map_template.py:97 +#: scripts/_lib/map_template.py:103 msgid "Clair" msgstr "Light" -#: scripts/_lib/map_template.py:98 +#: scripts/_lib/map_template.py:104 msgid "Sombre" msgstr "Dark" -#: scripts/_lib/map_template.py:99 +#: scripts/_lib/map_template.py:105 msgid "Système" msgstr "System" -#: scripts/_lib/map_template.py:100 +#: scripts/_lib/map_template.py:106 msgid "Afficher les IGP" msgstr "Show PGIs" -#: scripts/_lib/map_template.py:101 scripts/_lib/style_taxonomy.py:332 +#: scripts/_lib/map_template.py:107 scripts/_lib/style_taxonomy.py:332 #: scripts/_lib/style_taxonomy.py:361 msgid "rouge" msgstr "red" -#: scripts/_lib/map_template.py:102 scripts/_lib/style_taxonomy.py:332 +#: scripts/_lib/map_template.py:108 scripts/_lib/style_taxonomy.py:332 #: scripts/_lib/style_taxonomy.py:362 msgid "blanc" msgstr "white" -#: scripts/_lib/map_template.py:103 scripts/_lib/style_taxonomy.py:332 +#: scripts/_lib/map_template.py:109 scripts/_lib/style_taxonomy.py:332 #: scripts/_lib/style_taxonomy.py:363 msgid "rosé" msgstr "rosé" -#: scripts/_lib/map_template.py:104 scripts/_lib/style_taxonomy.py:332 +#: scripts/_lib/map_template.py:110 scripts/_lib/style_taxonomy.py:332 #: scripts/_lib/style_taxonomy.py:364 msgid "mousseux" msgstr "sparkling" -#: scripts/_lib/map_template.py:105 +#: scripts/_lib/map_template.py:111 msgid "moelleux / liquoreux" msgstr "sweet / dessert" -#: scripts/_lib/map_template.py:106 scripts/_lib/style_taxonomy.py:345 +#: scripts/_lib/map_template.py:112 scripts/_lib/style_taxonomy.py:345 msgid "oxydatif" msgstr "oxidative" -#: scripts/_lib/map_template.py:107 +#: scripts/_lib/map_template.py:113 msgid "autre" msgstr "other" -#: scripts/_lib/map_template.py:108 +#: scripts/_lib/map_template.py:114 msgid "Réinitialiser" msgstr "Reset" -#: scripts/_lib/map_template.py:109 +#: scripts/_lib/map_template.py:115 #, python-brace-format msgid "{n} appellations" msgstr "{n} appellations" -#: scripts/_lib/map_template.py:110 +#: scripts/_lib/map_template.py:116 #, python-brace-format msgid "{n} / {total} appellations" msgstr "{n} / {total} appellations" -#: scripts/_lib/map_template.py:111 +#: scripts/_lib/map_template.py:117 #, python-brace-format msgid "{n} masquées dans les IGP · afficher" msgstr "{n} hidden in PGIs · show" -#: scripts/_lib/map_template.py:112 +#: scripts/_lib/map_template.py:118 msgid "Fermer" msgstr "Close" -#: scripts/_lib/map_template.py:113 +#: scripts/_lib/map_template.py:119 msgid "Détails de l'appellation" msgstr "Appellation details" -#: scripts/_lib/map_template.py:114 +#: scripts/_lib/map_template.py:120 #, python-brace-format msgid "Retirer le filtre {label}" msgstr "Remove filter {label}" -#: scripts/_lib/map_template.py:115 +#: scripts/_lib/map_template.py:121 msgid "Filtres et options de la carte" msgstr "Filters and map options" -#: scripts/_lib/map_template.py:116 +#: scripts/_lib/map_template.py:122 msgid "Langue" msgstr "Language" -#: scripts/_lib/map_template.py:117 +#: scripts/_lib/map_template.py:123 msgid "Carte des appellations viticoles" msgstr "Map of wine appellations" -#: scripts/_lib/map_template.py:118 +#: scripts/_lib/map_template.py:124 msgid "Aller à la carte" msgstr "Skip to the map" -#: scripts/_lib/map_template.py:119 +#: scripts/_lib/map_template.py:125 msgid "Styles" msgstr "Styles" -#: scripts/_lib/map_template.py:120 +#: scripts/_lib/map_template.py:126 msgid "Variétés d'intérêt" msgstr "Varieties of interest" -#: scripts/_lib/map_template.py:121 scripts/_lib/map_template.py:443 +#: scripts/_lib/map_template.py:127 scripts/_lib/map_template.py:456 msgid "Sources" msgstr "Sources" -#: scripts/_lib/map_template.py:122 scripts/_lib/map_template.py:441 +#: scripts/_lib/map_template.py:128 scripts/_lib/map_template.py:454 msgid "Terroir" msgstr "Terroir" -#: scripts/_lib/map_template.py:123 +#: scripts/_lib/map_template.py:129 #, python-brace-format msgid "Dűlők (lieux-dits) : {n}" msgstr "Dűlők (named vineyards): {n}" -#: scripts/_lib/map_template.py:124 +#: scripts/_lib/map_template.py:130 #, python-brace-format msgid "Menzioni geografiche aggiuntive (crus) : {n}" msgstr "Additional geographical mentions (crus): {n}" -#: scripts/_lib/map_template.py:125 +#: scripts/_lib/map_template.py:131 msgid "Facteurs naturels" msgstr "Natural factors" -#: scripts/_lib/map_template.py:126 +#: scripts/_lib/map_template.py:132 msgid "Facteurs humains" msgstr "Human factors" -#: scripts/_lib/map_template.py:127 +#: scripts/_lib/map_template.py:133 msgid "Caractéristiques du produit" msgstr "Product characteristics" -#: scripts/_lib/map_template.py:128 +#: scripts/_lib/map_template.py:134 msgid "Lien terroir / vin" msgstr "Terroir / wine link" -#: scripts/_lib/map_template.py:130 +#: scripts/_lib/map_template.py:136 #, python-brace-format msgid "" "Faits dégagés du Lien au terroir par interprétation automatique — voir la" @@ -356,26 +356,26 @@ msgstr "" "Facts drawn from the cahier's terroir-link section (Lien au terroir) by " "automatic interpretation — see the {source}." -#: scripts/_lib/map_template.py:132 +#: scripts/_lib/map_template.py:138 msgid "source" msgstr "source" -#: scripts/_lib/map_template.py:133 +#: scripts/_lib/map_template.py:139 msgid "via Wikipedia · CC BY-SA 4.0" msgstr "via Wikipedia · CC BY-SA 4.0" -#: scripts/_lib/map_template.py:135 +#: scripts/_lib/map_template.py:141 #, python-brace-format msgid "Citation textuelle du Lien au terroir — voir la {source}." msgstr "" "Verbatim quote from the cahier's terroir-link section (Lien au terroir) —" " see the {source}." -#: scripts/_lib/map_template.py:137 +#: scripts/_lib/map_template.py:143 msgid "à vérifier — texte source court" msgstr "to verify — short source text" -#: scripts/_lib/map_template.py:139 +#: scripts/_lib/map_template.py:145 #, python-brace-format msgid "" "Ces repères décrivent l'appellation englobante {parent} — pas " @@ -384,29 +384,29 @@ msgstr "" "These highlights describe the wider {parent} appellation — not this " "denomination specifically." -#: scripts/_lib/map_template.py:142 +#: scripts/_lib/map_template.py:148 msgid "sans région" msgstr "no region" -#: scripts/_lib/map_template.py:143 +#: scripts/_lib/map_template.py:149 #, python-brace-format msgid "{n} commune(s) INAO" msgstr "{n} INAO commune(s)" -#: scripts/_lib/map_template.py:144 +#: scripts/_lib/map_template.py:150 #, python-brace-format msgid "{n} commune(s)" msgstr "{n} commune(s)" -#: scripts/_lib/map_template.py:145 +#: scripts/_lib/map_template.py:151 msgid "aire approchée" msgstr "approximate area" -#: scripts/_lib/map_template.py:146 +#: scripts/_lib/map_template.py:152 msgid "aire approchée (à l'échelle communale)" msgstr "approximate area (commune level)" -#: scripts/_lib/map_template.py:148 +#: scripts/_lib/map_template.py:154 #, python-brace-format msgid "" "Aire approchée — pas de données parcellaires précises pour cette " @@ -415,7 +415,7 @@ msgstr "" "Approximate area — no parcel-precise data published for this " "denomination; polygon inherited from {umbrella}." -#: scripts/_lib/map_template.py:152 +#: scripts/_lib/map_template.py:158 msgid "" "Aire approchée — pas de données parcellaires précises pour cette " "dénomination ; polygone hérité de l'appellation parente." @@ -423,7 +423,7 @@ msgstr "" "Approximate area — no parcel-precise data published for this " "denomination; polygon inherited from the parent appellation." -#: scripts/_lib/map_template.py:156 +#: scripts/_lib/map_template.py:162 msgid "" "Aire approchée — pas de données parcellaires disponibles ; affichée comme" " l'emprise de la commune où se situe la dénomination." @@ -431,7 +431,7 @@ msgstr "" "Approximate area — no parcel data available; shown as the outline of the " "commune containing the denomination." -#: scripts/_lib/map_template.py:160 +#: scripts/_lib/map_template.py:166 #, python-brace-format msgid "" "Aire issue du lieu-dit cadastral « {lieu_dit} » (commune de {commune}, " @@ -440,11 +440,11 @@ msgstr "" "Boundary derived from the cadastral lieu-dit “{lieu_dit}” (commune of " "{commune}, {source})." -#: scripts/_lib/map_template.py:163 +#: scripts/_lib/map_template.py:169 msgid "cadastre.data.gouv.fr" msgstr "cadastre.data.gouv.fr" -#: scripts/_lib/map_template.py:165 +#: scripts/_lib/map_template.py:171 msgid "" "Aire approchée — reconstituée à partir des références parcellaires du " "plan de l'aire délimitée annexé au cahier des charges ; ce n'est pas une " @@ -454,7 +454,7 @@ msgstr "" "plan of the demarcated area annexed to the product specification; this is" " not an official boundary." -#: scripts/_lib/map_template.py:174 +#: scripts/_lib/map_template.py:180 #, python-brace-format msgid "" "Aire approchée — réunion des {n} commune(s) de l'aire délimitée (INAO) ; " @@ -463,7 +463,7 @@ msgstr "" "Approximate area — the union of the {n} commune(s) of the delimited area " "(INAO); no parcel delimitation exists for this denomination." -#: scripts/_lib/map_template.py:178 +#: scripts/_lib/map_template.py:184 #, python-brace-format msgid "" "Vue d'ensemble : silhouette du vignoble, généralisée à {radius} m à " @@ -474,7 +474,7 @@ msgstr "" "the INAO parcel delimitation — not the delimitation itself; the parcels " "appear as you zoom in." -#: scripts/_lib/map_template.py:183 +#: scripts/_lib/map_template.py:189 #, python-brace-format msgid "" "Sur la carte, aux zooms d'ensemble, cette appellation est dessinée comme " @@ -486,14 +486,14 @@ msgstr "" "of its vineyard generalised at {radius} m from the INAO parcel " "delimitation; the parcels themselves appear as you zoom in." -#: scripts/_lib/map_template.py:189 +#: scripts/_lib/map_template.py:195 #, python-brace-format msgid "Aire : zone de production officielle publiée par le géoportail ({region})." msgstr "" "Area: the official production zone published by the regional geoportal " "({region})." -#: scripts/_lib/map_template.py:193 +#: scripts/_lib/map_template.py:199 msgid "" "Aire : zone de production officielle du MAPA (Zonas de Calidad " "Diferenciada : Vinos)." @@ -501,7 +501,7 @@ msgstr "" "Area: the official MAPA production zone (Zonas de Calidad Diferenciada: " "Vinos)." -#: scripts/_lib/map_template.py:197 +#: scripts/_lib/map_template.py:203 msgid "" "Aire approchée — polygone du jeu de données Bétard 2022 (AOP de l'UE), à " "la résolution des communes." @@ -509,7 +509,7 @@ msgstr "" "Approximate area — the Bétard 2022 dataset polygon (EU PDOs), at " "municipality resolution." -#: scripts/_lib/map_template.py:201 +#: scripts/_lib/map_template.py:207 msgid "" "Aire approchée — réunion des polygones des appellations membres (Bétard " "2022), à la résolution des communes." @@ -517,7 +517,7 @@ msgstr "" "Approximate area — the union of the member appellations' polygons (Bétard" " 2022), at municipality resolution." -#: scripts/_lib/map_template.py:205 +#: scripts/_lib/map_template.py:211 msgid "" "Aire approchée — réunion des unités administratives nommées dans le " "document (limites Eurostat GISCO ou cadastre national)." @@ -525,7 +525,7 @@ msgstr "" "Approximate area — the union of the administrative units named in the " "document (Eurostat GISCO or national cadastre boundaries)." -#: scripts/_lib/map_template.py:213 +#: scripts/_lib/map_template.py:219 #, python-brace-format msgid "" "Nommé(s) dans le document mais absent(s) de la couche communale, donc non" @@ -534,7 +534,7 @@ msgstr "" "Named in the document but not carried by the commune layer, so not drawn:" " {names}." -#: scripts/_lib/map_template.py:217 +#: scripts/_lib/map_template.py:223 #, python-brace-format msgid "" "Localité(s) sans polygone propre, tracée(s) par l'unité administrative " @@ -543,7 +543,7 @@ msgstr "" "Locality(ies) with no polygon of their own, drawn as the administrative " "unit that holds them: {names}." -#: scripts/_lib/map_template.py:221 +#: scripts/_lib/map_template.py:227 #, python-brace-format msgid "" "Aire approchée : le document délimite la zone par une ligne passant par " @@ -554,7 +554,7 @@ msgstr "" "places; drawn here as the polygon those places outline, each at its " "centre point (Wikidata coordinates): {names}." -#: scripts/_lib/map_template.py:227 +#: scripts/_lib/map_template.py:233 #, python-brace-format msgid "" "Aire approchée : le document délimite la zone par une ligne passant par " @@ -564,7 +564,7 @@ msgstr "" "Approximate area: the document delimits the zone by a line through named " "places, drawn here as the administrative units that hold them: {names}." -#: scripts/_lib/map_template.py:235 +#: scripts/_lib/map_template.py:241 #, python-brace-format msgid "" "Commune(s) de l'aire délimitée (INAO) sans délimitation parcellaire " @@ -573,7 +573,7 @@ msgstr "" "Commune(s) of the delimited aire (INAO) with no parcel delimitation " "published in the INAO parcel layer, drawn whole: {names}." -#: scripts/_lib/map_template.py:239 +#: scripts/_lib/map_template.py:245 #, python-brace-format msgid "" "Commune(s) de l'aire délimitée (INAO) sans délimitation parcellaire " @@ -584,7 +584,7 @@ msgstr "" "published for this denomination, drawn from the {donor} parcels of the " "commune: {names}." -#: scripts/_lib/map_template.py:244 +#: scripts/_lib/map_template.py:250 #, python-brace-format msgid "" "Commune(s) de l'aire délimitée (INAO) sans délimitation parcellaire " @@ -593,15 +593,16 @@ msgstr "" "Commune(s) of the delimited aire (INAO) with no parcel delimitation " "published in the INAO parcel layer, not drawn: {names}." -#: scripts/_lib/map_template.py:251 +#: scripts/_lib/map_template.py:257 #, python-brace-format msgid "" "Délimitation parcellaire de la publication INAO du {release} ; la " "publication du {current} ne porte pas cette dénomination." -msgstr "Parcel delimitation from the INAO release of {release}; the release of " +msgstr "" +"Parcel delimitation from the INAO release of {release}; the release of " "{current} carries no rows for this denomination." -#: scripts/_lib/map_template.py:251 +#: scripts/_lib/map_template.py:264 #, python-brace-format msgid "" "Aire complétée par {n} paroisse(s) civile(s) (parroquias) nommée(s) dans " @@ -611,7 +612,7 @@ msgstr "" "Area completed with the {n} civil parish(es) (parroquias) the document " "names, drawn from the Mapa de Parroquias de Galicia ({source}): {names}." -#: scripts/_lib/map_template.py:260 +#: scripts/_lib/map_template.py:273 #, python-brace-format msgid "" "Aire : les {n} polígonos cadastraux SIGPAC que le document énumère dans " @@ -622,7 +623,7 @@ msgstr "" "{municipios}, each drawn whole — any plot planted inside them qualifies " "for the appellation ({source})." -#: scripts/_lib/map_template.py:265 +#: scripts/_lib/map_template.py:278 #, python-brace-format msgid "" "Aire : les parcelles de vigne (SIGPAC, usage VI) situées dans les {n} " @@ -632,17 +633,17 @@ msgstr "" "Area: the vineyard parcels (SIGPAC land use VI) inside the {n} cadastral " "polígonos the document lists in {municipios} ({source})." -#: scripts/_lib/map_template.py:269 +#: scripts/_lib/map_template.py:282 #, python-brace-format msgid "S'y ajoutent, en totalité, les communes {whole}." msgstr "Added whole: the municipios {whole}." -#: scripts/_lib/map_template.py:270 +#: scripts/_lib/map_template.py:283 #, python-brace-format msgid "{n} appellations à ce point" msgstr "{n} appellations at this point" -#: scripts/_lib/map_template.py:272 +#: scripts/_lib/map_template.py:285 msgid "" "Cliquer à nouveau sur la carte pour parcourir les autres, ou sur une " "fiche pour l'afficher en premier" @@ -650,115 +651,115 @@ msgstr "" "Click the map again to cycle through the others, or a card to bring it to" " the front" -#: scripts/_lib/map_template.py:275 +#: scripts/_lib/map_template.py:288 msgid "Afficher cette appellation en premier" msgstr "Bring this appellation to the front" -#: scripts/_lib/map_template.py:276 +#: scripts/_lib/map_template.py:289 msgid "Cahier des charges (BO Agri, PDF)" msgstr "Product specification (BO Agri, PDF)" -#: scripts/_lib/map_template.py:277 +#: scripts/_lib/map_template.py:290 msgid "Cahier des charges (registre GI de l'UE, PDF)" msgstr "Product specification (EU GI register, PDF)" -#: scripts/_lib/map_template.py:278 +#: scripts/_lib/map_template.py:291 msgid "homologué" msgstr "approved" -#: scripts/_lib/map_template.py:279 +#: scripts/_lib/map_template.py:292 msgid "JORF" msgstr "JORF" -#: scripts/_lib/map_template.py:280 +#: scripts/_lib/map_template.py:293 msgid "Texte officiel INAO (show_texte)" msgstr "Official INAO text (show_texte)" -#: scripts/_lib/map_template.py:281 +#: scripts/_lib/map_template.py:294 msgid "Fiche produit INAO" msgstr "INAO product entry" -#: scripts/_lib/map_template.py:282 +#: scripts/_lib/map_template.py:295 msgid "Site officiel de l'interprofession" msgstr "Official trade body site" -#: scripts/_lib/map_template.py:283 +#: scripts/_lib/map_template.py:296 msgid "Cahier des charges (EUR-Lex, document unique)" msgstr "Specification (EUR-Lex, single document)" -#: scripts/_lib/map_template.py:284 +#: scripts/_lib/map_template.py:297 msgid "Pliego de condiciones (national, PDF)" msgstr "National pliego de condiciones (PDF)" -#: scripts/_lib/map_template.py:285 +#: scripts/_lib/map_template.py:298 msgid "variétés ajoutées" msgstr "varieties added" -#: scripts/_lib/map_template.py:286 +#: scripts/_lib/map_template.py:299 msgid "Cahier des charges national (PDF)" msgstr "National product specification (PDF)" -#: scripts/_lib/map_template.py:287 +#: scripts/_lib/map_template.py:300 msgid "Spécification du produit (IGP, PDF)" msgstr "Product specification (PGI, PDF)" -#: scripts/_lib/map_template.py:288 +#: scripts/_lib/map_template.py:301 msgid "Registre régional des cépages (PDF)" msgstr "Regional grape register (PDF)" -#: scripts/_lib/map_template.py:289 +#: scripts/_lib/map_template.py:302 msgid "Mapa de Parroquias de Galicia (IET, Xunta de Galicia)" msgstr "Mapa de Parroquias de Galicia (IET, Xunta de Galicia)" -#: scripts/_lib/map_template.py:290 +#: scripts/_lib/map_template.py:303 msgid "Registre eAmbrosia (UE)" msgstr "eAmbrosia register (EU)" -#: scripts/_lib/map_template.py:291 +#: scripts/_lib/map_template.py:304 msgid "Numéro de dossier" msgstr "File number" -#: scripts/_lib/map_template.py:292 +#: scripts/_lib/map_template.py:305 msgid "Règlement cantonal sur la vigne et le vin" msgstr "Cantonal wine regulation" -#: scripts/_lib/map_template.py:293 +#: scripts/_lib/map_template.py:306 msgid "Répertoire suisse des AOC (OFAG/BLW)" msgstr "Swiss AOC register (FOAG/BLW)" -#: scripts/_lib/map_template.py:294 +#: scripts/_lib/map_template.py:307 msgid "Cahier des charges (GOV.UK, PDF)" msgstr "Product specification (GOV.UK, PDF)" -#: scripts/_lib/map_template.py:295 +#: scripts/_lib/map_template.py:308 msgid "Registre des IG du Royaume-Uni" msgstr "UK GI register" -#: scripts/_lib/map_template.py:296 +#: scripts/_lib/map_template.py:309 msgid "Légende couleurs" msgstr "Colour legend" -#: scripts/_lib/map_template.py:297 +#: scripts/_lib/map_template.py:310 msgid "Bassin viticole" msgstr "Wine basin" -#: scripts/_lib/map_template.py:298 +#: scripts/_lib/map_template.py:311 msgid "Plus l'aire est petite, plus la teinte est dense." msgstr "Smaller area = denser fill." -#: scripts/_lib/map_template.py:300 +#: scripts/_lib/map_template.py:313 msgid "principal — variété de la cuvée" msgstr "principal — main variety" -#: scripts/_lib/map_template.py:301 +#: scripts/_lib/map_template.py:314 msgid "accessoire — assemblage limité" msgstr "accessory — limited blending" -#: scripts/_lib/map_template.py:302 +#: scripts/_lib/map_template.py:315 msgid "intérêt — observation/conservation" msgstr "of interest — observation / conservation" -#: scripts/_lib/map_template.py:304 +#: scripts/_lib/map_template.py:317 msgid "" "Le régulateur portugais (IVV) n'établit pas de distinction " "principal/accessoire — toutes les castas autorisées sont listées ensemble" @@ -768,7 +769,7 @@ msgstr "" "accessory varieties — every authorised casta is listed together in the " "caderno de especificações." -#: scripts/_lib/map_template.py:308 +#: scripts/_lib/map_template.py:321 msgid "" "Bianchello (ou Biancame) est traité ici comme un cépage distinct, " "conformément au disciplinare de la DOP Bianchello del Metauro ; le " @@ -778,20 +779,20 @@ msgstr "" "in line with the disciplinare of the Bianchello del Metauro DOP; the VIVC" " catalogue records it as a synonym of Trebbiano Toscano." -#: scripts/_lib/map_template.py:313 +#: scripts/_lib/map_template.py:326 #, python-brace-format msgid "Open Wine Map n'a pas encore trouvé de {doc} pour cette appellation." msgstr "Open Wine Map has not yet located a {doc} for this appellation." -#: scripts/_lib/map_template.py:315 +#: scripts/_lib/map_template.py:328 msgid "aidez-nous à le trouver" msgstr "help us find it" -#: scripts/_lib/map_template.py:319 +#: scripts/_lib/map_template.py:332 msgid "Annulée" msgstr "Cancelled" -#: scripts/_lib/map_template.py:321 +#: scripts/_lib/map_template.py:334 #, python-brace-format msgid "" "Cette appellation a été annulée le {date} par {regulation}. Des " @@ -802,183 +803,186 @@ msgstr "" "labels and teaching material may still mention it; it is kept here for " "reference." -#: scripts/_lib/map_template.py:325 +#: scripts/_lib/map_template.py:338 #, python-brace-format msgid "Acte national : {act}." msgstr "National act: {act}." -#: scripts/_lib/map_template.py:326 +#: scripts/_lib/map_template.py:339 #, python-brace-format msgid "Elle est remplacée par {successor}." msgstr "It is succeeded by {successor}." -#: scripts/_lib/map_template.py:327 +#: scripts/_lib/map_template.py:340 #, python-brace-format msgid "Enregistrement annulé le {date}" msgstr "Registration cancelled on {date}" -#: scripts/_lib/map_template.py:331 +#: scripts/_lib/map_template.py:344 msgid "Promue" msgstr "Promoted" -#: scripts/_lib/map_template.py:333 +#: scripts/_lib/map_template.py:346 #, python-brace-format msgid "" "Cette dénomination est devenue l'appellation {successor} à part entière " "le {date} ({act}). Des étiquettes plus anciennes peuvent encore porter " "l'ancien nom ; elle est conservée ici à titre de référence." -msgstr "On {date} this denomination became the appellation {successor} in its own right ({act}). Older labels may still carry the former name; it is kept here for reference." +msgstr "" +"On {date} this denomination became the appellation {successor} in its own" +" right ({act}). Older labels may still carry the former name; it is kept " +"here for reference." -#: scripts/_lib/map_template.py:337 +#: scripts/_lib/map_template.py:350 #, python-brace-format msgid "Cahier des charges de la nouvelle appellation : {cahier}." msgstr "Specification of the new appellation: {cahier}." -#: scripts/_lib/map_template.py:338 +#: scripts/_lib/map_template.py:351 msgid "BO Agri (PDF)" msgstr "BO Agri (PDF)" -#: scripts/_lib/map_template.py:339 +#: scripts/_lib/map_template.py:352 #, python-brace-format msgid "Devenue appellation à part entière le {date}" msgstr "Became an appellation in its own right on {date}" -#: scripts/_lib/map_template.py:345 +#: scripts/_lib/map_template.py:358 #, python-brace-format msgid "Délimitée par {regulator} dans son {doc}, qui autorise {grapes}." msgstr "Delimited by {regulator} in its {doc}, which authorises {grapes}." -#: scripts/_lib/map_template.py:347 +#: scripts/_lib/map_template.py:360 #, python-brace-format msgid "Délimitée par {regulator} dans son {doc}{extra}." msgstr "Delimited by {regulator} in its {doc}{extra}." -#: scripts/_lib/map_template.py:348 +#: scripts/_lib/map_template.py:361 #, python-brace-format msgid "{names} et {n} autres cépages" msgstr "{names} and {n} other grape varieties" -#: scripts/_lib/map_template.py:349 +#: scripts/_lib/map_template.py:362 msgid "le registre eAmbrosia de l'UE" msgstr "the EU eAmbrosia register" -#: scripts/_lib/map_template.py:350 +#: scripts/_lib/map_template.py:363 #, python-brace-format msgid "le canton de {canton}" msgstr "the canton of {canton}" -#: scripts/_lib/map_template.py:351 +#: scripts/_lib/map_template.py:364 msgid "(français)" msgstr "(source: French)" -#: scripts/_lib/map_template.py:352 +#: scripts/_lib/map_template.py:365 msgid "Texte source en français" msgstr "Source text in French" -#: scripts/_lib/map_template.py:353 +#: scripts/_lib/map_template.py:366 msgid "(español)" msgstr "(source: Spanish)" -#: scripts/_lib/map_template.py:354 +#: scripts/_lib/map_template.py:367 msgid "Texte source en espagnol" msgstr "Source text in Spanish" -#: scripts/_lib/map_template.py:355 +#: scripts/_lib/map_template.py:368 msgid "(português)" msgstr "(source: Portuguese)" -#: scripts/_lib/map_template.py:356 +#: scripts/_lib/map_template.py:369 msgid "Texte source en portugais" msgstr "Source text in Portuguese" -#: scripts/_lib/map_template.py:357 +#: scripts/_lib/map_template.py:370 msgid "Filtres" msgstr "Filters" -#: scripts/_lib/map_template.py:358 +#: scripts/_lib/map_template.py:371 #, python-brace-format msgid "Traduit de {wiki} · CC BY-SA 4.0" msgstr "Translated from {wiki} · CC BY-SA 4.0" -#: scripts/_lib/map_template.py:359 +#: scripts/_lib/map_template.py:372 msgid "Wikipédia en anglais" msgstr "English Wikipedia" -#: scripts/_lib/map_template.py:360 +#: scripts/_lib/map_template.py:373 msgid "Wikipédia en français" msgstr "French Wikipedia" -#: scripts/_lib/map_template.py:361 +#: scripts/_lib/map_template.py:374 msgid "Wikipédia en espagnol" msgstr "Spanish Wikipedia" -#: scripts/_lib/map_template.py:362 +#: scripts/_lib/map_template.py:375 msgid "Wikipédia en néerlandais" msgstr "Dutch Wikipedia" -#: scripts/_lib/map_template.py:363 +#: scripts/_lib/map_template.py:376 msgid "Wikipédia en portugais" msgstr "Portuguese Wikipedia" -#: scripts/_lib/map_template.py:364 +#: scripts/_lib/map_template.py:377 msgid "Wikipédia en croate" msgstr "Croatian Wikipedia" -#: scripts/_lib/map_template.py:365 +#: scripts/_lib/map_template.py:378 msgid "Wikipédia en italien" msgstr "Italian Wikipedia" -#: scripts/_lib/map_template.py:366 +#: scripts/_lib/map_template.py:379 msgid "Wikipédia en allemand" msgstr "German Wikipedia" -#: scripts/_lib/map_template.py:367 +#: scripts/_lib/map_template.py:380 msgid "Wikipédia en slovène" msgstr "Slovene Wikipedia" -#: scripts/_lib/map_template.py:368 +#: scripts/_lib/map_template.py:381 msgid "Wikipédia en bulgare" msgstr "Bulgarian Wikipedia" -#: scripts/_lib/map_template.py:369 +#: scripts/_lib/map_template.py:382 msgid "Vitis International Variety Catalogue (Julius Kühn-Institut)" msgstr "Vitis International Variety Catalogue (Julius Kühn-Institut)" -#: scripts/_lib/map_template.py:370 +#: scripts/_lib/map_template.py:383 #, python-brace-format msgid "VIVC #{id}" msgstr "VIVC #{id}" -#: scripts/_lib/map_template.py:371 +#: scripts/_lib/map_template.py:384 #, python-brace-format msgid "Traduction automatique depuis {source}" msgstr "Machine translated from {source}" -#: scripts/_lib/map_template.py:372 +#: scripts/_lib/map_template.py:385 msgid "le cahier des charges" msgstr "the cahier des charges" -#: scripts/_lib/map_template.py:373 scripts/_lib/map_template.py:375 +#: scripts/_lib/map_template.py:386 scripts/_lib/map_template.py:388 msgid "le pliego de condiciones" msgstr "the specification" -#: scripts/_lib/map_template.py:374 scripts/_lib/map_template.py:376 +#: scripts/_lib/map_template.py:387 scripts/_lib/map_template.py:389 msgid "le caderno de especificações" msgstr "the caderno de especificações" -#: scripts/_lib/map_template.py:377 +#: scripts/_lib/map_template.py:390 msgid "Dénomination géographique complémentaire de" msgstr "Sub-region of" -#: scripts/_lib/map_template.py:378 +#: scripts/_lib/map_template.py:391 msgid "À propos" msgstr "About" -#: scripts/_lib/map_template.py:379 +#: scripts/_lib/map_template.py:392 msgid "À propos d'Open Wine Map" msgstr "About Open Wine Map" -#: scripts/_lib/map_template.py:381 +#: scripts/_lib/map_template.py:394 msgid "" "Carte de référence des appellations viticoles, générée automatiquement à " "partir des registres publics : le registre de l'Union européenne des AOP " @@ -990,7 +994,7 @@ msgstr "" " Swiss federal repertoire of cantonal AOCs and the UK register of " "geographical indications." -#: scripts/_lib/map_template.py:388 +#: scripts/_lib/map_template.py:401 msgid "" "Une partie du texte est produite par des modèles de langage : les repères" " de terroir sont dégagés du texte du régulateur par Claude, vérifiés " @@ -1010,12 +1014,12 @@ msgstr "" "human translator, with a small machine-translated remainder. Each item " "carries its own attribution line in the panel." -#: scripts/_lib/map_template.py:397 +#: scripts/_lib/map_template.py:410 #, python-brace-format msgid "Réalisé avec ♡ par {devloed}." msgstr "Made with ♡ by {devloed}." -#: scripts/_lib/map_template.py:399 +#: scripts/_lib/map_template.py:412 #, python-brace-format msgid "" "Sources : INAO ({inao}) pour les cahiers des charges et les aires " @@ -1039,7 +1043,7 @@ msgstr "" "Röckel et al.). Every Wikipedia excerpt is flagged where it appears. Full" " source list and licences in the {readme}." -#: scripts/_lib/map_template.py:410 +#: scripts/_lib/map_template.py:423 #, python-brace-format msgid "" "Niveau de détail : en dessous du zoom {zoom}, les appellations françaises" @@ -1056,12 +1060,12 @@ msgstr "" "parcels themselves are drawn. Every other appellation keeps the same " "outline at every zoom." -#: scripts/_lib/map_template.py:418 +#: scripts/_lib/map_template.py:431 #, python-brace-format msgid "Suggestions et pull requests bienvenues sur {github}." msgstr "Issues and pull requests welcome on {github}." -#: scripts/_lib/map_template.py:420 +#: scripts/_lib/map_template.py:433 #, python-brace-format msgid "" "Vie privée : la fréquentation est mesurée avec une instance {plausible} " @@ -1077,20 +1081,20 @@ msgstr "" "identifiers, no IP address stored, no cross-site or cross-device tracking" " — which is why there is no consent banner to click through." -#: scripts/_lib/map_template.py:427 +#: scripts/_lib/map_template.py:440 #, python-brace-format msgid "Suivez les mises à jour sur {bluesky}." msgstr "Follow updates on {bluesky}." -#: scripts/_lib/map_template.py:428 +#: scripts/_lib/map_template.py:441 msgid "e-mail" msgstr "email" -#: scripts/_lib/map_template.py:429 +#: scripts/_lib/map_template.py:442 msgid "E-mail copié dans le presse-papiers" msgstr "Email copied to clipboard" -#: scripts/_lib/map_template.py:431 +#: scripts/_lib/map_template.py:444 #, python-brace-format msgid "" "Carte générée automatiquement — des erreurs sont possibles. Signalez-les " @@ -1099,55 +1103,55 @@ msgstr "" "Auto-generated map — errors are possible. Flag them from the appellation " "card, or by {email}." -#: scripts/_lib/map_template.py:437 +#: scripts/_lib/map_template.py:450 msgid "Signaler une erreur" msgstr "Report a mistake" -#: scripts/_lib/map_template.py:438 +#: scripts/_lib/map_template.py:451 msgid "Un clic nous dit quoi vérifier — sans compte." msgstr "One tap tells us what to check — no account needed." -#: scripts/_lib/map_template.py:439 +#: scripts/_lib/map_template.py:452 msgid "Aire / contour" msgstr "Boundary" -#: scripts/_lib/map_template.py:442 +#: scripts/_lib/map_template.py:455 msgid "Nom / type" msgstr "Name / type" -#: scripts/_lib/map_template.py:444 +#: scripts/_lib/map_template.py:457 msgid "Autre" msgstr "Other" -#: scripts/_lib/map_template.py:445 +#: scripts/_lib/map_template.py:458 #, python-brace-format msgid "Merci, c'est noté : {aspect}." msgstr "Thanks, noted: {aspect}." -#: scripts/_lib/map_template.py:447 +#: scripts/_lib/map_template.py:460 msgid "Précisez si vous voulez (facultatif, sans données personnelles)" msgstr "Add details if you like (optional — no personal details)" -#: scripts/_lib/map_template.py:449 +#: scripts/_lib/map_template.py:462 msgid "Envoyer" msgstr "Send" -#: scripts/_lib/map_template.py:452 +#: scripts/_lib/map_template.py:465 msgid "Géométrie" msgstr "Geometry" -#: scripts/_lib/map_template.py:453 +#: scripts/_lib/map_template.py:466 msgid "Envoyé, merci !" msgstr "Sent — thank you!" -#: scripts/_lib/map_template.py:455 +#: scripts/_lib/map_template.py:468 #, python-brace-format msgid "" "{aspect} — votre navigateur bloque notre outil de mesure, envoyez plutôt " "un {email}." msgstr "{aspect} — your browser blocks our analytics; send an {email} instead." -#: scripts/_lib/map_template.py:459 +#: scripts/_lib/map_template.py:472 #, python-brace-format msgid "" "{c} pays européens cartographiés ({n} appellations : {parents} " @@ -1160,15 +1164,15 @@ msgstr "" "quality and extend coverage beyond the EU, Switzerland and the United " "Kingdom." -#: scripts/_lib/map_template.py:464 +#: scripts/_lib/map_template.py:477 msgid "Toutes les appellations" msgstr "All appellations" -#: scripts/_lib/map_template.py:465 +#: scripts/_lib/map_template.py:478 msgid "Toutes les appellations viticoles — Open Wine Map" msgstr "All wine appellations — Open Wine Map" -#: scripts/_lib/map_template.py:467 +#: scripts/_lib/map_template.py:480 #, python-brace-format msgid "" "Liste des {n} appellations viticoles cartographiées sur Open Wine Map, " @@ -1177,212 +1181,243 @@ msgstr "" "List of the {n} wine appellations mapped on Open Wine Map, by country: EU" " PDOs and PGIs, Swiss AOCs and UK GIs." -#: scripts/_lib/map_template.py:471 +#: scripts/_lib/map_template.py:484 #, python-brace-format msgid "" "Les {n} appellations ci-dessous sont classées par pays. Retour à la " "{map_link}." msgstr "The {n} appellations below are grouped by country. Back to the {map_link}." -#: scripts/_lib/map_template.py:474 +#: scripts/_lib/map_template.py:487 msgid "carte interactive" msgstr "interactive map" -#: scripts/_lib/map_template.py:475 +#: scripts/_lib/map_template.py:488 msgid "Liste des appellations par pays" msgstr "List of appellations by country" -#: scripts/_lib/map_template.py:476 +#: scripts/_lib/map_template.py:489 msgid "Appellation parente" msgstr "Parent appellation" -#: scripts/_lib/map_template.py:477 +#: scripts/_lib/map_template.py:490 msgid "Dénominations rattachées" msgstr "Related denominations" -#: scripts/_lib/map_template.py:479 +#: scripts/_lib/map_template.py:492 #, python-brace-format msgid "Parcourir la liste complète des appellations : {browse_link}." msgstr "Browse the full list of appellations: {browse_link}." -#: scripts/_lib/map_template.py:481 +#: scripts/_lib/map_template.py:494 #, python-brace-format msgid "Données mises à jour le {date}." msgstr "Data updated on {date}." -#: scripts/_lib/map_template.py:499 +#: scripts/_lib/map_template.py:496 +#, python-brace-format +msgid "" +"Votre navigateur parle déjà {webmcp} ? Chaque page de la carte lui " +"propose les mêmes outils. Sinon, aucune importance : c'est le serveur MCP" +" ci-dessus que votre assistant IA sait utiliser." +msgstr "Does your browser already speak {webmcp}? Every page of the map offers it the same tools. If that means nothing to you, never mind: the MCP server above is the one your AI assistant knows how to use." + +#: scripts/_lib/map_template.py:500 +msgid "Copier" +msgstr "Copy" + +#: scripts/_lib/map_template.py:501 +msgid "Copié" +msgstr "Copied" + +#: scripts/_lib/map_template.py:502 +msgid "Copier l'adresse du serveur MCP" +msgstr "Copy the MCP server address" + +#: scripts/_lib/map_template.py:504 +#, python-brace-format +msgid "" +"Les assistants IA peuvent interroger les appellations avec les mêmes " +"outils de recherche que la carte, via le serveur MCP {mcp_link} (sans " +"authentification ; à ajouter comme connecteur personnalisé)." +msgstr "" +"AI assistants can query the appellations with the same search tools as " +"the map, through the MCP server {mcp_link} (no authentication; add it as " +"a custom connector)." + +#: scripts/_lib/map_template.py:525 msgid "BOURGOGNE" msgstr "Burgundy" -#: scripts/_lib/map_template.py:500 +#: scripts/_lib/map_template.py:526 msgid "BEAUJOLAIS" msgstr "Beaujolais" -#: scripts/_lib/map_template.py:501 +#: scripts/_lib/map_template.py:527 msgid "JURA" msgstr "Jura" -#: scripts/_lib/map_template.py:502 +#: scripts/_lib/map_template.py:528 msgid "SAVOIE" msgstr "Savoie" -#: scripts/_lib/map_template.py:503 +#: scripts/_lib/map_template.py:529 msgid "BUGEY" msgstr "Bugey" -#: scripts/_lib/map_template.py:504 +#: scripts/_lib/map_template.py:530 msgid "ALSACE" msgstr "Alsace" -#: scripts/_lib/map_template.py:505 +#: scripts/_lib/map_template.py:531 msgid "LORRAINE" msgstr "Lorraine" -#: scripts/_lib/map_template.py:506 +#: scripts/_lib/map_template.py:532 msgid "VAL DE LOIRE" msgstr "Loire Valley" -#: scripts/_lib/map_template.py:507 +#: scripts/_lib/map_template.py:533 msgid "BORDEAUX" msgstr "Bordeaux" -#: scripts/_lib/map_template.py:508 +#: scripts/_lib/map_template.py:534 msgid "SUD-OUEST" msgstr "South-West" -#: scripts/_lib/map_template.py:509 +#: scripts/_lib/map_template.py:535 msgid "VALLEE DU RHÔNE" msgstr "Rhône Valley" -#: scripts/_lib/map_template.py:510 +#: scripts/_lib/map_template.py:536 msgid "LANGUEDOC" msgstr "Languedoc" -#: scripts/_lib/map_template.py:511 +#: scripts/_lib/map_template.py:537 msgid "ROUSSILLON" msgstr "Roussillon" -#: scripts/_lib/map_template.py:512 +#: scripts/_lib/map_template.py:538 msgid "PROVENCE" msgstr "Provence" -#: scripts/_lib/map_template.py:513 +#: scripts/_lib/map_template.py:539 msgid "CORSE" msgstr "Corsica" -#: scripts/_lib/map_template.py:514 +#: scripts/_lib/map_template.py:540 msgid "CHAMPAGNE" msgstr "Champagne" -#: scripts/_lib/map_template.py:515 +#: scripts/_lib/map_template.py:541 msgid "ILE-DE-FRANCE" msgstr "Île-de-France" -#: scripts/_lib/map_template.py:516 +#: scripts/_lib/map_template.py:542 msgid "NORMANDIE" msgstr "Normandy" -#: scripts/_lib/map_template.py:517 +#: scripts/_lib/map_template.py:543 msgid "BRETAGNE" msgstr "Brittany" -#: scripts/_lib/map_template.py:518 +#: scripts/_lib/map_template.py:544 msgid "MAINE" msgstr "Maine" -#: scripts/_lib/map_template.py:519 +#: scripts/_lib/map_template.py:545 msgid "COGNAC" msgstr "Cognac" -#: scripts/_lib/map_template.py:520 +#: scripts/_lib/map_template.py:546 msgid "ARMAGNAC" msgstr "Armagnac" -#: scripts/_lib/map_template.py:521 +#: scripts/_lib/map_template.py:547 msgid "RHUM" msgstr "Rum" -#: scripts/_lib/map_template.py:561 +#: scripts/_lib/map_template.py:587 msgid "France" msgstr "France" -#: scripts/_lib/map_template.py:562 +#: scripts/_lib/map_template.py:588 msgid "Espagne" msgstr "Spain" -#: scripts/_lib/map_template.py:563 +#: scripts/_lib/map_template.py:589 msgid "Portugal" msgstr "Portugal" -#: scripts/_lib/map_template.py:564 +#: scripts/_lib/map_template.py:590 msgid "Italie" msgstr "Italy" -#: scripts/_lib/map_template.py:565 +#: scripts/_lib/map_template.py:591 msgid "Autriche" msgstr "Austria" -#: scripts/_lib/map_template.py:566 +#: scripts/_lib/map_template.py:592 msgid "Slovénie" msgstr "Slovenia" -#: scripts/_lib/map_template.py:567 +#: scripts/_lib/map_template.py:593 msgid "Croatie" msgstr "Croatia" -#: scripts/_lib/map_template.py:568 +#: scripts/_lib/map_template.py:594 msgid "Hongrie" msgstr "Hungary" -#: scripts/_lib/map_template.py:569 +#: scripts/_lib/map_template.py:595 msgid "Roumanie" msgstr "Romania" -#: scripts/_lib/map_template.py:570 +#: scripts/_lib/map_template.py:596 msgid "Bulgarie" msgstr "Bulgaria" -#: scripts/_lib/map_template.py:571 +#: scripts/_lib/map_template.py:597 msgid "Grèce" msgstr "Greece" -#: scripts/_lib/map_template.py:572 +#: scripts/_lib/map_template.py:598 msgid "Allemagne" msgstr "Germany" -#: scripts/_lib/map_template.py:573 +#: scripts/_lib/map_template.py:599 msgid "Slovaquie" msgstr "Slovakia" -#: scripts/_lib/map_template.py:574 +#: scripts/_lib/map_template.py:600 msgid "Suisse" msgstr "Switzerland" -#: scripts/_lib/map_template.py:575 +#: scripts/_lib/map_template.py:601 msgid "Tchéquie" msgstr "Czechia" -#: scripts/_lib/map_template.py:576 +#: scripts/_lib/map_template.py:602 msgid "Luxembourg" msgstr "Luxembourg" -#: scripts/_lib/map_template.py:577 +#: scripts/_lib/map_template.py:603 msgid "Belgique" msgstr "Belgium" -#: scripts/_lib/map_template.py:578 +#: scripts/_lib/map_template.py:604 msgid "Pays-Bas" msgstr "Netherlands" -#: scripts/_lib/map_template.py:579 +#: scripts/_lib/map_template.py:605 msgid "Malte" msgstr "Malta" -#: scripts/_lib/map_template.py:580 +#: scripts/_lib/map_template.py:606 msgid "Chypre" msgstr "Cyprus" -#: scripts/_lib/map_template.py:581 +#: scripts/_lib/map_template.py:607 msgid "Royaume-Uni" msgstr "United Kingdom" diff --git a/locale/es/LC_MESSAGES/messages.po b/locale/es/LC_MESSAGES/messages.po index f3e0647..3cf733f 100644 --- a/locale/es/LC_MESSAGES/messages.po +++ b/locale/es/LC_MESSAGES/messages.po @@ -4,7 +4,7 @@ msgid "" msgstr "" "Project-Id-Version: open-wine-map\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-10-03 09:59+0200\n" +"POT-Creation-Date: 2026-10-04 16:41+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: es\n" @@ -27,20 +27,20 @@ msgstr "Prädikat" msgid "Sélection" msgstr "Selección" -#: scripts/_lib/map_template.py:46 +#: scripts/_lib/map_template.py:52 msgid "Open Wine Map — carte des appellations viticoles d'Europe" msgstr "Open Wine Map — mapa de las denominaciones de vino de Europa" -#: scripts/_lib/map_template.py:49 +#: scripts/_lib/map_template.py:55 #, python-brace-format msgid "{name}, carte du vignoble" msgstr "{name}, mapa de viñedos" -#: scripts/_lib/map_template.py:50 +#: scripts/_lib/map_template.py:56 msgid "carte des appellations viticoles" msgstr "Mapa de denominaciones vitivinícolas" -#: scripts/_lib/map_template.py:52 +#: scripts/_lib/map_template.py:58 msgid "" "Carte interactive des appellations viticoles d'Europe : cépages, styles " "et terroir, d'après les registres officiels." @@ -48,306 +48,306 @@ msgstr "" "Mapa interactivo de las denominaciones vitivinícolas de Europa: " "variedades, estilos y terruño, según los registros oficiales." -#: scripts/_lib/map_template.py:55 +#: scripts/_lib/map_template.py:61 msgid "Chargement…" msgstr "Cargando…" -#: scripts/_lib/map_template.py:56 +#: scripts/_lib/map_template.py:62 msgid "Recherche" msgstr "Búsqueda" -#: scripts/_lib/map_template.py:57 +#: scripts/_lib/map_template.py:63 msgid "nom d'appellation…" msgstr "nombre de denominación…" -#: scripts/_lib/map_template.py:58 +#: scripts/_lib/map_template.py:64 msgid "Recherche d'appellation…" msgstr "Buscar denominación…" -#: scripts/_lib/map_template.py:60 +#: scripts/_lib/map_template.py:66 msgid "Rechercher une appellation, un cépage, une région…" msgstr "Buscar una denominación, una uva o una región…" -#: scripts/_lib/map_template.py:61 +#: scripts/_lib/map_template.py:67 msgid "Cépage principal uniquement" msgstr "Solo uva principal" -#: scripts/_lib/map_template.py:62 +#: scripts/_lib/map_template.py:68 #, python-brace-format msgid "Aucun résultat pour « {q} »" msgstr "Sin resultados para «{q}»" -#: scripts/_lib/map_template.py:63 +#: scripts/_lib/map_template.py:69 msgid "IGP masquée · s'affiche à la sélection" msgstr "IGP oculta · se muestra al seleccionar" -#: scripts/_lib/map_template.py:64 +#: scripts/_lib/map_template.py:70 msgid "Eau-de-vie masquée · s'affiche à la sélection" msgstr "Aguardiente oculto · se muestra al seleccionar" -#: scripts/_lib/map_template.py:65 +#: scripts/_lib/map_template.py:71 msgid "Options" msgstr "Opciones" -#: scripts/_lib/map_template.py:66 +#: scripts/_lib/map_template.py:72 msgid "Filtres actifs" msgstr "Filtros activos" -#: scripts/_lib/map_template.py:67 +#: scripts/_lib/map_template.py:73 msgid "Tout sélectionner" msgstr "Seleccionar todo" -#: scripts/_lib/map_template.py:69 +#: scripts/_lib/map_template.py:75 #, python-brace-format msgid "{p} appellations · {s} dénominations géographiques complémentaires" msgstr "{p} denominaciones · {s} denominaciones geográficas complementarias" -#: scripts/_lib/map_template.py:71 +#: scripts/_lib/map_template.py:77 #, python-brace-format msgid "{p} appellations" msgstr "{p} denominaciones" -#: scripts/_lib/map_template.py:72 +#: scripts/_lib/map_template.py:78 #, python-brace-format msgid "Ouvrir la fiche de {name}" msgstr "Abrir la ficha de {name}" -#: scripts/_lib/map_template.py:73 +#: scripts/_lib/map_template.py:79 msgid "Ouvrir la fiche" msgstr "Abrir la ficha" -#: scripts/_lib/map_template.py:74 +#: scripts/_lib/map_template.py:80 msgid "Inclure les spiritueux" msgstr "Incluir destilados" -#: scripts/_lib/map_template.py:75 +#: scripts/_lib/map_template.py:81 msgid "Style de vin" msgstr "Estilo de vino" -#: scripts/_lib/map_template.py:76 +#: scripts/_lib/map_template.py:82 msgid "Classement" msgstr "Clasificación" -#: scripts/_lib/map_template.py:77 +#: scripts/_lib/map_template.py:83 msgid "Cépages principaux" msgstr "Variedades principales" -#: scripts/_lib/map_template.py:78 +#: scripts/_lib/map_template.py:84 msgid "Cépages accessoires" msgstr "Variedades complementarias" -#: scripts/_lib/map_template.py:79 scripts/_lib/map_template.py:299 -#: scripts/_lib/map_template.py:440 +#: scripts/_lib/map_template.py:85 scripts/_lib/map_template.py:312 +#: scripts/_lib/map_template.py:453 msgid "Cépages" msgstr "Variedades de uva" -#: scripts/_lib/map_template.py:80 +#: scripts/_lib/map_template.py:86 msgid "Région" msgstr "Región" -#: scripts/_lib/map_template.py:81 +#: scripts/_lib/map_template.py:87 msgid "Appellation" msgstr "Denominación" -#: scripts/_lib/map_template.py:82 +#: scripts/_lib/map_template.py:88 msgid "Type" msgstr "Tipo" -#: scripts/_lib/map_template.py:83 +#: scripts/_lib/map_template.py:89 msgid "Origine protégée (AOP, AOC, DOC, DO…)" msgstr "Origen protegido (DOP, AOC, DOC, DO…)" -#: scripts/_lib/map_template.py:84 +#: scripts/_lib/map_template.py:90 msgid "Indication géographique (IGP, IGT, Landwein…)" msgstr "Indicación geográfica (IGP, IGT, Landwein…)" -#: scripts/_lib/map_template.py:85 +#: scripts/_lib/map_template.py:91 msgid "AOP" msgstr "DOP" -#: scripts/_lib/map_template.py:86 +#: scripts/_lib/map_template.py:92 msgid "IGP" msgstr "IGP" -#: scripts/_lib/map_template.py:87 +#: scripts/_lib/map_template.py:93 msgid "IG spiritueux" msgstr "IG de bebida espirituosa" -#: scripts/_lib/map_template.py:88 +#: scripts/_lib/map_template.py:94 msgid "Type d'appellation" msgstr "Tipo de denominación" -#: scripts/_lib/map_template.py:89 +#: scripts/_lib/map_template.py:95 msgid "AOP (régime britannique)" msgstr "DOP (régimen británico)" -#: scripts/_lib/map_template.py:90 +#: scripts/_lib/map_template.py:96 msgid "IGP (régime britannique)" msgstr "IGP (régimen británico)" -#: scripts/_lib/map_template.py:91 +#: scripts/_lib/map_template.py:97 msgid "AOC (Suisse)" msgstr "AOC (Suiza)" -#: scripts/_lib/map_template.py:92 +#: scripts/_lib/map_template.py:98 msgid "Source" msgstr "Fuente" -#: scripts/_lib/map_template.py:93 +#: scripts/_lib/map_template.py:99 msgid "Vue" msgstr "Vista" -#: scripts/_lib/map_template.py:94 +#: scripts/_lib/map_template.py:100 msgid "Simple" msgstr "Simple" -#: scripts/_lib/map_template.py:95 +#: scripts/_lib/map_template.py:101 msgid "Avancée" msgstr "Avanzada" -#: scripts/_lib/map_template.py:96 +#: scripts/_lib/map_template.py:102 msgid "Thème" msgstr "Tema" -#: scripts/_lib/map_template.py:97 +#: scripts/_lib/map_template.py:103 msgid "Clair" msgstr "Claro" -#: scripts/_lib/map_template.py:98 +#: scripts/_lib/map_template.py:104 msgid "Sombre" msgstr "Oscuro" -#: scripts/_lib/map_template.py:99 +#: scripts/_lib/map_template.py:105 msgid "Système" msgstr "Sistema" -#: scripts/_lib/map_template.py:100 +#: scripts/_lib/map_template.py:106 msgid "Afficher les IGP" msgstr "Mostrar las IGP" -#: scripts/_lib/map_template.py:101 scripts/_lib/style_taxonomy.py:332 +#: scripts/_lib/map_template.py:107 scripts/_lib/style_taxonomy.py:332 #: scripts/_lib/style_taxonomy.py:361 msgid "rouge" msgstr "tinto" -#: scripts/_lib/map_template.py:102 scripts/_lib/style_taxonomy.py:332 +#: scripts/_lib/map_template.py:108 scripts/_lib/style_taxonomy.py:332 #: scripts/_lib/style_taxonomy.py:362 msgid "blanc" msgstr "blanco" -#: scripts/_lib/map_template.py:103 scripts/_lib/style_taxonomy.py:332 +#: scripts/_lib/map_template.py:109 scripts/_lib/style_taxonomy.py:332 #: scripts/_lib/style_taxonomy.py:363 msgid "rosé" msgstr "rosado" -#: scripts/_lib/map_template.py:104 scripts/_lib/style_taxonomy.py:332 +#: scripts/_lib/map_template.py:110 scripts/_lib/style_taxonomy.py:332 #: scripts/_lib/style_taxonomy.py:364 msgid "mousseux" msgstr "espumoso" -#: scripts/_lib/map_template.py:105 +#: scripts/_lib/map_template.py:111 msgid "moelleux / liquoreux" msgstr "dulce / licoroso" -#: scripts/_lib/map_template.py:106 scripts/_lib/style_taxonomy.py:345 +#: scripts/_lib/map_template.py:112 scripts/_lib/style_taxonomy.py:345 msgid "oxydatif" msgstr "oxidativo" -#: scripts/_lib/map_template.py:107 +#: scripts/_lib/map_template.py:113 msgid "autre" msgstr "otro" -#: scripts/_lib/map_template.py:108 +#: scripts/_lib/map_template.py:114 msgid "Réinitialiser" msgstr "Reiniciar" -#: scripts/_lib/map_template.py:109 +#: scripts/_lib/map_template.py:115 #, python-brace-format msgid "{n} appellations" msgstr "{n} denominaciones" -#: scripts/_lib/map_template.py:110 +#: scripts/_lib/map_template.py:116 #, python-brace-format msgid "{n} / {total} appellations" msgstr "{n} / {total} denominaciones" -#: scripts/_lib/map_template.py:111 +#: scripts/_lib/map_template.py:117 #, python-brace-format msgid "{n} masquées dans les IGP · afficher" msgstr "{n} ocultas en las IGP · mostrar" -#: scripts/_lib/map_template.py:112 +#: scripts/_lib/map_template.py:118 msgid "Fermer" msgstr "Cerrar" -#: scripts/_lib/map_template.py:113 +#: scripts/_lib/map_template.py:119 msgid "Détails de l'appellation" msgstr "Detalles de la denominación" -#: scripts/_lib/map_template.py:114 +#: scripts/_lib/map_template.py:120 #, python-brace-format msgid "Retirer le filtre {label}" msgstr "Quitar filtro {label}" -#: scripts/_lib/map_template.py:115 +#: scripts/_lib/map_template.py:121 msgid "Filtres et options de la carte" msgstr "Filtros y opciones del mapa" -#: scripts/_lib/map_template.py:116 +#: scripts/_lib/map_template.py:122 msgid "Langue" msgstr "Idioma" -#: scripts/_lib/map_template.py:117 +#: scripts/_lib/map_template.py:123 msgid "Carte des appellations viticoles" msgstr "Mapa de denominaciones vitivinícolas" -#: scripts/_lib/map_template.py:118 +#: scripts/_lib/map_template.py:124 msgid "Aller à la carte" msgstr "Ir al mapa" -#: scripts/_lib/map_template.py:119 +#: scripts/_lib/map_template.py:125 msgid "Styles" msgstr "Estilos" -#: scripts/_lib/map_template.py:120 +#: scripts/_lib/map_template.py:126 msgid "Variétés d'intérêt" msgstr "Variedades de interés" -#: scripts/_lib/map_template.py:121 scripts/_lib/map_template.py:443 +#: scripts/_lib/map_template.py:127 scripts/_lib/map_template.py:456 msgid "Sources" msgstr "Fuentes" -#: scripts/_lib/map_template.py:122 scripts/_lib/map_template.py:441 +#: scripts/_lib/map_template.py:128 scripts/_lib/map_template.py:454 msgid "Terroir" msgstr "Terroir" -#: scripts/_lib/map_template.py:123 +#: scripts/_lib/map_template.py:129 #, python-brace-format msgid "Dűlők (lieux-dits) : {n}" msgstr "Dűlők (pagos): {n}" -#: scripts/_lib/map_template.py:124 +#: scripts/_lib/map_template.py:130 #, python-brace-format msgid "Menzioni geografiche aggiuntive (crus) : {n}" msgstr "Menciones geográficas adicionales (crus): {n}" -#: scripts/_lib/map_template.py:125 +#: scripts/_lib/map_template.py:131 msgid "Facteurs naturels" msgstr "Factores naturales" -#: scripts/_lib/map_template.py:126 +#: scripts/_lib/map_template.py:132 msgid "Facteurs humains" msgstr "Factores humanos" -#: scripts/_lib/map_template.py:127 +#: scripts/_lib/map_template.py:133 msgid "Caractéristiques du produit" msgstr "Características del producto" -#: scripts/_lib/map_template.py:128 +#: scripts/_lib/map_template.py:134 msgid "Lien terroir / vin" msgstr "Vínculo terroir / vino" -#: scripts/_lib/map_template.py:130 +#: scripts/_lib/map_template.py:136 #, python-brace-format msgid "" "Faits dégagés du Lien au terroir par interprétation automatique — voir la" @@ -356,26 +356,26 @@ msgstr "" "Hechos extraídos de la sección de vínculo con el terruño (Lien au " "terroir) por interpretación automática — véase el {source}." -#: scripts/_lib/map_template.py:132 +#: scripts/_lib/map_template.py:138 msgid "source" msgstr "fuente" -#: scripts/_lib/map_template.py:133 +#: scripts/_lib/map_template.py:139 msgid "via Wikipedia · CC BY-SA 4.0" msgstr "vía Wikipedia · CC BY-SA 4.0" -#: scripts/_lib/map_template.py:135 +#: scripts/_lib/map_template.py:141 #, python-brace-format msgid "Citation textuelle du Lien au terroir — voir la {source}." msgstr "" "Cita textual de la sección de vínculo con el terruño (Lien au terroir) — " "véase el {source}." -#: scripts/_lib/map_template.py:137 +#: scripts/_lib/map_template.py:143 msgid "à vérifier — texte source court" msgstr "por verificar — texto fuente breve" -#: scripts/_lib/map_template.py:139 +#: scripts/_lib/map_template.py:145 #, python-brace-format msgid "" "Ces repères décrivent l'appellation englobante {parent} — pas " @@ -384,29 +384,29 @@ msgstr "" "Estas notas describen la denominación matriz {parent} — no " "específicamente esta denominación." -#: scripts/_lib/map_template.py:142 +#: scripts/_lib/map_template.py:148 msgid "sans région" msgstr "sin región" -#: scripts/_lib/map_template.py:143 +#: scripts/_lib/map_template.py:149 #, python-brace-format msgid "{n} commune(s) INAO" msgstr "{n} comuna(s) INAO" -#: scripts/_lib/map_template.py:144 +#: scripts/_lib/map_template.py:150 #, python-brace-format msgid "{n} commune(s)" msgstr "{n} comuna(s)" -#: scripts/_lib/map_template.py:145 +#: scripts/_lib/map_template.py:151 msgid "aire approchée" msgstr "área aproximada" -#: scripts/_lib/map_template.py:146 +#: scripts/_lib/map_template.py:152 msgid "aire approchée (à l'échelle communale)" msgstr "área aproximada (nivel municipal)" -#: scripts/_lib/map_template.py:148 +#: scripts/_lib/map_template.py:154 #, python-brace-format msgid "" "Aire approchée — pas de données parcellaires précises pour cette " @@ -415,7 +415,7 @@ msgstr "" "Área aproximada — no hay datos parcelarios precisos para esta " "denominación; polígono heredado de {umbrella}." -#: scripts/_lib/map_template.py:152 +#: scripts/_lib/map_template.py:158 msgid "" "Aire approchée — pas de données parcellaires précises pour cette " "dénomination ; polygone hérité de l'appellation parente." @@ -423,7 +423,7 @@ msgstr "" "Área aproximada — no hay datos parcelarios precisos para esta " "denominación; polígono heredado de la denominación de origen principal." -#: scripts/_lib/map_template.py:156 +#: scripts/_lib/map_template.py:162 msgid "" "Aire approchée — pas de données parcellaires disponibles ; affichée comme" " l'emprise de la commune où se situe la dénomination." @@ -431,7 +431,7 @@ msgstr "" "Área aproximada — sin datos parcelarios disponibles; se muestra como el " "contorno del municipio que contiene la denominación." -#: scripts/_lib/map_template.py:160 +#: scripts/_lib/map_template.py:166 #, python-brace-format msgid "" "Aire issue du lieu-dit cadastral « {lieu_dit} » (commune de {commune}, " @@ -440,11 +440,11 @@ msgstr "" "Superficie derivada del paraje catastral «{lieu_dit}» (municipio de " "{commune}, {source})." -#: scripts/_lib/map_template.py:163 +#: scripts/_lib/map_template.py:169 msgid "cadastre.data.gouv.fr" msgstr "cadastre.data.gouv.fr" -#: scripts/_lib/map_template.py:165 +#: scripts/_lib/map_template.py:171 msgid "" "Aire approchée — reconstituée à partir des références parcellaires du " "plan de l'aire délimitée annexé au cahier des charges ; ce n'est pas une " @@ -454,7 +454,7 @@ msgstr "" "del plano de la zona delimitada anexo al pliego de condiciones; no es un " "límite oficial." -#: scripts/_lib/map_template.py:174 +#: scripts/_lib/map_template.py:180 #, python-brace-format msgid "" "Aire approchée — réunion des {n} commune(s) de l'aire délimitée (INAO) ; " @@ -463,7 +463,7 @@ msgstr "" "Área aproximada: unión de los {n} municipio(s) del área delimitada " "(INAO); no existe delimitación parcelaria para esta denominación." -#: scripts/_lib/map_template.py:178 +#: scripts/_lib/map_template.py:184 #, python-brace-format msgid "" "Vue d'ensemble : silhouette du vignoble, généralisée à {radius} m à " @@ -474,7 +474,7 @@ msgstr "" "la delimitación parcelaria del INAO; no es la delimitación en sí. Las " "parcelas aparecen al acercar el zoom." -#: scripts/_lib/map_template.py:183 +#: scripts/_lib/map_template.py:189 #, python-brace-format msgid "" "Sur la carte, aux zooms d'ensemble, cette appellation est dessinée comme " @@ -487,14 +487,14 @@ msgstr "" "delimitación parcelaria del INAO; las propias parcelas aparecen al " "acercar el zoom." -#: scripts/_lib/map_template.py:189 +#: scripts/_lib/map_template.py:195 #, python-brace-format msgid "Aire : zone de production officielle publiée par le géoportail ({region})." msgstr "" "Área: zona de producción oficial publicada por el geoportal regional " "({region})." -#: scripts/_lib/map_template.py:193 +#: scripts/_lib/map_template.py:199 msgid "" "Aire : zone de production officielle du MAPA (Zonas de Calidad " "Diferenciada : Vinos)." @@ -502,7 +502,7 @@ msgstr "" "Área: zona de producción oficial del MAPA (Zonas de Calidad Diferenciada:" " Vinos)." -#: scripts/_lib/map_template.py:197 +#: scripts/_lib/map_template.py:203 msgid "" "Aire approchée — polygone du jeu de données Bétard 2022 (AOP de l'UE), à " "la résolution des communes." @@ -510,7 +510,7 @@ msgstr "" "Área aproximada: polígono del conjunto de datos Bétard 2022 (DOP de la " "UE), con resolución municipal." -#: scripts/_lib/map_template.py:201 +#: scripts/_lib/map_template.py:207 msgid "" "Aire approchée — réunion des polygones des appellations membres (Bétard " "2022), à la résolution des communes." @@ -518,7 +518,7 @@ msgstr "" "Área aproximada: unión de los polígonos de las denominaciones miembro " "(Bétard 2022), con resolución municipal." -#: scripts/_lib/map_template.py:205 +#: scripts/_lib/map_template.py:211 msgid "" "Aire approchée — réunion des unités administratives nommées dans le " "document (limites Eurostat GISCO ou cadastre national)." @@ -526,7 +526,7 @@ msgstr "" "Área aproximada: unión de las unidades administrativas citadas en el " "documento (límites de Eurostat GISCO o del catastro nacional)." -#: scripts/_lib/map_template.py:213 +#: scripts/_lib/map_template.py:219 #, python-brace-format msgid "" "Nommé(s) dans le document mais absent(s) de la couche communale, donc non" @@ -535,7 +535,7 @@ msgstr "" "Citado(s) en el documento pero ausente(s) de la capa municipal, por lo " "que no se dibuja(n): {names}." -#: scripts/_lib/map_template.py:217 +#: scripts/_lib/map_template.py:223 #, python-brace-format msgid "" "Localité(s) sans polygone propre, tracée(s) par l'unité administrative " @@ -544,7 +544,7 @@ msgstr "" "Localidad(es) sin polígono propio, dibujada(s) como la unidad " "administrativa que las contiene: {names}." -#: scripts/_lib/map_template.py:221 +#: scripts/_lib/map_template.py:227 #, python-brace-format msgid "" "Aire approchée : le document délimite la zone par une ligne passant par " @@ -556,7 +556,7 @@ msgstr "" "esos lugares, cada uno en su punto central (coordenadas de Wikidata): " "{names}." -#: scripts/_lib/map_template.py:227 +#: scripts/_lib/map_template.py:233 #, python-brace-format msgid "" "Aire approchée : le document délimite la zone par une ligne passant par " @@ -567,7 +567,7 @@ msgstr "" "pasa por lugares con nombre, trazada aquí como las unidades " "administrativas que los contienen: {names}." -#: scripts/_lib/map_template.py:235 +#: scripts/_lib/map_template.py:241 #, python-brace-format msgid "" "Commune(s) de l'aire délimitée (INAO) sans délimitation parcellaire " @@ -576,7 +576,7 @@ msgstr "" "Municipio(s) del área delimitada (INAO) sin delimitación parcelaria " "publicada en la capa parcelaria del INAO, dibujado(s) entero(s): {names}." -#: scripts/_lib/map_template.py:239 +#: scripts/_lib/map_template.py:245 #, python-brace-format msgid "" "Commune(s) de l'aire délimitée (INAO) sans délimitation parcellaire " @@ -587,7 +587,7 @@ msgstr "" "publicada para esta denominación, dibujado(s) a partir de las parcelas " "{donor} del municipio: {names}." -#: scripts/_lib/map_template.py:244 +#: scripts/_lib/map_template.py:250 #, python-brace-format msgid "" "Commune(s) de l'aire délimitée (INAO) sans délimitation parcellaire " @@ -596,15 +596,16 @@ msgstr "" "Municipio(s) del área delimitada (INAO) sin delimitación parcelaria " "publicada en la capa parcelaria del INAO, no dibujado(s): {names}." -#: scripts/_lib/map_template.py:251 +#: scripts/_lib/map_template.py:257 #, python-brace-format msgid "" "Délimitation parcellaire de la publication INAO du {release} ; la " "publication du {current} ne porte pas cette dénomination." -msgstr "Delimitación parcelaria de la publicación INAO del {release}; la " +msgstr "" +"Delimitación parcelaria de la publicación INAO del {release}; la " "publicación del {current} no incluye esta denominación." -#: scripts/_lib/map_template.py:251 +#: scripts/_lib/map_template.py:264 #, python-brace-format msgid "" "Aire complétée par {n} paroisse(s) civile(s) (parroquias) nommée(s) dans " @@ -614,7 +615,7 @@ msgstr "" "Área completada con las {n} parroquias que nombra el documento, dibujadas" " a partir del Mapa de Parroquias de Galicia ({source}): {names}." -#: scripts/_lib/map_template.py:260 +#: scripts/_lib/map_template.py:273 #, python-brace-format msgid "" "Aire : les {n} polígonos cadastraux SIGPAC que le document énumère dans " @@ -625,7 +626,7 @@ msgstr "" "en {municipios}, cada uno dibujado entero — cualquier parcela plantada " "dentro de ellos queda amparada por la denominación ({source})." -#: scripts/_lib/map_template.py:265 +#: scripts/_lib/map_template.py:278 #, python-brace-format msgid "" "Aire : les parcelles de vigne (SIGPAC, usage VI) situées dans les {n} " @@ -636,17 +637,17 @@ msgstr "" "polígonos catastrales que el documento enumera en {municipios} " "({source})." -#: scripts/_lib/map_template.py:269 +#: scripts/_lib/map_template.py:282 #, python-brace-format msgid "S'y ajoutent, en totalité, les communes {whole}." msgstr "Se añaden, en su totalidad, los municipios {whole}." -#: scripts/_lib/map_template.py:270 +#: scripts/_lib/map_template.py:283 #, python-brace-format msgid "{n} appellations à ce point" msgstr "{n} denominaciones en este punto" -#: scripts/_lib/map_template.py:272 +#: scripts/_lib/map_template.py:285 msgid "" "Cliquer à nouveau sur la carte pour parcourir les autres, ou sur une " "fiche pour l'afficher en premier" @@ -654,115 +655,115 @@ msgstr "" "Vuelve a hacer clic en el mapa para recorrer las demás, o en una ficha " "para mostrarla en primer lugar" -#: scripts/_lib/map_template.py:275 +#: scripts/_lib/map_template.py:288 msgid "Afficher cette appellation en premier" msgstr "Mostrar esta denominación en primer lugar" -#: scripts/_lib/map_template.py:276 +#: scripts/_lib/map_template.py:289 msgid "Cahier des charges (BO Agri, PDF)" msgstr "Pliego de condiciones (BO Agri, PDF)" -#: scripts/_lib/map_template.py:277 +#: scripts/_lib/map_template.py:290 msgid "Cahier des charges (registre GI de l'UE, PDF)" msgstr "Pliego de condiciones (registro de IG de la UE, PDF)" -#: scripts/_lib/map_template.py:278 +#: scripts/_lib/map_template.py:291 msgid "homologué" msgstr "homologado" -#: scripts/_lib/map_template.py:279 +#: scripts/_lib/map_template.py:292 msgid "JORF" msgstr "JORF" -#: scripts/_lib/map_template.py:280 +#: scripts/_lib/map_template.py:293 msgid "Texte officiel INAO (show_texte)" msgstr "Texto oficial INAO (show_texte)" -#: scripts/_lib/map_template.py:281 +#: scripts/_lib/map_template.py:294 msgid "Fiche produit INAO" msgstr "Ficha de producto INAO" -#: scripts/_lib/map_template.py:282 +#: scripts/_lib/map_template.py:295 msgid "Site officiel de l'interprofession" msgstr "Sitio oficial de la interprofesión" -#: scripts/_lib/map_template.py:283 +#: scripts/_lib/map_template.py:296 msgid "Cahier des charges (EUR-Lex, document unique)" msgstr "Pliego de condiciones (EUR-Lex, documento único)" -#: scripts/_lib/map_template.py:284 +#: scripts/_lib/map_template.py:297 msgid "Pliego de condiciones (national, PDF)" msgstr "Pliego de condiciones nacional (PDF)" -#: scripts/_lib/map_template.py:285 +#: scripts/_lib/map_template.py:298 msgid "variétés ajoutées" msgstr "variedades añadidas" -#: scripts/_lib/map_template.py:286 +#: scripts/_lib/map_template.py:299 msgid "Cahier des charges national (PDF)" msgstr "Pliego de condiciones nacional (PDF)" -#: scripts/_lib/map_template.py:287 +#: scripts/_lib/map_template.py:300 msgid "Spécification du produit (IGP, PDF)" msgstr "Pliego de condiciones (IGP, PDF)" -#: scripts/_lib/map_template.py:288 +#: scripts/_lib/map_template.py:301 msgid "Registre régional des cépages (PDF)" msgstr "Registro regional de variedades (PDF)" -#: scripts/_lib/map_template.py:289 +#: scripts/_lib/map_template.py:302 msgid "Mapa de Parroquias de Galicia (IET, Xunta de Galicia)" msgstr "Mapa de Parroquias de Galicia (IET, Xunta de Galicia)" -#: scripts/_lib/map_template.py:290 +#: scripts/_lib/map_template.py:303 msgid "Registre eAmbrosia (UE)" msgstr "Registro eAmbrosia (UE)" -#: scripts/_lib/map_template.py:291 +#: scripts/_lib/map_template.py:304 msgid "Numéro de dossier" msgstr "Número de expediente" -#: scripts/_lib/map_template.py:292 +#: scripts/_lib/map_template.py:305 msgid "Règlement cantonal sur la vigne et le vin" msgstr "Reglamento cantonal de la viña y el vino" -#: scripts/_lib/map_template.py:293 +#: scripts/_lib/map_template.py:306 msgid "Répertoire suisse des AOC (OFAG/BLW)" msgstr "Registro suizo de AOC (OFAG/BLW)" -#: scripts/_lib/map_template.py:294 +#: scripts/_lib/map_template.py:307 msgid "Cahier des charges (GOV.UK, PDF)" msgstr "Pliego de condiciones (GOV.UK, PDF)" -#: scripts/_lib/map_template.py:295 +#: scripts/_lib/map_template.py:308 msgid "Registre des IG du Royaume-Uni" msgstr "Registro de IG del Reino Unido" -#: scripts/_lib/map_template.py:296 +#: scripts/_lib/map_template.py:309 msgid "Légende couleurs" msgstr "Leyenda de colores" -#: scripts/_lib/map_template.py:297 +#: scripts/_lib/map_template.py:310 msgid "Bassin viticole" msgstr "Cuenca vitícola" -#: scripts/_lib/map_template.py:298 +#: scripts/_lib/map_template.py:311 msgid "Plus l'aire est petite, plus la teinte est dense." msgstr "Cuanto menor es el área, más densa es la tonalidad." -#: scripts/_lib/map_template.py:300 +#: scripts/_lib/map_template.py:313 msgid "principal — variété de la cuvée" msgstr "principal — variedad de la mezcla" -#: scripts/_lib/map_template.py:301 +#: scripts/_lib/map_template.py:314 msgid "accessoire — assemblage limité" msgstr "accesoria — mezcla limitada" -#: scripts/_lib/map_template.py:302 +#: scripts/_lib/map_template.py:315 msgid "intérêt — observation/conservation" msgstr "de interés — observación/conservación" -#: scripts/_lib/map_template.py:304 +#: scripts/_lib/map_template.py:317 msgid "" "Le régulateur portugais (IVV) n'établit pas de distinction " "principal/accessoire — toutes les castas autorisées sont listées ensemble" @@ -772,7 +773,7 @@ msgstr "" "accesorias — todas las castas autorizadas se enumeran juntas en el " "caderno de especificações." -#: scripts/_lib/map_template.py:308 +#: scripts/_lib/map_template.py:321 msgid "" "Bianchello (ou Biancame) est traité ici comme un cépage distinct, " "conformément au disciplinare de la DOP Bianchello del Metauro ; le " @@ -782,20 +783,20 @@ msgstr "" "conforme al disciplinare de la DOP Bianchello del Metauro; el catálogo " "VIVC lo registra como sinónimo de Trebbiano Toscano." -#: scripts/_lib/map_template.py:313 +#: scripts/_lib/map_template.py:326 #, python-brace-format msgid "Open Wine Map n'a pas encore trouvé de {doc} pour cette appellation." msgstr "Open Wine Map todavía no ha localizado un {doc} para esta denominación." -#: scripts/_lib/map_template.py:315 +#: scripts/_lib/map_template.py:328 msgid "aidez-nous à le trouver" msgstr "ayúdanos a encontrarlo" -#: scripts/_lib/map_template.py:319 +#: scripts/_lib/map_template.py:332 msgid "Annulée" msgstr "Cancelada" -#: scripts/_lib/map_template.py:321 +#: scripts/_lib/map_template.py:334 #, python-brace-format msgid "" "Cette appellation a été annulée le {date} par {regulation}. Des " @@ -806,183 +807,186 @@ msgstr "" "etiquetas y materiales didácticos antiguos pueden seguir mencionándola; " "se conserva aquí como referencia." -#: scripts/_lib/map_template.py:325 +#: scripts/_lib/map_template.py:338 #, python-brace-format msgid "Acte national : {act}." msgstr "Acto nacional: {act}." -#: scripts/_lib/map_template.py:326 +#: scripts/_lib/map_template.py:339 #, python-brace-format msgid "Elle est remplacée par {successor}." msgstr "Le sucede {successor}." -#: scripts/_lib/map_template.py:327 +#: scripts/_lib/map_template.py:340 #, python-brace-format msgid "Enregistrement annulé le {date}" msgstr "Registro cancelado el {date}" -#: scripts/_lib/map_template.py:331 +#: scripts/_lib/map_template.py:344 msgid "Promue" msgstr "Promovida" -#: scripts/_lib/map_template.py:333 +#: scripts/_lib/map_template.py:346 #, python-brace-format msgid "" "Cette dénomination est devenue l'appellation {successor} à part entière " "le {date} ({act}). Des étiquettes plus anciennes peuvent encore porter " "l'ancien nom ; elle est conservée ici à titre de référence." -msgstr "El {date} esta denominación pasó a ser la denominación {successor} por derecho propio ({act}). Las etiquetas más antiguas pueden seguir llevando el nombre anterior; se conserva aquí como referencia." +msgstr "" +"El {date} esta denominación pasó a ser la denominación {successor} por " +"derecho propio ({act}). Las etiquetas más antiguas pueden seguir llevando" +" el nombre anterior; se conserva aquí como referencia." -#: scripts/_lib/map_template.py:337 +#: scripts/_lib/map_template.py:350 #, python-brace-format msgid "Cahier des charges de la nouvelle appellation : {cahier}." msgstr "Pliego de condiciones de la nueva denominación: {cahier}." -#: scripts/_lib/map_template.py:338 +#: scripts/_lib/map_template.py:351 msgid "BO Agri (PDF)" msgstr "BO Agri (PDF)" -#: scripts/_lib/map_template.py:339 +#: scripts/_lib/map_template.py:352 #, python-brace-format msgid "Devenue appellation à part entière le {date}" msgstr "Convertida en denominación por derecho propio el {date}" -#: scripts/_lib/map_template.py:345 +#: scripts/_lib/map_template.py:358 #, python-brace-format msgid "Délimitée par {regulator} dans son {doc}, qui autorise {grapes}." msgstr "Delimitada por {regulator} en su {doc}, que autoriza {grapes}." -#: scripts/_lib/map_template.py:347 +#: scripts/_lib/map_template.py:360 #, python-brace-format msgid "Délimitée par {regulator} dans son {doc}{extra}." msgstr "Delimitada por {regulator} en su {doc}{extra}." -#: scripts/_lib/map_template.py:348 +#: scripts/_lib/map_template.py:361 #, python-brace-format msgid "{names} et {n} autres cépages" msgstr "{names} y otras {n} variedades de uva" -#: scripts/_lib/map_template.py:349 +#: scripts/_lib/map_template.py:362 msgid "le registre eAmbrosia de l'UE" msgstr "el registro eAmbrosia de la UE" -#: scripts/_lib/map_template.py:350 +#: scripts/_lib/map_template.py:363 #, python-brace-format msgid "le canton de {canton}" msgstr "el cantón de {canton}" -#: scripts/_lib/map_template.py:351 +#: scripts/_lib/map_template.py:364 msgid "(français)" msgstr "(fuente: francés)" -#: scripts/_lib/map_template.py:352 +#: scripts/_lib/map_template.py:365 msgid "Texte source en français" msgstr "Texto fuente en francés" -#: scripts/_lib/map_template.py:353 +#: scripts/_lib/map_template.py:366 msgid "(español)" msgstr "(fuente: español)" -#: scripts/_lib/map_template.py:354 +#: scripts/_lib/map_template.py:367 msgid "Texte source en espagnol" msgstr "Texto fuente en español" -#: scripts/_lib/map_template.py:355 +#: scripts/_lib/map_template.py:368 msgid "(português)" msgstr "(fuente: portugués)" -#: scripts/_lib/map_template.py:356 +#: scripts/_lib/map_template.py:369 msgid "Texte source en portugais" msgstr "Texto fuente en portugués" -#: scripts/_lib/map_template.py:357 +#: scripts/_lib/map_template.py:370 msgid "Filtres" msgstr "Filtros" -#: scripts/_lib/map_template.py:358 +#: scripts/_lib/map_template.py:371 #, python-brace-format msgid "Traduit de {wiki} · CC BY-SA 4.0" msgstr "Traducido de {wiki} · CC BY-SA 4.0" -#: scripts/_lib/map_template.py:359 +#: scripts/_lib/map_template.py:372 msgid "Wikipédia en anglais" msgstr "Wikipedia en inglés" -#: scripts/_lib/map_template.py:360 +#: scripts/_lib/map_template.py:373 msgid "Wikipédia en français" msgstr "Wikipedia en francés" -#: scripts/_lib/map_template.py:361 +#: scripts/_lib/map_template.py:374 msgid "Wikipédia en espagnol" msgstr "Wikipedia en español" -#: scripts/_lib/map_template.py:362 +#: scripts/_lib/map_template.py:375 msgid "Wikipédia en néerlandais" msgstr "Wikipedia en neerlandés" -#: scripts/_lib/map_template.py:363 +#: scripts/_lib/map_template.py:376 msgid "Wikipédia en portugais" msgstr "Wikipedia en portugués" -#: scripts/_lib/map_template.py:364 +#: scripts/_lib/map_template.py:377 msgid "Wikipédia en croate" msgstr "Wikipedia en croata" -#: scripts/_lib/map_template.py:365 +#: scripts/_lib/map_template.py:378 msgid "Wikipédia en italien" msgstr "Wikipedia en italiano" -#: scripts/_lib/map_template.py:366 +#: scripts/_lib/map_template.py:379 msgid "Wikipédia en allemand" msgstr "Wikipedia en alemán" -#: scripts/_lib/map_template.py:367 +#: scripts/_lib/map_template.py:380 msgid "Wikipédia en slovène" msgstr "Wikipedia en esloveno" -#: scripts/_lib/map_template.py:368 +#: scripts/_lib/map_template.py:381 msgid "Wikipédia en bulgare" msgstr "Wikipedia en búlgaro" -#: scripts/_lib/map_template.py:369 +#: scripts/_lib/map_template.py:382 msgid "Vitis International Variety Catalogue (Julius Kühn-Institut)" msgstr "Vitis International Variety Catalogue (Julius Kühn-Institut)" -#: scripts/_lib/map_template.py:370 +#: scripts/_lib/map_template.py:383 #, python-brace-format msgid "VIVC #{id}" msgstr "VIVC #{id}" -#: scripts/_lib/map_template.py:371 +#: scripts/_lib/map_template.py:384 #, python-brace-format msgid "Traduction automatique depuis {source}" msgstr "Traducción automática desde {source}" -#: scripts/_lib/map_template.py:372 +#: scripts/_lib/map_template.py:385 msgid "le cahier des charges" msgstr "el pliego de condiciones" -#: scripts/_lib/map_template.py:373 scripts/_lib/map_template.py:375 +#: scripts/_lib/map_template.py:386 scripts/_lib/map_template.py:388 msgid "le pliego de condiciones" msgstr "el pliego de condiciones" -#: scripts/_lib/map_template.py:374 scripts/_lib/map_template.py:376 +#: scripts/_lib/map_template.py:387 scripts/_lib/map_template.py:389 msgid "le caderno de especificações" msgstr "el caderno de especificações" -#: scripts/_lib/map_template.py:377 +#: scripts/_lib/map_template.py:390 msgid "Dénomination géographique complémentaire de" msgstr "Subzona de" -#: scripts/_lib/map_template.py:378 +#: scripts/_lib/map_template.py:391 msgid "À propos" msgstr "Acerca de" -#: scripts/_lib/map_template.py:379 +#: scripts/_lib/map_template.py:392 msgid "À propos d'Open Wine Map" msgstr "Acerca de Open Wine Map" -#: scripts/_lib/map_template.py:381 +#: scripts/_lib/map_template.py:394 msgid "" "Carte de référence des appellations viticoles, générée automatiquement à " "partir des registres publics : le registre de l'Union européenne des AOP " @@ -995,7 +999,7 @@ msgstr "" "suizo de AOC cantonales y el registro británico de indicaciones " "geográficas." -#: scripts/_lib/map_template.py:388 +#: scripts/_lib/map_template.py:401 msgid "" "Une partie du texte est produite par des modèles de langage : les repères" " de terroir sont dégagés du texte du régulateur par Claude, vérifiés " @@ -1016,12 +1020,12 @@ msgstr "" "salvo un pequeño resto traducido automáticamente. Cada elemento lleva su " "propia línea de atribución en el panel." -#: scripts/_lib/map_template.py:397 +#: scripts/_lib/map_template.py:410 #, python-brace-format msgid "Réalisé avec ♡ par {devloed}." msgstr "Hecho con ♡ por {devloed}." -#: scripts/_lib/map_template.py:399 +#: scripts/_lib/map_template.py:412 #, python-brace-format msgid "" "Sources : INAO ({inao}) pour les cahiers des charges et les aires " @@ -1046,7 +1050,7 @@ msgstr "" "extracto de Wikipedia se indica allí donde aparece. Detalles y licencias " "en el {readme}." -#: scripts/_lib/map_template.py:410 +#: scripts/_lib/map_template.py:423 #, python-brace-format msgid "" "Niveau de détail : en dessous du zoom {zoom}, les appellations françaises" @@ -1064,12 +1068,12 @@ msgstr "" "demás denominaciones conservan el mismo contorno en todos los niveles de " "zoom." -#: scripts/_lib/map_template.py:418 +#: scripts/_lib/map_template.py:431 #, python-brace-format msgid "Suggestions et pull requests bienvenues sur {github}." msgstr "Sugerencias y pull requests bienvenidos en {github}." -#: scripts/_lib/map_template.py:420 +#: scripts/_lib/map_template.py:433 #, python-brace-format msgid "" "Vie privée : la fréquentation est mesurée avec une instance {plausible} " @@ -1086,20 +1090,20 @@ msgstr "" "seguimiento entre sitios ni entre dispositivos — por eso no hay banner de" " consentimiento." -#: scripts/_lib/map_template.py:427 +#: scripts/_lib/map_template.py:440 #, python-brace-format msgid "Suivez les mises à jour sur {bluesky}." msgstr "Sigue las novedades en {bluesky}." -#: scripts/_lib/map_template.py:428 +#: scripts/_lib/map_template.py:441 msgid "e-mail" msgstr "correo" -#: scripts/_lib/map_template.py:429 +#: scripts/_lib/map_template.py:442 msgid "E-mail copié dans le presse-papiers" msgstr "Correo copiado al portapapeles" -#: scripts/_lib/map_template.py:431 +#: scripts/_lib/map_template.py:444 #, python-brace-format msgid "" "Carte générée automatiquement — des erreurs sont possibles. Signalez-les " @@ -1108,48 +1112,48 @@ msgstr "" "Mapa generado automáticamente — puede contener errores. Señálalos desde " "la ficha de la denominación, o por {email}." -#: scripts/_lib/map_template.py:437 +#: scripts/_lib/map_template.py:450 msgid "Signaler une erreur" msgstr "Señalar un error" -#: scripts/_lib/map_template.py:438 +#: scripts/_lib/map_template.py:451 msgid "Un clic nous dit quoi vérifier — sans compte." msgstr "Un toque nos dice qué revisar — sin necesidad de cuenta." -#: scripts/_lib/map_template.py:439 +#: scripts/_lib/map_template.py:452 msgid "Aire / contour" msgstr "Zona / límites" -#: scripts/_lib/map_template.py:442 +#: scripts/_lib/map_template.py:455 msgid "Nom / type" msgstr "Nombre / tipo" -#: scripts/_lib/map_template.py:444 +#: scripts/_lib/map_template.py:457 msgid "Autre" msgstr "Otro" -#: scripts/_lib/map_template.py:445 +#: scripts/_lib/map_template.py:458 #, python-brace-format msgid "Merci, c'est noté : {aspect}." msgstr "Gracias, anotado: {aspect}." -#: scripts/_lib/map_template.py:447 +#: scripts/_lib/map_template.py:460 msgid "Précisez si vous voulez (facultatif, sans données personnelles)" msgstr "Añade detalles si quieres (opcional, sin datos personales)" -#: scripts/_lib/map_template.py:449 +#: scripts/_lib/map_template.py:462 msgid "Envoyer" msgstr "Enviar" -#: scripts/_lib/map_template.py:452 +#: scripts/_lib/map_template.py:465 msgid "Géométrie" msgstr "Geometría" -#: scripts/_lib/map_template.py:453 +#: scripts/_lib/map_template.py:466 msgid "Envoyé, merci !" msgstr "Enviado, ¡gracias!" -#: scripts/_lib/map_template.py:455 +#: scripts/_lib/map_template.py:468 #, python-brace-format msgid "" "{aspect} — votre navigateur bloque notre outil de mesure, envoyez plutôt " @@ -1158,7 +1162,7 @@ msgstr "" "{aspect} — tu navegador bloquea nuestra herramienta de medición; envía un" " {email} en su lugar." -#: scripts/_lib/map_template.py:459 +#: scripts/_lib/map_template.py:472 #, python-brace-format msgid "" "{c} pays européens cartographiés ({n} appellations : {parents} " @@ -1171,15 +1175,15 @@ msgstr "" "iteraciones afinarán la calidad de los datos y ampliarán la cobertura más" " allá de la UE, Suiza y el Reino Unido." -#: scripts/_lib/map_template.py:464 +#: scripts/_lib/map_template.py:477 msgid "Toutes les appellations" msgstr "Todas las denominaciones" -#: scripts/_lib/map_template.py:465 +#: scripts/_lib/map_template.py:478 msgid "Toutes les appellations viticoles — Open Wine Map" msgstr "Todas las denominaciones vitivinícolas — Open Wine Map" -#: scripts/_lib/map_template.py:467 +#: scripts/_lib/map_template.py:480 #, python-brace-format msgid "" "Liste des {n} appellations viticoles cartographiées sur Open Wine Map, " @@ -1188,7 +1192,7 @@ msgstr "" "Lista de las {n} denominaciones vitivinícolas cartografiadas en Open Wine" " Map, por país: DOP e IGP de la UE, AOC suizas e IG británicas." -#: scripts/_lib/map_template.py:471 +#: scripts/_lib/map_template.py:484 #, python-brace-format msgid "" "Les {n} appellations ci-dessous sont classées par pays. Retour à la " @@ -1197,205 +1201,236 @@ msgstr "" "Las {n} denominaciones siguientes están agrupadas por país. Volver al " "{map_link}." -#: scripts/_lib/map_template.py:474 +#: scripts/_lib/map_template.py:487 msgid "carte interactive" msgstr "mapa interactivo" -#: scripts/_lib/map_template.py:475 +#: scripts/_lib/map_template.py:488 msgid "Liste des appellations par pays" msgstr "Lista de denominaciones por país" -#: scripts/_lib/map_template.py:476 +#: scripts/_lib/map_template.py:489 msgid "Appellation parente" msgstr "Denominación matriz" -#: scripts/_lib/map_template.py:477 +#: scripts/_lib/map_template.py:490 msgid "Dénominations rattachées" msgstr "Denominaciones vinculadas" -#: scripts/_lib/map_template.py:479 +#: scripts/_lib/map_template.py:492 #, python-brace-format msgid "Parcourir la liste complète des appellations : {browse_link}." msgstr "Explore la lista completa de denominaciones: {browse_link}." -#: scripts/_lib/map_template.py:481 +#: scripts/_lib/map_template.py:494 #, python-brace-format msgid "Données mises à jour le {date}." msgstr "Datos actualizados el {date}." -#: scripts/_lib/map_template.py:499 +#: scripts/_lib/map_template.py:496 +#, python-brace-format +msgid "" +"Votre navigateur parle déjà {webmcp} ? Chaque page de la carte lui " +"propose les mêmes outils. Sinon, aucune importance : c'est le serveur MCP" +" ci-dessus que votre assistant IA sait utiliser." +msgstr "¿Su navegador ya habla {webmcp}? Cada página del mapa le ofrece las mismas herramientas. Si no le suena, no importa: el servidor MCP de arriba es el que su asistente de IA sabe usar." + +#: scripts/_lib/map_template.py:500 +msgid "Copier" +msgstr "Copiar" + +#: scripts/_lib/map_template.py:501 +msgid "Copié" +msgstr "Copiado" + +#: scripts/_lib/map_template.py:502 +msgid "Copier l'adresse du serveur MCP" +msgstr "Copiar la dirección del servidor MCP" + +#: scripts/_lib/map_template.py:504 +#, python-brace-format +msgid "" +"Les assistants IA peuvent interroger les appellations avec les mêmes " +"outils de recherche que la carte, via le serveur MCP {mcp_link} (sans " +"authentification ; à ajouter comme connecteur personnalisé)." +msgstr "" +"Los asistentes de IA pueden consultar las denominaciones con las mismas " +"herramientas de búsqueda que el mapa, a través del servidor MCP " +"{mcp_link} (sin autenticación; añádalo como conector personalizado)." + +#: scripts/_lib/map_template.py:525 msgid "BOURGOGNE" msgstr "Borgoña" -#: scripts/_lib/map_template.py:500 +#: scripts/_lib/map_template.py:526 msgid "BEAUJOLAIS" msgstr "Beaujolais" -#: scripts/_lib/map_template.py:501 +#: scripts/_lib/map_template.py:527 msgid "JURA" msgstr "Jura" -#: scripts/_lib/map_template.py:502 +#: scripts/_lib/map_template.py:528 msgid "SAVOIE" msgstr "Saboya" -#: scripts/_lib/map_template.py:503 +#: scripts/_lib/map_template.py:529 msgid "BUGEY" msgstr "Bugey" -#: scripts/_lib/map_template.py:504 +#: scripts/_lib/map_template.py:530 msgid "ALSACE" msgstr "Alsacia" -#: scripts/_lib/map_template.py:505 +#: scripts/_lib/map_template.py:531 msgid "LORRAINE" msgstr "Lorena" -#: scripts/_lib/map_template.py:506 +#: scripts/_lib/map_template.py:532 msgid "VAL DE LOIRE" msgstr "Valle del Loira" -#: scripts/_lib/map_template.py:507 +#: scripts/_lib/map_template.py:533 msgid "BORDEAUX" msgstr "Burdeos" -#: scripts/_lib/map_template.py:508 +#: scripts/_lib/map_template.py:534 msgid "SUD-OUEST" msgstr "Suroeste" -#: scripts/_lib/map_template.py:509 +#: scripts/_lib/map_template.py:535 msgid "VALLEE DU RHÔNE" msgstr "Valle del Ródano" -#: scripts/_lib/map_template.py:510 +#: scripts/_lib/map_template.py:536 msgid "LANGUEDOC" msgstr "Languedoc" -#: scripts/_lib/map_template.py:511 +#: scripts/_lib/map_template.py:537 msgid "ROUSSILLON" msgstr "Rosellón" -#: scripts/_lib/map_template.py:512 +#: scripts/_lib/map_template.py:538 msgid "PROVENCE" msgstr "Provenza" -#: scripts/_lib/map_template.py:513 +#: scripts/_lib/map_template.py:539 msgid "CORSE" msgstr "Córcega" -#: scripts/_lib/map_template.py:514 +#: scripts/_lib/map_template.py:540 msgid "CHAMPAGNE" msgstr "Champaña" -#: scripts/_lib/map_template.py:515 +#: scripts/_lib/map_template.py:541 msgid "ILE-DE-FRANCE" msgstr "Isla de Francia" -#: scripts/_lib/map_template.py:516 +#: scripts/_lib/map_template.py:542 msgid "NORMANDIE" msgstr "Normandía" -#: scripts/_lib/map_template.py:517 +#: scripts/_lib/map_template.py:543 msgid "BRETAGNE" msgstr "Bretaña" -#: scripts/_lib/map_template.py:518 +#: scripts/_lib/map_template.py:544 msgid "MAINE" msgstr "Maine" -#: scripts/_lib/map_template.py:519 +#: scripts/_lib/map_template.py:545 msgid "COGNAC" msgstr "Coñac" -#: scripts/_lib/map_template.py:520 +#: scripts/_lib/map_template.py:546 msgid "ARMAGNAC" msgstr "Armañac" -#: scripts/_lib/map_template.py:521 +#: scripts/_lib/map_template.py:547 msgid "RHUM" msgstr "Ron" -#: scripts/_lib/map_template.py:561 +#: scripts/_lib/map_template.py:587 msgid "France" msgstr "Francia" -#: scripts/_lib/map_template.py:562 +#: scripts/_lib/map_template.py:588 msgid "Espagne" msgstr "España" -#: scripts/_lib/map_template.py:563 +#: scripts/_lib/map_template.py:589 msgid "Portugal" msgstr "Portugal" -#: scripts/_lib/map_template.py:564 +#: scripts/_lib/map_template.py:590 msgid "Italie" msgstr "Italia" -#: scripts/_lib/map_template.py:565 +#: scripts/_lib/map_template.py:591 msgid "Autriche" msgstr "Austria" -#: scripts/_lib/map_template.py:566 +#: scripts/_lib/map_template.py:592 msgid "Slovénie" msgstr "Eslovenia" -#: scripts/_lib/map_template.py:567 +#: scripts/_lib/map_template.py:593 msgid "Croatie" msgstr "Croacia" -#: scripts/_lib/map_template.py:568 +#: scripts/_lib/map_template.py:594 msgid "Hongrie" msgstr "Hungría" -#: scripts/_lib/map_template.py:569 +#: scripts/_lib/map_template.py:595 msgid "Roumanie" msgstr "Rumanía" -#: scripts/_lib/map_template.py:570 +#: scripts/_lib/map_template.py:596 msgid "Bulgarie" msgstr "Bulgaria" -#: scripts/_lib/map_template.py:571 +#: scripts/_lib/map_template.py:597 msgid "Grèce" msgstr "Grecia" -#: scripts/_lib/map_template.py:572 +#: scripts/_lib/map_template.py:598 msgid "Allemagne" msgstr "Alemania" -#: scripts/_lib/map_template.py:573 +#: scripts/_lib/map_template.py:599 msgid "Slovaquie" msgstr "Eslovaquia" -#: scripts/_lib/map_template.py:574 +#: scripts/_lib/map_template.py:600 msgid "Suisse" msgstr "Suiza" -#: scripts/_lib/map_template.py:575 +#: scripts/_lib/map_template.py:601 msgid "Tchéquie" msgstr "Chequia" -#: scripts/_lib/map_template.py:576 +#: scripts/_lib/map_template.py:602 msgid "Luxembourg" msgstr "Luxemburgo" -#: scripts/_lib/map_template.py:577 +#: scripts/_lib/map_template.py:603 msgid "Belgique" msgstr "Bélgica" -#: scripts/_lib/map_template.py:578 +#: scripts/_lib/map_template.py:604 msgid "Pays-Bas" msgstr "Países Bajos" -#: scripts/_lib/map_template.py:579 +#: scripts/_lib/map_template.py:605 msgid "Malte" msgstr "Malta" -#: scripts/_lib/map_template.py:580 +#: scripts/_lib/map_template.py:606 msgid "Chypre" msgstr "Chipre" -#: scripts/_lib/map_template.py:581 +#: scripts/_lib/map_template.py:607 msgid "Royaume-Uni" msgstr "Reino Unido" diff --git a/locale/fr/LC_MESSAGES/messages.po b/locale/fr/LC_MESSAGES/messages.po index dbca02f..d01b28b 100644 --- a/locale/fr/LC_MESSAGES/messages.po +++ b/locale/fr/LC_MESSAGES/messages.po @@ -3,7 +3,7 @@ msgid "" msgstr "" "Project-Id-Version: open-wine-map\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-10-03 09:59+0200\n" +"POT-Creation-Date: 2026-10-04 16:41+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: fr\n" @@ -26,20 +26,20 @@ msgstr "Prädikat" msgid "Sélection" msgstr "Sélection" -#: scripts/_lib/map_template.py:46 +#: scripts/_lib/map_template.py:52 msgid "Open Wine Map — carte des appellations viticoles d'Europe" msgstr "Open Wine Map — carte des appellations viticoles d'Europe" -#: scripts/_lib/map_template.py:49 +#: scripts/_lib/map_template.py:55 #, python-brace-format msgid "{name}, carte du vignoble" msgstr "{name}, carte du vignoble" -#: scripts/_lib/map_template.py:50 +#: scripts/_lib/map_template.py:56 msgid "carte des appellations viticoles" msgstr "carte des appellations viticoles" -#: scripts/_lib/map_template.py:52 +#: scripts/_lib/map_template.py:58 msgid "" "Carte interactive des appellations viticoles d'Europe : cépages, styles " "et terroir, d'après les registres officiels." @@ -47,306 +47,306 @@ msgstr "" "Carte interactive des appellations viticoles d'Europe : cépages, styles " "et terroir, d'après les registres officiels." -#: scripts/_lib/map_template.py:55 +#: scripts/_lib/map_template.py:61 msgid "Chargement…" msgstr "Chargement…" -#: scripts/_lib/map_template.py:56 +#: scripts/_lib/map_template.py:62 msgid "Recherche" msgstr "Recherche" -#: scripts/_lib/map_template.py:57 +#: scripts/_lib/map_template.py:63 msgid "nom d'appellation…" msgstr "nom d'appellation…" -#: scripts/_lib/map_template.py:58 +#: scripts/_lib/map_template.py:64 msgid "Recherche d'appellation…" msgstr "Recherche d'appellation…" -#: scripts/_lib/map_template.py:60 +#: scripts/_lib/map_template.py:66 msgid "Rechercher une appellation, un cépage, une région…" msgstr "Rechercher une appellation, un cépage, une région…" -#: scripts/_lib/map_template.py:61 +#: scripts/_lib/map_template.py:67 msgid "Cépage principal uniquement" msgstr "Cépage principal uniquement" -#: scripts/_lib/map_template.py:62 +#: scripts/_lib/map_template.py:68 #, python-brace-format msgid "Aucun résultat pour « {q} »" msgstr "" -#: scripts/_lib/map_template.py:63 +#: scripts/_lib/map_template.py:69 msgid "IGP masquée · s'affiche à la sélection" msgstr "" -#: scripts/_lib/map_template.py:64 +#: scripts/_lib/map_template.py:70 msgid "Eau-de-vie masquée · s'affiche à la sélection" msgstr "" -#: scripts/_lib/map_template.py:65 +#: scripts/_lib/map_template.py:71 msgid "Options" msgstr "Options" -#: scripts/_lib/map_template.py:66 +#: scripts/_lib/map_template.py:72 msgid "Filtres actifs" msgstr "Filtres actifs" -#: scripts/_lib/map_template.py:67 +#: scripts/_lib/map_template.py:73 msgid "Tout sélectionner" msgstr "Tout sélectionner" -#: scripts/_lib/map_template.py:69 +#: scripts/_lib/map_template.py:75 #, python-brace-format msgid "{p} appellations · {s} dénominations géographiques complémentaires" msgstr "{p} appellations · {s} dénominations géographiques complémentaires" -#: scripts/_lib/map_template.py:71 +#: scripts/_lib/map_template.py:77 #, python-brace-format msgid "{p} appellations" msgstr "{p} appellations" -#: scripts/_lib/map_template.py:72 +#: scripts/_lib/map_template.py:78 #, python-brace-format msgid "Ouvrir la fiche de {name}" msgstr "Ouvrir la fiche de {name}" -#: scripts/_lib/map_template.py:73 +#: scripts/_lib/map_template.py:79 msgid "Ouvrir la fiche" msgstr "Ouvrir la fiche" -#: scripts/_lib/map_template.py:74 +#: scripts/_lib/map_template.py:80 msgid "Inclure les spiritueux" msgstr "Inclure les spiritueux" -#: scripts/_lib/map_template.py:75 +#: scripts/_lib/map_template.py:81 msgid "Style de vin" msgstr "Style de vin" -#: scripts/_lib/map_template.py:76 +#: scripts/_lib/map_template.py:82 msgid "Classement" msgstr "Classement" -#: scripts/_lib/map_template.py:77 +#: scripts/_lib/map_template.py:83 msgid "Cépages principaux" msgstr "Cépages principaux" -#: scripts/_lib/map_template.py:78 +#: scripts/_lib/map_template.py:84 msgid "Cépages accessoires" msgstr "Cépages accessoires" -#: scripts/_lib/map_template.py:79 scripts/_lib/map_template.py:299 -#: scripts/_lib/map_template.py:440 +#: scripts/_lib/map_template.py:85 scripts/_lib/map_template.py:312 +#: scripts/_lib/map_template.py:453 msgid "Cépages" msgstr "Cépages" -#: scripts/_lib/map_template.py:80 +#: scripts/_lib/map_template.py:86 msgid "Région" msgstr "Région" -#: scripts/_lib/map_template.py:81 +#: scripts/_lib/map_template.py:87 msgid "Appellation" msgstr "Appellation" -#: scripts/_lib/map_template.py:82 +#: scripts/_lib/map_template.py:88 msgid "Type" msgstr "Type" -#: scripts/_lib/map_template.py:83 +#: scripts/_lib/map_template.py:89 msgid "Origine protégée (AOP, AOC, DOC, DO…)" msgstr "Origine protégée (AOP, AOC, DOC, DO…)" -#: scripts/_lib/map_template.py:84 +#: scripts/_lib/map_template.py:90 msgid "Indication géographique (IGP, IGT, Landwein…)" msgstr "Indication géographique (IGP, IGT, Landwein…)" -#: scripts/_lib/map_template.py:85 +#: scripts/_lib/map_template.py:91 msgid "AOP" msgstr "AOP" -#: scripts/_lib/map_template.py:86 +#: scripts/_lib/map_template.py:92 msgid "IGP" msgstr "IGP" -#: scripts/_lib/map_template.py:87 +#: scripts/_lib/map_template.py:93 msgid "IG spiritueux" msgstr "IG spiritueux" -#: scripts/_lib/map_template.py:88 +#: scripts/_lib/map_template.py:94 msgid "Type d'appellation" msgstr "Type d'appellation" -#: scripts/_lib/map_template.py:89 +#: scripts/_lib/map_template.py:95 msgid "AOP (régime britannique)" msgstr "" -#: scripts/_lib/map_template.py:90 +#: scripts/_lib/map_template.py:96 msgid "IGP (régime britannique)" msgstr "" -#: scripts/_lib/map_template.py:91 +#: scripts/_lib/map_template.py:97 msgid "AOC (Suisse)" msgstr "" -#: scripts/_lib/map_template.py:92 +#: scripts/_lib/map_template.py:98 msgid "Source" msgstr "Source" -#: scripts/_lib/map_template.py:93 +#: scripts/_lib/map_template.py:99 msgid "Vue" msgstr "Vue" -#: scripts/_lib/map_template.py:94 +#: scripts/_lib/map_template.py:100 msgid "Simple" msgstr "Simple" -#: scripts/_lib/map_template.py:95 +#: scripts/_lib/map_template.py:101 msgid "Avancée" msgstr "Avancée" -#: scripts/_lib/map_template.py:96 +#: scripts/_lib/map_template.py:102 msgid "Thème" msgstr "Thème" -#: scripts/_lib/map_template.py:97 +#: scripts/_lib/map_template.py:103 msgid "Clair" msgstr "Clair" -#: scripts/_lib/map_template.py:98 +#: scripts/_lib/map_template.py:104 msgid "Sombre" msgstr "Sombre" -#: scripts/_lib/map_template.py:99 +#: scripts/_lib/map_template.py:105 msgid "Système" msgstr "Système" -#: scripts/_lib/map_template.py:100 +#: scripts/_lib/map_template.py:106 msgid "Afficher les IGP" msgstr "Afficher les IGP" -#: scripts/_lib/map_template.py:101 scripts/_lib/style_taxonomy.py:332 +#: scripts/_lib/map_template.py:107 scripts/_lib/style_taxonomy.py:332 #: scripts/_lib/style_taxonomy.py:361 msgid "rouge" msgstr "rouge" -#: scripts/_lib/map_template.py:102 scripts/_lib/style_taxonomy.py:332 +#: scripts/_lib/map_template.py:108 scripts/_lib/style_taxonomy.py:332 #: scripts/_lib/style_taxonomy.py:362 msgid "blanc" msgstr "blanc" -#: scripts/_lib/map_template.py:103 scripts/_lib/style_taxonomy.py:332 +#: scripts/_lib/map_template.py:109 scripts/_lib/style_taxonomy.py:332 #: scripts/_lib/style_taxonomy.py:363 msgid "rosé" msgstr "rosé" -#: scripts/_lib/map_template.py:104 scripts/_lib/style_taxonomy.py:332 +#: scripts/_lib/map_template.py:110 scripts/_lib/style_taxonomy.py:332 #: scripts/_lib/style_taxonomy.py:364 msgid "mousseux" msgstr "mousseux" -#: scripts/_lib/map_template.py:105 +#: scripts/_lib/map_template.py:111 msgid "moelleux / liquoreux" msgstr "moelleux / liquoreux" -#: scripts/_lib/map_template.py:106 scripts/_lib/style_taxonomy.py:345 +#: scripts/_lib/map_template.py:112 scripts/_lib/style_taxonomy.py:345 msgid "oxydatif" msgstr "oxydatif" -#: scripts/_lib/map_template.py:107 +#: scripts/_lib/map_template.py:113 msgid "autre" msgstr "autre" -#: scripts/_lib/map_template.py:108 +#: scripts/_lib/map_template.py:114 msgid "Réinitialiser" msgstr "Réinitialiser" -#: scripts/_lib/map_template.py:109 +#: scripts/_lib/map_template.py:115 #, python-brace-format msgid "{n} appellations" msgstr "{n} appellations" -#: scripts/_lib/map_template.py:110 +#: scripts/_lib/map_template.py:116 #, python-brace-format msgid "{n} / {total} appellations" msgstr "{n} / {total} appellations" -#: scripts/_lib/map_template.py:111 +#: scripts/_lib/map_template.py:117 #, python-brace-format msgid "{n} masquées dans les IGP · afficher" msgstr "{n} masquées dans les IGP · afficher" -#: scripts/_lib/map_template.py:112 +#: scripts/_lib/map_template.py:118 msgid "Fermer" msgstr "Fermer" -#: scripts/_lib/map_template.py:113 +#: scripts/_lib/map_template.py:119 msgid "Détails de l'appellation" msgstr "Détails de l'appellation" -#: scripts/_lib/map_template.py:114 +#: scripts/_lib/map_template.py:120 #, python-brace-format msgid "Retirer le filtre {label}" msgstr "Retirer le filtre {label}" -#: scripts/_lib/map_template.py:115 +#: scripts/_lib/map_template.py:121 msgid "Filtres et options de la carte" msgstr "Filtres et options de la carte" -#: scripts/_lib/map_template.py:116 +#: scripts/_lib/map_template.py:122 msgid "Langue" msgstr "Langue" -#: scripts/_lib/map_template.py:117 +#: scripts/_lib/map_template.py:123 msgid "Carte des appellations viticoles" msgstr "Carte des appellations viticoles" -#: scripts/_lib/map_template.py:118 +#: scripts/_lib/map_template.py:124 msgid "Aller à la carte" msgstr "Aller à la carte" -#: scripts/_lib/map_template.py:119 +#: scripts/_lib/map_template.py:125 msgid "Styles" msgstr "Styles" -#: scripts/_lib/map_template.py:120 +#: scripts/_lib/map_template.py:126 msgid "Variétés d'intérêt" msgstr "Variétés d'intérêt" -#: scripts/_lib/map_template.py:121 scripts/_lib/map_template.py:443 +#: scripts/_lib/map_template.py:127 scripts/_lib/map_template.py:456 msgid "Sources" msgstr "Sources" -#: scripts/_lib/map_template.py:122 scripts/_lib/map_template.py:441 +#: scripts/_lib/map_template.py:128 scripts/_lib/map_template.py:454 msgid "Terroir" msgstr "Terroir" -#: scripts/_lib/map_template.py:123 +#: scripts/_lib/map_template.py:129 #, python-brace-format msgid "Dűlők (lieux-dits) : {n}" msgstr "Dűlők (lieux-dits) : {n}" -#: scripts/_lib/map_template.py:124 +#: scripts/_lib/map_template.py:130 #, python-brace-format msgid "Menzioni geografiche aggiuntive (crus) : {n}" msgstr "Mentions géographiques complémentaires (crus) : {n}" -#: scripts/_lib/map_template.py:125 +#: scripts/_lib/map_template.py:131 msgid "Facteurs naturels" msgstr "Facteurs naturels" -#: scripts/_lib/map_template.py:126 +#: scripts/_lib/map_template.py:132 msgid "Facteurs humains" msgstr "Facteurs humains" -#: scripts/_lib/map_template.py:127 +#: scripts/_lib/map_template.py:133 msgid "Caractéristiques du produit" msgstr "Caractéristiques du produit" -#: scripts/_lib/map_template.py:128 +#: scripts/_lib/map_template.py:134 msgid "Lien terroir / vin" msgstr "Lien terroir / vin" -#: scripts/_lib/map_template.py:130 +#: scripts/_lib/map_template.py:136 #, python-brace-format msgid "" "Faits dégagés du Lien au terroir par interprétation automatique — voir la" @@ -355,53 +355,53 @@ msgstr "" "Faits dégagés du Lien au terroir par interprétation automatique — voir la" " {source}." -#: scripts/_lib/map_template.py:132 +#: scripts/_lib/map_template.py:138 msgid "source" msgstr "source" -#: scripts/_lib/map_template.py:133 +#: scripts/_lib/map_template.py:139 msgid "via Wikipedia · CC BY-SA 4.0" msgstr "via Wikipedia · CC BY-SA 4.0" -#: scripts/_lib/map_template.py:135 +#: scripts/_lib/map_template.py:141 #, python-brace-format msgid "Citation textuelle du Lien au terroir — voir la {source}." msgstr "Citation textuelle du Lien au terroir — voir la {source}." -#: scripts/_lib/map_template.py:137 +#: scripts/_lib/map_template.py:143 msgid "à vérifier — texte source court" msgstr "" -#: scripts/_lib/map_template.py:139 +#: scripts/_lib/map_template.py:145 #, python-brace-format msgid "" "Ces repères décrivent l'appellation englobante {parent} — pas " "spécifiquement cette dénomination." msgstr "" -#: scripts/_lib/map_template.py:142 +#: scripts/_lib/map_template.py:148 msgid "sans région" msgstr "sans région" -#: scripts/_lib/map_template.py:143 +#: scripts/_lib/map_template.py:149 #, python-brace-format msgid "{n} commune(s) INAO" msgstr "{n} commune(s) INAO" -#: scripts/_lib/map_template.py:144 +#: scripts/_lib/map_template.py:150 #, python-brace-format msgid "{n} commune(s)" msgstr "{n} commune(s)" -#: scripts/_lib/map_template.py:145 +#: scripts/_lib/map_template.py:151 msgid "aire approchée" msgstr "aire approchée" -#: scripts/_lib/map_template.py:146 +#: scripts/_lib/map_template.py:152 msgid "aire approchée (à l'échelle communale)" msgstr "aire approchée (à l'échelle communale)" -#: scripts/_lib/map_template.py:148 +#: scripts/_lib/map_template.py:154 #, python-brace-format msgid "" "Aire approchée — pas de données parcellaires précises pour cette " @@ -410,7 +410,7 @@ msgstr "" "Aire approchée — pas de données parcellaires précises pour cette " "dénomination ; polygone hérité de {umbrella}." -#: scripts/_lib/map_template.py:152 +#: scripts/_lib/map_template.py:158 msgid "" "Aire approchée — pas de données parcellaires précises pour cette " "dénomination ; polygone hérité de l'appellation parente." @@ -418,7 +418,7 @@ msgstr "" "Aire approchée — pas de données parcellaires précises pour cette " "dénomination ; polygone hérité de l'appellation parente." -#: scripts/_lib/map_template.py:156 +#: scripts/_lib/map_template.py:162 msgid "" "Aire approchée — pas de données parcellaires disponibles ; affichée comme" " l'emprise de la commune où se situe la dénomination." @@ -426,18 +426,18 @@ msgstr "" "Aire approchée — pas de données parcellaires disponibles ; affichée comme" " l'emprise de la commune où se situe la dénomination." -#: scripts/_lib/map_template.py:160 +#: scripts/_lib/map_template.py:166 #, python-brace-format msgid "" "Aire issue du lieu-dit cadastral « {lieu_dit} » (commune de {commune}, " "{source})." msgstr "" -#: scripts/_lib/map_template.py:163 +#: scripts/_lib/map_template.py:169 msgid "cadastre.data.gouv.fr" msgstr "" -#: scripts/_lib/map_template.py:165 +#: scripts/_lib/map_template.py:171 msgid "" "Aire approchée — reconstituée à partir des références parcellaires du " "plan de l'aire délimitée annexé au cahier des charges ; ce n'est pas une " @@ -447,14 +447,14 @@ msgstr "" "plan de l'aire délimitée annexé au cahier des charges ; ce n'est pas une " "limite officielle." -#: scripts/_lib/map_template.py:174 +#: scripts/_lib/map_template.py:180 #, python-brace-format msgid "" "Aire approchée — réunion des {n} commune(s) de l'aire délimitée (INAO) ; " "pas de délimitation parcellaire pour cette dénomination." msgstr "" -#: scripts/_lib/map_template.py:178 +#: scripts/_lib/map_template.py:184 #, python-brace-format msgid "" "Vue d'ensemble : silhouette du vignoble, généralisée à {radius} m à " @@ -462,7 +462,7 @@ msgid "" "même ; les parcelles apparaissent en zoomant." msgstr "" -#: scripts/_lib/map_template.py:183 +#: scripts/_lib/map_template.py:189 #, python-brace-format msgid "" "Sur la carte, aux zooms d'ensemble, cette appellation est dessinée comme " @@ -471,50 +471,50 @@ msgid "" " zoomant." msgstr "" -#: scripts/_lib/map_template.py:189 +#: scripts/_lib/map_template.py:195 #, python-brace-format msgid "Aire : zone de production officielle publiée par le géoportail ({region})." msgstr "" -#: scripts/_lib/map_template.py:193 +#: scripts/_lib/map_template.py:199 msgid "" "Aire : zone de production officielle du MAPA (Zonas de Calidad " "Diferenciada : Vinos)." msgstr "" -#: scripts/_lib/map_template.py:197 +#: scripts/_lib/map_template.py:203 msgid "" "Aire approchée — polygone du jeu de données Bétard 2022 (AOP de l'UE), à " "la résolution des communes." msgstr "" -#: scripts/_lib/map_template.py:201 +#: scripts/_lib/map_template.py:207 msgid "" "Aire approchée — réunion des polygones des appellations membres (Bétard " "2022), à la résolution des communes." msgstr "" -#: scripts/_lib/map_template.py:205 +#: scripts/_lib/map_template.py:211 msgid "" "Aire approchée — réunion des unités administratives nommées dans le " "document (limites Eurostat GISCO ou cadastre national)." msgstr "" -#: scripts/_lib/map_template.py:213 +#: scripts/_lib/map_template.py:219 #, python-brace-format msgid "" "Nommé(s) dans le document mais absent(s) de la couche communale, donc non" " tracé(s) : {names}." msgstr "" -#: scripts/_lib/map_template.py:217 +#: scripts/_lib/map_template.py:223 #, python-brace-format msgid "" "Localité(s) sans polygone propre, tracée(s) par l'unité administrative " "qui les contient : {names}." msgstr "" -#: scripts/_lib/map_template.py:221 +#: scripts/_lib/map_template.py:227 #, python-brace-format msgid "" "Aire approchée : le document délimite la zone par une ligne passant par " @@ -522,7 +522,7 @@ msgid "" "chacun à son point central (coordonnées Wikidata) : {names}." msgstr "" -#: scripts/_lib/map_template.py:227 +#: scripts/_lib/map_template.py:233 #, python-brace-format msgid "" "Aire approchée : le document délimite la zone par une ligne passant par " @@ -530,14 +530,14 @@ msgid "" "contiennent : {names}." msgstr "" -#: scripts/_lib/map_template.py:235 +#: scripts/_lib/map_template.py:241 #, python-brace-format msgid "" "Commune(s) de l'aire délimitée (INAO) sans délimitation parcellaire " "publiée dans la couche parcellaire INAO, tracée(s) entière(s) : {names}." msgstr "" -#: scripts/_lib/map_template.py:239 +#: scripts/_lib/map_template.py:245 #, python-brace-format msgid "" "Commune(s) de l'aire délimitée (INAO) sans délimitation parcellaire " @@ -545,21 +545,21 @@ msgid "" "la commune : {names}." msgstr "" -#: scripts/_lib/map_template.py:244 +#: scripts/_lib/map_template.py:250 #, python-brace-format msgid "" "Commune(s) de l'aire délimitée (INAO) sans délimitation parcellaire " "publiée dans la couche parcellaire INAO, non tracée(s) : {names}." msgstr "" -#: scripts/_lib/map_template.py:251 +#: scripts/_lib/map_template.py:257 #, python-brace-format msgid "" "Délimitation parcellaire de la publication INAO du {release} ; la " "publication du {current} ne porte pas cette dénomination." msgstr "" -#: scripts/_lib/map_template.py:251 +#: scripts/_lib/map_template.py:264 #, python-brace-format msgid "" "Aire complétée par {n} paroisse(s) civile(s) (parroquias) nommée(s) dans " @@ -567,7 +567,7 @@ msgid "" "({source}) : {names}." msgstr "" -#: scripts/_lib/map_template.py:260 +#: scripts/_lib/map_template.py:273 #, python-brace-format msgid "" "Aire : les {n} polígonos cadastraux SIGPAC que le document énumère dans " @@ -575,7 +575,7 @@ msgid "" "plantée relève de l'appellation ({source})." msgstr "" -#: scripts/_lib/map_template.py:265 +#: scripts/_lib/map_template.py:278 #, python-brace-format msgid "" "Aire : les parcelles de vigne (SIGPAC, usage VI) situées dans les {n} " @@ -583,17 +583,17 @@ msgid "" "({source})." msgstr "" -#: scripts/_lib/map_template.py:269 +#: scripts/_lib/map_template.py:282 #, python-brace-format msgid "S'y ajoutent, en totalité, les communes {whole}." msgstr "" -#: scripts/_lib/map_template.py:270 +#: scripts/_lib/map_template.py:283 #, python-brace-format msgid "{n} appellations à ce point" msgstr "{n} appellations à ce point" -#: scripts/_lib/map_template.py:272 +#: scripts/_lib/map_template.py:285 msgid "" "Cliquer à nouveau sur la carte pour parcourir les autres, ou sur une " "fiche pour l'afficher en premier" @@ -601,142 +601,142 @@ msgstr "" "Cliquer à nouveau sur la carte pour parcourir les autres, ou sur une " "fiche pour l'afficher en premier" -#: scripts/_lib/map_template.py:275 +#: scripts/_lib/map_template.py:288 msgid "Afficher cette appellation en premier" msgstr "Afficher cette appellation en premier" -#: scripts/_lib/map_template.py:276 +#: scripts/_lib/map_template.py:289 msgid "Cahier des charges (BO Agri, PDF)" msgstr "Cahier des charges (BO Agri, PDF)" -#: scripts/_lib/map_template.py:277 +#: scripts/_lib/map_template.py:290 msgid "Cahier des charges (registre GI de l'UE, PDF)" msgstr "" -#: scripts/_lib/map_template.py:278 +#: scripts/_lib/map_template.py:291 msgid "homologué" msgstr "homologué" -#: scripts/_lib/map_template.py:279 +#: scripts/_lib/map_template.py:292 msgid "JORF" msgstr "JORF" -#: scripts/_lib/map_template.py:280 +#: scripts/_lib/map_template.py:293 msgid "Texte officiel INAO (show_texte)" msgstr "Texte officiel INAO (show_texte)" -#: scripts/_lib/map_template.py:281 +#: scripts/_lib/map_template.py:294 msgid "Fiche produit INAO" msgstr "Fiche produit INAO" -#: scripts/_lib/map_template.py:282 +#: scripts/_lib/map_template.py:295 msgid "Site officiel de l'interprofession" msgstr "Site officiel de l'interprofession" -#: scripts/_lib/map_template.py:283 +#: scripts/_lib/map_template.py:296 msgid "Cahier des charges (EUR-Lex, document unique)" msgstr "Cahier des charges (EUR-Lex, document unique)" -#: scripts/_lib/map_template.py:284 +#: scripts/_lib/map_template.py:297 msgid "Pliego de condiciones (national, PDF)" msgstr "Pliego de condiciones (national, PDF)" -#: scripts/_lib/map_template.py:285 +#: scripts/_lib/map_template.py:298 msgid "variétés ajoutées" msgstr "variétés ajoutées" -#: scripts/_lib/map_template.py:286 +#: scripts/_lib/map_template.py:299 msgid "Cahier des charges national (PDF)" msgstr "Cahier des charges national (PDF)" -#: scripts/_lib/map_template.py:287 +#: scripts/_lib/map_template.py:300 msgid "Spécification du produit (IGP, PDF)" msgstr "Spécification du produit (IGP, PDF)" -#: scripts/_lib/map_template.py:288 +#: scripts/_lib/map_template.py:301 msgid "Registre régional des cépages (PDF)" msgstr "Registre régional des cépages (PDF)" -#: scripts/_lib/map_template.py:289 +#: scripts/_lib/map_template.py:302 msgid "Mapa de Parroquias de Galicia (IET, Xunta de Galicia)" msgstr "" -#: scripts/_lib/map_template.py:290 +#: scripts/_lib/map_template.py:303 msgid "Registre eAmbrosia (UE)" msgstr "Registre eAmbrosia (UE)" -#: scripts/_lib/map_template.py:291 +#: scripts/_lib/map_template.py:304 msgid "Numéro de dossier" msgstr "Numéro de dossier" -#: scripts/_lib/map_template.py:292 +#: scripts/_lib/map_template.py:305 msgid "Règlement cantonal sur la vigne et le vin" msgstr "Règlement cantonal sur la vigne et le vin" -#: scripts/_lib/map_template.py:293 +#: scripts/_lib/map_template.py:306 msgid "Répertoire suisse des AOC (OFAG/BLW)" msgstr "Répertoire suisse des AOC (OFAG/BLW)" -#: scripts/_lib/map_template.py:294 +#: scripts/_lib/map_template.py:307 msgid "Cahier des charges (GOV.UK, PDF)" msgstr "Cahier des charges (GOV.UK, PDF)" -#: scripts/_lib/map_template.py:295 +#: scripts/_lib/map_template.py:308 msgid "Registre des IG du Royaume-Uni" msgstr "Registre des IG du Royaume-Uni" -#: scripts/_lib/map_template.py:296 +#: scripts/_lib/map_template.py:309 msgid "Légende couleurs" msgstr "Légende couleurs" -#: scripts/_lib/map_template.py:297 +#: scripts/_lib/map_template.py:310 msgid "Bassin viticole" msgstr "Bassin viticole" -#: scripts/_lib/map_template.py:298 +#: scripts/_lib/map_template.py:311 msgid "Plus l'aire est petite, plus la teinte est dense." msgstr "Plus l'aire est petite, plus la teinte est dense." -#: scripts/_lib/map_template.py:300 +#: scripts/_lib/map_template.py:313 msgid "principal — variété de la cuvée" msgstr "principal — variété de la cuvée" -#: scripts/_lib/map_template.py:301 +#: scripts/_lib/map_template.py:314 msgid "accessoire — assemblage limité" msgstr "accessoire — assemblage limité" -#: scripts/_lib/map_template.py:302 +#: scripts/_lib/map_template.py:315 msgid "intérêt — observation/conservation" msgstr "intérêt — observation/conservation" -#: scripts/_lib/map_template.py:304 +#: scripts/_lib/map_template.py:317 msgid "" "Le régulateur portugais (IVV) n'établit pas de distinction " "principal/accessoire — toutes les castas autorisées sont listées ensemble" " dans le caderno de especificações." msgstr "" -#: scripts/_lib/map_template.py:308 +#: scripts/_lib/map_template.py:321 msgid "" "Bianchello (ou Biancame) est traité ici comme un cépage distinct, " "conformément au disciplinare de la DOP Bianchello del Metauro ; le " "catalogue VIVC le recense comme synonyme du Trebbiano Toscano." msgstr "" -#: scripts/_lib/map_template.py:313 +#: scripts/_lib/map_template.py:326 #, python-brace-format msgid "Open Wine Map n'a pas encore trouvé de {doc} pour cette appellation." msgstr "" -#: scripts/_lib/map_template.py:315 +#: scripts/_lib/map_template.py:328 msgid "aidez-nous à le trouver" msgstr "aidez-nous à le trouver" -#: scripts/_lib/map_template.py:319 +#: scripts/_lib/map_template.py:332 msgid "Annulée" msgstr "Annulée" -#: scripts/_lib/map_template.py:321 +#: scripts/_lib/map_template.py:334 #, python-brace-format msgid "" "Cette appellation a été annulée le {date} par {regulation}. Des " @@ -747,26 +747,26 @@ msgstr "" "bouteilles, étiquettes et supports de formation plus anciens peuvent " "encore la mentionner ; elle est conservée ici à titre de référence." -#: scripts/_lib/map_template.py:325 +#: scripts/_lib/map_template.py:338 #, python-brace-format msgid "Acte national : {act}." msgstr "Acte national : {act}." -#: scripts/_lib/map_template.py:326 +#: scripts/_lib/map_template.py:339 #, python-brace-format msgid "Elle est remplacée par {successor}." msgstr "Elle est remplacée par {successor}." -#: scripts/_lib/map_template.py:327 +#: scripts/_lib/map_template.py:340 #, python-brace-format msgid "Enregistrement annulé le {date}" msgstr "Enregistrement annulé le {date}" -#: scripts/_lib/map_template.py:331 +#: scripts/_lib/map_template.py:344 msgid "Promue" msgstr "" -#: scripts/_lib/map_template.py:333 +#: scripts/_lib/map_template.py:346 #, python-brace-format msgid "" "Cette dénomination est devenue l'appellation {successor} à part entière " @@ -774,157 +774,157 @@ msgid "" "l'ancien nom ; elle est conservée ici à titre de référence." msgstr "" -#: scripts/_lib/map_template.py:337 +#: scripts/_lib/map_template.py:350 #, python-brace-format msgid "Cahier des charges de la nouvelle appellation : {cahier}." msgstr "" -#: scripts/_lib/map_template.py:338 +#: scripts/_lib/map_template.py:351 msgid "BO Agri (PDF)" msgstr "" -#: scripts/_lib/map_template.py:339 +#: scripts/_lib/map_template.py:352 #, python-brace-format msgid "Devenue appellation à part entière le {date}" msgstr "" -#: scripts/_lib/map_template.py:345 +#: scripts/_lib/map_template.py:358 #, python-brace-format msgid "Délimitée par {regulator} dans son {doc}, qui autorise {grapes}." msgstr "" -#: scripts/_lib/map_template.py:347 +#: scripts/_lib/map_template.py:360 #, python-brace-format msgid "Délimitée par {regulator} dans son {doc}{extra}." msgstr "" -#: scripts/_lib/map_template.py:348 +#: scripts/_lib/map_template.py:361 #, python-brace-format msgid "{names} et {n} autres cépages" msgstr "" -#: scripts/_lib/map_template.py:349 +#: scripts/_lib/map_template.py:362 #, fuzzy msgid "le registre eAmbrosia de l'UE" msgstr "Registre eAmbrosia (UE)" -#: scripts/_lib/map_template.py:350 +#: scripts/_lib/map_template.py:363 #, python-brace-format msgid "le canton de {canton}" msgstr "" -#: scripts/_lib/map_template.py:351 +#: scripts/_lib/map_template.py:364 msgid "(français)" msgstr "(français)" -#: scripts/_lib/map_template.py:352 +#: scripts/_lib/map_template.py:365 msgid "Texte source en français" msgstr "Texte source en français" -#: scripts/_lib/map_template.py:353 +#: scripts/_lib/map_template.py:366 msgid "(español)" msgstr "(español)" -#: scripts/_lib/map_template.py:354 +#: scripts/_lib/map_template.py:367 msgid "Texte source en espagnol" msgstr "Texte source en espagnol" -#: scripts/_lib/map_template.py:355 +#: scripts/_lib/map_template.py:368 msgid "(português)" msgstr "" -#: scripts/_lib/map_template.py:356 +#: scripts/_lib/map_template.py:369 msgid "Texte source en portugais" msgstr "Texte source en portugais" -#: scripts/_lib/map_template.py:357 +#: scripts/_lib/map_template.py:370 msgid "Filtres" msgstr "Filtres" -#: scripts/_lib/map_template.py:358 +#: scripts/_lib/map_template.py:371 #, python-brace-format msgid "Traduit de {wiki} · CC BY-SA 4.0" msgstr "Traduit de {wiki} · CC BY-SA 4.0" -#: scripts/_lib/map_template.py:359 +#: scripts/_lib/map_template.py:372 msgid "Wikipédia en anglais" msgstr "Wikipédia en anglais" -#: scripts/_lib/map_template.py:360 +#: scripts/_lib/map_template.py:373 msgid "Wikipédia en français" msgstr "Wikipédia en français" -#: scripts/_lib/map_template.py:361 +#: scripts/_lib/map_template.py:374 msgid "Wikipédia en espagnol" msgstr "Wikipédia en espagnol" -#: scripts/_lib/map_template.py:362 +#: scripts/_lib/map_template.py:375 msgid "Wikipédia en néerlandais" msgstr "Wikipédia en néerlandais" -#: scripts/_lib/map_template.py:363 +#: scripts/_lib/map_template.py:376 msgid "Wikipédia en portugais" msgstr "Wikipédia en portugais" -#: scripts/_lib/map_template.py:364 +#: scripts/_lib/map_template.py:377 msgid "Wikipédia en croate" msgstr "Wikipédia en croate" -#: scripts/_lib/map_template.py:365 +#: scripts/_lib/map_template.py:378 msgid "Wikipédia en italien" msgstr "" -#: scripts/_lib/map_template.py:366 +#: scripts/_lib/map_template.py:379 msgid "Wikipédia en allemand" msgstr "" -#: scripts/_lib/map_template.py:367 +#: scripts/_lib/map_template.py:380 msgid "Wikipédia en slovène" msgstr "" -#: scripts/_lib/map_template.py:368 +#: scripts/_lib/map_template.py:381 msgid "Wikipédia en bulgare" msgstr "" -#: scripts/_lib/map_template.py:369 +#: scripts/_lib/map_template.py:382 msgid "Vitis International Variety Catalogue (Julius Kühn-Institut)" msgstr "" -#: scripts/_lib/map_template.py:370 +#: scripts/_lib/map_template.py:383 #, python-brace-format msgid "VIVC #{id}" msgstr "" -#: scripts/_lib/map_template.py:371 +#: scripts/_lib/map_template.py:384 #, python-brace-format msgid "Traduction automatique depuis {source}" msgstr "Traduction automatique depuis {source}" -#: scripts/_lib/map_template.py:372 +#: scripts/_lib/map_template.py:385 msgid "le cahier des charges" msgstr "le cahier des charges" -#: scripts/_lib/map_template.py:373 scripts/_lib/map_template.py:375 +#: scripts/_lib/map_template.py:386 scripts/_lib/map_template.py:388 msgid "le pliego de condiciones" msgstr "le pliego de condiciones" -#: scripts/_lib/map_template.py:374 scripts/_lib/map_template.py:376 +#: scripts/_lib/map_template.py:387 scripts/_lib/map_template.py:389 msgid "le caderno de especificações" msgstr "" -#: scripts/_lib/map_template.py:377 +#: scripts/_lib/map_template.py:390 msgid "Dénomination géographique complémentaire de" msgstr "Dénomination géographique complémentaire de" -#: scripts/_lib/map_template.py:378 +#: scripts/_lib/map_template.py:391 msgid "À propos" msgstr "À propos" -#: scripts/_lib/map_template.py:379 +#: scripts/_lib/map_template.py:392 msgid "À propos d'Open Wine Map" msgstr "À propos d'Open Wine Map" -#: scripts/_lib/map_template.py:381 +#: scripts/_lib/map_template.py:394 msgid "" "Carte de référence des appellations viticoles, générée automatiquement à " "partir des registres publics : le registre de l'Union européenne des AOP " @@ -936,7 +936,7 @@ msgstr "" "et IGP, les régulateurs nationaux, le répertoire fédéral suisse des AOC " "cantonales et le registre britannique des indications géographiques." -#: scripts/_lib/map_template.py:388 +#: scripts/_lib/map_template.py:401 msgid "" "Une partie du texte est produite par des modèles de langage : les repères" " de terroir sont dégagés du texte du régulateur par Claude, vérifiés " @@ -958,12 +958,12 @@ msgstr "" "automatiquement. Chaque élément porte sa propre ligne d'attribution dans " "le panneau." -#: scripts/_lib/map_template.py:397 +#: scripts/_lib/map_template.py:410 #, python-brace-format msgid "Réalisé avec ♡ par {devloed}." msgstr "Réalisé avec ♡ par {devloed}." -#: scripts/_lib/map_template.py:399 +#: scripts/_lib/map_template.py:412 #, python-brace-format msgid "" "Sources : INAO ({inao}) pour les cahiers des charges et les aires " @@ -988,7 +988,7 @@ msgstr "" "et al.). Tout extrait Wikipedia est signalé sur place. Détails et " "licences dans le {readme}." -#: scripts/_lib/map_template.py:410 +#: scripts/_lib/map_template.py:423 #, python-brace-format msgid "" "Niveau de détail : en dessous du zoom {zoom}, les appellations françaises" @@ -999,12 +999,12 @@ msgid "" "mêmes. Les autres appellations gardent le même contour à tous les zooms." msgstr "" -#: scripts/_lib/map_template.py:418 +#: scripts/_lib/map_template.py:431 #, python-brace-format msgid "Suggestions et pull requests bienvenues sur {github}." msgstr "Suggestions et pull requests bienvenues sur {github}." -#: scripts/_lib/map_template.py:420 +#: scripts/_lib/map_template.py:433 #, python-brace-format msgid "" "Vie privée : la fréquentation est mesurée avec une instance {plausible} " @@ -1021,20 +1021,20 @@ msgstr "" "adresse IP conservée, pas de suivi entre sites ni entre appareils — d'où " "l'absence de bandeau de consentement." -#: scripts/_lib/map_template.py:427 +#: scripts/_lib/map_template.py:440 #, python-brace-format msgid "Suivez les mises à jour sur {bluesky}." msgstr "Suivez les mises à jour sur {bluesky}." -#: scripts/_lib/map_template.py:428 +#: scripts/_lib/map_template.py:441 msgid "e-mail" msgstr "e-mail" -#: scripts/_lib/map_template.py:429 +#: scripts/_lib/map_template.py:442 msgid "E-mail copié dans le presse-papiers" msgstr "E-mail copié dans le presse-papiers" -#: scripts/_lib/map_template.py:431 +#: scripts/_lib/map_template.py:444 #, python-brace-format msgid "" "Carte générée automatiquement — des erreurs sont possibles. Signalez-les " @@ -1043,48 +1043,48 @@ msgstr "" "Carte générée automatiquement — des erreurs sont possibles. Signalez-les " "depuis la fiche de l'appellation, ou par {email}." -#: scripts/_lib/map_template.py:437 +#: scripts/_lib/map_template.py:450 msgid "Signaler une erreur" msgstr "Signaler une erreur" -#: scripts/_lib/map_template.py:438 +#: scripts/_lib/map_template.py:451 msgid "Un clic nous dit quoi vérifier — sans compte." msgstr "Un clic nous dit quoi vérifier — sans compte." -#: scripts/_lib/map_template.py:439 +#: scripts/_lib/map_template.py:452 msgid "Aire / contour" msgstr "Aire / contour" -#: scripts/_lib/map_template.py:442 +#: scripts/_lib/map_template.py:455 msgid "Nom / type" msgstr "Nom / type" -#: scripts/_lib/map_template.py:444 +#: scripts/_lib/map_template.py:457 msgid "Autre" msgstr "Autre" -#: scripts/_lib/map_template.py:445 +#: scripts/_lib/map_template.py:458 #, python-brace-format msgid "Merci, c'est noté : {aspect}." msgstr "Merci, c'est noté : {aspect}." -#: scripts/_lib/map_template.py:447 +#: scripts/_lib/map_template.py:460 msgid "Précisez si vous voulez (facultatif, sans données personnelles)" msgstr "Précisez si vous voulez (facultatif, sans données personnelles)" -#: scripts/_lib/map_template.py:449 +#: scripts/_lib/map_template.py:462 msgid "Envoyer" msgstr "Envoyer" -#: scripts/_lib/map_template.py:452 +#: scripts/_lib/map_template.py:465 msgid "Géométrie" msgstr "Géométrie" -#: scripts/_lib/map_template.py:453 +#: scripts/_lib/map_template.py:466 msgid "Envoyé, merci !" msgstr "Envoyé, merci !" -#: scripts/_lib/map_template.py:455 +#: scripts/_lib/map_template.py:468 #, python-brace-format msgid "" "{aspect} — votre navigateur bloque notre outil de mesure, envoyez plutôt " @@ -1093,7 +1093,7 @@ msgstr "" "{aspect} — votre navigateur bloque notre outil de mesure, envoyez plutôt " "un {email}." -#: scripts/_lib/map_template.py:459 +#: scripts/_lib/map_template.py:472 #, python-brace-format msgid "" "{c} pays européens cartographiés ({n} appellations : {parents} " @@ -1106,15 +1106,15 @@ msgstr "" "supplémentaires affineront la qualité des données et étendront la " "couverture au-delà de l'UE, de la Suisse et du Royaume-Uni." -#: scripts/_lib/map_template.py:464 +#: scripts/_lib/map_template.py:477 msgid "Toutes les appellations" msgstr "Toutes les appellations" -#: scripts/_lib/map_template.py:465 +#: scripts/_lib/map_template.py:478 msgid "Toutes les appellations viticoles — Open Wine Map" msgstr "Toutes les appellations viticoles — Open Wine Map" -#: scripts/_lib/map_template.py:467 +#: scripts/_lib/map_template.py:480 #, python-brace-format msgid "" "Liste des {n} appellations viticoles cartographiées sur Open Wine Map, " @@ -1123,7 +1123,7 @@ msgstr "" "Liste des {n} appellations viticoles cartographiées sur Open Wine Map, " "classées par pays : AOP et IGP de l'UE, AOC suisses et IG britanniques." -#: scripts/_lib/map_template.py:471 +#: scripts/_lib/map_template.py:484 #, python-brace-format msgid "" "Les {n} appellations ci-dessous sont classées par pays. Retour à la " @@ -1132,205 +1132,236 @@ msgstr "" "Les {n} appellations ci-dessous sont classées par pays. Retour à la " "{map_link}." -#: scripts/_lib/map_template.py:474 +#: scripts/_lib/map_template.py:487 msgid "carte interactive" msgstr "carte interactive" -#: scripts/_lib/map_template.py:475 +#: scripts/_lib/map_template.py:488 msgid "Liste des appellations par pays" msgstr "Liste des appellations par pays" -#: scripts/_lib/map_template.py:476 +#: scripts/_lib/map_template.py:489 msgid "Appellation parente" msgstr "Appellation parente" -#: scripts/_lib/map_template.py:477 +#: scripts/_lib/map_template.py:490 msgid "Dénominations rattachées" msgstr "Dénominations rattachées" -#: scripts/_lib/map_template.py:479 +#: scripts/_lib/map_template.py:492 #, python-brace-format msgid "Parcourir la liste complète des appellations : {browse_link}." msgstr "Parcourir la liste complète des appellations : {browse_link}." -#: scripts/_lib/map_template.py:481 +#: scripts/_lib/map_template.py:494 #, python-brace-format msgid "Données mises à jour le {date}." msgstr "Données mises à jour le {date}." -#: scripts/_lib/map_template.py:499 +#: scripts/_lib/map_template.py:496 +#, python-brace-format +msgid "" +"Votre navigateur parle déjà {webmcp} ? Chaque page de la carte lui " +"propose les mêmes outils. Sinon, aucune importance : c'est le serveur MCP" +" ci-dessus que votre assistant IA sait utiliser." +msgstr "Votre navigateur parle déjà {webmcp} ? Chaque page de la carte lui propose les mêmes outils. Sinon, aucune importance : c'est le serveur MCP ci-dessus que votre assistant IA sait utiliser." + +#: scripts/_lib/map_template.py:500 +msgid "Copier" +msgstr "Copier" + +#: scripts/_lib/map_template.py:501 +msgid "Copié" +msgstr "Copié" + +#: scripts/_lib/map_template.py:502 +msgid "Copier l'adresse du serveur MCP" +msgstr "Copier l'adresse du serveur MCP" + +#: scripts/_lib/map_template.py:504 +#, python-brace-format +msgid "" +"Les assistants IA peuvent interroger les appellations avec les mêmes " +"outils de recherche que la carte, via le serveur MCP {mcp_link} (sans " +"authentification ; à ajouter comme connecteur personnalisé)." +msgstr "" +"Les assistants IA peuvent interroger les appellations avec les mêmes " +"outils de recherche que la carte, via le serveur MCP {mcp_link} (sans " +"authentification ; à ajouter comme connecteur personnalisé)." + +#: scripts/_lib/map_template.py:525 msgid "BOURGOGNE" msgstr "Bourgogne" -#: scripts/_lib/map_template.py:500 +#: scripts/_lib/map_template.py:526 msgid "BEAUJOLAIS" msgstr "Beaujolais" -#: scripts/_lib/map_template.py:501 +#: scripts/_lib/map_template.py:527 msgid "JURA" msgstr "Jura" -#: scripts/_lib/map_template.py:502 +#: scripts/_lib/map_template.py:528 msgid "SAVOIE" msgstr "Savoie" -#: scripts/_lib/map_template.py:503 +#: scripts/_lib/map_template.py:529 msgid "BUGEY" msgstr "Bugey" -#: scripts/_lib/map_template.py:504 +#: scripts/_lib/map_template.py:530 msgid "ALSACE" msgstr "Alsace" -#: scripts/_lib/map_template.py:505 +#: scripts/_lib/map_template.py:531 msgid "LORRAINE" msgstr "Lorraine" -#: scripts/_lib/map_template.py:506 +#: scripts/_lib/map_template.py:532 msgid "VAL DE LOIRE" msgstr "Val de Loire" -#: scripts/_lib/map_template.py:507 +#: scripts/_lib/map_template.py:533 msgid "BORDEAUX" msgstr "Bordeaux" -#: scripts/_lib/map_template.py:508 +#: scripts/_lib/map_template.py:534 msgid "SUD-OUEST" msgstr "Sud-Ouest" -#: scripts/_lib/map_template.py:509 +#: scripts/_lib/map_template.py:535 msgid "VALLEE DU RHÔNE" msgstr "Vallée du Rhône" -#: scripts/_lib/map_template.py:510 +#: scripts/_lib/map_template.py:536 msgid "LANGUEDOC" msgstr "Languedoc" -#: scripts/_lib/map_template.py:511 +#: scripts/_lib/map_template.py:537 msgid "ROUSSILLON" msgstr "Roussillon" -#: scripts/_lib/map_template.py:512 +#: scripts/_lib/map_template.py:538 msgid "PROVENCE" msgstr "Provence" -#: scripts/_lib/map_template.py:513 +#: scripts/_lib/map_template.py:539 msgid "CORSE" msgstr "Corse" -#: scripts/_lib/map_template.py:514 +#: scripts/_lib/map_template.py:540 msgid "CHAMPAGNE" msgstr "Champagne" -#: scripts/_lib/map_template.py:515 +#: scripts/_lib/map_template.py:541 msgid "ILE-DE-FRANCE" msgstr "Île-de-France" -#: scripts/_lib/map_template.py:516 +#: scripts/_lib/map_template.py:542 msgid "NORMANDIE" msgstr "Normandie" -#: scripts/_lib/map_template.py:517 +#: scripts/_lib/map_template.py:543 msgid "BRETAGNE" msgstr "Bretagne" -#: scripts/_lib/map_template.py:518 +#: scripts/_lib/map_template.py:544 msgid "MAINE" msgstr "Maine" -#: scripts/_lib/map_template.py:519 +#: scripts/_lib/map_template.py:545 msgid "COGNAC" msgstr "Cognac" -#: scripts/_lib/map_template.py:520 +#: scripts/_lib/map_template.py:546 msgid "ARMAGNAC" msgstr "Armagnac" -#: scripts/_lib/map_template.py:521 +#: scripts/_lib/map_template.py:547 msgid "RHUM" msgstr "Rhum" -#: scripts/_lib/map_template.py:561 +#: scripts/_lib/map_template.py:587 msgid "France" msgstr "France" -#: scripts/_lib/map_template.py:562 +#: scripts/_lib/map_template.py:588 msgid "Espagne" msgstr "Espagne" -#: scripts/_lib/map_template.py:563 +#: scripts/_lib/map_template.py:589 msgid "Portugal" msgstr "Portugal" -#: scripts/_lib/map_template.py:564 +#: scripts/_lib/map_template.py:590 msgid "Italie" msgstr "Italie" -#: scripts/_lib/map_template.py:565 +#: scripts/_lib/map_template.py:591 msgid "Autriche" msgstr "Autriche" -#: scripts/_lib/map_template.py:566 +#: scripts/_lib/map_template.py:592 msgid "Slovénie" msgstr "Slovénie" -#: scripts/_lib/map_template.py:567 +#: scripts/_lib/map_template.py:593 msgid "Croatie" msgstr "Croatie" -#: scripts/_lib/map_template.py:568 +#: scripts/_lib/map_template.py:594 msgid "Hongrie" msgstr "Hongrie" -#: scripts/_lib/map_template.py:569 +#: scripts/_lib/map_template.py:595 msgid "Roumanie" msgstr "Roumanie" -#: scripts/_lib/map_template.py:570 +#: scripts/_lib/map_template.py:596 msgid "Bulgarie" msgstr "Bulgarie" -#: scripts/_lib/map_template.py:571 +#: scripts/_lib/map_template.py:597 msgid "Grèce" msgstr "Grèce" -#: scripts/_lib/map_template.py:572 +#: scripts/_lib/map_template.py:598 msgid "Allemagne" msgstr "Allemagne" -#: scripts/_lib/map_template.py:573 +#: scripts/_lib/map_template.py:599 msgid "Slovaquie" msgstr "Slovaquie" -#: scripts/_lib/map_template.py:574 +#: scripts/_lib/map_template.py:600 msgid "Suisse" msgstr "Suisse" -#: scripts/_lib/map_template.py:575 +#: scripts/_lib/map_template.py:601 msgid "Tchéquie" msgstr "Tchéquie" -#: scripts/_lib/map_template.py:576 +#: scripts/_lib/map_template.py:602 msgid "Luxembourg" msgstr "Luxembourg" -#: scripts/_lib/map_template.py:577 +#: scripts/_lib/map_template.py:603 msgid "Belgique" msgstr "Belgique" -#: scripts/_lib/map_template.py:578 +#: scripts/_lib/map_template.py:604 msgid "Pays-Bas" msgstr "Pays-Bas" -#: scripts/_lib/map_template.py:579 +#: scripts/_lib/map_template.py:605 msgid "Malte" msgstr "Malte" -#: scripts/_lib/map_template.py:580 +#: scripts/_lib/map_template.py:606 msgid "Chypre" msgstr "Chypre" -#: scripts/_lib/map_template.py:581 +#: scripts/_lib/map_template.py:607 msgid "Royaume-Uni" msgstr "Royaume-Uni" diff --git a/locale/messages.pot b/locale/messages.pot index 613180b..cc3cca8 100644 --- a/locale/messages.pot +++ b/locale/messages.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-10-03 09:59+0200\n" +"POT-Creation-Date: 2026-10-04 16:41+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -29,422 +29,422 @@ msgstr "" msgid "Sélection" msgstr "" -#: scripts/_lib/map_template.py:46 +#: scripts/_lib/map_template.py:52 msgid "Open Wine Map — carte des appellations viticoles d'Europe" msgstr "" -#: scripts/_lib/map_template.py:49 +#: scripts/_lib/map_template.py:55 #, python-brace-format msgid "{name}, carte du vignoble" msgstr "" -#: scripts/_lib/map_template.py:50 +#: scripts/_lib/map_template.py:56 msgid "carte des appellations viticoles" msgstr "" -#: scripts/_lib/map_template.py:52 +#: scripts/_lib/map_template.py:58 msgid "" "Carte interactive des appellations viticoles d'Europe : cépages, styles " "et terroir, d'après les registres officiels." msgstr "" -#: scripts/_lib/map_template.py:55 +#: scripts/_lib/map_template.py:61 msgid "Chargement…" msgstr "" -#: scripts/_lib/map_template.py:56 +#: scripts/_lib/map_template.py:62 msgid "Recherche" msgstr "" -#: scripts/_lib/map_template.py:57 +#: scripts/_lib/map_template.py:63 msgid "nom d'appellation…" msgstr "" -#: scripts/_lib/map_template.py:58 +#: scripts/_lib/map_template.py:64 msgid "Recherche d'appellation…" msgstr "" -#: scripts/_lib/map_template.py:60 +#: scripts/_lib/map_template.py:66 msgid "Rechercher une appellation, un cépage, une région…" msgstr "" -#: scripts/_lib/map_template.py:61 +#: scripts/_lib/map_template.py:67 msgid "Cépage principal uniquement" msgstr "" -#: scripts/_lib/map_template.py:62 +#: scripts/_lib/map_template.py:68 #, python-brace-format msgid "Aucun résultat pour « {q} »" msgstr "" -#: scripts/_lib/map_template.py:63 +#: scripts/_lib/map_template.py:69 msgid "IGP masquée · s'affiche à la sélection" msgstr "" -#: scripts/_lib/map_template.py:64 +#: scripts/_lib/map_template.py:70 msgid "Eau-de-vie masquée · s'affiche à la sélection" msgstr "" -#: scripts/_lib/map_template.py:65 +#: scripts/_lib/map_template.py:71 msgid "Options" msgstr "" -#: scripts/_lib/map_template.py:66 +#: scripts/_lib/map_template.py:72 msgid "Filtres actifs" msgstr "" -#: scripts/_lib/map_template.py:67 +#: scripts/_lib/map_template.py:73 msgid "Tout sélectionner" msgstr "" -#: scripts/_lib/map_template.py:69 +#: scripts/_lib/map_template.py:75 #, python-brace-format msgid "{p} appellations · {s} dénominations géographiques complémentaires" msgstr "" -#: scripts/_lib/map_template.py:71 +#: scripts/_lib/map_template.py:77 #, python-brace-format msgid "{p} appellations" msgstr "" -#: scripts/_lib/map_template.py:72 +#: scripts/_lib/map_template.py:78 #, python-brace-format msgid "Ouvrir la fiche de {name}" msgstr "" -#: scripts/_lib/map_template.py:73 +#: scripts/_lib/map_template.py:79 msgid "Ouvrir la fiche" msgstr "" -#: scripts/_lib/map_template.py:74 +#: scripts/_lib/map_template.py:80 msgid "Inclure les spiritueux" msgstr "" -#: scripts/_lib/map_template.py:75 +#: scripts/_lib/map_template.py:81 msgid "Style de vin" msgstr "" -#: scripts/_lib/map_template.py:76 +#: scripts/_lib/map_template.py:82 msgid "Classement" msgstr "" -#: scripts/_lib/map_template.py:77 +#: scripts/_lib/map_template.py:83 msgid "Cépages principaux" msgstr "" -#: scripts/_lib/map_template.py:78 +#: scripts/_lib/map_template.py:84 msgid "Cépages accessoires" msgstr "" -#: scripts/_lib/map_template.py:79 scripts/_lib/map_template.py:299 -#: scripts/_lib/map_template.py:440 +#: scripts/_lib/map_template.py:85 scripts/_lib/map_template.py:312 +#: scripts/_lib/map_template.py:453 msgid "Cépages" msgstr "" -#: scripts/_lib/map_template.py:80 +#: scripts/_lib/map_template.py:86 msgid "Région" msgstr "" -#: scripts/_lib/map_template.py:81 +#: scripts/_lib/map_template.py:87 msgid "Appellation" msgstr "" -#: scripts/_lib/map_template.py:82 +#: scripts/_lib/map_template.py:88 msgid "Type" msgstr "" -#: scripts/_lib/map_template.py:83 +#: scripts/_lib/map_template.py:89 msgid "Origine protégée (AOP, AOC, DOC, DO…)" msgstr "" -#: scripts/_lib/map_template.py:84 +#: scripts/_lib/map_template.py:90 msgid "Indication géographique (IGP, IGT, Landwein…)" msgstr "" -#: scripts/_lib/map_template.py:85 +#: scripts/_lib/map_template.py:91 msgid "AOP" msgstr "" -#: scripts/_lib/map_template.py:86 +#: scripts/_lib/map_template.py:92 msgid "IGP" msgstr "" -#: scripts/_lib/map_template.py:87 +#: scripts/_lib/map_template.py:93 msgid "IG spiritueux" msgstr "" -#: scripts/_lib/map_template.py:88 +#: scripts/_lib/map_template.py:94 msgid "Type d'appellation" msgstr "" -#: scripts/_lib/map_template.py:89 +#: scripts/_lib/map_template.py:95 msgid "AOP (régime britannique)" msgstr "" -#: scripts/_lib/map_template.py:90 +#: scripts/_lib/map_template.py:96 msgid "IGP (régime britannique)" msgstr "" -#: scripts/_lib/map_template.py:91 +#: scripts/_lib/map_template.py:97 msgid "AOC (Suisse)" msgstr "" -#: scripts/_lib/map_template.py:92 +#: scripts/_lib/map_template.py:98 msgid "Source" msgstr "" -#: scripts/_lib/map_template.py:93 +#: scripts/_lib/map_template.py:99 msgid "Vue" msgstr "" -#: scripts/_lib/map_template.py:94 +#: scripts/_lib/map_template.py:100 msgid "Simple" msgstr "" -#: scripts/_lib/map_template.py:95 +#: scripts/_lib/map_template.py:101 msgid "Avancée" msgstr "" -#: scripts/_lib/map_template.py:96 +#: scripts/_lib/map_template.py:102 msgid "Thème" msgstr "" -#: scripts/_lib/map_template.py:97 +#: scripts/_lib/map_template.py:103 msgid "Clair" msgstr "" -#: scripts/_lib/map_template.py:98 +#: scripts/_lib/map_template.py:104 msgid "Sombre" msgstr "" -#: scripts/_lib/map_template.py:99 +#: scripts/_lib/map_template.py:105 msgid "Système" msgstr "" -#: scripts/_lib/map_template.py:100 +#: scripts/_lib/map_template.py:106 msgid "Afficher les IGP" msgstr "" -#: scripts/_lib/map_template.py:101 scripts/_lib/style_taxonomy.py:332 +#: scripts/_lib/map_template.py:107 scripts/_lib/style_taxonomy.py:332 #: scripts/_lib/style_taxonomy.py:361 msgid "rouge" msgstr "" -#: scripts/_lib/map_template.py:102 scripts/_lib/style_taxonomy.py:332 +#: scripts/_lib/map_template.py:108 scripts/_lib/style_taxonomy.py:332 #: scripts/_lib/style_taxonomy.py:362 msgid "blanc" msgstr "" -#: scripts/_lib/map_template.py:103 scripts/_lib/style_taxonomy.py:332 +#: scripts/_lib/map_template.py:109 scripts/_lib/style_taxonomy.py:332 #: scripts/_lib/style_taxonomy.py:363 msgid "rosé" msgstr "" -#: scripts/_lib/map_template.py:104 scripts/_lib/style_taxonomy.py:332 +#: scripts/_lib/map_template.py:110 scripts/_lib/style_taxonomy.py:332 #: scripts/_lib/style_taxonomy.py:364 msgid "mousseux" msgstr "" -#: scripts/_lib/map_template.py:105 +#: scripts/_lib/map_template.py:111 msgid "moelleux / liquoreux" msgstr "" -#: scripts/_lib/map_template.py:106 scripts/_lib/style_taxonomy.py:345 +#: scripts/_lib/map_template.py:112 scripts/_lib/style_taxonomy.py:345 msgid "oxydatif" msgstr "" -#: scripts/_lib/map_template.py:107 +#: scripts/_lib/map_template.py:113 msgid "autre" msgstr "" -#: scripts/_lib/map_template.py:108 +#: scripts/_lib/map_template.py:114 msgid "Réinitialiser" msgstr "" -#: scripts/_lib/map_template.py:109 +#: scripts/_lib/map_template.py:115 #, python-brace-format msgid "{n} appellations" msgstr "" -#: scripts/_lib/map_template.py:110 +#: scripts/_lib/map_template.py:116 #, python-brace-format msgid "{n} / {total} appellations" msgstr "" -#: scripts/_lib/map_template.py:111 +#: scripts/_lib/map_template.py:117 #, python-brace-format msgid "{n} masquées dans les IGP · afficher" msgstr "" -#: scripts/_lib/map_template.py:112 +#: scripts/_lib/map_template.py:118 msgid "Fermer" msgstr "" -#: scripts/_lib/map_template.py:113 +#: scripts/_lib/map_template.py:119 msgid "Détails de l'appellation" msgstr "" -#: scripts/_lib/map_template.py:114 +#: scripts/_lib/map_template.py:120 #, python-brace-format msgid "Retirer le filtre {label}" msgstr "" -#: scripts/_lib/map_template.py:115 +#: scripts/_lib/map_template.py:121 msgid "Filtres et options de la carte" msgstr "" -#: scripts/_lib/map_template.py:116 +#: scripts/_lib/map_template.py:122 msgid "Langue" msgstr "" -#: scripts/_lib/map_template.py:117 +#: scripts/_lib/map_template.py:123 msgid "Carte des appellations viticoles" msgstr "" -#: scripts/_lib/map_template.py:118 +#: scripts/_lib/map_template.py:124 msgid "Aller à la carte" msgstr "" -#: scripts/_lib/map_template.py:119 +#: scripts/_lib/map_template.py:125 msgid "Styles" msgstr "" -#: scripts/_lib/map_template.py:120 +#: scripts/_lib/map_template.py:126 msgid "Variétés d'intérêt" msgstr "" -#: scripts/_lib/map_template.py:121 scripts/_lib/map_template.py:443 +#: scripts/_lib/map_template.py:127 scripts/_lib/map_template.py:456 msgid "Sources" msgstr "" -#: scripts/_lib/map_template.py:122 scripts/_lib/map_template.py:441 +#: scripts/_lib/map_template.py:128 scripts/_lib/map_template.py:454 msgid "Terroir" msgstr "" -#: scripts/_lib/map_template.py:123 +#: scripts/_lib/map_template.py:129 #, python-brace-format msgid "Dűlők (lieux-dits) : {n}" msgstr "" -#: scripts/_lib/map_template.py:124 +#: scripts/_lib/map_template.py:130 #, python-brace-format msgid "Menzioni geografiche aggiuntive (crus) : {n}" msgstr "" -#: scripts/_lib/map_template.py:125 +#: scripts/_lib/map_template.py:131 msgid "Facteurs naturels" msgstr "" -#: scripts/_lib/map_template.py:126 +#: scripts/_lib/map_template.py:132 msgid "Facteurs humains" msgstr "" -#: scripts/_lib/map_template.py:127 +#: scripts/_lib/map_template.py:133 msgid "Caractéristiques du produit" msgstr "" -#: scripts/_lib/map_template.py:128 +#: scripts/_lib/map_template.py:134 msgid "Lien terroir / vin" msgstr "" -#: scripts/_lib/map_template.py:130 +#: scripts/_lib/map_template.py:136 #, python-brace-format msgid "" "Faits dégagés du Lien au terroir par interprétation automatique — voir la" " {source}." msgstr "" -#: scripts/_lib/map_template.py:132 +#: scripts/_lib/map_template.py:138 msgid "source" msgstr "" -#: scripts/_lib/map_template.py:133 +#: scripts/_lib/map_template.py:139 msgid "via Wikipedia · CC BY-SA 4.0" msgstr "" -#: scripts/_lib/map_template.py:135 +#: scripts/_lib/map_template.py:141 #, python-brace-format msgid "Citation textuelle du Lien au terroir — voir la {source}." msgstr "" -#: scripts/_lib/map_template.py:137 +#: scripts/_lib/map_template.py:143 msgid "à vérifier — texte source court" msgstr "" -#: scripts/_lib/map_template.py:139 +#: scripts/_lib/map_template.py:145 #, python-brace-format msgid "" "Ces repères décrivent l'appellation englobante {parent} — pas " "spécifiquement cette dénomination." msgstr "" -#: scripts/_lib/map_template.py:142 +#: scripts/_lib/map_template.py:148 msgid "sans région" msgstr "" -#: scripts/_lib/map_template.py:143 +#: scripts/_lib/map_template.py:149 #, python-brace-format msgid "{n} commune(s) INAO" msgstr "" -#: scripts/_lib/map_template.py:144 +#: scripts/_lib/map_template.py:150 #, python-brace-format msgid "{n} commune(s)" msgstr "" -#: scripts/_lib/map_template.py:145 +#: scripts/_lib/map_template.py:151 msgid "aire approchée" msgstr "" -#: scripts/_lib/map_template.py:146 +#: scripts/_lib/map_template.py:152 msgid "aire approchée (à l'échelle communale)" msgstr "" -#: scripts/_lib/map_template.py:148 +#: scripts/_lib/map_template.py:154 #, python-brace-format msgid "" "Aire approchée — pas de données parcellaires précises pour cette " "dénomination ; polygone hérité de {umbrella}." msgstr "" -#: scripts/_lib/map_template.py:152 +#: scripts/_lib/map_template.py:158 msgid "" "Aire approchée — pas de données parcellaires précises pour cette " "dénomination ; polygone hérité de l'appellation parente." msgstr "" -#: scripts/_lib/map_template.py:156 +#: scripts/_lib/map_template.py:162 msgid "" "Aire approchée — pas de données parcellaires disponibles ; affichée comme" " l'emprise de la commune où se situe la dénomination." msgstr "" -#: scripts/_lib/map_template.py:160 +#: scripts/_lib/map_template.py:166 #, python-brace-format msgid "" "Aire issue du lieu-dit cadastral « {lieu_dit} » (commune de {commune}, " "{source})." msgstr "" -#: scripts/_lib/map_template.py:163 +#: scripts/_lib/map_template.py:169 msgid "cadastre.data.gouv.fr" msgstr "" -#: scripts/_lib/map_template.py:165 +#: scripts/_lib/map_template.py:171 msgid "" "Aire approchée — reconstituée à partir des références parcellaires du " "plan de l'aire délimitée annexé au cahier des charges ; ce n'est pas une " "limite officielle." msgstr "" -#: scripts/_lib/map_template.py:174 +#: scripts/_lib/map_template.py:180 #, python-brace-format msgid "" "Aire approchée — réunion des {n} commune(s) de l'aire délimitée (INAO) ; " "pas de délimitation parcellaire pour cette dénomination." msgstr "" -#: scripts/_lib/map_template.py:178 +#: scripts/_lib/map_template.py:184 #, python-brace-format msgid "" "Vue d'ensemble : silhouette du vignoble, généralisée à {radius} m à " @@ -452,7 +452,7 @@ msgid "" "même ; les parcelles apparaissent en zoomant." msgstr "" -#: scripts/_lib/map_template.py:183 +#: scripts/_lib/map_template.py:189 #, python-brace-format msgid "" "Sur la carte, aux zooms d'ensemble, cette appellation est dessinée comme " @@ -461,50 +461,50 @@ msgid "" " zoomant." msgstr "" -#: scripts/_lib/map_template.py:189 +#: scripts/_lib/map_template.py:195 #, python-brace-format msgid "Aire : zone de production officielle publiée par le géoportail ({region})." msgstr "" -#: scripts/_lib/map_template.py:193 +#: scripts/_lib/map_template.py:199 msgid "" "Aire : zone de production officielle du MAPA (Zonas de Calidad " "Diferenciada : Vinos)." msgstr "" -#: scripts/_lib/map_template.py:197 +#: scripts/_lib/map_template.py:203 msgid "" "Aire approchée — polygone du jeu de données Bétard 2022 (AOP de l'UE), à " "la résolution des communes." msgstr "" -#: scripts/_lib/map_template.py:201 +#: scripts/_lib/map_template.py:207 msgid "" "Aire approchée — réunion des polygones des appellations membres (Bétard " "2022), à la résolution des communes." msgstr "" -#: scripts/_lib/map_template.py:205 +#: scripts/_lib/map_template.py:211 msgid "" "Aire approchée — réunion des unités administratives nommées dans le " "document (limites Eurostat GISCO ou cadastre national)." msgstr "" -#: scripts/_lib/map_template.py:213 +#: scripts/_lib/map_template.py:219 #, python-brace-format msgid "" "Nommé(s) dans le document mais absent(s) de la couche communale, donc non" " tracé(s) : {names}." msgstr "" -#: scripts/_lib/map_template.py:217 +#: scripts/_lib/map_template.py:223 #, python-brace-format msgid "" "Localité(s) sans polygone propre, tracée(s) par l'unité administrative " "qui les contient : {names}." msgstr "" -#: scripts/_lib/map_template.py:221 +#: scripts/_lib/map_template.py:227 #, python-brace-format msgid "" "Aire approchée : le document délimite la zone par une ligne passant par " @@ -512,7 +512,7 @@ msgid "" "chacun à son point central (coordonnées Wikidata) : {names}." msgstr "" -#: scripts/_lib/map_template.py:227 +#: scripts/_lib/map_template.py:233 #, python-brace-format msgid "" "Aire approchée : le document délimite la zone par une ligne passant par " @@ -520,14 +520,14 @@ msgid "" "contiennent : {names}." msgstr "" -#: scripts/_lib/map_template.py:235 +#: scripts/_lib/map_template.py:241 #, python-brace-format msgid "" "Commune(s) de l'aire délimitée (INAO) sans délimitation parcellaire " "publiée dans la couche parcellaire INAO, tracée(s) entière(s) : {names}." msgstr "" -#: scripts/_lib/map_template.py:239 +#: scripts/_lib/map_template.py:245 #, python-brace-format msgid "" "Commune(s) de l'aire délimitée (INAO) sans délimitation parcellaire " @@ -535,21 +535,21 @@ msgid "" "la commune : {names}." msgstr "" -#: scripts/_lib/map_template.py:244 +#: scripts/_lib/map_template.py:250 #, python-brace-format msgid "" "Commune(s) de l'aire délimitée (INAO) sans délimitation parcellaire " "publiée dans la couche parcellaire INAO, non tracée(s) : {names}." msgstr "" -#: scripts/_lib/map_template.py:251 +#: scripts/_lib/map_template.py:257 #, python-brace-format msgid "" "Délimitation parcellaire de la publication INAO du {release} ; la " "publication du {current} ne porte pas cette dénomination." msgstr "" -#: scripts/_lib/map_template.py:251 +#: scripts/_lib/map_template.py:264 #, python-brace-format msgid "" "Aire complétée par {n} paroisse(s) civile(s) (parroquias) nommée(s) dans " @@ -557,7 +557,7 @@ msgid "" "({source}) : {names}." msgstr "" -#: scripts/_lib/map_template.py:260 +#: scripts/_lib/map_template.py:273 #, python-brace-format msgid "" "Aire : les {n} polígonos cadastraux SIGPAC que le document énumère dans " @@ -565,7 +565,7 @@ msgid "" "plantée relève de l'appellation ({source})." msgstr "" -#: scripts/_lib/map_template.py:265 +#: scripts/_lib/map_template.py:278 #, python-brace-format msgid "" "Aire : les parcelles de vigne (SIGPAC, usage VI) situées dans les {n} " @@ -573,158 +573,158 @@ msgid "" "({source})." msgstr "" -#: scripts/_lib/map_template.py:269 +#: scripts/_lib/map_template.py:282 #, python-brace-format msgid "S'y ajoutent, en totalité, les communes {whole}." msgstr "" -#: scripts/_lib/map_template.py:270 +#: scripts/_lib/map_template.py:283 #, python-brace-format msgid "{n} appellations à ce point" msgstr "" -#: scripts/_lib/map_template.py:272 +#: scripts/_lib/map_template.py:285 msgid "" "Cliquer à nouveau sur la carte pour parcourir les autres, ou sur une " "fiche pour l'afficher en premier" msgstr "" -#: scripts/_lib/map_template.py:275 +#: scripts/_lib/map_template.py:288 msgid "Afficher cette appellation en premier" msgstr "" -#: scripts/_lib/map_template.py:276 +#: scripts/_lib/map_template.py:289 msgid "Cahier des charges (BO Agri, PDF)" msgstr "" -#: scripts/_lib/map_template.py:277 +#: scripts/_lib/map_template.py:290 msgid "Cahier des charges (registre GI de l'UE, PDF)" msgstr "" -#: scripts/_lib/map_template.py:278 +#: scripts/_lib/map_template.py:291 msgid "homologué" msgstr "" -#: scripts/_lib/map_template.py:279 +#: scripts/_lib/map_template.py:292 msgid "JORF" msgstr "" -#: scripts/_lib/map_template.py:280 +#: scripts/_lib/map_template.py:293 msgid "Texte officiel INAO (show_texte)" msgstr "" -#: scripts/_lib/map_template.py:281 +#: scripts/_lib/map_template.py:294 msgid "Fiche produit INAO" msgstr "" -#: scripts/_lib/map_template.py:282 +#: scripts/_lib/map_template.py:295 msgid "Site officiel de l'interprofession" msgstr "" -#: scripts/_lib/map_template.py:283 +#: scripts/_lib/map_template.py:296 msgid "Cahier des charges (EUR-Lex, document unique)" msgstr "" -#: scripts/_lib/map_template.py:284 +#: scripts/_lib/map_template.py:297 msgid "Pliego de condiciones (national, PDF)" msgstr "" -#: scripts/_lib/map_template.py:285 +#: scripts/_lib/map_template.py:298 msgid "variétés ajoutées" msgstr "" -#: scripts/_lib/map_template.py:286 +#: scripts/_lib/map_template.py:299 msgid "Cahier des charges national (PDF)" msgstr "" -#: scripts/_lib/map_template.py:287 +#: scripts/_lib/map_template.py:300 msgid "Spécification du produit (IGP, PDF)" msgstr "" -#: scripts/_lib/map_template.py:288 +#: scripts/_lib/map_template.py:301 msgid "Registre régional des cépages (PDF)" msgstr "" -#: scripts/_lib/map_template.py:289 +#: scripts/_lib/map_template.py:302 msgid "Mapa de Parroquias de Galicia (IET, Xunta de Galicia)" msgstr "" -#: scripts/_lib/map_template.py:290 +#: scripts/_lib/map_template.py:303 msgid "Registre eAmbrosia (UE)" msgstr "" -#: scripts/_lib/map_template.py:291 +#: scripts/_lib/map_template.py:304 msgid "Numéro de dossier" msgstr "" -#: scripts/_lib/map_template.py:292 +#: scripts/_lib/map_template.py:305 msgid "Règlement cantonal sur la vigne et le vin" msgstr "" -#: scripts/_lib/map_template.py:293 +#: scripts/_lib/map_template.py:306 msgid "Répertoire suisse des AOC (OFAG/BLW)" msgstr "" -#: scripts/_lib/map_template.py:294 +#: scripts/_lib/map_template.py:307 msgid "Cahier des charges (GOV.UK, PDF)" msgstr "" -#: scripts/_lib/map_template.py:295 +#: scripts/_lib/map_template.py:308 msgid "Registre des IG du Royaume-Uni" msgstr "" -#: scripts/_lib/map_template.py:296 +#: scripts/_lib/map_template.py:309 msgid "Légende couleurs" msgstr "" -#: scripts/_lib/map_template.py:297 +#: scripts/_lib/map_template.py:310 msgid "Bassin viticole" msgstr "" -#: scripts/_lib/map_template.py:298 +#: scripts/_lib/map_template.py:311 msgid "Plus l'aire est petite, plus la teinte est dense." msgstr "" -#: scripts/_lib/map_template.py:300 +#: scripts/_lib/map_template.py:313 msgid "principal — variété de la cuvée" msgstr "" -#: scripts/_lib/map_template.py:301 +#: scripts/_lib/map_template.py:314 msgid "accessoire — assemblage limité" msgstr "" -#: scripts/_lib/map_template.py:302 +#: scripts/_lib/map_template.py:315 msgid "intérêt — observation/conservation" msgstr "" -#: scripts/_lib/map_template.py:304 +#: scripts/_lib/map_template.py:317 msgid "" "Le régulateur portugais (IVV) n'établit pas de distinction " "principal/accessoire — toutes les castas autorisées sont listées ensemble" " dans le caderno de especificações." msgstr "" -#: scripts/_lib/map_template.py:308 +#: scripts/_lib/map_template.py:321 msgid "" "Bianchello (ou Biancame) est traité ici comme un cépage distinct, " "conformément au disciplinare de la DOP Bianchello del Metauro ; le " "catalogue VIVC le recense comme synonyme du Trebbiano Toscano." msgstr "" -#: scripts/_lib/map_template.py:313 +#: scripts/_lib/map_template.py:326 #, python-brace-format msgid "Open Wine Map n'a pas encore trouvé de {doc} pour cette appellation." msgstr "" -#: scripts/_lib/map_template.py:315 +#: scripts/_lib/map_template.py:328 msgid "aidez-nous à le trouver" msgstr "" -#: scripts/_lib/map_template.py:319 +#: scripts/_lib/map_template.py:332 msgid "Annulée" msgstr "" -#: scripts/_lib/map_template.py:321 +#: scripts/_lib/map_template.py:334 #, python-brace-format msgid "" "Cette appellation a été annulée le {date} par {regulation}. Des " @@ -732,26 +732,26 @@ msgid "" "encore la mentionner ; elle est conservée ici à titre de référence." msgstr "" -#: scripts/_lib/map_template.py:325 +#: scripts/_lib/map_template.py:338 #, python-brace-format msgid "Acte national : {act}." msgstr "" -#: scripts/_lib/map_template.py:326 +#: scripts/_lib/map_template.py:339 #, python-brace-format msgid "Elle est remplacée par {successor}." msgstr "" -#: scripts/_lib/map_template.py:327 +#: scripts/_lib/map_template.py:340 #, python-brace-format msgid "Enregistrement annulé le {date}" msgstr "" -#: scripts/_lib/map_template.py:331 +#: scripts/_lib/map_template.py:344 msgid "Promue" msgstr "" -#: scripts/_lib/map_template.py:333 +#: scripts/_lib/map_template.py:346 #, python-brace-format msgid "" "Cette dénomination est devenue l'appellation {successor} à part entière " @@ -759,156 +759,156 @@ msgid "" "l'ancien nom ; elle est conservée ici à titre de référence." msgstr "" -#: scripts/_lib/map_template.py:337 +#: scripts/_lib/map_template.py:350 #, python-brace-format msgid "Cahier des charges de la nouvelle appellation : {cahier}." msgstr "" -#: scripts/_lib/map_template.py:338 +#: scripts/_lib/map_template.py:351 msgid "BO Agri (PDF)" msgstr "" -#: scripts/_lib/map_template.py:339 +#: scripts/_lib/map_template.py:352 #, python-brace-format msgid "Devenue appellation à part entière le {date}" msgstr "" -#: scripts/_lib/map_template.py:345 +#: scripts/_lib/map_template.py:358 #, python-brace-format msgid "Délimitée par {regulator} dans son {doc}, qui autorise {grapes}." msgstr "" -#: scripts/_lib/map_template.py:347 +#: scripts/_lib/map_template.py:360 #, python-brace-format msgid "Délimitée par {regulator} dans son {doc}{extra}." msgstr "" -#: scripts/_lib/map_template.py:348 +#: scripts/_lib/map_template.py:361 #, python-brace-format msgid "{names} et {n} autres cépages" msgstr "" -#: scripts/_lib/map_template.py:349 +#: scripts/_lib/map_template.py:362 msgid "le registre eAmbrosia de l'UE" msgstr "" -#: scripts/_lib/map_template.py:350 +#: scripts/_lib/map_template.py:363 #, python-brace-format msgid "le canton de {canton}" msgstr "" -#: scripts/_lib/map_template.py:351 +#: scripts/_lib/map_template.py:364 msgid "(français)" msgstr "" -#: scripts/_lib/map_template.py:352 +#: scripts/_lib/map_template.py:365 msgid "Texte source en français" msgstr "" -#: scripts/_lib/map_template.py:353 +#: scripts/_lib/map_template.py:366 msgid "(español)" msgstr "" -#: scripts/_lib/map_template.py:354 +#: scripts/_lib/map_template.py:367 msgid "Texte source en espagnol" msgstr "" -#: scripts/_lib/map_template.py:355 +#: scripts/_lib/map_template.py:368 msgid "(português)" msgstr "" -#: scripts/_lib/map_template.py:356 +#: scripts/_lib/map_template.py:369 msgid "Texte source en portugais" msgstr "" -#: scripts/_lib/map_template.py:357 +#: scripts/_lib/map_template.py:370 msgid "Filtres" msgstr "" -#: scripts/_lib/map_template.py:358 +#: scripts/_lib/map_template.py:371 #, python-brace-format msgid "Traduit de {wiki} · CC BY-SA 4.0" msgstr "" -#: scripts/_lib/map_template.py:359 +#: scripts/_lib/map_template.py:372 msgid "Wikipédia en anglais" msgstr "" -#: scripts/_lib/map_template.py:360 +#: scripts/_lib/map_template.py:373 msgid "Wikipédia en français" msgstr "" -#: scripts/_lib/map_template.py:361 +#: scripts/_lib/map_template.py:374 msgid "Wikipédia en espagnol" msgstr "" -#: scripts/_lib/map_template.py:362 +#: scripts/_lib/map_template.py:375 msgid "Wikipédia en néerlandais" msgstr "" -#: scripts/_lib/map_template.py:363 +#: scripts/_lib/map_template.py:376 msgid "Wikipédia en portugais" msgstr "" -#: scripts/_lib/map_template.py:364 +#: scripts/_lib/map_template.py:377 msgid "Wikipédia en croate" msgstr "" -#: scripts/_lib/map_template.py:365 +#: scripts/_lib/map_template.py:378 msgid "Wikipédia en italien" msgstr "" -#: scripts/_lib/map_template.py:366 +#: scripts/_lib/map_template.py:379 msgid "Wikipédia en allemand" msgstr "" -#: scripts/_lib/map_template.py:367 +#: scripts/_lib/map_template.py:380 msgid "Wikipédia en slovène" msgstr "" -#: scripts/_lib/map_template.py:368 +#: scripts/_lib/map_template.py:381 msgid "Wikipédia en bulgare" msgstr "" -#: scripts/_lib/map_template.py:369 +#: scripts/_lib/map_template.py:382 msgid "Vitis International Variety Catalogue (Julius Kühn-Institut)" msgstr "" -#: scripts/_lib/map_template.py:370 +#: scripts/_lib/map_template.py:383 #, python-brace-format msgid "VIVC #{id}" msgstr "" -#: scripts/_lib/map_template.py:371 +#: scripts/_lib/map_template.py:384 #, python-brace-format msgid "Traduction automatique depuis {source}" msgstr "" -#: scripts/_lib/map_template.py:372 +#: scripts/_lib/map_template.py:385 msgid "le cahier des charges" msgstr "" -#: scripts/_lib/map_template.py:373 scripts/_lib/map_template.py:375 +#: scripts/_lib/map_template.py:386 scripts/_lib/map_template.py:388 msgid "le pliego de condiciones" msgstr "" -#: scripts/_lib/map_template.py:374 scripts/_lib/map_template.py:376 +#: scripts/_lib/map_template.py:387 scripts/_lib/map_template.py:389 msgid "le caderno de especificações" msgstr "" -#: scripts/_lib/map_template.py:377 +#: scripts/_lib/map_template.py:390 msgid "Dénomination géographique complémentaire de" msgstr "" -#: scripts/_lib/map_template.py:378 +#: scripts/_lib/map_template.py:391 msgid "À propos" msgstr "" -#: scripts/_lib/map_template.py:379 +#: scripts/_lib/map_template.py:392 msgid "À propos d'Open Wine Map" msgstr "" -#: scripts/_lib/map_template.py:381 +#: scripts/_lib/map_template.py:394 msgid "" "Carte de référence des appellations viticoles, générée automatiquement à " "partir des registres publics : le registre de l'Union européenne des AOP " @@ -916,7 +916,7 @@ msgid "" "cantonales et le registre britannique des indications géographiques." msgstr "" -#: scripts/_lib/map_template.py:388 +#: scripts/_lib/map_template.py:401 msgid "" "Une partie du texte est produite par des modèles de langage : les repères" " de terroir sont dégagés du texte du régulateur par Claude, vérifiés " @@ -929,12 +929,12 @@ msgid "" "le panneau." msgstr "" -#: scripts/_lib/map_template.py:397 +#: scripts/_lib/map_template.py:410 #, python-brace-format msgid "Réalisé avec ♡ par {devloed}." msgstr "" -#: scripts/_lib/map_template.py:399 +#: scripts/_lib/map_template.py:412 #, python-brace-format msgid "" "Sources : INAO ({inao}) pour les cahiers des charges et les aires " @@ -949,7 +949,7 @@ msgid "" "licences dans le {readme}." msgstr "" -#: scripts/_lib/map_template.py:410 +#: scripts/_lib/map_template.py:423 #, python-brace-format msgid "" "Niveau de détail : en dessous du zoom {zoom}, les appellations françaises" @@ -960,12 +960,12 @@ msgid "" "mêmes. Les autres appellations gardent le même contour à tous les zooms." msgstr "" -#: scripts/_lib/map_template.py:418 +#: scripts/_lib/map_template.py:431 #, python-brace-format msgid "Suggestions et pull requests bienvenues sur {github}." msgstr "" -#: scripts/_lib/map_template.py:420 +#: scripts/_lib/map_template.py:433 #, python-brace-format msgid "" "Vie privée : la fréquentation est mesurée avec une instance {plausible} " @@ -976,75 +976,75 @@ msgid "" "l'absence de bandeau de consentement." msgstr "" -#: scripts/_lib/map_template.py:427 +#: scripts/_lib/map_template.py:440 #, python-brace-format msgid "Suivez les mises à jour sur {bluesky}." msgstr "" -#: scripts/_lib/map_template.py:428 +#: scripts/_lib/map_template.py:441 msgid "e-mail" msgstr "" -#: scripts/_lib/map_template.py:429 +#: scripts/_lib/map_template.py:442 msgid "E-mail copié dans le presse-papiers" msgstr "" -#: scripts/_lib/map_template.py:431 +#: scripts/_lib/map_template.py:444 #, python-brace-format msgid "" "Carte générée automatiquement — des erreurs sont possibles. Signalez-les " "depuis la fiche de l'appellation, ou par {email}." msgstr "" -#: scripts/_lib/map_template.py:437 +#: scripts/_lib/map_template.py:450 msgid "Signaler une erreur" msgstr "" -#: scripts/_lib/map_template.py:438 +#: scripts/_lib/map_template.py:451 msgid "Un clic nous dit quoi vérifier — sans compte." msgstr "" -#: scripts/_lib/map_template.py:439 +#: scripts/_lib/map_template.py:452 msgid "Aire / contour" msgstr "" -#: scripts/_lib/map_template.py:442 +#: scripts/_lib/map_template.py:455 msgid "Nom / type" msgstr "" -#: scripts/_lib/map_template.py:444 +#: scripts/_lib/map_template.py:457 msgid "Autre" msgstr "" -#: scripts/_lib/map_template.py:445 +#: scripts/_lib/map_template.py:458 #, python-brace-format msgid "Merci, c'est noté : {aspect}." msgstr "" -#: scripts/_lib/map_template.py:447 +#: scripts/_lib/map_template.py:460 msgid "Précisez si vous voulez (facultatif, sans données personnelles)" msgstr "" -#: scripts/_lib/map_template.py:449 +#: scripts/_lib/map_template.py:462 msgid "Envoyer" msgstr "" -#: scripts/_lib/map_template.py:452 +#: scripts/_lib/map_template.py:465 msgid "Géométrie" msgstr "" -#: scripts/_lib/map_template.py:453 +#: scripts/_lib/map_template.py:466 msgid "Envoyé, merci !" msgstr "" -#: scripts/_lib/map_template.py:455 +#: scripts/_lib/map_template.py:468 #, python-brace-format msgid "" "{aspect} — votre navigateur bloque notre outil de mesure, envoyez plutôt " "un {email}." msgstr "" -#: scripts/_lib/map_template.py:459 +#: scripts/_lib/map_template.py:472 #, python-brace-format msgid "" "{c} pays européens cartographiés ({n} appellations : {parents} " @@ -1053,227 +1053,255 @@ msgid "" "couverture au-delà de l'UE, de la Suisse et du Royaume-Uni." msgstr "" -#: scripts/_lib/map_template.py:464 +#: scripts/_lib/map_template.py:477 msgid "Toutes les appellations" msgstr "" -#: scripts/_lib/map_template.py:465 +#: scripts/_lib/map_template.py:478 msgid "Toutes les appellations viticoles — Open Wine Map" msgstr "" -#: scripts/_lib/map_template.py:467 +#: scripts/_lib/map_template.py:480 #, python-brace-format msgid "" "Liste des {n} appellations viticoles cartographiées sur Open Wine Map, " "classées par pays : AOP et IGP de l'UE, AOC suisses et IG britanniques." msgstr "" -#: scripts/_lib/map_template.py:471 +#: scripts/_lib/map_template.py:484 #, python-brace-format msgid "" "Les {n} appellations ci-dessous sont classées par pays. Retour à la " "{map_link}." msgstr "" -#: scripts/_lib/map_template.py:474 +#: scripts/_lib/map_template.py:487 msgid "carte interactive" msgstr "" -#: scripts/_lib/map_template.py:475 +#: scripts/_lib/map_template.py:488 msgid "Liste des appellations par pays" msgstr "" -#: scripts/_lib/map_template.py:476 +#: scripts/_lib/map_template.py:489 msgid "Appellation parente" msgstr "" -#: scripts/_lib/map_template.py:477 +#: scripts/_lib/map_template.py:490 msgid "Dénominations rattachées" msgstr "" -#: scripts/_lib/map_template.py:479 +#: scripts/_lib/map_template.py:492 #, python-brace-format msgid "Parcourir la liste complète des appellations : {browse_link}." msgstr "" -#: scripts/_lib/map_template.py:481 +#: scripts/_lib/map_template.py:494 #, python-brace-format msgid "Données mises à jour le {date}." msgstr "" -#: scripts/_lib/map_template.py:499 -msgid "BOURGOGNE" +#: scripts/_lib/map_template.py:496 +#, python-brace-format +msgid "" +"Votre navigateur parle déjà {webmcp} ? Chaque page de la carte lui " +"propose les mêmes outils. Sinon, aucune importance : c'est le serveur MCP" +" ci-dessus que votre assistant IA sait utiliser." msgstr "" #: scripts/_lib/map_template.py:500 -msgid "BEAUJOLAIS" +msgid "Copier" msgstr "" #: scripts/_lib/map_template.py:501 -msgid "JURA" +msgid "Copié" msgstr "" #: scripts/_lib/map_template.py:502 +msgid "Copier l'adresse du serveur MCP" +msgstr "" + +#: scripts/_lib/map_template.py:504 +#, python-brace-format +msgid "" +"Les assistants IA peuvent interroger les appellations avec les mêmes " +"outils de recherche que la carte, via le serveur MCP {mcp_link} (sans " +"authentification ; à ajouter comme connecteur personnalisé)." +msgstr "" + +#: scripts/_lib/map_template.py:525 +msgid "BOURGOGNE" +msgstr "" + +#: scripts/_lib/map_template.py:526 +msgid "BEAUJOLAIS" +msgstr "" + +#: scripts/_lib/map_template.py:527 +msgid "JURA" +msgstr "" + +#: scripts/_lib/map_template.py:528 msgid "SAVOIE" msgstr "" -#: scripts/_lib/map_template.py:503 +#: scripts/_lib/map_template.py:529 msgid "BUGEY" msgstr "" -#: scripts/_lib/map_template.py:504 +#: scripts/_lib/map_template.py:530 msgid "ALSACE" msgstr "" -#: scripts/_lib/map_template.py:505 +#: scripts/_lib/map_template.py:531 msgid "LORRAINE" msgstr "" -#: scripts/_lib/map_template.py:506 +#: scripts/_lib/map_template.py:532 msgid "VAL DE LOIRE" msgstr "" -#: scripts/_lib/map_template.py:507 +#: scripts/_lib/map_template.py:533 msgid "BORDEAUX" msgstr "" -#: scripts/_lib/map_template.py:508 +#: scripts/_lib/map_template.py:534 msgid "SUD-OUEST" msgstr "" -#: scripts/_lib/map_template.py:509 +#: scripts/_lib/map_template.py:535 msgid "VALLEE DU RHÔNE" msgstr "" -#: scripts/_lib/map_template.py:510 +#: scripts/_lib/map_template.py:536 msgid "LANGUEDOC" msgstr "" -#: scripts/_lib/map_template.py:511 +#: scripts/_lib/map_template.py:537 msgid "ROUSSILLON" msgstr "" -#: scripts/_lib/map_template.py:512 +#: scripts/_lib/map_template.py:538 msgid "PROVENCE" msgstr "" -#: scripts/_lib/map_template.py:513 +#: scripts/_lib/map_template.py:539 msgid "CORSE" msgstr "" -#: scripts/_lib/map_template.py:514 +#: scripts/_lib/map_template.py:540 msgid "CHAMPAGNE" msgstr "" -#: scripts/_lib/map_template.py:515 +#: scripts/_lib/map_template.py:541 msgid "ILE-DE-FRANCE" msgstr "" -#: scripts/_lib/map_template.py:516 +#: scripts/_lib/map_template.py:542 msgid "NORMANDIE" msgstr "" -#: scripts/_lib/map_template.py:517 +#: scripts/_lib/map_template.py:543 msgid "BRETAGNE" msgstr "" -#: scripts/_lib/map_template.py:518 +#: scripts/_lib/map_template.py:544 msgid "MAINE" msgstr "" -#: scripts/_lib/map_template.py:519 +#: scripts/_lib/map_template.py:545 msgid "COGNAC" msgstr "" -#: scripts/_lib/map_template.py:520 +#: scripts/_lib/map_template.py:546 msgid "ARMAGNAC" msgstr "" -#: scripts/_lib/map_template.py:521 +#: scripts/_lib/map_template.py:547 msgid "RHUM" msgstr "" -#: scripts/_lib/map_template.py:561 +#: scripts/_lib/map_template.py:587 msgid "France" msgstr "" -#: scripts/_lib/map_template.py:562 +#: scripts/_lib/map_template.py:588 msgid "Espagne" msgstr "" -#: scripts/_lib/map_template.py:563 +#: scripts/_lib/map_template.py:589 msgid "Portugal" msgstr "" -#: scripts/_lib/map_template.py:564 +#: scripts/_lib/map_template.py:590 msgid "Italie" msgstr "" -#: scripts/_lib/map_template.py:565 +#: scripts/_lib/map_template.py:591 msgid "Autriche" msgstr "" -#: scripts/_lib/map_template.py:566 +#: scripts/_lib/map_template.py:592 msgid "Slovénie" msgstr "" -#: scripts/_lib/map_template.py:567 +#: scripts/_lib/map_template.py:593 msgid "Croatie" msgstr "" -#: scripts/_lib/map_template.py:568 +#: scripts/_lib/map_template.py:594 msgid "Hongrie" msgstr "" -#: scripts/_lib/map_template.py:569 +#: scripts/_lib/map_template.py:595 msgid "Roumanie" msgstr "" -#: scripts/_lib/map_template.py:570 +#: scripts/_lib/map_template.py:596 msgid "Bulgarie" msgstr "" -#: scripts/_lib/map_template.py:571 +#: scripts/_lib/map_template.py:597 msgid "Grèce" msgstr "" -#: scripts/_lib/map_template.py:572 +#: scripts/_lib/map_template.py:598 msgid "Allemagne" msgstr "" -#: scripts/_lib/map_template.py:573 +#: scripts/_lib/map_template.py:599 msgid "Slovaquie" msgstr "" -#: scripts/_lib/map_template.py:574 +#: scripts/_lib/map_template.py:600 msgid "Suisse" msgstr "" -#: scripts/_lib/map_template.py:575 +#: scripts/_lib/map_template.py:601 msgid "Tchéquie" msgstr "" -#: scripts/_lib/map_template.py:576 +#: scripts/_lib/map_template.py:602 msgid "Luxembourg" msgstr "" -#: scripts/_lib/map_template.py:577 +#: scripts/_lib/map_template.py:603 msgid "Belgique" msgstr "" -#: scripts/_lib/map_template.py:578 +#: scripts/_lib/map_template.py:604 msgid "Pays-Bas" msgstr "" -#: scripts/_lib/map_template.py:579 +#: scripts/_lib/map_template.py:605 msgid "Malte" msgstr "" -#: scripts/_lib/map_template.py:580 +#: scripts/_lib/map_template.py:606 msgid "Chypre" msgstr "" -#: scripts/_lib/map_template.py:581 +#: scripts/_lib/map_template.py:607 msgid "Royaume-Uni" msgstr "" diff --git a/locale/nl/LC_MESSAGES/messages.po b/locale/nl/LC_MESSAGES/messages.po index 0080aad..5f2c074 100644 --- a/locale/nl/LC_MESSAGES/messages.po +++ b/locale/nl/LC_MESSAGES/messages.po @@ -4,7 +4,7 @@ msgid "" msgstr "" "Project-Id-Version: open-wine-map\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2026-10-03 09:59+0200\n" +"POT-Creation-Date: 2026-10-04 16:41+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: nl\n" @@ -27,20 +27,20 @@ msgstr "Prädikat" msgid "Sélection" msgstr "Selectie" -#: scripts/_lib/map_template.py:46 +#: scripts/_lib/map_template.py:52 msgid "Open Wine Map — carte des appellations viticoles d'Europe" msgstr "Open Wine Map — interactieve kaart van Europese wijnappellaties" -#: scripts/_lib/map_template.py:49 +#: scripts/_lib/map_template.py:55 #, python-brace-format msgid "{name}, carte du vignoble" msgstr "{name}, kaart van het wijngebied" -#: scripts/_lib/map_template.py:50 +#: scripts/_lib/map_template.py:56 msgid "carte des appellations viticoles" msgstr "Kaart van wijnappellaties" -#: scripts/_lib/map_template.py:52 +#: scripts/_lib/map_template.py:58 msgid "" "Carte interactive des appellations viticoles d'Europe : cépages, styles " "et terroir, d'après les registres officiels." @@ -48,306 +48,306 @@ msgstr "" "Interactieve kaart van de wijnappellaties van Europa: druivenrassen, " "stijlen en terroir, uit de officiële registers." -#: scripts/_lib/map_template.py:55 +#: scripts/_lib/map_template.py:61 msgid "Chargement…" msgstr "Laden…" -#: scripts/_lib/map_template.py:56 +#: scripts/_lib/map_template.py:62 msgid "Recherche" msgstr "Zoeken" -#: scripts/_lib/map_template.py:57 +#: scripts/_lib/map_template.py:63 msgid "nom d'appellation…" msgstr "naam van appellatie…" -#: scripts/_lib/map_template.py:58 +#: scripts/_lib/map_template.py:64 msgid "Recherche d'appellation…" msgstr "Appellatie zoeken…" -#: scripts/_lib/map_template.py:60 +#: scripts/_lib/map_template.py:66 msgid "Rechercher une appellation, un cépage, une région…" msgstr "Zoek een appellatie, druif of streek…" -#: scripts/_lib/map_template.py:61 +#: scripts/_lib/map_template.py:67 msgid "Cépage principal uniquement" msgstr "Alleen hoofddruif" -#: scripts/_lib/map_template.py:62 +#: scripts/_lib/map_template.py:68 #, python-brace-format msgid "Aucun résultat pour « {q} »" msgstr "Geen resultaten voor ‘{q}’" -#: scripts/_lib/map_template.py:63 +#: scripts/_lib/map_template.py:69 msgid "IGP masquée · s'affiche à la sélection" msgstr "Verborgen BGA · verschijnt bij selectie" -#: scripts/_lib/map_template.py:64 +#: scripts/_lib/map_template.py:70 msgid "Eau-de-vie masquée · s'affiche à la sélection" msgstr "Verborgen eau-de-vie · verschijnt bij selectie" -#: scripts/_lib/map_template.py:65 +#: scripts/_lib/map_template.py:71 msgid "Options" msgstr "Opties" -#: scripts/_lib/map_template.py:66 +#: scripts/_lib/map_template.py:72 msgid "Filtres actifs" msgstr "Actieve filters" -#: scripts/_lib/map_template.py:67 +#: scripts/_lib/map_template.py:73 msgid "Tout sélectionner" msgstr "Alles selecteren" -#: scripts/_lib/map_template.py:69 +#: scripts/_lib/map_template.py:75 #, python-brace-format msgid "{p} appellations · {s} dénominations géographiques complémentaires" msgstr "{p} appellaties · {s} aanvullende geografische benamingen" -#: scripts/_lib/map_template.py:71 +#: scripts/_lib/map_template.py:77 #, python-brace-format msgid "{p} appellations" msgstr "{p} appellaties" -#: scripts/_lib/map_template.py:72 +#: scripts/_lib/map_template.py:78 #, python-brace-format msgid "Ouvrir la fiche de {name}" msgstr "Open de fiche van {name}" -#: scripts/_lib/map_template.py:73 +#: scripts/_lib/map_template.py:79 msgid "Ouvrir la fiche" msgstr "Open fiche" -#: scripts/_lib/map_template.py:74 +#: scripts/_lib/map_template.py:80 msgid "Inclure les spiritueux" msgstr "Gedistilleerd tonen" -#: scripts/_lib/map_template.py:75 +#: scripts/_lib/map_template.py:81 msgid "Style de vin" msgstr "Wijnstijl" -#: scripts/_lib/map_template.py:76 +#: scripts/_lib/map_template.py:82 msgid "Classement" msgstr "Classificatie" -#: scripts/_lib/map_template.py:77 +#: scripts/_lib/map_template.py:83 msgid "Cépages principaux" msgstr "Hoofddruivenrassen" -#: scripts/_lib/map_template.py:78 +#: scripts/_lib/map_template.py:84 msgid "Cépages accessoires" msgstr "Aanvullende druivenrassen" -#: scripts/_lib/map_template.py:79 scripts/_lib/map_template.py:299 -#: scripts/_lib/map_template.py:440 +#: scripts/_lib/map_template.py:85 scripts/_lib/map_template.py:312 +#: scripts/_lib/map_template.py:453 msgid "Cépages" msgstr "Druivenrassen" -#: scripts/_lib/map_template.py:80 +#: scripts/_lib/map_template.py:86 msgid "Région" msgstr "Regio" -#: scripts/_lib/map_template.py:81 +#: scripts/_lib/map_template.py:87 msgid "Appellation" msgstr "Appellatie" -#: scripts/_lib/map_template.py:82 +#: scripts/_lib/map_template.py:88 msgid "Type" msgstr "Type" -#: scripts/_lib/map_template.py:83 +#: scripts/_lib/map_template.py:89 msgid "Origine protégée (AOP, AOC, DOC, DO…)" msgstr "Beschermde oorsprong (BOB, AOC, DOC, DO…)" -#: scripts/_lib/map_template.py:84 +#: scripts/_lib/map_template.py:90 msgid "Indication géographique (IGP, IGT, Landwein…)" msgstr "Geografische aanduiding (BGA, IGT, Landwein…)" -#: scripts/_lib/map_template.py:85 +#: scripts/_lib/map_template.py:91 msgid "AOP" msgstr "BOB" -#: scripts/_lib/map_template.py:86 +#: scripts/_lib/map_template.py:92 msgid "IGP" msgstr "BGA" -#: scripts/_lib/map_template.py:87 +#: scripts/_lib/map_template.py:93 msgid "IG spiritueux" msgstr "GA gedistilleerde drank" -#: scripts/_lib/map_template.py:88 +#: scripts/_lib/map_template.py:94 msgid "Type d'appellation" msgstr "Type appellatie" -#: scripts/_lib/map_template.py:89 +#: scripts/_lib/map_template.py:95 msgid "AOP (régime britannique)" msgstr "BOB (Britse regeling)" -#: scripts/_lib/map_template.py:90 +#: scripts/_lib/map_template.py:96 msgid "IGP (régime britannique)" msgstr "BGA (Britse regeling)" -#: scripts/_lib/map_template.py:91 +#: scripts/_lib/map_template.py:97 msgid "AOC (Suisse)" msgstr "AOC (Zwitserland)" -#: scripts/_lib/map_template.py:92 +#: scripts/_lib/map_template.py:98 msgid "Source" msgstr "Bron" -#: scripts/_lib/map_template.py:93 +#: scripts/_lib/map_template.py:99 msgid "Vue" msgstr "Weergave" -#: scripts/_lib/map_template.py:94 +#: scripts/_lib/map_template.py:100 msgid "Simple" msgstr "Eenvoudig" -#: scripts/_lib/map_template.py:95 +#: scripts/_lib/map_template.py:101 msgid "Avancée" msgstr "Geavanceerd" -#: scripts/_lib/map_template.py:96 +#: scripts/_lib/map_template.py:102 msgid "Thème" msgstr "Thema" -#: scripts/_lib/map_template.py:97 +#: scripts/_lib/map_template.py:103 msgid "Clair" msgstr "Licht" -#: scripts/_lib/map_template.py:98 +#: scripts/_lib/map_template.py:104 msgid "Sombre" msgstr "Donker" -#: scripts/_lib/map_template.py:99 +#: scripts/_lib/map_template.py:105 msgid "Système" msgstr "Systeem" -#: scripts/_lib/map_template.py:100 +#: scripts/_lib/map_template.py:106 msgid "Afficher les IGP" msgstr "BGA's tonen" -#: scripts/_lib/map_template.py:101 scripts/_lib/style_taxonomy.py:332 +#: scripts/_lib/map_template.py:107 scripts/_lib/style_taxonomy.py:332 #: scripts/_lib/style_taxonomy.py:361 msgid "rouge" msgstr "rood" -#: scripts/_lib/map_template.py:102 scripts/_lib/style_taxonomy.py:332 +#: scripts/_lib/map_template.py:108 scripts/_lib/style_taxonomy.py:332 #: scripts/_lib/style_taxonomy.py:362 msgid "blanc" msgstr "wit" -#: scripts/_lib/map_template.py:103 scripts/_lib/style_taxonomy.py:332 +#: scripts/_lib/map_template.py:109 scripts/_lib/style_taxonomy.py:332 #: scripts/_lib/style_taxonomy.py:363 msgid "rosé" msgstr "rosé" -#: scripts/_lib/map_template.py:104 scripts/_lib/style_taxonomy.py:332 +#: scripts/_lib/map_template.py:110 scripts/_lib/style_taxonomy.py:332 #: scripts/_lib/style_taxonomy.py:364 msgid "mousseux" msgstr "mousserend" -#: scripts/_lib/map_template.py:105 +#: scripts/_lib/map_template.py:111 msgid "moelleux / liquoreux" msgstr "zoet / dessertwijn" -#: scripts/_lib/map_template.py:106 scripts/_lib/style_taxonomy.py:345 +#: scripts/_lib/map_template.py:112 scripts/_lib/style_taxonomy.py:345 msgid "oxydatif" msgstr "oxidatief" -#: scripts/_lib/map_template.py:107 +#: scripts/_lib/map_template.py:113 msgid "autre" msgstr "andere" -#: scripts/_lib/map_template.py:108 +#: scripts/_lib/map_template.py:114 msgid "Réinitialiser" msgstr "Resetten" -#: scripts/_lib/map_template.py:109 +#: scripts/_lib/map_template.py:115 #, python-brace-format msgid "{n} appellations" msgstr "{n} appellaties" -#: scripts/_lib/map_template.py:110 +#: scripts/_lib/map_template.py:116 #, python-brace-format msgid "{n} / {total} appellations" msgstr "{n} / {total} appellaties" -#: scripts/_lib/map_template.py:111 +#: scripts/_lib/map_template.py:117 #, python-brace-format msgid "{n} masquées dans les IGP · afficher" msgstr "{n} verborgen in BGA's · tonen" -#: scripts/_lib/map_template.py:112 +#: scripts/_lib/map_template.py:118 msgid "Fermer" msgstr "Sluiten" -#: scripts/_lib/map_template.py:113 +#: scripts/_lib/map_template.py:119 msgid "Détails de l'appellation" msgstr "Details van de appellatie" -#: scripts/_lib/map_template.py:114 +#: scripts/_lib/map_template.py:120 #, python-brace-format msgid "Retirer le filtre {label}" msgstr "Filter {label} verwijderen" -#: scripts/_lib/map_template.py:115 +#: scripts/_lib/map_template.py:121 msgid "Filtres et options de la carte" msgstr "Filters en kaartopties" -#: scripts/_lib/map_template.py:116 +#: scripts/_lib/map_template.py:122 msgid "Langue" msgstr "Taal" -#: scripts/_lib/map_template.py:117 +#: scripts/_lib/map_template.py:123 msgid "Carte des appellations viticoles" msgstr "Kaart van wijnappellaties" -#: scripts/_lib/map_template.py:118 +#: scripts/_lib/map_template.py:124 msgid "Aller à la carte" msgstr "Naar de kaart" -#: scripts/_lib/map_template.py:119 +#: scripts/_lib/map_template.py:125 msgid "Styles" msgstr "Stijlen" -#: scripts/_lib/map_template.py:120 +#: scripts/_lib/map_template.py:126 msgid "Variétés d'intérêt" msgstr "Variëteiten van belang" -#: scripts/_lib/map_template.py:121 scripts/_lib/map_template.py:443 +#: scripts/_lib/map_template.py:127 scripts/_lib/map_template.py:456 msgid "Sources" msgstr "Bronnen" -#: scripts/_lib/map_template.py:122 scripts/_lib/map_template.py:441 +#: scripts/_lib/map_template.py:128 scripts/_lib/map_template.py:454 msgid "Terroir" msgstr "Terroir" -#: scripts/_lib/map_template.py:123 +#: scripts/_lib/map_template.py:129 #, python-brace-format msgid "Dűlők (lieux-dits) : {n}" msgstr "Dűlők (wijngaarden): {n}" -#: scripts/_lib/map_template.py:124 +#: scripts/_lib/map_template.py:130 #, python-brace-format msgid "Menzioni geografiche aggiuntive (crus) : {n}" msgstr "Aanvullende geografische aanduidingen (crus): {n}" -#: scripts/_lib/map_template.py:125 +#: scripts/_lib/map_template.py:131 msgid "Facteurs naturels" msgstr "Natuurlijke factoren" -#: scripts/_lib/map_template.py:126 +#: scripts/_lib/map_template.py:132 msgid "Facteurs humains" msgstr "Menselijke factoren" -#: scripts/_lib/map_template.py:127 +#: scripts/_lib/map_template.py:133 msgid "Caractéristiques du produit" msgstr "Producteigenschappen" -#: scripts/_lib/map_template.py:128 +#: scripts/_lib/map_template.py:134 msgid "Lien terroir / vin" msgstr "Verband terroir / wijn" -#: scripts/_lib/map_template.py:130 +#: scripts/_lib/map_template.py:136 #, python-brace-format msgid "" "Faits dégagés du Lien au terroir par interprétation automatique — voir la" @@ -356,26 +356,26 @@ msgstr "" "Feiten afgeleid uit de terroir-bandsectie (Lien au terroir) door " "automatische interpretatie — zie de {source}." -#: scripts/_lib/map_template.py:132 +#: scripts/_lib/map_template.py:138 msgid "source" msgstr "bron" -#: scripts/_lib/map_template.py:133 +#: scripts/_lib/map_template.py:139 msgid "via Wikipedia · CC BY-SA 4.0" msgstr "via Wikipedia · CC BY-SA 4.0" -#: scripts/_lib/map_template.py:135 +#: scripts/_lib/map_template.py:141 #, python-brace-format msgid "Citation textuelle du Lien au terroir — voir la {source}." msgstr "" "Letterlijk citaat uit de terroir-bandsectie (Lien au terroir) — zie de " "{source}." -#: scripts/_lib/map_template.py:137 +#: scripts/_lib/map_template.py:143 msgid "à vérifier — texte source court" msgstr "te controleren — korte brontekst" -#: scripts/_lib/map_template.py:139 +#: scripts/_lib/map_template.py:145 #, python-brace-format msgid "" "Ces repères décrivent l'appellation englobante {parent} — pas " @@ -384,29 +384,29 @@ msgstr "" "Deze kenmerken beschrijven de overkoepelende appellatie {parent} — niet " "specifiek deze denominatie." -#: scripts/_lib/map_template.py:142 +#: scripts/_lib/map_template.py:148 msgid "sans région" msgstr "geen regio" -#: scripts/_lib/map_template.py:143 +#: scripts/_lib/map_template.py:149 #, python-brace-format msgid "{n} commune(s) INAO" msgstr "{n} INAO-gemeente(n)" -#: scripts/_lib/map_template.py:144 +#: scripts/_lib/map_template.py:150 #, python-brace-format msgid "{n} commune(s)" msgstr "{n} gemeente(n)" -#: scripts/_lib/map_template.py:145 +#: scripts/_lib/map_template.py:151 msgid "aire approchée" msgstr "benaderd gebied" -#: scripts/_lib/map_template.py:146 +#: scripts/_lib/map_template.py:152 msgid "aire approchée (à l'échelle communale)" msgstr "benaderd gebied (gemeenteniveau)" -#: scripts/_lib/map_template.py:148 +#: scripts/_lib/map_template.py:154 #, python-brace-format msgid "" "Aire approchée — pas de données parcellaires précises pour cette " @@ -415,7 +415,7 @@ msgstr "" "Benaderd gebied — geen perceelnauwkeurige data beschikbaar voor deze " "benaming; polygoon overgenomen van {umbrella}." -#: scripts/_lib/map_template.py:152 +#: scripts/_lib/map_template.py:158 msgid "" "Aire approchée — pas de données parcellaires précises pour cette " "dénomination ; polygone hérité de l'appellation parente." @@ -423,7 +423,7 @@ msgstr "" "Benaderd gebied — geen perceelnauwkeurige data beschikbaar voor deze " "benaming; polygoon overgenomen van de bovenliggende appellatie." -#: scripts/_lib/map_template.py:156 +#: scripts/_lib/map_template.py:162 msgid "" "Aire approchée — pas de données parcellaires disponibles ; affichée comme" " l'emprise de la commune où se situe la dénomination." @@ -431,7 +431,7 @@ msgstr "" "Benaderd gebied — geen perceelgegevens beschikbaar; weergegeven als de " "omtrek van de gemeente waarin de benaming ligt." -#: scripts/_lib/map_template.py:160 +#: scripts/_lib/map_template.py:166 #, python-brace-format msgid "" "Aire issue du lieu-dit cadastral « {lieu_dit} » (commune de {commune}, " @@ -440,11 +440,11 @@ msgstr "" "Gebied afgeleid van het kadastrale gehucht „{lieu_dit}” (gemeente " "{commune}, {source})." -#: scripts/_lib/map_template.py:163 +#: scripts/_lib/map_template.py:169 msgid "cadastre.data.gouv.fr" msgstr "cadastre.data.gouv.fr" -#: scripts/_lib/map_template.py:165 +#: scripts/_lib/map_template.py:171 msgid "" "Aire approchée — reconstituée à partir des références parcellaires du " "plan de l'aire délimitée annexé au cahier des charges ; ce n'est pas une " @@ -454,7 +454,7 @@ msgstr "" "het plan van het afgebakende gebied bij het productdossier; dit is geen " "officiële grens." -#: scripts/_lib/map_template.py:174 +#: scripts/_lib/map_template.py:180 #, python-brace-format msgid "" "Aire approchée — réunion des {n} commune(s) de l'aire délimitée (INAO) ; " @@ -464,7 +464,7 @@ msgstr "" "afgebakende gebied (INAO); voor deze benaming bestaat geen " "perceelsgewijze afbakening." -#: scripts/_lib/map_template.py:178 +#: scripts/_lib/map_template.py:184 #, python-brace-format msgid "" "Vue d'ensemble : silhouette du vignoble, généralisée à {radius} m à " @@ -475,7 +475,7 @@ msgstr "" "basis van de perceelsafbakening van het INAO — niet de afbakening zelf; " "de percelen verschijnen bij inzoomen." -#: scripts/_lib/map_template.py:183 +#: scripts/_lib/map_template.py:189 #, python-brace-format msgid "" "Sur la carte, aux zooms d'ensemble, cette appellation est dessinée comme " @@ -488,14 +488,14 @@ msgstr "" "perceelsafbakening van het INAO; de percelen zelf verschijnen bij " "inzoomen." -#: scripts/_lib/map_template.py:189 +#: scripts/_lib/map_template.py:195 #, python-brace-format msgid "Aire : zone de production officielle publiée par le géoportail ({region})." msgstr "" "Gebied: de officiële productiezone gepubliceerd door het regionale " "geoportaal ({region})." -#: scripts/_lib/map_template.py:193 +#: scripts/_lib/map_template.py:199 msgid "" "Aire : zone de production officielle du MAPA (Zonas de Calidad " "Diferenciada : Vinos)." @@ -503,7 +503,7 @@ msgstr "" "Gebied: de officiële productiezone van het MAPA (Zonas de Calidad " "Diferenciada: Vinos)." -#: scripts/_lib/map_template.py:197 +#: scripts/_lib/map_template.py:203 msgid "" "Aire approchée — polygone du jeu de données Bétard 2022 (AOP de l'UE), à " "la résolution des communes." @@ -511,7 +511,7 @@ msgstr "" "Benaderd gebied — polygoon uit de dataset Bétard 2022 (EU-BOB's), op " "gemeenteniveau." -#: scripts/_lib/map_template.py:201 +#: scripts/_lib/map_template.py:207 msgid "" "Aire approchée — réunion des polygones des appellations membres (Bétard " "2022), à la résolution des communes." @@ -519,7 +519,7 @@ msgstr "" "Benaderd gebied — de vereniging van de polygonen van de deelnemende " "appellations (Bétard 2022), op gemeenteniveau." -#: scripts/_lib/map_template.py:205 +#: scripts/_lib/map_template.py:211 msgid "" "Aire approchée — réunion des unités administratives nommées dans le " "document (limites Eurostat GISCO ou cadastre national)." @@ -528,7 +528,7 @@ msgstr "" "bestuurlijke eenheden (grenzen van Eurostat GISCO of het nationale " "kadaster)." -#: scripts/_lib/map_template.py:213 +#: scripts/_lib/map_template.py:219 #, python-brace-format msgid "" "Nommé(s) dans le document mais absent(s) de la couche communale, donc non" @@ -537,7 +537,7 @@ msgstr "" "In het document genoemd maar niet in de gemeentelaag aanwezig, dus niet " "getekend: {names}." -#: scripts/_lib/map_template.py:217 +#: scripts/_lib/map_template.py:223 #, python-brace-format msgid "" "Localité(s) sans polygone propre, tracée(s) par l'unité administrative " @@ -546,7 +546,7 @@ msgstr "" "Plaats(en) zonder eigen polygoon, getekend als de bestuurlijke eenheid " "die ze bevat: {names}." -#: scripts/_lib/map_template.py:221 +#: scripts/_lib/map_template.py:227 #, python-brace-format msgid "" "Aire approchée : le document délimite la zone par une ligne passant par " @@ -557,7 +557,7 @@ msgstr "" "genoemde plaatsen; hier getekend als de veelhoek die deze plaatsen " "omschrijven, elk op zijn middelpunt (Wikidata-coördinaten): {names}." -#: scripts/_lib/map_template.py:227 +#: scripts/_lib/map_template.py:233 #, python-brace-format msgid "" "Aire approchée : le document délimite la zone par une ligne passant par " @@ -568,7 +568,7 @@ msgstr "" "genoemde plaatsen, hier getekend als de bestuurlijke eenheden die ze " "bevatten: {names}." -#: scripts/_lib/map_template.py:235 +#: scripts/_lib/map_template.py:241 #, python-brace-format msgid "" "Commune(s) de l'aire délimitée (INAO) sans délimitation parcellaire " @@ -578,7 +578,7 @@ msgstr "" "perceelsafbakening in de INAO-perceellaag, in hun geheel getekend: " "{names}." -#: scripts/_lib/map_template.py:239 +#: scripts/_lib/map_template.py:245 #, python-brace-format msgid "" "Commune(s) de l'aire délimitée (INAO) sans délimitation parcellaire " @@ -589,7 +589,7 @@ msgstr "" "perceelsafbakening voor deze benaming, getekend uit de {donor}-percelen " "van de gemeente: {names}." -#: scripts/_lib/map_template.py:244 +#: scripts/_lib/map_template.py:250 #, python-brace-format msgid "" "Commune(s) de l'aire délimitée (INAO) sans délimitation parcellaire " @@ -598,15 +598,16 @@ msgstr "" "Gemeente(n) van het afgebakende gebied (INAO) zonder gepubliceerde " "perceelsafbakening in de INAO-perceellaag, niet getekend: {names}." -#: scripts/_lib/map_template.py:251 +#: scripts/_lib/map_template.py:257 #, python-brace-format msgid "" "Délimitation parcellaire de la publication INAO du {release} ; la " "publication du {current} ne porte pas cette dénomination." -msgstr "Perceelsafbakening uit de INAO-publicatie van {release}; de publicatie van " -"{current} bevat deze benaming niet." +msgstr "" +"Perceelsafbakening uit de INAO-publicatie van {release}; de publicatie " +"van {current} bevat deze benaming niet." -#: scripts/_lib/map_template.py:251 +#: scripts/_lib/map_template.py:264 #, python-brace-format msgid "" "Aire complétée par {n} paroisse(s) civile(s) (parroquias) nommée(s) dans " @@ -617,7 +618,7 @@ msgstr "" "document noemt, getekend naar de Mapa de Parroquias de Galicia " "({source}): {names}." -#: scripts/_lib/map_template.py:260 +#: scripts/_lib/map_template.py:273 #, python-brace-format msgid "" "Aire : les {n} polígonos cadastraux SIGPAC que le document énumère dans " @@ -628,7 +629,7 @@ msgstr "" "opsomt in {municipios}, elk volledig getekend — elk perceel dat erin " "wordt aangeplant komt voor de appellation in aanmerking ({source})." -#: scripts/_lib/map_template.py:265 +#: scripts/_lib/map_template.py:278 #, python-brace-format msgid "" "Aire : les parcelles de vigne (SIGPAC, usage VI) situées dans les {n} " @@ -638,17 +639,17 @@ msgstr "" "Gebied: de wijngaardpercelen (SIGPAC, grondgebruik VI) binnen de {n} " "kadastrale polígonos die het document opsomt in {municipios} ({source})." -#: scripts/_lib/map_template.py:269 +#: scripts/_lib/map_template.py:282 #, python-brace-format msgid "S'y ajoutent, en totalité, les communes {whole}." msgstr "Daarbij komen, in hun geheel, de gemeenten {whole}." -#: scripts/_lib/map_template.py:270 +#: scripts/_lib/map_template.py:283 #, python-brace-format msgid "{n} appellations à ce point" msgstr "{n} appellaties op dit punt" -#: scripts/_lib/map_template.py:272 +#: scripts/_lib/map_template.py:285 msgid "" "Cliquer à nouveau sur la carte pour parcourir les autres, ou sur une " "fiche pour l'afficher en premier" @@ -656,115 +657,115 @@ msgstr "" "Klik opnieuw op de kaart om door de andere te bladeren, of op een fiche " "om die vooraan te tonen" -#: scripts/_lib/map_template.py:275 +#: scripts/_lib/map_template.py:288 msgid "Afficher cette appellation en premier" msgstr "Deze appellatie vooraan tonen" -#: scripts/_lib/map_template.py:276 +#: scripts/_lib/map_template.py:289 msgid "Cahier des charges (BO Agri, PDF)" msgstr "Productdossier (BO Agri, PDF)" -#: scripts/_lib/map_template.py:277 +#: scripts/_lib/map_template.py:290 msgid "Cahier des charges (registre GI de l'UE, PDF)" msgstr "Productdossier (EU GI-register, PDF)" -#: scripts/_lib/map_template.py:278 +#: scripts/_lib/map_template.py:291 msgid "homologué" msgstr "goedgekeurd" -#: scripts/_lib/map_template.py:279 +#: scripts/_lib/map_template.py:292 msgid "JORF" msgstr "JORF" -#: scripts/_lib/map_template.py:280 +#: scripts/_lib/map_template.py:293 msgid "Texte officiel INAO (show_texte)" msgstr "Officiële INAO-tekst (show_texte)" -#: scripts/_lib/map_template.py:281 +#: scripts/_lib/map_template.py:294 msgid "Fiche produit INAO" msgstr "INAO-productfiche" -#: scripts/_lib/map_template.py:282 +#: scripts/_lib/map_template.py:295 msgid "Site officiel de l'interprofession" msgstr "Officiële site van de wijnorganisatie" -#: scripts/_lib/map_template.py:283 +#: scripts/_lib/map_template.py:296 msgid "Cahier des charges (EUR-Lex, document unique)" msgstr "Productspecificatie (EUR-Lex, enig document)" -#: scripts/_lib/map_template.py:284 +#: scripts/_lib/map_template.py:297 msgid "Pliego de condiciones (national, PDF)" msgstr "Nationaal pliego de condiciones (PDF)" -#: scripts/_lib/map_template.py:285 +#: scripts/_lib/map_template.py:298 msgid "variétés ajoutées" msgstr "rassen toegevoegd" -#: scripts/_lib/map_template.py:286 +#: scripts/_lib/map_template.py:299 msgid "Cahier des charges national (PDF)" msgstr "Nationaal productdossier (PDF)" -#: scripts/_lib/map_template.py:287 +#: scripts/_lib/map_template.py:300 msgid "Spécification du produit (IGP, PDF)" msgstr "Productdossier (BGA, PDF)" -#: scripts/_lib/map_template.py:288 +#: scripts/_lib/map_template.py:301 msgid "Registre régional des cépages (PDF)" msgstr "Regionaal druivenrasregister (PDF)" -#: scripts/_lib/map_template.py:289 +#: scripts/_lib/map_template.py:302 msgid "Mapa de Parroquias de Galicia (IET, Xunta de Galicia)" msgstr "Mapa de Parroquias de Galicia (IET, Xunta de Galicia)" -#: scripts/_lib/map_template.py:290 +#: scripts/_lib/map_template.py:303 msgid "Registre eAmbrosia (UE)" msgstr "eAmbrosia-register (EU)" -#: scripts/_lib/map_template.py:291 +#: scripts/_lib/map_template.py:304 msgid "Numéro de dossier" msgstr "Dossiernummer" -#: scripts/_lib/map_template.py:292 +#: scripts/_lib/map_template.py:305 msgid "Règlement cantonal sur la vigne et le vin" msgstr "Kantonaal wijnreglement" -#: scripts/_lib/map_template.py:293 +#: scripts/_lib/map_template.py:306 msgid "Répertoire suisse des AOC (OFAG/BLW)" msgstr "Zwitsers AOC-register (OFAG/BLW)" -#: scripts/_lib/map_template.py:294 +#: scripts/_lib/map_template.py:307 msgid "Cahier des charges (GOV.UK, PDF)" msgstr "Productdossier (GOV.UK, PDF)" -#: scripts/_lib/map_template.py:295 +#: scripts/_lib/map_template.py:308 msgid "Registre des IG du Royaume-Uni" msgstr "Register van geografische aanduidingen (VK)" -#: scripts/_lib/map_template.py:296 +#: scripts/_lib/map_template.py:309 msgid "Légende couleurs" msgstr "Legende" -#: scripts/_lib/map_template.py:297 +#: scripts/_lib/map_template.py:310 msgid "Bassin viticole" msgstr "Wijnstreek" -#: scripts/_lib/map_template.py:298 +#: scripts/_lib/map_template.py:311 msgid "Plus l'aire est petite, plus la teinte est dense." msgstr "Hoe kleiner het gebied, hoe dichter de tint." -#: scripts/_lib/map_template.py:300 +#: scripts/_lib/map_template.py:313 msgid "principal — variété de la cuvée" msgstr "hoofdras — variëteit van de cuvée" -#: scripts/_lib/map_template.py:301 +#: scripts/_lib/map_template.py:314 msgid "accessoire — assemblage limité" msgstr "bijras — beperkte assemblage" -#: scripts/_lib/map_template.py:302 +#: scripts/_lib/map_template.py:315 msgid "intérêt — observation/conservation" msgstr "interesse — observatie/behoud" -#: scripts/_lib/map_template.py:304 +#: scripts/_lib/map_template.py:317 msgid "" "Le régulateur portugais (IVV) n'établit pas de distinction " "principal/accessoire — toutes les castas autorisées sont listées ensemble" @@ -774,7 +775,7 @@ msgstr "" "en bijrassen — alle toegelaten druivenrassen worden samen vermeld in de " "caderno de especificações." -#: scripts/_lib/map_template.py:308 +#: scripts/_lib/map_template.py:321 msgid "" "Bianchello (ou Biancame) est traité ici comme un cépage distinct, " "conformément au disciplinare de la DOP Bianchello del Metauro ; le " @@ -785,20 +786,20 @@ msgstr "" "Metauro; de VIVC-catalogus vermeldt het als synoniem van Trebbiano " "Toscano." -#: scripts/_lib/map_template.py:313 +#: scripts/_lib/map_template.py:326 #, python-brace-format msgid "Open Wine Map n'a pas encore trouvé de {doc} pour cette appellation." msgstr "Open Wine Map heeft nog geen {doc} gevonden voor deze benaming." -#: scripts/_lib/map_template.py:315 +#: scripts/_lib/map_template.py:328 msgid "aidez-nous à le trouver" msgstr "help ons het te vinden" -#: scripts/_lib/map_template.py:319 +#: scripts/_lib/map_template.py:332 msgid "Annulée" msgstr "Geannuleerd" -#: scripts/_lib/map_template.py:321 +#: scripts/_lib/map_template.py:334 #, python-brace-format msgid "" "Cette appellation a été annulée le {date} par {regulation}. Des " @@ -809,183 +810,186 @@ msgstr "" "flessen, etiketten en cursusmateriaal kunnen de naam nog vermelden; ze " "blijft hier ter referentie staan." -#: scripts/_lib/map_template.py:325 +#: scripts/_lib/map_template.py:338 #, python-brace-format msgid "Acte national : {act}." msgstr "Nationaal besluit: {act}." -#: scripts/_lib/map_template.py:326 +#: scripts/_lib/map_template.py:339 #, python-brace-format msgid "Elle est remplacée par {successor}." msgstr "Ze is opgevolgd door {successor}." -#: scripts/_lib/map_template.py:327 +#: scripts/_lib/map_template.py:340 #, python-brace-format msgid "Enregistrement annulé le {date}" msgstr "Registratie geannuleerd op {date}" -#: scripts/_lib/map_template.py:331 +#: scripts/_lib/map_template.py:344 msgid "Promue" msgstr "Verzelfstandigd" -#: scripts/_lib/map_template.py:333 +#: scripts/_lib/map_template.py:346 #, python-brace-format msgid "" "Cette dénomination est devenue l'appellation {successor} à part entière " "le {date} ({act}). Des étiquettes plus anciennes peuvent encore porter " "l'ancien nom ; elle est conservée ici à titre de référence." -msgstr "Op {date} werd deze benaming de zelfstandige appellation {successor} ({act}). Oudere etiketten kunnen de vroegere naam nog dragen; ze blijft hier ter referentie." +msgstr "" +"Op {date} werd deze benaming de zelfstandige appellation {successor} " +"({act}). Oudere etiketten kunnen de vroegere naam nog dragen; ze blijft " +"hier ter referentie." -#: scripts/_lib/map_template.py:337 +#: scripts/_lib/map_template.py:350 #, python-brace-format msgid "Cahier des charges de la nouvelle appellation : {cahier}." msgstr "Productdossier van de nieuwe appellation: {cahier}." -#: scripts/_lib/map_template.py:338 +#: scripts/_lib/map_template.py:351 msgid "BO Agri (PDF)" msgstr "BO Agri (PDF)" -#: scripts/_lib/map_template.py:339 +#: scripts/_lib/map_template.py:352 #, python-brace-format msgid "Devenue appellation à part entière le {date}" msgstr "Zelfstandige appellation sinds {date}" -#: scripts/_lib/map_template.py:345 +#: scripts/_lib/map_template.py:358 #, python-brace-format msgid "Délimitée par {regulator} dans son {doc}, qui autorise {grapes}." msgstr "Afgebakend door {regulator} in zijn {doc}, dat {grapes} toestaat." -#: scripts/_lib/map_template.py:347 +#: scripts/_lib/map_template.py:360 #, python-brace-format msgid "Délimitée par {regulator} dans son {doc}{extra}." msgstr "Afgebakend door {regulator} in zijn {doc}{extra}." -#: scripts/_lib/map_template.py:348 +#: scripts/_lib/map_template.py:361 #, python-brace-format msgid "{names} et {n} autres cépages" msgstr "{names} en {n} andere druivenrassen" -#: scripts/_lib/map_template.py:349 +#: scripts/_lib/map_template.py:362 msgid "le registre eAmbrosia de l'UE" msgstr "het eAmbrosia-register van de EU" -#: scripts/_lib/map_template.py:350 +#: scripts/_lib/map_template.py:363 #, python-brace-format msgid "le canton de {canton}" msgstr "het kanton {canton}" -#: scripts/_lib/map_template.py:351 +#: scripts/_lib/map_template.py:364 msgid "(français)" msgstr "(bron: Frans)" -#: scripts/_lib/map_template.py:352 +#: scripts/_lib/map_template.py:365 msgid "Texte source en français" msgstr "Brontekst in het Frans" -#: scripts/_lib/map_template.py:353 +#: scripts/_lib/map_template.py:366 msgid "(español)" msgstr "(bron: Spaans)" -#: scripts/_lib/map_template.py:354 +#: scripts/_lib/map_template.py:367 msgid "Texte source en espagnol" msgstr "Brontekst in het Spaans" -#: scripts/_lib/map_template.py:355 +#: scripts/_lib/map_template.py:368 msgid "(português)" msgstr "(bron: Portugees)" -#: scripts/_lib/map_template.py:356 +#: scripts/_lib/map_template.py:369 msgid "Texte source en portugais" msgstr "Brontekst in het Portugees" -#: scripts/_lib/map_template.py:357 +#: scripts/_lib/map_template.py:370 msgid "Filtres" msgstr "Filters" -#: scripts/_lib/map_template.py:358 +#: scripts/_lib/map_template.py:371 #, python-brace-format msgid "Traduit de {wiki} · CC BY-SA 4.0" msgstr "Vertaald uit {wiki} · CC BY-SA 4.0" -#: scripts/_lib/map_template.py:359 +#: scripts/_lib/map_template.py:372 msgid "Wikipédia en anglais" msgstr "Engelstalige Wikipedia" -#: scripts/_lib/map_template.py:360 +#: scripts/_lib/map_template.py:373 msgid "Wikipédia en français" msgstr "Franstalige Wikipedia" -#: scripts/_lib/map_template.py:361 +#: scripts/_lib/map_template.py:374 msgid "Wikipédia en espagnol" msgstr "Spaanstalige Wikipedia" -#: scripts/_lib/map_template.py:362 +#: scripts/_lib/map_template.py:375 msgid "Wikipédia en néerlandais" msgstr "Nederlandstalige Wikipedia" -#: scripts/_lib/map_template.py:363 +#: scripts/_lib/map_template.py:376 msgid "Wikipédia en portugais" msgstr "Portugeestalige Wikipedia" -#: scripts/_lib/map_template.py:364 +#: scripts/_lib/map_template.py:377 msgid "Wikipédia en croate" msgstr "Kroatische Wikipedia" -#: scripts/_lib/map_template.py:365 +#: scripts/_lib/map_template.py:378 msgid "Wikipédia en italien" msgstr "Italiaanstalige Wikipedia" -#: scripts/_lib/map_template.py:366 +#: scripts/_lib/map_template.py:379 msgid "Wikipédia en allemand" msgstr "Duitstalige Wikipedia" -#: scripts/_lib/map_template.py:367 +#: scripts/_lib/map_template.py:380 msgid "Wikipédia en slovène" msgstr "Sloveenstalige Wikipedia" -#: scripts/_lib/map_template.py:368 +#: scripts/_lib/map_template.py:381 msgid "Wikipédia en bulgare" msgstr "Bulgaarstalige Wikipedia" -#: scripts/_lib/map_template.py:369 +#: scripts/_lib/map_template.py:382 msgid "Vitis International Variety Catalogue (Julius Kühn-Institut)" msgstr "Vitis International Variety Catalogue (Julius Kühn-Institut)" -#: scripts/_lib/map_template.py:370 +#: scripts/_lib/map_template.py:383 #, python-brace-format msgid "VIVC #{id}" msgstr "VIVC #{id}" -#: scripts/_lib/map_template.py:371 +#: scripts/_lib/map_template.py:384 #, python-brace-format msgid "Traduction automatique depuis {source}" msgstr "Automatisch vertaald uit {source}" -#: scripts/_lib/map_template.py:372 +#: scripts/_lib/map_template.py:385 msgid "le cahier des charges" msgstr "het cahier des charges" -#: scripts/_lib/map_template.py:373 scripts/_lib/map_template.py:375 +#: scripts/_lib/map_template.py:386 scripts/_lib/map_template.py:388 msgid "le pliego de condiciones" msgstr "de productspecificatie" -#: scripts/_lib/map_template.py:374 scripts/_lib/map_template.py:376 +#: scripts/_lib/map_template.py:387 scripts/_lib/map_template.py:389 msgid "le caderno de especificações" msgstr "de caderno de especificações" -#: scripts/_lib/map_template.py:377 +#: scripts/_lib/map_template.py:390 msgid "Dénomination géographique complémentaire de" msgstr "Deelgebied van" -#: scripts/_lib/map_template.py:378 +#: scripts/_lib/map_template.py:391 msgid "À propos" msgstr "Over" -#: scripts/_lib/map_template.py:379 +#: scripts/_lib/map_template.py:392 msgid "À propos d'Open Wine Map" msgstr "Over Open Wine Map" -#: scripts/_lib/map_template.py:381 +#: scripts/_lib/map_template.py:394 msgid "" "Carte de référence des appellations viticoles, générée automatiquement à " "partir des registres publics : le registre de l'Union européenne des AOP " @@ -997,7 +1001,7 @@ msgstr "" " het Zwitserse federale repertorium van kantonnale AOC's en het Britse " "register van geografische aanduidingen." -#: scripts/_lib/map_template.py:388 +#: scripts/_lib/map_template.py:401 msgid "" "Une partie du texte est produite par des modèles de langage : les repères" " de terroir sont dégagés du texte du régulateur par Claude, vérifiés " @@ -1018,12 +1022,12 @@ msgstr "" "menselijke vertaler vertaald, op een klein machinaal vertaald restant na." " Elk onderdeel heeft zijn eigen bronvermelding in het paneel." -#: scripts/_lib/map_template.py:397 +#: scripts/_lib/map_template.py:410 #, python-brace-format msgid "Réalisé avec ♡ par {devloed}." msgstr "Met ♡ gemaakt door {devloed}." -#: scripts/_lib/map_template.py:399 +#: scripts/_lib/map_template.py:412 #, python-brace-format msgid "" "Sources : INAO ({inao}) pour les cahiers des charges et les aires " @@ -1047,7 +1051,7 @@ msgstr "" "druivennamen en rasnummers (citaat: Röckel et al.). Elk Wikipedia-" "fragment wordt ter plaatse vermeld. Details en licenties in de {readme}." -#: scripts/_lib/map_template.py:410 +#: scripts/_lib/map_template.py:423 #, python-brace-format msgid "" "Niveau de détail : en dessous du zoom {zoom}, les appellations françaises" @@ -1064,12 +1068,12 @@ msgstr "" "appellations); vanaf zoomniveau {zoom} worden de percelen zelf getekend. " "Alle andere appellations behouden dezelfde omtrek op elk zoomniveau." -#: scripts/_lib/map_template.py:418 +#: scripts/_lib/map_template.py:431 #, python-brace-format msgid "Suggestions et pull requests bienvenues sur {github}." msgstr "Issues en pull requests welkom op {github}." -#: scripts/_lib/map_template.py:420 +#: scripts/_lib/map_template.py:433 #, python-brace-format msgid "" "Vie privée : la fréquentation est mesurée avec une instance {plausible} " @@ -1086,20 +1090,20 @@ msgstr "" "adressen, geen tracking over sites of apparaten heen — daarom is er geen " "cookiebanner." -#: scripts/_lib/map_template.py:427 +#: scripts/_lib/map_template.py:440 #, python-brace-format msgid "Suivez les mises à jour sur {bluesky}." msgstr "Volg de updates op {bluesky}." -#: scripts/_lib/map_template.py:428 +#: scripts/_lib/map_template.py:441 msgid "e-mail" msgstr "e-mail" -#: scripts/_lib/map_template.py:429 +#: scripts/_lib/map_template.py:442 msgid "E-mail copié dans le presse-papiers" msgstr "E-mail gekopieerd naar klembord" -#: scripts/_lib/map_template.py:431 +#: scripts/_lib/map_template.py:444 #, python-brace-format msgid "" "Carte générée automatiquement — des erreurs sont possibles. Signalez-les " @@ -1108,55 +1112,55 @@ msgstr "" "Automatisch gegenereerde kaart — fouten zijn mogelijk. Meld ze op de " "fiche van de appellatie, of per {email}." -#: scripts/_lib/map_template.py:437 +#: scripts/_lib/map_template.py:450 msgid "Signaler une erreur" msgstr "Een fout melden" -#: scripts/_lib/map_template.py:438 +#: scripts/_lib/map_template.py:451 msgid "Un clic nous dit quoi vérifier — sans compte." msgstr "Eén tik vertelt ons wat we moeten nakijken — geen account nodig." -#: scripts/_lib/map_template.py:439 +#: scripts/_lib/map_template.py:452 msgid "Aire / contour" msgstr "Gebied / grens" -#: scripts/_lib/map_template.py:442 +#: scripts/_lib/map_template.py:455 msgid "Nom / type" msgstr "Naam / type" -#: scripts/_lib/map_template.py:444 +#: scripts/_lib/map_template.py:457 msgid "Autre" msgstr "Anders" -#: scripts/_lib/map_template.py:445 +#: scripts/_lib/map_template.py:458 #, python-brace-format msgid "Merci, c'est noté : {aspect}." msgstr "Bedankt, genoteerd: {aspect}." -#: scripts/_lib/map_template.py:447 +#: scripts/_lib/map_template.py:460 msgid "Précisez si vous voulez (facultatif, sans données personnelles)" msgstr "Licht toe als je wilt (optioneel, geen persoonsgegevens)" -#: scripts/_lib/map_template.py:449 +#: scripts/_lib/map_template.py:462 msgid "Envoyer" msgstr "Verzenden" -#: scripts/_lib/map_template.py:452 +#: scripts/_lib/map_template.py:465 msgid "Géométrie" msgstr "Geometrie" -#: scripts/_lib/map_template.py:453 +#: scripts/_lib/map_template.py:466 msgid "Envoyé, merci !" msgstr "Verzonden, bedankt!" -#: scripts/_lib/map_template.py:455 +#: scripts/_lib/map_template.py:468 #, python-brace-format msgid "" "{aspect} — votre navigateur bloque notre outil de mesure, envoyez plutôt " "un {email}." msgstr "{aspect} — je browser blokkeert onze meettool; stuur liever een {email}." -#: scripts/_lib/map_template.py:459 +#: scripts/_lib/map_template.py:472 #, python-brace-format msgid "" "{c} pays européens cartographiés ({n} appellations : {parents} " @@ -1169,15 +1173,15 @@ msgstr "" "verfijnen de datakwaliteit en breiden de dekking uit tot buiten de EU, " "Zwitserland en het Verenigd Koninkrijk." -#: scripts/_lib/map_template.py:464 +#: scripts/_lib/map_template.py:477 msgid "Toutes les appellations" msgstr "Alle appellaties" -#: scripts/_lib/map_template.py:465 +#: scripts/_lib/map_template.py:478 msgid "Toutes les appellations viticoles — Open Wine Map" msgstr "Alle wijnappellaties — Open Wine Map" -#: scripts/_lib/map_template.py:467 +#: scripts/_lib/map_template.py:480 #, python-brace-format msgid "" "Liste des {n} appellations viticoles cartographiées sur Open Wine Map, " @@ -1186,7 +1190,7 @@ msgstr "" "Lijst van de {n} wijnappellaties op Open Wine Map, per land: BOB's en " "BGA's van de EU, Zwitserse AOC's en Britse GA's." -#: scripts/_lib/map_template.py:471 +#: scripts/_lib/map_template.py:484 #, python-brace-format msgid "" "Les {n} appellations ci-dessous sont classées par pays. Retour à la " @@ -1195,205 +1199,236 @@ msgstr "" "De {n} onderstaande appellaties zijn gegroepeerd per land. Terug naar de " "{map_link}." -#: scripts/_lib/map_template.py:474 +#: scripts/_lib/map_template.py:487 msgid "carte interactive" msgstr "interactieve kaart" -#: scripts/_lib/map_template.py:475 +#: scripts/_lib/map_template.py:488 msgid "Liste des appellations par pays" msgstr "Lijst van appellaties per land" -#: scripts/_lib/map_template.py:476 +#: scripts/_lib/map_template.py:489 msgid "Appellation parente" msgstr "Bovenliggende appellatie" -#: scripts/_lib/map_template.py:477 +#: scripts/_lib/map_template.py:490 msgid "Dénominations rattachées" msgstr "Gekoppelde benamingen" -#: scripts/_lib/map_template.py:479 +#: scripts/_lib/map_template.py:492 #, python-brace-format msgid "Parcourir la liste complète des appellations : {browse_link}." msgstr "Blader door de volledige lijst van appellaties: {browse_link}." -#: scripts/_lib/map_template.py:481 +#: scripts/_lib/map_template.py:494 #, python-brace-format msgid "Données mises à jour le {date}." msgstr "Gegevens bijgewerkt op {date}." -#: scripts/_lib/map_template.py:499 +#: scripts/_lib/map_template.py:496 +#, python-brace-format +msgid "" +"Votre navigateur parle déjà {webmcp} ? Chaque page de la carte lui " +"propose les mêmes outils. Sinon, aucune importance : c'est le serveur MCP" +" ci-dessus que votre assistant IA sait utiliser." +msgstr "Spreekt je browser al {webmcp}? Elke pagina van de kaart biedt hem dezelfde zoekfuncties aan. Zegt dat je niets, geen probleem: de MCP-server hierboven is degene die je AI-assistent kan gebruiken." + +#: scripts/_lib/map_template.py:500 +msgid "Copier" +msgstr "Kopiëren" + +#: scripts/_lib/map_template.py:501 +msgid "Copié" +msgstr "Gekopieerd" + +#: scripts/_lib/map_template.py:502 +msgid "Copier l'adresse du serveur MCP" +msgstr "Het adres van de MCP-server kopiëren" + +#: scripts/_lib/map_template.py:504 +#, python-brace-format +msgid "" +"Les assistants IA peuvent interroger les appellations avec les mêmes " +"outils de recherche que la carte, via le serveur MCP {mcp_link} (sans " +"authentification ; à ajouter comme connecteur personnalisé)." +msgstr "" +"AI-assistenten kunnen de appellaties doorzoeken met dezelfde zoekfuncties" +" als de kaart, via de MCP-server {mcp_link} (zonder authenticatie; voeg " +"hem toe als eigen connector)." + +#: scripts/_lib/map_template.py:525 msgid "BOURGOGNE" msgstr "Bourgogne" -#: scripts/_lib/map_template.py:500 +#: scripts/_lib/map_template.py:526 msgid "BEAUJOLAIS" msgstr "Beaujolais" -#: scripts/_lib/map_template.py:501 +#: scripts/_lib/map_template.py:527 msgid "JURA" msgstr "Jura" -#: scripts/_lib/map_template.py:502 +#: scripts/_lib/map_template.py:528 msgid "SAVOIE" msgstr "Savoie" -#: scripts/_lib/map_template.py:503 +#: scripts/_lib/map_template.py:529 msgid "BUGEY" msgstr "Bugey" -#: scripts/_lib/map_template.py:504 +#: scripts/_lib/map_template.py:530 msgid "ALSACE" msgstr "Elzas" -#: scripts/_lib/map_template.py:505 +#: scripts/_lib/map_template.py:531 msgid "LORRAINE" msgstr "Lotharingen" -#: scripts/_lib/map_template.py:506 +#: scripts/_lib/map_template.py:532 msgid "VAL DE LOIRE" msgstr "Loirestreek" -#: scripts/_lib/map_template.py:507 +#: scripts/_lib/map_template.py:533 msgid "BORDEAUX" msgstr "Bordeaux" -#: scripts/_lib/map_template.py:508 +#: scripts/_lib/map_template.py:534 msgid "SUD-OUEST" msgstr "Zuidwest-Frankrijk" -#: scripts/_lib/map_template.py:509 +#: scripts/_lib/map_template.py:535 msgid "VALLEE DU RHÔNE" msgstr "Rhônestreek" -#: scripts/_lib/map_template.py:510 +#: scripts/_lib/map_template.py:536 msgid "LANGUEDOC" msgstr "Languedoc" -#: scripts/_lib/map_template.py:511 +#: scripts/_lib/map_template.py:537 msgid "ROUSSILLON" msgstr "Roussillon" -#: scripts/_lib/map_template.py:512 +#: scripts/_lib/map_template.py:538 msgid "PROVENCE" msgstr "Provence" -#: scripts/_lib/map_template.py:513 +#: scripts/_lib/map_template.py:539 msgid "CORSE" msgstr "Corsica" -#: scripts/_lib/map_template.py:514 +#: scripts/_lib/map_template.py:540 msgid "CHAMPAGNE" msgstr "Champagne" -#: scripts/_lib/map_template.py:515 +#: scripts/_lib/map_template.py:541 msgid "ILE-DE-FRANCE" msgstr "Île-de-France" -#: scripts/_lib/map_template.py:516 +#: scripts/_lib/map_template.py:542 msgid "NORMANDIE" msgstr "Normandië" -#: scripts/_lib/map_template.py:517 +#: scripts/_lib/map_template.py:543 msgid "BRETAGNE" msgstr "Bretagne" -#: scripts/_lib/map_template.py:518 +#: scripts/_lib/map_template.py:544 msgid "MAINE" msgstr "Maine" -#: scripts/_lib/map_template.py:519 +#: scripts/_lib/map_template.py:545 msgid "COGNAC" msgstr "Cognac" -#: scripts/_lib/map_template.py:520 +#: scripts/_lib/map_template.py:546 msgid "ARMAGNAC" msgstr "Armagnac" -#: scripts/_lib/map_template.py:521 +#: scripts/_lib/map_template.py:547 msgid "RHUM" msgstr "Rum" -#: scripts/_lib/map_template.py:561 +#: scripts/_lib/map_template.py:587 msgid "France" msgstr "Frankrijk" -#: scripts/_lib/map_template.py:562 +#: scripts/_lib/map_template.py:588 msgid "Espagne" msgstr "Spanje" -#: scripts/_lib/map_template.py:563 +#: scripts/_lib/map_template.py:589 msgid "Portugal" msgstr "Portugal" -#: scripts/_lib/map_template.py:564 +#: scripts/_lib/map_template.py:590 msgid "Italie" msgstr "Italië" -#: scripts/_lib/map_template.py:565 +#: scripts/_lib/map_template.py:591 msgid "Autriche" msgstr "Oostenrijk" -#: scripts/_lib/map_template.py:566 +#: scripts/_lib/map_template.py:592 msgid "Slovénie" msgstr "Slovenië" -#: scripts/_lib/map_template.py:567 +#: scripts/_lib/map_template.py:593 msgid "Croatie" msgstr "Kroatië" -#: scripts/_lib/map_template.py:568 +#: scripts/_lib/map_template.py:594 msgid "Hongrie" msgstr "Hongarije" -#: scripts/_lib/map_template.py:569 +#: scripts/_lib/map_template.py:595 msgid "Roumanie" msgstr "Roemenië" -#: scripts/_lib/map_template.py:570 +#: scripts/_lib/map_template.py:596 msgid "Bulgarie" msgstr "Bulgarije" -#: scripts/_lib/map_template.py:571 +#: scripts/_lib/map_template.py:597 msgid "Grèce" msgstr "Griekenland" -#: scripts/_lib/map_template.py:572 +#: scripts/_lib/map_template.py:598 msgid "Allemagne" msgstr "Duitsland" -#: scripts/_lib/map_template.py:573 +#: scripts/_lib/map_template.py:599 msgid "Slovaquie" msgstr "Slowakije" -#: scripts/_lib/map_template.py:574 +#: scripts/_lib/map_template.py:600 msgid "Suisse" msgstr "Zwitserland" -#: scripts/_lib/map_template.py:575 +#: scripts/_lib/map_template.py:601 msgid "Tchéquie" msgstr "Tsjechië" -#: scripts/_lib/map_template.py:576 +#: scripts/_lib/map_template.py:602 msgid "Luxembourg" msgstr "Luxemburg" -#: scripts/_lib/map_template.py:577 +#: scripts/_lib/map_template.py:603 msgid "Belgique" msgstr "België" -#: scripts/_lib/map_template.py:578 +#: scripts/_lib/map_template.py:604 msgid "Pays-Bas" msgstr "Nederland" -#: scripts/_lib/map_template.py:579 +#: scripts/_lib/map_template.py:605 msgid "Malte" msgstr "Malta" -#: scripts/_lib/map_template.py:580 +#: scripts/_lib/map_template.py:606 msgid "Chypre" msgstr "Cyprus" -#: scripts/_lib/map_template.py:581 +#: scripts/_lib/map_template.py:607 msgid "Royaume-Uni" msgstr "Verenigd Koninkrijk" diff --git a/mcp/.gitignore b/mcp/.gitignore new file mode 100644 index 0000000..39bd836 --- /dev/null +++ b/mcp/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +!view/view.html diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 0000000..6f13584 --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,48 @@ +# Open Wine Map — remote MCP server + +A stateless MCP server for the Open Wine Map corpus, deployed as a Bunny +standalone Edge Script at **https://mcp.openwinemap.com/mcp** (Streamable HTTP, +no authentication). + +Tools — the same definitions and code the map page registers as WebMCP tools +(`scripts/_lib/assets/query_core.mjs`), plus a `locale` argument (en / fr / es / nl): + +| tool | does | +|---|---| +| `search_appellations` | find appellations by name (romanised Greek / Bulgarian forms included) | +| `filter_appellations` | country, region, style, grape (VIVC synonyms), scheme / traditional term | +| `get_appellation` | grapes, styles, terroir facts with provenance, sources, attribution | +| `list_facets` | the values `filter_appellations` accepts, with counts | +| `show_on_map` | the appellations on an interactive map in the chat (MCP Apps view `ui://open-wine-map/map`; a text answer with the site link elsewhere) | + +## Layout + +- `src/server.js` — tools + `createMcpHandler` (protocol 2026-07-28 and the + 2025-era stateless fallback) +- `src/context.js` — loads `/data/mcp/.json` from the site (written by + stage 04), caches it per isolate, revalidates by ETag +- `src/http.js` — routing (`POST /mcp`), CORS, `no-store` +- `src/edge.js` — Bunny entry point; optional `MCP Tool` Plausible event +- `view/` — the `show_on_map` view (MapLibre + pmtiles + the ext-apps `App`), + bundled into one HTML file, `dist/view.html`, by `scripts/build-view.mjs` + +## Develop + +```sh +npm ci +npm test # needs a stage-04 build in ../wiki for most tests +npm run build # → dist/edge.js (single file, uploaded as is) +node test/smoke.js https://mcp.openwinemap.com/mcp # all three protocol modes +../.venv/bin/python test/parity_webmcp.py # page WebMCP tools == server +../.venv/bin/python test/apps_host_check.py --shots /tmp/shots # the view in a local MCP Apps host +``` + +Deploy: `.venv/bin/python scripts/deploy_mcp.py deploy` from the repo root +(see that script's docstring; `BUNNY_API_KEY`, `BUNNY_MCP_SCRIPT_ID` in `.env`). +Script variables: `DATA_ORIGIN` (default https://www.openwinemap.com), +`PLAUSIBLE_HOST`, `PLAUSIBLE_DOMAIN`; for the view `TILE_ORIGIN`, `CARTO_KEY` +and (spike only) `TILE_PROXY=1`. + +Add it to a client: Le Chat → Connectors → Custom MCP Connector → server URL +`https://mcp.openwinemap.com/mcp`; Claude → Settings → Connectors → Add custom +connector, same URL. diff --git a/mcp/package-lock.json b/mcp/package-lock.json new file mode 100644 index 0000000..79c3644 --- /dev/null +++ b/mcp/package-lock.json @@ -0,0 +1,1141 @@ +{ + "name": "owm-mcp", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "owm-mcp", + "dependencies": { + "@bunny.net/edgescript-sdk": "0.13.0", + "@cfworker/json-schema": "4.1.1", + "@modelcontextprotocol/ext-apps": "2.0.3", + "@modelcontextprotocol/server": "2.3.0", + "maplibre-gl": "4.7.1", + "pmtiles": "3.2.0" + }, + "devDependencies": { + "@modelcontextprotocol/client": "2.3.0", + "esbuild": "0.28.2" + } + }, + "node_modules/@bunny.net/edgescript-sdk": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@bunny.net/edgescript-sdk/-/edgescript-sdk-0.13.0.tgz", + "integrity": "sha512-rxYkjhEg40t/NaDy65S3CRbUuCdlWwz5MakZrZrA3SACzJWLaXRUVEW3WdmQ8hzCLGTY/nmKzo3gWxQfzb+VXA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.12.0", + "hono": "^4.5.5" + } + }, + "node_modules/@cfworker/json-schema": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", + "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@hono/node-server": { + "version": "1.19.17", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", + "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@mapbox/geojson-rewind": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", + "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", + "license": "ISC", + "dependencies": { + "get-stream": "^6.0.1", + "minimist": "^1.2.6" + }, + "bin": { + "geojson-rewind": "geojson-rewind" + } + }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz", + "integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==", + "license": "MIT", + "engines": { + "node": ">= 22" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", + "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz", + "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz", + "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~0.1.0" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "20.4.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-20.4.0.tgz", + "integrity": "sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^0.0.1", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^2.0.0", + "rw": "^1.3.3", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/quickselect": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", + "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", + "license": "ISC" + }, + "node_modules/@modelcontextprotocol/client": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.3.0.tgz", + "integrity": "sha512-pbHtGj6EeFCHngFE8gtNPaKrsj8Mb1IMw45BLcaPML/m2WTxI7pyYmeNQvaux88Lw8BSO5r7nyRbUyIkICmGrg==", + "license": "Apache-2.0", + "dependencies": { + "@modelcontextprotocol/core": "2.3.0", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.8", + "jose": "^6.1.3", + "pkce-challenge": "^5.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.3.0.tgz", + "integrity": "sha512-09BHFaNVBe5pB2AhzCfLj0cqmsKLERvZQ4FblzP90UMGLaXVqdZPByFjURwsUYCmxonUW2yAwgTTvle+8O2/8w==", + "license": "Apache-2.0", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/ext-apps": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-2.0.3.tgz", + "integrity": "sha512-Tn+4+cyhO4f4cQSDxNyLiIfEE+qlTTuIbWBJB8JTinEXOhDYgCT4wDTQplxD9MBYZH5pCYGC59GaxiwIT4bu8w==", + "license": "MIT", + "workspaces": [ + "examples/*" + ], + "dependencies": { + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@modelcontextprotocol/client": "^2.0.0", + "@modelcontextprotocol/core": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "zod": "^4.2.0" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/server": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.3.0.tgz", + "integrity": "sha512-+6b0LdsQLmHsvS7J6sQJ4weTpzUn61fU1JKS+321/+IyDYoHW5CR2Tgqcs6GgAAQvlesCo6yX3xttgmx+XGtJw==", + "license": "Apache-2.0", + "dependencies": { + "@modelcontextprotocol/core": "2.3.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, + "node_modules/@types/geojson-vt": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz", + "integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/leaflet": { + "version": "1.9.22", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz", + "integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/mapbox__point-geometry": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz", + "integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==", + "license": "MIT" + }, + "node_modules/@types/mapbox__vector-tile": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz", + "integrity": "sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*", + "@types/mapbox__point-geometry": "*", + "@types/pbf": "*" + } + }, + "node_modules/@types/pbf": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", + "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==", + "license": "MIT" + }, + "node_modules/@types/supercluster": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/earcut": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.4.tgz", + "integrity": "sha512-FHc6yNpnTORB0tM5kmZ03iD2mDuNakDE2lvf4Hw1XkfxtEHIGVi01B/GqUSwx+NcC1WGjidAfpTXzeALtPtfpg==", + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, + "node_modules/geojson-vt": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.3.tgz", + "integrity": "sha512-jR1MwkLaZGa8Zftct9ZFruyWFrdl9ZyD2OliXNy9Qq5bBPeg5wHVpBQF9p5GjnicSDQqvBVpysxTPKmWdsfWMA==", + "license": "ISC" + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, + "node_modules/global-prefix": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-4.0.0.tgz", + "integrity": "sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==", + "license": "MIT", + "dependencies": { + "ini": "^4.1.3", + "kind-of": "^6.0.3", + "which": "^4.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/global-prefix/node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/global-prefix/node_modules/which": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "license": "ISC", + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^16.13.0 || >=18.0.0" + } + }, + "node_modules/hono": { + "version": "4.13.13", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.13.tgz", + "integrity": "sha512-CQ46U0ZkAGmbT/4UxdzzGJpacP2IeKgY4a5/tOI9AABbpOMfK739wfDXmv1usCk+3RkKj1hQy4/fjhiwa2xlrA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ini": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, + "node_modules/kdbush": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", + "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==", + "license": "ISC" + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/maplibre-gl": { + "version": "4.7.1", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz", + "integrity": "sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/geojson-rewind": "^0.5.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^0.1.0", + "@mapbox/tiny-sdf": "^2.0.6", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^1.3.1", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/maplibre-gl-style-spec": "^20.3.1", + "@types/geojson": "^7946.0.14", + "@types/geojson-vt": "3.2.5", + "@types/mapbox__point-geometry": "^0.1.4", + "@types/mapbox__vector-tile": "^1.3.4", + "@types/pbf": "^3.0.5", + "@types/supercluster": "^7.1.3", + "earcut": "^3.0.0", + "geojson-vt": "^4.0.2", + "gl-matrix": "^3.4.3", + "global-prefix": "^4.0.0", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^3.3.0", + "potpack": "^2.0.0", + "quickselect": "^3.0.0", + "supercluster": "^8.0.1", + "tinyqueue": "^3.0.0", + "vt-pbf": "^3.1.3" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pbf": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", + "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "ieee754": "^1.1.12", + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/pmtiles": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-3.2.0.tgz", + "integrity": "sha512-4v3Nw5xeMxaUReLZQTz3PyM4VM/Lx/Xp/rc2GGEWMl0nqAmcb+gjyi+eOTwfPu8LnB0ash36hz0dV76uYvih5A==", + "license": "BSD-3-Clause", + "dependencies": { + "@types/leaflet": "^1.9.8", + "fflate": "^0.8.0" + } + }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "license": "MIT" + }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, + "node_modules/rw": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", + "license": "BSD-3-Clause" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/supercluster": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.0.2" + } + }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, + "node_modules/vt-pbf": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz", + "integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "0.1.0", + "@mapbox/vector-tile": "^1.3.1", + "pbf": "^3.2.1" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/zod": { + "version": "4.6.5", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", + "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/mcp/package.json b/mcp/package.json new file mode 100644 index 0000000..7927147 --- /dev/null +++ b/mcp/package.json @@ -0,0 +1,23 @@ +{ + "name": "owm-mcp", + "private": true, + "type": "module", + "description": "Remote MCP server for Open Wine Map (Bunny Edge Script). See docs/plan-mcp-server.md.", + "scripts": { + "build": "node scripts/build-view.mjs && esbuild src/edge.js --bundle --format=esm --platform=browser --target=es2022 --external:node:* --loader:.html=text --minify --legal-comments=none --metafile=dist/meta.json --outfile=dist/edge.js", + "test": "node --test test/*.test.js", + "build:view": "node scripts/build-view.mjs" + }, + "dependencies": { + "@bunny.net/edgescript-sdk": "0.13.0", + "@cfworker/json-schema": "4.1.1", + "@modelcontextprotocol/ext-apps": "2.0.3", + "@modelcontextprotocol/server": "2.3.0", + "maplibre-gl": "4.7.1", + "pmtiles": "3.2.0" + }, + "devDependencies": { + "@modelcontextprotocol/client": "2.3.0", + "esbuild": "0.28.2" + } +} diff --git a/mcp/scripts/build-view.mjs b/mcp/scripts/build-view.mjs new file mode 100644 index 0000000..8109e08 --- /dev/null +++ b/mcp/scripts/build-view.mjs @@ -0,0 +1,35 @@ +// Builds the MCP Apps view (view/view.html + view/view.js) into one +// self-contained HTML document, dist/view.html: MCP Apps resources are a +// single HTML string (no sibling files), so the bundle and MapLibre's CSS are +// inlined. The edge bundle imports the result as text (esbuild --loader). +import { build } from 'esbuild'; +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const out = await build({ + entryPoints: [path.join(root, 'view', 'view.js')], + bundle: true, + format: 'iife', + platform: 'browser', + target: 'es2020', + minify: true, + legalComments: 'none', + write: false, +}); +const js = out.outputFiles[0].text.replace(/<\/script/gi, '<\\/script'); +const css = (await readFile(path.join(root, 'node_modules', 'maplibre-gl', 'dist', 'maplibre-gl.css'), 'utf8')) + .replace(/<\/style/gi, '<\\/style'); +const template = await readFile(path.join(root, 'view', 'view.html'), 'utf8'); +for (const token of ['/*__OWM_MAPLIBRE_CSS__*/', '/*__OWM_VIEW_JS__*/', '__OWM_VIEW_CONFIG__']) { + if (template.split(token).length !== 2) throw new Error(`view.html: expected one ${token}`); +} +// Function replacers: the bundle contains `$&`-style sequences a string +// replacement would interpret. +const html = template + .replace('/*__OWM_MAPLIBRE_CSS__*/', () => css) + .replace('/*__OWM_VIEW_JS__*/', () => js); +await mkdir(path.join(root, 'dist'), { recursive: true }); +await writeFile(path.join(root, 'dist', 'view.html'), html); +console.log(`dist/view.html ${(Buffer.byteLength(html) / 1024).toFixed(1)}kb`); diff --git a/mcp/src/context.js b/mcp/src/context.js new file mode 100644 index 0000000..d1b2c9e --- /dev/null +++ b/mcp/src/context.js @@ -0,0 +1,78 @@ +// Per-locale query context: /data/mcp/.json from the site (written +// by stage 04), turned into a query core. Cached per isolate and revalidated +// with its ETag at most every `ttlMs`; a failed refresh keeps serving the copy +// it has, so a CDN hiccup never takes the tools down once they have loaded. +import { createQueryCore } from '../../scripts/_lib/assets/query_core.mjs'; + +export const LOCALES = ['en', 'fr', 'es', 'nl']; +export const SUPPORTED_FORMATS = [1]; + +export function coreFromContext(ctx, siteOrigin) { + if (!SUPPORTED_FORMATS.includes(ctx.format_version)) { + throw new Error(`unsupported query context format ${ctx.format_version}`); + } + return createQueryCore({ + locale: ctx.locale, + siteOrigin, + slugBase: `/${ctx.locale}/`, + aocs: ctx.aocs, + grapesInfo: ctx.grapes_info, + grapeSearchIndex: ctx.grape_search_index, + vivcSiblings: ctx.vivc_siblings, + styleDescendants: ctx.style_descendants, + styleLabels: ctx.style_labels, + simpleStyleLabels: ctx.simple_style_labels, + simpleStyleBuckets: ctx.simple_style_buckets, + regionLabels: ctx.region_labels, + regionSearchTerms: ctx.region_search_terms, + countryLabels: ctx.country_labels, + termLabels: ctx.term_labels, + labels: ctx.labels, + }); +} + +export function createContextStore({ dataOrigin, fetchImpl = fetch, ttlMs = 5 * 60 * 1000, now = Date.now }) { + const entries = new Map(); // locale → { core, aocs, tiles, etag, checkedAt, pending } + + async function refresh(locale, entry) { + const headers = entry?.etag ? { 'If-None-Match': entry.etag } : {}; + const resp = await fetchImpl(`${dataOrigin}/data/mcp/${locale}.json`, { headers }); + if (resp.status === 304 && entry) { + entry.checkedAt = now(); + return entry; + } + if (!resp.ok) throw new Error(`query context ${locale}: HTTP ${resp.status}`); + const ctx = await resp.json(); + const fresh = { + core: coreFromContext(ctx, dataOrigin), aocs: ctx.aocs, tiles: ctx.tiles || null, + etag: resp.headers.get('ETag'), checkedAt: now(), + }; + entries.set(locale, fresh); + return fresh; + } + + // The query core for `locale` (and the raw records, for merging panels). + async function get(locale) { + if (!LOCALES.includes(locale)) throw new Error(`unsupported locale ${locale}`); + const entry = entries.get(locale); + if (entry && now() - entry.checkedAt < ttlMs) return entry; + if (entry?.pending) return entry.pending; + if (!entry) { + // Deduplicate concurrent cold loads of the same locale. + const placeholder = { pending: refresh(locale, null) }; + entries.set(locale, placeholder); + try { + return await placeholder.pending; + } catch (e) { + entries.delete(locale); + throw e; + } + } + entry.pending = refresh(locale, entry) + .catch(() => { entry.checkedAt = now(); return entry; }) + .finally(() => { entry.pending = null; }); + return entry.pending; + } + + return { get }; +} diff --git a/mcp/src/edge.js b/mcp/src/edge.js new file mode 100644 index 0000000..f3e0a39 --- /dev/null +++ b/mcp/src/edge.js @@ -0,0 +1,50 @@ +// Bunny Edge Scripting entry point (standalone script). +// +// Script environment variables (all optional): +// DATA_ORIGIN the site the data is read from (https://www.openwinemap.com) +// PLAUSIBLE_HOST self-hosted Plausible; unset → no analytics +// PLAUSIBLE_DOMAIN the Plausible site the `MCP Tool` event is counted under +// TILE_ORIGIN where the map view loads /map-data/*.pmtiles (needs CORS) +// TILE_PROXY "1": this script re-serves the site's tiles with CORS +// CARTO_KEY CARTO basemap key for the map view (public, as on the site) +import * as BunnySDK from '@bunny.net/edgescript-sdk'; +import process from 'node:process'; +import { createApp } from './http.js'; +import VIEW_HTML from '../dist/view.html'; + +const env = name => (process.env[name] || '').trim().replace(/\/$/, ''); +const dataOrigin = env('DATA_ORIGIN') || 'https://www.openwinemap.com'; +const plausibleHost = env('PLAUSIBLE_HOST'); +const plausibleDomain = env('PLAUSIBLE_DOMAIN') || 'openwinemap.com'; + +// `MCP Tool` {tool, locale}: tool arguments are never sent (the same rule as +// the page's `WebMCP Tool` event). Sent after the response, without blocking. +function trackToolCall(request) { + if (!plausibleHost || request.headers.get('X-OWM-Smoke')) return undefined; + return (tool, locale) => { + const send = fetch(`${plausibleHost}/api/event`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': request.headers.get('User-Agent') || 'mcp-client', + 'X-Forwarded-For': request.headers.get('X-Forwarded-For') || '', + }, + body: JSON.stringify({ + name: 'MCP Tool', + url: new URL(request.url).origin + '/mcp', + domain: plausibleDomain, + props: { tool, locale }, + }), + }).catch(() => {}); + try { Bunny.v1.waitUntil(send); } catch { /* not on Bunny */ } + }; +} + +const app = createApp({ + dataOrigin, + onToolCallFor: trackToolCall, + view: { html: VIEW_HTML, tileOrigin: env('TILE_ORIGIN') || dataOrigin, cartoKey: env('CARTO_KEY') }, + tileProxy: env('TILE_PROXY') === '1', +}); + +BunnySDK.net.http.serve(app); diff --git a/mcp/src/http.js b/mcp/src/http.js new file mode 100644 index 0000000..5b7cd88 --- /dev/null +++ b/mcp/src/http.js @@ -0,0 +1,58 @@ +// HTTP shell around the MCP handler: routing, CORS, no-store. Shared by the +// Bunny entry point and the tests. +import { createHandler } from './server.js'; +import { createContextStore } from './context.js'; + +const CORS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', + 'Access-Control-Allow-Headers': + 'Content-Type, Accept, MCP-Protocol-Version, Mcp-Session-Id, Mcp-Method, Mcp-Name, Last-Event-ID, X-OWM-Smoke, Range, If-Match, If-None-Match', + 'Access-Control-Expose-Headers': 'MCP-Protocol-Version, Mcp-Session-Id, Content-Range, Content-Length, ETag, Accept-Ranges', + 'Access-Control-Max-Age': '86400', +}; + +function withHeaders(resp, extra) { + const headers = new Headers(resp.headers); + for (const [k, v] of Object.entries(extra)) headers.set(k, v); + return new Response(resp.body, { status: resp.status, statusText: resp.statusText, headers }); +} + +// The site's vector tiles, re-served with CORS so the MCP Apps view can read +// them from its sandbox origin. Only for a deployment whose TILE_ORIGIN is +// this script (the spike); production serves CORS from the site's own CDN. +async function proxyTiles(request, url, opts, fetchImpl) { + if (!/^\/map-data\/[a-z0-9-]+\.pmtiles$/.test(url.pathname) || !['GET', 'HEAD'].includes(request.method)) { + return new Response('not found', { status: 404, headers: CORS }); + } + const headers = {}; + for (const h of ['Range', 'If-Match', 'If-None-Match']) { + if (request.headers.get(h)) headers[h] = request.headers.get(h); + } + const upstream = await fetchImpl(`${opts.dataOrigin}${url.pathname}`, { method: request.method, headers }); + // no-store: a standalone script runs only on a cache miss, and a cached + // 206 would be served to requests for other ranges of the same file. + return withHeaders(upstream, { ...CORS, 'Cache-Control': 'no-store' }); +} + +// opts: { dataOrigin, fetchImpl?, onToolCallFor?(request) → (tool, locale) => void, +// view?: { html, tileOrigin, cartoKey }, tileProxy?: boolean } +export function createApp(opts) { + const fetchImpl = opts.fetchImpl || fetch; + // One context store per isolate, shared by every request's handler. + const store = createContextStore({ dataOrigin: opts.dataOrigin, fetchImpl }); + const mcp = createHandler({ dataOrigin: opts.dataOrigin, fetchImpl, store, onToolCallFor: opts.onToolCallFor, view: opts.view }); + return async function handle(request) { + const url = new URL(request.url); + if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: CORS }); + if (opts.tileProxy && url.pathname.startsWith('/map-data/')) return proxyTiles(request, url, opts, fetchImpl); + if (url.pathname !== '/mcp') { + return Response.json( + { error: 'not found', mcp_endpoint: `${url.origin}/mcp`, site: opts.dataOrigin }, + { status: 404, headers: { ...CORS, 'Cache-Control': 'no-store' } }, + ); + } + const resp = await mcp.fetch(request); + return withHeaders(resp, { ...CORS, 'Cache-Control': 'no-store' }); + }; +} diff --git a/mcp/src/server.js b/mcp/src/server.js new file mode 100644 index 0000000..1cf9b49 --- /dev/null +++ b/mcp/src/server.js @@ -0,0 +1,225 @@ +// Open Wine Map MCP server: a stateless, web-standard request handler. +// Runtime-agnostic (Bunny Edge Scripting in production, Node in tests): the +// entry point supplies `dataOrigin` and, optionally, a `fetch` and an +// `onToolCall` hook (analytics). The tools are the query core's TOOL_DEFS — +// the same definitions and code the map page registers as WebMCP tools. +import { McpServer, createMcpHandler, fromJsonSchema } from '@modelcontextprotocol/server'; +import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/server/validators/cf-worker'; +import { TOOL_DEFS, QueryError } from '../../scripts/_lib/assets/query_core.mjs'; +import { LOCALES, createContextStore } from './context.js'; + +// OWM_BUILD is the release's git SHA, injected by esbuild (--define) at +// deploy time; the smoke test waits until the endpoint reports it, so a +// freshly published release is what gets tested, not a lingering isolate. +/* global OWM_BUILD */ +const BUILD = typeof OWM_BUILD === 'undefined' ? 'dev' : OWM_BUILD; + +export const SERVER_INFO = { + name: 'open-wine-map', + title: 'Open Wine Map', + version: `1.0.0+${BUILD}`, + websiteUrl: 'https://www.openwinemap.com/', +}; + +const INSTRUCTIONS = [ + 'Open Wine Map covers the protected wine appellations of 19 European countries (EU PDO/PGI and the UK and Swiss schemes),', + 'built from the regulators\' own specifications. Use search_appellations to find an appellation by name,', + 'filter_appellations for structured questions (country, region, style, grape, scheme) — list_facets gives the accepted', + 'values — and get_appellation for one appellation\'s grapes, styles, terroir facts and sources.', + 'When quoting terroir facts, cite the specification URL, and Wikipedia (CC BY-SA 4.0) where a fact says so.', + 'Every result carries the appellation\'s page on www.openwinemap.com.', + 'Whenever your answer names specific appellations, call show_on_map with their slugs so the user sees them', + 'on the map, and give the map link of each.', +].join(' '); + +// The MCP Apps view (view/ → dist/view.html): the map shown by show_on_map. +export const VIEW_URI = 'ui://open-wine-map/map'; +const VIEW_MIME = 'text/html;profile=mcp-app'; +const CARTO = 'https://*.basemaps.cartocdn.com'; +const SLUG_PATTERN = '^[a-z0-9-]{1,120}$'; +const VIEW_LABELS = { + open: 'Open on Open Wine Map ↗', + site: 'Open Wine Map ↗', + zoom: 'Zoom to this appellation', + show: 'Show', + hide: 'Hide', + show_all: 'Show all', + nothing: 'No appellation to show.', + waiting: 'Waiting for the appellations…', + appellations: 'appellations', +}; +// The site opens a set of appellations from /?aocs=a,b,c (panel stack, map +// framed on all), up to the tool's own 200 slugs (≤ 10 KB of URL; the CDN +// takes 16 KB). + +const LOCALE_PROP = { + type: 'string', + enum: LOCALES, + description: 'Language of labels (country, region, style and grape names, terroir facts): en (default), fr, es, nl. Appellation names stay in the regulator\'s language.', +}; + +// Isolates that forbid `new Function` (ajv's code generation) still validate. +const validator = new CfWorkerJsonSchemaValidator(); +const asResult = obj => ({ content: [{ type: 'text', text: JSON.stringify(obj) }], structuredContent: obj }); +const fail = msg => ({ content: [{ type: 'text', text: msg }], isError: true }); + +function withLocale(inputSchema) { + return { ...inputSchema, properties: { ...inputSchema.properties, locale: LOCALE_PROP } }; +} + +export function createTools({ dataOrigin, fetchImpl = fetch, store }) { + const contexts = store || createContextStore({ dataOrigin, fetchImpl }); + + async function getAppellation(entry, slug, locale) { + if (!entry.aocs[slug]) throw new QueryError('unknown appellation slug: ' + slug); + let panel = null; + try { + const resp = await fetchImpl(`${dataOrigin}/data/d/${locale}/${encodeURIComponent(slug)}.json`); + if (resp.ok) panel = await resp.json(); + } catch { + panel = null; + } + return entry.core.full(slug, { ...entry.aocs[slug], ...panel }, panel !== null); + } + + // name → (args, locale) → result object; throws QueryError for bad input. + return { + search_appellations: async (a, entry) => entry.core.search(a), + filter_appellations: async (a, entry) => entry.core.filter(a), + get_appellation: async (a, entry, locale) => getAppellation(entry, a.slug, locale), + list_facets: async (_a, entry) => entry.core.facets(), + contexts, + }; +} + +// show_on_map: the appellations' briefs plus kind and bounding box (what the +// view needs to draw and frame them), and Markdown map links as the text every +// host shows — hosts without MCP Apps, or the model, get the links. +async function showOnMap(entry, { slugs }, locale, siteOrigin) { + const known = [...new Set(slugs)].filter(s => entry.aocs[s]); + const unknown = slugs.filter(s => !entry.aocs[s]); + if (!known.length) throw new QueryError('unknown appellation slug(s): ' + unknown.join(', ')); + const appellations = known.map(slug => { + const r = entry.aocs[slug]; + return { ...entry.core.brief(slug), kind: r.kind || null, bbox: r.bbox_villages || r.bbox || null }; + }); + const boxes = appellations.map(a => a.bbox).filter(Boolean); + const bbox = boxes.length ? [ + Math.min(...boxes.map(b => b[0])), Math.min(...boxes.map(b => b[1])), + Math.max(...boxes.map(b => b[2])), Math.max(...boxes.map(b => b[3])), + ] : null; + const home = `${siteOrigin}${locale === 'en' ? '/' : `/${locale}/`}`; + const mapUrl = appellations.length === 1 ? appellations[0].url : `${home}?aocs=${known.join(',')}`; + return { appellations, bbox, map_url: mapUrl, tiles: tilePaths(entry.tiles), unknown }; +} + +// The site's fingerprinted tile paths (/map-data/.pmtiles?v=, from +// the query context); null before the site carries them, and the view then +// falls back to the bare paths. +function tilePaths(tiles) { + const ok = p => typeof p === 'string' && /^\/map-data\/[a-z0-9-]+\.pmtiles(\?v=[0-9a-f]+)?$/.test(p); + return tiles && ok(tiles.overview) && ok(tiles.detail) ? { overview: tiles.overview, detail: tiles.detail } : null; +} + +function showOnMapText(out) { + const lines = out.appellations.map(a => `- [${a.name}](${a.url}) — ${[a.classification, a.region, a.country_name].filter(Boolean).join(' · ')}`); + if (out.appellations.length > 1) lines.push(`\nAll of them on the map: ${out.map_url}`); + if (out.unknown.length) lines.push(`(not found: ${out.unknown.join(', ')})`); + return `Shown on the map (Open Wine Map):\n${lines.join('\n')}`; +} + +function viewResource(view) { + const config = { tileOrigin: view.tileOrigin, cartoKey: view.cartoKey || '', labels: VIEW_LABELS }; + const html = view.html.replace('__OWM_VIEW_CONFIG__', () => JSON.stringify(config).replace(/ ({ contents: [viewResource(view)] })); + server.registerTool('show_on_map', { + title: 'Show on the map', + description: 'Show wine appellations on an interactive map in the conversation — their real boundaries, framed and highlighted. ' + + 'Call it whenever your answer names specific appellations (after search_appellations, filter_appellations or get_appellation), ' + + 'with their slugs; up to 200 at once.', + inputSchema: fromJsonSchema({ + type: 'object', + properties: { + slugs: { type: 'array', items: { type: 'string', pattern: SLUG_PATTERN }, minItems: 1, maxItems: 200, description: 'Appellation slugs from the other tools\' results.' }, + locale: LOCALE_PROP, + }, + required: ['slugs'], + }, validator), + annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false }, + _meta: { ui: { resourceUri: VIEW_URI } }, + }, async (args) => { + const { locale = 'en', slugs } = args || {}; + if (onToolCall) onToolCall('show_on_map', locale); + let entry; + try { + entry = await tools.contexts.get(locale); + } catch { + return fail('The appellation data is temporarily unavailable; try again shortly.'); + } + try { + const out = await showOnMap(entry, { slugs }, locale, siteOrigin); + return { content: [{ type: 'text', text: showOnMapText(out) }], structuredContent: out }; + } catch (e) { + if (e instanceof QueryError) return fail(e.message); + throw e; + } + }); + } + for (const [name, def] of Object.entries(TOOL_DEFS)) { + server.registerTool(name, { + description: def.description, + inputSchema: fromJsonSchema(withLocale(def.inputSchema), validator), + annotations: def.annotations, + }, async (args) => { + const { locale = 'en', ...rest } = args || {}; + if (onToolCall) onToolCall(name, locale); + let entry; + try { + entry = await tools.contexts.get(locale); + } catch { + return fail('The appellation data is temporarily unavailable; try again shortly.'); + } + try { + return asResult(await tools[name](rest, entry, locale)); + } catch (e) { + if (e instanceof QueryError) return fail(e.message); + throw e; + } + }); + } + return server; +} + +// onToolCallFor(request) → (tool, locale) => void | undefined: the analytics +// hook, built from the HTTP request the SDK hands each per-request server. +// view: { html, tileOrigin, cartoKey } — the MCP Apps map; omitted → no +// show_on_map tool (the data tools are unaffected). +export function createHandler({ dataOrigin, fetchImpl = fetch, onToolCallFor, store, view }) { + const tools = createTools({ dataOrigin, fetchImpl, store }); + const hook = ctx => (onToolCallFor && ctx && ctx.requestInfo ? onToolCallFor(ctx.requestInfo) : undefined); + return createMcpHandler(ctx => buildServer(tools, hook(ctx), view, dataOrigin), { + legacy: 'stateless', + responseMode: 'json', + maxRequestBodySize: 64 * 1024, + }); +} diff --git a/mcp/test/apps-host/host.js b/mcp/test/apps-host/host.js new file mode 100644 index 0000000..78a205e --- /dev/null +++ b/mcp/test/apps-host/host.js @@ -0,0 +1,31 @@ +// A minimal MCP Apps host for tests: frames the view (served from another +// origin under the CSP the spec prescribes) and drives it with the SDK's own +// AppBridge — initialize, host context, then the show_on_map tool result. +import { AppBridge, PostMessageTransport } from '@modelcontextprotocol/ext-apps/app-bridge'; + +const result = JSON.parse(document.getElementById('tool-result').textContent); +const iframe = document.getElementById('view'); +window.__hostLog = []; +const log = (...a) => { window.__hostLog.push(a.map(String).join(' ')); }; + +// Bridge first, then navigate: the view sends ui/initialize as soon as its +// script runs, before the iframe's load event (real hosts work the same way). +(async () => { + const bridge = new AppBridge( + null, + { name: 'owm-test-host', version: '0' }, + { openLinks: {}, logging: {} }, + { hostContext: { theme: document.body.dataset.theme || 'light', displayMode: 'inline', availableDisplayModes: ['inline', 'fullscreen'], containerDimensions: { maxHeight: 600 } } }, + ); + bridge.oninitialized = async () => { + log('initialized'); + await bridge.sendToolInput({ arguments: { slugs: result.appellations.map(a => a.slug) } }); + await bridge.sendToolResult({ content: [{ type: 'text', text: 'shown' }], structuredContent: result }); + log('tool-result sent'); + }; + bridge.onopenlink = async ({ url }) => { log('openLink', url); return {}; }; + bridge.onsizechange = ({ height }) => { if (height) iframe.style.height = `${height}px`; }; + await bridge.connect(new PostMessageTransport(iframe.contentWindow, iframe.contentWindow)); + log('bridge connected'); + iframe.src = iframe.dataset.src; +})(); diff --git a/mcp/test/apps_host_check.py b/mcp/test/apps_host_check.py new file mode 100644 index 0000000..b21b7bd --- /dev/null +++ b/mcp/test/apps_host_check.py @@ -0,0 +1,221 @@ +"""Renders the MCP Apps view (dist/view.html) the way an MCP Apps host does +and checks the map draws: the view on its own origin under the CSP the spec +prescribes (ext-apps SEP-1865 "CSP Construction from Metadata"), driven by the +SDK's AppBridge, tiles from a local Range + CORS server over wiki/map-data. + +Two policies: `spec` is the spec's reference CSP verbatim (it has no +worker-src, so blob: workers fall back to script-src and are refused); +`claude` adds `worker-src 'self' blob:` as Claude's sandbox does. MapLibre +needs a blob: worker, so `claude` must draw and `spec` is expected not to. + + (cd mcp && npm run build:view) + .venv/bin/python mcp/test/apps_host_check.py [--shots DIR] +""" + +from __future__ import annotations + +import argparse +import functools +import http.server +import json +import os +import socketserver +import subprocess +import sys +import threading +from pathlib import Path + +from playwright.sync_api import sync_playwright + +ROOT = Path(__file__).resolve().parents[2] +MCP = ROOT / "mcp" +sys.path.insert(0, str(ROOT / "scripts")) +import serve # noqa: E402 — the Range-capable static handler +from _lib.env import load_dotenv # noqa: E402 + +load_dotenv() +CARTO = "https://*.basemaps.cartocdn.com" + + +def reference_csp(connect: list[str], resource: list[str], worker_blob: bool) -> str: + r, c = " ".join(resource), " ".join(connect) + csp = (f"default-src 'none'; script-src 'self' 'unsafe-inline' {r}; style-src 'self' 'unsafe-inline' {r}; " + f"connect-src 'self' {c}; img-src 'self' data: {r}; font-src 'self' {r}; media-src 'self' data: {r}; " + "frame-src 'none'; object-src 'none'; base-uri 'self'") + return csp + ("; worker-src 'self' blob:" if worker_blob else "") + + +def serve_dir(handler, port: int): + srv = socketserver.ThreadingTCPServer(("127.0.0.1", port), handler) + srv.daemon_threads = True + threading.Thread(target=srv.serve_forever, daemon=True).start() + return srv + + +class TileHandler(serve.RangeHandler): + def log_message(self, *a): + pass + + def end_headers(self): + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Expose-Headers", "Content-Range, Content-Length, ETag, Accept-Ranges") + self.send_header("Access-Control-Allow-Headers", "Range, If-Match, If-None-Match") + super().end_headers() + + def do_OPTIONS(self): + self.send_response(204) + self.end_headers() + + +def tool_result(slugs: list[str], site: str) -> dict: + ctx = json.loads((ROOT / "wiki/data/mcp/en.json").read_text(encoding="utf-8")) + out = [] + for s in slugs: + r = ctx["aocs"][s] + out.append({"slug": s, "name": r["name"], "kind": r.get("kind"), "classification": r.get("class_label"), + "region": r.get("region"), "country_name": r.get("country"), "url": f"{site}/en/{s}", + "bbox": r.get("bbox_villages") or r.get("bbox")}) + bb = [b["bbox"] for b in out] + union = [min(b[0] for b in bb), min(b[1] for b in bb), max(b[2] for b in bb), max(b[3] for b in bb)] + map_url = out[0]["url"] if len(out) == 1 else f"{site}/?aocs={','.join(slugs)}" + return {"appellations": out, "bbox": union, "map_url": map_url, "tiles": ctx.get("tiles"), "unknown": []} + + +def interactions(page, frame, features_before: int, shots: Path | None) -> bool: + """The legend list: open with one row per appellation; hovering a row + lights it; the colour square hides an appellation (fewer features, site link drops + it); a row click pins it and points the site button at its page.""" + problems = [] + rows = frame.locator("#rows .row") + if frame.evaluate("document.getElementById('list').hidden") or rows.count() != 2: + problems.append(f"list hidden or {rows.count()} rows") + frame.locator("#rows .row").nth(0).hover() + if "lit" not in (rows.nth(0).get_attribute("class") or ""): + problems.append("hover did not light the row") + page.mouse.move(0, 0) + second = rows.nth(1).get_attribute("data-slug") + rows.nth(1).locator(".swatch").click() + page.wait_for_timeout(1500) + if shots: + page.screenshot(path=str(shots / "view-claude-light-hidden.png")) + after = frame.evaluate("window.__owmView.features()") + if not (0 < after < features_before): + problems.append(f"toggle: features {features_before} -> {after}") + frame.locator("#open-site").click() + page.wait_for_timeout(300) + opened = [m for m in page.evaluate("window.__hostLog") if m.startswith("openLink")] + if not opened or "aocs=" in opened[-1] or second in opened[-1]: + problems.append(f"site link with one hidden: {opened[-1:]}") + rows.nth(1).locator(".swatch").click() + rows.nth(1).locator(".row-text").click() + page.wait_for_timeout(1200) + name = frame.evaluate("document.querySelector('#rows .row.pinned .row-name')?.textContent") + button = frame.evaluate("document.getElementById('open-site').textContent") + if not name or not button.startswith(name): + problems.append(f"pin: row={name!r} button={button!r}") + if shots: + page.screenshot(path=str(shots / "view-claude-light-pinned.png")) + frame.locator("#show-all").click() + page.wait_for_timeout(800) + frame.locator("#open-site").click() + page.wait_for_timeout(300) + opened = [m for m in page.evaluate("window.__hostLog") if m.startswith("openLink")] + if "aocs=" not in opened[-1]: + problems.append(f"show all: site link {opened[-1]}") + print(" interactions:", "ok" if not problems else "; ".join(problems)) + return not problems + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--shots", type=Path, default=None) + ap.add_argument("--live", metavar="MCP_URL", default=None, + help="render the view resource as served by this endpoint (its own tiles + CSP)") + args = ap.parse_args() + host_js = subprocess.run( + ["npx", "esbuild", "test/apps-host/host.js", "--bundle", "--format=iife", "--platform=browser"], + cwd=MCP, capture_output=True, text=True, check=True).stdout + + tiles = serve_dir(functools.partial(TileHandler, directory=str(ROOT / "wiki")), 0) + tile_origin = f"http://127.0.0.1:{tiles.server_address[1]}" + view_tpl = (MCP / "dist/view.html").read_text(encoding="utf-8") + config = {"tileOrigin": tile_origin, "cartoKey": os.environ.get("CARTO_BASEMAP_KEY", ""), + "labels": {"open": "Open ↗", "site": "Open Wine Map ↗", "zoom": "zoom", "show": "show", "hide": "hide", + "show_all": "Show all", "nothing": "none", "waiting": "waiting", "appellations": "appellations"}} + view_html = view_tpl.replace("__OWM_VIEW_CONFIG__", json.dumps(config)) + domains = [tile_origin, CARTO] + if args.live: + live = json.loads(subprocess.run(["node", "test/read_view.js", args.live], cwd=MCP, + capture_output=True, text=True, check=True).stdout) + view_html = live["text"] + tile_origin = live["csp"]["connectDomains"][0] + domains = live["csp"]["connectDomains"] + csps = {"spec": reference_csp(domains, domains, False), "claude": reference_csp(domains, domains, True)} + + class ViewHandler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + policy = "claude" if "claude" in self.path else "spec" + body = view_html.encode() + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Security-Policy", csps[policy]) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *a): + pass + + view_srv = serve_dir(ViewHandler, 0) + # A different hostname from the host page → a different origin. + view_origin = f"http://localhost:{view_srv.server_address[1]}" + result = tool_result(["priorat", "montsant"], "https://www.openwinemap.com") + + def host_page(policy: str, theme: str) -> str: + return (f"" + f"" + f"") + + ok = True + with sync_playwright() as pw: + browser = pw.chromium.launch(args=["--enable-unsafe-swiftshader", "--use-gl=swiftshader"]) + for policy, expect_map in (("claude", True), ("spec", False)): + for theme in (("light", "dark") if expect_map else ("light",)): + page = browser.new_page(viewport={"width": 760, "height": 640}) + msgs: list[str] = [] + page.on("console", lambda m: msgs.append(f"{m.type}: {m.text}")) + tile_hits = [] + page.on("request", lambda r: tile_hits.append(r.url) if r.url.startswith(tile_origin) else None) + page.set_content(host_page(policy, theme), wait_until="load") + page.wait_for_timeout(9000) + frame = next(f for f in page.frames if f.url.startswith(view_origin)) + title = frame.evaluate("document.getElementById('title').textContent") + status = frame.evaluate("document.getElementById('status').textContent") + painted = frame.evaluate("""() => { const c = document.querySelector('#map canvas'); + if (!c) return 0; const g = c.getContext('webgl2') || c.getContext('webgl'); + return c.width * c.height; }""") + csp_errors = [m for m in msgs if "Content Security Policy" in m or "Refused" in m] + features = frame.evaluate("window.__owmView ? window.__owmView.features() : -1") + drew = features > 0 and title.startswith("2 ") and not status + print(f"{policy:6} {theme:5} title={title!r} status={status!r} canvas={painted} " + f"features={features} tile_requests={len(tile_hits)} csp_errors={len(csp_errors)} host={page.evaluate('window.__hostLog')}") + for m in (csp_errors[:3] if drew == expect_map else msgs[:12]): + print(" ", m[:300]) + if args.shots: + args.shots.mkdir(parents=True, exist_ok=True) + page.screenshot(path=str(args.shots / f"view-{policy}-{theme}.png")) + if drew and expect_map and theme == "light" and not interactions(page, frame, features, args.shots): + ok = False + if drew != expect_map: + ok = False + print(f" UNEXPECTED: drew={drew}, expected {expect_map}") + page.close() + browser.close() + tiles.shutdown() + view_srv.shutdown() + return 0 if ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/mcp/test/core.test.js b/mcp/test/core.test.js new file mode 100644 index 0000000..edd3a12 --- /dev/null +++ b/mcp/test/core.test.js @@ -0,0 +1,109 @@ +// The shared query core against the built query context (wiki/data/mcp/). +// These anchors are the same answers the map page's WebMCP tools give. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { WIKI, haveBuild } from './local.js'; +import { coreFromContext, createContextStore } from '../src/context.js'; +import { QueryError } from '../../scripts/_lib/assets/query_core.mjs'; + +const skip = haveBuild() ? false : 'no wiki/ build (run scripts/04_build_maps.py)'; +const load = locale => JSON.parse(readFileSync(path.join(WIKI, 'data', 'mcp', `${locale}.json`), 'utf8')); +const cores = {}; +const core = locale => (cores[locale] ||= coreFromContext(load(locale), 'https://www.openwinemap.com')); +const slugs = res => res.results.map(r => r.slug); + +test('search: exact names rank first, sub-denominations after parents', { skip }, () => { + assert.equal(core('en').search({ query: 'Priorat' }).results[0].slug, 'priorat'); + assert.equal(core('en').search({ query: 'montsant' }).results[0].slug, 'montsant'); + const rioja = core('en').search({ query: 'rioja', limit: 10 }).results; + assert.equal(rioja[0].slug, 'rioja'); + assert.ok(rioja.findIndex(r => r.is_sub_denomination) > 0); +}); + +test('search: punctuation folds and romanised Greek / Bulgarian names match', { skip }, () => { + assert.equal(core('en').search({ query: 'aloxe corton' }).results[0].slug, 'aloxe-corton'); + const naoussa = core('en').search({ query: 'naoussa' }).results[0]; + assert.equal(naoussa.country, 'gr'); + assert.ok(core('en').search({ query: 'agio oros' }).total >= 1); +}); + +test('search: country narrows; an empty query is a QueryError', { skip }, () => { + assert.ok(slugs(core('en').search({ query: 'champagne', country: 'es' })).every(s => s !== 'champagne')); + assert.throws(() => core('en').search({ query: ' - ' }), QueryError); +}); + +test('filter: grape synonyms, region labels, styles and schemes', { skip }, () => { + const garnacha = slugs(core('en').filter({ country: 'es', grape: 'Garnacha', limit: 200 })); + assert.ok(garnacha.includes('priorat') && garnacha.includes('montsant')); + assert.ok(slugs(core('en').filter({ grape: 'malbec', limit: 200 })).includes('cahors')); + assert.ok(slugs(core('en').filter({ region: 'Cataluña', limit: 200 })).includes('priorat')); + assert.ok(slugs(core('en').filter({ region: 'catalonia', limit: 200 })).includes('priorat')); + assert.equal(core('en').filter({ scheme: 'Vino de Pago', limit: 1 }).total, + core('en').filter({ scheme: 'es:vino-de-pago', limit: 1 }).total); + assert.ok(slugs(core('en').filter({ country: 'fr', style: 'sparkling', limit: 200 })).includes('champagne')); + const docg = core('en').filter({ scheme: 'docg', limit: 200 }); + assert.ok(docg.total >= 70 && docg.results.every(r => r.country === 'it')); + assert.equal(core('en').filter({ scheme: 'it:docg', limit: 200 }).total, docg.total); + assert.throws(() => core('en').filter({ grape: 'not-a-grape-at-all' }), QueryError); + assert.throws(() => core('en').filter({ region: '!!' }), QueryError); +}); + +test('filter: sub-denominations and spirits only on request', { skip }, () => { + const base = core('en').filter({ country: 'fr', limit: 1 }).total; + assert.ok(core('en').filter({ country: 'fr', include_sub_denominations: true, limit: 1 }).total > base); + assert.ok(core('en').filter({ country: 'fr', include_spirits: true, limit: 1 }).total > base); +}); + +test('full: panel merge, attribution, locale labels', { skip }, () => { + const panel = JSON.parse(readFileSync(path.join(WIKI, 'data', 'd', 'fr', 'priorat.json'), 'utf8')); + const ctx = load('fr'); + const r = core('fr').full('priorat', { ...ctx.aocs.priorat, ...panel }, true); + assert.equal(r.url, 'https://www.openwinemap.com/fr/priorat'); + assert.ok(r.grapes.principal.length > 0); + assert.ok(r.terroir_facts.length > 0 && r.terroir_facts_attribution); + assert.ok(Object.values(r.sources).every(u => /^https?:\/\//.test(u))); + const partial = core('fr').full('priorat', ctx.aocs.priorat, false); + assert.ok(partial.detail_unavailable); + assert.throws(() => core('fr').full('no-such-slug'), QueryError); +}); + +test('facets: values round-trip into filter', { skip }, () => { + const f = core('en').facets(); + assert.ok(f.countries.length >= 19); + const region = f.regions.find(r => r.country === 'es'); + assert.equal(core('en').filter({ region: region.value, limit: 1 }).total >= 1, true); + const scheme = f.schemes.find(s => s.value.includes(':')); + assert.equal(core('en').filter({ scheme: scheme.value, limit: 1 }).total, scheme.count); + const style = f.styles[0]; + assert.ok(core('en').filter({ style: style.value, limit: 1 }).total >= style.count); +}); + +test('context store: ETag revalidation, stale copy on failure, one cold load', async () => { + let t = 0; + const calls = []; + let mode = 'ok'; + const ctx = { format_version: 1, locale: 'en', aocs: { a: { name: 'A' } }, labels: {} }; + const fetchImpl = async (url, init) => { + calls.push(init && init.headers['If-None-Match']); + if (mode === 'down') return new Response('x', { status: 503 }); + if (init && init.headers['If-None-Match'] === '"v1"') return new Response(null, { status: 304 }); + return new Response(JSON.stringify(ctx), { status: 200, headers: { ETag: '"v1"' } }); + }; + const store = createContextStore({ dataOrigin: 'https://x', fetchImpl, ttlMs: 1000, now: () => t }); + const [a, b] = await Promise.all([store.get('en'), store.get('en')]); + assert.equal(calls.length, 1, 'concurrent cold loads share one fetch'); + assert.equal(a.core.search({ query: 'a' }).results[0].slug, 'a'); + assert.equal(a, b); + await store.get('en'); + assert.equal(calls.length, 1, 'fresh copy within the TTL'); + t = 2000; + await store.get('en'); + assert.equal(calls[1], '"v1"', 'revalidates with the ETag'); + t = 4000; + mode = 'down'; + const stale = await store.get('en'); + assert.equal(stale.core.search({ query: 'a' }).total, 1, 'keeps serving the copy it has'); + await assert.rejects(store.get('de')); +}); diff --git a/mcp/test/local.js b/mcp/test/local.js new file mode 100644 index 0000000..edd9fe2 --- /dev/null +++ b/mcp/test/local.js @@ -0,0 +1,52 @@ +// A local HTTP server around the app for tests: Node's http module adapted to +// the web-standard handler, with data served from the built wiki/ directory +// instead of the live site. +import http from 'node:http'; +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { createApp } from '../src/http.js'; + +export const WIKI = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../wiki'); +const DATA_ORIGIN = 'https://data.test'; + +export function haveBuild() { + return existsSync(path.join(WIKI, 'data', 'mcp', 'en.json')); +} + +export async function wikiFetch(url) { + const u = new URL(url); + if (u.origin !== DATA_ORIGIN) throw new Error(`unexpected fetch ${url}`); + try { + const body = await readFile(path.join(WIKI, decodeURIComponent(u.pathname))); + return new Response(body, { status: 200, headers: { 'Content-Type': 'application/json' } }); + } catch { + return new Response('not found', { status: 404 }); + } +} + +const VIEW = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../dist/view.html'); + +export async function startServer() { + const view = existsSync(VIEW) + ? { html: await readFile(VIEW, 'utf8'), tileOrigin: DATA_ORIGIN, cartoKey: 'test-key' } + : undefined; + const app = createApp({ dataOrigin: DATA_ORIGIN, fetchImpl: wikiFetch, view }); + const server = http.createServer(async (req, res) => { + const chunks = []; + for await (const c of req) chunks.push(c); + const body = chunks.length ? Buffer.concat(chunks) : undefined; + const request = new Request(`http://${req.headers.host}${req.url}`, { + method: req.method, + headers: req.headers, + body: req.method === 'GET' || req.method === 'HEAD' ? undefined : body, + }); + const resp = await app(request); + res.writeHead(resp.status, Object.fromEntries(resp.headers)); + res.end(Buffer.from(await resp.arrayBuffer())); + }); + await new Promise(r => server.listen(0, '127.0.0.1', r)); + const { port } = server.address(); + return { url: `http://127.0.0.1:${port}/mcp`, close: () => new Promise(r => server.close(r)) }; +} diff --git a/mcp/test/mcp_calls.js b/mcp/test/mcp_calls.js new file mode 100644 index 0000000..52980ce --- /dev/null +++ b/mcp/test/mcp_calls.js @@ -0,0 +1,20 @@ +// Reads [[tool, args], …] as JSON on stdin, answers each through the MCP +// server (local, data from wiki/) with the official client, and prints +// [structuredContent | {isError, text}, …] as JSON. Used by parity_webmcp.py. +import { startServer } from './local.js'; +import { smoke } from './smoke.js'; + +const input = await new Promise(resolve => { + let buf = ''; + process.stdin.on('data', c => { buf += c; }); + process.stdin.on('end', () => resolve(buf)); +}); +const calls = JSON.parse(input); +const srv = await startServer(); +try { + const r = await smoke(srv.url, 'modern', calls); + const out = r.results.map(x => (x.isError ? { isError: true, text: x.content[0].text } : x.structuredContent)); + process.stdout.write(JSON.stringify({ origin: 'https://data.test', results: out })); +} finally { + await srv.close(); +} diff --git a/mcp/test/parity_webmcp.py b/mcp/test/parity_webmcp.py new file mode 100644 index 0000000..f1315c8 --- /dev/null +++ b/mcp/test/parity_webmcp.py @@ -0,0 +1,122 @@ +"""Parity: the map page's WebMCP tools and the MCP server must answer alike. + +Serves wiki/ locally, loads each locale's homepage in headless Chromium with a +stub `document.modelContext` that captures the tools app.js registers, calls +them, then asks the MCP server (mcp/test/mcp_calls.js, same wiki/ data) the +same questions and diffs the answers (page origin normalised). Needs a stage-04 +build, Playwright and `npm ci` in mcp/. + + .venv/bin/python mcp/test/parity_webmcp.py +""" + +from __future__ import annotations + +import json +import os +import socket +import subprocess +import sys +import time +from pathlib import Path + +from playwright.sync_api import sync_playwright + +ROOT = Path(__file__).resolve().parents[2] + +CALLS = { + "en": [ + ["search_appellations", {"query": "priorat"}], + ["search_appellations", {"query": "naoussa"}], + ["search_appellations", {"query": "agio oros"}], + ["search_appellations", {"query": "saint", "country": "fr", "limit": 30}], + ["filter_appellations", {"country": "es", "grape": "Garnacha", "limit": 200}], + ["filter_appellations", {"scheme": "docg", "style": "red", "limit": 200}], + ["filter_appellations", {"region": "Bourgogne", "include_sub_denominations": True, "limit": 200}], + ["filter_appellations", {"grape": "malbec", "main_grape_only": True, "limit": 200}], + ["filter_appellations", {"scheme": "es:vino-de-pago", "limit": 200}], + ["filter_appellations", {"scheme": "Vino de Pago", "limit": 200}], + ["filter_appellations", {"style": "!!"}], + ["get_appellation", {"slug": "montsant"}], + ["get_appellation", {"slug": "champagne"}], + ["get_appellation", {"slug": "no-such-slug"}], + ["list_facets", {}], + ], + "fr": [ + ["get_appellation", {"slug": "priorat"}], + ["filter_appellations", {"region": "Catalunya", "limit": 200}], + ["list_facets", {}], + ], + "nl": [["get_appellation", {"slug": "mosel"}], ["search_appellations", {"query": "mosel"}]], +} + +STUB = """ +window.__owmTools = {}; +Object.defineProperty(document, 'modelContext', { value: { + registerTool(t) { window.__owmTools[t.name] = t; }, +}}); +""" + + +def free_port() -> int: + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def normalise(obj, origin: str): + return json.loads(json.dumps(obj).replace(origin, "ORIGIN")) + + +def page_answers(page, base: str, locale: str, calls): + page.goto(f"{base}/" if locale == "en" else f"{base}/{locale}/", wait_until="networkidle") + page.wait_for_function("Object.keys(window.__owmTools).length >= 5", timeout=30000) + out = [] + for name, args in calls: + res = page.evaluate( + "async ([n, a]) => window.__owmTools[n].execute(a)", [name, args] + ) + out.append({"isError": True, "text": res["content"][0]["text"]} if res.get("isError") + else res["structuredContent"]) + return out + + +def mcp_answers(locale: str, calls): + with_locale = [[n, {**a, "locale": locale}] for n, a in calls] + proc = subprocess.run(["node", "test/mcp_calls.js"], cwd=ROOT / "mcp", input=json.dumps(with_locale), + capture_output=True, text=True, check=True) + payload = json.loads(proc.stdout) + return normalise(payload["results"], payload["origin"]) + + +def main() -> int: + port = free_port() + server = subprocess.Popen([sys.executable, "scripts/serve.py"], cwd=ROOT, + env={**os.environ, "PORT": str(port)}, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + base = f"http://127.0.0.1:{port}" + failures = 0 + try: + time.sleep(1.0) + with sync_playwright() as pw: + browser = pw.chromium.launch(args=["--enable-unsafe-swiftshader", "--use-gl=swiftshader"]) + page = browser.new_page() + page.add_init_script(STUB) + for locale, calls in CALLS.items(): + web = normalise(page_answers(page, base, locale, calls), base) + srv = mcp_answers(locale, calls) + for (name, args), w, m in zip(calls, web, srv): + ok = w == m + failures += not ok + print(f"{'ok ' if ok else 'DIFF'} {locale} {name} {json.dumps(args, ensure_ascii=False)}") + if not ok: + print(" page:", json.dumps(w, ensure_ascii=False)[:600]) + print(" mcp: ", json.dumps(m, ensure_ascii=False)[:600]) + browser.close() + finally: + server.terminate() + print(f"{failures} difference(s)") + return 1 if failures else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/mcp/test/protocol.test.js b/mcp/test/protocol.test.js new file mode 100644 index 0000000..09eaeed --- /dev/null +++ b/mcp/test/protocol.test.js @@ -0,0 +1,97 @@ +import { test, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { startServer, haveBuild } from './local.js'; +import { smoke, MODES, SMOKE_CALLS } from './smoke.js'; + +const skip = haveBuild() ? false : 'no wiki/ build (run scripts/04_build_maps.py)'; +let srv; +before(async () => { if (!skip) srv = await startServer(); }); +after(async () => { if (srv) await srv.close(); }); + +const TOOLS = ['filter_appellations', 'get_appellation', 'list_facets', 'search_appellations', 'show_on_map']; + +for (const mode of Object.keys(MODES)) { + test(`connects and answers every tool (${mode})`, { skip }, async () => { + const r = await smoke(srv.url, mode, SMOKE_CALLS); + assert.equal(r.era, mode === 'legacy' ? 'legacy' : 'modern'); + assert.deepEqual([...r.tools].sort(), TOOLS); + const [search, get, filter, facets] = r.results.map(x => x.structuredContent); + assert.equal(search.results[0].slug, 'priorat'); + assert.equal(get.slug, 'priorat'); + assert.ok(get.grapes.principal.length > 0); + assert.ok(get.terroir_facts.length > 0, 'priorat carries terroir facts'); + assert.equal(get.detail_unavailable, undefined); + assert.match(get.url, /\/fr\/priorat$/); + assert.ok(filter.results.some(x => x.slug === 'priorat')); + assert.ok(facets.countries.length >= 19); + }); +} + +test('tool errors are results, not protocol errors', { skip }, async () => { + const r = await smoke(srv.url, 'modern', [ + ['get_appellation', { slug: 'no-such-slug' }], + ['filter_appellations', { grape: 'not-a-grape-at-all' }], + ['filter_appellations', { style: '!!' }], + ['search_appellations', { query: ' ' }], + ]); + for (const res of r.results) assert.equal(res.isError, true); + assert.match(r.results[0].content[0].text, /unknown appellation slug/); +}); + +test('arguments outside the schema are rejected', { skip }, async () => { + const r = await smoke(srv.url, 'modern', [ + ['get_appellation', { slug: '../../etc/passwd' }], + ['search_appellations', { query: 'x', limit: 500 }], + ['search_appellations', { query: 'x', locale: 'de' }], + ]); + for (const res of r.results) assert.equal(res.isError, true); +}); + +test('GET is 405 and other paths are 404, with CORS', { skip }, async () => { + const get = await fetch(srv.url, { headers: { Accept: 'text/event-stream' } }); + assert.equal(get.status, 405); + assert.equal(get.headers.get('access-control-allow-origin'), '*'); + const other = await fetch(srv.url.replace('/mcp', '/')); + assert.equal(other.status, 404); + const pre = await fetch(srv.url, { method: 'OPTIONS' }); + assert.equal(pre.status, 204); +}); + +test('show_on_map: briefs with bbox, union bbox, map links, UI resource', { skip }, async () => { + const { Client, StreamableHTTPClientTransport } = await import('@modelcontextprotocol/client'); + const client = new Client({ name: 't', version: '0' }, { versionNegotiation: { mode: { pin: '2026-07-28' } } }); + await client.connect(new StreamableHTTPClientTransport(new URL(srv.url))); + try { + const tool = (await client.listTools()).tools.find(t => t.name === 'show_on_map'); + assert.equal(tool._meta.ui.resourceUri, 'ui://open-wine-map/map'); + const res = await client.callTool({ name: 'show_on_map', arguments: { slugs: ['priorat', 'montsant', 'nope'] } }); + const out = res.structuredContent; + assert.deepEqual(out.appellations.map(a => a.slug), ['priorat', 'montsant']); + assert.deepEqual(out.unknown, ['nope']); + assert.equal(out.bbox.length, 4); + assert.ok(out.appellations.every(a => a.bbox && a.kind && a.url)); + assert.match(res.content[0].text, /\[Priorat\]\(https:\/\/data\.test\/en\/priorat\)/); + assert.equal(out.map_url, 'https://data.test/?aocs=priorat,montsant'); + assert.match(out.tiles.overview, /^\/map-data\/appellations-overview\.pmtiles\?v=[0-9a-f]{8}$/); + assert.match(out.tiles.detail, /^\/map-data\/appellations\.pmtiles\?v=[0-9a-f]{8}$/); + assert.ok(res.content[0].text.includes('All of them on the map: https://data.test/?aocs=priorat,montsant')); + // A set over 50 (the 51 Alsace grands crus) still links the set, not the homepage. + const crus = (await client.callTool({ name: 'search_appellations', arguments: { query: 'Alsace grand cru', limit: 60 } })) + .structuredContent.results.map(r => r.slug).filter(s => s.startsWith('alsace-grand-cru-')); + assert.ok(crus.length > 50, `only ${crus.length} grands crus found`); + const many = await client.callTool({ name: 'show_on_map', arguments: { slugs: crus } }); + assert.equal(many.structuredContent.map_url, `https://data.test/?aocs=${crus.join(',')}`); + const one = await client.callTool({ name: 'show_on_map', arguments: { slugs: ['priorat'], locale: 'fr' } }); + assert.equal(one.structuredContent.map_url, 'https://data.test/fr/priorat'); + assert.ok(!one.content[0].text.includes('All of them')); + const read = await client.readResource({ uri: 'ui://open-wine-map/map' }); + const c = read.contents[0]; + assert.equal(c.mimeType, 'text/html;profile=mcp-app'); + assert.deepEqual(c._meta.ui.csp.connectDomains, ['https://data.test', 'https://*.basemaps.cartocdn.com']); + assert.ok(c.text.includes('"tileOrigin":"https://data.test"') && !c.text.includes('__OWM_VIEW_CONFIG__')); + const bad = await client.callTool({ name: 'show_on_map', arguments: { slugs: ['nope'] } }); + assert.equal(bad.isError, true); + } finally { + await client.close(); + } +}); diff --git a/mcp/test/read_view.js b/mcp/test/read_view.js new file mode 100644 index 0000000..56f7303 --- /dev/null +++ b/mcp/test/read_view.js @@ -0,0 +1,10 @@ +// Prints the live MCP Apps view resource of an endpoint as JSON +// ({text, csp}) — used by apps_host_check.py --live. +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +const client = new Client({ name: 'owm-view-check', version: '0' }, { versionNegotiation: { mode: { pin: '2026-07-28' } } }); +await client.connect(new StreamableHTTPClientTransport(new URL(process.argv[2]), { requestInit: { headers: { 'X-OWM-Smoke': '1' } } })); +const res = await client.readResource({ uri: 'ui://open-wine-map/map' }); +const c = res.contents[0]; +process.stdout.write(JSON.stringify({ text: c.text, csp: c._meta.ui.csp })); +await client.close(); diff --git a/mcp/test/smoke.js b/mcp/test/smoke.js new file mode 100644 index 0000000..4551461 --- /dev/null +++ b/mcp/test/smoke.js @@ -0,0 +1,85 @@ +// Connects the official MCP client to `url` once per protocol mode (2025-era +// legacy handshake, 2026-07-28 pinned, auto-negotiated) and exercises the +// server. Used by the tests against a local server and, as a CLI, against a +// deployed endpoint: node test/smoke.js https://mcp.openwinemap.com/mcp +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; + +export const MODES = { + legacy: undefined, + modern: { mode: { pin: '2026-07-28' } }, + auto: { mode: 'auto' }, +}; + +export async function smoke(url, mode, calls) { + const opts = MODES[mode] ? { versionNegotiation: MODES[mode] } : {}; + const client = new Client({ name: 'owm-smoke', version: '0' }, opts); + // X-OWM-Smoke: the edge script does not count test traffic as `MCP Tool` events. + const transport = new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers: { 'X-OWM-Smoke': '1' } } }); + const t0 = performance.now(); + await client.connect(transport); + const connectMs = performance.now() - t0; + try { + const tools = (await client.listTools()).tools.map(t => t.name); + const results = []; + for (const [name, args] of calls) results.push(await client.callTool({ name, arguments: args })); + return { + mode, + era: client.getProtocolEra(), + version: client.getNegotiatedProtocolVersion(), + server: client.getServerVersion(), + instructions: client.getInstructions ? client.getInstructions() : undefined, + connectMs: Math.round(connectMs), + tools, + results, + }; + } finally { + await client.close(); + } +} + +export const SMOKE_CALLS = [ + ['search_appellations', { query: 'priorat', limit: 3 }], + ['get_appellation', { slug: 'priorat', locale: 'fr' }], + ['filter_appellations', { country: 'es', grape: 'Garnacha', style: 'red', limit: 200 }], + ['list_facets', {}], +]; + +// Poll until the endpoint reports `version` (a just-published release can +// take a moment to replace the isolates still serving the previous one). +export async function waitForVersion(url, version, timeoutMs = 120000) { + const until = Date.now() + timeoutMs; + let seen; + while (Date.now() < until) { + try { + const r = await smoke(url, 'modern', []); + seen = r.server && r.server.version; + if (seen === version) return seen; + } catch (e) { + seen = String((e && e.message) || e); + } + await new Promise(res => setTimeout(res, 3000)); + } + throw new Error(`${url} still reports ${seen}, expected ${version}`); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + const [url, expect] = [process.argv[2], process.argv.indexOf('--expect-version')]; + if (!url) throw new Error('usage: node test/smoke.js [--expect-version V]'); + if (expect > 0) console.log(JSON.stringify({ version: await waitForVersion(url, process.argv[expect + 1]) })); + for (const mode of Object.keys(MODES)) { + try { + const r = await smoke(url, mode, SMOKE_CALLS); + const [search, get, filter, facets] = r.results.map(x => x.structuredContent || x.content); + console.log(JSON.stringify({ + mode, era: r.era, version: r.version, connectMs: r.connectMs, tools: r.tools, + search_top: search.results && search.results.map(x => x.slug), + get: get.name && { name: get.name, region: get.region, grapes: get.grapes.principal.length, facts: (get.terroir_facts || []).length }, + filter_total: filter.total, + facets: facets.countries && { countries: facets.countries.length, regions: facets.regions.length }, + })); + } catch (e) { + console.log(JSON.stringify({ mode, error: String((e && e.message) || e) })); + process.exitCode = 1; + } + } +} diff --git a/mcp/view/view.html b/mcp/view/view.html new file mode 100644 index 0000000..d376de1 --- /dev/null +++ b/mcp/view/view.html @@ -0,0 +1,70 @@ + + + + + +Open Wine Map + + + + +
+
+
Loading the map…
+
+ + + + +
+ +
+ + + + diff --git a/mcp/view/view.js b/mcp/view/view.js new file mode 100644 index 0000000..d0c27dc --- /dev/null +++ b/mcp/view/view.js @@ -0,0 +1,337 @@ +// The MCP Apps view of `show_on_map`: a MapLibre map of the requested +// appellations, drawn from the site's own vector tiles over the CARTO basemap. +// Runs in the host's sandboxed iframe; everything it loads is declared in the +// resource's `_meta.ui.csp` (server.js). MapLibre 4's UMD build starts its +// worker from a blob: URL, which the MCP Apps hosts allow. +// +// Interaction: the title opens a legend list — one colour per appellation (up +// to PALETTE.length of them), hover a row or a polygon to highlight the +// other, click a row to zoom to it (and point "Open ↗" at its page), the +// colour square toggles it, "Show all" resets. +import maplibregl from 'maplibre-gl'; +import { Protocol } from 'pmtiles'; +import { App } from '@modelcontextprotocol/ext-apps'; + +const CONFIG = JSON.parse(document.getElementById('owm-config').textContent); +const KIND_COLOUR = { IGP: '#6e7546', other: '#934050' }; +// Tableau 10: distinct, readable on both basemaps. +const PALETTE = ['#4e79a7', '#f28e2b', '#e15759', '#76b7b2', '#59a14f', '#edc948', '#b07aa1', '#ff9da7', '#9c755f', '#bab0ac']; +const OVERVIEW_MAX_ZOOM = 11.9; // the site's LOD: footprints below, parcels from z11 +const DETAIL_MIN_ZOOM = 11; +const FILLS = ['fill-overview', 'fill-detail']; +const LINES = ['line-overview', 'line-detail']; + +const $ = id => document.getElementById(id); +const status = msg => { $('status').textContent = msg || ''; $('status').hidden = !msg; }; + +let map = null; +let theme = 'light'; +let data = null; // the tool result: { appellations, bbox, map_url } +let colours = {}; // slug → fill colour +const hidden = new Set(); // slugs toggled off in the list +let pinned = null; // slug the user zoomed to (row or polygon click) +let hovered = null; // slug under the pointer (row or polygon) + +function cartoTiles(style) { + const auth = CONFIG.cartoKey ? `?key=${encodeURIComponent(CONFIG.cartoKey)}` : ''; + return ['a', 'b', 'c'].map(s => `https://${s}.basemaps.cartocdn.com/${style}/{z}/{x}/{y}.png${auth}`); +} + +const lineColour = () => (theme === 'dark' ? '#f2e6d0' : '#2a1014'); + +function applyTheme(t) { + theme = t === 'dark' ? 'dark' : 'light'; + document.documentElement.classList.toggle('dark', theme === 'dark'); + if (!map || !map.getLayer('basemap-light')) return; + map.setLayoutProperty('basemap-light', 'visibility', theme === 'dark' ? 'none' : 'visible'); + map.setLayoutProperty('basemap-dark', 'visibility', theme === 'dark' ? 'visible' : 'none'); + for (const id of LINES) if (map.getLayer(id)) map.setPaintProperty(id, 'line-color', lineColour()); +} + +function setFeatureFlag(slug, key, on) { + if (!map || !slug) return; + for (const source of ['overview', 'detail']) { + try { map.setFeatureState({ source, sourceLayer: 'appellations', id: slug }, { [key]: on }); } catch { /* not loaded yet */ } + } +} + +function buildMap() { + // The site's versioned paths (the files are browser-cached for 30 days, so + // a deploy must change the URL); bare paths until the server has them. + const tiles = (data && data.tiles) || { overview: '/map-data/appellations-overview.pmtiles', detail: '/map-data/appellations.pmtiles' }; + const protocol = new Protocol(); + maplibregl.addProtocol('pmtiles', protocol.tile); + const attribution = '© OpenStreetMap © CARTO · Open Wine Map'; + map = new maplibregl.Map({ + container: 'map', + style: { + version: 8, + sources: { + 'basemap-light': { type: 'raster', tileSize: 256, tiles: cartoTiles('rastertiles/voyager'), attribution }, + 'basemap-dark': { type: 'raster', tileSize: 256, tiles: cartoTiles('dark_all'), attribution }, + overview: { type: 'vector', url: `pmtiles://${CONFIG.tileOrigin}${tiles.overview}`, promoteId: 'slug' }, + detail: { type: 'vector', url: `pmtiles://${CONFIG.tileOrigin}${tiles.detail}`, promoteId: 'slug' }, + }, + layers: [ + { id: 'basemap-light', type: 'raster', source: 'basemap-light' }, + { id: 'basemap-dark', type: 'raster', source: 'basemap-dark', layout: { visibility: 'none' } }, + ], + }, + attributionControl: { compact: true }, + dragRotate: false, + pitchWithRotate: false, + }); + map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'bottom-right'); + map.on('error', e => console.warn('map error', e && e.error)); + const fills = () => FILLS.filter(id => map.getLayer(id)); + map.on('click', e => { + const f = data ? map.queryRenderedFeatures(e.point, { layers: fills() })[0] : null; + if (!f) return; + focus(f.properties.slug, { fly: false }); + showPopup(f.properties.slug, e.lngLat); + }); + map.on('mousemove', e => { + const f = map.queryRenderedFeatures(e.point, { layers: fills() })[0]; + map.getCanvas().style.cursor = f ? 'pointer' : ''; + hover(f ? f.properties.slug : null); + }); + map.getCanvas().addEventListener('mouseleave', () => hover(null)); + return new Promise(resolve => map.on('load', resolve)); +} + +// Black or white check mark, whichever reads on the swatch colour. +function inkOn(hex) { + const [r, g, b] = [1, 3, 5].map(i => parseInt(hex.slice(i, i + 2), 16)); + return 0.299 * r + 0.587 * g + 0.114 * b > 150 ? '#222' : '#fff'; +} + +function colourFor(a, i, n) { + if (n <= PALETTE.length) return PALETTE[i]; + return a.kind === 'IGP' ? KIND_COLOUR.IGP : KIND_COLOUR.other; +} + +function addAppellationLayers() { + for (const id of [...FILLS, ...LINES]) if (map.getLayer(id)) map.removeLayer(id); + const slugs = data.appellations.map(a => a.slug); + const matchColour = ['match', ['get', 'slug'], ...slugs.flatMap(s => [s, colours[s]]), KIND_COLOUR.other]; + const visible = ['in', ['get', 'slug'], ['literal', slugs.filter(s => !hidden.has(s))]]; + const over = { 'source-layer': 'appellations', maxzoom: OVERVIEW_MAX_ZOOM, filter: visible }; + const detail = { 'source-layer': 'appellations', minzoom: DETAIL_MIN_ZOOM, filter: visible }; + const lit = ['boolean', ['feature-state', 'lit'], false]; + // One lit: it stands out, the others fade back; none lit: all at 0.55. + const fill = { + 'fill-color': matchColour, + 'fill-opacity': ['case', lit, 0.8, ['boolean', ['feature-state', 'dim'], false], 0.18, 0.55], + }; + const line = { 'line-color': lineColour(), 'line-width': ['case', lit, 3, 1.1] }; + const sort = { 'fill-sort-key': ['-', 0, ['get', 'area']] }; + map.addLayer({ id: 'fill-overview', type: 'fill', source: 'overview', ...over, layout: sort, paint: fill }); + map.addLayer({ id: 'fill-detail', type: 'fill', source: 'detail', ...detail, layout: sort, paint: fill }); + map.addLayer({ id: 'line-overview', type: 'line', source: 'overview', ...over, paint: line }); + map.addLayer({ id: 'line-detail', type: 'line', source: 'detail', ...detail, paint: line }); + repaintState(); +} + +// Feature state from (hovered || pinned): that one lit, the others dimmed — +// unless it is hidden, which must not dim what is still shown. +function repaintState() { + const shown = s => (s && !hidden.has(s) ? s : null); + const lit = shown(hovered) || shown(pinned); + for (const a of data.appellations) { + setFeatureFlag(a.slug, 'lit', a.slug === lit); + setFeatureFlag(a.slug, 'dim', !!lit && a.slug !== lit); + } + for (const row of document.querySelectorAll('#rows .row')) { + row.classList.toggle('lit', row.dataset.slug === lit); + row.classList.toggle('pinned', row.dataset.slug === pinned); + } + const target = pinned && data.appellations.find(a => a.slug === pinned); + $('open-site').textContent = target ? `${target.name} ↗` : CONFIG.labels.site; +} + +function hover(slug) { + if (slug === hovered) return; + hovered = slug; + repaintState(); +} + +function fitTo(bbox, maxZoom) { + if (bbox) map.fitBounds([[bbox[0], bbox[1]], [bbox[2], bbox[3]]], { padding: 40, maxZoom, duration: 400 }); +} + +function focus(slug, { fly = true } = {}) { + const a = data.appellations.find(x => x.slug === slug); + if (!a) return; + pinned = slug; + if (hidden.delete(slug)) { addAppellationLayers(); renderList(); } + if (fly) fitTo(a.bbox, 12); + repaintState(); + const row = document.querySelector(`#rows .row[data-slug="${CSS.escape(slug)}"]`); + if (row) row.scrollIntoView({ block: 'nearest' }); +} + +function showAll() { + pinned = null; + hidden.clear(); + addAppellationLayers(); + renderList(); + fitTo(data.bbox, data.appellations.length === 1 ? 12 : 10); +} + +function showPopup(slug, lngLat) { + const a = data.appellations.find(x => x.slug === slug); + if (!a) return; + const box = document.createElement('div'); + const name = document.createElement('div'); + name.className = 'popup-name'; + name.textContent = a.name; + const meta = document.createElement('div'); + meta.className = 'popup-meta'; + meta.textContent = [a.classification, a.region, a.country_name].filter(Boolean).join(' · '); + const open = document.createElement('button'); + open.className = 'popup-open'; + open.type = 'button'; + open.textContent = CONFIG.labels.open; + open.addEventListener('click', () => app.openLink({ url: a.url })); + box.append(name, meta, open); + new maplibregl.Popup({ closeButton: true, maxWidth: '260px' }).setLngLat(lngLat).setDOMContent(box).addTo(map); +} + +function toggleHidden(slug) { + if (!hidden.delete(slug)) hidden.add(slug); + if (pinned === slug && hidden.has(slug)) pinned = null; + addAppellationLayers(); + renderList(); +} + +function renderList() { + const list = $('rows'); + list.replaceChildren(); + for (const a of data.appellations) { + const off = hidden.has(a.slug); + const row = document.createElement('div'); + row.className = 'row' + (off ? ' off' : ''); + row.dataset.slug = a.slug; + // The colour square is the toggle: filled with a check = shown, hollow = hidden. + const sw = document.createElement('button'); + sw.type = 'button'; + sw.className = 'swatch'; + sw.style.setProperty('--c', colours[a.slug]); + sw.style.color = inkOn(colours[a.slug]); + sw.textContent = off ? '' : '✓'; + sw.title = off ? CONFIG.labels.show : CONFIG.labels.hide; + sw.setAttribute('aria-label', `${off ? CONFIG.labels.show : CONFIG.labels.hide}: ${a.name}`); + sw.setAttribute('aria-pressed', String(!off)); + sw.addEventListener('click', () => toggleHidden(a.slug)); + const text = document.createElement('button'); + text.type = 'button'; + text.className = 'row-text'; + text.title = CONFIG.labels.zoom; + const nm = document.createElement('span'); + nm.className = 'row-name'; + nm.textContent = a.name; + const meta = document.createElement('span'); + meta.className = 'row-meta'; + meta.textContent = [a.classification, a.region].filter(Boolean).join(' · '); + text.append(nm, meta); + text.addEventListener('click', () => focus(a.slug)); + row.addEventListener('mouseenter', () => hover(a.slug)); + row.addEventListener('mouseleave', () => hover(null)); + row.append(sw, text); + list.append(row); + } + repaintState(); +} + +function toggleList(open) { + const show = open === undefined ? $('list').hidden : open; + $('list').hidden = !show; + $('title').setAttribute('aria-expanded', String(show)); +} + +async function render(result) { + data = result; + if (!data || !Array.isArray(data.appellations) || !data.appellations.length) { + status(CONFIG.labels.nothing); + return; + } + const n = data.appellations.length; + colours = Object.fromEntries(data.appellations.map((a, i) => [a.slug, colourFor(a, i, n)])); + hidden.clear(); + pinned = null; + if (!map) await buildMap(); + applyTheme(theme); + addAppellationLayers(); + renderList(); + fitTo(data.bbox, n === 1 ? 12 : 10); + status(''); + // Test hook (apps_host_check.py): how many requested appellations are drawn. + window.__owmView = { + features: () => map.queryRenderedFeatures({ layers: FILLS.filter(id => map.getLayer(id)) }).length, + }; + $('title').textContent = n === 1 ? `${data.appellations[0].name} ▾` : `${n} ${CONFIG.labels.appellations} ▾`; + $('title').hidden = false; + $('show-all').textContent = CONFIG.labels.show_all; + $('open-site').hidden = false; + // A multi-appellation answer opens the list, so the colours are explained. + toggleList(n > 1); +} + +const app = new App( + { name: 'open-wine-map-view', version: '1.0.0' }, + { availableDisplayModes: ['inline', 'fullscreen'] }, + { autoResize: true }, +); + +app.ontoolresult = params => { + render(params && params.structuredContent).catch(e => status(String((e && e.message) || e))); +}; +app.onhostcontextchanged = ctx => { + if (ctx && ctx.theme) applyTheme(ctx.theme); + if (ctx && ctx.displayMode) { + document.documentElement.classList.toggle('fullscreen', ctx.displayMode === 'fullscreen'); + if (map) setTimeout(() => map.resize(), 0); + } +}; + +$('title').addEventListener('click', () => toggleList()); +$('show-all').addEventListener('click', () => showAll()); +// The site link follows the view: the appellation zoomed to, else the ones +// still shown (the server's ?aocs= link, minus what the reader hid). +function siteUrl() { + const target = pinned && data.appellations.find(a => a.slug === pinned); + if (target) return target.url; + const shown = data.appellations.filter(a => !hidden.has(a.slug)); + if (shown.length === 1) return shown[0].url; + if (hidden.size && shown.length && data.map_url) { + try { + const u = new URL(data.map_url); + if (u.searchParams.has('aocs')) { + u.searchParams.set('aocs', shown.map(a => a.slug).join(',')); + return u.toString().replace(/%2C/g, ','); + } + } catch (e) { /* fall back to the server's link */ } + } + return data.map_url || data.appellations[0].url; +} + +$('open-site').addEventListener('click', () => { + if (data) app.openLink({ url: siteUrl() }); +}); + +async function main() { + await app.connect(); + const ctx = app.getHostContext && app.getHostContext(); + if (ctx && ctx.theme) applyTheme(ctx.theme); + const modes = (ctx && ctx.availableDisplayModes) || []; + if (modes.includes('fullscreen')) { + $('fullscreen').hidden = false; + $('fullscreen').onclick = async () => { + const isFull = document.documentElement.classList.contains('fullscreen'); + await app.requestDisplayMode({ mode: isFull ? 'inline' : 'fullscreen' }); + }; + } + if (!data) status(CONFIG.labels.waiting); +} + +main().catch(e => status(String((e && e.message) || e))); diff --git a/scripts/04_build_maps.py b/scripts/04_build_maps.py index d05eb95..8675619 100644 --- a/scripts/04_build_maps.py +++ b/scripts/04_build_maps.py @@ -169,6 +169,7 @@ from _lib.lu.geometry import LUPolygonIndex from _lib.lu.region import derive_region as derive_lu_region from _lib.map_template import ( + MCP_ENDPOINT_URL, STARTUP_AOCS_FIELDS, build_country_labels, build_labels, @@ -4641,6 +4642,14 @@ def _src_lang_for(slug: str) -> str: if _old.name != app_filename: _old.unlink() (assets_dir / app_filename).write_bytes(app_bytes) + # The remote MCP server's query context: stable path, not hashed (the + # edge script fetches it by name and revalidates with its ETag). + query_filename, query_bytes = assets["query"] + query_path = data_dir / "mcp" / query_filename + query_path.parent.mkdir(parents=True, exist_ok=True) + if not query_path.exists() or query_path.read_bytes() != query_bytes: + query_path.write_bytes(query_bytes) + print(f"[mcp] {query_path.relative_to(WIKI)}: {len(query_bytes):,} bytes", file=sys.stderr) # Per-slug panel payload — the heavy detail the map loads lazily on # panel open (summary, terroir facts, sources, grape display-names, # dűlők, menzioni, notes, …). One small JSON per slug per locale at @@ -4943,6 +4952,20 @@ def _write_llms_txt(entity_entries: list[tuple[str, str, str]]) -> None: lines.append(f"- [Source code (GitHub)]({GITHUB_URL})") lines.append(f"- [Sitemap]({SITE_BASE_URL}/sitemap.xml)") lines.append("") + lines += [ + "## For AI agents", + "", + f"- [MCP server]({MCP_ENDPOINT_URL}): a remote MCP server (Streamable HTTP, " + "no authentication) with the tools search_appellations, filter_appellations, " + "get_appellation and list_facets, in en / fr / es / nl, and show_on_map, which " + "draws the appellations on an interactive map in clients that support MCP Apps. " + "Add it as a custom connector.", + "- Every map page registers the same search tools as WebMCP tools " + "(document.modelContext; Chrome origin trial).", + "- Per-appellation pages (below) are server-rendered: grapes, styles, terroir " + "facts and links to the regulator's specification.", + "", + ] by_country: dict[str, list[tuple[str, str]]] = {} for slug, name, country in entity_entries: diff --git a/scripts/_lib/assets/app.js b/scripts/_lib/assets/app.js index a92626a..ab8c70d 100644 --- a/scripts/_lib/assets/app.js +++ b/scripts/_lib/assets/app.js @@ -1,4 +1,8 @@ + // The shared query core (scripts/_lib/assets/query_core.mjs), inlined here + // at build time: search, filter and record shaping, identical to the MCP + // server's. Instantiated as QC once the build tables below are defined. + __OWM_query_core__ const AOCS = (window.__OWM_DATA && window.__OWM_DATA.aocs) || {}; // The server-rendered #ssr-content card is hidden pre-paint by the `js` // class (see the head script). Every path on which the live panel cannot @@ -45,10 +49,6 @@ // in its sub-line (a Wikidata or gettext label, never an alias or a // derived romanisation). The displayed label stays native either way. const REGION_SEARCH_TERMS = __OWM_region_search_terms_json__; - function regionSearchForms(region) { - const t = REGION_SEARCH_TERMS[region]; - return t ? t.forms : []; - } // Classification facet (aging / Prädikat / selection tiers) — a facet parallel // to styles; same tree/descendants/labels/synonym shape. const FACET_CLASS_TREE = __OWM_class_tree_json__; @@ -110,15 +110,6 @@ // auxerrois]). Sorted by global usage; the row's `.name` span includes // every synonym so the per-facet search input matches any spelling. const GRAPE_SYNONYMS = __OWM_grape_synonyms_json__; - function expandGrapeSet(set) { - if (!set || !set.size) return set; - const out = new Set(set); - for (const slug of set) { - const sibs = VIVC_SIBLINGS[slug]; - if (sibs) for (const s of sibs) out.add(s); - } - return out; - } function grapeSynonymsHtml(canonSlug) { const syns = GRAPE_SYNONYMS[canonSlug]; if (!syns || !syns.length) return ''; @@ -190,6 +181,29 @@ const LANG = "__OWM_lang_attr__"; const SOURCE_TYPE = "__OWM_source_type__"; + const QC = createQueryCore({ + locale: LANG, + siteOrigin: window.location.origin, + slugBase: '/' + LANG + '/', + aocs: AOCS, + grapesInfo: GRAPES_INFO, + grapeSearchIndex: GRAPE_SEARCH_INDEX, + vivcSiblings: VIVC_SIBLINGS, + styleDescendants: STYLE_DESCENDANTS, + styleLabels: STYLE_LABELS, + simpleStyleLabels: SIMPLE_STYLE_LABELS, + simpleStyleBuckets: SIMPLE_STYLE_BUCKETS, + regionLabels: REGION_LABELS, + regionSearchTerms: REGION_SEARCH_TERMS, + countryLabels: COUNTRY_LABELS, + termLabels: TERM_LABELS, + labels: LABELS, + }); + const { + searchScore, searchableText, regionSearchForms, expandGrapeSet, + grapeName, regionLabel, countryLabel, + } = QC; + // Plausible custom-event helper. The page's inline snippet always defines // `window.plausible` (a queue stub until the tracker script arrives), so // when the tracker never loads — ad-blocker, offline preview, a host with @@ -212,31 +226,6 @@ try { return !!(window.plausible && window.plausible.l); } catch (e) { return false; } } - // Title-case the first letter of each word (after start, whitespace, - // hyphen, or apostrophe). Wikipedia grape titles aren't uniformly - // cased (FR uses "Cabernet sauvignon" sentence case while EN uses - // "Cabernet Sauvignon" title case), and the slug fallback is pure - // lowercase — normalising here makes pills and filter entries - // consistent regardless of source. - function toTitleCase(s) { - return s.replace(/(?:^|[\s\-'(])\p{L}/gu, c => c.toUpperCase()); - } - - function grapeName(slug) { - const info = GRAPES_INFO[slug]; - const raw = (info && info.name) ? info.name : slug.replace(/-/g, ' '); - return toTitleCase(raw); - } - - function regionLabel(region) { - if (!region) return LABELS.meta_no_region; - return REGION_LABELS[region] || region; - } - - function countryLabel(cc) { - return COUNTRY_LABELS[cc] || cc || ''; - } - function oneCountryChip(countryCode) { const flag = COUNTRY_FLAG_EMOJI[countryCode] || ''; const name = COUNTRY_LABELS[countryCode] || ''; @@ -279,53 +268,6 @@ return norm(canon) === norm(cahier); } - // Both sides of every search comparison go through this: diacritics - // stripped, lower-cased, and every run of punctuation or whitespace folded - // to one space, so "aloxe corton" finds Aloxe-Corton and "d alba" finds - // d'Alba (727 records were unfindable by their own full name before the - // fold, 2026-09-25). - function searchNormalize(s) { - return (s || '').normalize('NFD').replace(/\p{Diacritic}/gu, '') - .toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim(); - } - - // The forms a record can be found by: the regulator's own name, the EU / - // official Latin transcription (`name_latin`, the bracket on screen) and - // the search-only `search_forms` stage 04 derives from the name (ELOT 743 - // and unidecode for Greek, unidecode for Bulgarian — _lib/romanise.py). - // Derived forms are never displayed: what is on screen stays the - // regulator's string; they only make "agio oros" or "targovishte" match. - // Each form is matched on its own, so a query never spans two forms. - function searchForms(rec) { - if (!rec) return []; - if (!rec._sf) { - const raw = [rec.name || '', rec.name_latin || ''].concat(rec.search_forms || []); - const seen = new Set(); - rec._sf = []; - for (const f of raw.map(searchNormalize)) { - if (f && !seen.has(f)) { seen.add(f); rec._sf.push(f); } - } - } - return rec._sf; - } - - // Best match of a normalised query against a record's forms: prefix 100, - // substring 80, no match -1. - function searchScore(rec, nq) { - let best = -1; - for (const f of searchForms(rec)) { - const s = f.startsWith(nq) ? 100 : (f.includes(nq) ? 80 : -1); - if (s > best) best = s; - } - return best; - } - - // The tree row's data-name: the forms joined by a newline, which no - // normalised query contains, so a substring test on it is a per-form test. - function searchableText(rec) { - return searchForms(rec).join('\n'); - } - // The toggles that currently hide a record from the map, 'igp' and/or // 'spirit' (a cider PGI is behind both). function hiddenGates(rec) { @@ -805,12 +747,6 @@ el.textContent = fmt(LABELS.count_filtered, { n: n, total: total }); } - function setIntersects(set, arr) { - if (!arr) return false; - for (const v of arr) if (set.has(v)) return true; - return false; - } - function buildFacet(containerId, items, store, format, extraFormat) { const el = document.getElementById(containerId); const html = items.map(([key, count]) => { @@ -2939,6 +2875,7 @@ .filter(s => AOCS[s]) .sort((a, b) => localityRank(a) - localityRank(b)); if (!sorted.length) return; + if (via !== 'stack-pick') currentStackIsSet = via === 'set'; currentStack = sorted; const focus = ((((focusIndex | 0) % sorted.length) + sorted.length) % sorted.length); const ordered = focus === 0 @@ -2946,8 +2883,12 @@ : [sorted[focus], ...sorted.filter((_, i) => i !== focus)]; let header = ''; if (sorted.length > 1) { - const pos = `${focus + 1} / ${sorted.length}`; - header = `
${fmt(LABELS.stack_header, { n: sorted.length })}${pos}
`; + // A shared ?aocs= set is a list, not the polygons under one map point: + // a plain count, and no "click again to cycle" hint (a card pick keeps it). + const isSet = currentStackIsSet; + const posTitle = isSet ? '' : ` title="${escapeAttr(LABELS.stack_cycle_hint)}"`; + const pos = `${focus + 1} / ${sorted.length}`; + header = `
${fmt(isSet ? LABELS.count_total : LABELS.stack_header, { n: sorted.length })}${pos}
`; } // A generation token cancels a stale fetch's render when the user opens or // cycles to a different stack before this one's detail arrives. @@ -3032,6 +2973,7 @@ // sort), so a click on a subordinate card re-renders the same stack with // that card in front — and a map re-click on the spot cycles on from it. let currentStack = []; + let currentStackIsSet = false; // Bring one record of the open stack to the front: first card, gold // outline on the map, URL and title follow. The sidebar's counterpart of @@ -3139,6 +3081,34 @@ } return; } + // A set shared as ?aocs=a,b,c (the MCP server's show_on_map links one, + // up to its 200 slugs): + // the whole set in the panel stack, all of it highlighted, the map framed + // on all of it. The URL keeps the query until the reader opens something + // else (setAocPath drops it), so the view stays shareable. + let setSlugs = []; + try { + const q = new URLSearchParams(window.location.search).get('aocs'); + if (q) setSlugs = [...new Set(q.split(','))].filter(s => AOCS[s]).slice(0, 200); + } catch (e) {} + if (setSlugs.length) { + lastStackKey = setSlugs.slice().sort().join('|'); + stackFocusIndex = 0; + renderPanelStack(setSlugs, 0, false, 'set'); + setSelection(setSlugs); + if (_ssr) _ssr.remove(); + if (!INITIAL_CAMERA_HASH) { + const boxes = setSlugs.map(s => fitBbox(AOCS[s])).filter(Boolean); + if (boxes.length) { + const b = [ + Math.min(...boxes.map(x => x[0])), Math.min(...boxes.map(x => x[1])), + Math.max(...boxes.map(x => x[2])), Math.max(...boxes.map(x => x[3])), + ]; + map.once('load', () => map.fitBounds([[b[0], b[1]], [b[2], b[3]]], { padding: 60, maxZoom: LOD.overview_max_zoom, duration: 0 })); + } + } + return; + } if (_ssr) { showSsrFallback(); return; } let saved = null; try { saved = localStorage.getItem('selected_slugs'); } catch (e) {} @@ -3406,229 +3376,74 @@ __OWM_source_block__ updateStatus(); }); + // Copy-to-clipboard buttons (the About dialog's MCP server address). Falls + // back to selecting the address when the clipboard API is unavailable. + document.addEventListener('click', e => { + const btn = e.target.closest && e.target.closest('button[data-copy]'); + if (!btn) return; + const code = btn.parentElement && btn.parentElement.querySelector('code'); + const selectCode = () => { + if (!code) return; + const range = document.createRange(); + range.selectNodeContents(code); + const sel = window.getSelection(); + sel.removeAllRanges(); + sel.addRange(range); + }; + const done = () => { + if (!btn.dataset.label) btn.dataset.label = btn.textContent; + btn.textContent = LABELS.copied_label; + btn.classList.add('copied'); + clearTimeout(btn._copyTimer); + btn._copyTimer = setTimeout(() => { btn.textContent = btn.dataset.label; btn.classList.remove('copied'); }, 1600); + }; + track('MCP Address Copied', { locale: LANG }); + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(btn.dataset.copy).then(done, selectCode); + } else { + selectCode(); + } + }); + // ---- WebMCP (document.modelContext) -------------------------------------- // The map is a WebGL canvas and the panel is client-rendered, so a browser // agent cannot read either. WebMCP (W3C Web Machine Learning CG draft; - // Chrome 146+ behind a flag, `navigator.modelContext` before the July 2026 - // draft) lets the page hand it typed, read-only tools over the records it - // already holds, plus one tool that opens a panel. A no-op where the API is - // absent. Results carry the canonical page URL and the same attribution the - // panel shows: an agent quoting a terroir fact must be able to cite it. + // Chrome origin trial 149–162, token in the page head) lets the page hand it + // typed, read-only tools over the records it already holds, plus one tool + // that opens a panel. A no-op where the API is absent. The tools themselves + // live in the query core (TOOL_DEFS + createQueryCore), shared with the + // remote MCP server (mcp/), so both surfaces answer identically. Results + // carry the canonical page URL and the same attribution the panel shows: an + // agent quoting a terroir fact must be able to cite it. (function registerWebMcpTools() { const mc = document.modelContext || navigator.modelContext; if (!mc || typeof mc.registerTool !== 'function' || !window.__OWM_DATA) return; - const PAGE_ORIGIN = window.location.origin; - const asContent = obj => ({ content: [{ type: 'text', text: JSON.stringify(obj) }] }); + const asContent = obj => ({ content: [{ type: 'text', text: JSON.stringify(obj) }], structuredContent: obj }); const fail = msg => ({ content: [{ type: 'text', text: msg }], isError: true }); - const capInt = (v, dflt, max) => Math.max(1, Math.min(max, parseInt(v, 10) || dflt)); - - function brief(slug) { - const r = AOCS[slug]; - const out = { - slug: slug, - name: r.name, - country: r.country || 'fr', - country_name: countryLabel(r.country || 'fr'), - region: r.region ? regionLabel(r.region) : null, - classification: r.class_label || r.kind || null, - url: PAGE_ORIGIN + SLUG_BASE + encodeURIComponent(slug), - }; - if (r.name_latin) out.name_latin = r.name_latin; - if (r.is_sub_denomination) out.is_sub_denomination = true; - if (r.is_wine === false) out.is_wine = false; - if (r.cancelled) out.cancelled = r.cancelled; - if (r.promoted) out.promoted = r.promoted; - return out; - } - - // A grape given by name or slug → every slug of its VIVC variety. - function grapeSlugsFor(q) { - const nq = searchNormalize(q); - if (!nq) return null; - const hits = new Set(); - for (const e of _GRAPE_INDEX_NORM) { - if (searchNormalize(e.entry.slug) === nq || e.labelN === nq || e.aliasesN.includes(nq)) hits.add(e.entry.slug); - } - if (!hits.size) for (const slug in GRAPES_INFO) { - const info = GRAPES_INFO[slug] || {}; - if (searchNormalize(slug) === nq || searchNormalize(info.name) === nq) hits.add(slug); - } - if (!hits.size) for (const k in AOCS) { - for (const s of AOCS[k].grapes_all || []) if (searchNormalize(s) === nq) hits.add(s); - } - return hits.size ? expandGrapeSet(hits) : new Set(); - } - - // A style given by slug or by its label in this locale → the style and - // every style under it in the taxonomy. - function styleSlugsFor(q) { - const nq = searchNormalize(q); - if (!nq) return null; - const keys = new Set(Object.keys(STYLE_DESCENDANTS).concat(Object.keys(STYLE_LABELS), Object.keys(SIMPLE_STYLE_BUCKETS))); - for (const s of keys) { - if (searchNormalize(s) === nq || searchNormalize(STYLE_LABELS[s]) === nq || searchNormalize(SIMPLE_STYLE_LABELS[s]) === nq) { - return new Set([s].concat(STYLE_DESCENDANTS[s] || [], SIMPLE_STYLE_BUCKETS[s] || [])); - } - } - return new Set(); - } - function register(tool) { - const run = tool.execute; - tool.execute = async input => { - track('WebMCP Tool', { tool: tool.name, locale: LANG }); - try { return await run(input || {}); } catch (e) { return fail(String(e && e.message || e)); } - }; - try { mc.registerTool(tool); } catch (e) { console.warn('WebMCP: could not register ' + tool.name, e); } - } - - register({ - name: 'search_appellations', - description: 'Find European wine appellations (PDO/PGI, AOC, DOC, DO, …) by name, including romanised forms of Greek and Bulgarian names. Returns slug, official name, country, region, classification and the page URL.', - inputSchema: { - type: 'object', - properties: { - query: { type: 'string', description: 'Appellation name or part of it, e.g. "Priorat", "Chablis", "naoussa".' }, - country: { type: 'string', description: 'Optional ISO 3166-1 alpha-2 code, lower-case (fr, es, it, pt, de, at, gr, gb, …).' }, - limit: { type: 'integer', description: 'Maximum results (default 20, max 100).' }, + function register(name, def, run) { + const tool = Object.assign({ name: name }, def, { + execute: async input => { + track('WebMCP Tool', { tool: name, locale: LANG }); + try { return asContent(await run(input || {})); } catch (e) { return fail(String(e && e.message || e)); } }, - required: ['query'], - }, - annotations: { readOnlyHint: true }, - execute: async ({ query, country, limit }) => { - const nq = searchNormalize(query); - if (!nq) return fail('query is empty'); - const cc = (country || '').toLowerCase(); - const hits = []; - for (const slug in AOCS) { - const r = AOCS[slug]; - if (cc && (r.country || 'fr') !== cc && !(r.country_aliases || []).includes(cc)) continue; - const score = searchScore(r, nq); - if (score < 0) continue; - hits.push({ slug, score, sub: r.is_sub_denomination ? 1 : 0, name: r.name || slug }); - } - hits.sort((a, b) => b.score - a.score || a.sub - b.sub || a.name.localeCompare(b.name)); - const n = capInt(limit, 20, 100); - return asContent({ total: hits.length, results: hits.slice(0, n).map(h => brief(h.slug)) }); - }, - }); - - register({ - name: 'filter_appellations', - description: 'List wine appellations matching structured criteria: country, region, wine style, grape variety, legal scheme or traditional term. All criteria are optional and combined with AND.', - inputSchema: { - type: 'object', - properties: { - country: { type: 'string', description: 'ISO 3166-1 alpha-2 code, lower-case.' }, - region: { type: 'string', description: 'Wine region as shown on the map, e.g. "Bourgogne", "Catalunya", "Mosel".' }, - style: { type: 'string', description: 'Wine style slug or label, e.g. "red", "white", "rose", "sparkling", "sweet", "fortified", "vin-jaune".' }, - grape: { type: 'string', description: 'Grape variety name or slug, e.g. "Garnacha", "pinot-noir". Synonyms of the same VIVC variety are included.' }, - main_grape_only: { type: 'boolean', description: 'Only match the grape among the principal varieties (default false).' }, - scheme: { type: 'string', description: 'Legal scheme (pdo, pgi, spirit-gi, uk-pdo, uk-pgi, none) or traditional term (aoc, docg, doc, igt, doca, doq, dac, …).' }, - include_sub_denominations: { type: 'boolean', description: 'Include sub-denominations (DGCs, subzonas, sottozone, crus); default false.' }, - include_spirits: { type: 'boolean', description: 'Include spirit-drink and cider GIs; default false.' }, - limit: { type: 'integer', description: 'Maximum results (default 50, max 200).' }, - }, - }, - annotations: { readOnlyHint: true }, - execute: async (a) => { - const cc = (a.country || '').toLowerCase(); - // A criterion that was given but normalises to nothing ("!!") is an - // error, never silently dropped from the AND. - const nRegion = searchNormalize(a.region); - if (a.region && !nRegion) return fail('unknown region: ' + a.region); - const styles = a.style ? styleSlugsFor(a.style) : null; - if (a.style && !(styles && styles.size)) return fail('unknown style: ' + a.style); - const grapes = a.grape ? grapeSlugsFor(a.grape) : null; - if (a.grape && !(grapes && grapes.size)) return fail('unknown grape: ' + a.grape); - const term = searchNormalize(a.scheme).replace(/ /g, '-'); - if (a.scheme && !term) return fail('unknown scheme: ' + a.scheme); - const out = []; - for (const slug in AOCS) { - const r = AOCS[slug]; - if (!a.include_sub_denominations && r.is_sub_denomination) continue; - if (!a.include_spirits && r.is_wine === false) continue; - if (cc && (r.country || 'fr') !== cc && !(r.country_aliases || []).includes(cc)) continue; - if (nRegion && searchNormalize(r.region) !== nRegion && searchNormalize(regionLabel(r.region)) !== nRegion - && !regionSearchForms(r.region).some(f => searchNormalize(f) === nRegion)) continue; - if (styles && !setIntersects(styles, r.styles || [])) continue; - if (grapes && !setIntersects(grapes, (a.main_grape_only ? r.grapes_principal : r.grapes_all) || [])) continue; - if (term) { - const segs = (r.class_key || '').split(';').filter(Boolean); - if (!segs.some(s => s === term || s.split(':').pop() === term)) continue; - } - out.push(slug); - } - out.sort((x, y) => (AOCS[x].name || x).localeCompare(AOCS[y].name || y)); - const n = capInt(a.limit, 50, 200); - return asContent({ total: out.length, results: out.slice(0, n).map(brief) }); - }, - }); + }); + try { mc.registerTool(tool); } catch (e) { console.warn('WebMCP: could not register ' + name, e); } + } - register({ - name: 'get_appellation', - description: 'Full record of one wine appellation by slug: grape varieties (principal / accessory), wine styles, terroir facts with their provenance, source documents and the attribution to give when quoting it.', - inputSchema: { - type: 'object', - properties: { slug: { type: 'string', description: 'Appellation slug, as returned by search_appellations.' } }, - required: ['slug'], - }, - annotations: { readOnlyHint: true }, - execute: async ({ slug }) => { - if (!AOCS[slug]) return fail('unknown appellation slug: ' + slug); - const complete = await hydratePanel(slug); - const r = AOCS[slug]; - const out = brief(slug); - if (!complete) { - out.detail_unavailable = 'The detail data (terroir facts, sources, attribution) could not be loaded; ' - + 'this record is partial. Call get_appellation again to retry.'; - } - if (r.parent_slug && AOCS[r.parent_slug]) out.parent = brief(r.parent_slug); - out.styles = (r.styles || []).map(s => STYLE_LABELS[s] || s); - out.grapes = { - principal: (r.grapes_principal || []).map(grapeName), - accessory: (r.grapes_accessory || []).map(grapeName), - }; - const tf = r.terroir_facts; - if (tf && tf.facts && tf.facts.length) { - out.terroir_facts = tf.facts.map(f => ({ - text: f.bullet, - section: FACTS_SUB_LABELS[f.subsection] || f.subsection, - source: f.provenance === 'wiki' ? 'wikipedia' : 'specification', - })); - out.terroir_facts_attribution = { - specification_url: tf.cahier_source_pdf_url || null, - wikipedia_url: tf.wiki_source_url || null, - wikipedia_licence: tf.facts.some(f => f.provenance === 'wiki') ? 'CC BY-SA 4.0' : null, - note: 'Extracted from the regulator specification (and Wikipedia where marked), machine-translated outside the source language.', - }; - } else if (r.summary) { - out.summary = r.summary; - if (r.summary_translation) out.summary_note = 'Machine translated from the regulator specification.'; - } - const src = {}; - for (const [k, v] of Object.entries(r.sources || {})) { - if (typeof v === 'string' && /^https?:\/\//.test(v)) src[k] = v; - } - if ((r.sources || {}).file_number) out.eu_file_number = r.sources.file_number; - out.sources = src; - if (r.note) out.note = r.note; - out.geometry_source = r.geom_source || null; - return asContent(out); - }, + register('search_appellations', TOOL_DEFS.search_appellations, a => QC.search(a)); + register('filter_appellations', TOOL_DEFS.filter_appellations, a => QC.filter(a)); + register('get_appellation', TOOL_DEFS.get_appellation, async ({ slug }) => { + if (!AOCS[slug]) throw new QueryError('unknown appellation slug: ' + slug); + const complete = await hydratePanel(slug); + return QC.full(slug, AOCS[slug], complete); }); - - register({ - name: 'show_appellation', + register('list_facets', TOOL_DEFS.list_facets, () => QC.facets()); + register('show_appellation', { description: 'Open an appellation on the map: shows its detail panel and frames its area. Use after search_appellations when the user wants to see it.', - inputSchema: { - type: 'object', - properties: { slug: { type: 'string', description: 'Appellation slug.' } }, - required: ['slug'], - }, - execute: async ({ slug }) => { - if (!openAppellation(slug, 'webmcp', null)) return fail('unknown appellation slug: ' + slug); - return asContent({ shown: brief(slug) }); - }, + inputSchema: TOOL_DEFS.get_appellation.inputSchema, + }, ({ slug }) => { + if (!openAppellation(slug, 'webmcp', null)) throw new QueryError('unknown appellation slug: ' + slug); + return { shown: QC.brief(slug) }; }); })(); diff --git a/scripts/_lib/assets/query_core.mjs b/scripts/_lib/assets/query_core.mjs new file mode 100644 index 0000000..24e3f89 --- /dev/null +++ b/scripts/_lib/assets/query_core.mjs @@ -0,0 +1,392 @@ +// Open Wine Map query core: appellation search, filtering and record shaping, +// shared by the map app and the remote MCP server so the two answer every +// query identically. +// +// - app.js: stage 04 inlines this file at the query_core build token +// (map_template._render_app_js, with every `export ` removed), and the app +// calls createQueryCore() with its build-injected tables. +// - mcp/: bundled by esbuild; the server calls createQueryCore() with the same +// tables, read from wiki/data/mcp/.json. +// +// Keep it dependency-free and free of window / document: it runs in browsers, +// Deno (Bunny Edge Scripting) and Node (tests). + +export class QueryError extends Error {} + +// Both sides of every search comparison go through this: diacritics +// stripped, lower-cased, and every run of punctuation or whitespace folded +// to one space, so "aloxe corton" finds Aloxe-Corton and "d alba" finds +// d'Alba (727 records were unfindable by their own full name before the +// fold, 2026-09-25). +export function searchNormalize(s) { + return (s || '').normalize('NFD').replace(/\p{Diacritic}/gu, '') + .toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim(); +} + +// Title-case the first letter of each word (after start, whitespace, +// hyphen, or apostrophe). Wikipedia grape titles aren't uniformly +// cased (FR uses "Cabernet sauvignon" sentence case while EN uses +// "Cabernet Sauvignon" title case), and the slug fallback is pure +// lowercase — normalising here makes pills and filter entries +// consistent regardless of source. +export function toTitleCase(s) { + return s.replace(/(?:^|[\s\-'(])\p{L}/gu, c => c.toUpperCase()); +} + +// A scheme / traditional term as typed ("DOCa", "Vino de Pago", "it:docg") +// → the form class_key segments use ("doca", "vino-de-pago", "it:docg"). +export function schemeKey(s) { + return (s || '').normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase() + .replace(/[^\p{L}\p{N}:]+/gu, ' ').trim().replace(/ /g, '-'); +} + +export function setIntersects(set, arr) { + if (!arr) return false; + for (const x of arr) if (set.has(x)) return true; + return false; +} + +const SLUG_PATTERN = '^[a-z0-9-]{1,120}$'; +const READ_ONLY = { readOnlyHint: true, idempotentHint: true, openWorldHint: false }; + +// The tool definitions both surfaces register (WebMCP in the page, MCP on +// the edge). The MCP server adds a `locale` property; the page answers in +// its own locale. +export const TOOL_DEFS = { + search_appellations: { + description: 'Find European wine appellations (PDO/PGI, AOC, DOC, DO, …) by name, including romanised forms of Greek and Bulgarian names. Returns slug, official name, country, region, classification and the page URL.', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', minLength: 1, maxLength: 200, description: 'Appellation name or part of it, e.g. "Priorat", "Chablis", "naoussa".' }, + country: { type: 'string', pattern: '^[a-z]{2}$', description: 'Optional ISO 3166-1 alpha-2 code, lower-case (fr, es, it, pt, de, at, gr, gb, …).' }, + limit: { type: 'integer', minimum: 1, maximum: 100, description: 'Maximum results (default 20).' }, + }, + required: ['query'], + }, + annotations: READ_ONLY, + }, + filter_appellations: { + description: 'List wine appellations matching structured criteria: country, region, wine style, grape variety, legal scheme or traditional term. All criteria are optional and combined with AND. list_facets returns the accepted values.', + inputSchema: { + type: 'object', + properties: { + country: { type: 'string', pattern: '^[a-z]{2}$', description: 'ISO 3166-1 alpha-2 code, lower-case.' }, + region: { type: 'string', maxLength: 100, description: 'Wine region as shown on the map, e.g. "Bourgogne", "Catalunya", "Mosel".' }, + style: { type: 'string', maxLength: 100, description: 'Wine style slug or label, e.g. "red", "white", "rose", "sparkling", "sweet", "fortified", "vin-jaune".' }, + grape: { type: 'string', maxLength: 100, description: 'Grape variety name or slug, e.g. "Garnacha", "pinot-noir". Synonyms of the same VIVC variety are included.' }, + main_grape_only: { type: 'boolean', description: 'Only match the grape among the principal varieties (default false).' }, + scheme: { type: 'string', maxLength: 50, description: 'Legal scheme (pdo, pgi, spirit-gi, uk-pdo, uk-pgi, none) or traditional term (aoc, docg, doc, igt, doca, doq, dac, …; "it:doc" to name one country\'s).' }, + include_sub_denominations: { type: 'boolean', description: 'Include sub-denominations (DGCs, subzonas, sottozone, crus); default false.' }, + include_spirits: { type: 'boolean', description: 'Include spirit-drink and cider GIs; default false.' }, + limit: { type: 'integer', minimum: 1, maximum: 200, description: 'Maximum results (default 50).' }, + }, + }, + annotations: READ_ONLY, + }, + get_appellation: { + description: 'Full record of one wine appellation by slug: grape varieties (principal / accessory), wine styles, terroir facts with their provenance, source documents and the attribution to give when quoting it.', + inputSchema: { + type: 'object', + properties: { slug: { type: 'string', pattern: SLUG_PATTERN, description: 'Appellation slug, as returned by search_appellations.' } }, + required: ['slug'], + }, + annotations: READ_ONLY, + }, + list_facets: { + description: 'The values filter_appellations accepts, with appellation counts: countries, wine regions, wine styles, and legal schemes / traditional terms. Counts cover wine appellations, not sub-denominations or spirits.', + inputSchema: { type: 'object', properties: {} }, + annotations: READ_ONLY, + }, +}; + +const capInt = (v, dflt, max) => Math.max(1, Math.min(max, parseInt(v, 10) || dflt)); + +// ctx: { locale, siteOrigin, slugBase, aocs, grapesInfo, grapeSearchIndex, +// vivcSiblings, styleDescendants, styleLabels, simpleStyleLabels, +// simpleStyleBuckets, regionLabels, regionSearchTerms, countryLabels, +// termLabels, labels } +export function createQueryCore(ctx) { + const aocs = ctx.aocs; + const labels = ctx.labels || {}; + const collator = new Intl.Collator(ctx.locale); + const byName = (x, y) => collator.compare(aocs[x].name || x, aocs[y].name || y); + + // The forms a record can be found by: the regulator's own name, the EU / + // official Latin transcription (`name_latin`, the bracket on screen) and + // the search-only `search_forms` stage 04 derives from the name (ELOT 743 + // and unidecode for Greek, unidecode for Bulgarian — _lib/romanise.py). + // Derived forms are never displayed: what is on screen stays the + // regulator's string; they only make "agio oros" or "targovishte" match. + // Each form is matched on its own, so a query never spans two forms. + function searchForms(rec) { + if (!rec) return []; + if (!rec._sf) { + const raw = [rec.name || '', rec.name_latin || ''].concat(rec.search_forms || []); + const seen = new Set(); + rec._sf = []; + for (const f of raw.map(searchNormalize)) { + if (f && !seen.has(f)) { seen.add(f); rec._sf.push(f); } + } + } + return rec._sf; + } + + // Best match of a normalised query against a record's forms: prefix 100, + // substring 80, no match -1. + function searchScore(rec, nq) { + let best = -1; + for (const f of searchForms(rec)) { + const s = f.startsWith(nq) ? 100 : (f.includes(nq) ? 80 : -1); + if (s > best) best = s; + } + return best; + } + + // The tree row's data-name: the forms joined by a newline, which no + // normalised query contains, so a substring test on it is a per-form test. + function searchableText(rec) { + return searchForms(rec).join('\n'); + } + + function regionSearchForms(region) { + const t = (ctx.regionSearchTerms || {})[region]; + return t ? t.forms : []; + } + + // A grape set → itself plus every slug sharing its VIVC variety. + function expandGrapeSet(set) { + if (!set || !set.size) return set; + const out = new Set(set); + for (const slug of set) { + const sibs = (ctx.vivcSiblings || {})[slug]; + if (sibs) for (const s of sibs) out.add(s); + } + return out; + } + + function grapeName(slug) { + const info = (ctx.grapesInfo || {})[slug]; + const raw = (info && info.name) ? info.name : slug.replace(/-/g, ' '); + return toTitleCase(raw); + } + + function regionLabel(region) { + if (!region) return labels.meta_no_region; + return (ctx.regionLabels || {})[region] || region; + } + + function countryLabel(cc) { + return (ctx.countryLabels || {})[cc] || cc || ''; + } + + const factsSubLabels = { + facteurs_naturels: labels.facts_sub_facteurs_naturels, + facteurs_humains: labels.facts_sub_facteurs_humains, + produit: labels.facts_sub_produit, + interactions: labels.facts_sub_interactions, + }; + + const grapeIndexNorm = (ctx.grapeSearchIndex || []).map(entry => ({ + entry, + labelN: searchNormalize(entry.label), + aliasesN: (entry.aliases || []).map(a => searchNormalize(a)), + })); + + function pageUrl(slug) { + return ctx.siteOrigin + ctx.slugBase + encodeURIComponent(slug); + } + + function brief(slug) { + const r = aocs[slug]; + const out = { + slug, + name: r.name, + country: r.country || 'fr', + country_name: countryLabel(r.country || 'fr'), + region: r.region ? regionLabel(r.region) : null, + classification: r.class_label || r.kind || null, + url: pageUrl(slug), + }; + if (r.name_latin) out.name_latin = r.name_latin; + if (r.is_sub_denomination) out.is_sub_denomination = true; + if (r.is_wine === false) out.is_wine = false; + if (r.cancelled) out.cancelled = r.cancelled; + if (r.promoted) out.promoted = r.promoted; + return out; + } + + // A grape given by name or slug → every slug of its VIVC variety. + function grapeSlugsFor(q) { + const nq = searchNormalize(q); + if (!nq) return null; + const hits = new Set(); + for (const e of grapeIndexNorm) { + if (searchNormalize(e.entry.slug) === nq || e.labelN === nq || e.aliasesN.includes(nq)) hits.add(e.entry.slug); + } + if (!hits.size) for (const slug in (ctx.grapesInfo || {})) { + const info = ctx.grapesInfo[slug] || {}; + if (searchNormalize(slug) === nq || searchNormalize(info.name) === nq) hits.add(slug); + } + if (!hits.size) for (const k in aocs) { + for (const s of aocs[k].grapes_all || []) if (searchNormalize(s) === nq) hits.add(s); + } + return hits.size ? expandGrapeSet(hits) : new Set(); + } + + // A style given by slug or by its label in this locale → the style and + // every style under it in the taxonomy. + function styleSlugsFor(q) { + const nq = searchNormalize(q); + if (!nq) return null; + const desc = ctx.styleDescendants || {}; + const lab = ctx.styleLabels || {}; + const simpleLab = ctx.simpleStyleLabels || {}; + const buckets = ctx.simpleStyleBuckets || {}; + const keys = new Set(Object.keys(desc).concat(Object.keys(lab), Object.keys(buckets))); + for (const s of keys) { + if (searchNormalize(s) === nq || searchNormalize(lab[s]) === nq || searchNormalize(simpleLab[s]) === nq) { + return new Set([s].concat(desc[s] || [], buckets[s] || [])); + } + } + return new Set(); + } + + function inCountry(r, cc) { + return (r.country || 'fr') === cc || (r.country_aliases || []).includes(cc); + } + + function search({ query, country, limit } = {}) { + const nq = searchNormalize(query); + if (!nq) throw new QueryError('query is empty'); + const cc = (country || '').toLowerCase(); + const hits = []; + for (const slug in aocs) { + const r = aocs[slug]; + if (cc && !inCountry(r, cc)) continue; + const score = searchScore(r, nq); + if (score < 0) continue; + hits.push({ slug, score, sub: r.is_sub_denomination ? 1 : 0, name: r.name || slug }); + } + hits.sort((a, b) => b.score - a.score || a.sub - b.sub || collator.compare(a.name, b.name)); + const n = capInt(limit, 20, 100); + return { total: hits.length, results: hits.slice(0, n).map(h => brief(h.slug)) }; + } + + function filter(a = {}) { + const cc = (a.country || '').toLowerCase(); + // A criterion that was given but normalises to nothing ("!!") is an + // error, never silently dropped from the AND. + const nRegion = searchNormalize(a.region); + if (a.region && !nRegion) throw new QueryError('unknown region: ' + a.region); + const styles = a.style ? styleSlugsFor(a.style) : null; + if (a.style && !(styles && styles.size)) throw new QueryError('unknown style: ' + a.style); + const grapes = a.grape ? grapeSlugsFor(a.grape) : null; + if (a.grape && !(grapes && grapes.size)) throw new QueryError('unknown grape: ' + a.grape); + // class_key segments are 'pdo' or ':' ('it:docg', + // 'es:vino-de-pago'): fold case, accents and spaces, keep the ':'. + const term = schemeKey(a.scheme); + if (a.scheme && !term) throw new QueryError('unknown scheme: ' + a.scheme); + const out = []; + for (const slug in aocs) { + const r = aocs[slug]; + if (!a.include_sub_denominations && r.is_sub_denomination) continue; + if (!a.include_spirits && r.is_wine === false) continue; + if (cc && !inCountry(r, cc)) continue; + if (nRegion && searchNormalize(r.region) !== nRegion && searchNormalize(regionLabel(r.region)) !== nRegion + && !regionSearchForms(r.region).some(f => searchNormalize(f) === nRegion)) continue; + if (styles && !setIntersects(styles, r.styles || [])) continue; + if (grapes && !setIntersects(grapes, (a.main_grape_only ? r.grapes_principal : r.grapes_all) || [])) continue; + if (term) { + const segs = (r.class_key || '').split(';').filter(Boolean); + if (!segs.some(s => s === term || s.split(':').pop() === term)) continue; + } + out.push(slug); + } + out.sort(byName); + const n = capInt(a.limit, 50, 200); + return { total: out.length, results: out.slice(0, n).map(brief) }; + } + + // The full record. `rec` is the startup record merged with its panel + // payload (the app hydrates AOCS in place; the server merges a copy); + // `complete` is false when the panel payload could not be loaded. + function full(slug, rec = aocs[slug], complete = true) { + if (!aocs[slug]) throw new QueryError('unknown appellation slug: ' + slug); + const r = rec; + const out = brief(slug); + if (!complete) { + out.detail_unavailable = 'The detail data (terroir facts, sources, attribution) could not be loaded; ' + + 'this record is partial. Call get_appellation again to retry.'; + } + if (r.parent_slug && aocs[r.parent_slug]) out.parent = brief(r.parent_slug); + out.styles = (r.styles || []).map(s => (ctx.styleLabels || {})[s] || s); + out.grapes = { + principal: (r.grapes_principal || []).map(grapeName), + accessory: (r.grapes_accessory || []).map(grapeName), + }; + const tf = r.terroir_facts; + if (tf && tf.facts && tf.facts.length) { + out.terroir_facts = tf.facts.map(f => ({ + text: f.bullet, + section: factsSubLabels[f.subsection] || f.subsection, + source: f.provenance === 'wiki' ? 'wikipedia' : 'specification', + })); + out.terroir_facts_attribution = { + specification_url: tf.cahier_source_pdf_url || null, + wikipedia_url: tf.wiki_source_url || null, + wikipedia_licence: tf.facts.some(f => f.provenance === 'wiki') ? 'CC BY-SA 4.0' : null, + note: 'Extracted from the regulator specification (and Wikipedia where marked), machine-translated outside the source language.', + }; + } else if (r.summary) { + out.summary = r.summary; + if (r.summary_translation) out.summary_note = 'Machine translated from the regulator specification.'; + } + const src = {}; + for (const [k, v] of Object.entries(r.sources || {})) { + if (typeof v === 'string' && /^https?:\/\//.test(v)) src[k] = v; + } + if ((r.sources || {}).file_number) out.eu_file_number = r.sources.file_number; + out.sources = src; + if (r.note) out.note = r.note; + out.geometry_source = r.geom_source || null; + return out; + } + + // The values filter() accepts, counted over wine appellations (no + // sub-denominations, no spirits), each list sorted by count then label. + function facets() { + const countries = new Map(); + const regions = new Map(); + const styles = new Map(); + const schemes = new Map(); + const bump = (m, key, init) => { + const e = m.get(key) || Object.assign({ count: 0 }, init()); + e.count += 1; + m.set(key, e); + }; + for (const slug in aocs) { + const r = aocs[slug]; + if (r.is_sub_denomination || r.is_wine === false) continue; + const cc = r.country || 'fr'; + bump(countries, cc, () => ({ code: cc, name: countryLabel(cc) })); + if (r.region) bump(regions, r.region, () => ({ value: regionLabel(r.region), country: cc })); + for (const s of r.styles || []) bump(styles, s, () => ({ value: s, label: (ctx.styleLabels || {})[s] || s })); + for (const seg of (r.class_key || '').split(';').filter(Boolean)) { + bump(schemes, seg, () => ({ value: seg, label: (ctx.termLabels || {})[seg] || seg })); + } + } + const order = (key) => (x, y) => y.count - x.count || collator.compare(x[key], y[key]); + return { + countries: [...countries.values()].sort(order('name')), + regions: [...regions.values()].sort(order('value')), + styles: [...styles.values()].sort(order('label')), + schemes: [...schemes.values()].sort(order('label')), + }; + } + + return { + searchForms, searchScore, searchableText, regionSearchForms, expandGrapeSet, + grapeName, regionLabel, countryLabel, brief, search, filter, full, facets, + }; +} diff --git a/scripts/_lib/map_template.py b/scripts/_lib/map_template.py index 3a11b36..bbed2ea 100644 --- a/scripts/_lib/map_template.py +++ b/scripts/_lib/map_template.py @@ -492,6 +492,19 @@ def build_labels(_: Callable[[str], str]) -> dict[str, str]: "Parcourir la liste complète des appellations : {browse_link}." ), "about_updated_html": _("Données mises à jour le {date}."), + "about_webmcp_html": _( + "Votre navigateur parle déjà {webmcp} ? Chaque page de la carte lui propose les " + "mêmes outils. Sinon, aucune importance : c'est le serveur MCP ci-dessus que votre " + "assistant IA sait utiliser." + ), + "copy_label": _("Copier"), + "copied_label": _("Copié"), + "copy_mcp_aria": _("Copier l'adresse du serveur MCP"), + "about_mcp_html": _( + "Les assistants IA peuvent interroger les appellations avec les mêmes outils " + "de recherche que la carte, via le serveur MCP {mcp_link} (sans " + "authentification ; à ajouter comme connecteur personnalisé)." + ), } @@ -673,6 +686,9 @@ def build_country_labels(_: Callable[[str], str]) -> dict[str, str]: _SITE_BASE_URL = "https://www.openwinemap.com" +# The remote MCP server (mcp/, a Bunny Edge Script): advertised in the About +# dialog and llms.txt. +MCP_ENDPOINT_URL = "https://mcp.openwinemap.com/mcp" _OG_LOCALES = {"fr": "fr_FR", "en": "en_US", "es": "es_ES", "nl": "nl_NL"} _GITHUB_URL = "https://github.com/devloed-com/open-wine-map" _GITHUB_NEW_ISSUE_URL = _GITHUB_URL + "/issues/new" @@ -759,6 +775,17 @@ def _build_about_dialog( if browse_path: browse_link = f'{esc(labels["browse_all_label"])}' paragraphs.append(labels["about_browse_html"].format(browse_link=browse_link)) + # The address in a code box with a copy button (app.js: button[data-copy]): + # it is meant to be pasted into an AI assistant's connector settings. + mcp_copy = ( + f'{esc(MCP_ENDPOINT_URL)}' + f'' + ) + paragraphs.append(labels["about_mcp_html"].format(mcp_link=mcp_copy)) + paragraphs.append(labels["about_webmcp_html"].format( + webmcp=_ext_link("https://developer.chrome.com/docs/ai/webmcp", "WebMCP") + )) paragraphs.append( labels["about_privacy_html"].format(plausible=_ext_link(_PLAUSIBLE_POLICY_URL, "Plausible")) ) @@ -2048,6 +2075,21 @@ def _country_name(cc: str) -> str: # localityRank / renderActiveFilters→grapeName / the map-click geom_source # guard — MUST be listed here, or the sidebar/map silently breaks. 04_build_ # maps.py imports this to emit the complement as the per-slug panel JSON. +# The remote MCP server's query context (/data/mcp/.json): the keys +# the shared query core reads. Bump QUERY_CONTEXT_FORMAT on an incompatible +# change; the edge script accepts the formats it knows. +QUERY_CONTEXT_FORMAT = 1 +QUERY_CONTEXT_KEYS = frozenset({ + "format_version", "locale", "aocs", "grapes_info", "grape_search_index", + "vivc_siblings", "style_descendants", "style_labels", "simple_style_labels", + "simple_style_buckets", "region_labels", "region_search_terms", + "country_labels", "term_labels", "labels", "tiles", +}) +QUERY_CONTEXT_LABELS = ( + "meta_no_region", "facts_sub_facteurs_naturels", "facts_sub_facteurs_humains", + "facts_sub_produit", "facts_sub_interactions", +) + STARTUP_AOCS_FIELDS = frozenset({ "name", "name_latin", "kind", "region", "country", "is_wine", # Search-only romanisations of a Greek / Cyrillic name (_lib/romanise.py): @@ -2411,8 +2453,42 @@ def _grape_rank_for(rec: dict) -> dict[str, float] | None: carto_key_json=json.dumps(carto_basemap_key()), carto_keys_json=json.dumps(carto_basemap_keys(), sort_keys=True), lod_json=json.dumps(lod or {}, sort_keys=True), + query_core=_QUERY_CORE_INLINE, ) + # The query context for the remote MCP server (mcp/): the tables the + # shared query core (assets/query_core.mjs) reads, as one JSON per locale at + # a stable path (/data/mcp/.json) the edge script can find. The + # app gets the same tables inline; keep the two in step (see + # QUERY_CONTEXT_KEYS and tests/test_mcp_query_context.py). + query_context = { + "format_version": QUERY_CONTEXT_FORMAT, + "locale": locale, + "aocs": startup_aocs, + "grapes_info": { + slug: {"name": info["name"]} + for slug, info in sorted((grapes_info or {}).items()) + if isinstance(info, dict) and info.get("name") + }, + "grape_search_index": grape_search_index, + "vivc_siblings": vivc_siblings, + "style_descendants": style_descendants, + "style_labels": style_labels, + "simple_style_labels": simple_style_labels, + "simple_style_buckets": simple_style_buckets, + "region_labels": region_labels, + "region_search_terms": region_search_terms or {}, + "country_labels": country_labels, + "term_labels": term_labels, + "labels": {k: labels[k] for k in QUERY_CONTEXT_LABELS}, + # The map's own tile URLs with their content fingerprint (?v=): the + # MCP map view reads them so its 30-day-cached range requests change + # URL exactly when a tile file changes, as the site's do. + "tiles": {"detail": layer_url, "overview": overview_layer_url}, + } + assert set(query_context) == QUERY_CONTEXT_KEYS, set(query_context) ^ QUERY_CONTEXT_KEYS + query_bytes = json.dumps(query_context, ensure_ascii=False, sort_keys=True).encode("utf-8") + style_body = _STYLE_CSS.replace("{{", "{").replace("}}", "}") style_bytes = style_body.encode("utf-8") style_filename = f"style.{hashlib.sha256(style_bytes).hexdigest()[:10]}.css" @@ -2572,6 +2648,7 @@ def _emit(slug: str, meta: dict, ssr: str, has_card: bool) -> None: "data": (data_filename, data_bytes), "style": (style_filename, style_bytes), "app": (app_filename, app_bytes), + "query": (f"{locale}.json", query_bytes), } return html, assets, n_index, n_fold @@ -3001,6 +3078,14 @@ def _origin_trial_meta() -> str: .feedback-copied.visible {{ opacity:1; transform:translateY(0) }} #about-dialog {{ width:520px; max-width:calc(100vw - 32px); padding:0; border:1px solid #ccc; border-radius:6px; box-shadow:0 8px 32px rgba(0,0,0,0.18); background:#fff; color:#222 }} #about-dialog::backdrop {{ background:rgba(0,0,0,0.45) }} + #about-dialog .copy-field {{ display:inline-flex; align-items:center; gap:6px; flex-wrap:wrap; margin:2px 0 }} + #about-dialog .copy-field code {{ user-select:all; background:#f3f3f3; border:1px solid #ddd; border-radius:4px; padding:2px 6px; font-size:12px; word-break:break-all }} + #about-dialog .copy-btn {{ font:inherit; font-size:12px; padding:2px 10px; border:1px solid #bbb; border-radius:4px; background:#fff; color:#222; cursor:pointer }} + #about-dialog .copy-btn:hover {{ background:#f3f3f3 }} + #about-dialog .copy-btn.copied {{ border-color:#2e7d32; color:#2e7d32 }} + html.theme-dark #about-dialog .copy-field code {{ background:#2a2a2a; border-color:#444; color:#eee }} + html.theme-dark #about-dialog .copy-btn {{ background:#2a2a2a; border-color:#555; color:#eee }} + html.theme-dark #about-dialog .copy-btn.copied {{ border-color:#66bb6a; color:#66bb6a }} #about-dialog .close {{ position:absolute; top:10px; right:10px; background:#eee; border:none; border-radius:50%; width:28px; height:28px; cursor:pointer; font-size:16px; color:#666 }} #about-dialog .close:hover {{ background:#ddd; color:#000 }} #about-dialog .about-body {{ padding:24px 28px; line-height:1.55 }} @@ -3328,6 +3413,24 @@ def _render_sidebar() -> str: _OWM_TOKEN_RE = re.compile(r"__OWM_(\w+?)__") +def _load_query_core_inline() -> str: + """assets/query_core.mjs as a classic-script fragment for app.js: the ES + module's `export ` keywords dropped (app.js is not a module). The same file + is bundled, unchanged, into the MCP server (mcp/), which is what keeps the + page's WebMCP tools and the server answering identically.""" + src = (Path(__file__).resolve().parent / "assets" / "query_core.mjs").read_text( + encoding="utf-8" + ) + out, n = re.subn(r"^export ", "", src, flags=re.M) + if not n or re.search(r"^\s*(?:import|export)\b", out, flags=re.M) or "__OWM_" in out: + raise AssertionError("query_core.mjs must be a flat ES module: top-level `export` " + "declarations only, no imports, no build tokens") + return out + + +_QUERY_CORE_INLINE = _load_query_core_inline() + + def _render_app_js(kwargs: dict[str, str]) -> str: """Fill the per-locale build tokens in assets/app.js (single pass, so a value can never be re-scanned for another token).""" diff --git a/scripts/deploy_mcp.py b/scripts/deploy_mcp.py new file mode 100644 index 0000000..4b5b22e --- /dev/null +++ b/scripts/deploy_mcp.py @@ -0,0 +1,202 @@ +"""Build and publish the MCP server (mcp/) as a Bunny Edge Script. + +The server is a standalone Edge Script with its own linked pull zone; its code +is uploaded as one bundled file (`npm run build` in mcp/ → mcp/dist/edge.js), +then published as a release whose note is the git commit. See +docs/plan-mcp-server.md. + +Usage: + .venv/bin/python scripts/deploy_mcp.py create NAME one-time: create the + script + linked pull zone, print its id and hostname + .venv/bin/python scripts/deploy_mcp.py deploy [--script-id ID] [--no-build] + build, upload, publish, then smoke-test the default hostname + .venv/bin/python scripts/deploy_mcp.py hostname HOST [--script-id ID] + one-time: attach HOST to the script's pull zone, add the CNAME in the + Bunny DNS zone, issue the free certificate, force SSL (idempotent) + .venv/bin/python scripts/deploy_mcp.py vars NAME=VALUE ... [--script-id ID] + set script environment variables (DATA_ORIGIN, PLAUSIBLE_HOST, …) + .venv/bin/python scripts/deploy_mcp.py info [--script-id ID] + .venv/bin/python scripts/deploy_mcp.py delete --script-id ID + +Env (repo-root .env): + BUNNY_API_KEY account API key (shared with deploy.py) + BUNNY_MCP_SCRIPT_ID the production script id (deploy / info default) +""" + +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import subprocess +import sys + +import requests + +ROOT = pathlib.Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) +from _lib.env import load_dotenv # noqa: E402 + +MCP_DIR = ROOT / "mcp" +BUNDLE = MCP_DIR / "dist" / "edge.js" +API = "https://api.bunny.net" +SCRIPT_TYPE_STANDALONE = 1 # EdgeScriptTypes: 0 DNS, 1 CDN (standalone), 2 Middleware + + +def api(method: str, path: str, key: str, **kw) -> requests.Response: + r = requests.request(method, f"{API}{path}", headers={"AccessKey": key}, timeout=60, **kw) + if r.status_code >= 400: + sys.exit(f"{method} {path} → {r.status_code} {r.text[:300]}") + return r + + +def build(note: str) -> None: + """Bundle mcp/ with the release note (git SHA) stamped in as OWM_BUILD: + the server reports it as its version, which the smoke test waits for.""" + if not (MCP_DIR / "node_modules").exists(): + subprocess.run(["npm", "ci"], cwd=MCP_DIR, check=True) + (MCP_DIR / "dist").mkdir(exist_ok=True) + subprocess.run(["npm", "run", "build", "--", f"--define:OWM_BUILD={json.dumps(note)}"], + cwd=MCP_DIR, check=True) + + +def git_note() -> str: + sha = subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=ROOT, + capture_output=True, text=True).stdout.strip() + dirty = subprocess.run(["git", "status", "--porcelain", "--", "mcp", "scripts/_lib/assets"], + cwd=ROOT, capture_output=True, text=True).stdout.strip() + return f"{sha}{'+dirty' if dirty else ''}" + + +def script_info(key: str, script_id: str) -> dict: + return api("GET", f"/compute/script/{script_id}", key).json() + + +def hostnames(info: dict) -> list[str]: + names = [info.get("DefaultHostname"), info.get("SystemHostname")] + return [n.removeprefix("https://").removeprefix("http://").rstrip("/") for n in names if n] + + +def cmd_create(key: str, name: str) -> None: + build(git_note()) + body = { + "Name": name, + "Code": BUNDLE.read_text(encoding="utf-8"), + "ScriptType": SCRIPT_TYPE_STANDALONE, + "CreateLinkedPullZone": True, + "LinkedPullZoneName": name, + } + info = api("POST", "/compute/script", key, json=body).json() + script_id = info["Id"] + api("POST", f"/compute/script/{script_id}/publish", key, json={"Note": git_note()}) + info = script_info(key, script_id) + print(json.dumps({"Id": script_id, "Hostnames": hostnames(info), + "LinkedPullZones": info.get("LinkedPullZones")}, indent=1)) + + +def cmd_deploy(key: str, script_id: str, no_build: bool, smoke_url: str | None) -> None: + note = git_note() + if not no_build: + build(note) + code = BUNDLE.read_text(encoding="utf-8") + api("POST", f"/compute/script/{script_id}/code", key, json={"Code": code}) + api("POST", f"/compute/script/{script_id}/publish", key, json={"Note": note}) + hosts = hostnames(script_info(key, script_id)) + print(f"published {len(code.encode()):,} bytes as release '{note}' → {hosts}", file=sys.stderr) + url = smoke_url or (f"https://{hosts[0]}/mcp" if hosts else None) + if url: + expect = [] if no_build else ["--expect-version", f"1.0.0+{note}"] + r = subprocess.run(["node", "test/smoke.js", url, *expect], cwd=MCP_DIR, check=False) + if r.returncode: + sys.exit("smoke test failed") + + +def linked_pullzone(key: str, script_id: str) -> dict: + zones = script_info(key, script_id).get("LinkedPullZones") or [] + if len(zones) != 1: + sys.exit(f"script {script_id}: expected one linked pull zone, found {len(zones)}") + return api("GET", f"/pullzone/{zones[0]['Id']}", key).json() + + +def cmd_hostname(key: str, script_id: str, host: str) -> None: + pz = linked_pullzone(key, script_id) + pz_id, target = pz["Id"], f"{pz['Name']}.b-cdn.net" + names = {h["Value"].lower() for h in pz.get("Hostnames") or []} + if host.lower() not in names: + api("POST", f"/pullzone/{pz_id}/addHostname", key, json={"Hostname": host}) + print(f"pull zone {pz_id}: added hostname {host}", file=sys.stderr) + # The CNAME, in the Bunny DNS zone of the registrable domain. + label, _, domain = host.partition(".") + zones = api("GET", "/dnszone", key, params={"perPage": 1000}).json() + zone = next((z for z in zones.get("Items", zones) if z.get("Domain") == domain), None) + if not zone: + sys.exit(f"no Bunny DNS zone for {domain}; add the CNAME {host} -> {target} by hand") + existing = [r for r in zone.get("Records") or [] if r.get("Name") == label] + if existing: + r = existing[0] + if r.get("Type") != 2 or r.get("Value", "").rstrip(".") != target: + sys.exit(f"DNS {host} already exists as type {r.get('Type')} -> {r.get('Value')}; not touching it") + else: + api("PUT", f"/dnszone/{zone['Id']}/records", key, + json={"Type": 2, "Name": label, "Value": target, "Ttl": 300}) + print(f"DNS zone {domain}: CNAME {label} -> {target}", file=sys.stderr) + api("GET", "/pullzone/loadFreeCertificate", key, params={"hostname": host}) + api("POST", f"/pullzone/{pz_id}/setForceSSL", key, json={"Hostname": host, "ForceSSL": True}) + print(f"{host}: certificate issued, SSL forced", file=sys.stderr) + + +def cmd_vars(key: str, script_id: str, assignments: list[str]) -> None: + for item in assignments: + name, sep, value = item.partition("=") + if not sep or not name: + sys.exit(f"expected NAME=VALUE, got {item!r}") + api("PUT", f"/compute/script/{script_id}/variables", key, + json={"Name": name, "Required": False, "DefaultValue": value}) + print(f"variable {name} set", file=sys.stderr) + + +def main() -> int: + load_dotenv() + ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + sub = ap.add_subparsers(dest="cmd", required=True) + c = sub.add_parser("create") + c.add_argument("name") + for name in ("deploy", "info", "delete", "hostname", "vars"): + p = sub.add_parser(name) + p.add_argument("--script-id", default=os.environ.get("BUNNY_MCP_SCRIPT_ID")) + if name == "deploy": + p.add_argument("--no-build", action="store_true") + p.add_argument("--smoke-url", default="https://mcp.openwinemap.com/mcp", + help="endpoint to smoke-test after publishing") + if name == "hostname": + p.add_argument("host") + if name == "vars": + p.add_argument("assignments", nargs="+") + args = ap.parse_args() + key = os.environ.get("BUNNY_API_KEY") + if not key: + sys.exit("set BUNNY_API_KEY in .env") + if args.cmd == "create": + cmd_create(key, args.name) + return 0 + if not args.script_id: + sys.exit("pass --script-id or set BUNNY_MCP_SCRIPT_ID") + if args.cmd == "deploy": + cmd_deploy(key, args.script_id, args.no_build, args.smoke_url) + elif args.cmd == "info": + info = script_info(key, args.script_id) + info.pop("DeploymentKey", None) + print(json.dumps(info, indent=1)) + elif args.cmd == "hostname": + cmd_hostname(key, args.script_id, args.host) + elif args.cmd == "vars": + cmd_vars(key, args.script_id, args.assignments) + elif args.cmd == "delete": + api("DELETE", f"/compute/script/{args.script_id}", key) + print(f"deleted script {args.script_id}", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_mcp_query_context.py b/tests/test_mcp_query_context.py new file mode 100644 index 0000000..d732f04 --- /dev/null +++ b/tests/test_mcp_query_context.py @@ -0,0 +1,60 @@ +"""The MCP server's query context (/data/mcp/.json) is the contract +between stage 04 and the edge script (mcp/src/context.js): a key stage 04 stops +writing, or one the script starts reading, fails silently at runtime (a filter +that matches nothing). Pin both sides.""" + +from __future__ import annotations + +import glob +import json +import re +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) + +from _lib import map_template as mt # noqa: E402 + +CONTEXT_JS = ROOT / "mcp" / "src" / "context.js" +WIKI = ROOT / "wiki" + + +def test_context_js_reads_exactly_the_written_keys(): + src = CONTEXT_JS.read_text(encoding="utf-8") + read = set(re.findall(r"\bctx\.([a-z_]+)\b", src)) + assert read == set(mt.QUERY_CONTEXT_KEYS), read ^ set(mt.QUERY_CONTEXT_KEYS) + + +def test_supported_format_matches(): + src = CONTEXT_JS.read_text(encoding="utf-8") + formats = json.loads(re.search(r"SUPPORTED_FORMATS = (\[[^\]]*\])", src).group(1)) + assert mt.QUERY_CONTEXT_FORMAT in formats + + +def test_query_core_inlines_as_a_classic_script(): + inline = mt._QUERY_CORE_INLINE + assert not re.search(r"^\s*(?:import|export)\b", inline, flags=re.M) + assert "function createQueryCore" in inline and "TOOL_DEFS" in inline + + +def test_app_js_carries_the_query_core_token(): + assert mt._APP_JS_SOURCE.count("__OWM_query_core__") == 1 + + +@pytest.mark.skipif(not (WIKI / "data" / "mcp" / "en.json").exists(), reason="no wiki/ build") +@pytest.mark.parametrize("locale", ["en", "fr", "es", "nl"]) +def test_built_context(locale): + ctx = json.loads((WIKI / "data" / "mcp" / f"{locale}.json").read_text(encoding="utf-8")) + assert set(ctx) == set(mt.QUERY_CONTEXT_KEYS) + assert ctx["format_version"] == mt.QUERY_CONTEXT_FORMAT + assert ctx["locale"] == locale + assert set(ctx["labels"]) == set(mt.QUERY_CONTEXT_LABELS) + # Same records as the page's startup bundle, same fields. + blob = glob.glob(str(WIKI / "data" / f"aocs.{locale}.*.js"))[0] + text = Path(blob).read_text(encoding="utf-8") + startup = json.loads(text[text.index("{"):text.rindex("}") + 1])["aocs"] + assert ctx["aocs"] == startup + assert all(set(v) == {"name"} for v in ctx["grapes_info"].values())