From 389b0e76564a58bdfcb41d82964f78c306262146 Mon Sep 17 00:00:00 2001 From: Boris De Vloed Date: Sun, 4 Oct 2026 15:44:01 +0200 Subject: [PATCH 01/14] feat(webmcp): shared query core, list_facets, MCP query context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search, filter and record shaping move out of app.js into scripts/_lib/assets/query_core.mjs, a dependency-free ES module that stage 04 inlines into app.js (export keywords stripped) and the coming MCP server bundles unchanged, so both surfaces answer identically. The omnisearch, tree filter and label helpers are aliases of the core instance; the WebMCP tools become thin adapters over it. - new WebMCP tool list_facets (countries, regions, styles, schemes with counts, values that round-trip into filter_appellations) - tool schemas hardened (limit bounds, country pattern, slug pattern, maxLength) and shared as TOOL_DEFS; results carry structuredContent - fix: a country-qualified scheme ("it:docg", "es:vino-de-pago") never matched — the ':' was normalised away; "Vino de Pago" now matches too - stage 04 writes wiki/data/mcp/.json, the query context the server reads (QUERY_CONTEXT_KEYS / QUERY_CONTEXT_FORMAT) - About dialog (4 locales) and llms.txt advertise https://mcp.openwinemap.com/mcp Co-Authored-By: Claude Opus 5.5 --- eslint.config.js | 26 +- locale/en/LC_MESSAGES/messages.po | 548 ++++++++++++++-------------- locale/es/LC_MESSAGES/messages.po | 548 ++++++++++++++-------------- locale/fr/LC_MESSAGES/messages.po | 540 ++++++++++++++-------------- locale/messages.pot | 540 ++++++++++++++-------------- locale/nl/LC_MESSAGES/messages.po | 550 +++++++++++++++-------------- scripts/04_build_maps.py | 22 ++ scripts/_lib/assets/app.js | 358 +++---------------- scripts/_lib/assets/query_core.mjs | 392 ++++++++++++++++++++ scripts/_lib/map_template.py | 75 ++++ 10 files changed, 1958 insertions(+), 1641 deletions(-) create mode 100644 scripts/_lib/assets/query_core.mjs 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..6a13b0f 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 15:27+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,220 @@ 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 "" +"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:517 msgid "BOURGOGNE" msgstr "Burgundy" -#: scripts/_lib/map_template.py:500 +#: scripts/_lib/map_template.py:518 msgid "BEAUJOLAIS" msgstr "Beaujolais" -#: scripts/_lib/map_template.py:501 +#: scripts/_lib/map_template.py:519 msgid "JURA" msgstr "Jura" -#: scripts/_lib/map_template.py:502 +#: scripts/_lib/map_template.py:520 msgid "SAVOIE" msgstr "Savoie" -#: scripts/_lib/map_template.py:503 +#: scripts/_lib/map_template.py:521 msgid "BUGEY" msgstr "Bugey" -#: scripts/_lib/map_template.py:504 +#: scripts/_lib/map_template.py:522 msgid "ALSACE" msgstr "Alsace" -#: scripts/_lib/map_template.py:505 +#: scripts/_lib/map_template.py:523 msgid "LORRAINE" msgstr "Lorraine" -#: scripts/_lib/map_template.py:506 +#: scripts/_lib/map_template.py:524 msgid "VAL DE LOIRE" msgstr "Loire Valley" -#: scripts/_lib/map_template.py:507 +#: scripts/_lib/map_template.py:525 msgid "BORDEAUX" msgstr "Bordeaux" -#: scripts/_lib/map_template.py:508 +#: scripts/_lib/map_template.py:526 msgid "SUD-OUEST" msgstr "South-West" -#: scripts/_lib/map_template.py:509 +#: scripts/_lib/map_template.py:527 msgid "VALLEE DU RHÔNE" msgstr "Rhône Valley" -#: scripts/_lib/map_template.py:510 +#: scripts/_lib/map_template.py:528 msgid "LANGUEDOC" msgstr "Languedoc" -#: scripts/_lib/map_template.py:511 +#: scripts/_lib/map_template.py:529 msgid "ROUSSILLON" msgstr "Roussillon" -#: scripts/_lib/map_template.py:512 +#: scripts/_lib/map_template.py:530 msgid "PROVENCE" msgstr "Provence" -#: scripts/_lib/map_template.py:513 +#: scripts/_lib/map_template.py:531 msgid "CORSE" msgstr "Corsica" -#: scripts/_lib/map_template.py:514 +#: scripts/_lib/map_template.py:532 msgid "CHAMPAGNE" msgstr "Champagne" -#: scripts/_lib/map_template.py:515 +#: scripts/_lib/map_template.py:533 msgid "ILE-DE-FRANCE" msgstr "Île-de-France" -#: scripts/_lib/map_template.py:516 +#: scripts/_lib/map_template.py:534 msgid "NORMANDIE" msgstr "Normandy" -#: scripts/_lib/map_template.py:517 +#: scripts/_lib/map_template.py:535 msgid "BRETAGNE" msgstr "Brittany" -#: scripts/_lib/map_template.py:518 +#: scripts/_lib/map_template.py:536 msgid "MAINE" msgstr "Maine" -#: scripts/_lib/map_template.py:519 +#: scripts/_lib/map_template.py:537 msgid "COGNAC" msgstr "Cognac" -#: scripts/_lib/map_template.py:520 +#: scripts/_lib/map_template.py:538 msgid "ARMAGNAC" msgstr "Armagnac" -#: scripts/_lib/map_template.py:521 +#: scripts/_lib/map_template.py:539 msgid "RHUM" msgstr "Rum" -#: scripts/_lib/map_template.py:561 +#: scripts/_lib/map_template.py:579 msgid "France" msgstr "France" -#: scripts/_lib/map_template.py:562 +#: scripts/_lib/map_template.py:580 msgid "Espagne" msgstr "Spain" -#: scripts/_lib/map_template.py:563 +#: scripts/_lib/map_template.py:581 msgid "Portugal" msgstr "Portugal" -#: scripts/_lib/map_template.py:564 +#: scripts/_lib/map_template.py:582 msgid "Italie" msgstr "Italy" -#: scripts/_lib/map_template.py:565 +#: scripts/_lib/map_template.py:583 msgid "Autriche" msgstr "Austria" -#: scripts/_lib/map_template.py:566 +#: scripts/_lib/map_template.py:584 msgid "Slovénie" msgstr "Slovenia" -#: scripts/_lib/map_template.py:567 +#: scripts/_lib/map_template.py:585 msgid "Croatie" msgstr "Croatia" -#: scripts/_lib/map_template.py:568 +#: scripts/_lib/map_template.py:586 msgid "Hongrie" msgstr "Hungary" -#: scripts/_lib/map_template.py:569 +#: scripts/_lib/map_template.py:587 msgid "Roumanie" msgstr "Romania" -#: scripts/_lib/map_template.py:570 +#: scripts/_lib/map_template.py:588 msgid "Bulgarie" msgstr "Bulgaria" -#: scripts/_lib/map_template.py:571 +#: scripts/_lib/map_template.py:589 msgid "Grèce" msgstr "Greece" -#: scripts/_lib/map_template.py:572 +#: scripts/_lib/map_template.py:590 msgid "Allemagne" msgstr "Germany" -#: scripts/_lib/map_template.py:573 +#: scripts/_lib/map_template.py:591 msgid "Slovaquie" msgstr "Slovakia" -#: scripts/_lib/map_template.py:574 +#: scripts/_lib/map_template.py:592 msgid "Suisse" msgstr "Switzerland" -#: scripts/_lib/map_template.py:575 +#: scripts/_lib/map_template.py:593 msgid "Tchéquie" msgstr "Czechia" -#: scripts/_lib/map_template.py:576 +#: scripts/_lib/map_template.py:594 msgid "Luxembourg" msgstr "Luxembourg" -#: scripts/_lib/map_template.py:577 +#: scripts/_lib/map_template.py:595 msgid "Belgique" msgstr "Belgium" -#: scripts/_lib/map_template.py:578 +#: scripts/_lib/map_template.py:596 msgid "Pays-Bas" msgstr "Netherlands" -#: scripts/_lib/map_template.py:579 +#: scripts/_lib/map_template.py:597 msgid "Malte" msgstr "Malta" -#: scripts/_lib/map_template.py:580 +#: scripts/_lib/map_template.py:598 msgid "Chypre" msgstr "Cyprus" -#: scripts/_lib/map_template.py:581 +#: scripts/_lib/map_template.py:599 msgid "Royaume-Uni" msgstr "United Kingdom" diff --git a/locale/es/LC_MESSAGES/messages.po b/locale/es/LC_MESSAGES/messages.po index f3e0647..056238c 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 15:27+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,213 @@ 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 "" +"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:517 msgid "BOURGOGNE" msgstr "Borgoña" -#: scripts/_lib/map_template.py:500 +#: scripts/_lib/map_template.py:518 msgid "BEAUJOLAIS" msgstr "Beaujolais" -#: scripts/_lib/map_template.py:501 +#: scripts/_lib/map_template.py:519 msgid "JURA" msgstr "Jura" -#: scripts/_lib/map_template.py:502 +#: scripts/_lib/map_template.py:520 msgid "SAVOIE" msgstr "Saboya" -#: scripts/_lib/map_template.py:503 +#: scripts/_lib/map_template.py:521 msgid "BUGEY" msgstr "Bugey" -#: scripts/_lib/map_template.py:504 +#: scripts/_lib/map_template.py:522 msgid "ALSACE" msgstr "Alsacia" -#: scripts/_lib/map_template.py:505 +#: scripts/_lib/map_template.py:523 msgid "LORRAINE" msgstr "Lorena" -#: scripts/_lib/map_template.py:506 +#: scripts/_lib/map_template.py:524 msgid "VAL DE LOIRE" msgstr "Valle del Loira" -#: scripts/_lib/map_template.py:507 +#: scripts/_lib/map_template.py:525 msgid "BORDEAUX" msgstr "Burdeos" -#: scripts/_lib/map_template.py:508 +#: scripts/_lib/map_template.py:526 msgid "SUD-OUEST" msgstr "Suroeste" -#: scripts/_lib/map_template.py:509 +#: scripts/_lib/map_template.py:527 msgid "VALLEE DU RHÔNE" msgstr "Valle del Ródano" -#: scripts/_lib/map_template.py:510 +#: scripts/_lib/map_template.py:528 msgid "LANGUEDOC" msgstr "Languedoc" -#: scripts/_lib/map_template.py:511 +#: scripts/_lib/map_template.py:529 msgid "ROUSSILLON" msgstr "Rosellón" -#: scripts/_lib/map_template.py:512 +#: scripts/_lib/map_template.py:530 msgid "PROVENCE" msgstr "Provenza" -#: scripts/_lib/map_template.py:513 +#: scripts/_lib/map_template.py:531 msgid "CORSE" msgstr "Córcega" -#: scripts/_lib/map_template.py:514 +#: scripts/_lib/map_template.py:532 msgid "CHAMPAGNE" msgstr "Champaña" -#: scripts/_lib/map_template.py:515 +#: scripts/_lib/map_template.py:533 msgid "ILE-DE-FRANCE" msgstr "Isla de Francia" -#: scripts/_lib/map_template.py:516 +#: scripts/_lib/map_template.py:534 msgid "NORMANDIE" msgstr "Normandía" -#: scripts/_lib/map_template.py:517 +#: scripts/_lib/map_template.py:535 msgid "BRETAGNE" msgstr "Bretaña" -#: scripts/_lib/map_template.py:518 +#: scripts/_lib/map_template.py:536 msgid "MAINE" msgstr "Maine" -#: scripts/_lib/map_template.py:519 +#: scripts/_lib/map_template.py:537 msgid "COGNAC" msgstr "Coñac" -#: scripts/_lib/map_template.py:520 +#: scripts/_lib/map_template.py:538 msgid "ARMAGNAC" msgstr "Armañac" -#: scripts/_lib/map_template.py:521 +#: scripts/_lib/map_template.py:539 msgid "RHUM" msgstr "Ron" -#: scripts/_lib/map_template.py:561 +#: scripts/_lib/map_template.py:579 msgid "France" msgstr "Francia" -#: scripts/_lib/map_template.py:562 +#: scripts/_lib/map_template.py:580 msgid "Espagne" msgstr "España" -#: scripts/_lib/map_template.py:563 +#: scripts/_lib/map_template.py:581 msgid "Portugal" msgstr "Portugal" -#: scripts/_lib/map_template.py:564 +#: scripts/_lib/map_template.py:582 msgid "Italie" msgstr "Italia" -#: scripts/_lib/map_template.py:565 +#: scripts/_lib/map_template.py:583 msgid "Autriche" msgstr "Austria" -#: scripts/_lib/map_template.py:566 +#: scripts/_lib/map_template.py:584 msgid "Slovénie" msgstr "Eslovenia" -#: scripts/_lib/map_template.py:567 +#: scripts/_lib/map_template.py:585 msgid "Croatie" msgstr "Croacia" -#: scripts/_lib/map_template.py:568 +#: scripts/_lib/map_template.py:586 msgid "Hongrie" msgstr "Hungría" -#: scripts/_lib/map_template.py:569 +#: scripts/_lib/map_template.py:587 msgid "Roumanie" msgstr "Rumanía" -#: scripts/_lib/map_template.py:570 +#: scripts/_lib/map_template.py:588 msgid "Bulgarie" msgstr "Bulgaria" -#: scripts/_lib/map_template.py:571 +#: scripts/_lib/map_template.py:589 msgid "Grèce" msgstr "Grecia" -#: scripts/_lib/map_template.py:572 +#: scripts/_lib/map_template.py:590 msgid "Allemagne" msgstr "Alemania" -#: scripts/_lib/map_template.py:573 +#: scripts/_lib/map_template.py:591 msgid "Slovaquie" msgstr "Eslovaquia" -#: scripts/_lib/map_template.py:574 +#: scripts/_lib/map_template.py:592 msgid "Suisse" msgstr "Suiza" -#: scripts/_lib/map_template.py:575 +#: scripts/_lib/map_template.py:593 msgid "Tchéquie" msgstr "Chequia" -#: scripts/_lib/map_template.py:576 +#: scripts/_lib/map_template.py:594 msgid "Luxembourg" msgstr "Luxemburgo" -#: scripts/_lib/map_template.py:577 +#: scripts/_lib/map_template.py:595 msgid "Belgique" msgstr "Bélgica" -#: scripts/_lib/map_template.py:578 +#: scripts/_lib/map_template.py:596 msgid "Pays-Bas" msgstr "Países Bajos" -#: scripts/_lib/map_template.py:579 +#: scripts/_lib/map_template.py:597 msgid "Malte" msgstr "Malta" -#: scripts/_lib/map_template.py:580 +#: scripts/_lib/map_template.py:598 msgid "Chypre" msgstr "Chipre" -#: scripts/_lib/map_template.py:581 +#: scripts/_lib/map_template.py:599 msgid "Royaume-Uni" msgstr "Reino Unido" diff --git a/locale/fr/LC_MESSAGES/messages.po b/locale/fr/LC_MESSAGES/messages.po index dbca02f..1b612bd 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 15:27+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,213 @@ 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 "" +"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:517 msgid "BOURGOGNE" msgstr "Bourgogne" -#: scripts/_lib/map_template.py:500 +#: scripts/_lib/map_template.py:518 msgid "BEAUJOLAIS" msgstr "Beaujolais" -#: scripts/_lib/map_template.py:501 +#: scripts/_lib/map_template.py:519 msgid "JURA" msgstr "Jura" -#: scripts/_lib/map_template.py:502 +#: scripts/_lib/map_template.py:520 msgid "SAVOIE" msgstr "Savoie" -#: scripts/_lib/map_template.py:503 +#: scripts/_lib/map_template.py:521 msgid "BUGEY" msgstr "Bugey" -#: scripts/_lib/map_template.py:504 +#: scripts/_lib/map_template.py:522 msgid "ALSACE" msgstr "Alsace" -#: scripts/_lib/map_template.py:505 +#: scripts/_lib/map_template.py:523 msgid "LORRAINE" msgstr "Lorraine" -#: scripts/_lib/map_template.py:506 +#: scripts/_lib/map_template.py:524 msgid "VAL DE LOIRE" msgstr "Val de Loire" -#: scripts/_lib/map_template.py:507 +#: scripts/_lib/map_template.py:525 msgid "BORDEAUX" msgstr "Bordeaux" -#: scripts/_lib/map_template.py:508 +#: scripts/_lib/map_template.py:526 msgid "SUD-OUEST" msgstr "Sud-Ouest" -#: scripts/_lib/map_template.py:509 +#: scripts/_lib/map_template.py:527 msgid "VALLEE DU RHÔNE" msgstr "Vallée du Rhône" -#: scripts/_lib/map_template.py:510 +#: scripts/_lib/map_template.py:528 msgid "LANGUEDOC" msgstr "Languedoc" -#: scripts/_lib/map_template.py:511 +#: scripts/_lib/map_template.py:529 msgid "ROUSSILLON" msgstr "Roussillon" -#: scripts/_lib/map_template.py:512 +#: scripts/_lib/map_template.py:530 msgid "PROVENCE" msgstr "Provence" -#: scripts/_lib/map_template.py:513 +#: scripts/_lib/map_template.py:531 msgid "CORSE" msgstr "Corse" -#: scripts/_lib/map_template.py:514 +#: scripts/_lib/map_template.py:532 msgid "CHAMPAGNE" msgstr "Champagne" -#: scripts/_lib/map_template.py:515 +#: scripts/_lib/map_template.py:533 msgid "ILE-DE-FRANCE" msgstr "Île-de-France" -#: scripts/_lib/map_template.py:516 +#: scripts/_lib/map_template.py:534 msgid "NORMANDIE" msgstr "Normandie" -#: scripts/_lib/map_template.py:517 +#: scripts/_lib/map_template.py:535 msgid "BRETAGNE" msgstr "Bretagne" -#: scripts/_lib/map_template.py:518 +#: scripts/_lib/map_template.py:536 msgid "MAINE" msgstr "Maine" -#: scripts/_lib/map_template.py:519 +#: scripts/_lib/map_template.py:537 msgid "COGNAC" msgstr "Cognac" -#: scripts/_lib/map_template.py:520 +#: scripts/_lib/map_template.py:538 msgid "ARMAGNAC" msgstr "Armagnac" -#: scripts/_lib/map_template.py:521 +#: scripts/_lib/map_template.py:539 msgid "RHUM" msgstr "Rhum" -#: scripts/_lib/map_template.py:561 +#: scripts/_lib/map_template.py:579 msgid "France" msgstr "France" -#: scripts/_lib/map_template.py:562 +#: scripts/_lib/map_template.py:580 msgid "Espagne" msgstr "Espagne" -#: scripts/_lib/map_template.py:563 +#: scripts/_lib/map_template.py:581 msgid "Portugal" msgstr "Portugal" -#: scripts/_lib/map_template.py:564 +#: scripts/_lib/map_template.py:582 msgid "Italie" msgstr "Italie" -#: scripts/_lib/map_template.py:565 +#: scripts/_lib/map_template.py:583 msgid "Autriche" msgstr "Autriche" -#: scripts/_lib/map_template.py:566 +#: scripts/_lib/map_template.py:584 msgid "Slovénie" msgstr "Slovénie" -#: scripts/_lib/map_template.py:567 +#: scripts/_lib/map_template.py:585 msgid "Croatie" msgstr "Croatie" -#: scripts/_lib/map_template.py:568 +#: scripts/_lib/map_template.py:586 msgid "Hongrie" msgstr "Hongrie" -#: scripts/_lib/map_template.py:569 +#: scripts/_lib/map_template.py:587 msgid "Roumanie" msgstr "Roumanie" -#: scripts/_lib/map_template.py:570 +#: scripts/_lib/map_template.py:588 msgid "Bulgarie" msgstr "Bulgarie" -#: scripts/_lib/map_template.py:571 +#: scripts/_lib/map_template.py:589 msgid "Grèce" msgstr "Grèce" -#: scripts/_lib/map_template.py:572 +#: scripts/_lib/map_template.py:590 msgid "Allemagne" msgstr "Allemagne" -#: scripts/_lib/map_template.py:573 +#: scripts/_lib/map_template.py:591 msgid "Slovaquie" msgstr "Slovaquie" -#: scripts/_lib/map_template.py:574 +#: scripts/_lib/map_template.py:592 msgid "Suisse" msgstr "Suisse" -#: scripts/_lib/map_template.py:575 +#: scripts/_lib/map_template.py:593 msgid "Tchéquie" msgstr "Tchéquie" -#: scripts/_lib/map_template.py:576 +#: scripts/_lib/map_template.py:594 msgid "Luxembourg" msgstr "Luxembourg" -#: scripts/_lib/map_template.py:577 +#: scripts/_lib/map_template.py:595 msgid "Belgique" msgstr "Belgique" -#: scripts/_lib/map_template.py:578 +#: scripts/_lib/map_template.py:596 msgid "Pays-Bas" msgstr "Pays-Bas" -#: scripts/_lib/map_template.py:579 +#: scripts/_lib/map_template.py:597 msgid "Malte" msgstr "Malte" -#: scripts/_lib/map_template.py:580 +#: scripts/_lib/map_template.py:598 msgid "Chypre" msgstr "Chypre" -#: scripts/_lib/map_template.py:581 +#: scripts/_lib/map_template.py:599 msgid "Royaume-Uni" msgstr "Royaume-Uni" diff --git a/locale/messages.pot b/locale/messages.pot index 613180b..3bf3b63 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 15:27+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,235 @@ 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 +#: scripts/_lib/map_template.py:496 +#, 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:517 msgid "BOURGOGNE" msgstr "" -#: scripts/_lib/map_template.py:500 +#: scripts/_lib/map_template.py:518 msgid "BEAUJOLAIS" msgstr "" -#: scripts/_lib/map_template.py:501 +#: scripts/_lib/map_template.py:519 msgid "JURA" msgstr "" -#: scripts/_lib/map_template.py:502 +#: scripts/_lib/map_template.py:520 msgid "SAVOIE" msgstr "" -#: scripts/_lib/map_template.py:503 +#: scripts/_lib/map_template.py:521 msgid "BUGEY" msgstr "" -#: scripts/_lib/map_template.py:504 +#: scripts/_lib/map_template.py:522 msgid "ALSACE" msgstr "" -#: scripts/_lib/map_template.py:505 +#: scripts/_lib/map_template.py:523 msgid "LORRAINE" msgstr "" -#: scripts/_lib/map_template.py:506 +#: scripts/_lib/map_template.py:524 msgid "VAL DE LOIRE" msgstr "" -#: scripts/_lib/map_template.py:507 +#: scripts/_lib/map_template.py:525 msgid "BORDEAUX" msgstr "" -#: scripts/_lib/map_template.py:508 +#: scripts/_lib/map_template.py:526 msgid "SUD-OUEST" msgstr "" -#: scripts/_lib/map_template.py:509 +#: scripts/_lib/map_template.py:527 msgid "VALLEE DU RHÔNE" msgstr "" -#: scripts/_lib/map_template.py:510 +#: scripts/_lib/map_template.py:528 msgid "LANGUEDOC" msgstr "" -#: scripts/_lib/map_template.py:511 +#: scripts/_lib/map_template.py:529 msgid "ROUSSILLON" msgstr "" -#: scripts/_lib/map_template.py:512 +#: scripts/_lib/map_template.py:530 msgid "PROVENCE" msgstr "" -#: scripts/_lib/map_template.py:513 +#: scripts/_lib/map_template.py:531 msgid "CORSE" msgstr "" -#: scripts/_lib/map_template.py:514 +#: scripts/_lib/map_template.py:532 msgid "CHAMPAGNE" msgstr "" -#: scripts/_lib/map_template.py:515 +#: scripts/_lib/map_template.py:533 msgid "ILE-DE-FRANCE" msgstr "" -#: scripts/_lib/map_template.py:516 +#: scripts/_lib/map_template.py:534 msgid "NORMANDIE" msgstr "" -#: scripts/_lib/map_template.py:517 +#: scripts/_lib/map_template.py:535 msgid "BRETAGNE" msgstr "" -#: scripts/_lib/map_template.py:518 +#: scripts/_lib/map_template.py:536 msgid "MAINE" msgstr "" -#: scripts/_lib/map_template.py:519 +#: scripts/_lib/map_template.py:537 msgid "COGNAC" msgstr "" -#: scripts/_lib/map_template.py:520 +#: scripts/_lib/map_template.py:538 msgid "ARMAGNAC" msgstr "" -#: scripts/_lib/map_template.py:521 +#: scripts/_lib/map_template.py:539 msgid "RHUM" msgstr "" -#: scripts/_lib/map_template.py:561 +#: scripts/_lib/map_template.py:579 msgid "France" msgstr "" -#: scripts/_lib/map_template.py:562 +#: scripts/_lib/map_template.py:580 msgid "Espagne" msgstr "" -#: scripts/_lib/map_template.py:563 +#: scripts/_lib/map_template.py:581 msgid "Portugal" msgstr "" -#: scripts/_lib/map_template.py:564 +#: scripts/_lib/map_template.py:582 msgid "Italie" msgstr "" -#: scripts/_lib/map_template.py:565 +#: scripts/_lib/map_template.py:583 msgid "Autriche" msgstr "" -#: scripts/_lib/map_template.py:566 +#: scripts/_lib/map_template.py:584 msgid "Slovénie" msgstr "" -#: scripts/_lib/map_template.py:567 +#: scripts/_lib/map_template.py:585 msgid "Croatie" msgstr "" -#: scripts/_lib/map_template.py:568 +#: scripts/_lib/map_template.py:586 msgid "Hongrie" msgstr "" -#: scripts/_lib/map_template.py:569 +#: scripts/_lib/map_template.py:587 msgid "Roumanie" msgstr "" -#: scripts/_lib/map_template.py:570 +#: scripts/_lib/map_template.py:588 msgid "Bulgarie" msgstr "" -#: scripts/_lib/map_template.py:571 +#: scripts/_lib/map_template.py:589 msgid "Grèce" msgstr "" -#: scripts/_lib/map_template.py:572 +#: scripts/_lib/map_template.py:590 msgid "Allemagne" msgstr "" -#: scripts/_lib/map_template.py:573 +#: scripts/_lib/map_template.py:591 msgid "Slovaquie" msgstr "" -#: scripts/_lib/map_template.py:574 +#: scripts/_lib/map_template.py:592 msgid "Suisse" msgstr "" -#: scripts/_lib/map_template.py:575 +#: scripts/_lib/map_template.py:593 msgid "Tchéquie" msgstr "" -#: scripts/_lib/map_template.py:576 +#: scripts/_lib/map_template.py:594 msgid "Luxembourg" msgstr "" -#: scripts/_lib/map_template.py:577 +#: scripts/_lib/map_template.py:595 msgid "Belgique" msgstr "" -#: scripts/_lib/map_template.py:578 +#: scripts/_lib/map_template.py:596 msgid "Pays-Bas" msgstr "" -#: scripts/_lib/map_template.py:579 +#: scripts/_lib/map_template.py:597 msgid "Malte" msgstr "" -#: scripts/_lib/map_template.py:580 +#: scripts/_lib/map_template.py:598 msgid "Chypre" msgstr "" -#: scripts/_lib/map_template.py:581 +#: scripts/_lib/map_template.py:599 msgid "Royaume-Uni" msgstr "" diff --git a/locale/nl/LC_MESSAGES/messages.po b/locale/nl/LC_MESSAGES/messages.po index 0080aad..1681dbb 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 15:27+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,213 @@ 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 "" +"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 aangepaste connector)." + +#: scripts/_lib/map_template.py:517 msgid "BOURGOGNE" msgstr "Bourgogne" -#: scripts/_lib/map_template.py:500 +#: scripts/_lib/map_template.py:518 msgid "BEAUJOLAIS" msgstr "Beaujolais" -#: scripts/_lib/map_template.py:501 +#: scripts/_lib/map_template.py:519 msgid "JURA" msgstr "Jura" -#: scripts/_lib/map_template.py:502 +#: scripts/_lib/map_template.py:520 msgid "SAVOIE" msgstr "Savoie" -#: scripts/_lib/map_template.py:503 +#: scripts/_lib/map_template.py:521 msgid "BUGEY" msgstr "Bugey" -#: scripts/_lib/map_template.py:504 +#: scripts/_lib/map_template.py:522 msgid "ALSACE" msgstr "Elzas" -#: scripts/_lib/map_template.py:505 +#: scripts/_lib/map_template.py:523 msgid "LORRAINE" msgstr "Lotharingen" -#: scripts/_lib/map_template.py:506 +#: scripts/_lib/map_template.py:524 msgid "VAL DE LOIRE" msgstr "Loirestreek" -#: scripts/_lib/map_template.py:507 +#: scripts/_lib/map_template.py:525 msgid "BORDEAUX" msgstr "Bordeaux" -#: scripts/_lib/map_template.py:508 +#: scripts/_lib/map_template.py:526 msgid "SUD-OUEST" msgstr "Zuidwest-Frankrijk" -#: scripts/_lib/map_template.py:509 +#: scripts/_lib/map_template.py:527 msgid "VALLEE DU RHÔNE" msgstr "Rhônestreek" -#: scripts/_lib/map_template.py:510 +#: scripts/_lib/map_template.py:528 msgid "LANGUEDOC" msgstr "Languedoc" -#: scripts/_lib/map_template.py:511 +#: scripts/_lib/map_template.py:529 msgid "ROUSSILLON" msgstr "Roussillon" -#: scripts/_lib/map_template.py:512 +#: scripts/_lib/map_template.py:530 msgid "PROVENCE" msgstr "Provence" -#: scripts/_lib/map_template.py:513 +#: scripts/_lib/map_template.py:531 msgid "CORSE" msgstr "Corsica" -#: scripts/_lib/map_template.py:514 +#: scripts/_lib/map_template.py:532 msgid "CHAMPAGNE" msgstr "Champagne" -#: scripts/_lib/map_template.py:515 +#: scripts/_lib/map_template.py:533 msgid "ILE-DE-FRANCE" msgstr "Île-de-France" -#: scripts/_lib/map_template.py:516 +#: scripts/_lib/map_template.py:534 msgid "NORMANDIE" msgstr "Normandië" -#: scripts/_lib/map_template.py:517 +#: scripts/_lib/map_template.py:535 msgid "BRETAGNE" msgstr "Bretagne" -#: scripts/_lib/map_template.py:518 +#: scripts/_lib/map_template.py:536 msgid "MAINE" msgstr "Maine" -#: scripts/_lib/map_template.py:519 +#: scripts/_lib/map_template.py:537 msgid "COGNAC" msgstr "Cognac" -#: scripts/_lib/map_template.py:520 +#: scripts/_lib/map_template.py:538 msgid "ARMAGNAC" msgstr "Armagnac" -#: scripts/_lib/map_template.py:521 +#: scripts/_lib/map_template.py:539 msgid "RHUM" msgstr "Rum" -#: scripts/_lib/map_template.py:561 +#: scripts/_lib/map_template.py:579 msgid "France" msgstr "Frankrijk" -#: scripts/_lib/map_template.py:562 +#: scripts/_lib/map_template.py:580 msgid "Espagne" msgstr "Spanje" -#: scripts/_lib/map_template.py:563 +#: scripts/_lib/map_template.py:581 msgid "Portugal" msgstr "Portugal" -#: scripts/_lib/map_template.py:564 +#: scripts/_lib/map_template.py:582 msgid "Italie" msgstr "Italië" -#: scripts/_lib/map_template.py:565 +#: scripts/_lib/map_template.py:583 msgid "Autriche" msgstr "Oostenrijk" -#: scripts/_lib/map_template.py:566 +#: scripts/_lib/map_template.py:584 msgid "Slovénie" msgstr "Slovenië" -#: scripts/_lib/map_template.py:567 +#: scripts/_lib/map_template.py:585 msgid "Croatie" msgstr "Kroatië" -#: scripts/_lib/map_template.py:568 +#: scripts/_lib/map_template.py:586 msgid "Hongrie" msgstr "Hongarije" -#: scripts/_lib/map_template.py:569 +#: scripts/_lib/map_template.py:587 msgid "Roumanie" msgstr "Roemenië" -#: scripts/_lib/map_template.py:570 +#: scripts/_lib/map_template.py:588 msgid "Bulgarie" msgstr "Bulgarije" -#: scripts/_lib/map_template.py:571 +#: scripts/_lib/map_template.py:589 msgid "Grèce" msgstr "Griekenland" -#: scripts/_lib/map_template.py:572 +#: scripts/_lib/map_template.py:590 msgid "Allemagne" msgstr "Duitsland" -#: scripts/_lib/map_template.py:573 +#: scripts/_lib/map_template.py:591 msgid "Slovaquie" msgstr "Slowakije" -#: scripts/_lib/map_template.py:574 +#: scripts/_lib/map_template.py:592 msgid "Suisse" msgstr "Zwitserland" -#: scripts/_lib/map_template.py:575 +#: scripts/_lib/map_template.py:593 msgid "Tchéquie" msgstr "Tsjechië" -#: scripts/_lib/map_template.py:576 +#: scripts/_lib/map_template.py:594 msgid "Luxembourg" msgstr "Luxemburg" -#: scripts/_lib/map_template.py:577 +#: scripts/_lib/map_template.py:595 msgid "Belgique" msgstr "België" -#: scripts/_lib/map_template.py:578 +#: scripts/_lib/map_template.py:596 msgid "Pays-Bas" msgstr "Nederland" -#: scripts/_lib/map_template.py:579 +#: scripts/_lib/map_template.py:597 msgid "Malte" msgstr "Malta" -#: scripts/_lib/map_template.py:580 +#: scripts/_lib/map_template.py:598 msgid "Chypre" msgstr "Cyprus" -#: scripts/_lib/map_template.py:581 +#: scripts/_lib/map_template.py:599 msgid "Royaume-Uni" msgstr "Verenigd Koninkrijk" diff --git a/scripts/04_build_maps.py b/scripts/04_build_maps.py index d05eb95..02767c2 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,19 @@ 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. Add it as a custom " + "connector.", + "- Every map page registers the same 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..f3c3355 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]) => { @@ -3409,226 +3345,42 @@ __OWM_source_block__ // ---- 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..dc62772 100644 --- a/scripts/_lib/map_template.py +++ b/scripts/_lib/map_template.py @@ -492,6 +492,11 @@ 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_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 +678,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 +767,9 @@ 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)) + paragraphs.append(labels["about_mcp_html"].format( + mcp_link=f"{esc(MCP_ENDPOINT_URL)}" + )) paragraphs.append( labels["about_privacy_html"].format(plausible=_ext_link(_PLAUSIBLE_POLICY_URL, "Plausible")) ) @@ -2048,6 +2059,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", +}) +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 +2437,38 @@ 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}, + } + 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 +2628,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 @@ -3328,6 +3385,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).""" From 922b27a730cff4cfec6c150d70ad29eb3887d750 Mon Sep 17 00:00:00 2001 From: Boris De Vloed Date: Sun, 4 Oct 2026 15:44:01 +0200 Subject: [PATCH 02/14] feat(mcp): remote MCP server on Bunny Edge Scripting mcp/ is a stateless MCP server (official @modelcontextprotocol/server 2.3.0, createMcpHandler) deployed as a Bunny standalone Edge Script at https://mcp.openwinemap.com/mcp. It serves protocol 2026-07-28 natively and 2025-era clients through the SDK's stateless fallback, with the query core's tools (search_appellations, filter_appellations, get_appellation, list_facets) plus a locale argument. - data: /data/mcp/.json from the site, cached per isolate and revalidated by ETag; get_appellation merges the panel JSON - MCP Tool Plausible event sent server-side (tool + locale only) - scripts/deploy_mcp.py: esbuild bundle -> Bunny compute API -> publish with the git SHA -> smoke test in all three protocol modes - tests: node --test (core anchors, protocol conformance per mode, context store), parity_webmcp.py (page WebMCP == server, headless), tests/test_mcp_query_context.py (stage 04 <-> context.js contract) - CI job builds the bundle and runs the tests - docs/plan-mcp-server.md: plan, decisions, spike results Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 18 +- CLAUDE.md | 54 ++- docs/analytics.md | 3 +- docs/plan-mcp-server.md | 266 ++++++++++++ mcp/.gitignore | 2 + mcp/README.md | 43 ++ mcp/package-lock.json | 707 ++++++++++++++++++++++++++++++++ mcp/package.json | 19 + mcp/src/context.js | 75 ++++ mcp/src/edge.js | 41 ++ mcp/src/http.js | 39 ++ mcp/src/server.js | 108 +++++ mcp/test/core.test.js | 109 +++++ mcp/test/local.js | 47 +++ mcp/test/mcp_calls.js | 20 + mcp/test/parity_webmcp.py | 122 ++++++ mcp/test/protocol.test.js | 58 +++ mcp/test/smoke.js | 64 +++ scripts/deploy_mcp.py | 137 +++++++ tests/test_mcp_query_context.py | 60 +++ 20 files changed, 1977 insertions(+), 15 deletions(-) create mode 100644 docs/plan-mcp-server.md create mode 100644 mcp/.gitignore create mode 100644 mcp/README.md create mode 100644 mcp/package-lock.json create mode 100644 mcp/package.json create mode 100644 mcp/src/context.js create mode 100644 mcp/src/edge.js create mode 100644 mcp/src/http.js create mode 100644 mcp/src/server.js create mode 100644 mcp/test/core.test.js create mode 100644 mcp/test/local.js create mode 100644 mcp/test/mcp_calls.js create mode 100644 mcp/test/parity_webmcp.py create mode 100644 mcp/test/protocol.test.js create mode 100644 mcp/test/smoke.js create mode 100644 scripts/deploy_mcp.py create mode 100644 tests/test_mcp_query_context.py 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..5bfded5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6245,19 +6245,47 @@ 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). ## Analytics diff --git a/docs/analytics.md b/docs/analytics.md index 428f2a4..e9d8ff1 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -61,7 +61,8 @@ 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 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-server.md b/docs/plan-mcp-server.md new file mode 100644 index 0000000..e420b06 --- /dev/null +++ b/docs/plan-mcp-server.md @@ -0,0 +1,266 @@ +# Plan — a remote MCP server for Open Wine Map + +Status (2026-10-04): phases 0–3 and the code of 4–5 done on branch +`feat/mcp-server` (from `origin/main` a619b99), not yet committed or deployed. + +| 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 | `scripts/deploy_mcp.py` ready; waiting: site deploy (ships the data + new app.js), production script `owm-mcp`, hostname, DNS, SSL | +| 5 discovery | About dialog sentence (4 locales) + `llms.txt` "For AI agents" section + docs done; registry entry and Le Chat live test pending | + +## 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/mcp/.gitignore b/mcp/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/mcp/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/mcp/README.md b/mcp/README.md new file mode 100644 index 0000000..805ea08 --- /dev/null +++ b/mcp/README.md @@ -0,0 +1,43 @@ +# 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 | + +## 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 + +## 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 +``` + +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`. + +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..6ebf96d --- /dev/null +++ b/mcp/package-lock.json @@ -0,0 +1,707 @@ +{ + "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/server": "2.3.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/@modelcontextprotocol/client": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.3.0.tgz", + "integrity": "sha512-pbHtGj6EeFCHngFE8gtNPaKrsj8Mb1IMw45BLcaPML/m2WTxI7pyYmeNQvaux88Lw8BSO5r7nyRbUyIkICmGrg==", + "dev": true, + "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/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/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=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/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "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==", + "dev": true, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "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..6d1f1f5 --- /dev/null +++ b/mcp/package.json @@ -0,0 +1,19 @@ +{ + "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": "esbuild src/edge.js --bundle --format=esm --platform=browser --target=es2022 --external:node:* --minify --legal-comments=none --metafile=dist/meta.json --outfile=dist/edge.js", + "test": "node --test test/*.test.js" + }, + "dependencies": { + "@bunny.net/edgescript-sdk": "0.13.0", + "@cfworker/json-schema": "4.1.1", + "@modelcontextprotocol/server": "2.3.0" + }, + "devDependencies": { + "@modelcontextprotocol/client": "2.3.0", + "esbuild": "0.28.2" + } +} diff --git a/mcp/src/context.js b/mcp/src/context.js new file mode 100644 index 0000000..3919f6d --- /dev/null +++ b/mcp/src/context.js @@ -0,0 +1,75 @@ +// 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, 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, 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..91464f1 --- /dev/null +++ b/mcp/src/edge.js @@ -0,0 +1,41 @@ +// 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 +import * as BunnySDK from '@bunny.net/edgescript-sdk'; +import process from 'node:process'; +import { createApp } from './http.js'; + +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) 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 }); + +BunnySDK.net.http.serve(app); diff --git a/mcp/src/http.js b/mcp/src/http.js new file mode 100644 index 0000000..5f4cd3d --- /dev/null +++ b/mcp/src/http.js @@ -0,0 +1,39 @@ +// 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', + 'Access-Control-Expose-Headers': 'MCP-Protocol-Version, Mcp-Session-Id', + '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 }); +} + +// opts: { dataOrigin, fetchImpl?, onToolCallFor?(request) → (tool, locale) => void } +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 }); + return async function handle(request) { + const url = new URL(request.url); + if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: CORS }); + 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..c1db323 --- /dev/null +++ b/mcp/src/server.js @@ -0,0 +1,108 @@ +// 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'; + +export const SERVER_INFO = { + name: 'open-wine-map', + title: 'Open Wine Map', + version: '1.0.0', + 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.', +].join(' '); + +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, + }; +} + +function buildServer(tools, onToolCall) { + const server = new McpServer(SERVER_INFO, { + jsonSchemaValidator: validator, + capabilities: { tools: { listChanged: false } }, + instructions: INSTRUCTIONS, + }); + 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. +export function createHandler({ dataOrigin, fetchImpl = fetch, onToolCallFor, store }) { + const tools = createTools({ dataOrigin, fetchImpl, store }); + const hook = ctx => (onToolCallFor && ctx && ctx.requestInfo ? onToolCallFor(ctx.requestInfo) : undefined); + return createMcpHandler(ctx => buildServer(tools, hook(ctx)), { + legacy: 'stateless', + responseMode: 'json', + maxRequestBodySize: 64 * 1024, + }); +} 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..b1e27b3 --- /dev/null +++ b/mcp/test/local.js @@ -0,0 +1,47 @@ +// 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 }); + } +} + +export async function startServer() { + const app = createApp({ dataOrigin: DATA_ORIGIN, fetchImpl: wikiFetch }); + 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..5edb55d --- /dev/null +++ b/mcp/test/protocol.test.js @@ -0,0 +1,58 @@ +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']; + +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); +}); diff --git a/mcp/test/smoke.js b/mcp/test/smoke.js new file mode 100644 index 0000000..db3665d --- /dev/null +++ b/mcp/test/smoke.js @@ -0,0 +1,64 @@ +// 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); + const transport = new StreamableHTTPClientTransport(new URL(url)); + 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', {}], +]; + +if (import.meta.url === `file://${process.argv[1]}`) { + const url = process.argv[2]; + if (!url) throw new Error('usage: node test/smoke.js '); + 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) })); + } + } +} diff --git a/scripts/deploy_mcp.py b/scripts/deploy_mcp.py new file mode 100644 index 0000000..10d4528 --- /dev/null +++ b/scripts/deploy_mcp.py @@ -0,0 +1,137 @@ +"""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 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() -> None: + 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"], 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() + 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) -> None: + if not no_build: + build() + code = BUNDLE.read_text(encoding="utf-8") + api("POST", f"/compute/script/{script_id}/code", key, json={"Code": code}) + note = git_note() + api("POST", f"/compute/script/{script_id}/publish", key, json={"Note": note}) + info = script_info(key, script_id) + hosts = hostnames(info) + print(f"published {len(code.encode()):,} bytes as release '{note}' → {hosts}", file=sys.stderr) + if hosts: + subprocess.run(["node", "test/smoke.js", f"https://{hosts[0]}/mcp"], cwd=MCP_DIR, check=False) + + +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"): + 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") + 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) + elif args.cmd == "info": + info = script_info(key, args.script_id) + info.pop("DeploymentKey", None) + print(json.dumps(info, indent=1)) + 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()) From b9b1a0fa586fdf0d5d0a20927079208106b62ebe Mon Sep 17 00:00:00 2001 From: Boris De Vloed Date: Sun, 4 Oct 2026 15:50:12 +0200 Subject: [PATCH 03/14] feat(mcp): hostname / vars setup steps; smoke traffic not counted - deploy_mcp.py hostname HOST: attach the hostname to the script's pull zone, add the CNAME in the Bunny DNS zone, issue the free certificate, force SSL (idempotent; refuses to overwrite a different record) - deploy_mcp.py vars NAME=VALUE: set script environment variables - the smoke client sends X-OWM-Smoke; the edge script skips the `MCP Tool` Plausible event for it, so deploys don't inflate the counts Production: script owm-mcp (id 94571), https://mcp.openwinemap.com/mcp. Co-Authored-By: Claude Opus 5.5 --- mcp/src/edge.js | 2 +- mcp/src/http.js | 2 +- mcp/test/smoke.js | 3 ++- scripts/deploy_mcp.py | 59 ++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 62 insertions(+), 4 deletions(-) diff --git a/mcp/src/edge.js b/mcp/src/edge.js index 91464f1..8b873e2 100644 --- a/mcp/src/edge.js +++ b/mcp/src/edge.js @@ -16,7 +16,7 @@ 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) return undefined; + if (!plausibleHost || request.headers.get('X-OWM-Smoke')) return undefined; return (tool, locale) => { const send = fetch(`${plausibleHost}/api/event`, { method: 'POST', diff --git a/mcp/src/http.js b/mcp/src/http.js index 5f4cd3d..c381714 100644 --- a/mcp/src/http.js +++ b/mcp/src/http.js @@ -7,7 +7,7 @@ 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', + 'Content-Type, Accept, MCP-Protocol-Version, Mcp-Session-Id, Mcp-Method, Mcp-Name, Last-Event-ID, X-OWM-Smoke', 'Access-Control-Expose-Headers': 'MCP-Protocol-Version, Mcp-Session-Id', 'Access-Control-Max-Age': '86400', }; diff --git a/mcp/test/smoke.js b/mcp/test/smoke.js index db3665d..fbef3ea 100644 --- a/mcp/test/smoke.js +++ b/mcp/test/smoke.js @@ -13,7 +13,8 @@ export const MODES = { export async function smoke(url, mode, calls) { const opts = MODES[mode] ? { versionNegotiation: MODES[mode] } : {}; const client = new Client({ name: 'owm-smoke', version: '0' }, opts); - const transport = new StreamableHTTPClientTransport(new URL(url)); + // 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; diff --git a/scripts/deploy_mcp.py b/scripts/deploy_mcp.py index 10d4528..2d51e92 100644 --- a/scripts/deploy_mcp.py +++ b/scripts/deploy_mcp.py @@ -10,6 +10,11 @@ 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 @@ -101,17 +106,65 @@ def cmd_deploy(key: str, script_id: str, no_build: bool) -> None: subprocess.run(["node", "test/smoke.js", f"https://{hosts[0]}/mcp"], cwd=MCP_DIR, check=False) +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"): + 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") + 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: @@ -127,6 +180,10 @@ def main() -> int: 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) From a053593dc3771542d9dd3f25c42a1c099c19a7cc Mon Sep 17 00:00:00 2001 From: Boris De Vloed Date: Sun, 4 Oct 2026 15:54:09 +0200 Subject: [PATCH 04/14] fix(mcp): the deploy smoke test waits for the published release A smoke test right after publishing was answered by isolates still running the previous release (seen as test calls counted under the old, untracked code). The bundle now carries its git SHA as OWM_BUILD, the server reports it as version 1.0.0+, and deploy_mcp.py's smoke test polls until the endpoint reports that version before testing; a failed mode now fails the deploy. Co-Authored-By: Claude Opus 5.5 --- mcp/src/server.js | 8 +++++++- mcp/test/smoke.js | 24 ++++++++++++++++++++++-- scripts/deploy_mcp.py | 30 +++++++++++++++++++----------- 3 files changed, 48 insertions(+), 14 deletions(-) diff --git a/mcp/src/server.js b/mcp/src/server.js index c1db323..1d1ed46 100644 --- a/mcp/src/server.js +++ b/mcp/src/server.js @@ -8,10 +8,16 @@ import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/server/valida 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', + version: `1.0.0+${BUILD}`, websiteUrl: 'https://www.openwinemap.com/', }; diff --git a/mcp/test/smoke.js b/mcp/test/smoke.js index fbef3ea..4551461 100644 --- a/mcp/test/smoke.js +++ b/mcp/test/smoke.js @@ -44,9 +44,28 @@ export const SMOKE_CALLS = [ ['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 = process.argv[2]; - if (!url) throw new Error('usage: node test/smoke.js '); + 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); @@ -60,6 +79,7 @@ if (import.meta.url === `file://${process.argv[1]}`) { })); } catch (e) { console.log(JSON.stringify({ mode, error: String((e && e.message) || e) })); + process.exitCode = 1; } } } diff --git a/scripts/deploy_mcp.py b/scripts/deploy_mcp.py index 2d51e92..4b5b22e 100644 --- a/scripts/deploy_mcp.py +++ b/scripts/deploy_mcp.py @@ -51,11 +51,14 @@ def api(method: str, path: str, key: str, **kw) -> requests.Response: return r -def build() -> None: +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"], cwd=MCP_DIR, check=True) + subprocess.run(["npm", "run", "build", "--", f"--define:OWM_BUILD={json.dumps(note)}"], + cwd=MCP_DIR, check=True) def git_note() -> str: @@ -76,7 +79,7 @@ def hostnames(info: dict) -> list[str]: def cmd_create(key: str, name: str) -> None: - build() + build(git_note()) body = { "Name": name, "Code": BUNDLE.read_text(encoding="utf-8"), @@ -92,18 +95,21 @@ def cmd_create(key: str, name: str) -> None: "LinkedPullZones": info.get("LinkedPullZones")}, indent=1)) -def cmd_deploy(key: str, script_id: str, no_build: bool) -> None: +def cmd_deploy(key: str, script_id: str, no_build: bool, smoke_url: str | None) -> None: + note = git_note() if not no_build: - build() + build(note) code = BUNDLE.read_text(encoding="utf-8") api("POST", f"/compute/script/{script_id}/code", key, json={"Code": code}) - note = git_note() api("POST", f"/compute/script/{script_id}/publish", key, json={"Note": note}) - info = script_info(key, script_id) - hosts = hostnames(info) + hosts = hostnames(script_info(key, script_id)) print(f"published {len(code.encode()):,} bytes as release '{note}' → {hosts}", file=sys.stderr) - if hosts: - subprocess.run(["node", "test/smoke.js", f"https://{hosts[0]}/mcp"], cwd=MCP_DIR, check=False) + 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: @@ -161,6 +167,8 @@ def main() -> int: 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": @@ -175,7 +183,7 @@ def main() -> int: 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) + 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) From 188dd5d029d8c81dc2c62a4e4a9bb9caba917dc4 Mon Sep 17 00:00:00 2001 From: Boris De Vloed Date: Sun, 4 Oct 2026 15:54:28 +0200 Subject: [PATCH 05/14] =?UTF-8?q?docs(mcp):=20plan=20status=20=E2=80=94=20?= =?UTF-8?q?live=20on=20mcp.openwinemap.com?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/plan-mcp-server.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/plan-mcp-server.md b/docs/plan-mcp-server.md index e420b06..5e1f783 100644 --- a/docs/plan-mcp-server.md +++ b/docs/plan-mcp-server.md @@ -1,7 +1,6 @@ # Plan — a remote MCP server for Open Wine Map -Status (2026-10-04): phases 0–3 and the code of 4–5 done on branch -`feat/mcp-server` (from `origin/main` a619b99), not yet committed or deployed. +Status (2026-10-04): live. Branch `feat/mcp-server` (from `origin/main` a619b99). | phase | state | |---|---| @@ -9,8 +8,8 @@ Status (2026-10-04): phases 0–3 and the code of 4–5 done on branch | 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 | `scripts/deploy_mcp.py` ready; waiting: site deploy (ships the data + new app.js), production script `owm-mcp`, hostname, DNS, SSL | -| 5 discovery | About dialog sentence (4 locales) + `llms.txt` "For AI agents" section + docs done; registry entry and Le Chat live test pending | +| 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 From 98e7a0dd6e6336a60e950519fdab2d441d6ed79e Mon Sep 17 00:00:00 2001 From: Boris De Vloed Date: Sun, 4 Oct 2026 16:12:36 +0200 Subject: [PATCH 06/14] feat(about): copy button for the MCP server address; NL "eigen connector" The About dialog shows https://mcp.openwinemap.com/mcp in a code box with a Copy button (clipboard API, falls back to selecting the address); copies are counted as the `MCP Address Copied` Plausible event. Dutch wording "aangepaste connector" (a calque of "custom connector") becomes "eigen connector". Co-Authored-By: Claude Opus 5.5 --- docs/analytics.md | 1 + locale/en/LC_MESSAGES/messages.po | 107 +++++++++++++++++------------- locale/es/LC_MESSAGES/messages.po | 107 +++++++++++++++++------------- locale/fr/LC_MESSAGES/messages.po | 107 +++++++++++++++++------------- locale/messages.pot | 102 +++++++++++++++------------- locale/nl/LC_MESSAGES/messages.po | 107 +++++++++++++++++------------- scripts/_lib/assets/app.js | 29 ++++++++ scripts/_lib/map_template.py | 22 +++++- 8 files changed, 350 insertions(+), 232 deletions(-) diff --git a/docs/analytics.md b/docs/analytics.md index e9d8ff1..1581298 100644 --- a/docs/analytics.md +++ b/docs/analytics.md @@ -62,6 +62,7 @@ option is inert, and a build with no `localhost` entry loads nothing there. | `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` / `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 | diff --git a/locale/en/LC_MESSAGES/messages.po b/locale/en/LC_MESSAGES/messages.po index 6a13b0f..eb4c98b 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-04 15:27+0200\n" +"POT-Creation-Date: 2026-10-04 16:11+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: en\n" @@ -1214,187 +1214,202 @@ msgstr "Browse the full list of appellations: {browse_link}." msgid "Données mises à jour le {date}." msgstr "Data updated on {date}." +#: scripts/_lib/map_template.py:495 +msgid "Copier" +msgstr "Copy" + #: scripts/_lib/map_template.py:496 +msgid "Copié" +msgstr "Copied" + +#: scripts/_lib/map_template.py:497 +msgid "Copier l'adresse du serveur MCP" +msgstr "Copy the MCP server address" + +#: scripts/_lib/map_template.py:499 #, 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)." +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:517 +#: scripts/_lib/map_template.py:520 msgid "BOURGOGNE" msgstr "Burgundy" -#: scripts/_lib/map_template.py:518 +#: scripts/_lib/map_template.py:521 msgid "BEAUJOLAIS" msgstr "Beaujolais" -#: scripts/_lib/map_template.py:519 +#: scripts/_lib/map_template.py:522 msgid "JURA" msgstr "Jura" -#: scripts/_lib/map_template.py:520 +#: scripts/_lib/map_template.py:523 msgid "SAVOIE" msgstr "Savoie" -#: scripts/_lib/map_template.py:521 +#: scripts/_lib/map_template.py:524 msgid "BUGEY" msgstr "Bugey" -#: scripts/_lib/map_template.py:522 +#: scripts/_lib/map_template.py:525 msgid "ALSACE" msgstr "Alsace" -#: scripts/_lib/map_template.py:523 +#: scripts/_lib/map_template.py:526 msgid "LORRAINE" msgstr "Lorraine" -#: scripts/_lib/map_template.py:524 +#: scripts/_lib/map_template.py:527 msgid "VAL DE LOIRE" msgstr "Loire Valley" -#: scripts/_lib/map_template.py:525 +#: scripts/_lib/map_template.py:528 msgid "BORDEAUX" msgstr "Bordeaux" -#: scripts/_lib/map_template.py:526 +#: scripts/_lib/map_template.py:529 msgid "SUD-OUEST" msgstr "South-West" -#: scripts/_lib/map_template.py:527 +#: scripts/_lib/map_template.py:530 msgid "VALLEE DU RHÔNE" msgstr "Rhône Valley" -#: scripts/_lib/map_template.py:528 +#: scripts/_lib/map_template.py:531 msgid "LANGUEDOC" msgstr "Languedoc" -#: scripts/_lib/map_template.py:529 +#: scripts/_lib/map_template.py:532 msgid "ROUSSILLON" msgstr "Roussillon" -#: scripts/_lib/map_template.py:530 +#: scripts/_lib/map_template.py:533 msgid "PROVENCE" msgstr "Provence" -#: scripts/_lib/map_template.py:531 +#: scripts/_lib/map_template.py:534 msgid "CORSE" msgstr "Corsica" -#: scripts/_lib/map_template.py:532 +#: scripts/_lib/map_template.py:535 msgid "CHAMPAGNE" msgstr "Champagne" -#: scripts/_lib/map_template.py:533 +#: scripts/_lib/map_template.py:536 msgid "ILE-DE-FRANCE" msgstr "Île-de-France" -#: scripts/_lib/map_template.py:534 +#: scripts/_lib/map_template.py:537 msgid "NORMANDIE" msgstr "Normandy" -#: scripts/_lib/map_template.py:535 +#: scripts/_lib/map_template.py:538 msgid "BRETAGNE" msgstr "Brittany" -#: scripts/_lib/map_template.py:536 +#: scripts/_lib/map_template.py:539 msgid "MAINE" msgstr "Maine" -#: scripts/_lib/map_template.py:537 +#: scripts/_lib/map_template.py:540 msgid "COGNAC" msgstr "Cognac" -#: scripts/_lib/map_template.py:538 +#: scripts/_lib/map_template.py:541 msgid "ARMAGNAC" msgstr "Armagnac" -#: scripts/_lib/map_template.py:539 +#: scripts/_lib/map_template.py:542 msgid "RHUM" msgstr "Rum" -#: scripts/_lib/map_template.py:579 +#: scripts/_lib/map_template.py:582 msgid "France" msgstr "France" -#: scripts/_lib/map_template.py:580 +#: scripts/_lib/map_template.py:583 msgid "Espagne" msgstr "Spain" -#: scripts/_lib/map_template.py:581 +#: scripts/_lib/map_template.py:584 msgid "Portugal" msgstr "Portugal" -#: scripts/_lib/map_template.py:582 +#: scripts/_lib/map_template.py:585 msgid "Italie" msgstr "Italy" -#: scripts/_lib/map_template.py:583 +#: scripts/_lib/map_template.py:586 msgid "Autriche" msgstr "Austria" -#: scripts/_lib/map_template.py:584 +#: scripts/_lib/map_template.py:587 msgid "Slovénie" msgstr "Slovenia" -#: scripts/_lib/map_template.py:585 +#: scripts/_lib/map_template.py:588 msgid "Croatie" msgstr "Croatia" -#: scripts/_lib/map_template.py:586 +#: scripts/_lib/map_template.py:589 msgid "Hongrie" msgstr "Hungary" -#: scripts/_lib/map_template.py:587 +#: scripts/_lib/map_template.py:590 msgid "Roumanie" msgstr "Romania" -#: scripts/_lib/map_template.py:588 +#: scripts/_lib/map_template.py:591 msgid "Bulgarie" msgstr "Bulgaria" -#: scripts/_lib/map_template.py:589 +#: scripts/_lib/map_template.py:592 msgid "Grèce" msgstr "Greece" -#: scripts/_lib/map_template.py:590 +#: scripts/_lib/map_template.py:593 msgid "Allemagne" msgstr "Germany" -#: scripts/_lib/map_template.py:591 +#: scripts/_lib/map_template.py:594 msgid "Slovaquie" msgstr "Slovakia" -#: scripts/_lib/map_template.py:592 +#: scripts/_lib/map_template.py:595 msgid "Suisse" msgstr "Switzerland" -#: scripts/_lib/map_template.py:593 +#: scripts/_lib/map_template.py:596 msgid "Tchéquie" msgstr "Czechia" -#: scripts/_lib/map_template.py:594 +#: scripts/_lib/map_template.py:597 msgid "Luxembourg" msgstr "Luxembourg" -#: scripts/_lib/map_template.py:595 +#: scripts/_lib/map_template.py:598 msgid "Belgique" msgstr "Belgium" -#: scripts/_lib/map_template.py:596 +#: scripts/_lib/map_template.py:599 msgid "Pays-Bas" msgstr "Netherlands" -#: scripts/_lib/map_template.py:597 +#: scripts/_lib/map_template.py:600 msgid "Malte" msgstr "Malta" -#: scripts/_lib/map_template.py:598 +#: scripts/_lib/map_template.py:601 msgid "Chypre" msgstr "Cyprus" -#: scripts/_lib/map_template.py:599 +#: scripts/_lib/map_template.py:602 msgid "Royaume-Uni" msgstr "United Kingdom" diff --git a/locale/es/LC_MESSAGES/messages.po b/locale/es/LC_MESSAGES/messages.po index 056238c..c8122cd 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-04 15:27+0200\n" +"POT-Creation-Date: 2026-10-04 16:11+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: es\n" @@ -1227,187 +1227,202 @@ msgstr "Explore la lista completa de denominaciones: {browse_link}." msgid "Données mises à jour le {date}." msgstr "Datos actualizados el {date}." +#: scripts/_lib/map_template.py:495 +msgid "Copier" +msgstr "Copiar" + #: scripts/_lib/map_template.py:496 +msgid "Copié" +msgstr "Copiado" + +#: scripts/_lib/map_template.py:497 +msgid "Copier l'adresse du serveur MCP" +msgstr "Copiar la dirección del servidor MCP" + +#: scripts/_lib/map_template.py:499 #, 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)." +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:517 +#: scripts/_lib/map_template.py:520 msgid "BOURGOGNE" msgstr "Borgoña" -#: scripts/_lib/map_template.py:518 +#: scripts/_lib/map_template.py:521 msgid "BEAUJOLAIS" msgstr "Beaujolais" -#: scripts/_lib/map_template.py:519 +#: scripts/_lib/map_template.py:522 msgid "JURA" msgstr "Jura" -#: scripts/_lib/map_template.py:520 +#: scripts/_lib/map_template.py:523 msgid "SAVOIE" msgstr "Saboya" -#: scripts/_lib/map_template.py:521 +#: scripts/_lib/map_template.py:524 msgid "BUGEY" msgstr "Bugey" -#: scripts/_lib/map_template.py:522 +#: scripts/_lib/map_template.py:525 msgid "ALSACE" msgstr "Alsacia" -#: scripts/_lib/map_template.py:523 +#: scripts/_lib/map_template.py:526 msgid "LORRAINE" msgstr "Lorena" -#: scripts/_lib/map_template.py:524 +#: scripts/_lib/map_template.py:527 msgid "VAL DE LOIRE" msgstr "Valle del Loira" -#: scripts/_lib/map_template.py:525 +#: scripts/_lib/map_template.py:528 msgid "BORDEAUX" msgstr "Burdeos" -#: scripts/_lib/map_template.py:526 +#: scripts/_lib/map_template.py:529 msgid "SUD-OUEST" msgstr "Suroeste" -#: scripts/_lib/map_template.py:527 +#: scripts/_lib/map_template.py:530 msgid "VALLEE DU RHÔNE" msgstr "Valle del Ródano" -#: scripts/_lib/map_template.py:528 +#: scripts/_lib/map_template.py:531 msgid "LANGUEDOC" msgstr "Languedoc" -#: scripts/_lib/map_template.py:529 +#: scripts/_lib/map_template.py:532 msgid "ROUSSILLON" msgstr "Rosellón" -#: scripts/_lib/map_template.py:530 +#: scripts/_lib/map_template.py:533 msgid "PROVENCE" msgstr "Provenza" -#: scripts/_lib/map_template.py:531 +#: scripts/_lib/map_template.py:534 msgid "CORSE" msgstr "Córcega" -#: scripts/_lib/map_template.py:532 +#: scripts/_lib/map_template.py:535 msgid "CHAMPAGNE" msgstr "Champaña" -#: scripts/_lib/map_template.py:533 +#: scripts/_lib/map_template.py:536 msgid "ILE-DE-FRANCE" msgstr "Isla de Francia" -#: scripts/_lib/map_template.py:534 +#: scripts/_lib/map_template.py:537 msgid "NORMANDIE" msgstr "Normandía" -#: scripts/_lib/map_template.py:535 +#: scripts/_lib/map_template.py:538 msgid "BRETAGNE" msgstr "Bretaña" -#: scripts/_lib/map_template.py:536 +#: scripts/_lib/map_template.py:539 msgid "MAINE" msgstr "Maine" -#: scripts/_lib/map_template.py:537 +#: scripts/_lib/map_template.py:540 msgid "COGNAC" msgstr "Coñac" -#: scripts/_lib/map_template.py:538 +#: scripts/_lib/map_template.py:541 msgid "ARMAGNAC" msgstr "Armañac" -#: scripts/_lib/map_template.py:539 +#: scripts/_lib/map_template.py:542 msgid "RHUM" msgstr "Ron" -#: scripts/_lib/map_template.py:579 +#: scripts/_lib/map_template.py:582 msgid "France" msgstr "Francia" -#: scripts/_lib/map_template.py:580 +#: scripts/_lib/map_template.py:583 msgid "Espagne" msgstr "España" -#: scripts/_lib/map_template.py:581 +#: scripts/_lib/map_template.py:584 msgid "Portugal" msgstr "Portugal" -#: scripts/_lib/map_template.py:582 +#: scripts/_lib/map_template.py:585 msgid "Italie" msgstr "Italia" -#: scripts/_lib/map_template.py:583 +#: scripts/_lib/map_template.py:586 msgid "Autriche" msgstr "Austria" -#: scripts/_lib/map_template.py:584 +#: scripts/_lib/map_template.py:587 msgid "Slovénie" msgstr "Eslovenia" -#: scripts/_lib/map_template.py:585 +#: scripts/_lib/map_template.py:588 msgid "Croatie" msgstr "Croacia" -#: scripts/_lib/map_template.py:586 +#: scripts/_lib/map_template.py:589 msgid "Hongrie" msgstr "Hungría" -#: scripts/_lib/map_template.py:587 +#: scripts/_lib/map_template.py:590 msgid "Roumanie" msgstr "Rumanía" -#: scripts/_lib/map_template.py:588 +#: scripts/_lib/map_template.py:591 msgid "Bulgarie" msgstr "Bulgaria" -#: scripts/_lib/map_template.py:589 +#: scripts/_lib/map_template.py:592 msgid "Grèce" msgstr "Grecia" -#: scripts/_lib/map_template.py:590 +#: scripts/_lib/map_template.py:593 msgid "Allemagne" msgstr "Alemania" -#: scripts/_lib/map_template.py:591 +#: scripts/_lib/map_template.py:594 msgid "Slovaquie" msgstr "Eslovaquia" -#: scripts/_lib/map_template.py:592 +#: scripts/_lib/map_template.py:595 msgid "Suisse" msgstr "Suiza" -#: scripts/_lib/map_template.py:593 +#: scripts/_lib/map_template.py:596 msgid "Tchéquie" msgstr "Chequia" -#: scripts/_lib/map_template.py:594 +#: scripts/_lib/map_template.py:597 msgid "Luxembourg" msgstr "Luxemburgo" -#: scripts/_lib/map_template.py:595 +#: scripts/_lib/map_template.py:598 msgid "Belgique" msgstr "Bélgica" -#: scripts/_lib/map_template.py:596 +#: scripts/_lib/map_template.py:599 msgid "Pays-Bas" msgstr "Países Bajos" -#: scripts/_lib/map_template.py:597 +#: scripts/_lib/map_template.py:600 msgid "Malte" msgstr "Malta" -#: scripts/_lib/map_template.py:598 +#: scripts/_lib/map_template.py:601 msgid "Chypre" msgstr "Chipre" -#: scripts/_lib/map_template.py:599 +#: scripts/_lib/map_template.py:602 msgid "Royaume-Uni" msgstr "Reino Unido" diff --git a/locale/fr/LC_MESSAGES/messages.po b/locale/fr/LC_MESSAGES/messages.po index 1b612bd..b4c1808 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-04 15:27+0200\n" +"POT-Creation-Date: 2026-10-04 16:11+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: fr\n" @@ -1158,187 +1158,202 @@ msgstr "Parcourir la liste complète des appellations : {browse_link}." msgid "Données mises à jour le {date}." msgstr "Données mises à jour le {date}." +#: scripts/_lib/map_template.py:495 +msgid "Copier" +msgstr "Copier" + #: scripts/_lib/map_template.py:496 +msgid "Copié" +msgstr "Copié" + +#: scripts/_lib/map_template.py:497 +msgid "Copier l'adresse du serveur MCP" +msgstr "Copier l'adresse du serveur MCP" + +#: scripts/_lib/map_template.py:499 #, 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é)." +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:517 +#: scripts/_lib/map_template.py:520 msgid "BOURGOGNE" msgstr "Bourgogne" -#: scripts/_lib/map_template.py:518 +#: scripts/_lib/map_template.py:521 msgid "BEAUJOLAIS" msgstr "Beaujolais" -#: scripts/_lib/map_template.py:519 +#: scripts/_lib/map_template.py:522 msgid "JURA" msgstr "Jura" -#: scripts/_lib/map_template.py:520 +#: scripts/_lib/map_template.py:523 msgid "SAVOIE" msgstr "Savoie" -#: scripts/_lib/map_template.py:521 +#: scripts/_lib/map_template.py:524 msgid "BUGEY" msgstr "Bugey" -#: scripts/_lib/map_template.py:522 +#: scripts/_lib/map_template.py:525 msgid "ALSACE" msgstr "Alsace" -#: scripts/_lib/map_template.py:523 +#: scripts/_lib/map_template.py:526 msgid "LORRAINE" msgstr "Lorraine" -#: scripts/_lib/map_template.py:524 +#: scripts/_lib/map_template.py:527 msgid "VAL DE LOIRE" msgstr "Val de Loire" -#: scripts/_lib/map_template.py:525 +#: scripts/_lib/map_template.py:528 msgid "BORDEAUX" msgstr "Bordeaux" -#: scripts/_lib/map_template.py:526 +#: scripts/_lib/map_template.py:529 msgid "SUD-OUEST" msgstr "Sud-Ouest" -#: scripts/_lib/map_template.py:527 +#: scripts/_lib/map_template.py:530 msgid "VALLEE DU RHÔNE" msgstr "Vallée du Rhône" -#: scripts/_lib/map_template.py:528 +#: scripts/_lib/map_template.py:531 msgid "LANGUEDOC" msgstr "Languedoc" -#: scripts/_lib/map_template.py:529 +#: scripts/_lib/map_template.py:532 msgid "ROUSSILLON" msgstr "Roussillon" -#: scripts/_lib/map_template.py:530 +#: scripts/_lib/map_template.py:533 msgid "PROVENCE" msgstr "Provence" -#: scripts/_lib/map_template.py:531 +#: scripts/_lib/map_template.py:534 msgid "CORSE" msgstr "Corse" -#: scripts/_lib/map_template.py:532 +#: scripts/_lib/map_template.py:535 msgid "CHAMPAGNE" msgstr "Champagne" -#: scripts/_lib/map_template.py:533 +#: scripts/_lib/map_template.py:536 msgid "ILE-DE-FRANCE" msgstr "Île-de-France" -#: scripts/_lib/map_template.py:534 +#: scripts/_lib/map_template.py:537 msgid "NORMANDIE" msgstr "Normandie" -#: scripts/_lib/map_template.py:535 +#: scripts/_lib/map_template.py:538 msgid "BRETAGNE" msgstr "Bretagne" -#: scripts/_lib/map_template.py:536 +#: scripts/_lib/map_template.py:539 msgid "MAINE" msgstr "Maine" -#: scripts/_lib/map_template.py:537 +#: scripts/_lib/map_template.py:540 msgid "COGNAC" msgstr "Cognac" -#: scripts/_lib/map_template.py:538 +#: scripts/_lib/map_template.py:541 msgid "ARMAGNAC" msgstr "Armagnac" -#: scripts/_lib/map_template.py:539 +#: scripts/_lib/map_template.py:542 msgid "RHUM" msgstr "Rhum" -#: scripts/_lib/map_template.py:579 +#: scripts/_lib/map_template.py:582 msgid "France" msgstr "France" -#: scripts/_lib/map_template.py:580 +#: scripts/_lib/map_template.py:583 msgid "Espagne" msgstr "Espagne" -#: scripts/_lib/map_template.py:581 +#: scripts/_lib/map_template.py:584 msgid "Portugal" msgstr "Portugal" -#: scripts/_lib/map_template.py:582 +#: scripts/_lib/map_template.py:585 msgid "Italie" msgstr "Italie" -#: scripts/_lib/map_template.py:583 +#: scripts/_lib/map_template.py:586 msgid "Autriche" msgstr "Autriche" -#: scripts/_lib/map_template.py:584 +#: scripts/_lib/map_template.py:587 msgid "Slovénie" msgstr "Slovénie" -#: scripts/_lib/map_template.py:585 +#: scripts/_lib/map_template.py:588 msgid "Croatie" msgstr "Croatie" -#: scripts/_lib/map_template.py:586 +#: scripts/_lib/map_template.py:589 msgid "Hongrie" msgstr "Hongrie" -#: scripts/_lib/map_template.py:587 +#: scripts/_lib/map_template.py:590 msgid "Roumanie" msgstr "Roumanie" -#: scripts/_lib/map_template.py:588 +#: scripts/_lib/map_template.py:591 msgid "Bulgarie" msgstr "Bulgarie" -#: scripts/_lib/map_template.py:589 +#: scripts/_lib/map_template.py:592 msgid "Grèce" msgstr "Grèce" -#: scripts/_lib/map_template.py:590 +#: scripts/_lib/map_template.py:593 msgid "Allemagne" msgstr "Allemagne" -#: scripts/_lib/map_template.py:591 +#: scripts/_lib/map_template.py:594 msgid "Slovaquie" msgstr "Slovaquie" -#: scripts/_lib/map_template.py:592 +#: scripts/_lib/map_template.py:595 msgid "Suisse" msgstr "Suisse" -#: scripts/_lib/map_template.py:593 +#: scripts/_lib/map_template.py:596 msgid "Tchéquie" msgstr "Tchéquie" -#: scripts/_lib/map_template.py:594 +#: scripts/_lib/map_template.py:597 msgid "Luxembourg" msgstr "Luxembourg" -#: scripts/_lib/map_template.py:595 +#: scripts/_lib/map_template.py:598 msgid "Belgique" msgstr "Belgique" -#: scripts/_lib/map_template.py:596 +#: scripts/_lib/map_template.py:599 msgid "Pays-Bas" msgstr "Pays-Bas" -#: scripts/_lib/map_template.py:597 +#: scripts/_lib/map_template.py:600 msgid "Malte" msgstr "Malte" -#: scripts/_lib/map_template.py:598 +#: scripts/_lib/map_template.py:601 msgid "Chypre" msgstr "Chypre" -#: scripts/_lib/map_template.py:599 +#: scripts/_lib/map_template.py:602 msgid "Royaume-Uni" msgstr "Royaume-Uni" diff --git a/locale/messages.pot b/locale/messages.pot index 3bf3b63..3bb41b1 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-04 15:27+0200\n" +"POT-Creation-Date: 2026-10-04 16:11+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -1101,7 +1101,19 @@ msgstr "" msgid "Données mises à jour le {date}." msgstr "" +#: scripts/_lib/map_template.py:495 +msgid "Copier" +msgstr "" + #: scripts/_lib/map_template.py:496 +msgid "Copié" +msgstr "" + +#: scripts/_lib/map_template.py:497 +msgid "Copier l'adresse du serveur MCP" +msgstr "" + +#: scripts/_lib/map_template.py:499 #, python-brace-format msgid "" "Les assistants IA peuvent interroger les appellations avec les mêmes " @@ -1109,179 +1121,179 @@ msgid "" "authentification ; à ajouter comme connecteur personnalisé)." msgstr "" -#: scripts/_lib/map_template.py:517 +#: scripts/_lib/map_template.py:520 msgid "BOURGOGNE" msgstr "" -#: scripts/_lib/map_template.py:518 +#: scripts/_lib/map_template.py:521 msgid "BEAUJOLAIS" msgstr "" -#: scripts/_lib/map_template.py:519 +#: scripts/_lib/map_template.py:522 msgid "JURA" msgstr "" -#: scripts/_lib/map_template.py:520 +#: scripts/_lib/map_template.py:523 msgid "SAVOIE" msgstr "" -#: scripts/_lib/map_template.py:521 +#: scripts/_lib/map_template.py:524 msgid "BUGEY" msgstr "" -#: scripts/_lib/map_template.py:522 +#: scripts/_lib/map_template.py:525 msgid "ALSACE" msgstr "" -#: scripts/_lib/map_template.py:523 +#: scripts/_lib/map_template.py:526 msgid "LORRAINE" msgstr "" -#: scripts/_lib/map_template.py:524 +#: scripts/_lib/map_template.py:527 msgid "VAL DE LOIRE" msgstr "" -#: scripts/_lib/map_template.py:525 +#: scripts/_lib/map_template.py:528 msgid "BORDEAUX" msgstr "" -#: scripts/_lib/map_template.py:526 +#: scripts/_lib/map_template.py:529 msgid "SUD-OUEST" msgstr "" -#: scripts/_lib/map_template.py:527 +#: scripts/_lib/map_template.py:530 msgid "VALLEE DU RHÔNE" msgstr "" -#: scripts/_lib/map_template.py:528 +#: scripts/_lib/map_template.py:531 msgid "LANGUEDOC" msgstr "" -#: scripts/_lib/map_template.py:529 +#: scripts/_lib/map_template.py:532 msgid "ROUSSILLON" msgstr "" -#: scripts/_lib/map_template.py:530 +#: scripts/_lib/map_template.py:533 msgid "PROVENCE" msgstr "" -#: scripts/_lib/map_template.py:531 +#: scripts/_lib/map_template.py:534 msgid "CORSE" msgstr "" -#: scripts/_lib/map_template.py:532 +#: scripts/_lib/map_template.py:535 msgid "CHAMPAGNE" msgstr "" -#: scripts/_lib/map_template.py:533 +#: scripts/_lib/map_template.py:536 msgid "ILE-DE-FRANCE" msgstr "" -#: scripts/_lib/map_template.py:534 +#: scripts/_lib/map_template.py:537 msgid "NORMANDIE" msgstr "" -#: scripts/_lib/map_template.py:535 +#: scripts/_lib/map_template.py:538 msgid "BRETAGNE" msgstr "" -#: scripts/_lib/map_template.py:536 +#: scripts/_lib/map_template.py:539 msgid "MAINE" msgstr "" -#: scripts/_lib/map_template.py:537 +#: scripts/_lib/map_template.py:540 msgid "COGNAC" msgstr "" -#: scripts/_lib/map_template.py:538 +#: scripts/_lib/map_template.py:541 msgid "ARMAGNAC" msgstr "" -#: scripts/_lib/map_template.py:539 +#: scripts/_lib/map_template.py:542 msgid "RHUM" msgstr "" -#: scripts/_lib/map_template.py:579 +#: scripts/_lib/map_template.py:582 msgid "France" msgstr "" -#: scripts/_lib/map_template.py:580 +#: scripts/_lib/map_template.py:583 msgid "Espagne" msgstr "" -#: scripts/_lib/map_template.py:581 +#: scripts/_lib/map_template.py:584 msgid "Portugal" msgstr "" -#: scripts/_lib/map_template.py:582 +#: scripts/_lib/map_template.py:585 msgid "Italie" msgstr "" -#: scripts/_lib/map_template.py:583 +#: scripts/_lib/map_template.py:586 msgid "Autriche" msgstr "" -#: scripts/_lib/map_template.py:584 +#: scripts/_lib/map_template.py:587 msgid "Slovénie" msgstr "" -#: scripts/_lib/map_template.py:585 +#: scripts/_lib/map_template.py:588 msgid "Croatie" msgstr "" -#: scripts/_lib/map_template.py:586 +#: scripts/_lib/map_template.py:589 msgid "Hongrie" msgstr "" -#: scripts/_lib/map_template.py:587 +#: scripts/_lib/map_template.py:590 msgid "Roumanie" msgstr "" -#: scripts/_lib/map_template.py:588 +#: scripts/_lib/map_template.py:591 msgid "Bulgarie" msgstr "" -#: scripts/_lib/map_template.py:589 +#: scripts/_lib/map_template.py:592 msgid "Grèce" msgstr "" -#: scripts/_lib/map_template.py:590 +#: scripts/_lib/map_template.py:593 msgid "Allemagne" msgstr "" -#: scripts/_lib/map_template.py:591 +#: scripts/_lib/map_template.py:594 msgid "Slovaquie" msgstr "" -#: scripts/_lib/map_template.py:592 +#: scripts/_lib/map_template.py:595 msgid "Suisse" msgstr "" -#: scripts/_lib/map_template.py:593 +#: scripts/_lib/map_template.py:596 msgid "Tchéquie" msgstr "" -#: scripts/_lib/map_template.py:594 +#: scripts/_lib/map_template.py:597 msgid "Luxembourg" msgstr "" -#: scripts/_lib/map_template.py:595 +#: scripts/_lib/map_template.py:598 msgid "Belgique" msgstr "" -#: scripts/_lib/map_template.py:596 +#: scripts/_lib/map_template.py:599 msgid "Pays-Bas" msgstr "" -#: scripts/_lib/map_template.py:597 +#: scripts/_lib/map_template.py:600 msgid "Malte" msgstr "" -#: scripts/_lib/map_template.py:598 +#: scripts/_lib/map_template.py:601 msgid "Chypre" msgstr "" -#: scripts/_lib/map_template.py:599 +#: scripts/_lib/map_template.py:602 msgid "Royaume-Uni" msgstr "" diff --git a/locale/nl/LC_MESSAGES/messages.po b/locale/nl/LC_MESSAGES/messages.po index 1681dbb..1c05f11 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-04 15:27+0200\n" +"POT-Creation-Date: 2026-10-04 16:11+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language: nl\n" @@ -1225,187 +1225,202 @@ msgstr "Blader door de volledige lijst van appellaties: {browse_link}." msgid "Données mises à jour le {date}." msgstr "Gegevens bijgewerkt op {date}." +#: scripts/_lib/map_template.py:495 +msgid "Copier" +msgstr "Kopiëren" + #: scripts/_lib/map_template.py:496 +msgid "Copié" +msgstr "Gekopieerd" + +#: scripts/_lib/map_template.py:497 +msgid "Copier l'adresse du serveur MCP" +msgstr "Het adres van de MCP-server kopiëren" + +#: scripts/_lib/map_template.py:499 #, 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 aangepaste connector)." +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:517 +#: scripts/_lib/map_template.py:520 msgid "BOURGOGNE" msgstr "Bourgogne" -#: scripts/_lib/map_template.py:518 +#: scripts/_lib/map_template.py:521 msgid "BEAUJOLAIS" msgstr "Beaujolais" -#: scripts/_lib/map_template.py:519 +#: scripts/_lib/map_template.py:522 msgid "JURA" msgstr "Jura" -#: scripts/_lib/map_template.py:520 +#: scripts/_lib/map_template.py:523 msgid "SAVOIE" msgstr "Savoie" -#: scripts/_lib/map_template.py:521 +#: scripts/_lib/map_template.py:524 msgid "BUGEY" msgstr "Bugey" -#: scripts/_lib/map_template.py:522 +#: scripts/_lib/map_template.py:525 msgid "ALSACE" msgstr "Elzas" -#: scripts/_lib/map_template.py:523 +#: scripts/_lib/map_template.py:526 msgid "LORRAINE" msgstr "Lotharingen" -#: scripts/_lib/map_template.py:524 +#: scripts/_lib/map_template.py:527 msgid "VAL DE LOIRE" msgstr "Loirestreek" -#: scripts/_lib/map_template.py:525 +#: scripts/_lib/map_template.py:528 msgid "BORDEAUX" msgstr "Bordeaux" -#: scripts/_lib/map_template.py:526 +#: scripts/_lib/map_template.py:529 msgid "SUD-OUEST" msgstr "Zuidwest-Frankrijk" -#: scripts/_lib/map_template.py:527 +#: scripts/_lib/map_template.py:530 msgid "VALLEE DU RHÔNE" msgstr "Rhônestreek" -#: scripts/_lib/map_template.py:528 +#: scripts/_lib/map_template.py:531 msgid "LANGUEDOC" msgstr "Languedoc" -#: scripts/_lib/map_template.py:529 +#: scripts/_lib/map_template.py:532 msgid "ROUSSILLON" msgstr "Roussillon" -#: scripts/_lib/map_template.py:530 +#: scripts/_lib/map_template.py:533 msgid "PROVENCE" msgstr "Provence" -#: scripts/_lib/map_template.py:531 +#: scripts/_lib/map_template.py:534 msgid "CORSE" msgstr "Corsica" -#: scripts/_lib/map_template.py:532 +#: scripts/_lib/map_template.py:535 msgid "CHAMPAGNE" msgstr "Champagne" -#: scripts/_lib/map_template.py:533 +#: scripts/_lib/map_template.py:536 msgid "ILE-DE-FRANCE" msgstr "Île-de-France" -#: scripts/_lib/map_template.py:534 +#: scripts/_lib/map_template.py:537 msgid "NORMANDIE" msgstr "Normandië" -#: scripts/_lib/map_template.py:535 +#: scripts/_lib/map_template.py:538 msgid "BRETAGNE" msgstr "Bretagne" -#: scripts/_lib/map_template.py:536 +#: scripts/_lib/map_template.py:539 msgid "MAINE" msgstr "Maine" -#: scripts/_lib/map_template.py:537 +#: scripts/_lib/map_template.py:540 msgid "COGNAC" msgstr "Cognac" -#: scripts/_lib/map_template.py:538 +#: scripts/_lib/map_template.py:541 msgid "ARMAGNAC" msgstr "Armagnac" -#: scripts/_lib/map_template.py:539 +#: scripts/_lib/map_template.py:542 msgid "RHUM" msgstr "Rum" -#: scripts/_lib/map_template.py:579 +#: scripts/_lib/map_template.py:582 msgid "France" msgstr "Frankrijk" -#: scripts/_lib/map_template.py:580 +#: scripts/_lib/map_template.py:583 msgid "Espagne" msgstr "Spanje" -#: scripts/_lib/map_template.py:581 +#: scripts/_lib/map_template.py:584 msgid "Portugal" msgstr "Portugal" -#: scripts/_lib/map_template.py:582 +#: scripts/_lib/map_template.py:585 msgid "Italie" msgstr "Italië" -#: scripts/_lib/map_template.py:583 +#: scripts/_lib/map_template.py:586 msgid "Autriche" msgstr "Oostenrijk" -#: scripts/_lib/map_template.py:584 +#: scripts/_lib/map_template.py:587 msgid "Slovénie" msgstr "Slovenië" -#: scripts/_lib/map_template.py:585 +#: scripts/_lib/map_template.py:588 msgid "Croatie" msgstr "Kroatië" -#: scripts/_lib/map_template.py:586 +#: scripts/_lib/map_template.py:589 msgid "Hongrie" msgstr "Hongarije" -#: scripts/_lib/map_template.py:587 +#: scripts/_lib/map_template.py:590 msgid "Roumanie" msgstr "Roemenië" -#: scripts/_lib/map_template.py:588 +#: scripts/_lib/map_template.py:591 msgid "Bulgarie" msgstr "Bulgarije" -#: scripts/_lib/map_template.py:589 +#: scripts/_lib/map_template.py:592 msgid "Grèce" msgstr "Griekenland" -#: scripts/_lib/map_template.py:590 +#: scripts/_lib/map_template.py:593 msgid "Allemagne" msgstr "Duitsland" -#: scripts/_lib/map_template.py:591 +#: scripts/_lib/map_template.py:594 msgid "Slovaquie" msgstr "Slowakije" -#: scripts/_lib/map_template.py:592 +#: scripts/_lib/map_template.py:595 msgid "Suisse" msgstr "Zwitserland" -#: scripts/_lib/map_template.py:593 +#: scripts/_lib/map_template.py:596 msgid "Tchéquie" msgstr "Tsjechië" -#: scripts/_lib/map_template.py:594 +#: scripts/_lib/map_template.py:597 msgid "Luxembourg" msgstr "Luxemburg" -#: scripts/_lib/map_template.py:595 +#: scripts/_lib/map_template.py:598 msgid "Belgique" msgstr "België" -#: scripts/_lib/map_template.py:596 +#: scripts/_lib/map_template.py:599 msgid "Pays-Bas" msgstr "Nederland" -#: scripts/_lib/map_template.py:597 +#: scripts/_lib/map_template.py:600 msgid "Malte" msgstr "Malta" -#: scripts/_lib/map_template.py:598 +#: scripts/_lib/map_template.py:601 msgid "Chypre" msgstr "Cyprus" -#: scripts/_lib/map_template.py:599 +#: scripts/_lib/map_template.py:602 msgid "Royaume-Uni" msgstr "Verenigd Koninkrijk" diff --git a/scripts/_lib/assets/app.js b/scripts/_lib/assets/app.js index f3c3355..e4b2e0c 100644 --- a/scripts/_lib/assets/app.js +++ b/scripts/_lib/assets/app.js @@ -3342,6 +3342,35 @@ __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; diff --git a/scripts/_lib/map_template.py b/scripts/_lib/map_template.py index dc62772..75c9cf3 100644 --- a/scripts/_lib/map_template.py +++ b/scripts/_lib/map_template.py @@ -492,6 +492,9 @@ 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}."), + "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 " @@ -767,9 +770,14 @@ 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)) - paragraphs.append(labels["about_mcp_html"].format( - mcp_link=f"{esc(MCP_ENDPOINT_URL)}" - )) + # 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_privacy_html"].format(plausible=_ext_link(_PLAUSIBLE_POLICY_URL, "Plausible")) ) @@ -3058,6 +3066,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 }} From a75975a538907bee9e7f48cbde64b0b329efb07a Mon Sep 17 00:00:00 2001 From: Boris De Vloed Date: Sun, 4 Oct 2026 16:13:59 +0200 Subject: [PATCH 07/14] =?UTF-8?q?docs(mcp):=20plan=20for=20the=20map=20ins?= =?UTF-8?q?ide=20the=20chat=20(MCP=20Apps)=20=E2=80=94=20widget=20vs=20emb?= =?UTF-8?q?ed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/plan-mcp-apps.md | 165 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 docs/plan-mcp-apps.md diff --git a/docs/plan-mcp-apps.md b/docs/plan-mcp-apps.md new file mode 100644 index 0000000..512730a --- /dev/null +++ b/docs/plan-mcp-apps.md @@ -0,0 +1,165 @@ +# Plan — the map inside the chat (MCP Apps) + +Status: proposed, 2026-10-04. Builds on the live MCP server +(docs/plan-mcp-server.md). Nothing implemented. + +## 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 From 7a497c046f673787fe68d9e190b1746fd943fd1f Mon Sep 17 00:00:00 2001 From: Boris De Vloed Date: Sun, 4 Oct 2026 16:38:06 +0200 Subject: [PATCH 08/14] =?UTF-8?q?feat(mcp):=20show=5Fon=5Fmap=20=E2=80=94?= =?UTF-8?q?=20the=20map=20inside=20the=20chat=20(MCP=20Apps),=20phase=200?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `show_on_map` tool with an MCP Apps view (ui://open-wine-map/map): a MapLibre 4.7.1 map of the requested appellations drawn from the site's own pmtiles over the CARTO basemap, framed and highlighted, click for name + "Open on Open Wine Map", fullscreen, host light/dark theme. The text result stays Markdown map links for hosts without MCP Apps. - view/ → scripts/build-view.mjs → dist/view.html (one self-contained document, 1.46 MB; MapLibre's UMD build starts its worker from blob:), imported as text by the edge bundle (1.8 MB) - the resource declares its CSP (tile origin + *.basemaps.cartocdn.com); tile origin and CARTO key are injected at resources/read time, so one bundle serves the spike and production - optional TILE_PROXY: the script re-serves /map-data/*.pmtiles with CORS (no-store), for the spike, leaving the production site untouched - server instructions and the tool description ask the model to call show_on_map whenever it names appellations - test/apps_host_check.py: a local MCP Apps host (the SDK's AppBridge), the view on its own origin under the spec's reference CSP: draws with a Claude-like `worker-src blob:`, and — as expected — not under the literal spec CSP (blob: worker refused by script-src) Co-Authored-By: Claude Opus 5.5 --- mcp/.gitignore | 1 + mcp/package-lock.json | 458 +++++++++++++++++++++++++++++++++++- mcp/package.json | 10 +- mcp/scripts/build-view.mjs | 35 +++ mcp/src/edge.js | 11 +- mcp/src/http.js | 27 ++- mcp/src/server.js | 101 +++++++- mcp/test/apps-host/host.js | 31 +++ mcp/test/apps_host_check.py | 164 +++++++++++++ mcp/test/local.js | 7 +- mcp/test/protocol.test.js | 28 ++- mcp/view/view.html | 44 ++++ mcp/view/view.js | 169 +++++++++++++ 13 files changed, 1060 insertions(+), 26 deletions(-) create mode 100644 mcp/scripts/build-view.mjs create mode 100644 mcp/test/apps-host/host.js create mode 100644 mcp/test/apps_host_check.py create mode 100644 mcp/view/view.html create mode 100644 mcp/view/view.js diff --git a/mcp/.gitignore b/mcp/.gitignore index b947077..39bd836 100644 --- a/mcp/.gitignore +++ b/mcp/.gitignore @@ -1,2 +1,3 @@ node_modules/ dist/ +!view/view.html diff --git a/mcp/package-lock.json b/mcp/package-lock.json index 6ebf96d..79c3644 100644 --- a/mcp/package-lock.json +++ b/mcp/package-lock.json @@ -8,7 +8,10 @@ "dependencies": { "@bunny.net/edgescript-sdk": "0.13.0", "@cfworker/json-schema": "4.1.1", - "@modelcontextprotocol/server": "2.3.0" + "@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", @@ -485,11 +488,94 @@ "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==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/core": "2.3.0", @@ -516,6 +602,40 @@ "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", @@ -529,11 +649,72 @@ "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==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -544,6 +725,12 @@ "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", @@ -590,7 +777,6 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", - "dev": true, "license": "MIT", "dependencies": { "eventsource-parser": "^3.0.1" @@ -603,12 +789,79 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", - "dev": true, "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", @@ -618,48 +871,205 @@ "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==", - "dev": true, "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==", - "dev": true, "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==", - "dev": true, "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==", - "dev": true, "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==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -672,17 +1082,41 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "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==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" diff --git a/mcp/package.json b/mcp/package.json index 6d1f1f5..7927147 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -4,13 +4,17 @@ "type": "module", "description": "Remote MCP server for Open Wine Map (Bunny Edge Script). See docs/plan-mcp-server.md.", "scripts": { - "build": "esbuild src/edge.js --bundle --format=esm --platform=browser --target=es2022 --external:node:* --minify --legal-comments=none --metafile=dist/meta.json --outfile=dist/edge.js", - "test": "node --test test/*.test.js" + "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/server": "2.3.0" + "@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", 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/edge.js b/mcp/src/edge.js index 8b873e2..f3e0a39 100644 --- a/mcp/src/edge.js +++ b/mcp/src/edge.js @@ -4,9 +4,13 @@ // 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'; @@ -36,6 +40,11 @@ function trackToolCall(request) { }; } -const app = createApp({ dataOrigin, onToolCallFor: trackToolCall }); +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 index c381714..5b7cd88 100644 --- a/mcp/src/http.js +++ b/mcp/src/http.js @@ -7,8 +7,8 @@ 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', - 'Access-Control-Expose-Headers': 'MCP-Protocol-Version, Mcp-Session-Id', + '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', }; @@ -18,15 +18,34 @@ function withHeaders(resp, extra) { return new Response(resp.body, { status: resp.status, statusText: resp.statusText, headers }); } -// opts: { dataOrigin, fetchImpl?, onToolCallFor?(request) → (tool, locale) => void } +// 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 }); + 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 }, diff --git a/mcp/src/server.js b/mcp/src/server.js index 1d1ed46..b706c0a 100644 --- a/mcp/src/server.js +++ b/mcp/src/server.js @@ -28,8 +28,22 @@ const INSTRUCTIONS = [ '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 ↗', + nothing: 'No appellation to show.', + waiting: 'Waiting for the appellations…', + appellations: 'appellations', +}; + const LOCALE_PROP = { type: 'string', enum: LOCALES, @@ -70,12 +84,89 @@ export function createTools({ dataOrigin, fetchImpl = fetch, store }) { }; } -function buildServer(tools, onToolCall) { +// 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 mapUrl = appellations.length === 1 ? appellations[0].url : `${siteOrigin}${locale === 'en' ? '/' : `/${locale}/`}`; + return { appellations, bbox, map_url: mapUrl, unknown }; +} + +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.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, @@ -103,10 +194,12 @@ function buildServer(tools, onToolCall) { // onToolCallFor(request) → (tool, locale) => void | undefined: the analytics // hook, built from the HTTP request the SDK hands each per-request server. -export function createHandler({ dataOrigin, fetchImpl = fetch, onToolCallFor, store }) { +// 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)), { + 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..8955bcd --- /dev/null +++ b/mcp/test/apps_host_check.py @@ -0,0 +1,164 @@ +"""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)] + return {"appellations": out, "bbox": union, "map_url": out[0]["url"], "unknown": []} + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--shots", type=Path, default=None) + 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 ↗", "nothing": "none", "waiting": "waiting", "appellations": "appellations"}} + view_html = view_tpl.replace("__OWM_VIEW_CONFIG__", json.dumps(config)) + domains = [tile_origin, CARTO] + 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 != 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/local.js b/mcp/test/local.js index b1e27b3..edd9fe2 100644 --- a/mcp/test/local.js +++ b/mcp/test/local.js @@ -26,8 +26,13 @@ export async function wikiFetch(url) { } } +const VIEW = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../dist/view.html'); + export async function startServer() { - const app = createApp({ dataOrigin: DATA_ORIGIN, fetchImpl: wikiFetch }); + 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); diff --git a/mcp/test/protocol.test.js b/mcp/test/protocol.test.js index 5edb55d..7b36c8d 100644 --- a/mcp/test/protocol.test.js +++ b/mcp/test/protocol.test.js @@ -8,7 +8,7 @@ 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']; +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 () => { @@ -56,3 +56,29 @@ test('GET is 405 and other paths are 404, with CORS', { skip }, async () => { 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\)/); + 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/view/view.html b/mcp/view/view.html new file mode 100644 index 0000000..b61590b --- /dev/null +++ b/mcp/view/view.html @@ -0,0 +1,44 @@ + + + + + +Open Wine Map + + + + +
+
+
Loading the map…
+
+ + + + +
+
+ + + + diff --git a/mcp/view/view.js b/mcp/view/view.js new file mode 100644 index 0000000..a8982d4 --- /dev/null +++ b/mcp/view/view.js @@ -0,0 +1,169 @@ +// 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. +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 = ['case', ['==', ['get', 'kind'], 'IGP'], '#6e7546', '#934050']; +const OVERVIEW_MAX_ZOOM = 11.9; // the site's LOD: footprints below, parcels from z11 +const DETAIL_MIN_ZOOM = 11; + +const $ = id => document.getElementById(id); +const status = msg => { $('status').textContent = msg || ''; $('status').hidden = !msg; }; + +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}`); +} + +let map = null; +let current = null; +let theme = 'light'; + +function applyTheme(t) { + theme = t === 'dark' ? 'dark' : 'light'; + document.documentElement.classList.toggle('dark', theme === 'dark'); + if (map && map.getLayer('basemap-light')) { + map.setLayoutProperty('basemap-light', 'visibility', theme === 'dark' ? 'none' : 'visible'); + map.setLayoutProperty('basemap-dark', 'visibility', theme === 'dark' ? 'visible' : 'none'); + } +} + +function buildMap() { + 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}/map-data/appellations-overview.pmtiles`, promoteId: 'slug' }, + detail: { type: 'vector', url: `pmtiles://${CONFIG.tileOrigin}/map-data/appellations.pmtiles`, 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 = () => ['fill-overview', 'fill-detail'].filter(id => map.getLayer(id)); + map.on('click', e => { + const feats = current ? map.queryRenderedFeatures(e.point, { layers: fills() }) : []; + if (feats.length) showPopup(feats[0], e.lngLat); + }); + map.on('mousemove', e => { + map.getCanvas().style.cursor = map.queryRenderedFeatures(e.point, { layers: fills() }).length ? 'pointer' : ''; + }); + return new Promise(resolve => map.on('load', resolve)); +} + +function addAppellationLayers(slugs) { + for (const id of ['fill-overview', 'fill-detail', 'line-overview', 'line-detail']) { + if (map.getLayer(id)) map.removeLayer(id); + } + const inSet = ['in', ['get', 'slug'], ['literal', slugs]]; + const over = { 'source-layer': 'appellations', maxzoom: OVERVIEW_MAX_ZOOM, filter: inSet }; + const detail = { 'source-layer': 'appellations', minzoom: DETAIL_MIN_ZOOM, filter: inSet }; + const sort = { 'fill-sort-key': ['-', 0, ['get', 'area']] }; + map.addLayer({ id: 'fill-overview', type: 'fill', source: 'overview', ...over, layout: sort, + paint: { 'fill-color': KIND_COLOUR, 'fill-opacity': 0.5 } }); + map.addLayer({ id: 'fill-detail', type: 'fill', source: 'detail', ...detail, layout: sort, + paint: { 'fill-color': KIND_COLOUR, 'fill-opacity': 0.5 } }); + const line = { 'line-color': theme === 'dark' ? '#f2e6d0' : '#2a1014', 'line-width': 1.2 }; + map.addLayer({ id: 'line-overview', type: 'line', source: 'overview', ...over, paint: line }); + map.addLayer({ id: 'line-detail', type: 'line', source: 'detail', ...detail, paint: line }); +} + +function fit(bbox, single) { + if (!bbox) return; + map.fitBounds([[bbox[0], bbox[1]], [bbox[2], bbox[3]]], { padding: 36, maxZoom: single ? 12 : 10, duration: 0 }); +} + +function showPopup(feature, lngLat) { + const a = (current.appellations || []).find(x => x.slug === feature.properties.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); +} + +async function render(data) { + current = data; + if (!data || !Array.isArray(data.appellations) || !data.appellations.length) { + status(CONFIG.labels.nothing); + return; + } + if (!map) await buildMap(); + applyTheme(theme); + addAppellationLayers(data.appellations.map(a => a.slug)); + fit(data.bbox, data.appellations.length === 1); + status(''); + // Test hook (apps_host_check.py): how many requested appellations are drawn. + window.__owmView = { + features: () => map.queryRenderedFeatures({ layers: ['fill-overview', 'fill-detail'].filter(id => map.getLayer(id)) }).length, + }; + const title = data.appellations.length === 1 + ? data.appellations[0].name + : `${data.appellations.length} ${CONFIG.labels.appellations}`; + $('title').textContent = title; + $('title').hidden = false; + $('open-site').hidden = false; + $('open-site').onclick = () => app.openLink({ url: data.map_url || data.appellations[0].url }); +} + +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); + } +}; + +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 (!current) status(CONFIG.labels.waiting); +} + +main().catch(e => status(String((e && e.message) || e))); From 0720fb043fc1eacd333eafc6b8080265aa2aad90 Mon Sep 17 00:00:00 2001 From: Boris De Vloed Date: Sun, 4 Oct 2026 16:41:10 +0200 Subject: [PATCH 09/14] test(mcp): apps_host_check --live renders a deployed view; plan: phase 0 results Co-Authored-By: Claude Opus 5.5 --- docs/plan-mcp-apps.md | 32 ++++++++++++++++++++++++++++++-- mcp/test/apps_host_check.py | 8 ++++++++ mcp/test/read_view.js | 10 ++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 mcp/test/read_view.js diff --git a/docs/plan-mcp-apps.md b/docs/plan-mcp-apps.md index 512730a..08ac200 100644 --- a/docs/plan-mcp-apps.md +++ b/docs/plan-mcp-apps.md @@ -1,7 +1,35 @@ # Plan — the map inside the chat (MCP Apps) -Status: proposed, 2026-10-04. Builds on the live MCP server -(docs/plan-mcp-server.md). Nothing implemented. +Status (2026-10-04): phase 0 done locally and on the spike; waiting for a +live check in Claude. Builds on the live MCP server (docs/plan-mcp-server.md). + +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`. ## Goal diff --git a/mcp/test/apps_host_check.py b/mcp/test/apps_host_check.py index 8955bcd..dba3e0a 100644 --- a/mcp/test/apps_host_check.py +++ b/mcp/test/apps_host_check.py @@ -83,6 +83,8 @@ def tool_result(slugs: list[str], site: str) -> dict: 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"], @@ -95,6 +97,12 @@ def main() -> int: "labels": {"open": "Open ↗", "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): 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(); From c27d6d21e2613a64379f6fba9bcc9b274f55cb30 Mon Sep 17 00:00:00 2001 From: Boris De Vloed Date: Sun, 4 Oct 2026 16:42:17 +0200 Subject: [PATCH 10/14] feat(about): a playful WebMCP note next to the MCP server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "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." — in the four locales, WebMCP linked to Chrome's documentation. Co-Authored-By: Claude Opus 5.5 --- locale/en/LC_MESSAGES/messages.po | 106 ++++++++++++++++-------------- locale/es/LC_MESSAGES/messages.po | 106 ++++++++++++++++-------------- locale/fr/LC_MESSAGES/messages.po | 106 ++++++++++++++++-------------- locale/messages.pot | 106 ++++++++++++++++-------------- locale/nl/LC_MESSAGES/messages.po | 106 ++++++++++++++++-------------- scripts/_lib/map_template.py | 8 +++ 6 files changed, 293 insertions(+), 245 deletions(-) diff --git a/locale/en/LC_MESSAGES/messages.po b/locale/en/LC_MESSAGES/messages.po index eb4c98b..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-04 16:11+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" @@ -1214,19 +1214,27 @@ msgstr "Browse the full list of appellations: {browse_link}." msgid "Données mises à jour le {date}." msgstr "Data updated on {date}." -#: scripts/_lib/map_template.py:495 +#: 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:496 +#: scripts/_lib/map_template.py:501 msgid "Copié" msgstr "Copied" -#: scripts/_lib/map_template.py:497 +#: scripts/_lib/map_template.py:502 msgid "Copier l'adresse du serveur MCP" msgstr "Copy the MCP server address" -#: scripts/_lib/map_template.py:499 +#: scripts/_lib/map_template.py:504 #, python-brace-format msgid "" "Les assistants IA peuvent interroger les appellations avec les mêmes " @@ -1237,179 +1245,179 @@ msgstr "" "the map, through the MCP server {mcp_link} (no authentication; add it as " "a custom connector)." -#: scripts/_lib/map_template.py:520 +#: scripts/_lib/map_template.py:525 msgid "BOURGOGNE" msgstr "Burgundy" -#: scripts/_lib/map_template.py:521 +#: scripts/_lib/map_template.py:526 msgid "BEAUJOLAIS" msgstr "Beaujolais" -#: scripts/_lib/map_template.py:522 +#: scripts/_lib/map_template.py:527 msgid "JURA" msgstr "Jura" -#: scripts/_lib/map_template.py:523 +#: scripts/_lib/map_template.py:528 msgid "SAVOIE" msgstr "Savoie" -#: scripts/_lib/map_template.py:524 +#: scripts/_lib/map_template.py:529 msgid "BUGEY" msgstr "Bugey" -#: scripts/_lib/map_template.py:525 +#: scripts/_lib/map_template.py:530 msgid "ALSACE" msgstr "Alsace" -#: scripts/_lib/map_template.py:526 +#: scripts/_lib/map_template.py:531 msgid "LORRAINE" msgstr "Lorraine" -#: scripts/_lib/map_template.py:527 +#: scripts/_lib/map_template.py:532 msgid "VAL DE LOIRE" msgstr "Loire Valley" -#: scripts/_lib/map_template.py:528 +#: scripts/_lib/map_template.py:533 msgid "BORDEAUX" msgstr "Bordeaux" -#: scripts/_lib/map_template.py:529 +#: scripts/_lib/map_template.py:534 msgid "SUD-OUEST" msgstr "South-West" -#: scripts/_lib/map_template.py:530 +#: scripts/_lib/map_template.py:535 msgid "VALLEE DU RHÔNE" msgstr "Rhône Valley" -#: scripts/_lib/map_template.py:531 +#: scripts/_lib/map_template.py:536 msgid "LANGUEDOC" msgstr "Languedoc" -#: scripts/_lib/map_template.py:532 +#: scripts/_lib/map_template.py:537 msgid "ROUSSILLON" msgstr "Roussillon" -#: scripts/_lib/map_template.py:533 +#: scripts/_lib/map_template.py:538 msgid "PROVENCE" msgstr "Provence" -#: scripts/_lib/map_template.py:534 +#: scripts/_lib/map_template.py:539 msgid "CORSE" msgstr "Corsica" -#: scripts/_lib/map_template.py:535 +#: scripts/_lib/map_template.py:540 msgid "CHAMPAGNE" msgstr "Champagne" -#: scripts/_lib/map_template.py:536 +#: scripts/_lib/map_template.py:541 msgid "ILE-DE-FRANCE" msgstr "Île-de-France" -#: scripts/_lib/map_template.py:537 +#: scripts/_lib/map_template.py:542 msgid "NORMANDIE" msgstr "Normandy" -#: scripts/_lib/map_template.py:538 +#: scripts/_lib/map_template.py:543 msgid "BRETAGNE" msgstr "Brittany" -#: scripts/_lib/map_template.py:539 +#: scripts/_lib/map_template.py:544 msgid "MAINE" msgstr "Maine" -#: scripts/_lib/map_template.py:540 +#: scripts/_lib/map_template.py:545 msgid "COGNAC" msgstr "Cognac" -#: scripts/_lib/map_template.py:541 +#: scripts/_lib/map_template.py:546 msgid "ARMAGNAC" msgstr "Armagnac" -#: scripts/_lib/map_template.py:542 +#: scripts/_lib/map_template.py:547 msgid "RHUM" msgstr "Rum" -#: scripts/_lib/map_template.py:582 +#: scripts/_lib/map_template.py:587 msgid "France" msgstr "France" -#: scripts/_lib/map_template.py:583 +#: scripts/_lib/map_template.py:588 msgid "Espagne" msgstr "Spain" -#: scripts/_lib/map_template.py:584 +#: scripts/_lib/map_template.py:589 msgid "Portugal" msgstr "Portugal" -#: scripts/_lib/map_template.py:585 +#: scripts/_lib/map_template.py:590 msgid "Italie" msgstr "Italy" -#: scripts/_lib/map_template.py:586 +#: scripts/_lib/map_template.py:591 msgid "Autriche" msgstr "Austria" -#: scripts/_lib/map_template.py:587 +#: scripts/_lib/map_template.py:592 msgid "Slovénie" msgstr "Slovenia" -#: scripts/_lib/map_template.py:588 +#: scripts/_lib/map_template.py:593 msgid "Croatie" msgstr "Croatia" -#: scripts/_lib/map_template.py:589 +#: scripts/_lib/map_template.py:594 msgid "Hongrie" msgstr "Hungary" -#: scripts/_lib/map_template.py:590 +#: scripts/_lib/map_template.py:595 msgid "Roumanie" msgstr "Romania" -#: scripts/_lib/map_template.py:591 +#: scripts/_lib/map_template.py:596 msgid "Bulgarie" msgstr "Bulgaria" -#: scripts/_lib/map_template.py:592 +#: scripts/_lib/map_template.py:597 msgid "Grèce" msgstr "Greece" -#: scripts/_lib/map_template.py:593 +#: scripts/_lib/map_template.py:598 msgid "Allemagne" msgstr "Germany" -#: scripts/_lib/map_template.py:594 +#: scripts/_lib/map_template.py:599 msgid "Slovaquie" msgstr "Slovakia" -#: scripts/_lib/map_template.py:595 +#: scripts/_lib/map_template.py:600 msgid "Suisse" msgstr "Switzerland" -#: scripts/_lib/map_template.py:596 +#: scripts/_lib/map_template.py:601 msgid "Tchéquie" msgstr "Czechia" -#: scripts/_lib/map_template.py:597 +#: scripts/_lib/map_template.py:602 msgid "Luxembourg" msgstr "Luxembourg" -#: scripts/_lib/map_template.py:598 +#: scripts/_lib/map_template.py:603 msgid "Belgique" msgstr "Belgium" -#: scripts/_lib/map_template.py:599 +#: scripts/_lib/map_template.py:604 msgid "Pays-Bas" msgstr "Netherlands" -#: scripts/_lib/map_template.py:600 +#: scripts/_lib/map_template.py:605 msgid "Malte" msgstr "Malta" -#: scripts/_lib/map_template.py:601 +#: scripts/_lib/map_template.py:606 msgid "Chypre" msgstr "Cyprus" -#: scripts/_lib/map_template.py:602 +#: 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 c8122cd..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-04 16:11+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" @@ -1227,19 +1227,27 @@ msgstr "Explore la lista completa de denominaciones: {browse_link}." msgid "Données mises à jour le {date}." msgstr "Datos actualizados el {date}." -#: scripts/_lib/map_template.py:495 +#: 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:496 +#: scripts/_lib/map_template.py:501 msgid "Copié" msgstr "Copiado" -#: scripts/_lib/map_template.py:497 +#: 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:499 +#: scripts/_lib/map_template.py:504 #, python-brace-format msgid "" "Les assistants IA peuvent interroger les appellations avec les mêmes " @@ -1250,179 +1258,179 @@ msgstr "" "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:520 +#: scripts/_lib/map_template.py:525 msgid "BOURGOGNE" msgstr "Borgoña" -#: scripts/_lib/map_template.py:521 +#: scripts/_lib/map_template.py:526 msgid "BEAUJOLAIS" msgstr "Beaujolais" -#: scripts/_lib/map_template.py:522 +#: scripts/_lib/map_template.py:527 msgid "JURA" msgstr "Jura" -#: scripts/_lib/map_template.py:523 +#: scripts/_lib/map_template.py:528 msgid "SAVOIE" msgstr "Saboya" -#: scripts/_lib/map_template.py:524 +#: scripts/_lib/map_template.py:529 msgid "BUGEY" msgstr "Bugey" -#: scripts/_lib/map_template.py:525 +#: scripts/_lib/map_template.py:530 msgid "ALSACE" msgstr "Alsacia" -#: scripts/_lib/map_template.py:526 +#: scripts/_lib/map_template.py:531 msgid "LORRAINE" msgstr "Lorena" -#: scripts/_lib/map_template.py:527 +#: scripts/_lib/map_template.py:532 msgid "VAL DE LOIRE" msgstr "Valle del Loira" -#: scripts/_lib/map_template.py:528 +#: scripts/_lib/map_template.py:533 msgid "BORDEAUX" msgstr "Burdeos" -#: scripts/_lib/map_template.py:529 +#: scripts/_lib/map_template.py:534 msgid "SUD-OUEST" msgstr "Suroeste" -#: scripts/_lib/map_template.py:530 +#: scripts/_lib/map_template.py:535 msgid "VALLEE DU RHÔNE" msgstr "Valle del Ródano" -#: scripts/_lib/map_template.py:531 +#: scripts/_lib/map_template.py:536 msgid "LANGUEDOC" msgstr "Languedoc" -#: scripts/_lib/map_template.py:532 +#: scripts/_lib/map_template.py:537 msgid "ROUSSILLON" msgstr "Rosellón" -#: scripts/_lib/map_template.py:533 +#: scripts/_lib/map_template.py:538 msgid "PROVENCE" msgstr "Provenza" -#: scripts/_lib/map_template.py:534 +#: scripts/_lib/map_template.py:539 msgid "CORSE" msgstr "Córcega" -#: scripts/_lib/map_template.py:535 +#: scripts/_lib/map_template.py:540 msgid "CHAMPAGNE" msgstr "Champaña" -#: scripts/_lib/map_template.py:536 +#: scripts/_lib/map_template.py:541 msgid "ILE-DE-FRANCE" msgstr "Isla de Francia" -#: scripts/_lib/map_template.py:537 +#: scripts/_lib/map_template.py:542 msgid "NORMANDIE" msgstr "Normandía" -#: scripts/_lib/map_template.py:538 +#: scripts/_lib/map_template.py:543 msgid "BRETAGNE" msgstr "Bretaña" -#: scripts/_lib/map_template.py:539 +#: scripts/_lib/map_template.py:544 msgid "MAINE" msgstr "Maine" -#: scripts/_lib/map_template.py:540 +#: scripts/_lib/map_template.py:545 msgid "COGNAC" msgstr "Coñac" -#: scripts/_lib/map_template.py:541 +#: scripts/_lib/map_template.py:546 msgid "ARMAGNAC" msgstr "Armañac" -#: scripts/_lib/map_template.py:542 +#: scripts/_lib/map_template.py:547 msgid "RHUM" msgstr "Ron" -#: scripts/_lib/map_template.py:582 +#: scripts/_lib/map_template.py:587 msgid "France" msgstr "Francia" -#: scripts/_lib/map_template.py:583 +#: scripts/_lib/map_template.py:588 msgid "Espagne" msgstr "España" -#: scripts/_lib/map_template.py:584 +#: scripts/_lib/map_template.py:589 msgid "Portugal" msgstr "Portugal" -#: scripts/_lib/map_template.py:585 +#: scripts/_lib/map_template.py:590 msgid "Italie" msgstr "Italia" -#: scripts/_lib/map_template.py:586 +#: scripts/_lib/map_template.py:591 msgid "Autriche" msgstr "Austria" -#: scripts/_lib/map_template.py:587 +#: scripts/_lib/map_template.py:592 msgid "Slovénie" msgstr "Eslovenia" -#: scripts/_lib/map_template.py:588 +#: scripts/_lib/map_template.py:593 msgid "Croatie" msgstr "Croacia" -#: scripts/_lib/map_template.py:589 +#: scripts/_lib/map_template.py:594 msgid "Hongrie" msgstr "Hungría" -#: scripts/_lib/map_template.py:590 +#: scripts/_lib/map_template.py:595 msgid "Roumanie" msgstr "Rumanía" -#: scripts/_lib/map_template.py:591 +#: scripts/_lib/map_template.py:596 msgid "Bulgarie" msgstr "Bulgaria" -#: scripts/_lib/map_template.py:592 +#: scripts/_lib/map_template.py:597 msgid "Grèce" msgstr "Grecia" -#: scripts/_lib/map_template.py:593 +#: scripts/_lib/map_template.py:598 msgid "Allemagne" msgstr "Alemania" -#: scripts/_lib/map_template.py:594 +#: scripts/_lib/map_template.py:599 msgid "Slovaquie" msgstr "Eslovaquia" -#: scripts/_lib/map_template.py:595 +#: scripts/_lib/map_template.py:600 msgid "Suisse" msgstr "Suiza" -#: scripts/_lib/map_template.py:596 +#: scripts/_lib/map_template.py:601 msgid "Tchéquie" msgstr "Chequia" -#: scripts/_lib/map_template.py:597 +#: scripts/_lib/map_template.py:602 msgid "Luxembourg" msgstr "Luxemburgo" -#: scripts/_lib/map_template.py:598 +#: scripts/_lib/map_template.py:603 msgid "Belgique" msgstr "Bélgica" -#: scripts/_lib/map_template.py:599 +#: scripts/_lib/map_template.py:604 msgid "Pays-Bas" msgstr "Países Bajos" -#: scripts/_lib/map_template.py:600 +#: scripts/_lib/map_template.py:605 msgid "Malte" msgstr "Malta" -#: scripts/_lib/map_template.py:601 +#: scripts/_lib/map_template.py:606 msgid "Chypre" msgstr "Chipre" -#: scripts/_lib/map_template.py:602 +#: 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 b4c1808..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-04 16:11+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" @@ -1158,19 +1158,27 @@ msgstr "Parcourir la liste complète des appellations : {browse_link}." msgid "Données mises à jour le {date}." msgstr "Données mises à jour le {date}." -#: scripts/_lib/map_template.py:495 +#: 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:496 +#: scripts/_lib/map_template.py:501 msgid "Copié" msgstr "Copié" -#: scripts/_lib/map_template.py:497 +#: 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:499 +#: scripts/_lib/map_template.py:504 #, python-brace-format msgid "" "Les assistants IA peuvent interroger les appellations avec les mêmes " @@ -1181,179 +1189,179 @@ msgstr "" "outils de recherche que la carte, via le serveur MCP {mcp_link} (sans " "authentification ; à ajouter comme connecteur personnalisé)." -#: scripts/_lib/map_template.py:520 +#: scripts/_lib/map_template.py:525 msgid "BOURGOGNE" msgstr "Bourgogne" -#: scripts/_lib/map_template.py:521 +#: scripts/_lib/map_template.py:526 msgid "BEAUJOLAIS" msgstr "Beaujolais" -#: scripts/_lib/map_template.py:522 +#: scripts/_lib/map_template.py:527 msgid "JURA" msgstr "Jura" -#: scripts/_lib/map_template.py:523 +#: scripts/_lib/map_template.py:528 msgid "SAVOIE" msgstr "Savoie" -#: scripts/_lib/map_template.py:524 +#: scripts/_lib/map_template.py:529 msgid "BUGEY" msgstr "Bugey" -#: scripts/_lib/map_template.py:525 +#: scripts/_lib/map_template.py:530 msgid "ALSACE" msgstr "Alsace" -#: scripts/_lib/map_template.py:526 +#: scripts/_lib/map_template.py:531 msgid "LORRAINE" msgstr "Lorraine" -#: scripts/_lib/map_template.py:527 +#: scripts/_lib/map_template.py:532 msgid "VAL DE LOIRE" msgstr "Val de Loire" -#: scripts/_lib/map_template.py:528 +#: scripts/_lib/map_template.py:533 msgid "BORDEAUX" msgstr "Bordeaux" -#: scripts/_lib/map_template.py:529 +#: scripts/_lib/map_template.py:534 msgid "SUD-OUEST" msgstr "Sud-Ouest" -#: scripts/_lib/map_template.py:530 +#: scripts/_lib/map_template.py:535 msgid "VALLEE DU RHÔNE" msgstr "Vallée du Rhône" -#: scripts/_lib/map_template.py:531 +#: scripts/_lib/map_template.py:536 msgid "LANGUEDOC" msgstr "Languedoc" -#: scripts/_lib/map_template.py:532 +#: scripts/_lib/map_template.py:537 msgid "ROUSSILLON" msgstr "Roussillon" -#: scripts/_lib/map_template.py:533 +#: scripts/_lib/map_template.py:538 msgid "PROVENCE" msgstr "Provence" -#: scripts/_lib/map_template.py:534 +#: scripts/_lib/map_template.py:539 msgid "CORSE" msgstr "Corse" -#: scripts/_lib/map_template.py:535 +#: scripts/_lib/map_template.py:540 msgid "CHAMPAGNE" msgstr "Champagne" -#: scripts/_lib/map_template.py:536 +#: scripts/_lib/map_template.py:541 msgid "ILE-DE-FRANCE" msgstr "Île-de-France" -#: scripts/_lib/map_template.py:537 +#: scripts/_lib/map_template.py:542 msgid "NORMANDIE" msgstr "Normandie" -#: scripts/_lib/map_template.py:538 +#: scripts/_lib/map_template.py:543 msgid "BRETAGNE" msgstr "Bretagne" -#: scripts/_lib/map_template.py:539 +#: scripts/_lib/map_template.py:544 msgid "MAINE" msgstr "Maine" -#: scripts/_lib/map_template.py:540 +#: scripts/_lib/map_template.py:545 msgid "COGNAC" msgstr "Cognac" -#: scripts/_lib/map_template.py:541 +#: scripts/_lib/map_template.py:546 msgid "ARMAGNAC" msgstr "Armagnac" -#: scripts/_lib/map_template.py:542 +#: scripts/_lib/map_template.py:547 msgid "RHUM" msgstr "Rhum" -#: scripts/_lib/map_template.py:582 +#: scripts/_lib/map_template.py:587 msgid "France" msgstr "France" -#: scripts/_lib/map_template.py:583 +#: scripts/_lib/map_template.py:588 msgid "Espagne" msgstr "Espagne" -#: scripts/_lib/map_template.py:584 +#: scripts/_lib/map_template.py:589 msgid "Portugal" msgstr "Portugal" -#: scripts/_lib/map_template.py:585 +#: scripts/_lib/map_template.py:590 msgid "Italie" msgstr "Italie" -#: scripts/_lib/map_template.py:586 +#: scripts/_lib/map_template.py:591 msgid "Autriche" msgstr "Autriche" -#: scripts/_lib/map_template.py:587 +#: scripts/_lib/map_template.py:592 msgid "Slovénie" msgstr "Slovénie" -#: scripts/_lib/map_template.py:588 +#: scripts/_lib/map_template.py:593 msgid "Croatie" msgstr "Croatie" -#: scripts/_lib/map_template.py:589 +#: scripts/_lib/map_template.py:594 msgid "Hongrie" msgstr "Hongrie" -#: scripts/_lib/map_template.py:590 +#: scripts/_lib/map_template.py:595 msgid "Roumanie" msgstr "Roumanie" -#: scripts/_lib/map_template.py:591 +#: scripts/_lib/map_template.py:596 msgid "Bulgarie" msgstr "Bulgarie" -#: scripts/_lib/map_template.py:592 +#: scripts/_lib/map_template.py:597 msgid "Grèce" msgstr "Grèce" -#: scripts/_lib/map_template.py:593 +#: scripts/_lib/map_template.py:598 msgid "Allemagne" msgstr "Allemagne" -#: scripts/_lib/map_template.py:594 +#: scripts/_lib/map_template.py:599 msgid "Slovaquie" msgstr "Slovaquie" -#: scripts/_lib/map_template.py:595 +#: scripts/_lib/map_template.py:600 msgid "Suisse" msgstr "Suisse" -#: scripts/_lib/map_template.py:596 +#: scripts/_lib/map_template.py:601 msgid "Tchéquie" msgstr "Tchéquie" -#: scripts/_lib/map_template.py:597 +#: scripts/_lib/map_template.py:602 msgid "Luxembourg" msgstr "Luxembourg" -#: scripts/_lib/map_template.py:598 +#: scripts/_lib/map_template.py:603 msgid "Belgique" msgstr "Belgique" -#: scripts/_lib/map_template.py:599 +#: scripts/_lib/map_template.py:604 msgid "Pays-Bas" msgstr "Pays-Bas" -#: scripts/_lib/map_template.py:600 +#: scripts/_lib/map_template.py:605 msgid "Malte" msgstr "Malte" -#: scripts/_lib/map_template.py:601 +#: scripts/_lib/map_template.py:606 msgid "Chypre" msgstr "Chypre" -#: scripts/_lib/map_template.py:602 +#: scripts/_lib/map_template.py:607 msgid "Royaume-Uni" msgstr "Royaume-Uni" diff --git a/locale/messages.pot b/locale/messages.pot index 3bb41b1..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-04 16:11+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" @@ -1101,19 +1101,27 @@ msgstr "" msgid "Données mises à jour le {date}." msgstr "" -#: scripts/_lib/map_template.py:495 +#: 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 "Copier" msgstr "" -#: scripts/_lib/map_template.py:496 +#: scripts/_lib/map_template.py:501 msgid "Copié" msgstr "" -#: scripts/_lib/map_template.py:497 +#: scripts/_lib/map_template.py:502 msgid "Copier l'adresse du serveur MCP" msgstr "" -#: scripts/_lib/map_template.py:499 +#: scripts/_lib/map_template.py:504 #, python-brace-format msgid "" "Les assistants IA peuvent interroger les appellations avec les mêmes " @@ -1121,179 +1129,179 @@ msgid "" "authentification ; à ajouter comme connecteur personnalisé)." msgstr "" -#: scripts/_lib/map_template.py:520 +#: scripts/_lib/map_template.py:525 msgid "BOURGOGNE" msgstr "" -#: scripts/_lib/map_template.py:521 +#: scripts/_lib/map_template.py:526 msgid "BEAUJOLAIS" msgstr "" -#: scripts/_lib/map_template.py:522 +#: scripts/_lib/map_template.py:527 msgid "JURA" msgstr "" -#: scripts/_lib/map_template.py:523 +#: scripts/_lib/map_template.py:528 msgid "SAVOIE" msgstr "" -#: scripts/_lib/map_template.py:524 +#: scripts/_lib/map_template.py:529 msgid "BUGEY" msgstr "" -#: scripts/_lib/map_template.py:525 +#: scripts/_lib/map_template.py:530 msgid "ALSACE" msgstr "" -#: scripts/_lib/map_template.py:526 +#: scripts/_lib/map_template.py:531 msgid "LORRAINE" msgstr "" -#: scripts/_lib/map_template.py:527 +#: scripts/_lib/map_template.py:532 msgid "VAL DE LOIRE" msgstr "" -#: scripts/_lib/map_template.py:528 +#: scripts/_lib/map_template.py:533 msgid "BORDEAUX" msgstr "" -#: scripts/_lib/map_template.py:529 +#: scripts/_lib/map_template.py:534 msgid "SUD-OUEST" msgstr "" -#: scripts/_lib/map_template.py:530 +#: scripts/_lib/map_template.py:535 msgid "VALLEE DU RHÔNE" msgstr "" -#: scripts/_lib/map_template.py:531 +#: scripts/_lib/map_template.py:536 msgid "LANGUEDOC" msgstr "" -#: scripts/_lib/map_template.py:532 +#: scripts/_lib/map_template.py:537 msgid "ROUSSILLON" msgstr "" -#: scripts/_lib/map_template.py:533 +#: scripts/_lib/map_template.py:538 msgid "PROVENCE" msgstr "" -#: scripts/_lib/map_template.py:534 +#: scripts/_lib/map_template.py:539 msgid "CORSE" msgstr "" -#: scripts/_lib/map_template.py:535 +#: scripts/_lib/map_template.py:540 msgid "CHAMPAGNE" msgstr "" -#: scripts/_lib/map_template.py:536 +#: scripts/_lib/map_template.py:541 msgid "ILE-DE-FRANCE" msgstr "" -#: scripts/_lib/map_template.py:537 +#: scripts/_lib/map_template.py:542 msgid "NORMANDIE" msgstr "" -#: scripts/_lib/map_template.py:538 +#: scripts/_lib/map_template.py:543 msgid "BRETAGNE" msgstr "" -#: scripts/_lib/map_template.py:539 +#: scripts/_lib/map_template.py:544 msgid "MAINE" msgstr "" -#: scripts/_lib/map_template.py:540 +#: scripts/_lib/map_template.py:545 msgid "COGNAC" msgstr "" -#: scripts/_lib/map_template.py:541 +#: scripts/_lib/map_template.py:546 msgid "ARMAGNAC" msgstr "" -#: scripts/_lib/map_template.py:542 +#: scripts/_lib/map_template.py:547 msgid "RHUM" msgstr "" -#: scripts/_lib/map_template.py:582 +#: scripts/_lib/map_template.py:587 msgid "France" msgstr "" -#: scripts/_lib/map_template.py:583 +#: scripts/_lib/map_template.py:588 msgid "Espagne" msgstr "" -#: scripts/_lib/map_template.py:584 +#: scripts/_lib/map_template.py:589 msgid "Portugal" msgstr "" -#: scripts/_lib/map_template.py:585 +#: scripts/_lib/map_template.py:590 msgid "Italie" msgstr "" -#: scripts/_lib/map_template.py:586 +#: scripts/_lib/map_template.py:591 msgid "Autriche" msgstr "" -#: scripts/_lib/map_template.py:587 +#: scripts/_lib/map_template.py:592 msgid "Slovénie" msgstr "" -#: scripts/_lib/map_template.py:588 +#: scripts/_lib/map_template.py:593 msgid "Croatie" msgstr "" -#: scripts/_lib/map_template.py:589 +#: scripts/_lib/map_template.py:594 msgid "Hongrie" msgstr "" -#: scripts/_lib/map_template.py:590 +#: scripts/_lib/map_template.py:595 msgid "Roumanie" msgstr "" -#: scripts/_lib/map_template.py:591 +#: scripts/_lib/map_template.py:596 msgid "Bulgarie" msgstr "" -#: scripts/_lib/map_template.py:592 +#: scripts/_lib/map_template.py:597 msgid "Grèce" msgstr "" -#: scripts/_lib/map_template.py:593 +#: scripts/_lib/map_template.py:598 msgid "Allemagne" msgstr "" -#: scripts/_lib/map_template.py:594 +#: scripts/_lib/map_template.py:599 msgid "Slovaquie" msgstr "" -#: scripts/_lib/map_template.py:595 +#: scripts/_lib/map_template.py:600 msgid "Suisse" msgstr "" -#: scripts/_lib/map_template.py:596 +#: scripts/_lib/map_template.py:601 msgid "Tchéquie" msgstr "" -#: scripts/_lib/map_template.py:597 +#: scripts/_lib/map_template.py:602 msgid "Luxembourg" msgstr "" -#: scripts/_lib/map_template.py:598 +#: scripts/_lib/map_template.py:603 msgid "Belgique" msgstr "" -#: scripts/_lib/map_template.py:599 +#: scripts/_lib/map_template.py:604 msgid "Pays-Bas" msgstr "" -#: scripts/_lib/map_template.py:600 +#: scripts/_lib/map_template.py:605 msgid "Malte" msgstr "" -#: scripts/_lib/map_template.py:601 +#: scripts/_lib/map_template.py:606 msgid "Chypre" msgstr "" -#: scripts/_lib/map_template.py:602 +#: 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 1c05f11..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-04 16:11+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" @@ -1225,19 +1225,27 @@ msgstr "Blader door de volledige lijst van appellaties: {browse_link}." msgid "Données mises à jour le {date}." msgstr "Gegevens bijgewerkt op {date}." -#: scripts/_lib/map_template.py:495 +#: 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:496 +#: scripts/_lib/map_template.py:501 msgid "Copié" msgstr "Gekopieerd" -#: scripts/_lib/map_template.py:497 +#: 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:499 +#: scripts/_lib/map_template.py:504 #, python-brace-format msgid "" "Les assistants IA peuvent interroger les appellations avec les mêmes " @@ -1248,179 +1256,179 @@ msgstr "" " als de kaart, via de MCP-server {mcp_link} (zonder authenticatie; voeg " "hem toe als eigen connector)." -#: scripts/_lib/map_template.py:520 +#: scripts/_lib/map_template.py:525 msgid "BOURGOGNE" msgstr "Bourgogne" -#: scripts/_lib/map_template.py:521 +#: scripts/_lib/map_template.py:526 msgid "BEAUJOLAIS" msgstr "Beaujolais" -#: scripts/_lib/map_template.py:522 +#: scripts/_lib/map_template.py:527 msgid "JURA" msgstr "Jura" -#: scripts/_lib/map_template.py:523 +#: scripts/_lib/map_template.py:528 msgid "SAVOIE" msgstr "Savoie" -#: scripts/_lib/map_template.py:524 +#: scripts/_lib/map_template.py:529 msgid "BUGEY" msgstr "Bugey" -#: scripts/_lib/map_template.py:525 +#: scripts/_lib/map_template.py:530 msgid "ALSACE" msgstr "Elzas" -#: scripts/_lib/map_template.py:526 +#: scripts/_lib/map_template.py:531 msgid "LORRAINE" msgstr "Lotharingen" -#: scripts/_lib/map_template.py:527 +#: scripts/_lib/map_template.py:532 msgid "VAL DE LOIRE" msgstr "Loirestreek" -#: scripts/_lib/map_template.py:528 +#: scripts/_lib/map_template.py:533 msgid "BORDEAUX" msgstr "Bordeaux" -#: scripts/_lib/map_template.py:529 +#: scripts/_lib/map_template.py:534 msgid "SUD-OUEST" msgstr "Zuidwest-Frankrijk" -#: scripts/_lib/map_template.py:530 +#: scripts/_lib/map_template.py:535 msgid "VALLEE DU RHÔNE" msgstr "Rhônestreek" -#: scripts/_lib/map_template.py:531 +#: scripts/_lib/map_template.py:536 msgid "LANGUEDOC" msgstr "Languedoc" -#: scripts/_lib/map_template.py:532 +#: scripts/_lib/map_template.py:537 msgid "ROUSSILLON" msgstr "Roussillon" -#: scripts/_lib/map_template.py:533 +#: scripts/_lib/map_template.py:538 msgid "PROVENCE" msgstr "Provence" -#: scripts/_lib/map_template.py:534 +#: scripts/_lib/map_template.py:539 msgid "CORSE" msgstr "Corsica" -#: scripts/_lib/map_template.py:535 +#: scripts/_lib/map_template.py:540 msgid "CHAMPAGNE" msgstr "Champagne" -#: scripts/_lib/map_template.py:536 +#: scripts/_lib/map_template.py:541 msgid "ILE-DE-FRANCE" msgstr "Île-de-France" -#: scripts/_lib/map_template.py:537 +#: scripts/_lib/map_template.py:542 msgid "NORMANDIE" msgstr "Normandië" -#: scripts/_lib/map_template.py:538 +#: scripts/_lib/map_template.py:543 msgid "BRETAGNE" msgstr "Bretagne" -#: scripts/_lib/map_template.py:539 +#: scripts/_lib/map_template.py:544 msgid "MAINE" msgstr "Maine" -#: scripts/_lib/map_template.py:540 +#: scripts/_lib/map_template.py:545 msgid "COGNAC" msgstr "Cognac" -#: scripts/_lib/map_template.py:541 +#: scripts/_lib/map_template.py:546 msgid "ARMAGNAC" msgstr "Armagnac" -#: scripts/_lib/map_template.py:542 +#: scripts/_lib/map_template.py:547 msgid "RHUM" msgstr "Rum" -#: scripts/_lib/map_template.py:582 +#: scripts/_lib/map_template.py:587 msgid "France" msgstr "Frankrijk" -#: scripts/_lib/map_template.py:583 +#: scripts/_lib/map_template.py:588 msgid "Espagne" msgstr "Spanje" -#: scripts/_lib/map_template.py:584 +#: scripts/_lib/map_template.py:589 msgid "Portugal" msgstr "Portugal" -#: scripts/_lib/map_template.py:585 +#: scripts/_lib/map_template.py:590 msgid "Italie" msgstr "Italië" -#: scripts/_lib/map_template.py:586 +#: scripts/_lib/map_template.py:591 msgid "Autriche" msgstr "Oostenrijk" -#: scripts/_lib/map_template.py:587 +#: scripts/_lib/map_template.py:592 msgid "Slovénie" msgstr "Slovenië" -#: scripts/_lib/map_template.py:588 +#: scripts/_lib/map_template.py:593 msgid "Croatie" msgstr "Kroatië" -#: scripts/_lib/map_template.py:589 +#: scripts/_lib/map_template.py:594 msgid "Hongrie" msgstr "Hongarije" -#: scripts/_lib/map_template.py:590 +#: scripts/_lib/map_template.py:595 msgid "Roumanie" msgstr "Roemenië" -#: scripts/_lib/map_template.py:591 +#: scripts/_lib/map_template.py:596 msgid "Bulgarie" msgstr "Bulgarije" -#: scripts/_lib/map_template.py:592 +#: scripts/_lib/map_template.py:597 msgid "Grèce" msgstr "Griekenland" -#: scripts/_lib/map_template.py:593 +#: scripts/_lib/map_template.py:598 msgid "Allemagne" msgstr "Duitsland" -#: scripts/_lib/map_template.py:594 +#: scripts/_lib/map_template.py:599 msgid "Slovaquie" msgstr "Slowakije" -#: scripts/_lib/map_template.py:595 +#: scripts/_lib/map_template.py:600 msgid "Suisse" msgstr "Zwitserland" -#: scripts/_lib/map_template.py:596 +#: scripts/_lib/map_template.py:601 msgid "Tchéquie" msgstr "Tsjechië" -#: scripts/_lib/map_template.py:597 +#: scripts/_lib/map_template.py:602 msgid "Luxembourg" msgstr "Luxemburg" -#: scripts/_lib/map_template.py:598 +#: scripts/_lib/map_template.py:603 msgid "Belgique" msgstr "België" -#: scripts/_lib/map_template.py:599 +#: scripts/_lib/map_template.py:604 msgid "Pays-Bas" msgstr "Nederland" -#: scripts/_lib/map_template.py:600 +#: scripts/_lib/map_template.py:605 msgid "Malte" msgstr "Malta" -#: scripts/_lib/map_template.py:601 +#: scripts/_lib/map_template.py:606 msgid "Chypre" msgstr "Cyprus" -#: scripts/_lib/map_template.py:602 +#: scripts/_lib/map_template.py:607 msgid "Royaume-Uni" msgstr "Verenigd Koninkrijk" diff --git a/scripts/_lib/map_template.py b/scripts/_lib/map_template.py index 75c9cf3..2d7aaf6 100644 --- a/scripts/_lib/map_template.py +++ b/scripts/_lib/map_template.py @@ -492,6 +492,11 @@ 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"), @@ -778,6 +783,9 @@ def _build_about_dialog( f'aria-label="{esc(labels["copy_mcp_aria"])}">{esc(labels["copy_label"])}' ) 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")) ) From 77a54a3c173fd80d1ec07c2e88da42cb858b4582 Mon Sep 17 00:00:00 2001 From: Boris De Vloed Date: Sun, 4 Oct 2026 17:04:20 +0200 Subject: [PATCH 11/14] =?UTF-8?q?feat(mcp):=20show=5Fon=5Fmap=20legend=20?= =?UTF-8?q?=E2=80=94=20which=20polygon=20is=20which,=20and=20a=20link=20to?= =?UTF-8?q?=20the=20same=20set?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Up to 10 appellations each get their own colour; the title opens a legend list (open by default for several). Hover a row or a polygon to light it, click a row to zoom to it (the site button becomes " ↗"), the colour square hides it, "Show all" resets. A multi-appellation result links the set as /?aocs=a,b,c (≤ 50) in the text and the view; a hidden appellation drops out of that link. MapLibre's controls get dark styling. apps_host_check.py exercises the list, hover, hide, pin and the links. Co-Authored-By: Claude Opus 5.5 --- docs/plan-mcp-apps.md | 26 +++- mcp/src/server.js | 14 ++- mcp/test/apps_host_check.py | 53 +++++++- mcp/test/protocol.test.js | 5 + mcp/view/view.html | 32 ++++- mcp/view/view.js | 243 ++++++++++++++++++++++++++++++------ 6 files changed, 326 insertions(+), 47 deletions(-) diff --git a/docs/plan-mcp-apps.md b/docs/plan-mcp-apps.md index 08ac200..7b03b1e 100644 --- a/docs/plan-mcp-apps.md +++ b/docs/plan-mcp-apps.md @@ -1,7 +1,9 @@ # Plan — the map inside the chat (MCP Apps) -Status (2026-10-04): phase 0 done locally and on the spike; waiting for a -live check in Claude. Builds on the live MCP server (docs/plan-mcp-server.md). +Status (2026-10-04): phase 0 done locally and on the spike, checked live in +Claude by Boris ("mcp server worked"); his two follow-ups landed the same day +(see "After the first live check"). Builds on the live MCP server +(docs/plan-mcp-server.md). 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); @@ -31,6 +33,26 @@ personal use first (no ChatGPT directory review yet). - 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, ≤ 50 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; more than 50 fall back to the homepage) + 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 diff --git a/mcp/src/server.js b/mcp/src/server.js index b706c0a..d7582e5 100644 --- a/mcp/src/server.js +++ b/mcp/src/server.js @@ -39,10 +39,18 @@ 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); longer sets link the homepage. +const MAX_LINKED_SET = 50; const LOCALE_PROP = { type: 'string', @@ -100,12 +108,16 @@ async function showOnMap(entry, { slugs }, locale, siteOrigin) { 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 mapUrl = appellations.length === 1 ? appellations[0].url : `${siteOrigin}${locale === 'en' ? '/' : `/${locale}/`}`; + const home = `${siteOrigin}${locale === 'en' ? '/' : `/${locale}/`}`; + const mapUrl = appellations.length === 1 + ? appellations[0].url + : appellations.length <= MAX_LINKED_SET ? `${home}?aocs=${known.join(',')}` : home; return { appellations, bbox, map_url: mapUrl, unknown }; } 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')}`; } diff --git a/mcp/test/apps_host_check.py b/mcp/test/apps_host_check.py index dba3e0a..dc0895e 100644 --- a/mcp/test/apps_host_check.py +++ b/mcp/test/apps_host_check.py @@ -77,7 +77,53 @@ def tool_result(slugs: list[str], site: str) -> dict: "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)] - return {"appellations": out, "bbox": union, "map_url": out[0]["url"], "unknown": []} + map_url = out[0]["url"] if len(out) == 1 else f"{site}/?aocs={','.join(slugs)}" + return {"appellations": out, "bbox": union, "map_url": map_url, "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: @@ -94,7 +140,8 @@ def main() -> int: 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 ↗", "nothing": "none", "waiting": "waiting", "appellations": "appellations"}} + "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: @@ -158,6 +205,8 @@ def host_page(policy: str, theme: str) -> str: 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}") diff --git a/mcp/test/protocol.test.js b/mcp/test/protocol.test.js index 7b36c8d..f8aad0b 100644 --- a/mcp/test/protocol.test.js +++ b/mcp/test/protocol.test.js @@ -71,6 +71,11 @@ test('show_on_map: briefs with bbox, union bbox, map links, UI resource', { skip 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.ok(res.content[0].text.includes('All of them on the map: https://data.test/?aocs=priorat,montsant')); + 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'); diff --git a/mcp/view/view.html b/mcp/view/view.html index b61590b..d376de1 100644 --- a/mcp/view/view.html +++ b/mcp/view/view.html @@ -14,8 +14,26 @@ html.fullscreen #wrap { height: 100vh; } #map { position: absolute; inset: 0; } #bar { position: absolute; top: 8px; left: 8px; right: 8px; display: flex; gap: 6px; align-items: center; pointer-events: none; } - #title { pointer-events: auto; background: var(--card); border: 1px solid var(--border); border-radius: 6px; padding: 4px 8px; max-width: 70%; - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + #title { pointer-events: auto; background: var(--card); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; padding: 4px 8px; max-width: 60%; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font: inherit; cursor: pointer; } + #list { position: absolute; top: 40px; left: 8px; width: min(300px, calc(100% - 16px)); max-height: calc(100% - 56px); display: flex; flex-direction: column; + background: var(--card); border: 1px solid var(--border); border-radius: 6px; box-shadow: 0 4px 16px rgba(0,0,0,0.15); } + #list[hidden] { display: none; } + #list .head { display: flex; justify-content: flex-end; padding: 4px 6px; border-bottom: 1px solid var(--border); } + #list .head button { background: none; border: none; color: var(--muted); cursor: pointer; font: inherit; text-decoration: underline; } + #rows { overflow-y: auto; } + #rows .row { display: flex; align-items: center; gap: 6px; padding: 4px 6px; border-left: 3px solid transparent; } + #rows .row.lit { background: rgba(127,127,127,0.12); } + #rows .row.pinned { border-left-color: var(--fg); } + #rows .row.off .row-text { opacity: 0.45; } + #rows .swatch { flex: none; width: 18px; height: 18px; padding: 0; border-radius: 4px; border: 2px solid var(--c); background: var(--c); + font: 700 12px/14px system-ui, sans-serif; text-align: center; cursor: pointer; } + #rows .swatch[aria-pressed="false"] { background: transparent; } + #rows .swatch:focus-visible, #rows .row-text:focus-visible, #title:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; } + #rows .row-text { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: flex-start; background: none; border: none; padding: 0; + color: var(--fg); font: inherit; text-align: left; cursor: pointer; } + #rows .row-name { font-weight: 600; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + #rows .row-meta { color: var(--muted); font-size: 12px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #bar .spacer { flex: 1; } #bar button { pointer-events: auto; background: var(--card); color: var(--fg); border: 1px solid var(--border); border-radius: 6px; padding: 4px 8px; cursor: pointer; font: inherit; } #status { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: var(--muted); } @@ -25,6 +43,10 @@ .popup-meta { color: var(--muted); margin: 2px 0 6px; } .popup-open { background: none; border: none; padding: 0; color: #934050; text-decoration: underline; cursor: pointer; font: inherit; } html.dark .popup-open { color: #e08a9a; } + html.dark .maplibregl-ctrl-attrib, html.dark .maplibregl-ctrl-group { background: var(--card); color: var(--fg); } + html.dark .maplibregl-ctrl-attrib a { color: var(--fg); } + html.dark .maplibregl-ctrl-attrib-button, html.dark .maplibregl-ctrl-group .maplibregl-ctrl-icon { filter: invert(1); } + html.dark .maplibregl-ctrl-group button + button { border-top-color: var(--border); } @@ -32,11 +54,15 @@
Loading the map…
- +
+ diff --git a/mcp/view/view.js b/mcp/view/view.js index a8982d4..97da083 100644 --- a/mcp/view/view.js +++ b/mcp/view/view.js @@ -3,33 +3,55 @@ // 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 = ['case', ['==', ['get', 'kind'], 'IGP'], '#6e7546', '#934050']; +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}`); } -let map = null; -let current = null; -let theme = 'light'; +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')) { - map.setLayoutProperty('basemap-light', 'visibility', theme === 'dark' ? 'none' : 'visible'); - map.setLayoutProperty('basemap-dark', 'visibility', theme === 'dark' ? 'visible' : 'none'); + 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 */ } } } @@ -58,41 +80,103 @@ function buildMap() { }); map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'bottom-right'); map.on('error', e => console.warn('map error', e && e.error)); - const fills = () => ['fill-overview', 'fill-detail'].filter(id => map.getLayer(id)); + const fills = () => FILLS.filter(id => map.getLayer(id)); map.on('click', e => { - const feats = current ? map.queryRenderedFeatures(e.point, { layers: fills() }) : []; - if (feats.length) showPopup(feats[0], e.lngLat); + 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 => { - map.getCanvas().style.cursor = map.queryRenderedFeatures(e.point, { layers: fills() }).length ? 'pointer' : ''; + 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)); } -function addAppellationLayers(slugs) { - for (const id of ['fill-overview', 'fill-detail', 'line-overview', 'line-detail']) { - if (map.getLayer(id)) map.removeLayer(id); - } - const inSet = ['in', ['get', 'slug'], ['literal', slugs]]; - const over = { 'source-layer': 'appellations', maxzoom: OVERVIEW_MAX_ZOOM, filter: inSet }; - const detail = { 'source-layer': 'appellations', minzoom: DETAIL_MIN_ZOOM, filter: inSet }; +// 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-color': KIND_COLOUR, 'fill-opacity': 0.5 } }); - map.addLayer({ id: 'fill-detail', type: 'fill', source: 'detail', ...detail, layout: sort, - paint: { 'fill-color': KIND_COLOUR, 'fill-opacity': 0.5 } }); - const line = { 'line-color': theme === 'dark' ? '#f2e6d0' : '#2a1014', 'line-width': 1.2 }; + 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(); } -function fit(bbox, single) { - if (!bbox) return; - map.fitBounds([[bbox[0], bbox[1]], [bbox[2], bbox[3]]], { padding: 36, maxZoom: single ? 12 : 10, duration: 0 }); +// 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 showPopup(feature, lngLat) { - const a = (current.appellations || []).find(x => x.slug === feature.properties.slug); +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'); @@ -110,28 +194,84 @@ function showPopup(feature, lngLat) { new maplibregl.Popup({ closeButton: true, maxWidth: '260px' }).setLngLat(lngLat).setDOMContent(box).addTo(map); } -async function render(data) { - current = data; +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(data.appellations.map(a => a.slug)); - fit(data.bbox, data.appellations.length === 1); + 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: ['fill-overview', 'fill-detail'].filter(id => map.getLayer(id)) }).length, + features: () => map.queryRenderedFeatures({ layers: FILLS.filter(id => map.getLayer(id)) }).length, }; - const title = data.appellations.length === 1 - ? data.appellations[0].name - : `${data.appellations.length} ${CONFIG.labels.appellations}`; - $('title').textContent = title; + $('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; - $('open-site').onclick = () => app.openLink({ url: data.map_url || data.appellations[0].url }); + // A multi-appellation answer opens the list, so the colours are explained. + toggleList(n > 1); } const app = new App( @@ -151,6 +291,31 @@ app.onhostcontextchanged = ctx => { } }; +$('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(); @@ -163,7 +328,7 @@ async function main() { await app.requestDisplayMode({ mode: isFull ? 'inline' : 'fullscreen' }); }; } - if (!current) status(CONFIG.labels.waiting); + if (!data) status(CONFIG.labels.waiting); } main().catch(e => status(String((e && e.message) || e))); From 3cf6c31f2ea96088edc63f533a60d7a47a68b2bd Mon Sep 17 00:00:00 2001 From: Boris De Vloed Date: Sun, 4 Oct 2026 17:30:13 +0200 Subject: [PATCH 12/14] =?UTF-8?q?feat(map):=20=3Faocs=3Da,b,c=20opens=20a?= =?UTF-8?q?=20set=20=E2=80=94=20the=20link=20show=5Fon=5Fmap=20gives=20for?= =?UTF-8?q?=20several=20appellations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The landing IIFE opens the set as one panel stack, highlights all of it and frames the map on the union of its boxes (unless the link carries a camera hash). The query stays in the URL until the reader opens something else. A set's header is the plain count ("2 appellations"), not "at this point", and keeps it through a card pick. Docs: CLAUDE.md (map inside the chat), mcp/README (show_on_map, view/, host check). Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 13 +++++++++++++ mcp/README.md | 7 ++++++- scripts/_lib/assets/app.js | 37 +++++++++++++++++++++++++++++++++++-- 3 files changed, 54 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5bfded5..1750857 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6286,6 +6286,19 @@ instance — change search semantics in the core, never in app.js. 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, on the spike only so far): 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 from the site's pmtiles. 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, ≤ 50 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/mcp/README.md b/mcp/README.md index 805ea08..d3717a2 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -13,6 +13,7 @@ Tools — the same definitions and code the map page registers as WebMCP tools | `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`; text link elsewhere) — on the spike, not yet in production | ## Layout @@ -22,6 +23,8 @@ Tools — the same definitions and code the map page registers as WebMCP tools 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 @@ -31,12 +34,14 @@ 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`. +`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 diff --git a/scripts/_lib/assets/app.js b/scripts/_lib/assets/app.js index e4b2e0c..1e201b0 100644 --- a/scripts/_lib/assets/app.js +++ b/scripts/_lib/assets/app.js @@ -2875,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 @@ -2882,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. @@ -2968,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 @@ -3075,6 +3081,33 @@ } return; } + // A set shared as ?aocs=a,b,c (the MCP server's show_on_map links one): + // 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, 50); + } 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) {} From 79ef2b63b3fe5522ffa811bbaf9b29646b507f31 Mon Sep 17 00:00:00 2001 From: Boris De Vloed Date: Sun, 4 Oct 2026 18:02:14 +0200 Subject: [PATCH 13/14] =?UTF-8?q?feat(mcp):=20show=5Fon=5Fmap=20in=20produ?= =?UTF-8?q?ction=20=E2=80=94=20versioned=20tile=20URLs,=20no=20proxy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The site's pull zone already answers /map-data/*.pmtiles cross-origin (ranged GET and a Range preflight), so the view reads the tiles straight from the site. They are browser-cached for 30 days, so stage 04 writes the map's own fingerprinted URLs (?v=) into the query context as `tiles`; show_on_map passes them on and the view uses them (bare paths only while the site predates the key; additive, format stays 1). llms.txt names show_on_map. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 15 ++++++++++----- docs/plan-mcp-apps.md | 28 ++++++++++++++++++++++++---- mcp/README.md | 2 +- mcp/src/context.js | 7 +++++-- mcp/src/server.js | 10 +++++++++- mcp/test/apps_host_check.py | 2 +- mcp/test/protocol.test.js | 2 ++ mcp/view/view.js | 7 +++++-- scripts/04_build_maps.py | 7 ++++--- scripts/_lib/map_template.py | 6 +++++- 10 files changed, 66 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1750857..43b9a37 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6286,11 +6286,16 @@ instance — change search semantics in the core, never in app.js. 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, on the spike only so far): 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 from the site's pmtiles. With ≤ 10 +- **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 diff --git a/docs/plan-mcp-apps.md b/docs/plan-mcp-apps.md index 7b03b1e..81be0f0 100644 --- a/docs/plan-mcp-apps.md +++ b/docs/plan-mcp-apps.md @@ -1,9 +1,29 @@ # Plan — the map inside the chat (MCP Apps) -Status (2026-10-04): phase 0 done locally and on the spike, checked live in -Claude by Boris ("mcp server worked"); his two follow-ups landed the same day -(see "After the first live check"). Builds on the live MCP server -(docs/plan-mcp-server.md). +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); diff --git a/mcp/README.md b/mcp/README.md index d3717a2..6f13584 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -13,7 +13,7 @@ Tools — the same definitions and code the map page registers as WebMCP tools | `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`; text link elsewhere) — on the spike, not yet in production | +| `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 diff --git a/mcp/src/context.js b/mcp/src/context.js index 3919f6d..d1b2c9e 100644 --- a/mcp/src/context.js +++ b/mcp/src/context.js @@ -32,7 +32,7 @@ export function coreFromContext(ctx, siteOrigin) { } export function createContextStore({ dataOrigin, fetchImpl = fetch, ttlMs = 5 * 60 * 1000, now = Date.now }) { - const entries = new Map(); // locale → { core, aocs, etag, checkedAt, pending } + 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 } : {}; @@ -43,7 +43,10 @@ export function createContextStore({ dataOrigin, fetchImpl = fetch, ttlMs = 5 * } 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, etag: resp.headers.get('ETag'), checkedAt: now() }; + 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; } diff --git a/mcp/src/server.js b/mcp/src/server.js index d7582e5..a1aa950 100644 --- a/mcp/src/server.js +++ b/mcp/src/server.js @@ -112,7 +112,15 @@ async function showOnMap(entry, { slugs }, locale, siteOrigin) { const mapUrl = appellations.length === 1 ? appellations[0].url : appellations.length <= MAX_LINKED_SET ? `${home}?aocs=${known.join(',')}` : home; - return { appellations, bbox, map_url: mapUrl, unknown }; + 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) { diff --git a/mcp/test/apps_host_check.py b/mcp/test/apps_host_check.py index dc0895e..b21b7bd 100644 --- a/mcp/test/apps_host_check.py +++ b/mcp/test/apps_host_check.py @@ -78,7 +78,7 @@ def tool_result(slugs: list[str], site: str) -> dict: 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, "unknown": []} + 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: diff --git a/mcp/test/protocol.test.js b/mcp/test/protocol.test.js index f8aad0b..e63f9a2 100644 --- a/mcp/test/protocol.test.js +++ b/mcp/test/protocol.test.js @@ -72,6 +72,8 @@ test('show_on_map: briefs with bbox, union bbox, map links, UI resource', { skip 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')); 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'); diff --git a/mcp/view/view.js b/mcp/view/view.js index 97da083..d0c27dc 100644 --- a/mcp/view/view.js +++ b/mcp/view/view.js @@ -56,6 +56,9 @@ function setFeatureFlag(slug, key, on) { } 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'; @@ -66,8 +69,8 @@ function buildMap() { 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}/map-data/appellations-overview.pmtiles`, promoteId: 'slug' }, - detail: { type: 'vector', url: `pmtiles://${CONFIG.tileOrigin}/map-data/appellations.pmtiles`, promoteId: 'slug' }, + 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' }, diff --git a/scripts/04_build_maps.py b/scripts/04_build_maps.py index 02767c2..8675619 100644 --- a/scripts/04_build_maps.py +++ b/scripts/04_build_maps.py @@ -4957,9 +4957,10 @@ def _write_llms_txt(entity_entries: list[tuple[str, str, str]]) -> None: "", 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. Add it as a custom " - "connector.", - "- Every map page registers the same tools as WebMCP tools " + "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.", diff --git a/scripts/_lib/map_template.py b/scripts/_lib/map_template.py index 2d7aaf6..bbed2ea 100644 --- a/scripts/_lib/map_template.py +++ b/scripts/_lib/map_template.py @@ -2083,7 +2083,7 @@ def _country_name(cc: str) -> str: "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", + "country_labels", "term_labels", "labels", "tiles", }) QUERY_CONTEXT_LABELS = ( "meta_no_region", "facts_sub_facteurs_naturels", "facts_sub_facteurs_humains", @@ -2481,6 +2481,10 @@ def _grape_rank_for(rec: dict) -> dict[str, float] | None: "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") From e4bca3e8a9368a8515132bbbce1155bee146b63a Mon Sep 17 00:00:00 2001 From: Boris De Vloed Date: Sun, 4 Oct 2026 18:16:17 +0200 Subject: [PATCH 14/14] fix(mcp): link sets of up to 200 appellations, not 50 The 51 Alsace grands crus fell back to the homepage. 200 is show_on_map's own maximum; the worst case is under 10 KB of URL and the CDN takes 16 KB, so the homepage fallback goes. The site's ?aocs= landing reads up to 200. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- docs/plan-mcp-apps.md | 6 ++++-- mcp/src/server.js | 8 +++----- mcp/test/protocol.test.js | 6 ++++++ scripts/_lib/assets/app.js | 5 +++-- 5 files changed, 17 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 43b9a37..ca71a59 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6299,7 +6299,7 @@ instance — change search semantics in the core, never in app.js. 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, ≤ 50 slugs): + 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] diff --git a/docs/plan-mcp-apps.md b/docs/plan-mcp-apps.md index 81be0f0..b75e5bf 100644 --- a/docs/plan-mcp-apps.md +++ b/docs/plan-mcp-apps.md @@ -58,10 +58,12 @@ personal use first (no ChatGPT directory review yet). 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, ≤ 50 slugs) +- **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; more than 50 fall back to the homepage) + 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), diff --git a/mcp/src/server.js b/mcp/src/server.js index a1aa950..1cf9b49 100644 --- a/mcp/src/server.js +++ b/mcp/src/server.js @@ -49,8 +49,8 @@ const VIEW_LABELS = { appellations: 'appellations', }; // The site opens a set of appellations from /?aocs=a,b,c (panel stack, map -// framed on all); longer sets link the homepage. -const MAX_LINKED_SET = 50; +// 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', @@ -109,9 +109,7 @@ async function showOnMap(entry, { slugs }, locale, siteOrigin) { 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 - : appellations.length <= MAX_LINKED_SET ? `${home}?aocs=${known.join(',')}` : home; + const mapUrl = appellations.length === 1 ? appellations[0].url : `${home}?aocs=${known.join(',')}`; return { appellations, bbox, map_url: mapUrl, tiles: tilePaths(entry.tiles), unknown }; } diff --git a/mcp/test/protocol.test.js b/mcp/test/protocol.test.js index e63f9a2..09eaeed 100644 --- a/mcp/test/protocol.test.js +++ b/mcp/test/protocol.test.js @@ -75,6 +75,12 @@ test('show_on_map: briefs with bbox, union bbox, map links, UI resource', { skip 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')); diff --git a/scripts/_lib/assets/app.js b/scripts/_lib/assets/app.js index 1e201b0..ab8c70d 100644 --- a/scripts/_lib/assets/app.js +++ b/scripts/_lib/assets/app.js @@ -3081,14 +3081,15 @@ } return; } - // A set shared as ?aocs=a,b,c (the MCP server's show_on_map links one): + // 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, 50); + if (q) setSlugs = [...new Set(q.split(','))].filter(s => AOCS[s]).slice(0, 200); } catch (e) {} if (setSlugs.length) { lastStackKey = setSlugs.slice().sort().join('|');